#!/usr/bin/env node const fs = require('node:fs'); const path = require('node:path'); const orderRegistry = require('./erp_order_registry'); const defaultTaskLock = require('./erp_task_lock'); const travelerLists = require('./erp_traveler_list'); const { validateTaskEnvelope } = require('./erp_task_contract'); const ROOT = path.resolve(__dirname, '..'); const DEFAULT_CONFIG = path.join(ROOT, 'config', 'erp-deployment.example.json'); const DEFAULT_AUDIT_DIR = path.join(ROOT, 'runtime', 'erp-order-entry', 'audit'); const KNOWN_ROUTES = new Set(['team_single', 'team_batch', 'split_parent', 'split_child']); const KNOWN_OPERATIONS = new Set(['create_order', 'update_order', 'export_confirmation']); const DEFAULT_NO_SAVE_BLOCK_WINDOW_MS = 60 * 60 * 1000; const DEFAULT_UPDATE_DUPLICATE_WINDOW_MS = 2 * 60 * 60 * 1000; function timestamp() { return new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14); } function safeFilePart(value) { return String(value || 'task') .replace(/[^a-zA-Z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80) || 'task'; } function resolvePath(filePath, cwd = process.cwd()) { if (!filePath) return ''; return path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); } function readJson(filePath) { const text = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); return JSON.parse(text); } function readConfig(configPath) { const resolved = configPath ? resolvePath(configPath) : DEFAULT_CONFIG; if (!fs.existsSync(resolved)) return { safety: { allowRealSubmit: false } }; return readJson(resolved); } function liveSessionConfig(config = {}) { return (config.browser && config.browser.liveSession) || {}; } function liveSessionEnabled(config = {}) { return Boolean(liveSessionConfig(config).enabled); } function liveSessionLockPath(config = {}, auditDir = DEFAULT_AUDIT_DIR) { const paths = config.paths || {}; const workRoot = paths.workRoot ? resolvePath(paths.workRoot) : path.dirname(auditDir); return path.join(workRoot, 'erp-live-session.lock'); } async function runExecutionHandler(handler, task, handlerOptions, dispatcherOptions, auditDir) { const config = handlerOptions.config || {}; if (!liveSessionEnabled(config)) return handler(task, handlerOptions); const live = liveSessionConfig(config); const taskLock = dispatcherOptions.taskLock || defaultTaskLock; return taskLock.withFileLock( liveSessionLockPath(config, auditDir), () => handler(task, handlerOptions), { timeoutMs: Number(live.lockTimeoutMs || 10 * 60 * 1000), pollMs: Number(live.lockPollMs || 1000), } ); } function normalizeMode(mode) { const normalized = String(mode || 'dry-run').trim().toLowerCase(); if (normalized === 'execute' || normalized === 'dry-run') return normalized; throw new Error(`Unsupported dispatcher mode: ${mode}`); } function normalizeEnvelope(input) { if (!input || typeof input !== 'object') { return { status: 'invalid', task: null, reason: 'invalid_input' }; } if (input.status) return input; if (input.route) { return { status: 'ready', operation: input.operation || 'create_order', route: input.route, task: input, }; } if (input.operation) { return { status: 'ready', operation: input.operation, route: input.route || '', task: input.task || input, }; } return { status: 'invalid', task: null, reason: 'missing_task_operation' }; } function auditFileName(result) { const task = result.task || {}; const taskId = task.taskId || result.taskId || result.route || result.reason || result.status; return `dispatch-${timestamp()}-${safeFilePart(taskId)}.json`; } function writeAudit(result, auditDir) { const targetDir = auditDir || DEFAULT_AUDIT_DIR; fs.mkdirSync(targetDir, { recursive: true }); const auditPath = path.join(targetDir, auditFileName(result)); const payload = { ...result, auditPath }; fs.writeFileSync(auditPath, JSON.stringify(cloneForAudit(payload), null, 2), 'utf8'); return payload; } const TRAVELER_PII_FIELDS = new Set([ 'name', 'chineseName', 'englishName', 'idNo', 'birthDate', 'passportNo', 'phone', 'remark', 'raw', ]); function redactValue(value) { return value ? '[redacted]' : ''; } function sanitizeTravelerRows(rows = []) { if (!Array.isArray(rows)) return rows; return rows.map((row) => { if (!row || typeof row !== 'object') return row; const safe = { ...row }; for (const field of TRAVELER_PII_FIELDS) { if (Object.prototype.hasOwnProperty.call(safe, field)) safe[field] = redactValue(safe[field]); } return safe; }); } function redactTravelerRowInPlace(row) { if (!row || typeof row !== 'object') return row; for (const field of TRAVELER_PII_FIELDS) { if (Object.prototype.hasOwnProperty.call(row, field)) row[field] = redactValue(row[field]); } return row; } function looksLikeTravelerList(value) { return value && typeof value === 'object' && Array.isArray(value.rows) && value.rows.some((row) => row && typeof row === 'object' && ( Object.prototype.hasOwnProperty.call(row, 'passportNo') || Object.prototype.hasOwnProperty.call(row, 'chineseName') || Object.prototype.hasOwnProperty.call(row, 'englishName') )); } function sanitizeAuditNode(value, seen = new Set()) { if (!value || typeof value !== 'object' || seen.has(value)) return value; seen.add(value); if (Array.isArray(value)) { value.forEach((item) => sanitizeAuditNode(item, seen)); return value; } if (looksLikeTravelerList(value)) { Object.assign(value, travelerLists.sanitizeTravelerListForAudit(value)); } redactTravelerRowInPlace(value); if (value.travelerList && typeof value.travelerList === 'object') { value.travelerList = travelerLists.sanitizeTravelerListForAudit(value.travelerList); } if (Array.isArray(value.travelers)) { value.travelers = sanitizeTravelerRows(value.travelers); } for (const item of Object.values(value)) sanitizeAuditNode(item, seen); return value; } function cloneForAudit(value) { const clone = JSON.parse(JSON.stringify(value || {})); return sanitizeAuditNode(clone); } function blocked(reason, customerMessage, extra = {}) { return { status: 'blocked', reason, customerMessage, ...extra, }; } function dispatcherErrorCustomerMessage(operation, route) { if (operation === 'update_order') { return 'ERP 修改未完成,系统已记录原因,请稍后重试或由管理员继续处理。'; } if (operation === 'export_confirmation') { return 'ERP 确认件导出未完成,系统已记录原因,请稍后重试或由管理员继续处理。'; } const label = routeLabel(route); return `ERP 执行未完成(${label}),系统已记录原因,请稍后重试或由管理员继续处理。`; } function routeLabel(route) { return { team_single: 'team single order', team_batch: 'team batch order', split_parent: 'split parent plan', split_child: 'split child order', }[route] || route || 'unknown route'; } function taskOperation(task, envelope = {}) { return task && task.operation ? task.operation : envelope.operation || 'create_order'; } function operationHandlerKey(task, envelope = {}) { const operation = taskOperation(task, envelope); return operation === 'create_order' ? task.route : operation; } function operationLabel(operation, route) { if (operation === 'create_order') return `ERP execution: ${routeLabel(route)}`; return { update_order: 'ERP order update', export_confirmation: 'ERP confirmation export', }[operation] || `ERP operation: ${operation}`; } function orderTextForbidsRealSubmit(task) { const fields = (task && task.fields) || {}; const text = [ task && task.originalText, fields.remark, fields.notes, fields.note, ].filter(Boolean).join('\n'); return /dry[-\s]?run|干跑|只解析|仅解析|不要真实(?:保存|提交|操作)|不(?:要)?真实(?:保存|提交|操作)|不要(?:保存|提交).*ERP|不要.*ERP.*(?:保存|提交|操作)|不要触碰\s*ERP/i.test(text); } function orderTextConfirmsRealSubmit(task) { const fields = (task && task.fields) || {}; const text = [ task && task.originalText, fields.remark, fields.notes, fields.note, ].filter(Boolean).join('\n'); return /confirm\s+real\s+(?:erp\s+)?(?:submit|save)|确认真实(?:保存|提交|操作)\s*ERP|确认(?:保存|提交|操作).*ERP/i.test(text); } function taskTextForSafety(task) { const fields = (task && task.fields) || {}; return [ task && task.originalText, fields.remark, fields.notes, fields.note, ].filter(Boolean).join('\n'); } function normalizeWhitelistValue(value) { return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase(); } function arrayFromConfig(value) { if (!value) return []; if (Array.isArray(value)) return value; if (typeof value === 'string') { return value.split(/[,\n;]/).map((item) => item.trim()).filter(Boolean); } return []; } function configuredWhitelist(config) { const safety = (config && config.safety) || {}; const whitelist = safety.realSubmitWhitelist || safety.realSubmitAllowlist || {}; return { enabled: Boolean(whitelist.enabled), allowedSenders: [ ...arrayFromConfig(whitelist.allowedSenders), ...arrayFromConfig(whitelist.allowedSenderIds), ...arrayFromConfig(whitelist.allowedSenderNames), ...arrayFromConfig(whitelist.allowedWechatIds), ...arrayFromConfig(whitelist.allowedWeChatIds), ], allowedChannels: [ ...arrayFromConfig(whitelist.allowedChannels), ...arrayFromConfig(whitelist.allowedChannelIds), ...arrayFromConfig(whitelist.allowedChatIds), ], authorizationCodes: arrayFromConfig(whitelist.authorizationCodes || whitelist.allowedAuthorizationCodes), allowAuthorizationCodeFallback: whitelist.allowAuthorizationCodeFallback === true, }; } function collectSourceValues(source) { const values = []; const add = (value) => { if (value === undefined || value === null) return; if (Array.isArray(value)) { value.forEach(add); return; } if (typeof value === 'object') return; const normalized = normalizeWhitelistValue(value); if (normalized) values.push(normalized); }; if (typeof source === 'string') { add(source); return [...new Set(values)]; } const scan = (object) => { if (!object || typeof object !== 'object') return; [ 'sender', 'senderId', 'senderName', 'from', 'fromUser', 'fromUserId', 'fromUserName', 'user', 'userId', 'userName', 'author', 'authorId', 'authorName', 'wechatId', 'weChatId', 'wxid', 'openId', 'unionId', 'contactId', 'contactName', 'chatId', 'channelId', 'conversationId', 'roomId', ].forEach((key) => add(object[key])); }; scan(source); scan(source && source.wechat); scan(source && source.weChat); scan(source && source.message); scan(source && source.contact); scan(source && source.channel); return [...new Set(values)]; } function sourceFromEnvelope(envelope, task, options = {}) { return { ...(envelope && envelope.source && typeof envelope.source === 'object' ? envelope.source : {}), ...(task && task.source && typeof task.source === 'object' ? task.source : {}), ...(task && task.metadata && task.metadata.source && typeof task.metadata.source === 'object' ? task.metadata.source : {}), ...(options.source && typeof options.source === 'object' ? options.source : {}), sender: options.sender || (options.source && options.source.sender) || undefined, senderId: options.senderId || (options.source && options.source.senderId) || undefined, senderName: options.senderName || (options.source && options.source.senderName) || undefined, chatId: options.chatId || (options.source && options.source.chatId) || undefined, channelId: options.channelId || (options.source && options.source.channelId) || undefined, }; } function extractAuthorizationCodes(task) { const text = taskTextForSafety(task); const codes = []; const regex = /(?:真实保存授权码|ERP\s*授权码|授权码)\s*[::]\s*([A-Za-z0-9._-]{4,80})/gi; for (const match of text.matchAll(regex)) { codes.push(normalizeWhitelistValue(match[1])); } return [...new Set(codes)]; } function checkRealSubmitWhitelist(task, config, envelope, options = {}) { const whitelist = configuredWhitelist(config); if (!whitelist.enabled) { return { ok: true, enabled: false }; } const allowed = new Set([ ...whitelist.allowedSenders, ...whitelist.allowedChannels, ].map(normalizeWhitelistValue).filter(Boolean)); const sourceValues = collectSourceValues(sourceFromEnvelope(envelope, task, options)); const matchedSource = sourceValues.find((value) => allowed.has(value)); if (matchedSource) { return { ok: true, enabled: true, method: 'sender', matchedSource, sourceValues, }; } const allowedCodes = new Set(whitelist.authorizationCodes.map(normalizeWhitelistValue).filter(Boolean)); const submittedCodes = extractAuthorizationCodes(task); if (whitelist.allowAuthorizationCodeFallback && submittedCodes.some((code) => allowedCodes.has(code))) { return { ok: true, enabled: true, method: 'authorization_code', sourceValues, authorizationCodeProvided: true, }; } return { ok: false, enabled: true, sourceValues, configuredSenderCount: whitelist.allowedSenders.length, configuredChannelCount: whitelist.allowedChannels.length, authorizationCodeProvided: submittedCodes.length > 0, }; } function normalizeIdentityPart(value) { return String(value || '').trim().replace(/\s+/g, '').toLowerCase(); } function uniqueSorted(values) { return [...new Set(values.filter(Boolean).map(String))].sort(); } function extractTaskDates(task) { const fields = (task && task.fields) || {}; if (Array.isArray(fields.departureDates) && fields.departureDates.length) { return uniqueSorted(fields.departureDates); } if (fields.departureDate) return uniqueSorted([fields.departureDate]); if (fields.dateRange && (fields.dateRange.start || fields.dateRange.end)) { return uniqueSorted([`${fields.dateRange.start || ''}..${fields.dateRange.end || ''}`]); } return []; } function taskIdentity(task) { const fields = (task && task.fields) || {}; const customer = normalizeIdentityPart(fields.bookingCustomer || fields.channelCustomer || fields.customer); const product = normalizeIdentityPart(fields.productName || fields.productRoute || fields.routeProduct); const parentGroupNo = normalizeIdentityPart(fields.parentGroupNo); return { route: normalizeIdentityPart(task && task.route), dates: extractTaskDates(task), primary: parentGroupNo || [customer, product].filter(Boolean).join('|') || product || customer, }; } function sameTaskIdentity(leftTask, rightTask) { const left = taskIdentity(leftTask); const right = taskIdentity(rightTask); if (!left.route || left.route !== right.route) return false; if (!left.primary || left.primary !== right.primary) return false; if (left.dates.length !== right.dates.length) return false; return left.dates.every((date, index) => date === right.dates[index]); } function normalizeUpdateText(value) { return String(value || '') .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .split('\n') .map((line) => line.trim().replace(/\s+/g, ' ')) .filter(Boolean) .join('\n'); } function canonicalValue(value) { if (value === null || value === undefined) return value; if (typeof value === 'number') return Number.isFinite(value) ? value : String(value); if (typeof value === 'boolean') return value; if (typeof value === 'string') return value.trim().replace(/\s+/g, ' '); if (Array.isArray(value)) return value.map(canonicalValue); if (typeof value === 'object') { return Object.keys(value).sort().reduce((acc, key) => { acc[key] = canonicalValue(value[key]); return acc; }, {}); } return String(value); } function stableJson(value) { return JSON.stringify(canonicalValue(value)); } function canonicalUpdateActions(updatePlan = {}) { return (Array.isArray(updatePlan.actions) ? updatePlan.actions : []) .map((action) => ({ target: normalizeIdentityPart(action && action.target), operation: normalizeIdentityPart(action && action.operation), value: canonicalValue(action && action.value), })) .sort((left, right) => stableJson(left).localeCompare(stableJson(right))); } function updateTaskFingerprint(task = {}) { const actions = canonicalUpdateActions(task.updatePlan || {}); const payload = { operation: 'update_order', identifier: normalizeIdentityPart(task.identifier), actions, }; if (!actions.length) { payload.updateText = normalizeUpdateText(task.updateText || task.originalText || ''); } return stableJson(payload); } function updateTaskRequestsDuplicateOverride(task = {}) { const text = normalizeUpdateText([ task.updateText, task.originalText, ].filter(Boolean).join('\n')); return /force[_\s-]?duplicate[_\s-]?update|again[_\s-]?same[_\s-]?update|再次执行相同修改|确认再次执行相同修改/i.test(text); } function auditCreatedAtMs(auditPath, payload) { const createdAt = payload && payload.task && payload.task.createdAt; const parsed = createdAt ? Date.parse(createdAt) : NaN; if (Number.isFinite(parsed)) return parsed; try { return fs.statSync(auditPath).mtimeMs; } catch { return 0; } } function findRecentNoSaveBlockForSameOrder(task, auditDir, options = {}) { if (!auditDir || !fs.existsSync(auditDir)) return null; const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); const windowMs = Number.isFinite(options.windowMs) ? options.windowMs : DEFAULT_NO_SAVE_BLOCK_WINDOW_MS; const files = fs.readdirSync(auditDir) .filter((file) => file.endsWith('.json')) .map((file) => path.join(auditDir, file)) .sort((left, right) => { try { return fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs; } catch { return 0; } }) .slice(0, 200); for (const filePath of files) { let payload; try { payload = readJson(filePath); } catch { continue; } if (!payload || payload.status !== 'blocked') continue; if (payload.reason !== 'order_text_forbids_real_submit') continue; if (!payload.task || !sameTaskIdentity(task, payload.task)) continue; const createdAtMs = auditCreatedAtMs(filePath, payload); if (!createdAtMs || nowMs - createdAtMs > windowMs) continue; return { auditPath: payload.auditPath || filePath, createdAt: payload.task.createdAt || new Date(createdAtMs).toISOString(), }; } return null; } function findRecentCompletedUpdateForSameChange(task, auditDir, options = {}) { if (!auditDir || !fs.existsSync(auditDir)) return null; const targetFingerprint = updateTaskFingerprint(task); const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); const windowMs = Number.isFinite(options.windowMs) ? options.windowMs : DEFAULT_UPDATE_DUPLICATE_WINDOW_MS; const files = fs.readdirSync(auditDir) .filter((file) => file.endsWith('.json')) .map((file) => path.join(auditDir, file)) .sort((left, right) => { try { return fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs; } catch { return 0; } }) .slice(0, 300); for (const filePath of files) { let payload; try { payload = readJson(filePath); } catch { continue; } if (!payload || payload.status !== 'completed') continue; if (payload.operation !== 'update_order') continue; if (!payload.task || updateTaskFingerprint(payload.task) !== targetFingerprint) continue; const createdAtMs = auditCreatedAtMs(filePath, payload); if (!createdAtMs || (windowMs >= 0 && nowMs - createdAtMs > windowMs)) continue; return { auditPath: payload.auditPath || filePath, createdAt: payload.task.createdAt || new Date(createdAtMs).toISOString(), identifier: payload.task.identifier || '', }; } return null; } function formatIdentifierLines(identifiers) { if (!identifiers || typeof identifiers !== 'object') return []; if (identifiers.dateToGroupNo) { return Object.entries(identifiers.dateToGroupNo).map(([date, groupNo]) => `- ${date}: ${groupNo}`); } if (identifiers.dateToParentGroupNo) { return Object.entries(identifiers.dateToParentGroupNo).map(([date, groupNo]) => `- ${date}: ${groupNo}`); } if (identifiers.groupNo) return [`- ERP group no: ${identifiers.groupNo}`]; if (identifiers.parentGroupNo || identifiers.childOrderNo) { const lines = []; if (identifiers.parentGroupNo) lines.push(`- Parent group no: ${identifiers.parentGroupNo}`); if (identifiers.childOrderNo) lines.push(`- Child order no: ${identifiers.childOrderNo}`); return lines; } return Object.entries(identifiers).map(([key, value]) => `- ${key}: ${value}`); } function collectWarningMessages(executionResult = {}) { const warnings = []; const add = (items) => { if (!Array.isArray(items)) return; for (const item of items) { const message = item && (item.message || item.customerMessage || item.reason); if (message) warnings.push(String(message)); } }; add(executionResult.warnings); add(executionResult.rawExecutionResult && executionResult.rawExecutionResult.warnings); add(executionResult.rawExecutionResult && executionResult.rawExecutionResult.stageResults && executionResult.rawExecutionResult.stageResults.submit && executionResult.rawExecutionResult.stageResults.submit.warnings); return [...new Set(warnings)]; } function formatCompletionMessage(task, executionResult) { const lines = [ 'ERP execution completed.', `Route: ${routeLabel(task.route)}`, ]; const warningMessages = collectWarningMessages(executionResult); if (warningMessages.length) { lines.push('提醒:'); lines.push(...warningMessages.map((message) => `- ${message}`)); } const identifierLines = formatIdentifierLines(executionResult.identifiers); if (identifierLines.length) { lines.push('Identifiers:'); lines.push(...identifierLines); } if (executionResult.artifacts && executionResult.artifacts.length) { lines.push('PDF files are ready or queued for delivery.'); } return lines.join('\n'); } function defaultHandlers() { const handlers = {}; try { const operationHandlers = require('./erp_operation_handlers'); handlers.update_order = operationHandlers.executeUpdateOrder; handlers.export_confirmation = operationHandlers.executeExportConfirmation; } catch { // Keep dispatcher usable when operation handlers are absent. } try { handlers.team_single = require('./erp_team_single_executor').executeTeamSingle; } catch { // Keep dispatcher usable when optional route executors are absent. } try { handlers.team_batch = require('./erp_team_batch_executor').executeTeamBatch; } catch { // Keep dispatcher usable when optional route executors are absent. } try { handlers.split_parent = require('./erp_split_parent_executor').executeSplitParent; } catch { // Keep dispatcher usable when optional route executors are absent. } try { handlers.split_child = require('./erp_split_child_executor').executeSplitChild; } catch { // Keep dispatcher usable when optional route executors are absent. } return handlers; } async function dispatchTask(input, options = {}) { const mode = normalizeMode(options.mode || 'dry-run'); const config = options.config || {}; const auditDir = options.auditDir || (config.paths && config.paths.auditDir) || DEFAULT_AUDIT_DIR; const handlers = options.handlers || defaultHandlers(); let envelope = normalizeEnvelope(input); let result; try { if (envelope.status !== 'ready') { result = blocked( envelope.status === 'invalid' ? envelope.reason || 'invalid_input' : 'adapter_not_ready', envelope.customerMessage || 'The incoming order is not ready for ERP execution.', { mode, route: envelope.route || null, adapterStatus: envelope.status, } ); return writeAudit(result, auditDir); } const taskValidation = validateTaskEnvelope(envelope); if (!taskValidation.valid) { result = blocked( taskValidation.status === 'needs_clarification' ? 'adapter_not_ready' : 'invalid_task', taskValidation.status === 'needs_clarification' ? 'The incoming order is not ready for ERP execution.' : 'The ERP task JSON is invalid and was not sent to an executor.', { mode, operation: taskValidation.task && taskValidation.task.operation || envelope.operation || null, route: taskValidation.task && taskValidation.task.route || envelope.route || null, task: taskValidation.task, violations: taskValidation.violations, } ); return writeAudit(result, auditDir); } envelope = taskValidation.envelope; const task = taskValidation.task; if (!task) { result = blocked('missing_task', 'The ready envelope is missing a task.', { mode, route: null, operation: envelope.operation || null, task: null, }); return writeAudit(result, auditDir); } const operation = taskOperation(task, envelope); const handlerKey = operationHandlerKey(task, envelope); const route = task.route || envelope.route || null; if (!KNOWN_OPERATIONS.has(operation)) { result = blocked('unsupported_operation', `Unsupported ERP operation: ${operation}.`, { mode, operation, route, task, }); return writeAudit(result, auditDir); } if (operation === 'create_order' && !task.route) { result = blocked('missing_task_route', 'The ready task is missing a route.', { mode, operation, route: null, task, }); return writeAudit(result, auditDir); } if (mode === 'dry-run') { result = { status: 'dry_run', mode, operation, route, plannedExecutor: handlerKey, task, customerMessage: `Task is ready for ${operationLabel(operation, task.route)}.`, }; return writeAudit(result, auditDir); } const allowRealSubmit = Boolean(config.safety && config.safety.allowRealSubmit); if (!allowRealSubmit) { result = blocked('real_submit_disabled', 'Real ERP submit is disabled by config safety.allowRealSubmit.', { mode, operation, route, task, }); return writeAudit(result, auditDir); } if (orderTextForbidsRealSubmit(task)) { result = blocked('order_text_forbids_real_submit', 'The order text forbids real ERP submit. Run dry-run or remove the no-save instruction before execute.', { mode, operation, route, task, }); return writeAudit(result, auditDir); } const whitelistCheck = checkRealSubmitWhitelist(task, config, envelope, options); if (!whitelistCheck.ok) { result = blocked( 'real_submit_sender_not_authorized', '真实保存 ERP 已被白名单拦截:当前微信发送人不在允许名单中。请使用已授权微信号发送,或联系管理员加入白名单。', { mode, operation, route, task, whitelist: whitelistCheck, } ); return writeAudit(result, auditDir); } if (!orderTextConfirmsRealSubmit(task)) { const safety = (config && config.safety) || {}; const windowMs = Number.isFinite(Number(safety.noSaveBlockWindowMinutes)) ? Number(safety.noSaveBlockWindowMinutes) * 60 * 1000 : DEFAULT_NO_SAVE_BLOCK_WINDOW_MS; const recentNoSaveBlock = findRecentNoSaveBlockForSameOrder(task, auditDir, { windowMs }); if (recentNoSaveBlock) { result = blocked( 'recent_no_save_block_for_same_order', 'A recent no-save block exists for the same order. Stop instead of retrying a rewritten execute task; add an explicit real-save confirmation before executing.', { mode, operation, route, task, recentNoSaveBlock, } ); return writeAudit(result, auditDir); } } if (operation === 'update_order' && !updateTaskRequestsDuplicateOverride(task)) { const safety = (config && config.safety) || {}; const windowMs = Number.isFinite(Number(safety.updateDuplicateBlockWindowMinutes)) ? Number(safety.updateDuplicateBlockWindowMinutes) * 60 * 1000 : DEFAULT_UPDATE_DUPLICATE_WINDOW_MS; const recentDuplicateUpdate = findRecentCompletedUpdateForSameChange(task, auditDir, { windowMs }); if (recentDuplicateUpdate) { result = blocked( 'recent_duplicate_update_order', '检测到同一个订单号和相同修改内容近期已经执行成功,已拦截本次重复修改,避免相同增量被执行两次。如确实要再次执行同样修改,请在指令中加入“再次执行相同修改”。', { mode, operation, route, task, recentDuplicateUpdate, } ); return writeAudit(result, auditDir); } } const handler = handlers[handlerKey]; const knownHandlerKey = operation === 'create_order' ? KNOWN_ROUTES.has(task.route) : KNOWN_OPERATIONS.has(operation); if (!knownHandlerKey || typeof handler !== 'function') { result = blocked('executor_missing', `No executor is connected for operation: ${handlerKey}.`, { mode, operation, route, plannedExecutor: handlerKey, task, }); return writeAudit(result, auditDir); } const executionResult = await runExecutionHandler( handler, task, { config, mode, operation }, options, auditDir ); if (executionResult.status === 'blocked') { result = { ...executionResult, mode, operation, route, task, }; return writeAudit(result, auditDir); } const completed = { status: executionResult.status || 'completed', mode, operation, route, task, identifiers: executionResult.identifiers || {}, artifacts: executionResult.artifacts || [], warnings: executionResult.warnings || [], rawExecutionResult: executionResult, }; completed.customerMessage = executionResult.customerMessage || formatCompletionMessage(task, completed); const audited = writeAudit(completed, auditDir); const registryPath = options.registryPath || (config.paths && config.paths.orderRegistry) || ''; if (operation === 'create_order' && registryPath) { audited.registryRecords = orderRegistry.appendOrderRecord(audited, { registryPath }); fs.writeFileSync(audited.auditPath, JSON.stringify(cloneForAudit(audited), null, 2), 'utf8'); } return audited; } catch (error) { const operation = envelope.operation || (envelope.task && envelope.task.operation) || null; const route = envelope.route || (envelope.task && envelope.task.route) || null; result = blocked('dispatcher_error', dispatcherErrorCustomerMessage(operation, route), { mode, operation, route, error: { name: error.name, code: error.code, message: error.message, details: error.details, stack: error.stack, }, }); return writeAudit(result, auditDir); } } function parseArgs(argv) { const args = { task: '', order: '', config: '', auditDir: '', sender: '', senderId: '', senderName: '', chatId: '', channelId: '', attachments: [], mode: 'dry-run', json: false, help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--task') { args.task = argv[index + 1]; index += 1; } else if (arg.startsWith('--task=')) { args.task = arg.slice('--task='.length); } else if (arg === '--order') { args.order = argv[index + 1]; index += 1; } else if (arg.startsWith('--order=')) { args.order = arg.slice('--order='.length); } else if (arg === '--config') { args.config = argv[index + 1]; index += 1; } else if (arg.startsWith('--config=')) { args.config = arg.slice('--config='.length); } else if (arg === '--audit-dir') { args.auditDir = argv[index + 1]; index += 1; } else if (arg.startsWith('--audit-dir=')) { args.auditDir = arg.slice('--audit-dir='.length); } else if (arg === '--sender') { args.sender = argv[index + 1]; index += 1; } else if (arg.startsWith('--sender=')) { args.sender = arg.slice('--sender='.length); } else if (arg === '--sender-id') { args.senderId = argv[index + 1]; index += 1; } else if (arg.startsWith('--sender-id=')) { args.senderId = arg.slice('--sender-id='.length); } else if (arg === '--sender-name') { args.senderName = argv[index + 1]; index += 1; } else if (arg.startsWith('--sender-name=')) { args.senderName = arg.slice('--sender-name='.length); } else if (arg === '--chat-id') { args.chatId = argv[index + 1]; index += 1; } else if (arg.startsWith('--chat-id=')) { args.chatId = arg.slice('--chat-id='.length); } else if (arg === '--channel-id') { args.channelId = argv[index + 1]; index += 1; } else if (arg.startsWith('--channel-id=')) { args.channelId = arg.slice('--channel-id='.length); } else if (arg === '--attachment') { args.attachments.push(argv[index + 1]); index += 1; } else if (arg.startsWith('--attachment=')) { args.attachments.push(arg.slice('--attachment='.length)); } else if (arg === '--mode') { args.mode = argv[index + 1]; index += 1; } else if (arg.startsWith('--mode=')) { args.mode = arg.slice('--mode='.length); } else if (arg === '--json') { args.json = true; } else if (arg === '--help' || arg === '-h') { args.help = true; } } return args; } function helpText() { return [ 'Usage:', ' node tools/erp_task_dispatcher.js --order order.txt [--mode dry-run|execute] [--config config.json] [--sender-id wxid] [--json]', ' node tools/erp_task_dispatcher.js --task adapter-output.json [--mode dry-run|execute] [--config config.json] [--sender-id wxid] [--json]', '', 'Dispatches a normalized WeChat ERP task to the route executor layer.', 'By default this is a dry-run. Real ERP submit requires config.safety.allowRealSubmit=true, an authorized sender when whitelist is enabled, and a connected executor.', ].join('\n'); } function pickKeys(source = {}, keys = []) { const target = {}; for (const key of keys) { if (source[key] !== undefined && source[key] !== null && source[key] !== '') { target[key] = source[key]; } } return target; } function summarizeTravelerImport(importResult = {}) { return pickKeys(importResult, [ 'attempted', 'imported', 'method', 'expectedRows', 'beforeRows', 'beforeFilledRows', 'afterRows', 'afterFilledRows', 'pasteRows', 'reason', ]); } function collectTravelerImports(value, results = [], seen = new Set(), depth = 0) { if (!value || typeof value !== 'object' || depth > 12 || seen.has(value)) return results; seen.add(value); if (value.travelerImport && typeof value.travelerImport === 'object') { results.push(summarizeTravelerImport(value.travelerImport)); } if (Array.isArray(value)) { for (const item of value) collectTravelerImports(item, results, seen, depth + 1); return results; } for (const [key, item] of Object.entries(value)) { if (key === 'task' || key === 'travelerList' || key === 'travelers') continue; collectTravelerImports(item, results, seen, depth + 1); } return results; } function summarizeRegistryRecords(records = []) { if (!Array.isArray(records)) return []; return records.map((record) => pickKeys(record, [ 'operation', 'route', 'identifier', 'groupNo', 'childOrderNo', 'parentGroupNo', 'departureDate', 'ddid', 'did', 'tid', 'parentTid', 'childDid', 'updatedAt', ])); } function summarizeCliResult(result = {}) { const summary = pickKeys(result, [ 'status', 'reason', 'mode', 'operation', 'route', 'plannedExecutor', 'customerMessage', 'auditPath', ]); if (result.identifiers) summary.identifiers = result.identifiers; if (Array.isArray(result.artifacts)) summary.artifacts = result.artifacts; if (Array.isArray(result.warnings)) summary.warnings = result.warnings; const travelerImports = collectTravelerImports(result) .filter((item) => Object.keys(item).length > 0); if (travelerImports.length) summary.travelerImports = travelerImports; const registryRecords = summarizeRegistryRecords(result.registryRecords); if (registryRecords.length) summary.registryRecords = registryRecords; if (result.error) summary.error = pickKeys(result.error, ['name', 'code', 'message']); return summary; } async function loadInputEnvelope(args) { if (args.order) { const adapter = require('./erp_operation_adapter'); const orderPath = resolvePath(args.order); const text = fs.readFileSync(orderPath, 'utf8'); return adapter.handleIncomingMessage(text, { auditDir: args.auditDir ? resolvePath(args.auditDir) : undefined, attachments: (args.attachments || []).map((item) => resolvePath(item)), }); } if (args.task) { return readJson(resolvePath(args.task)); } throw new Error('Missing --order or --task.'); } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help || (!args.order && !args.task)) { console.log(helpText()); process.exitCode = args.help ? 0 : 1; return; } const config = readConfig(args.config); const auditDir = args.auditDir ? resolvePath(args.auditDir) : undefined; const envelope = await loadInputEnvelope(args); const result = await dispatchTask(envelope, { mode: args.mode, config, auditDir, source: { sender: args.sender, senderId: args.senderId, senderName: args.senderName, chatId: args.chatId, channelId: args.channelId, }, }); if (args.json) { console.log(JSON.stringify(summarizeCliResult(result), null, 2)); } else { console.log(result.customerMessage); console.log(`audit: ${result.auditPath}`); } if (result.status === 'blocked') process.exitCode = 2; await new Promise((resolve) => process.stdout.write('', resolve)); process.exit(process.exitCode || 0); } if (require.main === module) { main().catch((error) => { console.error(error.stack || error.message); process.exit(1); }); } module.exports = { KNOWN_ROUTES, KNOWN_OPERATIONS, normalizeEnvelope, readConfig, dispatchTask, formatCompletionMessage, collectWarningMessages, taskOperation, operationHandlerKey, summarizeCliResult, orderTextForbidsRealSubmit, orderTextConfirmsRealSubmit, checkRealSubmitWhitelist, collectSourceValues, extractAuthorizationCodes, findRecentNoSaveBlockForSameOrder, findRecentCompletedUpdateForSameChange, updateTaskFingerprint, updateTaskRequestsDuplicateOverride, loadInputEnvelope, parseArgs, summarizeCliResult, };