const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const dispatcher = require('../erp_task_dispatcher'); function tempAuditDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'erp-dispatch-audit-')); } function readyTeamBatchEnvelope() { return { status: 'ready', route: 'team_batch', task: { taskId: 'task-team-batch-1', route: 'team_batch', orderNature: 'test', fields: { bookingCustomer: 'LW customer', productName: 'Laos 6D5N', departureDates: ['2026-07-13', '2026-07-14', '2026-07-15'], defaultPax: { adult: 2, childBed: 1, childNoBed: 1, infant: 0, leader: 0 }, defaultRooms: { SGL: 0, TWN: 0, TRP: 0, DBL: 2, HNM: 0, TL: 0 }, defaultPrices: { adult: 520, childBed: 220, childNoBed: 120, infant: 0, leader: 0 }, op: 'test-op', sales: 'sales-a', }, erpIdentifiers: {}, }, }; } function readyTeamSingleEnvelope() { return { status: 'ready', route: 'team_single', task: { taskId: 'task-team-single-1', route: 'team_single', orderNature: 'test', fields: { productName: 'Laos', departureDate: '2026-07-12', pax: { adult: 2, childBed: 1, childNoBed: 1, infant: 0, leader: 0 }, rooms: { SGL: 0, TWN: 0, TRP: 0, DBL: 2, HNM: 0, TL: 0 }, prices: { adult: 500, childBed: 200, childNoBed: 100, infant: 0, leader: 0 }, op: 'test-op', sales: 'sales-a', }, erpIdentifiers: {}, }, }; } function readySplitParentEnvelope() { return { status: 'ready', route: 'split_parent', task: { taskId: 'task-split-parent-1', route: 'split_parent', orderNature: 'test', fields: { productRoute: 'Laos route Guangdong 8D7N', dateRange: { start: '2026-07-01', end: '2026-07-31' }, cycle: 'weekly-saturday', departureDates: ['2026-07-04', '2026-07-11'], plannedGuests: 10, parentPlansPerDate: 1, followOp: 'test-op', }, erpIdentifiers: {}, }, }; } function readySplitChildEnvelope() { return { status: 'ready', route: 'split_child', task: { taskId: 'task-split-child-1', route: 'split_child', orderNature: 'test', fields: { parentGroupNo: 'LW-260701A-F', departureDate: '2026-07-01', channelCustomer: 'LW customer', pax: { adult: 5, childBed: 0, childNoBed: 0, infant: 0, leader: 0 }, prices: { adult: 500, childBed: 0, childNoBed: 0, infant: 0, leader: 0 }, op: 'test-op', salesperson: 'sales-a', }, erpIdentifiers: {}, }, }; } function withTravelerList(envelope, totalCount = 1) { const cloned = JSON.parse(JSON.stringify(envelope)); cloned.task.fields.travelerList = { totalCount, fieldStatus: { chineseName: true, englishName: true, gender: true, birthDate: true, birthPlace: true, passportNo: true, issueDate: true, expiryDate: true, issuePlace: true, remark: true, }, rows: Array.from({ length: totalCount }, () => ({ chineseName: 'TEST', englishName: 'TEST GUEST', gender: 'X', })), }; return cloned; } test('blocks adapter results that are not ready', async () => { let called = false; const result = await dispatcher.dispatchTask( { status: 'needs_clarification', route: 'team_single', customerMessage: 'missing sales', task: null, }, { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, handlers: { team_single: async () => { called = true; return { status: 'saved' }; }, }, } ); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'adapter_not_ready'); assert.equal(called, false); assert.match(result.customerMessage, /missing sales/); assert.ok(fs.existsSync(result.auditPath)); }); test('blocks invalid ready tasks before invoking a route handler', async () => { let called = false; const result = await dispatcher.dispatchTask( { status: 'ready', operation: 'create_order', task: { operation: 'create_order', fields: { productName: '遇见老挝' }, }, }, { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, handlers: { team_single: async () => { called = true; return { status: 'completed' }; }, }, } ); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'invalid_task'); assert.equal(called, false); assert.ok(Array.isArray(result.violations)); }); test('dry-run accepts ready tasks without calling route handlers', async () => { let called = false; const result = await dispatcher.dispatchTask(readyTeamBatchEnvelope(), { mode: 'dry-run', auditDir: tempAuditDir(), handlers: { team_batch: async () => { called = true; return { status: 'saved' }; }, }, }); assert.equal(result.status, 'dry_run'); assert.equal(result.route, 'team_batch'); assert.equal(called, false); assert.match(result.customerMessage, /ready for ERP execution/i); }); test('formatCompletionMessage includes duplicate warning messages', () => { const message = dispatcher.formatCompletionMessage(readyTeamSingleEnvelope().task, { identifiers: { groupNo: 'LLW-260712A-B' }, warnings: [ { reason: 'same_date_product_order_exists', message: 'ERP already has LLW-260712A-A; continued with a new order.', }, ], }); assert.match(message, /ERP already has LLW-260712A-A/); assert.match(message, /LLW-260712A-B/); }); test('execute mode requires the real-submit safety switch', async () => { let called = false; const result = await dispatcher.dispatchTask(readyTeamBatchEnvelope(), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: false } }, handlers: { team_batch: async () => { called = true; return { status: 'saved' }; }, }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'real_submit_disabled'); assert.equal(called, false); assert.match(result.customerMessage, /real ERP submit is disabled/i); }); test('execute mode blocks when real-submit whitelist is enabled without sender metadata', async () => { let called = false; const result = await dispatcher.dispatchTask(readyTeamBatchEnvelope(), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true, realSubmitWhitelist: { enabled: true, allowedSenderIds: ['wxid-authorized'], }, }, }, handlers: { team_batch: async () => { called = true; return { status: 'saved' }; }, }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'real_submit_sender_not_authorized'); assert.equal(called, false); assert.match(result.customerMessage, /白名单/); }); test('execute mode blocks non-whitelisted senders', async () => { let called = false; const result = await dispatcher.dispatchTask(readyTeamBatchEnvelope(), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true, realSubmitWhitelist: { enabled: true, allowedSenderIds: ['wxid-authorized'], }, }, }, source: { senderId: 'wxid-someone-else', }, handlers: { team_batch: async () => { called = true; return { status: 'saved' }; }, }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'real_submit_sender_not_authorized'); assert.equal(called, false); }); test('execute mode allows whitelisted senders', async () => { let called = false; const result = await dispatcher.dispatchTask(withTravelerList(readyTeamBatchEnvelope()), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true, realSubmitWhitelist: { enabled: true, allowedSenderIds: ['wxid-authorized'], }, }, }, source: { senderId: 'WXID-AUTHORIZED', }, handlers: { team_batch: async () => { called = true; return { status: 'saved' }; }, }, }); assert.equal(result.status, 'saved'); assert.equal(called, true); }); test('execute mode can use configured authorization code fallback', async () => { let called = false; const envelope = withTravelerList(readyTeamBatchEnvelope()); envelope.task.originalText = '备注:确认真实保存 ERP。真实保存授权码:REAL-20260620'; envelope.task.fields.remark = '确认真实保存 ERP。真实保存授权码:REAL-20260620'; const result = await dispatcher.dispatchTask(envelope, { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true, realSubmitWhitelist: { enabled: true, allowedSenderIds: ['wxid-authorized'], allowAuthorizationCodeFallback: true, authorizationCodes: ['real-20260620'], }, }, }, handlers: { team_batch: async () => { called = true; return { status: 'saved' }; }, }, }); assert.equal(result.status, 'saved'); assert.equal(called, true); }); test('execute mode blocks orders whose text explicitly forbids real ERP save', async () => { let called = false; const envelope = readyTeamBatchEnvelope(); envelope.task.originalText = '备注:爱马仕 dry-run 测试,不要真实保存 ERP。'; envelope.task.fields.remark = '爱马仕 dry-run 测试,不要真实保存 ERP。'; const result = await dispatcher.dispatchTask(envelope, { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, handlers: { team_batch: async () => { called = true; return { status: 'saved' }; }, }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'order_text_forbids_real_submit'); assert.equal(called, false); assert.match(result.customerMessage, /forbids real ERP submit/i); }); test('execute mode blocks a rewritten same order after a recent no-save block', async () => { let called = false; const auditDir = tempAuditDir(); const prior = readyTeamBatchEnvelope(); prior.task.originalText = 'remark: dry-run only, do not save ERP'; prior.task.fields.remark = 'dry-run only, do not save ERP'; fs.writeFileSync( path.join(auditDir, 'dispatch-20260619081202-team_batch.json'), JSON.stringify({ status: 'blocked', reason: 'order_text_forbids_real_submit', mode: 'execute', route: 'team_batch', task: prior.task, }), 'utf8' ); const rewritten = readyTeamBatchEnvelope(); rewritten.task.originalText = 'remark: execute test order'; rewritten.task.fields.remark = 'execute test order'; const result = await dispatcher.dispatchTask(rewritten, { mode: 'execute', auditDir, config: { safety: { allowRealSubmit: true } }, handlers: { team_batch: async () => { called = true; return { status: 'saved' }; }, }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'recent_no_save_block_for_same_order'); assert.equal(called, false); assert.match(result.customerMessage, /recent no-save block/i); }); test('execute mode allows a rewritten same order after explicit real-save confirmation', async () => { let called = false; const auditDir = tempAuditDir(); const prior = readyTeamBatchEnvelope(); prior.task.originalText = 'remark: dry-run only, do not save ERP'; prior.task.fields.remark = 'dry-run only, do not save ERP'; fs.writeFileSync( path.join(auditDir, 'dispatch-20260619081202-team_batch.json'), JSON.stringify({ status: 'blocked', reason: 'order_text_forbids_real_submit', mode: 'execute', route: 'team_batch', task: prior.task, }), 'utf8' ); const confirmed = withTravelerList(readyTeamBatchEnvelope()); confirmed.task.originalText = 'remark: execute test order. 确认真实保存 ERP'; confirmed.task.fields.remark = 'execute test order. 确认真实保存 ERP'; const result = await dispatcher.dispatchTask(confirmed, { mode: 'execute', auditDir, config: { safety: { allowRealSubmit: true } }, handlers: { team_batch: async () => { called = true; return { status: 'saved' }; }, }, }); assert.equal(result.status, 'saved'); assert.equal(called, true); }); test('execute mode blocks a duplicate completed update_order task', async () => { let called = false; const auditDir = tempAuditDir(); const updatePlan = { status: 'ready', missingFields: [], actions: [ { target: 'pax.adult', operation: 'delta', value: 1, source: 'adult +1' }, { target: 'rooms.DBL', operation: 'delta', value: 1, source: 'DBL +1' }, ], }; const completedTask = { operation: 'update_order', identifier: 'LW-260813A-B', updateText: 'adult +1\nDBL +1', updatePlan, createdAt: new Date().toISOString(), }; fs.writeFileSync( path.join(auditDir, 'dispatch-20260623160941-completed.json'), JSON.stringify({ status: 'completed', mode: 'execute', operation: 'update_order', task: completedTask, }), 'utf8' ); const result = await dispatcher.dispatchTask({ status: 'ready', operation: 'update_order', task: { ...completedTask, createdAt: new Date().toISOString(), }, }, { mode: 'execute', auditDir, config: { safety: { allowRealSubmit: true } }, handlers: { update_order: async () => { called = true; return { status: 'completed' }; }, }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'recent_duplicate_update_order'); assert.equal(called, false); assert.match(result.customerMessage, /相同修改内容/); assert.doesNotMatch(result.customerMessage, /same changes/i); }); test('execute mode allows create-order routes to run without traveler lists', async () => { let called = false; const result = await dispatcher.dispatchTask(readyTeamSingleEnvelope(), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, handlers: { team_single: async () => { called = true; return { status: 'completed', identifiers: { groupNo: 'LW-260710A-A' } }; }, }, }); assert.equal(result.status, 'completed'); assert.equal(result.route, 'team_single'); assert.equal(called, true); assert.equal(result.identifiers.groupNo, 'LW-260710A-A'); }); test('execute mode does not require traveler lists for split parent plan creation', async () => { let called = false; const result = await dispatcher.dispatchTask(readySplitParentEnvelope(), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, handlers: { split_parent: async () => { called = true; return { status: 'completed', identifiers: { groupNo: 'LW-260704A-A' } }; }, }, }); assert.equal(result.status, 'completed'); assert.equal(called, true); }); test('execute mode calls the injected route handler when safety is enabled', async () => { let receivedTask = null; const result = await dispatcher.dispatchTask(withTravelerList(readyTeamBatchEnvelope()), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, handlers: { team_batch: async (task) => { receivedTask = task; return { status: 'completed', identifiers: { dateToGroupNo: { '2026-07-13': 'LW-260713A-A', '2026-07-14': 'LW-260714A-A', '2026-07-15': 'LW-260715A-A', }, }, artifacts: [ { type: 'pdf', path: 'outputs/LW-260713A-A-confirm.pdf' }, ], }; }, }, }); assert.equal(result.status, 'completed'); assert.equal(receivedTask.route, 'team_batch'); assert.deepEqual(result.identifiers.dateToGroupNo['2026-07-14'], 'LW-260714A-A'); assert.match(result.customerMessage, /2026-07-13: LW-260713A-A/); assert.match(result.customerMessage, /PDF/); }); test('execute mode wraps live-session ERP work in a file lock', async () => { const auditDir = tempAuditDir(); const lockCalls = []; let handlerCalled = false; const result = await dispatcher.dispatchTask(withTravelerList(readyTeamBatchEnvelope()), { mode: 'execute', auditDir, config: { safety: { allowRealSubmit: true }, paths: { workRoot: auditDir }, browser: { liveSession: { enabled: true, lockTimeoutMs: 12345, lockPollMs: 25, }, }, }, taskLock: { withFileLock: async (lockDir, fn, options) => { lockCalls.push({ lockDir, options }); return fn(); }, }, handlers: { team_batch: async () => { handlerCalled = true; return { status: 'completed', identifiers: { dateToGroupNo: { '2026-07-13': 'LW-260713A-A' } } }; }, }, }); assert.equal(result.status, 'completed'); assert.equal(handlerCalled, true); assert.equal(lockCalls.length, 1); assert.match(lockCalls[0].lockDir, /erp-live-session\.lock$/); assert.deepEqual(lockCalls[0].options, { timeoutMs: 12345, pollMs: 25 }); }); test('summarizeCliResult keeps identifiers and traveler import counts without PII', () => { const fullResult = { status: 'completed', mode: 'execute', operation: 'create_order', route: 'team_single', customerMessage: 'ERP execution completed.', identifiers: { groupNo: 'LLW-271116A-A' }, auditPath: 'runtime/audit/dispatch.json', task: { fields: { travelerList: { rows: [{ chineseName: '张三', passportNo: 'E12345678' }], }, }, }, rawExecutionResult: { stageResults: { submit: { rowText: 'contains 张三 and E12345678', state: { travelerImport: { attempted: true, imported: true, method: 'erp_textarea_paste', expectedRows: 17, beforeRows: 17, beforeFilledRows: 0, afterRows: 17, afterFilledRows: 17, reason: '', dialogFrame: { frame: { secret: 'playwright-frame' } }, }, }, }, }, }, registryRecords: [{ operation: 'create_order', route: 'team_single', identifier: 'LLW-271116A-A', groupNo: 'LLW-271116A-A', departureDate: '2027-11-16', ddid: '14151', customer: 'PII customer not needed in CLI summary', }], }; const summary = dispatcher.summarizeCliResult(fullResult); const text = JSON.stringify(summary); assert.equal(summary.status, 'completed'); assert.equal(summary.identifiers.groupNo, 'LLW-271116A-A'); assert.equal(summary.travelerImports[0].afterFilledRows, 17); assert.equal(summary.registryRecords[0].ddid, '14151'); assert.doesNotMatch(text, /张三|E12345678|travelerList|rawExecutionResult|playwright-frame|PII customer/); }); test('dispatcher audit redacts traveler list and supplemental traveler PII', async () => { const auditDir = tempAuditDir(); const envelope = readyTeamSingleEnvelope(); envelope.task.fields.travelerList = { totalCount: 1, rows: [{ chineseName: '张三', englishName: 'ZHANG SAN', passportNo: 'E12345678', idNo: '440100199001010011', birthDate: '1990-01-01', raw: '张三 E12345678', }], }; envelope.task.fields.supplemental = { travelers: [{ name: '张三', passportNo: 'E12345678', idNo: '440100199001010011', birthDate: '1990-01-01', raw: '张三 E12345678', }], }; const result = await dispatcher.dispatchTask(envelope, { mode: 'dry-run', auditDir, config: { safety: { allowRealSubmit: true } }, }); const audit = JSON.parse(fs.readFileSync(result.auditPath, 'utf8')); const text = JSON.stringify(audit); assert.equal(audit.task.fields.travelerList.rows[0].chineseName, '[redacted]'); assert.equal(audit.task.fields.travelerList.rows[0].passportNo, '[redacted]'); assert.equal(audit.task.fields.supplemental.travelers[0].name, '[redacted]'); assert.equal(audit.task.fields.supplemental.travelers[0].passportNo, '[redacted]'); assert.doesNotMatch(text, /张三|E12345678|440100199001010011|1990-01-01/); }); test('dispatcher keeps completed audit redacted after appending registry records', async () => { const auditDir = tempAuditDir(); const registryPath = path.join(auditDir, 'orders.jsonl'); const envelope = readyTeamSingleEnvelope(); envelope.task.fields.travelerList = { totalCount: 1, rows: [{ chineseName: '寮犱笁', englishName: 'ZHANG SAN', passportNo: 'E12345678', idNo: '440100199001010011', birthDate: '1990-01-01', raw: '寮犱笁 E12345678', }], }; envelope.task.fields.supplemental = { travelerList: envelope.task.fields.travelerList, travelers: [{ name: '寮犱笁', passportNo: 'E12345678', idNo: '440100199001010011', birthDate: '1990-01-01', raw: '寮犳笁 E12345678', }], }; const result = await dispatcher.dispatchTask(envelope, { mode: 'execute', auditDir, registryPath, config: { safety: { allowRealSubmit: true }, executors: { teamSingle: { enabled: true } }, }, handlers: { team_single: async () => ({ status: 'completed', identifiers: { groupNo: 'LLW-260712A-A', ddid: '14151', }, stageResults: { submit: { travelerImport: { imported: true, method: 'erp_textarea_paste', expectedRows: 1, afterFilledRows: 1, }, }, }, }), }, }); const audit = JSON.parse(fs.readFileSync(result.auditPath, 'utf8')); const text = JSON.stringify(audit); assert.equal(audit.registryRecords.length, 1); assert.equal(audit.task.fields.travelerList.rows[0].chineseName, '[redacted]'); assert.equal(audit.task.fields.supplemental.travelers[0].passportNo, '[redacted]'); assert.doesNotMatch(text, /寮犱笁|E12345678|440100199001010011|1990-01-01/); }); test('execute mode reports missing handlers without touching ERP', async () => { const envelope = readyTeamBatchEnvelope(); envelope.task.route = 'not_a_route'; const result = await dispatcher.dispatchTask(envelope, { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, handlers: {}, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'invalid_task'); assert.match(result.violations.join('\n'), /route is required/i); }); test('dry-run supports export_confirmation operation tasks without a route', async () => { const result = await dispatcher.dispatchTask({ status: 'ready', operation: 'export_confirmation', task: { operation: 'export_confirmation', identifier: 'D14079', exportTypes: ['confirmation'], }, }, { mode: 'dry-run', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, }); assert.equal(result.status, 'dry_run'); assert.equal(result.operation, 'export_confirmation'); assert.equal(result.plannedExecutor, 'export_confirmation'); }); test('execute mode routes export_confirmation to operation handler', async () => { let receivedTask = null; const result = await dispatcher.dispatchTask({ status: 'ready', operation: 'export_confirmation', task: { operation: 'export_confirmation', identifier: 'D14079', exportTypes: ['confirmation'], originalText: '操作类型:导出确认件\n订单编号:D14079', }, }, { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, handlers: { export_confirmation: async (task) => { receivedTask = task; return { status: 'completed', identifiers: { childOrderNo: task.identifier }, artifacts: [{ type: 'pdf', path: 'outputs/D14079-confirm.pdf' }], customerMessage: '确认件已导出。', }; }, }, }); assert.equal(result.status, 'completed'); assert.equal(result.operation, 'export_confirmation'); assert.equal(receivedTask.identifier, 'D14079'); assert.match(result.customerMessage, /确认件已导出/); }); test('default export_confirmation handler is connected before route executors', async () => { const registryPath = path.join(tempAuditDir(), 'orders.jsonl'); const result = await dispatcher.dispatchTask({ status: 'ready', operation: 'export_confirmation', task: { operation: 'export_confirmation', identifier: 'D14079', originalText: '操作类型:导出确认件\n订单编号:D14079', }, }, { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true }, paths: { orderRegistry: registryPath }, }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'order_record_not_found'); }); test('completed create_order writes registry records when registryPath is configured', async () => { const registryPath = path.join(tempAuditDir(), 'orders.jsonl'); const result = await dispatcher.dispatchTask(withTravelerList(readyTeamBatchEnvelope()), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true }, paths: { orderRegistry: registryPath }, }, handlers: { team_batch: async () => ({ status: 'completed', identifiers: { dateToGroupNo: { '2026-07-13': 'LW-260713A-A', }, }, customerMessage: '已创建。', }), }, }); assert.equal(result.status, 'completed'); assert.equal(result.registryRecords.length, 1); assert.equal(result.registryRecords[0].identifier, 'LW-260713A-A'); assert.equal(fs.existsSync(registryPath), true); }); test('dispatcher_error preserves executor error details for audit diagnosis', async () => { const error = new Error('submit was not confirmed'); error.code = 'erp_submit_unconfirmed'; error.details = { ok: false, source: 'team_batch.updateOrder:LW-260813A-B', bodyPreview: 'modify page without success text', }; const result = await dispatcher.dispatchTask(withTravelerList(readyTeamBatchEnvelope()), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, handlers: { team_batch: async () => { throw error; }, }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'dispatcher_error'); assert.notEqual(result.customerMessage, error.message); assert.match(result.customerMessage, /ERP/); assert.doesNotMatch(result.customerMessage, /submit was not confirmed|team_batch\.updateOrder|bodyPreview/); assert.equal(result.error.code, 'erp_submit_unconfirmed'); assert.deepEqual(result.error.details, error.details); }); test('loadInputEnvelope routes raw WeChat messages through the operation adapter', async () => { const dir = tempAuditDir(); const messagePath = path.join(dir, 'message.txt'); fs.writeFileSync(messagePath, '操作类型:导出确认件\n订单编号:D14079', 'utf8'); const envelope = await dispatcher.loadInputEnvelope({ order: messagePath, auditDir: dir, }); assert.equal(envelope.status, 'ready'); assert.equal(envelope.operation, 'export_confirmation'); assert.equal(envelope.task.identifier, 'D14079'); }); test('default team_batch handler blocks until ERP operations are connected', async () => { const result = await dispatcher.dispatchTask(withTravelerList(readyTeamBatchEnvelope()), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'team_batch_operations_missing'); assert.match(result.customerMessage, /team_batch ERP operations are not connected/); }); test('default team_single handler blocks until ERP operations are connected', async () => { const result = await dispatcher.dispatchTask(withTravelerList(readyTeamSingleEnvelope()), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'team_single_operations_missing'); assert.match(result.customerMessage, /team_single ERP operations are not connected/); }); test('default split_parent handler blocks until ERP operations are connected', async () => { const result = await dispatcher.dispatchTask(readySplitParentEnvelope(), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'split_parent_operations_missing'); assert.match(result.customerMessage, /split_parent ERP operations are not connected/); }); test('default split_child handler blocks until ERP operations are connected', async () => { const result = await dispatcher.dispatchTask(withTravelerList(readySplitChildEnvelope()), { mode: 'execute', auditDir: tempAuditDir(), config: { safety: { allowRealSubmit: true } }, }); assert.equal(result.status, 'blocked'); assert.equal(result.reason, 'split_child_operations_missing'); assert.match(result.customerMessage, /split_child ERP operations are not connected/); });