(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, }; }));