1927 lines
123 KiB
JavaScript
1927 lines
123 KiB
JavaScript
(function installLTJTOperationPlans(root, factory) {
|
||
const api = factory();
|
||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||
if (root) root.LTJTOperationPlans = api;
|
||
}(typeof self !== 'undefined' ? self : globalThis, () => {
|
||
const PASSENGER_MAX_TARGET_ROWS = 5000;
|
||
const OPERATION_DEFINITIONS = {
|
||
team_order_create: {
|
||
label: '团队-单个下单',
|
||
route: 'team_single',
|
||
execution: 'browser_team_single',
|
||
liveSubmit: 'guarded-production',
|
||
capabilityState: 'validated_core_create',
|
||
source: 'root-browser-validated'
|
||
},
|
||
team_order_batch_create: {
|
||
label: '团队-批量下单',
|
||
route: 'team_batch_native',
|
||
execution: 'browser_team_batch_native',
|
||
nativeStatus: 'validated',
|
||
capabilityState: 'validated_core_create',
|
||
source: 'native-batch-live-validated'
|
||
},
|
||
shared_plan_create: {
|
||
label: '散拼-创建母团计划',
|
||
route: 'split_parent',
|
||
execution: 'browser_live_split_parent',
|
||
liveSubmit: 'guarded-production',
|
||
capabilityState: 'validated_parent_create_batch_split_order_probe_only',
|
||
source: 'handoff-browser-validated'
|
||
},
|
||
shared_child_order_create: {
|
||
label: '散拼-拼单/录入子单',
|
||
route: 'split_child',
|
||
execution: 'browser_live_split_child',
|
||
liveSubmit: 'guarded-production',
|
||
capabilityState: 'validated_single_parent_child_create',
|
||
source: 'handoff-browser-validated'
|
||
},
|
||
shared_child_order_batch_create: {
|
||
label: '散拼-批量录入子单',
|
||
route: 'split_child_batch',
|
||
execution: 'browser_live_split_child_batch',
|
||
liveSubmit: 'guarded-production',
|
||
capabilityState: 'program_only_resolve_all_then_sequential_create',
|
||
source: 'program-browser-guarded'
|
||
},
|
||
passenger_list_import: {
|
||
label: '旅客名单导入/补充',
|
||
route: 'passenger_import',
|
||
execution: 'browser_lifecycle',
|
||
capabilityState: 'validated_first_import_or_confirmed_full_replace',
|
||
source: 'native-live-validated-2026-09'
|
||
},
|
||
arrangement_guide: {
|
||
label: '团队安排-导游',
|
||
route: 'arrangement_guide',
|
||
execution: 'browser_lifecycle',
|
||
capabilityState: 'native_create_clear_program_update_pending_live_acceptance',
|
||
source: 'native-live-validated-2026-09'
|
||
},
|
||
arrangement_vehicle: {
|
||
label: '团队安排-车辆',
|
||
route: 'arrangement_vehicle',
|
||
execution: 'browser_lifecycle',
|
||
capabilityState: 'native_create_clear_program_update_pending_live_acceptance',
|
||
source: 'native-live-validated-2026-09'
|
||
},
|
||
arrangement_hotel: {
|
||
label: '团队安排-酒店',
|
||
route: 'arrangement_hotel',
|
||
execution: 'browser_lifecycle',
|
||
capabilityState: 'validated_unassigned_create_update_clear_end_date_room_count',
|
||
source: 'native-live-validated-end-date-room-count-2026-09'
|
||
},
|
||
arrangement_transport: {
|
||
label: '团队安排-大交通',
|
||
route: 'arrangement_transport',
|
||
execution: 'browser_lifecycle',
|
||
capabilityState: 'native_create_clear_program_update_pending_live_acceptance',
|
||
source: 'native-live-validated-2026-09'
|
||
},
|
||
arrangement_other: {
|
||
label: '团队安排-其他/备案',
|
||
route: 'arrangement_other',
|
||
execution: 'browser_lifecycle',
|
||
capabilityState: 'native_create_clear_program_update_preserve_filing_date_pending_live_acceptance',
|
||
source: 'native-live-validated-2026-09'
|
||
},
|
||
order_update_independent: {
|
||
label: '独立团信息修改',
|
||
route: 'update_independent',
|
||
execution: 'browser_lifecycle',
|
||
capabilityState: 'parsed_wide_execute_validated_subset',
|
||
source: 'native-form-field-mapped-passenger-counts-pending-live-validation-2026-08'
|
||
},
|
||
order_update_shared_plan: {
|
||
label: '散拼母团-业务字段修改',
|
||
route: 'update_shared_plan',
|
||
execution: 'browser_lifecycle',
|
||
capabilityState: 'parsed_wide_execute_validated_subset',
|
||
source: 'native-live-validated-planned-capacity-only'
|
||
},
|
||
order_update_shared_child: {
|
||
label: '散拼子单-业务字段修改',
|
||
route: 'update_shared_child',
|
||
execution: 'browser_lifecycle',
|
||
capabilityState: 'parsed_wide_execute_validated_subset',
|
||
source: 'native-live-validated-lodging-note-only'
|
||
},
|
||
order_cancel: {
|
||
label: '订单-取消',
|
||
route: 'order_cancel',
|
||
execution: 'browser_lifecycle',
|
||
capabilityState: 'validated_with_receivable_and_arrangement_preconditions',
|
||
source: 'native-live-validated-2026-09'
|
||
},
|
||
order_restore: {
|
||
label: '订单-恢复',
|
||
route: 'order_restore',
|
||
execution: 'browser_lifecycle',
|
||
capabilityState: 'validated_edit_only',
|
||
source: 'native-live-validated-edit-only'
|
||
},
|
||
order_delete: {
|
||
label: '订单-测试清理删除',
|
||
route: 'order_delete',
|
||
execution: 'browser_lifecycle',
|
||
testOnly: true,
|
||
capabilityState: 'validated_test_objects_allowlist_only',
|
||
source: 'native-live-validated-exact-kind-routing'
|
||
},
|
||
order_update: {
|
||
label: '已有订单更新',
|
||
route: 'update_order',
|
||
execution: 'browser_dry_run',
|
||
dryRunAdapter: 'inspect_existing_order_update',
|
||
supportedTargets: ['rooms', 'remark', 'pax', 'prices'],
|
||
source: 'handoff-update-rule'
|
||
},
|
||
confirmation_export: {
|
||
label: '导出团队文件',
|
||
route: 'export_confirmation',
|
||
execution: 'browser_export_source',
|
||
dryRunAdapter: 'inspect_confirmation_sources',
|
||
capabilityState: 'backend_attachment_no_conversion_schedule_or_send',
|
||
source: 'source-live-validated-with-sha256-and-backend-attachment'
|
||
}
|
||
};
|
||
|
||
const STANDARD_ACTIONS = new Set(Object.keys(OPERATION_DEFINITIONS));
|
||
const EXPORT_TYPES = new Set([
|
||
'xingyou-confirm',
|
||
'liantai-confirm',
|
||
'job-order',
|
||
'visitor-list',
|
||
'guide-confirm',
|
||
'hotel-preorder',
|
||
'transport-preorder',
|
||
'filing-current',
|
||
'filing-history',
|
||
'pickup-sign'
|
||
]);
|
||
const TEST_MARKER = 'TEST-202609';
|
||
const CONTRACT_VERSION = 'ltjt-lifecycle-v2.9-roster-leader-contact-2026-08';
|
||
const LIFECYCLE_ACTIONS = new Set([
|
||
'passenger_list_import',
|
||
'arrangement_guide',
|
||
'arrangement_vehicle',
|
||
'arrangement_hotel',
|
||
'arrangement_transport',
|
||
'arrangement_other',
|
||
'order_update_independent',
|
||
'order_update_shared_plan',
|
||
'order_update_shared_child',
|
||
'order_cancel',
|
||
'order_restore',
|
||
'order_delete'
|
||
]);
|
||
const ERP_RESOLUTION_ACTIONS = new Set([
|
||
'shared_child_order_create',
|
||
'shared_child_order_batch_create',
|
||
'passenger_list_import',
|
||
'arrangement_guide',
|
||
'arrangement_vehicle',
|
||
'arrangement_hotel',
|
||
'arrangement_transport',
|
||
'arrangement_other',
|
||
'order_update_independent',
|
||
'order_update_shared_plan',
|
||
'order_update_shared_child',
|
||
'order_cancel',
|
||
'order_restore',
|
||
'confirmation_export'
|
||
]);
|
||
const ARRANGEMENT_ACTIONS = new Set([
|
||
'arrangement_guide',
|
||
'arrangement_vehicle',
|
||
'arrangement_hotel',
|
||
'arrangement_transport',
|
||
'arrangement_other'
|
||
]);
|
||
const UPDATE_ACTIONS = new Set([
|
||
'order_update_independent',
|
||
'order_update_shared_plan',
|
||
'order_update_shared_child'
|
||
]);
|
||
const REFERENCE_KEYS = [
|
||
'identifier', 'order_no', 'group_no', 'plan_no', 'parent_group_no', 'parent_plan_no',
|
||
'child_order_no', 'ddid', 'tid', 'child_did', 'ltjt_ddid', 'ltjt_tdid', 'ltjt_child_id'
|
||
];
|
||
const EXISTING_REF_KINDS = new Set(['independent_order', 'shared_plan', 'shared_child_order']);
|
||
const UPDATE_TARGETS = Object.freeze({
|
||
order_update_independent: new Set(['twin_room_count', 'rooms.SGL', 'rooms.TWN', 'rooms.DBL', 'pax.adult', 'pax.child_bed', 'pax.child_no_bed', 'pax.leader']),
|
||
order_update_shared_plan: new Set(['planned_capacity']),
|
||
order_update_shared_child: new Set(['lodging_note'])
|
||
});
|
||
const WIDE_UPDATE_TARGETS = Object.freeze(new Set([
|
||
'twin_room_count',
|
||
'planned_capacity',
|
||
'lodging_note',
|
||
'departure_date',
|
||
'trip_days',
|
||
'customer',
|
||
'product',
|
||
'leader',
|
||
'route',
|
||
'status',
|
||
'remark',
|
||
'xiadanbeizhu',
|
||
'booking_note',
|
||
...['adult', 'child_bed', 'child_no_bed', 'infant', 'leader', 'expected_total'].map((key) => `pax.${key}`),
|
||
...['SGL', 'TWN', 'TRP', 'DBL', 'HNM', 'TL'].map((key) => `rooms.${key}`),
|
||
...['adult', 'child_bed', 'child_no_bed', 'infant', 'leader'].map((key) => `prices.${key}`)
|
||
]));
|
||
const ARRANGEMENT_UPDATE_KEYS = Object.freeze({
|
||
arrangement_guide: new Set(["resource", "remark"]),
|
||
arrangement_vehicle: new Set(["supplier", "item", "quantity", "remark"]),
|
||
arrangement_hotel: new Set(['end_date', 'room_count', 'resource', 'room_type', 'remark']),
|
||
arrangement_transport: new Set(["supplier", "item", "quantity", "remark"]),
|
||
arrangement_other: new Set(["supplier", "item", "quantity", "remark", "filing"])
|
||
});
|
||
const HOTEL_ARRANGEMENT_STATUSES = new Set(['未安排', '未确认']);
|
||
const PASSENGER_HEADERS = Object.freeze([
|
||
'序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码',
|
||
'签发地', '签发日', '有效期', '电话', '备注'
|
||
]);
|
||
const PASSENGER_KEYS = new Set(['adult', 'child_bed', 'child_no_bed', 'infant', 'leader', 'expected_total']);
|
||
const TEAM_ROOM_KEYS = new Set(['SGL', 'TWN', 'TRP', 'DBL']);
|
||
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||
const SOURCE_KEYS = new Set(['test_context']);
|
||
const DATA_KEYS = new Set([
|
||
'order_mode', 'customer', 'product', 'leader', 'route', 'departure_dates', 'trip', 'order_number', 'recurrence',
|
||
'planned_capacity', 'cycle', 'groups_per_date', 'plans_per_date', 'parent_group_no', 'child_order_no',
|
||
'passenger_counts', 'split_order', 'room_counts', 'prices', 'op_user', 'sales_user', 'logistics', 'special_requests',
|
||
'passenger_list', 'attachments', 'existing_refs', 'updates', 'arrangement', 'transition', 'delete_guard',
|
||
'receivable_fixture', 'export_types', 'recovery', 'confirmation', 'visitor_name', 'system_defaults', 'validation_notes', 'resolution',
|
||
'batch_targets'
|
||
]);
|
||
const DISPATCH_OPERATION_KEYS = new Set(['action', 'order_nature', 'submit_mode', 'source', 'data']);
|
||
const DISPATCH_NAMED_REF_KEYS = new Set(['name', 'keyword', 'source_region']);
|
||
const DISPATCH_EXISTING_REF_KEYS = new Set(['kind', 'identifier', 'parent_group_no']);
|
||
const BUSINESS_LOOKUP_DATA_KEYS = ['customer', 'product', 'leader', 'departure_dates'];
|
||
const withBusinessLookupKeys = (...keys) => new Set([...keys, ...BUSINESS_LOOKUP_DATA_KEYS]);
|
||
const DISPATCH_DATA_KEYS = Object.freeze({
|
||
shared_child_order_create: withBusinessLookupKeys('existing_refs', 'passenger_counts', 'special_requests'),
|
||
shared_child_order_batch_create: new Set(['customer', 'product', 'leader', 'recurrence', 'passenger_counts', 'special_requests']),
|
||
passenger_list_import: withBusinessLookupKeys('existing_refs', 'passenger_list'),
|
||
arrangement_guide: new Set(['existing_refs', 'arrangement']),
|
||
arrangement_vehicle: new Set(['existing_refs', 'arrangement']),
|
||
arrangement_hotel: new Set(['existing_refs', 'arrangement', 'special_requests']),
|
||
arrangement_transport: new Set(['existing_refs', 'arrangement']),
|
||
arrangement_other: new Set(['existing_refs', 'arrangement']),
|
||
order_update_independent: withBusinessLookupKeys('existing_refs', 'updates', 'special_requests'),
|
||
order_update_shared_plan: withBusinessLookupKeys('existing_refs', 'updates', 'special_requests'),
|
||
order_update_shared_child: withBusinessLookupKeys('existing_refs', 'updates', 'special_requests'),
|
||
order_cancel: withBusinessLookupKeys('existing_refs'),
|
||
order_restore: withBusinessLookupKeys('existing_refs', 'transition'),
|
||
confirmation_export: withBusinessLookupKeys('existing_refs', 'confirmation', 'export_types', 'visitor_name')
|
||
});
|
||
|
||
function toNumber(value) {
|
||
const normalized = String(value ?? '').replace(/[,,]/g, '').trim();
|
||
const number = Number(normalized);
|
||
return Number.isFinite(number) ? number : 0;
|
||
}
|
||
|
||
function dateYyyyMD(value) {
|
||
return typeof value === 'string' ? value : String(value || '');
|
||
}
|
||
|
||
function passengerTotal(counts = {}) {
|
||
return ['adult', 'child_bed', 'child_no_bed', 'infant', 'leader']
|
||
.reduce((sum, key) => sum + Math.max(0, Math.trunc(toNumber(counts[key]))), 0);
|
||
}
|
||
|
||
function actionForOperation(operation = {}) {
|
||
const direct = String(operation.action || '').trim();
|
||
return STANDARD_ACTIONS.has(direct) ? direct : '';
|
||
}
|
||
|
||
function identifierForOperation(operation = {}, data = {}) {
|
||
const refs = data.existing_refs || {};
|
||
return String(
|
||
refs.identifier
|
||
|| refs.order_no
|
||
|| refs.group_no
|
||
|| refs.child_order_no
|
||
|| refs.parent_group_no
|
||
|| refs.parent_plan_no
|
||
|| refs.plan_no
|
||
|| refs.child_did
|
||
|| refs.ddid
|
||
|| refs.tid
|
||
|| refs.ltjt_ddid
|
||
|| refs.ltjt_tdid
|
||
|| refs.ltjt_child_id
|
||
|| ''
|
||
).trim();
|
||
}
|
||
|
||
function confirmationIdentifier(data = {}) {
|
||
const refs = data.existing_refs || {};
|
||
if (refs.kind === 'shared_child_order') {
|
||
return String(
|
||
refs.child_order_no
|
||
|| refs.identifier
|
||
|| refs.order_no
|
||
|| refs.child_did
|
||
|| refs.ddid
|
||
|| refs.ltjt_child_id
|
||
|| refs.ltjt_ddid
|
||
|| ''
|
||
).trim();
|
||
}
|
||
return identifierForOperation({}, data);
|
||
}
|
||
|
||
function exportTypesForOperation(operation = {}, data = {}) {
|
||
const source = Array.isArray(data.export_types) ? data.export_types : [];
|
||
if (!source.length && data.confirmation?.type) return [String(data.confirmation.type).trim()];
|
||
return [...new Set(source.map((value) => String(value || '').trim()).filter(Boolean))];
|
||
}
|
||
|
||
function validateSharedPlanVisitorExport(data = {}, blockers = [], { execution = false } = {}) {
|
||
if (data.existing_refs?.kind !== 'shared_plan') return;
|
||
const types = exportTypesForOperation({}, data);
|
||
if (types.length !== 1 || types[0] !== 'visitor-list') {
|
||
blockers.push('散拼母团导出当前只允许单独导出“整团游客信息”(visitor-list)。');
|
||
}
|
||
if (!execution) return;
|
||
const refs = data.existing_refs || {};
|
||
if (!refs.tid) blockers.push('散拼母团整团游客信息导出必须绑定母团 tid。');
|
||
if (!(refs.identifier || refs.parent_group_no || refs.parent_plan_no || refs.plan_no)) blockers.push('散拼母团整团游客信息导出必须绑定母团团号/计划号。');
|
||
if (refs.ddid || refs.child_did || refs.ltjt_ddid || refs.ltjt_child_id) blockers.push('散拼母团整团游客信息不得携带子单 did/ddid。');
|
||
}
|
||
|
||
function hasTravelerAction(actions = []) {
|
||
return actions.some((item) => /^travelerList(?:\.|$)/i.test(String(item?.target || ''))
|
||
|| String(item?.operation || '').toLowerCase() === 'traveler_upsert');
|
||
}
|
||
|
||
function normalizeOperation(input = {}) {
|
||
const source = input?.task && isPlainObject(input.task) ? input.task : input;
|
||
if (!isPlainObject(source) || source.action !== 'shared_plan_create' || !isPlainObject(source.data)) return source;
|
||
const data = source.data;
|
||
const legacyFields = ['customer', 'passenger_counts'];
|
||
const nested = data.split_order;
|
||
if (!legacyFields.some((key) => data[key] !== undefined)) return source;
|
||
if (nested !== undefined && !isPlainObject(nested)) return source;
|
||
if (nested && legacyFields.some((key) => data[key] !== undefined
|
||
&& nested[key] !== undefined
|
||
&& JSON.stringify(data[key]) !== JSON.stringify(nested[key]))) {
|
||
// Keep conflicting shapes visible to the validator instead of choosing
|
||
// one silently. This is a compatibility migration, not data loss.
|
||
return source;
|
||
}
|
||
const splitOrder = { ...(nested || {}) };
|
||
legacyFields.forEach((key) => {
|
||
if (data[key] !== undefined) splitOrder[key] = splitOrder[key] ?? data[key];
|
||
});
|
||
const normalizedData = { ...data, split_order: splitOrder };
|
||
legacyFields.forEach((key) => { delete normalizedData[key]; });
|
||
return { ...source, data: normalizedData };
|
||
}
|
||
|
||
function addMissing(blockers, label, value) {
|
||
if (value === undefined || value === null || String(value).trim() === '') blockers.push(label);
|
||
}
|
||
|
||
function isPlainObject(value) {
|
||
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||
}
|
||
|
||
function isValidDate(value) {
|
||
if (typeof value !== 'string') return false;
|
||
const match = value.match(DATE_RE);
|
||
if (!match) return false;
|
||
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
||
return date.getUTCFullYear() === Number(match[1])
|
||
&& date.getUTCMonth() + 1 === Number(match[2])
|
||
&& date.getUTCDate() === Number(match[3]);
|
||
}
|
||
|
||
function validateNamedRef(value, label, blockers, { required = false } = {}) {
|
||
if (value === undefined || value === null) {
|
||
if (required) blockers.push(`${label} 必须是包含 name 的对象。`);
|
||
return;
|
||
}
|
||
if (!isPlainObject(value) || typeof value.name !== 'string' || !value.name.trim()) {
|
||
blockers.push(`${label} 必须是包含非空 name 的对象,不能是字符串。`);
|
||
}
|
||
}
|
||
|
||
function validateResolvedNamedRef(value, label, blockers, { required = false } = {}) {
|
||
validateNamedRef(value, label, blockers, { required });
|
||
if (value === undefined || value === null || !isPlainObject(value)) return;
|
||
if (value.resolved !== true) blockers.push(`${label}.resolved 必须为 true,表示 ERP 候选唯一匹配。`);
|
||
if (![value.id, value.ltjt_id].some((item) => typeof item === 'string' && item.trim())) {
|
||
blockers.push(`${label} 必须包含唯一匹配得到的 id 或 ltjt_id。`);
|
||
}
|
||
}
|
||
|
||
function arrangementStatusAllowed(action, mode, status, { testMode = false } = {}) {
|
||
if (action === 'arrangement_hotel') {
|
||
if (mode === 'create') return status === '未安排' || (testMode && status === '未确认');
|
||
if (mode === 'update' || mode === 'clear') return HOTEL_ARRANGEMENT_STATUSES.has(status);
|
||
}
|
||
return status === '未确认';
|
||
}
|
||
|
||
function targetDatesForLifecycle(operation = {}) {
|
||
const data = operation.data || {};
|
||
const refs = data.existing_refs || {};
|
||
const context = operation.source?.test_context || {};
|
||
return [...new Set([
|
||
...(Array.isArray(context.target_dates) ? context.target_dates : []),
|
||
...(Array.isArray(data.departure_dates) ? data.departure_dates : []),
|
||
refs.departure_date
|
||
].filter((value) => typeof value === 'string' && value.trim()))];
|
||
}
|
||
|
||
function isSeptember2026Date(value) {
|
||
return isValidDate(value) && value.startsWith('2026-09-');
|
||
}
|
||
|
||
function passengerCell(row, header) {
|
||
const aliases = {
|
||
NAME: ['NAME', '英文名', '拼音'],
|
||
证件号码: ['证件号码', '证件号', '护照号码', '护照号'],
|
||
签发日: ['签发日', '签发日期'],
|
||
出生日期: ['出生日期', '生日']
|
||
};
|
||
for (const key of aliases[header] || [header]) {
|
||
if (Object.hasOwn(row, key)) return String(row[key] ?? '');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function normalizePassengerTsvText(text) {
|
||
// Tabs represent columns, including empty final fields; trim only boundary newlines.
|
||
return String(text || '').replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, '');
|
||
}
|
||
|
||
function buildPassengerTsv(passengerList = {}) {
|
||
if (typeof passengerList.text === 'string' && passengerList.text.trim()) {
|
||
return normalizePassengerTsvText(passengerList.text);
|
||
}
|
||
const rows = Array.isArray(passengerList.rows) ? passengerList.rows : [];
|
||
if (!rows.length) return '';
|
||
return [
|
||
PASSENGER_HEADERS.join('\t'),
|
||
...rows.map((row) => PASSENGER_HEADERS.map((header) => passengerCell(row, header)).join('\t'))
|
||
].join('\n');
|
||
}
|
||
|
||
function passengerLeaderFromTsv(text) {
|
||
const lines = normalizePassengerTsvText(text).split('\n').filter((line) => line.length > 0);
|
||
const headers = String(lines[0] || '').split('\t').map((header) => header.trim());
|
||
if (headers.length !== PASSENGER_HEADERS.length
|
||
|| headers.some((header, index) => header !== PASSENGER_HEADERS[index])) {
|
||
return { valid: false, count: 0, leader: null };
|
||
}
|
||
const leaders = lines.slice(1).map((line) => line.split('\t')).filter((cells) => (
|
||
cells.length === PASSENGER_HEADERS.length && String(cells[12] || '').trim() === '领队'
|
||
));
|
||
if (leaders.length !== 1) return { valid: true, count: leaders.length, leader: null };
|
||
const cells = leaders[0];
|
||
return {
|
||
valid: true,
|
||
count: 1,
|
||
leader: {
|
||
sequence: Number(String(cells[0] || '').trim()),
|
||
name: String(cells[1] || '').trim(),
|
||
phone: String(cells[11] || '').trim()
|
||
}
|
||
};
|
||
}
|
||
|
||
function validatePassengerLeaderContact(value, text, blockers) {
|
||
const derived = passengerLeaderFromTsv(text);
|
||
if (!derived.valid) return;
|
||
if (derived.count > 1) blockers.push('名单备注列只能有一行严格等于“领队”。');
|
||
if (derived.count === 1 && (!derived.leader.name || !derived.leader.phone)) {
|
||
blockers.push('名单领队行的姓名和电话都必须是非空字符串。');
|
||
}
|
||
if (value === undefined) {
|
||
if (derived.count === 1) blockers.push('名单领队行必须生成 data.passenger_list.leader_contact。');
|
||
return;
|
||
}
|
||
if (!isPlainObject(value)) {
|
||
blockers.push('data.passenger_list.leader_contact 必须是对象。');
|
||
return;
|
||
}
|
||
if (Object.keys(value).some((key) => !new Set(['sequence', 'name', 'phone']).has(key))) {
|
||
blockers.push('data.passenger_list.leader_contact 包含未声明字段。');
|
||
}
|
||
if (!Number.isInteger(value.sequence) || value.sequence < 1 || value.sequence > PASSENGER_MAX_TARGET_ROWS) {
|
||
blockers.push(`data.passenger_list.leader_contact.sequence 必须是 1 至 ${PASSENGER_MAX_TARGET_ROWS} 的整数。`);
|
||
}
|
||
if (typeof value.name !== 'string' || !value.name.trim()) blockers.push('data.passenger_list.leader_contact.name 必须是非空字符串。');
|
||
if (typeof value.phone !== 'string' || !value.phone.trim()) blockers.push('data.passenger_list.leader_contact.phone 必须是非空字符串。');
|
||
if (typeof value.name === 'string' && value.name.length > 200) blockers.push('data.passenger_list.leader_contact.name 最多 200 个字符。');
|
||
if (typeof value.phone === 'string' && value.phone.length > 200) blockers.push('data.passenger_list.leader_contact.phone 最多 200 个字符。');
|
||
if (derived.count !== 1) {
|
||
blockers.push('没有严格“领队”备注行时禁止携带 data.passenger_list.leader_contact。');
|
||
return;
|
||
}
|
||
if (value.sequence !== derived.leader.sequence
|
||
|| String(value.name || '').trim() !== derived.leader.name
|
||
|| String(value.phone || '').trim() !== derived.leader.phone) {
|
||
blockers.push('data.passenger_list.leader_contact 必须与严格“领队”备注行的序号、姓名和电话完全一致。');
|
||
}
|
||
}
|
||
|
||
function validateDates(value, label, blockers, { required = false, min = 1, max } = {}) {
|
||
if (value === undefined || value === null) {
|
||
if (required) blockers.push(`${label} 必须是日期数组。`);
|
||
return;
|
||
}
|
||
if (!Array.isArray(value)) {
|
||
blockers.push(`${label} 必须是 YYYY-MM-DD 日期数组。`);
|
||
return;
|
||
}
|
||
if (value.length < min || (max !== undefined && value.length > max)) blockers.push(`${label} 的日期数量不符合当前 action 要求。`);
|
||
if (new Set(value).size !== value.length) blockers.push(`${label} 不能包含重复日期。`);
|
||
if (value.some((date) => !isValidDate(date))) blockers.push(`${label} 必须只包含有效的 YYYY-MM-DD 日期。`);
|
||
}
|
||
|
||
function validateRecurrenceRange(value, label, blockers) {
|
||
if (!isPlainObject(value)) {
|
||
blockers.push(`${label} 必须包含有效的 start_date/end_date。`);
|
||
return;
|
||
}
|
||
if (Object.keys(value).some((key) => !new Set(['start_date', 'end_date']).has(key))) {
|
||
blockers.push(`${label} 只允许 start_date/end_date。`);
|
||
}
|
||
if (!isValidDate(value.start_date) || !isValidDate(value.end_date)) {
|
||
blockers.push(`${label} 必须包含有效的 start_date/end_date。`);
|
||
return;
|
||
}
|
||
if (value.start_date > value.end_date) blockers.push(`${label}.start_date 不能晚于 end_date。`);
|
||
}
|
||
|
||
function validateSharedChildBatchTargets(value, recurrence, blockers) {
|
||
if (!Array.isArray(value) || !value.length) {
|
||
blockers.push('data.batch_targets 必须是 ERP 只读解析得到的非空母团目标数组。');
|
||
return;
|
||
}
|
||
if (value.length > 500) blockers.push('data.batch_targets 不能超过 500 个母团目标。');
|
||
const identities = new Set();
|
||
value.forEach((target, index) => {
|
||
const label = `data.batch_targets[${index}]`;
|
||
if (!isPlainObject(target)) {
|
||
blockers.push(`${label} 必须是对象。`);
|
||
return;
|
||
}
|
||
if (Object.keys(target).some((key) => !new Set(['parent_group_no', 'tid', 'departure_date']).has(key))) {
|
||
blockers.push(`${label} 包含未声明字段。`);
|
||
}
|
||
const parent = String(target.parent_group_no || '').trim();
|
||
const tid = String(target.tid || '').trim();
|
||
if (!parent) blockers.push(`${label}.parent_group_no 必须是非空字符串。`);
|
||
if (!/^\d+$/.test(tid)) blockers.push(`${label}.tid 必须是 ERP 数字引用。`);
|
||
if (!isValidDate(target.departure_date)) blockers.push(`${label}.departure_date 必须是有效日期。`);
|
||
if (isValidDate(target.departure_date)
|
||
&& isValidDate(recurrence?.start_date)
|
||
&& isValidDate(recurrence?.end_date)
|
||
&& (target.departure_date < recurrence.start_date || target.departure_date > recurrence.end_date)) {
|
||
blockers.push(`${label}.departure_date 不在请求日期范围内。`);
|
||
}
|
||
const identity = `${tid}:${parent}`;
|
||
if (identities.has(identity)) blockers.push(`data.batch_targets 包含重复母团目标:${identity}。`);
|
||
identities.add(identity);
|
||
});
|
||
}
|
||
|
||
function validatePassengerCounts(value, blockers) {
|
||
if (!isPlainObject(value)) {
|
||
blockers.push('passenger_counts 必须是稀疏人数对象。');
|
||
return;
|
||
}
|
||
if (Object.keys(value).some((key) => !PASSENGER_KEYS.has(key))) blockers.push('passenger_counts 包含未声明人数类别。');
|
||
const keys = Object.keys(value).filter((key) => PASSENGER_KEYS.has(key) && key !== 'expected_total');
|
||
if (!keys.length) blockers.push('passenger_counts 至少需要一个人数类别。');
|
||
if (keys.some((key) => !Number.isInteger(value[key]) || value[key] < 0)) blockers.push('passenger_counts 的人数必须是大于等于 0 的整数。');
|
||
const total = passengerTotal(value);
|
||
if (total <= 0) blockers.push('人数合计必须大于 0。');
|
||
if (value.expected_total != null && (!Number.isInteger(value.expected_total) || value.expected_total !== total)) blockers.push('expected_total 与人数合计不一致。');
|
||
}
|
||
|
||
function validateSplitOrder(value, blockers, label = 'data.split_order') {
|
||
if (value === undefined || value === null) return;
|
||
if (!isPlainObject(value)) {
|
||
blockers.push(`${label} 必须是对象。`);
|
||
return;
|
||
}
|
||
const allowed = new Set(['customer', 'passenger_counts']);
|
||
if (Object.keys(value).some((key) => !allowed.has(key))) blockers.push(`${label} 包含未声明字段。`);
|
||
const supplied = Object.keys(value).filter((key) => allowed.has(key) && value[key] !== undefined);
|
||
if (!supplied.length) blockers.push(`${label} 至少需要提供 customer 或 passenger_counts 之一。`);
|
||
if (value.customer !== undefined) validateNamedRef(value.customer, `${label}.customer`, blockers);
|
||
if (value.passenger_counts !== undefined) validatePassengerCounts(value.passenger_counts, blockers);
|
||
}
|
||
|
||
function validateSharedBatchSplitOrderProbe(operation, data, blockers, warnings) {
|
||
const dates = Array.isArray(data.departure_dates) ? data.departure_dates : [];
|
||
const splitOrder = isPlainObject(data.split_order) ? data.split_order : {};
|
||
const hasSplitFacts = [splitOrder.customer, splitOrder.passenger_counts, data.customer, data.passenger_counts]
|
||
.some((value) => value !== undefined);
|
||
if (dates.length < 2 || !hasSplitFacts) return;
|
||
const context = operation?.source?.test_context;
|
||
if (!isPlainObject(context)) {
|
||
blockers.push('散拼批量客户/人数组合尚未完成逐日期真实验证;当前只允许宿主测试运行注入 source.test_context。');
|
||
return;
|
||
}
|
||
const requiredStrings = ['run_id', 'account', 'native_baseline_id'];
|
||
if (requiredStrings.some((key) => typeof context[key] !== 'string' || !context[key].trim())
|
||
|| context.marker !== TEST_MARKER
|
||
|| context.live_window !== '2026-09'
|
||
|| context.phase !== 'shared_batch_split_order_probe'
|
||
|| !Array.isArray(context.target_dates)
|
||
|| dates.some((date) => !context.target_dates.includes(date))) {
|
||
blockers.push('散拼批量客户/人数事实探针必须绑定 TEST-202609、AI 测试账号、native baseline、phase=shared_batch_split_order_probe 和覆盖全部批量日期的 2026-09 test_context。');
|
||
return;
|
||
}
|
||
warnings.push('散拼批量母团创建已验证;本次客户/15+1 组合仍是测试态事实探针,必须逐日期登记母团及任何具体子单,不能提前宣称批量拼单已正式放行。');
|
||
}
|
||
|
||
function validateTeamRooms(value, blockers) {
|
||
if (!isPlainObject(value)) {
|
||
blockers.push('room_counts 必须是稀疏房型对象。');
|
||
return;
|
||
}
|
||
const keys = Object.keys(value);
|
||
if (!keys.length) blockers.push('room_counts 至少需要一个房型。');
|
||
if (keys.some((key) => !TEAM_ROOM_KEYS.has(key))) blockers.push('room_counts 只允许 SGL、TWN、TRP、DBL。');
|
||
if (keys.some((key) => TEAM_ROOM_KEYS.has(key) && (!Number.isInteger(value[key]) || value[key] < 0))) blockers.push('room_counts 的数量必须是大于等于 0 的整数。');
|
||
}
|
||
|
||
function validateLifecycleTestContext(operation, blockers, { requireAllowlist = false } = {}) {
|
||
const context = operation?.source?.test_context;
|
||
if (!isPlainObject(context)) {
|
||
blockers.push('生命周期 action 必须由宿主注入 source.test_context;缺少测试运行上下文,已阻断。');
|
||
return null;
|
||
}
|
||
const allowed = new Set([
|
||
'run_id', 'marker', 'account', 'native_baseline_id', 'allow_live_write',
|
||
'allowlist_id', 'created_refs', 'phase', 'operator', 'live_window', 'target_dates'
|
||
]);
|
||
if (Object.keys(context).some((key) => !allowed.has(key))) blockers.push('source.test_context 包含未声明字段。');
|
||
for (const key of ['run_id', 'account', 'native_baseline_id']) {
|
||
if (typeof context[key] !== 'string' || !context[key].trim()) blockers.push(`source.test_context.${key} 必须是非空字符串。`);
|
||
}
|
||
if (context.marker !== TEST_MARKER) blockers.push(`source.test_context.marker 必须严格为 ${TEST_MARKER}。`);
|
||
if (context.allow_live_write !== undefined && typeof context.allow_live_write !== 'boolean') {
|
||
blockers.push('source.test_context.allow_live_write 必须是布尔值。');
|
||
}
|
||
if (context.allow_live_write === true) {
|
||
if (context.live_window !== '2026-09') blockers.push('真实测试写入必须绑定 live_window=2026-09。');
|
||
const targetDates = targetDatesForLifecycle(operation);
|
||
if (!targetDates.length) blockers.push('真实测试写入必须提供 source.test_context.target_dates 或 existing_refs.departure_date。');
|
||
if (targetDates.some((date) => !isSeptember2026Date(date))) blockers.push('真实测试写入的目标订单日期必须全部位于 2026 年 9 月。');
|
||
const departureDate = operation.data?.existing_refs?.departure_date;
|
||
if (departureDate && !targetDates.includes(departureDate)) blockers.push('source.test_context.target_dates 必须包含当前对象的 existing_refs.departure_date。');
|
||
const refs = operation.data?.existing_refs;
|
||
if (!Array.isArray(context.created_refs) || !context.created_refs.some((created) => exactCreatedReferenceMatch(refs, created))) {
|
||
blockers.push('真实测试写入必须在 source.test_context.created_refs 中精确命中当前对象;账号或单字段重合不足以授权。');
|
||
}
|
||
}
|
||
if (requireAllowlist && (!context.allowlist_id || typeof context.allowlist_id !== 'string')) {
|
||
blockers.push('删除清理必须包含 source.test_context.allowlist_id。');
|
||
}
|
||
return context;
|
||
}
|
||
|
||
function validateOptionalLifecycleTestContext(operation, blockers, { required = false, requireAllowlist = false } = {}) {
|
||
if (operation?.source?.test_context === undefined && !required) return null;
|
||
return validateLifecycleTestContext(operation, blockers, { requireAllowlist });
|
||
}
|
||
|
||
function validateLifecycleRefs(data, blockers, action = '', context = null) {
|
||
const refs = data?.existing_refs;
|
||
if (!isPlainObject(refs)) {
|
||
blockers.push('生命周期 action 必须包含 data.existing_refs。');
|
||
return;
|
||
}
|
||
if (!EXISTING_REF_KINDS.has(refs.kind)) blockers.push('data.existing_refs.kind 必须明确订单对象类型。');
|
||
if (context) {
|
||
if (refs.marker !== TEST_MARKER) blockers.push(`data.existing_refs.marker 必须严格为 ${TEST_MARKER}。`);
|
||
if (typeof refs.owner_account !== 'string' || !refs.owner_account.trim()) blockers.push('data.existing_refs.owner_account 必须明确绑定测试账号。');
|
||
if (context.account && refs.owner_account !== context.account) blockers.push('data.existing_refs.owner_account 与 source.test_context.account 不一致。');
|
||
if (!isSeptember2026Date(refs.departure_date)) blockers.push('data.existing_refs.departure_date 必须是 2026 年 9 月有效日期。');
|
||
} else if (refs.resolved !== true || refs.resolution_source !== 'erp_unique_match') {
|
||
blockers.push('data.existing_refs 必须来自 ERP 唯一解析结果,不能由 Agent 或用户预填内部引用。');
|
||
}
|
||
const tid = String(refs.tid || refs.ltjt_tdid || '').trim();
|
||
const ddid = String(refs.ddid || refs.child_did || refs.ltjt_ddid || refs.ltjt_child_id || '').trim();
|
||
const identifier = identifierForOperation({}, { existing_refs: refs });
|
||
if (!tid) blockers.push('data.existing_refs 必须包含 tid/ltjt_tdid。');
|
||
if (refs.kind === 'independent_order' && (!identifier || !ddid)) blockers.push('独立团生命周期引用必须同时包含团号、tid 和 ddid。');
|
||
if (refs.kind === 'shared_plan' && !identifier) blockers.push('散拼母团生命周期引用必须同时包含母团号和 tid。');
|
||
if (refs.kind === 'shared_child_order' && (!identifier || !ddid)) blockers.push('散拼子单生命周期引用必须同时包含子单号、母团 tid 和子单 ddid/did。');
|
||
if (action === 'order_update_shared_plan' && !['plan_no', 'parent_group_no', 'parent_plan_no', 'tid'].some((key) => refs[key])) {
|
||
blockers.push('散拼母团修改必须引用母团计划/母团编号。');
|
||
}
|
||
if (action === 'order_update_shared_child' && !['child_order_no', 'child_did', 'identifier', 'order_no', 'ddid'].some((key) => refs[key])) {
|
||
blockers.push('散拼子单修改必须引用具体子单编号。');
|
||
}
|
||
if (action === 'passenger_list_import' && !['independent_order', 'shared_child_order'].includes(refs.kind)) {
|
||
blockers.push('名单导入必须声明 existing_refs.kind=independent_order 或 shared_child_order。');
|
||
}
|
||
if (ARRANGEMENT_ACTIONS.has(action) && !['independent_order', 'shared_plan'].includes(refs.kind)) {
|
||
blockers.push('团队安排只能引用 independent_order 或 shared_plan,不能直接写散拼子单。');
|
||
}
|
||
}
|
||
|
||
function exactCreatedReferenceMatch(left, right) {
|
||
if (!isPlainObject(left) || !isPlainObject(right)) return false;
|
||
const leftTid = String(left.tid || left.ltjt_tdid || '').trim();
|
||
const rightTid = String(right.tid || right.ltjt_tdid || '').trim();
|
||
const leftDdid = String(left.ddid || left.child_did || left.ltjt_ddid || left.ltjt_child_id || '').trim();
|
||
const rightDdid = String(right.ddid || right.child_did || right.ltjt_ddid || right.ltjt_child_id || '').trim();
|
||
const leftIdentifier = identifierForOperation({}, { existing_refs: left });
|
||
const rightIdentifier = identifierForOperation({}, { existing_refs: right });
|
||
if (left.kind !== right.kind || right.marker !== TEST_MARKER || !leftTid || leftTid !== rightTid || !leftIdentifier || leftIdentifier !== rightIdentifier) return false;
|
||
return left.kind === 'shared_plan' || Boolean(leftDdid && leftDdid === rightDdid);
|
||
}
|
||
|
||
function validatePassengerList(value, blockers, { testMode = false } = {}) {
|
||
if (!isPlainObject(value)) {
|
||
blockers.push('data.passenger_list 必须是对象。');
|
||
return;
|
||
}
|
||
const allowed = new Set(['operation', 'confirmed', 'passenger_count', 'row_count', 'rows', 'text', 'leader_contact', 'file_count', 'file_refs', 'notes', 'marker']);
|
||
if (Object.keys(value).some((key) => !allowed.has(key))) blockers.push('data.passenger_list 包含未声明字段。');
|
||
const operations = new Set(['none', 'first_import', 'append', 'modify', 'delete', 'replace', 'full_replace', 'reduce']);
|
||
if (!operations.has(value.operation)) blockers.push('data.passenger_list.operation 不是支持的名单操作。');
|
||
if (['modify', 'delete', 'replace', 'full_replace', 'reduce'].includes(value.operation) && value.confirmed !== true) {
|
||
blockers.push('名单修改/删除/替换操作必须先 confirmed=true。');
|
||
}
|
||
if (value.passenger_count !== undefined && (!Number.isInteger(value.passenger_count) || value.passenger_count < 0)) blockers.push('data.passenger_list.passenger_count 必须是非负整数。');
|
||
if (value.row_count !== undefined && (!Number.isInteger(value.row_count) || value.row_count < 0)) blockers.push('data.passenger_list.row_count 必须是非负整数。');
|
||
if (value.rows !== undefined && (!Array.isArray(value.rows) || value.rows.some((row) => !isPlainObject(row)))) blockers.push('data.passenger_list.rows 必须是对象数组,禁止按列错位写入。');
|
||
if (Array.isArray(value.rows) && value.row_count !== undefined && value.rows.length !== value.row_count) blockers.push('data.passenger_list.row_count 与 rows 行数不一致。');
|
||
if (testMode && value.marker !== TEST_MARKER) blockers.push(`data.passenger_list.marker 必须严格为 ${TEST_MARKER}。`);
|
||
if (!['first_import', 'full_replace'].includes(value.operation)) blockers.push('ERP 原生名单解析从第 1 行处理;正式 adapter 只接受 first_import 或 confirmed 的 full_replace。');
|
||
if (value.operation === 'full_replace' && value.confirmed !== true) blockers.push('full_replace 必须显式 confirmed=true。');
|
||
const text = buildPassengerTsv(value);
|
||
if (!text) blockers.push('名单导入必须显式提供 passenger_list.rows 或 passenger_list.text。');
|
||
if (text) {
|
||
const lines = text.split(/\r?\n/).filter((line) => line.length > 0);
|
||
const headers = String(lines[0] || '').split('\t');
|
||
if (headers.length !== PASSENGER_HEADERS.length
|
||
|| headers.some((header, index) => header.trim() !== PASSENGER_HEADERS[index])) {
|
||
blockers.push('名单 TSV 必须使用规定的 13 列表头和顺序。');
|
||
}
|
||
for (const required of ['姓名', 'NAME', '证件类型', '证件号码', '签发日', '电话', '备注']) {
|
||
if (!headers.includes(required)) blockers.push(`名单 TSV 缺少 ERP 表头:${required}。`);
|
||
}
|
||
const documentTypeIndex = headers.indexOf('证件类型');
|
||
if (documentTypeIndex < 0 || lines.slice(1).some((line) => String(line.split('\t')[documentTypeIndex] || '').trim() !== '护照')) {
|
||
blockers.push('ERP 原生名单页不单独保存证件类型;当前失败关闭契约只允许每行证件类型严格为“护照”。');
|
||
}
|
||
const actualRows = Math.max(0, lines.length - 1);
|
||
if (value.row_count !== undefined && actualRows !== value.row_count) blockers.push('名单 TSV 实际行数与 row_count 不一致。');
|
||
if (actualRows > PASSENGER_MAX_TARGET_ROWS) blockers.push(`名单数据行数超过 ${PASSENGER_MAX_TARGET_ROWS} 行技术安全上限。`);
|
||
const sequenceIndex = headers.indexOf('序号');
|
||
const sequences = lines.slice(1).map((line) => Number(String(line.split('\t')[sequenceIndex] || '').trim()));
|
||
if (sequenceIndex < 0 || sequences.some((sequence) => !Number.isInteger(sequence) || sequence < 1)) blockers.push('名单每一行的序号必须是正整数。');
|
||
if (new Set(sequences).size !== sequences.length) blockers.push('名单序号不能重复。');
|
||
if (sequences.some((sequence) => sequence > PASSENGER_MAX_TARGET_ROWS)) blockers.push(`名单序号超过 ${PASSENGER_MAX_TARGET_ROWS} 行技术安全上限。`);
|
||
validatePassengerLeaderContact(value.leader_contact, text, blockers);
|
||
}
|
||
}
|
||
|
||
function updateTargetKind(target = '') {
|
||
const value = String(target || '').trim();
|
||
if (!WIDE_UPDATE_TARGETS.has(value)) return '';
|
||
if (value === 'departure_date') return 'date';
|
||
if (value === 'trip_days' || value === 'twin_room_count' || value === 'planned_capacity') return 'positive_integer';
|
||
if (/^pax\.(?:adult|child_bed|child_no_bed|infant|leader|expected_total)$/.test(value)) return 'nonnegative_integer';
|
||
if (/^rooms\.(?:SGL|TWN|TRP|DBL|HNM|TL)$/.test(value)) return 'nonnegative_integer';
|
||
if (/^prices\.(?:adult|child_bed|child_no_bed|infant|leader)$/.test(value)) return 'nonnegative_number';
|
||
if (['customer', 'product', 'leader', 'route'].includes(value)) return 'named_ref';
|
||
return 'text';
|
||
}
|
||
|
||
function validateUpdateValue(item, target, kind, blockers) {
|
||
if (kind === 'date' && !isValidDate(item.value)) blockers.push(`${target} 必须是有效的 YYYY-MM-DD 日期。`);
|
||
if (kind === 'positive_integer' && (!Number.isInteger(item.value) || item.value < 1)) blockers.push(`${target} 必须是正整数。`);
|
||
if (kind === 'nonnegative_integer' && (!Number.isInteger(item.value) || item.value < 0)) blockers.push(`${target} 必须是大于等于 0 的整数。`);
|
||
if (kind === 'nonnegative_number' && (typeof item.value !== 'number' || !Number.isFinite(item.value) || item.value < 0)) blockers.push(`${target} 必须是大于等于 0 的数字。`);
|
||
if (kind === 'text' && (typeof item.value !== 'string' || !item.value.trim())) blockers.push(`${target} 必须是非空字符串。`);
|
||
if (kind === 'named_ref' && !(typeof item.value === 'string' && item.value.trim())
|
||
&& !(isPlainObject(item.value) && typeof item.value.name === 'string' && item.value.name.trim())) blockers.push(`${target} 必须是非空名称或 namedRef 对象。`);
|
||
if (target === 'lodging_note' && typeof item.value === 'string' && item.value.length > 50) blockers.push('lodging_note 单次修改值不得超过 50 个字符。');
|
||
}
|
||
|
||
function validateLifecycleUpdates(value, action, refs, blockers, warnings = [], { testMode = false } = {}) {
|
||
if (!isPlainObject(value) || !Array.isArray(value.actions) || !value.actions.length) {
|
||
blockers.push('生命周期修改必须提供非空 data.updates.actions。');
|
||
return;
|
||
}
|
||
if (Object.keys(value).some((key) => key !== 'actions')) blockers.push('生命周期 data.updates 只允许 actions,禁止携带隐藏或未声明字段。');
|
||
if (value.actions.length > 32) blockers.push('生命周期修改每次最多允许 32 个业务字段动作。');
|
||
const operations = new Set(['set', 'delta', 'append', 'clear']);
|
||
const seen = new Set();
|
||
const manualReviewTargets = [];
|
||
for (const item of value.actions) {
|
||
const target = String(item?.target || '').trim();
|
||
const operation = String(item?.operation || '').trim().toLowerCase();
|
||
if (!target || !operation) {
|
||
blockers.push('生命周期修改动作必须包含 target 和 operation。');
|
||
continue;
|
||
}
|
||
if (seen.has(target)) blockers.push(`生命周期修改目标不能重复:${target}。`);
|
||
seen.add(target);
|
||
if (!WIDE_UPDATE_TARGETS.has(target)) blockers.push(`无法识别的生命周期修改目标:${target}。`);
|
||
else if (!UPDATE_TARGETS[action]?.has(target)) manualReviewTargets.push(target);
|
||
if (!operations.has(operation)) blockers.push(`修改目标 ${target} 使用了不支持的 operation:${operation}。`);
|
||
if (/(?:^|\.)(?:id|session|token|csrf|audit|trace|hidden|password)(?:$|\.)/i.test(target)) blockers.push(`禁止修改隐藏/审计字段:${target}。`);
|
||
if (operation !== 'clear' && !Object.hasOwn(item, 'value')) blockers.push(`修改目标 ${target} 缺少 value。`);
|
||
const kind = updateTargetKind(target);
|
||
if (operation === 'append' && !['text', 'named_ref'].includes(kind)) blockers.push(`${target} 只能对文本字段使用 append。`);
|
||
if (operation === 'delta' && !['positive_integer', 'nonnegative_integer', 'nonnegative_number'].includes(kind)) blockers.push(`${target} 只能对数值字段使用 delta。`);
|
||
if (operation !== 'clear') validateUpdateValue(item, target, kind, blockers);
|
||
if (testMode && action === 'order_update_shared_child' && typeof item.value === 'string' && !item.value.includes(TEST_MARKER)) blockers.push(`测试修改文本必须包含 ${TEST_MARKER}。`);
|
||
if (action === 'order_update_shared_child' && typeof item.value === 'string' && item.value.length > 50) blockers.push('lodging_note 单次追加值不得超过 50 字符;插件还会按当前值校验追加后的最终长度不超过 50。');
|
||
}
|
||
if (manualReviewTargets.length) warnings.push(`已解析但尚未纳入当前自动写入白名单的字段将转人工复核:${[...new Set(manualReviewTargets)].join('、')}。`);
|
||
}
|
||
|
||
function validateArrangement(value, action, blockers, { testMode = false } = {}) {
|
||
if (!isPlainObject(value)) {
|
||
blockers.push('安排 action 必须包含 data.arrangement 对象。');
|
||
return;
|
||
}
|
||
if (value.vehicle_type !== undefined && (action !== 'arrangement_vehicle' || !['create', 'update'].includes(value.mode)
|
||
|| typeof value.vehicle_type !== 'string' || !value.vehicle_type.trim())) blockers.push('用车类型只允许用于安排用车新增/变更,且必须是非空文本。');
|
||
if (value.ticket_description !== undefined && (action !== 'arrangement_transport' || !['create', 'update'].includes(value.mode)
|
||
|| typeof value.ticket_description !== 'string' || !value.ticket_description.trim())) blockers.push('车票说明只允许用于安排大交通新增/变更,且必须是非空文本。');
|
||
const allowed = new Set(['mode', 'target', 'changes', 'resource', 'supplier', 'item', 'room_type', 'date', 'start_date', 'end_date', 'quantity', 'room_count', 'status', 'remark', 'side_effect_policy', 'marker', 'filing', 'preserve_filing', 'vehicle_type', 'ticket_description']);
|
||
if (Object.keys(value).some((key) => !allowed.has(key))) blockers.push('data.arrangement 包含未声明字段。');
|
||
if (!['create', 'update', 'clear'].includes(value.mode)) blockers.push('data.arrangement.mode 必须是 create、update 或 clear。');
|
||
if (value.mode === 'create' && value.target !== undefined) blockers.push('create 模式必须由 ERP 页面证明空槽位,不能预填 target。');
|
||
if (value.mode === 'update' || value.mode === 'clear') {
|
||
if (!isPlainObject(value.target)) blockers.push(`${value.mode} 模式必须提供 data.arrangement.target。`);
|
||
else {
|
||
if (Object.keys(value.target).some((key) => !new Set(['slot_index', 'row_id']).has(key))) blockers.push('data.arrangement.target 只允许 slot_index 和 row_id。');
|
||
if (action !== 'arrangement_guide' && value.mode === 'update'
|
||
? !Number.isInteger(value.target.slot_index) || value.target.slot_index < 0
|
||
: value.target.slot_index !== 0) blockers.push(`当前安排 ${value.mode} 的目标槽位无效。`);
|
||
if (action !== 'arrangement_guide' && (typeof value.target.row_id !== 'string' || !value.target.row_id.trim())) blockers.push(`${action} ${value.mode} 必须提供原生 row_id。`);
|
||
}
|
||
}
|
||
if (value.mode === 'update') {
|
||
const changes = value.changes;
|
||
const updateKeys = ARRANGEMENT_UPDATE_KEYS[action] || new Set();
|
||
if (!isPlainObject(changes) || Object.keys(changes).length === 0) blockers.push('update 模式必须提供非空 data.arrangement.changes。');
|
||
for (const key of Object.keys(changes || {})) if (!updateKeys.has(key)) blockers.push(`${action} 尚未实测开放安排变更字段:${key}。`);
|
||
if (changes?.resource !== undefined) {
|
||
validateResolvedNamedRef(changes.resource, 'data.arrangement.changes.resource', blockers, { required: true });
|
||
if (action === 'arrangement_hotel' && (typeof changes.room_type !== 'string' || !changes.room_type.trim())) blockers.push('更换酒店必须由 ERP 联动解析新房型。');
|
||
} else if (changes?.room_type !== undefined) blockers.push(`${action} 尚未实测开放安排变更字段:room_type。`);
|
||
if (changes?.supplier !== undefined) {
|
||
validateResolvedNamedRef(changes.supplier, 'arrangement.changes.supplier', blockers, { required: true });
|
||
if (typeof changes.item !== 'string' || !changes.item.trim()) blockers.push('变更项目必须由 ERP 供应商候选联动带入。');
|
||
} else if (changes?.item !== undefined) blockers.push('禁止单独修改联动项目。');
|
||
if (changes?.filing !== undefined && (!isPlainObject(changes.filing) || !Object.keys(changes.filing).length || Object.keys(changes.filing).some((key) => !['number', 'entry_port', 'exit_port'].includes(key)) || Object.values(changes.filing).some((v) => typeof v !== 'string' || !v.trim()))) blockers.push('备案变更字段无效。');
|
||
if (!arrangementStatusAllowed(action, value.mode, value.status, { testMode })) blockers.push(action === 'arrangement_hotel'
|
||
? 'update 只能作用于 ERP 当前“未安排”或“未确认”酒店安排。'
|
||
: 'update 只能作用于 ERP 当前“未确认”安排。');
|
||
if (testMode && (typeof value.remark !== 'string' || !value.remark.includes(TEST_MARKER))) blockers.push(`测试 update 当前安排备注必须包含 ${TEST_MARKER}。`);
|
||
}
|
||
if (value.mode === 'clear') {
|
||
if (action === 'arrangement_other' && value.preserve_filing !== true) blockers.push('arrangement_other clear 必须 preserve_filing=true,成本行清除不得删除备案对象。');
|
||
if (action === 'arrangement_other' && !isPlainObject(value.filing)) blockers.push('arrangement_other clear 必须携带待保留备案快照。');
|
||
}
|
||
if (value.mode !== 'clear' && value.preserve_filing !== undefined) blockers.push('preserve_filing 只允许用于 arrangement_other clear。');
|
||
const finalValue = value.mode === 'update' && isPlainObject(value.changes) ? { ...value, ...value.changes, ...(value.changes.filing ? { filing: { ...value.filing, ...value.changes.filing } } : {}) } : value;
|
||
if (action === 'arrangement_guide' || action === 'arrangement_hotel') {
|
||
validateResolvedNamedRef(value.resource, 'data.arrangement.resource', blockers, { required: true });
|
||
} else if (value.resource !== undefined) {
|
||
blockers.push(`${action} 使用 supplier + item,不接受模糊 resource。`);
|
||
}
|
||
if (action === 'arrangement_vehicle') {
|
||
validateResolvedNamedRef(value.supplier, 'data.arrangement.supplier', blockers, { required: true });
|
||
if (typeof finalValue.item !== 'string' || !finalValue.item.trim()) blockers.push('arrangement_vehicle 必须提供 ERP 唯一匹配得到的 data.arrangement.item。');
|
||
if (!finalValue.start_date || !finalValue.end_date) blockers.push('arrangement_vehicle 必须提供 start_date 和 end_date。');
|
||
} else if (action === 'arrangement_transport') {
|
||
validateResolvedNamedRef(value.supplier, 'data.arrangement.supplier', blockers, { required: true });
|
||
// The ticket/control description is selected from the native settlement
|
||
// unit row and is added by route resolution; parser input may omit it.
|
||
} else if (action === 'arrangement_other') {
|
||
validateResolvedNamedRef(value.supplier, 'data.arrangement.supplier', blockers, { required: true });
|
||
// 项目由结算单位统一选择框的选中行带入;解析态可省略。
|
||
} else if (value.supplier !== undefined) {
|
||
validateResolvedNamedRef(value.supplier, 'data.arrangement.supplier', blockers);
|
||
}
|
||
if (action === 'arrangement_hotel' && (typeof finalValue.room_type !== 'string' || !finalValue.room_type.trim())) blockers.push('酒店安排必须提供 room_type。');
|
||
if (action === 'arrangement_hotel' && (!finalValue.start_date || !finalValue.end_date || finalValue.room_count === undefined)) blockers.push('酒店安排必须提供 start_date、end_date 和 room_count。');
|
||
if (['arrangement_transport', 'arrangement_other'].includes(action) && !finalValue.date) blockers.push(`${action} 必须提供 date。`);
|
||
if (action === 'arrangement_vehicle' && finalValue.start_date && finalValue.end_date
|
||
&& isValidDate(finalValue.start_date) && isValidDate(finalValue.end_date)
|
||
&& finalValue.end_date < finalValue.start_date) blockers.push('车辆安排 end_date 不能早于 start_date。');
|
||
if (['arrangement_vehicle', 'arrangement_transport'].includes(action) && finalValue.quantity === undefined) blockers.push(`${action} 必须提供 quantity。`);
|
||
if (!arrangementStatusAllowed(action, value.mode, finalValue.status, { testMode })) blockers.push(action === 'arrangement_hotel'
|
||
? '酒店安排新增必须保存为“未安排”状态;既有酒店安排只接受“未安排”或“未确认”状态。'
|
||
: '安排 action 只能保存为“未确认”状态。');
|
||
if (value.side_effect_policy !== 'no_external') blockers.push('安排 action 必须声明 side_effect_policy=no_external。');
|
||
if (finalValue.remark !== undefined && typeof finalValue.remark !== 'string') blockers.push('安排备注必须是字符串。');
|
||
if (testMode && (typeof finalValue.remark !== 'string' || !finalValue.remark.includes(TEST_MARKER))) blockers.push(`测试安排备注必须包含 ${TEST_MARKER}。`);
|
||
if (testMode && value.marker !== undefined && value.marker !== TEST_MARKER) blockers.push(`data.arrangement.marker 必须严格为 ${TEST_MARKER}。`);
|
||
if (value.filing !== undefined && (!isPlainObject(value.filing) || Object.keys(value.filing).some((key) => !new Set(['number', 'date', 'entry_port', 'exit_port', 'guide', 'driver', 'remark']).has(key)))) blockers.push('data.arrangement.filing 必须使用标准备案字段。');
|
||
if (isPlainObject(value.filing) && value.filing.date !== undefined
|
||
&& (!isValidDate(value.filing.date) || (testMode && !value.filing.date.startsWith('2026-09-')))) blockers.push(testMode ? 'data.arrangement.filing.date 必须是 2026 年 9 月有效日期。' : 'data.arrangement.filing.date 必须是有效的 YYYY-MM-DD 日期。');
|
||
if (isPlainObject(value.filing) && value.filing.guide !== undefined) validateNamedRef(value.filing.guide, 'data.arrangement.filing.guide', blockers);
|
||
if (isPlainObject(value.filing) && value.filing.driver !== undefined) validateNamedRef(value.filing.driver, 'data.arrangement.filing.driver', blockers);
|
||
for (const key of ['date', 'start_date', 'end_date']) {
|
||
if (finalValue[key] !== undefined && (!isValidDate(finalValue[key]) || (testMode && !finalValue[key].startsWith('2026-09-')))) blockers.push(testMode ? `data.arrangement.${key} 必须是 2026 年 9 月有效日期。` : `data.arrangement.${key} 必须是有效的 YYYY-MM-DD 日期。`);
|
||
}
|
||
for (const key of ['quantity', 'room_count']) {
|
||
if (finalValue[key] !== undefined && (!Number.isInteger(finalValue[key]) || (finalValue[key] <= 0 && !(key === 'quantity' && action === 'arrangement_other' && value.mode === 'update' && value.changes?.quantity === undefined && finalValue[key] === 0)))) blockers.push(`data.arrangement.${key} 必须是正整数。`);
|
||
}
|
||
if (action === 'arrangement_hotel' && isValidDate(finalValue.start_date) && isValidDate(finalValue.end_date)
|
||
&& Date.parse(`${finalValue.end_date}T00:00:00Z`) <= Date.parse(`${finalValue.start_date}T00:00:00Z`)) blockers.push('酒店安排 end_date 必须晚于 start_date。');
|
||
}
|
||
|
||
function validateTransition(value, action, refs, blockers, { testMode = false } = {}) {
|
||
if (!isPlainObject(value)) {
|
||
blockers.push(`${action} 必须包含 data.transition。`);
|
||
return;
|
||
}
|
||
const allowed = new Set(['mode', 'target_kind', 'from_status', 'to_status', 'receivables_cleared', 'arrangements_cleared', 'expected_passenger_count', 'marker', 'remark']);
|
||
if (Object.keys(value).some((key) => !allowed.has(key))) blockers.push('data.transition 包含未声明字段。');
|
||
if (!['edit', 'list'].includes(value.mode)) blockers.push('data.transition.mode 必须是 edit 或 list。');
|
||
const fromStatus = typeof value.from_status === 'string' ? value.from_status.trim() : '';
|
||
const toStatus = typeof value.to_status === 'string' ? value.to_status.trim() : '';
|
||
if (!fromStatus) blockers.push('data.transition.from_status 必须是当前 ERP 状态,不能为空。');
|
||
else if (fromStatus === toStatus) blockers.push(`data.transition.from_status 与 to_status 相同(${toStatus}),当前无需执行状态迁移。`);
|
||
if (!EXISTING_REF_KINDS.has(value.target_kind)) blockers.push('data.transition.target_kind 必须是明确的订单对象类型。');
|
||
if (refs?.kind && value.target_kind !== refs.kind) blockers.push('data.transition.target_kind 必须与 existing_refs.kind 一致。');
|
||
if (action === 'order_cancel' && value.to_status !== '已取消') blockers.push('order_cancel 必须写入 to_status=已取消。');
|
||
if (action === 'order_restore' && value.mode === 'list') blockers.push('ERP 列表级 cz=0 已跨独立团和散拼母团稳定复现无效;order_restore 只允许 mode=edit。');
|
||
if (action === 'order_restore' && value.target_kind === 'shared_plan' && value.to_status !== '收客中') blockers.push('散拼母团恢复必须写入 to_status=收客中。');
|
||
if (action === 'order_restore' && value.target_kind !== 'shared_plan' && !['预订', '已确认'].includes(value.to_status)) blockers.push('独立团/散拼子单恢复的 to_status 必须是预订或已确认。');
|
||
if (testMode && action === 'order_cancel' && value.receivables_cleared !== true) blockers.push('测试取消前必须声明 receivables_cleared=true 并由回查证据支持。');
|
||
if (testMode && action === 'order_cancel' && value.arrangements_cleared !== true) blockers.push('测试取消前必须声明 arrangements_cleared=true;零金额安排也可能建立取消前置账。');
|
||
if (value.expected_passenger_count !== undefined && (!Number.isInteger(value.expected_passenger_count) || value.expected_passenger_count < 0)) blockers.push('data.transition.expected_passenger_count 必须是非负整数。');
|
||
if (value.mode === 'list' && value.target_kind === 'shared_child_order') blockers.push('散拼子单取消/恢复没有已验证的列表级路径,必须使用 edit。');
|
||
if (testMode && value.marker !== TEST_MARKER) blockers.push(`data.transition.marker 必须严格为 ${TEST_MARKER}。`);
|
||
}
|
||
|
||
function validateReceivableFixture(value, blockers, action) {
|
||
if (value === undefined) return;
|
||
if (!isPlainObject(value)) {
|
||
blockers.push('data.receivable_fixture 必须是对象。');
|
||
return;
|
||
}
|
||
const allowed = new Set(['operation', 'row_id', 'rows', 'name', 'quantity', 'unit_price', 'currency', 'remark', 'paid', 'settled', 'marker']);
|
||
if (Object.keys(value).some((key) => !allowed.has(key))) blockers.push('data.receivable_fixture 包含未声明字段。');
|
||
if (!['add', 'clear', 'clear_generated_zero', 'none'].includes(value.operation)) blockers.push('data.receivable_fixture.operation 必须是 add、clear、clear_generated_zero 或 none。');
|
||
if (value.operation === 'none') {
|
||
if (Object.keys(value).some((key) => !['operation', 'marker'].includes(key))) blockers.push('operation=none 时不得携带应收业务字段。');
|
||
} else if (['add', 'clear'].includes(value.operation)) {
|
||
if (action !== 'order_update_shared_child') blockers.push('应收款 fixture 仅允许附着于 order_update_shared_child 业务页保存。');
|
||
if (value.name !== '其他费用') blockers.push('应收款 fixture.name 必须严格为“其他费用”。');
|
||
if (value.quantity !== 1) blockers.push('应收款 fixture.quantity 必须严格为 1。');
|
||
if (value.unit_price !== 0.01) blockers.push('应收款 fixture.unit_price 必须严格为 0.01。');
|
||
if (value.currency !== 'CNY') blockers.push('应收款 fixture.currency 必须严格为 CNY。');
|
||
if (typeof value.remark !== 'string' || !value.remark.includes(TEST_MARKER) || value.remark.length > 100) blockers.push(`应收款 fixture.remark 必须包含 ${TEST_MARKER} 且不超过 100 字符。`);
|
||
if (value.paid !== false || value.settled !== false) blockers.push('测试应收款 fixture 必须明确 paid=false、settled=false。');
|
||
if (value.operation === 'add' && value.row_id !== undefined) blockers.push('应收款新增不得预填 row_id。');
|
||
if (value.operation === 'clear' && (typeof value.row_id !== 'string' || !/^\d+$/.test(value.row_id) || value.row_id === '0')) blockers.push('应收款清零必须绑定新增回查得到的非零 row_id。');
|
||
} else if (value.operation === 'clear_generated_zero') {
|
||
if (action !== 'order_update_independent') blockers.push('自动生成零金额应收行只允许附着于 order_update_independent 业务页保存。');
|
||
if (Object.keys(value).some((key) => !['operation', 'rows', 'marker'].includes(key))) blockers.push('clear_generated_zero 只允许 operation、rows、marker。');
|
||
if (!Array.isArray(value.rows) || value.rows.length !== 2) blockers.push('clear_generated_zero 必须精确绑定两条 ERP 自动生成应收行。');
|
||
const rowAllowed = new Set(['row_id', 'name', 'quantity', 'unit_price', 'amount', 'currency', 'paid', 'settled']);
|
||
const rowIds = [];
|
||
const names = [];
|
||
for (const row of value.rows || []) {
|
||
if (!isPlainObject(row) || Object.keys(row).some((key) => !rowAllowed.has(key))) {
|
||
blockers.push('clear_generated_zero.rows 包含非对象或未声明字段。');
|
||
continue;
|
||
}
|
||
rowIds.push(row.row_id);
|
||
names.push(row.name);
|
||
if (typeof row.row_id !== 'string' || !/^[1-9]\d*$/.test(row.row_id)) blockers.push('clear_generated_zero.rows.row_id 必须是非零原生行 ID。');
|
||
if (!['成人团费', '单人房差'].includes(row.name)) blockers.push('clear_generated_zero 只允许成人团费和单人房差。');
|
||
if (!Number.isInteger(row.quantity) || row.quantity <= 0) blockers.push('clear_generated_zero.rows.quantity 必须是原生正整数。');
|
||
if (row.unit_price !== 0 || row.amount !== 0 || row.currency !== 'CNY') blockers.push('clear_generated_zero 仅允许 CNY 零单价、零金额行。');
|
||
if (row.paid !== false || row.settled !== false) blockers.push('clear_generated_zero 禁止已收款或已结算行。');
|
||
}
|
||
if (new Set(rowIds).size !== rowIds.length) blockers.push('clear_generated_zero.rows.row_id 不得重复。');
|
||
if (names.sort().join('|') !== ['单人房差', '成人团费'].sort().join('|')) blockers.push('clear_generated_zero 必须同时且仅包含成人团费和单人房差。');
|
||
}
|
||
if (value.marker !== TEST_MARKER) blockers.push(`应收款 fixture.marker 必须严格为 ${TEST_MARKER}。`);
|
||
}
|
||
|
||
function validateDeleteGuard(value, blockers, { existingRefs, context } = {}) {
|
||
if (!isPlainObject(value)) {
|
||
blockers.push('order_delete 必须包含 data.delete_guard。');
|
||
return;
|
||
}
|
||
const allowed = new Set([
|
||
'marker', 'allowlist_id', 'created_refs', 'post_delete_requery', 'account', 'run_id',
|
||
'export_evidence_frozen', 'delete_authorized', 'child_refs_deleted', 'delete_sequence'
|
||
]);
|
||
if (Object.keys(value).some((key) => !allowed.has(key))) blockers.push('data.delete_guard 包含未声明字段。');
|
||
if (value.marker !== TEST_MARKER) blockers.push(`data.delete_guard.marker 必须严格为 ${TEST_MARKER}。`);
|
||
if (typeof value.allowlist_id !== 'string' || !value.allowlist_id.trim()) blockers.push('data.delete_guard.allowlist_id 必须是非空字符串。');
|
||
if (typeof value.account !== 'string' || !value.account.trim()) blockers.push('data.delete_guard.account 必须明确绑定测试账号。');
|
||
if (typeof value.run_id !== 'string' || !value.run_id.trim()) blockers.push('data.delete_guard.run_id 必须明确绑定本次运行。');
|
||
if (!Array.isArray(value.created_refs) || !value.created_refs.length || value.created_refs.some((item) => !isPlainObject(item) || item.marker !== TEST_MARKER || !REFERENCE_KEYS.some((key) => String(item[key] || '').trim()))) {
|
||
blockers.push('data.delete_guard.created_refs 必须是带 TEST-202609 标记的本次运行创建对象引用数组。');
|
||
} else if (existingRefs && !value.created_refs.some((item) => exactCreatedReferenceMatch(existingRefs, item))) {
|
||
blockers.push('data.delete_guard.created_refs 未包含当前待删除对象的精确引用。');
|
||
}
|
||
if (value.post_delete_requery !== true) blockers.push('删除清理必须声明 post_delete_requery=true。');
|
||
if (value.export_evidence_frozen !== true) blockers.push('删除前必须声明 export_evidence_frozen=true。');
|
||
if (value.delete_authorized !== true) blockers.push('删除必须由宿主声明 delete_authorized=true。');
|
||
if (!EXISTING_REF_KINDS.has(existingRefs?.kind)) blockers.push('order_delete 必须用 existing_refs.kind 明确区分独立团、散拼母团或散拼子单。');
|
||
if (existingRefs?.kind === 'independent_order') {
|
||
if (!existingRefs.tid || !(existingRefs.ddid || existingRefs.ltjt_ddid) || !(existingRefs.identifier || existingRefs.order_no || existingRefs.group_no)) {
|
||
blockers.push('独立团删除必须同时绑定 tid、ddid 和团号。');
|
||
}
|
||
}
|
||
if (existingRefs?.kind === 'shared_child_order') {
|
||
if (!existingRefs.tid || !(existingRefs.child_did || existingRefs.ddid || existingRefs.ltjt_child_id)) blockers.push('散拼子单删除必须同时绑定母团 tid 与子单 did/ddid。');
|
||
}
|
||
if (existingRefs?.kind === 'shared_plan') {
|
||
if (!existingRefs.tid || !(existingRefs.identifier || existingRefs.parent_group_no || existingRefs.parent_plan_no || existingRefs.plan_no)) blockers.push('散拼母团删除必须同时绑定 tid 与母团团号。');
|
||
if (value.child_refs_deleted !== true || value.delete_sequence !== 'child_before_parent') blockers.push('散拼母团删除必须证明 child_refs_deleted=true 且 delete_sequence=child_before_parent。');
|
||
}
|
||
if (context && value.allowlist_id && value.allowlist_id !== context.allowlist_id) blockers.push('source.test_context.allowlist_id 与 data.delete_guard.allowlist_id 不一致。');
|
||
if (context && value.account && value.account !== context.account) blockers.push('删除对象账号归属与测试上下文不一致。');
|
||
if (context && value.run_id && value.run_id !== context.run_id) blockers.push('删除对象 run_id 与测试上下文不一致。');
|
||
}
|
||
|
||
function validateDispatchNamedRef(value, label, blockers, { required = false } = {}) {
|
||
if (value === undefined || value === null) {
|
||
if (required) blockers.push(`${label} 必须包含可用于 ERP 查询的名称或关键词。`);
|
||
return;
|
||
}
|
||
if (!isPlainObject(value)) {
|
||
blockers.push(`${label} 必须是名称/关键词对象,不能是字符串。`);
|
||
return;
|
||
}
|
||
if (Object.keys(value).some((key) => !DISPATCH_NAMED_REF_KEYS.has(key))) blockers.push(`${label} 包含不属于解析态的字段。`);
|
||
if (![value.name, value.keyword].some((item) => typeof item === 'string' && item.trim())) blockers.push(`${label} 至少需要一个非空 name 或 keyword。`);
|
||
}
|
||
|
||
function validateDispatchExistingRefs(value, label, blockers, {
|
||
required = true,
|
||
requireIdentifier = true,
|
||
requireKind = false,
|
||
requiredKind = '',
|
||
allowedKinds = null
|
||
} = {}) {
|
||
if (value === undefined || value === null) {
|
||
if (required) blockers.push(`${label} 必须包含用户可见编号或对象类型。`);
|
||
return;
|
||
}
|
||
if (!isPlainObject(value)) {
|
||
blockers.push(`${label} 必须是对象。`);
|
||
return;
|
||
}
|
||
if (Object.keys(value).some((key) => !DISPATCH_EXISTING_REF_KEYS.has(key))) blockers.push(`${label} 包含 ERP 内部引用;解析态只允许用户可见编号。`);
|
||
if (requireIdentifier && (typeof value.identifier !== 'string' || !value.identifier.trim())) blockers.push(`${label}.identifier 必须是非空的用户可见单号或团号。`);
|
||
if (value.identifier !== undefined && (typeof value.identifier !== 'string' || !value.identifier.trim())) blockers.push(`${label}.identifier 必须是非空的用户可见单号或团号。`);
|
||
if (value.kind !== undefined && !EXISTING_REF_KINDS.has(value.kind)) blockers.push(`${label}.kind 不是支持的业务对象类型。`);
|
||
if (requireKind && !EXISTING_REF_KINDS.has(value.kind)) blockers.push(`${label}.kind 必须明确业务对象类型。`);
|
||
if (requiredKind && value.kind !== requiredKind) blockers.push(`${label}.kind 必须是 ${requiredKind}。`);
|
||
if (allowedKinds && value.kind !== undefined && !allowedKinds.has(value.kind)) blockers.push(`${label}.kind 不适用于当前业务。`);
|
||
if (value.parent_group_no !== undefined && (typeof value.parent_group_no !== 'string' || !value.parent_group_no.trim())) blockers.push(`${label}.parent_group_no 必须是非空字符串。`);
|
||
}
|
||
|
||
function dispatchVisibleIdentifier(data = {}) {
|
||
return typeof data.existing_refs?.identifier === 'string'
|
||
? data.existing_refs.identifier.trim()
|
||
: '';
|
||
}
|
||
|
||
function validateDispatchBusinessLookup(data, blockers, { customerRequired = true } = {}) {
|
||
const identifier = dispatchVisibleIdentifier(data);
|
||
validateDispatchNamedRef(data.customer, 'data.customer', blockers, { required: !identifier && customerRequired });
|
||
validateDispatchNamedRef(data.product, 'data.product', blockers);
|
||
validateDispatchNamedRef(data.leader, 'data.leader', blockers);
|
||
validateDates(data.departure_dates, 'departure_dates', blockers, {
|
||
required: !identifier,
|
||
min: 1,
|
||
max: 1
|
||
});
|
||
}
|
||
|
||
function validateDispatchPassengerList(value, blockers) {
|
||
if (!isPlainObject(value)) {
|
||
blockers.push('data.passenger_list 必须是对象。');
|
||
return;
|
||
}
|
||
const allowed = new Set(['operation', 'confirmed', 'row_count', 'text', 'leader_contact']);
|
||
if (Object.keys(value).some((key) => !allowed.has(key))) blockers.push('data.passenger_list 包含执行态或未声明字段。');
|
||
if (!['import', 'full_replace'].includes(value.operation)) blockers.push('data.passenger_list.operation 必须是 import 或 full_replace。');
|
||
if (value.operation === 'full_replace' && value.confirmed !== true) blockers.push('full_replace 必须显式 confirmed=true。');
|
||
if (!Number.isInteger(value.row_count) || value.row_count < 1) blockers.push('data.passenger_list.row_count 必须是正整数。');
|
||
if (typeof value.text !== 'string' || !value.text.trim()) {
|
||
blockers.push('data.passenger_list.text 必须包含本次提供的 13 列名单 TSV。');
|
||
return;
|
||
}
|
||
const lines = normalizePassengerTsvText(value.text).split('\n').filter((line) => line.length > 0);
|
||
const headers = String(lines[0] || '').split('\t');
|
||
if (headers.length !== PASSENGER_HEADERS.length || headers.some((header, index) => header.trim() !== PASSENGER_HEADERS[index])) blockers.push('名单 TSV 必须使用规定的 13 列表头和顺序。');
|
||
if (lines.length - 1 !== value.row_count) blockers.push('data.passenger_list.row_count 与 TSV 数据行数不一致。');
|
||
if (lines.length - 1 > PASSENGER_MAX_TARGET_ROWS) blockers.push(`名单数据行数超过 ${PASSENGER_MAX_TARGET_ROWS} 行技术安全上限。`);
|
||
if (lines.slice(1).some((line) => line.split('\t').length !== PASSENGER_HEADERS.length)) blockers.push('名单 TSV 每一行都必须完整包含 13 列。');
|
||
const sequenceIndex = headers.indexOf('序号');
|
||
const sequences = lines.slice(1).map((line) => Number(String(line.split('\t')[sequenceIndex] || '').trim()));
|
||
if (sequenceIndex < 0 || sequences.some((sequence) => !Number.isInteger(sequence) || sequence < 1)) blockers.push('名单每一行的序号必须是正整数。');
|
||
if (new Set(sequences).size !== sequences.length) blockers.push('名单序号不能重复。');
|
||
if (sequences.some((sequence) => sequence > PASSENGER_MAX_TARGET_ROWS)) blockers.push(`名单序号超过 ${PASSENGER_MAX_TARGET_ROWS} 行技术安全上限。`);
|
||
validatePassengerLeaderContact(value.leader_contact, value.text, blockers);
|
||
}
|
||
|
||
function validateDispatchUpdates(value, action, blockers, warnings = []) {
|
||
if (!isPlainObject(value) || !Array.isArray(value.actions) || !value.actions.length || value.actions.length > 32) {
|
||
blockers.push('data.updates.actions 必须包含 1 至 32 个目标修改。');
|
||
return;
|
||
}
|
||
if (Object.keys(value).some((key) => key !== 'actions')) blockers.push('data.updates 包含未声明字段。');
|
||
const seen = new Set();
|
||
const manualReviewTargets = [];
|
||
value.actions.forEach((item, index) => {
|
||
if (!isPlainObject(item) || Object.keys(item).some((key) => !new Set(['target', 'operation', 'value']).has(key))) {
|
||
blockers.push(`修改动作 ${index + 1} 必须是只含 target、operation、value 的对象。`);
|
||
return;
|
||
}
|
||
const target = String(item.target || '').trim();
|
||
const operation = String(item.operation || '').trim().toLowerCase();
|
||
if (seen.has(target)) blockers.push(`生命周期修改目标不能重复:${target}。`);
|
||
seen.add(target);
|
||
if (!WIDE_UPDATE_TARGETS.has(target)) blockers.push(`无法识别的生命周期修改目标:${target}。`);
|
||
else if (!UPDATE_TARGETS[action]?.has(target)) manualReviewTargets.push(target);
|
||
if (!['set', 'delta', 'append', 'clear'].includes(operation)) blockers.push(`修改目标 ${target} 使用了不支持的 operation:${operation}。`);
|
||
if (operation !== 'clear' && !Object.hasOwn(item, 'value')) blockers.push(`修改目标 ${target} 缺少 value。`);
|
||
const kind = updateTargetKind(target);
|
||
if (operation === 'append' && !['text', 'named_ref'].includes(kind)) blockers.push(`${target} 只能对文本字段使用 append。`);
|
||
if (operation === 'delta' && !['positive_integer', 'nonnegative_integer', 'nonnegative_number'].includes(kind)) blockers.push(`${target} 只能对数值字段使用 delta。`);
|
||
if (operation !== 'clear' && kind) validateUpdateValue(item, target, kind, blockers);
|
||
});
|
||
if (manualReviewTargets.length) warnings.push(`已解析但尚未纳入当前自动写入白名单的字段将转人工复核:${[...new Set(manualReviewTargets)].join('、')}。`);
|
||
}
|
||
|
||
// Original selectors preserve an omitted year. Only ERP records may supply it.
|
||
function isOriginalArrangementDate(value) {
|
||
if (typeof value !== 'string') return false;
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return isValidDate(value);
|
||
return /^\d{2}-\d{2}$/.test(value) && isValidDate(`2000-${value}`);
|
||
}
|
||
|
||
function isOriginalArrangementRange(start, end, allowSameDay = false) {
|
||
if (!isOriginalArrangementDate(start) || !isOriginalArrangementDate(end)) return false;
|
||
// Partial ranges may cross December/January; ordering is checked on the ERP row.
|
||
return start.length !== 10 || end.length !== 10 || (allowSameDay ? end >= start : end > start);
|
||
}
|
||
|
||
function matchesOriginalArrangementDate(actual, selector) {
|
||
return typeof actual === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(actual)
|
||
&& isValidDate(actual) && isOriginalArrangementDate(selector)
|
||
&& (selector.length === 10 ? actual === selector : actual.slice(5) === selector);
|
||
}
|
||
|
||
function validateOriginalArrangementUpdate(value, action, errors) {
|
||
const guide = action === 'arrangement_guide';
|
||
const vehicle = action === 'arrangement_vehicle';
|
||
const other = action === 'arrangement_other';
|
||
const keys = new Set(guide ? ['resource', 'remark'] : ['supplier', 'quantity', 'remark', ...(other ? ['filing'] : [])]);
|
||
if (Object.keys(value).some((key) => !['mode', 'selection', 'changes', ...(vehicle ? ['vehicle_type'] : []), ...(action === 'arrangement_transport' ? ['ticket_description'] : [])].includes(key))) errors.push('安排变更包含未声明字段。');
|
||
const changes = value.changes;
|
||
if (!isPlainObject(changes)) { errors.push('安排变更缺少新的目标值。'); return; }
|
||
if (Object.keys(changes).some((key) => !keys.has(key))) errors.push('安排变更日期仅用于定位,不能修改日期或直接指定联动项目。');
|
||
validateDispatchNamedRef(changes[guide ? 'resource' : 'supplier'], 'arrangement.changes.resource_or_supplier', errors, { required: true });
|
||
if (changes.quantity !== undefined && (!Number.isInteger(changes.quantity) || changes.quantity < 1)) errors.push('安排变更数量必须为正整数。');
|
||
if ((vehicle || action === 'arrangement_transport') && changes.quantity === undefined) errors.push('安排变更缺少数量。');
|
||
if (changes.remark !== undefined && (typeof changes.remark !== 'string' || !changes.remark.trim())) errors.push('安排变更备注必须为非空文本。');
|
||
if (guide) {
|
||
if (value.selection !== undefined) errors.push('导游变更按团号定位唯一已有导游,不接受日期。');
|
||
} else {
|
||
const selection = value.selection;
|
||
const dateKeys = vehicle ? ['start_date', 'end_date'] : ['date', ...(other ? ['filing_date'] : [])];
|
||
if (!isPlainObject(selection) || Object.keys(selection).some((key) => !dateKeys.includes(key))) errors.push('安排变更需要原安排日期。');
|
||
if (vehicle) {
|
||
if (!isOriginalArrangementRange(selection?.start_date, selection?.end_date, true)) errors.push('原用车日期范围无效。');
|
||
} else if (!isOriginalArrangementDate(selection?.date)) errors.push('原业务日期无效。');
|
||
if (selection?.filing_date !== undefined && !isOriginalArrangementDate(selection.filing_date)) errors.push('原备案日期无效。');
|
||
}
|
||
if (changes.filing !== undefined) {
|
||
if (!other || !isPlainObject(changes.filing) || !Object.keys(changes.filing).length
|
||
|| Object.keys(changes.filing).some((key) => !['number', 'entry_port', 'exit_port'].includes(key))
|
||
|| Object.values(changes.filing).some((v) => typeof v !== 'string' || !v.trim())) errors.push('备案变更只允许明确填写的备案号和入出境口岸;日期保持原值。');
|
||
}
|
||
}
|
||
|
||
function validateDispatchArrangement(value, action, blockers) {
|
||
if (!isPlainObject(value)) {
|
||
blockers.push('data.arrangement 必须是对象。');
|
||
return;
|
||
}
|
||
if (value.vehicle_type !== undefined && (action !== 'arrangement_vehicle' || !['create', 'update'].includes(value.mode)
|
||
|| typeof value.vehicle_type !== 'string' || !value.vehicle_type.trim())) blockers.push('用车类型只允许用于安排用车新增/变更,且必须是非空文本。');
|
||
if (value.ticket_description !== undefined && (action !== 'arrangement_transport' || !['create', 'update'].includes(value.mode)
|
||
|| typeof value.ticket_description !== 'string' || !value.ticket_description.trim())) blockers.push('车票说明只允许用于安排大交通新增/变更,且必须是非空文本。');
|
||
if (value.mode === 'update' && action !== 'arrangement_hotel') {
|
||
validateOriginalArrangementUpdate(value, action, blockers);
|
||
return;
|
||
}
|
||
const common = {
|
||
arrangement_guide: new Set(['mode', 'resource', 'remark']),
|
||
arrangement_vehicle: new Set(['mode', 'supplier', 'start_date', 'end_date', 'quantity', 'remark', 'vehicle_type']),
|
||
arrangement_transport: new Set(['mode', 'supplier', 'item', 'quantity', 'date', 'remark', 'ticket_description']),
|
||
arrangement_other: new Set(['mode', 'supplier', 'item', 'quantity', 'date', 'remark', 'filing'])
|
||
};
|
||
const allowed = action === 'arrangement_hotel'
|
||
? value.mode === 'update' ? new Set(['mode', 'changes', 'selection']) : new Set(['mode', 'resource', 'room_type', 'start_date', 'end_date', 'room_count', 'remark'])
|
||
: common[action] || new Set();
|
||
if (Object.keys(value).some((key) => !allowed.has(key))) blockers.push('data.arrangement 包含当前业务未开放或执行态字段。');
|
||
if (action === 'arrangement_hotel' && value.mode === 'update') {
|
||
const changes = value.changes;
|
||
if (!isPlainObject(changes) || !Object.keys(changes).length || Object.keys(changes).some((key) => !new Set(['end_date', 'room_count', 'resource', 'remark']).has(key))) blockers.push('酒店安排 update 只允许非空 changes.end_date/room_count/resource。');
|
||
if (value.selection !== undefined) {
|
||
const selection = value.selection;
|
||
if (!isPlainObject(selection) || Object.keys(selection).some((key) => !['start_date', 'end_date'].includes(key))
|
||
|| !isOriginalArrangementRange(selection.start_date, selection.end_date)) blockers.push('酒店变更必须提供有效的原安排日期。');
|
||
}
|
||
if (changes?.resource !== undefined) {
|
||
validateDispatchNamedRef(changes.resource, 'data.arrangement.changes.resource', blockers, { required: true });
|
||
if (!isPlainObject(value.selection)) blockers.push('更换酒店必须提供原安排的入住和离店日期。');
|
||
}
|
||
if (changes?.end_date !== undefined && !isValidDate(changes.end_date)) blockers.push('changes.end_date 必须是有效的 YYYY-MM-DD 日期。');
|
||
if (changes?.room_count !== undefined && (!Number.isInteger(changes.room_count) || changes.room_count < 1)) blockers.push('changes.room_count 必须是正整数。');
|
||
if (value.changes?.remark !== undefined && (typeof value.changes.remark !== 'string' || !value.changes.remark.trim())) blockers.push('变更备注必须为非空文本。');
|
||
return;
|
||
}
|
||
if (value.mode !== 'create') blockers.push(`${action} 当前只允许 create${action === 'arrangement_hotel' ? ' 或已开放的 update' : ''}。`);
|
||
if (action === 'arrangement_guide' || action === 'arrangement_hotel') validateDispatchNamedRef(value.resource, 'data.arrangement.resource', blockers, { required: true });
|
||
if (action === 'arrangement_vehicle') {
|
||
validateDispatchNamedRef(value.supplier, 'data.arrangement.supplier', blockers, { required: true });
|
||
if (!isValidDate(value.start_date) || !isValidDate(value.end_date)) blockers.push('车辆用车起止日期必须是有效的 YYYY-MM-DD 日期。');
|
||
if (isValidDate(value.start_date) && isValidDate(value.end_date) && value.end_date < value.start_date) blockers.push('车辆用车结束日期不能早于开始日期。');
|
||
if (!Number.isInteger(value.quantity) || value.quantity < 1) blockers.push('data.arrangement.quantity 必须是正整数。');
|
||
} else if (action === 'arrangement_transport') {
|
||
validateDispatchNamedRef(value.supplier, 'data.arrangement.supplier', blockers, { required: true });
|
||
// item/车票说明由结算单位统一选择框的首条候选自动带入。
|
||
if (value.item !== undefined && (typeof value.item !== 'string' || !value.item.trim())) blockers.push('data.arrangement.item 如提供必须是非空字符串。');
|
||
if (!Number.isInteger(value.quantity) || value.quantity < 1) blockers.push('data.arrangement.quantity 必须是正整数。');
|
||
} else if (action === 'arrangement_other') {
|
||
validateDispatchNamedRef(value.supplier, 'data.arrangement.supplier', blockers, { required: true });
|
||
// 项目由结算单位候选的原生联动带入;数量由业务系统默认值承接,用户可选填。
|
||
if (value.item !== undefined && (typeof value.item !== 'string' || !value.item.trim())) blockers.push('data.arrangement.item 如提供必须是非空字符串。');
|
||
if (value.quantity !== undefined && (!Number.isInteger(value.quantity) || value.quantity < 1)) blockers.push('data.arrangement.quantity 如提供必须是正整数。');
|
||
if (!isValidDate(value.date)) blockers.push('其他/备案业务日期必须是有效的 YYYY-MM-DD 日期。');
|
||
}
|
||
if (action === 'arrangement_hotel') {
|
||
if (value.room_type !== undefined && (typeof value.room_type !== 'string' || !value.room_type.trim())) blockers.push('data.arrangement.room_type 如提供必须是非空字符串。');
|
||
if (!isValidDate(value.start_date) || !isValidDate(value.end_date)) blockers.push('酒店入住/离店日期必须是有效的 YYYY-MM-DD 日期。');
|
||
if (isValidDate(value.start_date) && isValidDate(value.end_date) && value.end_date <= value.start_date) blockers.push('酒店离店日期必须晚于入住日期。');
|
||
if (!Number.isInteger(value.room_count) || value.room_count < 1) blockers.push('data.arrangement.room_count 必须是正整数。');
|
||
}
|
||
if (value.date !== undefined && !isValidDate(value.date)) blockers.push('data.arrangement.date 必须是有效的 YYYY-MM-DD 日期。');
|
||
if (value.remark !== undefined && (typeof value.remark !== 'string' || !value.remark.trim())) blockers.push('data.arrangement.remark 必须是非空字符串。');
|
||
if (value.filing !== undefined) {
|
||
const filing = value.filing;
|
||
const allowedFiling = new Set(['number', 'date', 'entry_port', 'exit_port']);
|
||
if (!isPlainObject(filing) || !Object.keys(filing).length || Object.keys(filing).some((key) => !allowedFiling.has(key))) blockers.push('data.arrangement.filing 必须是非空标准备案对象。');
|
||
if (filing?.date !== undefined && !isValidDate(filing.date)) blockers.push('data.arrangement.filing.date 必须是有效的 YYYY-MM-DD 日期。');
|
||
}
|
||
}
|
||
|
||
function validateDispatchExport(data, blockers) {
|
||
const hasConfirmation = data.confirmation !== undefined;
|
||
const hasTypes = data.export_types !== undefined;
|
||
if (hasConfirmation === hasTypes) blockers.push('导出团队文件必须且只能提供 confirmation 或 export_types 之一。');
|
||
let types = [];
|
||
if (hasConfirmation) {
|
||
if (!isPlainObject(data.confirmation) || Object.keys(data.confirmation).some((key) => key !== 'type')) blockers.push('data.confirmation 只允许标准 type。');
|
||
else types = [data.confirmation.type];
|
||
}
|
||
if (hasTypes) {
|
||
if (!Array.isArray(data.export_types) || !data.export_types.length || new Set(data.export_types).size !== data.export_types.length) blockers.push('data.export_types 必须是非空且不重复的数组。');
|
||
else types = data.export_types;
|
||
}
|
||
const unsupported = types.filter((type) => !EXPORT_TYPES.has(type));
|
||
if (unsupported.length) blockers.push(`包含不支持的文件类型:${unsupported.join('、')}。`);
|
||
if (types.includes('pickup-sign') && (typeof data.visitor_name !== 'string' || !data.visitor_name.trim())) blockers.push('接机牌必须提供游客姓名。');
|
||
}
|
||
|
||
// A batch is an envelope of ordinary hotel creates, never an ERP form.
|
||
function expandHotelBatch(operation) {
|
||
const data = operation?.data;
|
||
if (!data || !Object.hasOwn(data, 'arrangements')) return null;
|
||
if (operation.action !== 'arrangement_hotel' || !Array.isArray(data.arrangements)
|
||
|| data.arrangements.length < 2 || data.arrangements.length > 10
|
||
|| Object.keys(data).some(key => !['existing_refs', 'arrangements'].includes(key))) {
|
||
throw new Error('批量酒店只支持同团 2—10 条新增安排,不能混用其他字段。');
|
||
}
|
||
const seen = new Set();
|
||
return data.arrangements.map((arrangement, index) => {
|
||
if (!isPlainObject(arrangement) || arrangement.mode !== 'create') throw new Error(`第 ${index + 1} 条必须为酒店新增。`);
|
||
const key = [arrangement.start_date, arrangement.end_date,
|
||
String(arrangement.resource?.keyword || arrangement.resource?.name || '').normalize('NFKC').replace(/\s+/gu, '').toUpperCase(), arrangement.room_count].join('|');
|
||
if (seen.has(key)) throw new Error(`第 ${index + 1} 条酒店安排重复,请核对后重新发送整批。`);
|
||
seen.add(key);
|
||
return { ...operation, data: { existing_refs: data.existing_refs, arrangement } };
|
||
});
|
||
}
|
||
|
||
function validateHotelBatch(operation) {
|
||
let items;
|
||
try { items = expandHotelBatch(operation); }
|
||
catch (error) { return { ok: false, action: operation.action, execution: 'blocked', blockers: [error.message], warnings: [] }; }
|
||
const plans = items.map(validateDispatchOperation);
|
||
const blockers = plans.flatMap((plan, index) => plan.blockers.map(message => `第 ${index + 1} 条:${message}`));
|
||
return { ...plans[0], ok: !blockers.length, execution: blockers.length ? 'blocked' : 'browser_hotel_batch', blockers };
|
||
}
|
||
|
||
function validateDispatchOperation(operation = {}) {
|
||
const normalized = normalizeOperation(operation);
|
||
if (normalized?.data && Object.hasOwn(normalized.data, 'arrangements')) return validateHotelBatch(normalized);
|
||
const action = actionForOperation(normalized);
|
||
const definition = OPERATION_DEFINITIONS[action] || null;
|
||
if (!definition) return {
|
||
ok: false,
|
||
action,
|
||
definition: null,
|
||
execution: 'blocked',
|
||
blockers: [`不支持的 ERP 业务类型:${normalized.action || normalized.operation || '未填写'}。`],
|
||
warnings: [],
|
||
requiresErpResolution: false
|
||
};
|
||
|
||
// Historical lifecycle probes and test cleanup already carry a complete
|
||
// execution envelope. Preserve their stricter contract instead of
|
||
// reinterpreting them as Agent parse-state operations.
|
||
if ((normalized.source?.test_context !== undefined && (LIFECYCLE_ACTIONS.has(action) || action === 'confirmation_export')) || action === 'order_delete' || action === 'order_update') {
|
||
return { ...validateOperation(normalized), phase: 'dispatch', requiresErpResolution: false };
|
||
}
|
||
if (['team_order_create', 'team_order_batch_create', 'shared_plan_create'].includes(action)) {
|
||
return { ...validateOperation(normalized), phase: 'dispatch', requiresErpResolution: false };
|
||
}
|
||
|
||
const blockers = [];
|
||
const warnings = [];
|
||
const data = normalized.data;
|
||
if (!isPlainObject(normalized) || Object.keys(normalized).some((key) => !DISPATCH_OPERATION_KEYS.has(key))) blockers.push('operation 包含解析态未声明字段。');
|
||
if (normalized.order_nature !== 'formal') blockers.push('order_nature 必须为 formal。');
|
||
if (normalized.submit_mode !== 'dry_run') blockers.push('submit_mode 必须为 dry_run。');
|
||
if (!isPlainObject(data)) blockers.push('operation.data 必须是对象。');
|
||
const allowedData = DISPATCH_DATA_KEYS[action];
|
||
if (isPlainObject(data) && allowedData && Object.keys(data).some((key) => !allowedData.has(key))) blockers.push('operation.data 包含当前业务未声明字段。');
|
||
if (isPlainObject(data) && data.special_requests !== undefined && (typeof data.special_requests !== 'string' || !data.special_requests.trim())) blockers.push('data.special_requests 必须是非空字符串。');
|
||
|
||
if (isPlainObject(data) && action === 'shared_child_order_create') {
|
||
validateDispatchExistingRefs(data.existing_refs, 'data.existing_refs', blockers, {
|
||
required: false,
|
||
requireIdentifier: false,
|
||
requiredKind: data.existing_refs === undefined ? '' : 'shared_plan'
|
||
});
|
||
if (isPlainObject(data.existing_refs) && data.existing_refs.identifier
|
||
&& data.existing_refs.parent_group_no !== undefined
|
||
&& data.existing_refs.parent_group_no !== data.existing_refs.identifier) blockers.push('母团团号同时写入 identifier 和 parent_group_no 时必须一致。');
|
||
validateDispatchNamedRef(data.customer, 'data.customer', blockers, { required: true });
|
||
validatePassengerCounts(data.passenger_counts, blockers);
|
||
validateDispatchBusinessLookup(data, blockers);
|
||
} else if (isPlainObject(data) && action === 'shared_child_order_batch_create') {
|
||
validateDispatchNamedRef(data.customer, 'data.customer', blockers, { required: true });
|
||
validateDispatchNamedRef(data.product, 'data.product', blockers, { required: true });
|
||
validateDispatchNamedRef(data.leader, 'data.leader', blockers);
|
||
validateRecurrenceRange(data.recurrence, 'data.recurrence', blockers);
|
||
validatePassengerCounts(data.passenger_counts, blockers);
|
||
warnings.push('批量散拼子单先按日期范围逐日只读枚举所有产品匹配的母团,完整锁定目标后再按顺序逐单创建;遇到首个异常立即停止且不自动重试。');
|
||
} else if (isPlainObject(data) && action === 'passenger_list_import') {
|
||
validateDispatchExistingRefs(data.existing_refs, 'data.existing_refs', blockers, {
|
||
requireIdentifier: false,
|
||
requireKind: true,
|
||
allowedKinds: new Set(['independent_order', 'shared_child_order'])
|
||
});
|
||
validateDispatchBusinessLookup(data, blockers);
|
||
validateDispatchPassengerList(data.passenger_list, blockers);
|
||
} else if (isPlainObject(data) && ARRANGEMENT_ACTIONS.has(action)) {
|
||
validateDispatchExistingRefs(data.existing_refs, 'data.existing_refs', blockers, { allowedKinds: new Set(['independent_order', 'shared_plan']) });
|
||
validateDispatchArrangement(data.arrangement, action, blockers);
|
||
} else if (isPlainObject(data) && UPDATE_ACTIONS.has(action)) {
|
||
const requiredKind = {
|
||
order_update_independent: 'independent_order',
|
||
order_update_shared_plan: 'shared_plan',
|
||
order_update_shared_child: 'shared_child_order'
|
||
}[action];
|
||
validateDispatchExistingRefs(data.existing_refs, 'data.existing_refs', blockers, {
|
||
requireIdentifier: false,
|
||
requireKind: true,
|
||
requiredKind
|
||
});
|
||
validateDispatchBusinessLookup(data, blockers, { customerRequired: action !== 'order_update_shared_plan' });
|
||
validateDispatchUpdates(data.updates, action, blockers, warnings);
|
||
} else if (isPlainObject(data) && (action === 'order_cancel' || action === 'order_restore')) {
|
||
validateDispatchExistingRefs(data.existing_refs, 'data.existing_refs', blockers, { required: false, requireIdentifier: false });
|
||
validateDispatchBusinessLookup(data, blockers);
|
||
if (action === 'order_cancel' && data.transition !== undefined) blockers.push('取消解析态不接受状态迁移细节。');
|
||
if (action === 'order_restore' && data.transition !== undefined) {
|
||
if (!isPlainObject(data.transition) || Object.keys(data.transition).some((key) => key !== 'to_status') || !['预订', '已确认', '收客中'].includes(data.transition.to_status)) blockers.push('恢复目标状态只允许预订、已确认或收客中。');
|
||
}
|
||
} else if (isPlainObject(data) && action === 'confirmation_export') {
|
||
validateDispatchExistingRefs(data.existing_refs, 'data.existing_refs', blockers, { required: false, requireIdentifier: false });
|
||
validateDispatchBusinessLookup(data, blockers);
|
||
validateDispatchExport(data, blockers);
|
||
validateSharedPlanVisitorExport(data, blockers);
|
||
}
|
||
|
||
const requiresErpResolution = ERP_RESOLUTION_ACTIONS.has(action);
|
||
return {
|
||
ok: blockers.length === 0,
|
||
action,
|
||
definition,
|
||
execution: blockers.length ? 'blocked' : definition.execution,
|
||
blockers: [...new Set(blockers)],
|
||
warnings,
|
||
identifier: identifierForOperation(normalized, data),
|
||
exportTypes: action === 'confirmation_export' ? exportTypesForOperation(normalized, data) : [],
|
||
noErpWrite: action === 'confirmation_export',
|
||
test_only: Boolean(definition.testOnly),
|
||
phase: 'dispatch',
|
||
requiresErpResolution
|
||
};
|
||
}
|
||
|
||
function validateCreate(operation, action, data, blockers, warnings) {
|
||
// Both independent-team create paths require an explicit booking customer.
|
||
// The single-order ERP form exposes this as a searchable SelectBox; leaving
|
||
// it implicit lets the product template silently overwrite the user choice.
|
||
validateNamedRef(data.customer, 'data.customer', blockers, { required: true });
|
||
validateNamedRef(data.product, 'data.product', blockers, { required: true });
|
||
if (data.prices !== undefined) blockers.push('新增业务不允许输出 prices,由 ERP 产品模板同步价格。');
|
||
if (data.op_user !== undefined) blockers.push('新增业务不允许输出 op_user,由 ERP 登录默认值解析。');
|
||
if (data.sales_user !== undefined) blockers.push('新增业务不允许输出 sales_user,由 ERP 登录默认值解析。');
|
||
validatePassengerCounts(data.passenger_counts, blockers);
|
||
validateTeamRooms(data.room_counts, blockers);
|
||
|
||
if (action === 'team_order_create') {
|
||
validateDates(data.departure_dates, 'departure_dates', blockers, { required: true, min: 1, max: 1 });
|
||
warnings.push('单个团队单必须先唯一匹配预订客户;点选产品后必须点击 ERP“更新行程”,再复核客户、团号设置和行程联动字段。');
|
||
return;
|
||
}
|
||
|
||
validateDates(data.departure_dates, 'departure_dates', blockers, { required: true, min: 2 });
|
||
if (!isPlainObject(data.recurrence)
|
||
|| !isValidDate(data.recurrence.start_date)
|
||
|| !isValidDate(data.recurrence.end_date)) {
|
||
blockers.push('team_order_batch_create 必须包含有效的 recurrence.start_date/end_date 日期范围。');
|
||
} else {
|
||
const start = Date.parse(`${data.recurrence.start_date}T00:00:00Z`);
|
||
const end = Date.parse(`${data.recurrence.end_date}T00:00:00Z`);
|
||
if (start > end) blockers.push('recurrence.start_date 不能晚于 recurrence.end_date。');
|
||
if (Array.isArray(data.departure_dates) && data.departure_dates.some((date) => {
|
||
const current = Date.parse(`${date}T00:00:00Z`);
|
||
return !Number.isFinite(current) || current < start || current > end;
|
||
})) blockers.push('departure_dates 必须全部落在 recurrence 日期范围内。');
|
||
}
|
||
warnings.push('批量任务使用已验证的 ERP 原生 orders_adds.asp/DoInfoJHs 单表提交;客户和产品必须在当前 ERP 上下文中唯一匹配。');
|
||
}
|
||
|
||
function validateCommonOperation(operation, data, blockers) {
|
||
if (operation.order_nature !== 'formal') blockers.push('order_nature 必须为 formal。');
|
||
if (operation.submit_mode !== 'dry_run') blockers.push('submit_mode 必须保持 dry_run;真实保存由业务系统确认、执行编号和插件安全门控制。');
|
||
if (!isPlainObject(data)) {
|
||
blockers.push('operation.data 必须是对象。');
|
||
return;
|
||
}
|
||
const allowedTopLevel = new Set(['action', 'order_nature', 'submit_mode', 'source', 'data']);
|
||
if (Object.keys(operation).some((key) => !allowedTopLevel.has(key))) blockers.push('operation 包含未声明字段。');
|
||
if (Object.keys(data).some((key) => !DATA_KEYS.has(key))) blockers.push('operation.data 包含未声明字段。');
|
||
if (data.customer !== undefined) validateNamedRef(data.customer, 'data.customer', blockers);
|
||
if (data.route !== undefined) validateNamedRef(data.route, 'data.route', blockers);
|
||
if (operation.source !== undefined && (!isPlainObject(operation.source) || Object.keys(operation.source).some((key) => !SOURCE_KEYS.has(key)))) {
|
||
blockers.push('operation.source 包含未声明字段。');
|
||
}
|
||
}
|
||
|
||
function validateOperation(operation = {}) {
|
||
const action = actionForOperation(operation);
|
||
const definition = OPERATION_DEFINITIONS[action] || null;
|
||
const data = operation.data;
|
||
const blockers = [];
|
||
const warnings = [];
|
||
|
||
if (!definition) {
|
||
return {
|
||
ok: false,
|
||
action,
|
||
definition: null,
|
||
execution: 'blocked',
|
||
blockers: [`不支持的 ERP 业务类型:${operation.action || operation.operation || '未填写'}。`],
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
validateCommonOperation(operation, data, blockers);
|
||
if (!isPlainObject(data)) {
|
||
return {
|
||
ok: false,
|
||
action,
|
||
definition,
|
||
execution: 'blocked',
|
||
blockers,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
if (action === 'team_order_create' || action === 'team_order_batch_create') {
|
||
validateCreate(operation, action, data, blockers, warnings);
|
||
} else if (action === 'shared_plan_create') {
|
||
validateNamedRef(data.product, 'data.product', blockers, { required: true });
|
||
validateSplitOrder(data.split_order, blockers);
|
||
validateSharedBatchSplitOrderProbe(operation, data, blockers, warnings);
|
||
// Accept the old flat envelope at this boundary as well; the runtime
|
||
// normalizer maps it into split_order before browser execution.
|
||
for (const key of ['customer', 'passenger_counts']) {
|
||
if (data[key] !== undefined && data.split_order?.[key] !== undefined
|
||
&& JSON.stringify(data[key]) !== JSON.stringify(data.split_order[key])) {
|
||
blockers.push(`data.${key} 与 data.split_order.${key} 冲突,只能保留一个一致值。`);
|
||
}
|
||
}
|
||
if (data.customer !== undefined) validateNamedRef(data.customer, 'data.customer', blockers);
|
||
if (data.passenger_counts !== undefined) validatePassengerCounts(data.passenger_counts, blockers);
|
||
if (data.prices !== undefined) blockers.push('新增业务不允许输出 prices,由 ERP 产品模板同步价格。');
|
||
if (data.op_user !== undefined) blockers.push('新增业务不允许输出 op_user,由 ERP 登录默认值解析。');
|
||
if (data.sales_user !== undefined) blockers.push('新增业务不允许输出 sales_user,由 ERP 登录默认值解析。');
|
||
const recurrence = data.recurrence || {};
|
||
const hasDateRange = recurrence.start_date && recurrence.end_date;
|
||
if (data.departure_dates !== undefined) validateDates(data.departure_dates, 'departure_dates', blockers);
|
||
if (data.recurrence !== undefined && (!isPlainObject(data.recurrence) || !isValidDate(data.recurrence.start_date) || !isValidDate(data.recurrence.end_date))) blockers.push('recurrence 必须包含有效的 start_date/end_date。');
|
||
if ((!Array.isArray(data.departure_dates) || !data.departure_dates.length) && !hasDateRange) blockers.push('缺少母团出发日期或日期范围。');
|
||
if (!Number.isInteger(data.planned_capacity) || data.planned_capacity <= 0) blockers.push('计划收客数必须是正整数。');
|
||
validateTeamRooms(data.room_counts, blockers);
|
||
if (toNumber(data.groups_per_date || 1) <= 0) blockers.push('每天母团计划数必须大于 0。');
|
||
if (toNumber(data.groups_per_date || 1) !== 1) blockers.push('当前母团浏览器执行器每天只允许一个计划。');
|
||
warnings.push('散拼新增计划按 ERP 原生顺序填写日期范围、产品、计划收客数、房型和周期;客户和人数属于 ERP 选填字段,用户提供时分别写入,未提供时保持页面默认/空值。');
|
||
} else if (action === 'shared_child_order_create') {
|
||
validateNamedRef(data.customer, 'data.customer', blockers, { required: true });
|
||
validatePassengerCounts(data.passenger_counts, blockers);
|
||
const parentGroupNo = data.parent_group_no || data.existing_refs?.parent_group_no || '';
|
||
if (!parentGroupNo && !(data.product?.name && Array.isArray(data.departure_dates) && data.departure_dates.length)) {
|
||
blockers.push('缺少母团/子单已有编号,或产品与出发日期。');
|
||
}
|
||
if (String(parentGroupNo).includes('TEST-202609') && !String(data.special_requests || '').includes('TEST-202609')) {
|
||
blockers.push('测试母团下的子单必须在 special_requests 中保留 TEST-202609 标记。');
|
||
}
|
||
if (data.departure_dates !== undefined) validateDates(data.departure_dates, 'departure_dates', blockers, { min: 1, max: 1 });
|
||
warnings.push('散拼子单使用已验证的浏览器提交路径;优先按母团团号、tid 和单一日期锁定唯一母团,再唯一匹配客户并回查具体子单引用。');
|
||
} else if (action === 'shared_child_order_batch_create') {
|
||
validateNamedRef(data.customer, 'data.customer', blockers, { required: true });
|
||
validateNamedRef(data.product, 'data.product', blockers, { required: true });
|
||
validateNamedRef(data.leader, 'data.leader', blockers);
|
||
validateRecurrenceRange(data.recurrence, 'data.recurrence', blockers);
|
||
validatePassengerCounts(data.passenger_counts, blockers);
|
||
validateSharedChildBatchTargets(data.batch_targets, data.recurrence, blockers);
|
||
if (Array.isArray(data.batch_targets)
|
||
&& data.batch_targets.some((target) => String(target?.parent_group_no || '').includes(TEST_MARKER))
|
||
&& !String(data.special_requests || '').includes(TEST_MARKER)) {
|
||
blockers.push(`测试母团下的批量子单必须在 special_requests 中保留 ${TEST_MARKER} 标记。`);
|
||
}
|
||
const resolution = data.resolution;
|
||
if (!isPlainObject(resolution)
|
||
|| resolution.stage !== 'erp_readonly'
|
||
|| resolution.target !== 'batch'
|
||
|| resolution.target_kind !== 'shared_plan'
|
||
|| resolution.write_attempted !== false
|
||
|| resolution.candidate_count !== data.batch_targets?.length) {
|
||
blockers.push('data.resolution 必须证明全部母团已在 ERP 中完成只读解析。');
|
||
}
|
||
if (data.prices !== undefined) blockers.push('新增业务不允许输出 prices,由 ERP 表单默认值处理。');
|
||
if (data.op_user !== undefined) blockers.push('新增业务不允许输出 op_user,由 ERP 登录默认值解析。');
|
||
if (data.sales_user !== undefined) blockers.push('新增业务不允许输出 sales_user,由 ERP 登录默认值解析。');
|
||
warnings.push('批量散拼子单使用预解析的完整母团目标清单顺序执行;每个母团只创建一个子单,首个失败或不确定结果后停止且不会自动重试。');
|
||
} else if (action === 'passenger_list_import') {
|
||
const testContext = validateOptionalLifecycleTestContext(operation, blockers);
|
||
validateLifecycleRefs(data, blockers, action, testContext);
|
||
const attachments = Array.isArray(data.attachments) ? data.attachments : [];
|
||
const passengerList = data.passenger_list;
|
||
const plan = data.updates || {};
|
||
const actions = Array.isArray(plan?.actions) ? plan.actions : [];
|
||
if (!attachments.length && !passengerList && !hasTravelerAction(actions)) blockers.push('缺少旅客名单附件或 passenger_list 描述。');
|
||
if (attachments.some((item) => !isPlainObject(item) || typeof item.name !== 'string' || !item.name.trim())) blockers.push('旅客名单附件必须是带有非空 name 的对象数组。');
|
||
if (passengerList) validatePassengerList(passengerList, blockers, { testMode: Boolean(testContext) });
|
||
if (passengerList && (!Number.isInteger(data.existing_refs?.expected_passenger_count)
|
||
|| data.existing_refs.expected_passenger_count < 1)) {
|
||
blockers.push('名单导入必须从 ERP 当前订单读取正整数 existing_refs.expected_passenger_count 作为初始行安全基线。');
|
||
}
|
||
if (actions.length && actions.some((item) => !/^travelerList(?:\.|$)/i.test(String(item?.target || ''))
|
||
&& String(item?.operation || '').toLowerCase() !== 'traveler_upsert')) {
|
||
blockers.push('旅客名单任务不能混入未声明的非旅客修改动作。');
|
||
}
|
||
warnings.push('名单导入按用户给出的正整数唯一序号安全补录;ERP 当前游客行只是合并基线,高序号不作为业务上限,未指定行保持不变,覆盖已有内容必须显式确认。');
|
||
} else if (ARRANGEMENT_ACTIONS.has(action)) {
|
||
const testContext = validateOptionalLifecycleTestContext(operation, blockers);
|
||
validateLifecycleRefs(data, blockers, action, testContext);
|
||
validateArrangement(data.arrangement, action, blockers, { testMode: Boolean(testContext) });
|
||
warnings.push('安排 action 按导游、车辆、酒店、大交通、其他/备案边界分别适配;酒店新增状态为未安排,其他安排状态为未确认;资源必须唯一匹配,不触发采购、付款、通知或外部发送。');
|
||
} else if (UPDATE_ACTIONS.has(action)) {
|
||
const testContext = validateOptionalLifecycleTestContext(operation, blockers);
|
||
validateLifecycleRefs(data, blockers, action, testContext);
|
||
const receivableOperation = data.receivable_fixture?.operation;
|
||
if (receivableOperation === 'clear_generated_zero') {
|
||
if (data.updates !== undefined) blockers.push('clear_generated_zero 不得同时修改其他业务字段。');
|
||
} else if (['add', 'clear'].includes(receivableOperation)) {
|
||
if (data.updates !== undefined) blockers.push('应收款 add/clear 必须作为独立操作执行,不得同时修改住宿备注或其他业务字段。');
|
||
} else validateLifecycleUpdates(data.updates, action, data.existing_refs, blockers, warnings, { testMode: Boolean(testContext) });
|
||
if (data.receivable_fixture !== undefined && !testContext) blockers.push('应收款 fixture 只属于显式测试上下文,不是生产用户输入能力。');
|
||
validateReceivableFixture(data.receivable_fixture, blockers, action);
|
||
warnings.push('修改 action 只允许业务可见字段;写入前后必须保留业务字段快照,隐藏 ID、session、审计和系统计算字段由页面维护。');
|
||
} else if (action === 'order_cancel' || action === 'order_restore') {
|
||
const testContext = validateOptionalLifecycleTestContext(operation, blockers);
|
||
validateLifecycleRefs(data, blockers, action, testContext);
|
||
validateTransition(data.transition, action, data.existing_refs, blockers, { testMode: Boolean(testContext) });
|
||
if (data.receivable_fixture !== undefined && !testContext) blockers.push('应收款 fixture 只属于显式测试上下文,不是生产用户输入能力。');
|
||
validateReceivableFixture(data.receivable_fixture, blockers, action);
|
||
warnings.push(action === 'order_cancel'
|
||
? '取消同时覆盖编辑页状态与列表级 JH_set_quxiao 基线;如存在测试应收款,只允许在测试上下文中清零,不代表真实结算。'
|
||
: '恢复使用独立 order_restore action,不以修改或删除替代;恢复后仍必须做列表/详情回查。');
|
||
} else if (action === 'order_delete') {
|
||
const testContext = validateOptionalLifecycleTestContext(operation, blockers, { required: true, requireAllowlist: true });
|
||
validateLifecycleRefs(data, blockers, action, testContext);
|
||
validateDeleteGuard(data.delete_guard, blockers, { existingRefs: data.existing_refs, context: testContext });
|
||
validateReceivableFixture(data.receivable_fixture, blockers, action);
|
||
warnings.push('删除仅用于本次运行创建且带测试标记、归属 AI 测试账号的对象;导出和证据留存完成前不得进入删除阶段,删除后必须回查。');
|
||
} else if (action === 'order_update') {
|
||
addMissing(blockers, '缺少已有订单编号。', identifierForOperation(operation, data));
|
||
const plan = data.updates;
|
||
const actions = Array.isArray(plan?.actions) ? plan.actions : [];
|
||
if (!actions.length && !data.updates) blockers.push('缺少结构化修改内容。');
|
||
if (actions.some((item) => !item || !item.target || !item.operation)) blockers.push('修改动作必须包含 target 和 operation。');
|
||
const unsupportedTargets = actions.filter((item) => !isSupportedUpdateTarget(item?.target));
|
||
if (unsupportedTargets.length) blockers.push(`包含未接入的修改目标:${unsupportedTargets.map((item) => item.target).join('、')}。`);
|
||
if (actions.some((item) => /^prices\.|^pax\./.test(String(item?.target || '')))) {
|
||
warnings.push('价格/人数跨路线更新仍属于 optional/unverified,本次只记录计划,不承诺等价执行。');
|
||
}
|
||
warnings.push('当前只做订单定位和修改预览,实际编辑页提交仍需独立预检与显式授权。');
|
||
} else if (action === 'confirmation_export') {
|
||
const testContext = validateOptionalLifecycleTestContext(operation, blockers);
|
||
addMissing(blockers, '缺少已有订单编号。', confirmationIdentifier(data));
|
||
if (!testContext && (data.existing_refs?.resolved !== true || data.existing_refs?.resolution_source !== 'erp_unique_match')) blockers.push('confirmation_export 必须先在 ERP 中唯一解析具体业务对象。');
|
||
if (!['independent_order', 'shared_plan', 'shared_child_order'].includes(data.existing_refs?.kind)) blockers.push('confirmation_export 必须声明 existing_refs.kind=independent_order、shared_plan 或 shared_child_order。');
|
||
if (data.existing_refs?.kind === 'shared_child_order' && !(data.existing_refs.child_order_no || data.existing_refs.ddid || data.existing_refs.child_did)) blockers.push('散拼导出必须引用具体子单编号或 ddid。');
|
||
if (data.existing_refs?.kind === 'shared_child_order' && !(data.existing_refs.parent_group_no || data.existing_refs.parent_plan_no || data.existing_refs.plan_no)) blockers.push('散拼子单导出必须同时提供母团号/计划号,用于在 ERP 列表中唯一定位并回查具体子单。');
|
||
const exportTypes = exportTypesForOperation(operation, data);
|
||
validateSharedPlanVisitorExport(data, blockers, { execution: true });
|
||
const unsupported = exportTypes.filter((type) => !EXPORT_TYPES.has(type));
|
||
if (!exportTypes.length) blockers.push('缺少标准确认件类型。');
|
||
if (unsupported.length) blockers.push(`包含不支持的确认件类型:${unsupported.join('、')}。`);
|
||
if (isPlainObject(data.confirmation) && Object.keys(data.confirmation).some((key) => key !== 'type')) {
|
||
blockers.push('confirmation_export 的 confirmation 只允许 canonical type;语言、格式及交付选项尚未开放。');
|
||
}
|
||
if (data.confirmation?.format !== undefined) blockers.push('导出团队文件按类型固定交付(游客信息保留源 XLS 并由平台生成 XLSX,AgentBus/微信只返回 XLSX;其余类型为 PDF),不接受用户指定其他 format。');
|
||
if (exportTypes.includes('pickup-sign') && typeof data.visitor_name !== 'string' && !data.existing_refs?.visitor_name) {
|
||
blockers.push('pickup-sign 必须提供 data.visitor_name 或 existing_refs.visitor_name。');
|
||
}
|
||
const recovery = data.recovery || {};
|
||
const exportOnly = recovery.export_only;
|
||
const neverResave = recovery.never_resave;
|
||
if (exportOnly && neverResave !== true) {
|
||
blockers.push('恢复导出任务必须同时声明 neverResave=true。');
|
||
}
|
||
warnings.push('导出路径读取 ERP 源文件并上传到平台后台附件;游客信息保留源 XLS 并由平台生成移动端兼容 XLSX(AgentBus/微信只返回 XLSX),其余类型转换为 PDF,转换失败回退源文件;插件不会重保存订单、定时或外发。');
|
||
}
|
||
|
||
const execution = definition.execution;
|
||
|
||
return {
|
||
ok: blockers.length === 0,
|
||
action,
|
||
definition,
|
||
execution: blockers.length ? 'blocked' : execution,
|
||
blockers,
|
||
warnings,
|
||
identifier: identifierForOperation(operation, data),
|
||
exportTypes: action === 'confirmation_export' ? exportTypesForOperation(operation, data) : [],
|
||
noErpWrite: execution === 'browser_lifecycle'
|
||
? false
|
||
: !['browser_team_single', 'browser_team_batch_native', 'browser_live_split_parent', 'browser_live_split_child', 'browser_live_split_child_batch'].includes(execution),
|
||
test_only: Boolean(definition.testOnly),
|
||
};
|
||
}
|
||
|
||
function expandTeamBatch(operation = {}) {
|
||
const data = operation.data || {};
|
||
const dates = Array.isArray(data.departure_dates) ? data.departure_dates : [];
|
||
return dates.map((date) => ({
|
||
...operation,
|
||
action: 'team_order_create',
|
||
data: (() => {
|
||
return { ...data, departure_dates: [dateYyyyMD(date)] };
|
||
})(),
|
||
}));
|
||
}
|
||
|
||
function publicPlan(result = {}) {
|
||
return {
|
||
action: result.action || '',
|
||
label: result.definition?.label || '',
|
||
route: result.definition?.route || '',
|
||
execution: result.execution || 'blocked',
|
||
native_status: result.definition?.nativeStatus || '',
|
||
fallback: result.definition?.fallback || '',
|
||
dry_run_adapter: result.definition?.dryRunAdapter || '',
|
||
test_only: Boolean(result.definition?.testOnly),
|
||
contract_version: CONTRACT_VERSION,
|
||
capability_state: result.definition?.capabilityState || '',
|
||
identifier: result.identifier || '',
|
||
export_types: Array.isArray(result.exportTypes) ? result.exportTypes : [],
|
||
no_erp_write: result.noErpWrite !== false,
|
||
blockers: Array.isArray(result.blockers) ? result.blockers : [],
|
||
warnings: Array.isArray(result.warnings) ? result.warnings : [],
|
||
validation_phase: result.phase || 'execution',
|
||
requires_erp_resolution: result.requiresErpResolution === true,
|
||
};
|
||
}
|
||
|
||
function operationSummary(operation = {}) {
|
||
const action = actionForOperation(operation);
|
||
const definition = OPERATION_DEFINITIONS[action] || {};
|
||
const data = operation.data || {};
|
||
const splitOrder = isPlainObject(data.split_order) ? data.split_order : {};
|
||
return {
|
||
action,
|
||
label: definition.label || action || '未知 ERP 业务',
|
||
route: definition.route || '',
|
||
capability_state: definition.capabilityState || '',
|
||
product: data.product?.name || '',
|
||
customer: data.customer?.name || splitOrder.customer?.name || '',
|
||
departure_dates: Array.isArray(data.departure_dates) ? data.departure_dates : [],
|
||
departure_range: data.recurrence ? {
|
||
start: data.recurrence.start_date || '',
|
||
end: data.recurrence.end_date || ''
|
||
} : undefined,
|
||
identifier: identifierForOperation(operation, data),
|
||
};
|
||
}
|
||
|
||
function isSupportedUpdateTarget(target = '') {
|
||
const value = String(target || '').trim();
|
||
const canonical = canonicalUpdateTarget(value);
|
||
return WIDE_UPDATE_TARGETS.has(canonical)
|
||
|| value === 'remark'
|
||
|| value === 'xiadanbeizhu'
|
||
|| /^rooms\.(SGL|TWN|TRP|DBL|HNM|TL)$/i.test(value)
|
||
|| /^pax\.(adult|child_bed|childBed|child_no_bed|childNoBed|infant|leader)$/i.test(value)
|
||
|| /^prices\.(adult|child_bed|childBed|child_no_bed|childNoBed|infant|leader)$/i.test(value)
|
||
|| /^travelerList(?:\.|$)/i.test(value);
|
||
}
|
||
|
||
function canonicalUpdateTarget(target = '') {
|
||
const value = String(target || '').replace(/\.(childBed|childNoBed)$/i, (match, key) => `.${key.toLowerCase() === 'childbed' ? 'child_bed' : 'child_no_bed'}`);
|
||
return value.replace(/^rooms\.(sgl|twn|trp|dbl|hnm|tl)$/i, (match, key) => `rooms.${key.toUpperCase()}`);
|
||
}
|
||
|
||
function clone(value) {
|
||
if (value === undefined) return undefined;
|
||
return JSON.parse(JSON.stringify(value));
|
||
}
|
||
|
||
function snapshotValue(snapshot = {}, target = '') {
|
||
if (target === 'remark' || target === 'xiadanbeizhu') return snapshot.remark || '';
|
||
const match = String(target).match(/^(rooms|pax|prices)\.(.+)$/);
|
||
if (!match) return undefined;
|
||
return snapshot[match[1]]?.[match[2]];
|
||
}
|
||
|
||
function setSnapshotValue(snapshot, target, value) {
|
||
const match = String(target).match(/^(rooms|pax|prices)\.(.+)$/);
|
||
if (target === 'remark' || target === 'xiadanbeizhu') {
|
||
snapshot.remark = String(value ?? '');
|
||
return;
|
||
}
|
||
if (!match) return;
|
||
if (!snapshot[match[1]]) snapshot[match[1]] = {};
|
||
snapshot[match[1]][match[2]] = value;
|
||
}
|
||
|
||
function applyUpdatePlanToSnapshot(updatePlan = {}, currentSnapshot = {}) {
|
||
const snapshot = clone(currentSnapshot) || {};
|
||
const actions = Array.isArray(updatePlan.actions) ? updatePlan.actions : [];
|
||
const changes = [];
|
||
for (const action of actions) {
|
||
const target = canonicalUpdateTarget(String(action?.target || '').trim());
|
||
const operation = String(action?.operation || '').trim().toLowerCase();
|
||
if (!isSupportedUpdateTarget(target) || /^travelerList(?:\.|$)/i.test(target)) {
|
||
return { status: 'blocked', reason: `unsupported_update_target:${target}` };
|
||
}
|
||
const before = snapshotValue(snapshot, target);
|
||
let after;
|
||
if (operation === 'set') {
|
||
after = target === 'remark' || target === 'xiadanbeizhu' ? String(action.value ?? '') : toNumber(action.value);
|
||
} else if (operation === 'delta') {
|
||
if (before === undefined || before === null || before === '') return { status: 'blocked', reason: `delta_baseline_missing:${target}` };
|
||
after = toNumber(before) + toNumber(action.value);
|
||
} else if (operation === 'append') {
|
||
if (target !== 'remark' && target !== 'xiadanbeizhu') return { status: 'blocked', reason: `append_target_invalid:${target}` };
|
||
after = [String(before || '').trim(), String(action.value || '').trim()].filter(Boolean).join('\n');
|
||
} else {
|
||
return { status: 'blocked', reason: `unsupported_update_operation:${operation}` };
|
||
}
|
||
if (['rooms', 'pax', 'prices'].includes(String(target).split('.')[0]) && after < 0) {
|
||
return { status: 'blocked', reason: `negative_update_result:${target}` };
|
||
}
|
||
setSnapshotValue(snapshot, target, after);
|
||
changes.push({ target, operation, before: clone(before), after: clone(after) });
|
||
}
|
||
return { status: 'ready', snapshot, changes };
|
||
}
|
||
|
||
function buildUpdatePreview(operation = {}, currentSnapshot = {}) {
|
||
const data = operation.data || {};
|
||
const updatePlan = data.updates || {};
|
||
const applied = applyUpdatePlanToSnapshot(updatePlan, currentSnapshot);
|
||
return {
|
||
status: applied.status,
|
||
identifier: identifierForOperation(operation, data),
|
||
changes: applied.changes || [],
|
||
snapshot: applied.snapshot,
|
||
write_targets: (applied.changes || []).map((item) => item.target),
|
||
no_erp_write: true,
|
||
reason: applied.reason || ''
|
||
};
|
||
}
|
||
|
||
function buildConfirmationExportPlan(operation = {}, record = {}, basePath = '/System/Business') {
|
||
const data = operation.data || {};
|
||
const types = exportTypesForOperation(operation, data);
|
||
const ddid = String(record.ddid || record.did || data.existing_refs?.ddid || '').trim();
|
||
const tid = String(record.tid || data.existing_refs?.tid || '').trim();
|
||
const identifier = confirmationIdentifier(data);
|
||
const artifacts = types.map((type) => {
|
||
let artifact;
|
||
if (type === 'xingyou-confirm') artifact = { type, path: `${basePath}/orders_confirm_news.asp`, params: { did: ddid, tid } };
|
||
else if (type === 'liantai-confirm') artifact = { type, path: `${basePath}/orders_confirm_new.asp`, params: { did: ddid, tid } };
|
||
else if (type === 'job-order') artifact = { type, path: `${basePath}/teams_beian1.asp`, params: { did: ddid } };
|
||
else if (type === 'visitor-list') artifact = {
|
||
type,
|
||
path: `${basePath}/orders_Visitor.asp`,
|
||
params: data.existing_refs?.kind === 'shared_plan' ? { tid } : { did: ddid, tid }
|
||
};
|
||
else if (type === 'guide-confirm') artifact = { type, path: `${basePath}/teams_confirms_new.asp`, params: { tid } };
|
||
else if (type === 'hotel-preorder') artifact = { type, path: `${basePath}/teams_hotel_confirm.asp`, params: { tid } };
|
||
else if (type === 'transport-preorder') artifact = { type, path: `${basePath}/teams_piao_confirm.asp`, params: { tid } };
|
||
else if (type === 'filing-current') artifact = { type, path: `${basePath}/teams_beian.asp`, params: { did: ddid } };
|
||
else if (type === 'filing-history') artifact = { type, path: `${basePath}/teams_beian1.asp`, params: { did: ddid } };
|
||
else if (type === 'pickup-sign') artifact = { type, path: `${basePath}/teams_confirm_pai.asp`, params: { xingming: data.visitor_name || data.existing_refs?.visitor_name || '' } };
|
||
else artifact = { type, path: '', params: {} };
|
||
return { ...artifact, downloaded: false, converted: false, scheduled: false, sent: false };
|
||
});
|
||
return {
|
||
identifier,
|
||
target_kind: data.existing_refs?.kind || '',
|
||
export_only: true,
|
||
never_resave: true,
|
||
downloaded: false,
|
||
converted: false,
|
||
scheduled: false,
|
||
sent: false,
|
||
delivery_scope: 'backend_task_attachment',
|
||
backend_stored: false,
|
||
types,
|
||
artifacts,
|
||
ready_for_browser_lookup: Boolean(identifier),
|
||
no_erp_write: true
|
||
};
|
||
}
|
||
|
||
return {
|
||
isOriginalArrangementDate,
|
||
isOriginalArrangementRange,
|
||
matchesOriginalArrangementDate,
|
||
OPERATION_DEFINITIONS,
|
||
ARRANGEMENT_ACTIONS,
|
||
CONTRACT_VERSION,
|
||
actionForOperation,
|
||
dateYyyyMD,
|
||
expandTeamBatch,
|
||
applyUpdatePlanToSnapshot,
|
||
buildConfirmationExportPlan,
|
||
buildPassengerTsv,
|
||
buildUpdatePreview,
|
||
isSupportedUpdateTarget,
|
||
PASSENGER_HEADERS,
|
||
targetDatesForLifecycle,
|
||
LIFECYCLE_ACTIONS,
|
||
ERP_RESOLUTION_ACTIONS,
|
||
expandHotelBatch,
|
||
normalizeOperation,
|
||
operationSummary,
|
||
publicPlan,
|
||
TEST_MARKER,
|
||
validateDispatchOperation,
|
||
validateOperation,
|
||
};
|
||
}));
|