Files
LWLT-AIBOT/chrome-extension/ltjt-order-assistant/operation-plans.js
T
2026-09-24 14:43:08 +08:00

1927 lines
123 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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,
};
}));