Files
2026-07-13 19:57:46 +08:00

594 lines
27 KiB
JavaScript
Raw Permalink 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 OPERATION_DEFINITIONS = {
team_order_create: {
label: '团队-单个下单',
route: 'team_single',
execution: 'browser_team_single',
liveSubmit: 'test-only',
source: 'root-browser-validated'
},
team_order_batch_create: {
label: '团队-批量下单',
route: 'team_batch',
execution: 'browser_team_single_fallback',
nativeStatus: 'deferred',
fallback: 'team_single_per_date',
source: 'handoff-fallback-rule'
},
shared_plan_create: {
label: '散拼-创建母团计划',
route: 'split_parent',
execution: 'browser_live_split_parent',
liveSubmit: 'test-only',
source: 'handoff-browser-validated'
},
shared_child_order_create: {
label: '散拼-拼单/录入子单',
route: 'split_child',
execution: 'browser_live_split_child',
liveSubmit: 'test-only',
source: 'handoff-browser-validated'
},
order_update: {
label: '已有订单更新',
route: 'update_order',
execution: 'browser_dry_run',
dryRunAdapter: 'inspect_existing_order_update',
supportedTargets: ['rooms', 'remark', 'pax', 'prices'],
source: 'handoff-update-rule'
},
passenger_list_import: {
label: '旅客名单导入/补充',
route: 'update_order',
execution: 'browser_dry_run',
dryRunAdapter: 'inspect_existing_order_traveler',
supportedTargets: ['travelerList'],
source: 'handoff-traveler-rule'
},
confirmation_export: {
label: '确认件导出/恢复',
route: 'export_confirmation',
execution: 'browser_export_source',
dryRunAdapter: 'inspect_confirmation_sources',
source: 'handoff-browser-validated'
}
};
const ACTION_ALIASES = {
create_order: {
team_single: 'team_order_create',
team_batch: 'team_order_batch_create',
split_parent: 'shared_plan_create',
split_child: 'shared_child_order_create'
},
update_order: 'order_update',
export_confirmation: 'confirmation_export'
};
const EXPORT_TYPE_ALIASES = {
confirmation: 'xingyou-confirm',
confirm: 'xingyou-confirm',
xingyou: 'xingyou-confirm',
'xingyou-confirm': 'xingyou-confirm',
liantai: 'liantai-confirm',
'liantai-confirm': 'liantai-confirm',
job: 'job-order',
beian: 'job-order',
'job-order': 'job-order',
};
function toNumber(value) {
const normalized = String(value ?? '').replace(/[,]/g, '').trim();
const number = Number(normalized);
return Number.isFinite(number) ? number : 0;
}
function dateYyyyMD(value) {
if (typeof value !== 'string') return String(value || '');
const match = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (!match) return value;
return `${match[1]}-${Number(match[2])}-${Number(match[3])}`;
}
function compactDate(value) {
const match = String(value || '').match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (!match) return String(value || '').replace(/-/g, '');
return `${match[1]}${match[2].padStart(2, '0')}${match[3].padStart(2, '0')}`;
}
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();
if (OPERATION_DEFINITIONS[direct]) return direct;
const legacy = String(operation.operation || '').trim();
if (legacy === 'create_order') {
return ACTION_ALIASES.create_order[String(operation.route || '').trim()] || '';
}
return ACTION_ALIASES[legacy] || '';
}
function identifierForOperation(operation = {}, data = {}) {
if (operation.identifier) return String(operation.identifier).trim();
const refs = data.existing_refs || {};
return String(
refs.order_no
|| refs.group_no
|| refs.child_order_no
|| refs.parent_group_no
|| refs.identifier
|| ''
).trim();
}
function exportTypesForOperation(operation = {}, data = {}) {
const source = Array.isArray(operation.exportTypes) && operation.exportTypes.length
? operation.exportTypes
: (Array.isArray(data.exportTypes || data.export_types) && (data.exportTypes || data.export_types).length
? (data.exportTypes || data.export_types)
: [data.confirmation?.type || 'xingyou-confirm']);
return [...new Set(source.map((value) => {
const key = String(value || '').trim().toLowerCase();
return EXPORT_TYPE_ALIASES[key] || key;
}))];
}
function normalizePax(value = {}) {
if (typeof value === 'number' || typeof value === 'string') return { adult: toNumber(value) };
return {
adult: toNumber(value.adult),
child_bed: toNumber(value.child_bed ?? value.childBed),
child_no_bed: toNumber(value.child_no_bed ?? value.childNoBed),
infant: toNumber(value.infant),
leader: toNumber(value.leader),
expected_total: value.expected_total ?? value.expectedTotal,
};
}
function normalizeCreateFields(fields = {}) {
const departureDates = Array.isArray(fields.departureDates)
? fields.departureDates
: (fields.departureDate ? [fields.departureDate] : []);
const orderNature = fields.orderNature || fields.order_nature || '';
const parentGroupNo = fields.parentGroupNo || fields.parent_group_no || '';
const productName = fields.productName || fields.product || fields.productRoute || '';
const bookingCustomer = fields.bookingCustomer || fields.channelCustomer || fields.customer || '';
const pax = fields.pax || fields.passengerCounts || fields.defaultPax || {};
return {
order_mode: fields.orderMode || fields.order_mode || '',
product: {
name: productName,
route: fields.productRoute || fields.route || productName,
cpid: fields.cpid || ''
},
customer: {
name: bookingCustomer,
id: fields.customerId || fields.bookingCustomerId || '',
contact: fields.contact || fields.bookingContact || '',
origin: fields.customerOrigin || fields.origin || ''
},
route: { name: fields.route || fields.productRoute || productName },
departure_dates: departureDates,
passenger_counts: normalizePax(pax),
room_counts: fields.rooms || fields.roomCounts || fields.defaultRooms || {},
prices: fields.prices || {},
op_user: { name: fields.op || fields.opUser || fields.followOp || fields.follow_op || '' },
sales_user: { name: fields.salesperson || fields.salesUser || '' },
special_requests: fields.remark || fields.specialRequests || '',
test_marker: fields.testMarker || '',
order_nature: orderNature,
parent_group_no: parentGroupNo,
child_order_no: fields.childOrderNo || fields.child_order_no || '',
planned_capacity: fields.plannedGuests || fields.plannedPax || fields.planned_capacity || 0,
groups_per_date: fields.parentPlansPerDate || fields.groupsPerDate || fields.groups_per_date || 1,
cycle: fields.cycle || '',
date_adjustments: fields.dateAdjustments || fields.adjustments || {},
supplemental: fields.supplemental || {},
system_defaults: fields.system_defaults || fields.systemDefaults || {},
};
}
function updateActionsFromSource(source = {}) {
const plan = source.updatePlan || source.update_plan || source.data?.updatePlan || {};
if (Array.isArray(plan.actions)) return plan.actions;
if (Array.isArray(source.actions)) return source.actions;
if (Array.isArray(source.data?.updates?.actions)) return source.data.updates.actions;
return [];
}
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 && typeof input.task === 'object' ? input.task : input;
const directAction = String(source.action || '').trim();
if (OPERATION_DEFINITIONS[directAction]) return source;
const operation = String(source.operation || '').trim();
if (operation === 'create_order') {
const data = normalizeCreateFields(source.fields || {});
return {
...source,
action: ACTION_ALIASES.create_order[String(source.route || '').trim()] || '',
order_nature: data.order_nature || source.orderNature || '',
submit_mode: source.submit_mode || 'dry_run',
data: {
...data,
original_text: source.originalText || source.original_text || '',
attachments: Array.isArray(source.attachments) ? source.attachments : [],
},
originalText: source.originalText || source.original_text || '',
};
}
if (operation === 'update_order') {
const actions = updateActionsFromSource(source);
const action = hasTravelerAction(actions) && actions.every((item) => /^travelerList(?:\.|$)/i.test(String(item?.target || ''))
|| String(item?.operation || '').toLowerCase() === 'traveler_upsert')
? 'passenger_list_import'
: 'order_update';
return {
...source,
action,
identifier: source.identifier || source.data?.existing_refs?.identifier || '',
data: {
...(source.data || {}),
existing_refs: {
...(source.data?.existing_refs || {}),
identifier: source.identifier || source.data?.existing_refs?.identifier || ''
},
updates: source.updatePlan || source.data?.updates || { actions },
updatePlan: source.updatePlan || source.data?.updatePlan || { actions },
attachments: Array.isArray(source.attachments) ? source.attachments : [],
},
};
}
if (operation === 'export_confirmation') {
return {
...source,
action: 'confirmation_export',
exportTypes: Array.isArray(source.exportTypes) && source.exportTypes.length
? source.exportTypes
: (Array.isArray(source.data?.exportTypes || source.data?.export_types)
? (source.data.exportTypes || source.data.export_types)
: undefined),
identifier: source.identifier || source.data?.existing_refs?.identifier || '',
data: {
...(source.data || {}),
existing_refs: {
...(source.data?.existing_refs || {}),
identifier: source.identifier || source.data?.existing_refs?.identifier || ''
},
confirmation: {
type: Array.isArray(source.exportTypes) && source.exportTypes.length ? source.exportTypes[0] : 'xingyou-confirm',
},
},
};
}
return source;
}
function addMissing(blockers, label, value) {
if (value === undefined || value === null || String(value).trim() === '') blockers.push(label);
}
function validateCreate(operation, action, data, blockers, warnings) {
const counts = data.passenger_counts || {};
if (!data.product?.name) blockers.push('缺少产品名称。');
if (!data.op_user?.name) blockers.push('缺少计调 OP。');
if (!data.sales_user?.name) blockers.push('缺少销售人。');
if (passengerTotal(counts) <= 0) blockers.push('人数合计必须大于 0。');
if (counts.expected_total != null && toNumber(counts.expected_total) !== passengerTotal(counts)) {
blockers.push('expected_total 与人数合计不一致。');
}
if (action === 'team_order_create') {
if (!Array.isArray(data.departure_dates) || data.departure_dates.length !== 1 || !data.departure_dates[0]) {
blockers.push('团队单必须有且只有一个出发日期。');
}
addMissing(blockers, '缺少 data.test_marker。', data.test_marker);
if (operation.submit_mode !== 'dry_run') {
blockers.push('submit_mode 必须保持 dry_run真实保存由插件安全门单独控制。');
}
if (operation.order_nature !== 'test') {
blockers.push('当前插件自动保存只允许 order_nature=test 的测试订单。');
}
return;
}
if (!Array.isArray(data.departure_dates) || data.departure_dates.length < 2) {
blockers.push('团队批量下单至少需要两个出发日期。');
}
warnings.push('原生 DoInfoJHs 尚未验证;当前业务规则要求按日期使用 team_single fallback。');
if (operation.order_nature !== 'test' || operation.submit_mode !== 'dry_run' || !data.test_marker) {
warnings.push('批量 fallback 当前仅允许带测试性质、dry_run 和测试标记的任务;本任务只生成计划,不写 ERP。');
}
}
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,
};
}
if (action === 'team_order_create' || action === 'team_order_batch_create') {
validateCreate(operation, action, data, blockers, warnings);
} else if (action === 'shared_plan_create') {
addMissing(blockers, '缺少产品名称。', data.product?.name);
if (!Array.isArray(data.departure_dates) || !data.departure_dates.length) blockers.push('缺少母团出发日期。');
if (Array.isArray(data.departure_dates) && data.departure_dates.length !== 1) blockers.push('当前母团浏览器执行器一次只允许一个出发日期。');
if (toNumber(data.planned_capacity) <= 0) blockers.push('计划收客数必须大于 0。');
addMissing(blockers, '缺少跟团/接待 OP。', data.op_user?.name);
addMissing(blockers, '缺少预订客户。', data.customer?.name);
if (toNumber(data.groups_per_date) <= 0) blockers.push('每天母团计划数必须大于 0。');
if (toNumber(data.groups_per_date || 1) !== 1) blockers.push('当前母团浏览器执行器每天只允许一个计划。');
if (operation.order_nature !== 'test' || operation.submit_mode !== 'dry_run' || !data.test_marker) {
blockers.push('散拼母团真实保存只允许带测试性质、dry_run 和测试标记的任务。');
}
warnings.push('散拼母团当前使用已验证的浏览器提交路径,仅允许测试订单。');
} else if (action === 'shared_child_order_create') {
addMissing(blockers, '缺少渠道客户。', data.customer?.name);
if (passengerTotal(data.passenger_counts || {}) <= 0) blockers.push('人数合计必须大于 0。');
addMissing(blockers, '缺少计调 OP。', data.op_user?.name);
addMissing(blockers, '缺少销售人。', data.sales_user?.name);
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 (!Array.isArray(data.departure_dates) || data.departure_dates.length !== 1) blockers.push('子单必须有且只有一个出发日期。');
if (operation.order_nature !== 'test' || operation.submit_mode !== 'dry_run' || !data.test_marker) {
blockers.push('散拼子单真实保存只允许带测试性质、dry_run 和测试标记的任务。');
}
warnings.push('散拼子单当前使用已验证的浏览器提交路径,仅允许测试订单。');
} else if (action === 'order_update') {
addMissing(blockers, '缺少已有订单编号。', identifierForOperation(operation, data));
const plan = operation.updatePlan || data.updatePlan || 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 === 'passenger_list_import') {
addMissing(blockers, '缺少已有订单编号。', identifierForOperation(operation, data));
const attachments = Array.isArray(data.attachments) ? data.attachments : [];
const passengerList = data.passenger_list || data.travelerList;
const plan = operation.updatePlan || data.updatePlan || data.updates || {};
const actions = Array.isArray(plan?.actions) ? plan.actions : [];
if (!attachments.length && !passengerList && !hasTravelerAction(actions)) blockers.push('缺少旅客名单附件或 passenger_list 描述。');
if (actions.length && actions.some((item) => !/^travelerList(?:\.|$)/i.test(String(item?.target || ''))
&& String(item?.operation || '').toLowerCase() !== 'traveler_upsert')) {
blockers.push('旅客名单任务不能混入未声明的非旅客修改动作。');
}
warnings.push('当前只定位订单并校验旅客导入计划;文件解析、粘贴导入和行数回查仍需独立执行器。');
} else if (action === 'confirmation_export') {
addMissing(blockers, '缺少已有订单编号。', identifierForOperation(operation, data));
if (data.route === 'split_parent' || operation.route === 'split_parent') blockers.push('母团计划不能直接导出客户确认件。');
const exportTypes = exportTypesForOperation(operation, data);
const unsupported = exportTypes.filter((type) => !['xingyou-confirm', 'liantai-confirm', 'job-order'].includes(type));
if (unsupported.length) blockers.push(`包含不支持的确认件类型:${unsupported.join('、')}`);
if (operation.recovery?.exportOnly && operation.recovery?.neverResave !== true) {
blockers.push('恢复导出任务必须同时声明 neverResave=true。');
}
warnings.push('导出路径只读取 ERP 源文件并回报响应摘要;插件不会重保存订单,也不会把未落盘文件误报为已交付。');
}
let execution = definition.execution;
if (
action === 'team_order_batch_create'
&& (operation.order_nature !== 'test' || operation.submit_mode !== 'dry_run' || !data.test_marker)
) {
execution = 'planned_only';
}
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: !['browser_team_single', 'browser_team_single_fallback', 'browser_live_split_parent', 'browser_live_split_child'].includes(execution),
};
}
function expandTeamBatch(operation = {}) {
const data = operation.data || {};
const dates = Array.isArray(data.departure_dates) ? data.departure_dates : [];
const marker = String(data.test_marker || 'TEAM-BATCH-TEST').trim();
return dates.map((date, index) => ({
...operation,
action: 'team_order_create',
data: {
...data,
departure_dates: [dateYyyyMD(date)],
test_marker: `${marker}-D${compactDate(date)}`,
},
source: {
...(operation.source || {}),
batch_index: index + 1,
batch_total: dates.length,
},
}));
}
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 || '',
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 : [],
};
}
function operationSummary(operation = {}) {
const action = actionForOperation(operation);
const definition = OPERATION_DEFINITIONS[action] || {};
const data = operation.data || {};
return {
action,
label: definition.label || action || '未知 ERP 业务',
route: definition.route || operation.route || '',
product: data.product?.name || data.productName || '',
departure_dates: Array.isArray(data.departure_dates) ? data.departure_dates : [],
identifier: identifierForOperation(operation, data),
test_marker: data.test_marker || '',
};
}
function isSupportedUpdateTarget(target = '') {
const value = String(target || '').trim();
return 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 = '') {
return String(target || '').replace(/\.(childBed|childNoBed)$/i, (match, key) => `.${key.toLowerCase() === 'childbed' ? 'child_bed' : 'child_no_bed'}`);
}
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 = operation.updatePlan || data.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 = identifierForOperation(operation, data);
const artifacts = types.map((type) => {
if (type === 'xingyou-confirm') return { type, path: `${basePath}/orders_confirm_news.asp`, params: { did: ddid, tid } };
if (type === 'liantai-confirm') return { type, path: `${basePath}/orders_confirm_new.asp`, params: { did: ddid, tid } };
if (type === 'job-order') return { type, path: `${basePath}/teams_beian1.asp`, params: { did: ddid } };
return { type, path: '', params: {} };
});
return {
identifier,
export_only: true,
never_resave: true,
types,
artifacts,
ready_for_browser_lookup: Boolean(identifier),
no_erp_write: true
};
}
return {
OPERATION_DEFINITIONS,
actionForOperation,
dateYyyyMD,
expandTeamBatch,
applyUpdatePlanToSnapshot,
buildConfirmationExportPlan,
buildUpdatePreview,
isSupportedUpdateTarget,
normalizeOperation,
operationSummary,
publicPlan,
validateOperation,
};
}));