1004 lines
30 KiB
JavaScript
1004 lines
30 KiB
JavaScript
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/);
|
||
});
|