修复变更、增加筛选条件
This commit is contained in:
1 parent
712ec51d68
commit
e06afc8c33
137 files changed
+35872
-275
No files matched your search
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseProgramInput } from '../src/program-parser.js';
|
||||
import { BUSINESS_ROUTES } from '../src/business-routes.js';
|
||||
|
||||
const receivedAt = '2026-09-11T08:50:12Z';
|
||||
const group = '\n团号:LW-TEST-UPDATE';
|
||||
const cases = [
|
||||
{ type: 'guide', input: `安排导游变更${group}\n导游搜索:测试新导游\n备注:接机`, selection: undefined, changes: { resource: { name: '测试新导游', keyword: '测试新导游' }, remark: '接机' } },
|
||||
{ type: 'vehicle', input: `安排用车变更${group}\n用车日期:2026-09-15到2026-09-16\n结算单位搜索:测试新车队\n数量:2`, selection: { start_date: '2026-09-15', end_date: '2026-09-16' }, changes: { supplier: { name: '测试新车队', keyword: '测试新车队' }, quantity: 2 } },
|
||||
{ type: 'transport', input: `安排大交通变更${group}\n交通日期:2026-09-15\n结算单位搜索:测试航空\n数量:8`, selection: { date: '2026-09-15' }, changes: { supplier: { name: '测试航空', keyword: '测试航空' }, quantity: 8 } },
|
||||
{ type: 'other', input: `安排其他/备案变更${group}\n业务日期:2026-09-15\n结算单位搜索:测试备案单位\n备案号:TEST-2\n备案日期:2026-09-14\n入境口岸:测试口岸`, selection: { date: '2026-09-15', filing_date: '2026-09-14' }, changes: { supplier: { name: '测试备案单位', keyword: '测试备案单位' }, filing: { number: 'TEST-2', entry_port: '测试口岸' } } }
|
||||
];
|
||||
|
||||
test('vehicle type is an optional candidate filter on create and update, never an original date or direct item edit', async () => {
|
||||
for (const directive of ['安排用车', '安排用车变更']) {
|
||||
const input = `${directive}${group}\n用车日期:2026-09-15到2026-09-16\n结算单位搜索:测试新车队\n数量:2`;
|
||||
for (const label of ['用车类型', '车型']) {
|
||||
const result = await parseProgramInput({ rawText: `${input}\n${label}:15座 VIP`, receivedAt });
|
||||
assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result));
|
||||
const value = (result.operation?.data as any).arrangement;
|
||||
assert.equal(value.vehicle_type, '15座 VIP');
|
||||
assert.equal(value.item, undefined);
|
||||
assert.equal(value.changes?.item, undefined);
|
||||
assert.equal(value.selection?.vehicle_type, undefined);
|
||||
if (directive.endsWith('变更')) assert.deepEqual(value.selection, { start_date: '2026-09-15', end_date: '2026-09-16' });
|
||||
}
|
||||
const omitted = await parseProgramInput({ rawText: input, receivedAt });
|
||||
assert.equal(omitted.status, 'agent_parse_passed');
|
||||
assert.equal((omitted.operation?.data as any).arrangement.vehicle_type, undefined);
|
||||
const conflict = await parseProgramInput({ rawText: `${input}\n用车类型:15座\n车型:45座`, receivedAt });
|
||||
assert.notEqual(conflict.status, 'agent_parse_passed');
|
||||
}
|
||||
});
|
||||
|
||||
for (const c of cases) test(`${c.type} update separates original dates from replacements`, async () => {
|
||||
const result = await parseProgramInput({ rawText: c.input, receivedAt });
|
||||
assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result));
|
||||
assert.equal(result.business_route_id, `arrangement_${c.type}_update`);
|
||||
const value = (result.operation?.data as any).arrangement;
|
||||
assert.deepEqual(value.changes, c.changes);
|
||||
assert.deepEqual(value.selection, c.selection);
|
||||
assert.equal(value.mode, 'update');
|
||||
assert.equal(value.changes.date, undefined);
|
||||
assert.equal(value.changes.start_date, undefined);
|
||||
assert.equal(value.changes.end_date, undefined);
|
||||
assert.equal(value.changes.filing?.date, undefined);
|
||||
assert.equal(BUSINESS_ROUTES.find(r => r.routeId === result.business_route_id)?.parserPolicy, 'program_only');
|
||||
});
|
||||
|
||||
test('updates require an original date and new resource; quantity remains optional only for other', async () => {
|
||||
for (const c of cases) {
|
||||
for (const rawText of [c.input.replace(/(?:导游搜索|结算单位搜索):[^\n]+\n?/u, ''), ...(c.type === 'guide' ? [] : [c.input.replace(/(?:用车日期|交通日期|业务日期):[^\n]+\n?/u, '')])]) {
|
||||
const result = await parseProgramInput({ rawText, receivedAt });
|
||||
assert.notEqual(result.status, 'agent_parse_passed', rawText);
|
||||
assert.equal(result.operation, null);
|
||||
}
|
||||
}
|
||||
for (const alias of ['安排其他变更', '安排备案变更']) {
|
||||
const result = await parseProgramInput({ rawText: cases[3].input.replace('安排其他/备案变更', alias), receivedAt });
|
||||
assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result));
|
||||
assert.equal((result.operation?.data as any).arrangement.changes.quantity, undefined);
|
||||
}
|
||||
for (const c of cases.slice(1, 3)) {
|
||||
const result = await parseProgramInput({ rawText: c.input.replace(/数量:\d+/u, '数量:0'), receivedAt });
|
||||
assert.notEqual(result.status, 'agent_parse_passed');
|
||||
}
|
||||
});
|
||||
|
||||
test('ticket description is optional on transport create/update and stays separate from linked item and original dates', async () => {
|
||||
for (const directive of ['安排大交通', '安排大交通变更']) {
|
||||
const input = `${directive}${group}\n交通日期:2026-09-15\n结算单位搜索:测试票务\n数量:2`;
|
||||
for (const label of ['车票说明', '车票说明搜索']) {
|
||||
const result = await parseProgramInput({ rawText: `${input}\n${label}:D88 二等座`, receivedAt });
|
||||
assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result));
|
||||
const value = (result.operation?.data as any).arrangement;
|
||||
assert.equal(value.ticket_description, 'D88 二等座');
|
||||
assert.equal(value.item, undefined);
|
||||
assert.equal(value.changes?.item, undefined);
|
||||
assert.equal(value.selection?.ticket_description, undefined);
|
||||
if (directive.endsWith('变更')) assert.deepEqual(value.selection, { date: '2026-09-15' });
|
||||
}
|
||||
const omitted = await parseProgramInput({ rawText: input, receivedAt });
|
||||
assert.equal(omitted.status, 'agent_parse_passed');
|
||||
assert.equal((omitted.operation?.data as any).arrangement.ticket_description, undefined);
|
||||
const legacy = await parseProgramInput({ rawText: `${input}\n车票/控位说明:旧兼容内容`, receivedAt });
|
||||
assert.equal(legacy.status, 'agent_parse_passed', JSON.stringify(legacy));
|
||||
assert.equal((legacy.operation?.data as any).arrangement.ticket_description, undefined);
|
||||
const conflict = await parseProgramInput({ rawText: `${input}\n车票说明:D88\n车票说明搜索:D87`, receivedAt });
|
||||
assert.notEqual(conflict.status, 'agent_parse_passed');
|
||||
}
|
||||
});
|
||||
@@ -447,7 +447,7 @@ test('control plane requires the leader summary webhook migration before readine
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8');
|
||||
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
|
||||
assert.equal(REQUIRED_SCHEMA_VERSION, '023_leader_summary_webhook_delivery');
|
||||
assert.equal(REQUIRED_SCHEMA_VERSION, '024_arrangement_update_routes');
|
||||
assert.match(db, /schema_migrations/);
|
||||
assert.match(db, /databaseReadiness/);
|
||||
assert.match(db, /assertDatabaseSchema/);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseProgramInput } from '../src/program-parser.js';
|
||||
|
||||
const input = `安排酒店变更
|
||||
团号:LW-TEST-HOTEL
|
||||
入住日期:2026-09-15
|
||||
离店日期:2026-09-16
|
||||
酒店搜索:测试酒店twn
|
||||
间数:9`;
|
||||
const receivedAt = '2026-09-11T08:50:12Z';
|
||||
|
||||
test('dated hotel replacement parses old dates separately from the new resource and count', async () => {
|
||||
const result = await parseProgramInput({ rawText: input, receivedAt });
|
||||
assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result));
|
||||
assert.equal(result.business_route_id, 'arrangement_hotel_update');
|
||||
assert.deepEqual((result.operation?.data as any).arrangement, {
|
||||
mode: 'update', selection: { start_date: '2026-09-15', end_date: '2026-09-16' },
|
||||
changes: { resource: { name: '测试酒店twn', keyword: '测试酒店twn' }, room_count: 9 }
|
||||
});
|
||||
});
|
||||
|
||||
test('hotel replacement rejects missing dates, invalid count, reversed dates and mixed update styles', async () => {
|
||||
for (const rawText of [
|
||||
input.replace('入住日期:2026-09-15\n', ''),
|
||||
input.replace('离店日期:2026-09-16\n', ''),
|
||||
input.replace('间数:9', '间数:0'),
|
||||
input.replace('离店日期:2026-09-16', '离店日期:2026-09-14'),
|
||||
`${input}\n目标变更:离店日期改为2026-09-18`
|
||||
]) {
|
||||
const result = await parseProgramInput({ rawText, receivedAt });
|
||||
assert.notEqual(result.status, 'agent_parse_passed', JSON.stringify(result));
|
||||
assert.equal(result.operation, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy hotel departure and count changes remain supported', async () => {
|
||||
const result = await parseProgramInput({ rawText: '安排变更\n团号:LW-TEST-HOTEL\n目标变更:离店日期改为2026-09-18;房间数改为9\n安排类型:酒店', receivedAt });
|
||||
assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result));
|
||||
assert.deepEqual((result.operation?.data as any).arrangement, { mode: 'update', changes: { end_date: '2026-09-18', room_count: 9 } });
|
||||
});
|
||||
@@ -14,11 +14,15 @@ import { resolveParserModeSnapshot, TaskError, TaskService } from '../src/task-s
|
||||
const programOnlyRouteIds = [
|
||||
'shared_child_order_batch_create',
|
||||
'passenger_list_import_independent',
|
||||
'passenger_list_import_shared_child'
|
||||
'passenger_list_import_shared_child',
|
||||
'arrangement_guide_update',
|
||||
'arrangement_vehicle_update',
|
||||
'arrangement_transport_update',
|
||||
'arrangement_other_update'
|
||||
] as const;
|
||||
const parserModes: ParserMode[] = ['ai', 'shadow', 'auto', 'program'];
|
||||
|
||||
test('the two roster routes and shared-child batch route are hard Program-only in the machine registry', () => {
|
||||
test('roster, shared-child batch and four arrangement update routes are hard Program-only in the machine registry', () => {
|
||||
const programOnly = BUSINESS_ROUTES.filter((route) => route.parserPolicy === 'program_only');
|
||||
assert.deepEqual(programOnly.map((route) => route.routeId), programOnlyRouteIds);
|
||||
|
||||
|
||||
@@ -143,17 +143,21 @@ ${row}
|
||||
预订客户:辽宁康辉
|
||||
出发日期:2026-09-03
|
||||
文件类型:联泰客户确认单`
|
||||
}
|
||||
},
|
||||
{ routeId: 'arrangement_guide_update', action: 'arrangement_guide', input: '安排导游变更\n团号:LW-TEST\n导游搜索:测试导游' },
|
||||
{ routeId: 'arrangement_vehicle_update', action: 'arrangement_vehicle', input: '安排用车变更\n团号:LW-TEST\n用车日期:2026-09-15到2026-09-16\n结算单位搜索:测试车队\n数量:2' },
|
||||
{ routeId: 'arrangement_transport_update', action: 'arrangement_transport', input: '安排大交通变更\n团号:LW-TEST\n交通日期:2026-09-15\n结算单位搜索:测试航空\n数量:2' },
|
||||
{ routeId: 'arrangement_other_update', action: 'arrangement_other', input: '安排其他/备案变更\n团号:LW-TEST\n业务日期:2026-09-15\n结算单位搜索:测试备案' }
|
||||
];
|
||||
|
||||
test('the machine registry contains the 19 independent routes exactly once', () => {
|
||||
assert.equal(BUSINESS_ROUTES.length, 19);
|
||||
assert.equal(new Set(BUSINESS_ROUTES.map((route) => route.routeId)).size, 19);
|
||||
assert.equal(new Set(BUSINESS_ROUTES.map((route) => route.directive)).size, 19);
|
||||
test('the machine registry contains the 23 independent routes exactly once', () => {
|
||||
assert.equal(BUSINESS_ROUTES.length, 23);
|
||||
assert.equal(new Set(BUSINESS_ROUTES.map((route) => route.routeId)).size, 23);
|
||||
assert.equal(new Set(BUSINESS_ROUTES.map((route) => route.directive)).size, 23);
|
||||
assert.deepEqual(BUSINESS_ROUTES.map((route) => route.routeId), routeCases.map((route) => route.routeId));
|
||||
assert.deepEqual(
|
||||
[...BUSINESS_ROUTES].sort((left, right) => left.releaseOrder - right.releaseOrder).map((route) => route.releaseOrder),
|
||||
Array.from({ length: 19 }, (_, index) => index + 1)
|
||||
Array.from({ length: 23 }, (_, index) => index + 1)
|
||||
);
|
||||
assert.equal(BUSINESS_ROUTES.find((route) => route.releaseOrder === 1)?.routeId, 'confirmation_export');
|
||||
assert.equal(BUSINESS_ROUTES.find((route) => route.releaseOrder === 18)?.routeId, 'order_update_independent');
|
||||
@@ -161,11 +165,12 @@ test('the machine registry contains the 19 independent routes exactly once', ()
|
||||
});
|
||||
|
||||
test('machine routes stay synchronized with their declared Program or Agent boundary', async () => {
|
||||
const [template, registry, initialMigration, batchMigration] = await Promise.all([
|
||||
const [template, registry, initialMigration, batchMigration, updateMigration] = await Promise.all([
|
||||
readFile(new URL('../../agent设计规范/templates/business-input-templates.md', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../../agent设计规范/business-adaptation-registry.md', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../migrations/013_business_parser_modes.sql', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../migrations/022_shared_child_order_batch_create.sql', import.meta.url), 'utf8')
|
||||
readFile(new URL('../migrations/022_shared_child_order_batch_create.sql', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../migrations/024_arrangement_update_routes.sql', import.meta.url), 'utf8')
|
||||
]);
|
||||
for (const route of BUSINESS_ROUTES) {
|
||||
const directivePattern = new RegExp(route.directive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
@@ -176,7 +181,7 @@ test('machine routes stay synchronized with their declared Program or Agent boun
|
||||
} else {
|
||||
assert.match(registry, actionPattern, route.routeId);
|
||||
}
|
||||
assert.match(`${initialMigration}\n${batchMigration}`, new RegExp(`'${route.routeId}'`), route.routeId);
|
||||
assert.match(`${initialMigration}\n${batchMigration}\n${updateMigration}`, new RegExp(`'${route.routeId}'`), route.routeId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -184,7 +189,7 @@ test('versioned sanitized gold corpus replays deterministically', async () => {
|
||||
const corpus = JSON.parse(await readFile(
|
||||
new URL('../../samples/business-parser-gold-corpus.json', import.meta.url), 'utf8'
|
||||
));
|
||||
assert.equal(corpus.version, 'ltjt-program-parser-v1.0.7');
|
||||
assert.equal(corpus.version, 'ltjt-program-parser-v1.0.11');
|
||||
assert.ok(corpus.cases.length >= 28);
|
||||
for (const candidate of corpus.cases) {
|
||||
const parse = () => parseProgramInput({
|
||||
@@ -202,7 +207,7 @@ test('versioned sanitized gold corpus replays deterministically', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('all 19 official-format examples produce contract-valid operations', async () => {
|
||||
test('all 23 official-format examples produce contract-valid operations', async () => {
|
||||
for (const candidate of routeCases) {
|
||||
const result = await parseProgramInput({ rawText: candidate.input, receivedAt });
|
||||
assert.equal(result.business_route_id, candidate.routeId, candidate.routeId);
|
||||
|
||||
@@ -82,6 +82,29 @@ test('hotel clear and update replies stay business-readable when the booking tem
|
||||
assert.equal(updateReply.text, '酒店安排成功\n团号:LW-260603Z-A\n退房日期:2026-06-05\n房数:9间');
|
||||
});
|
||||
|
||||
test('hotel replacement reply describes the new hotel and count while retaining the original dates', () => {
|
||||
const reply = buildCustomerSuccessReply(
|
||||
{ success: true, group_number: 'LW-TEST-A' },
|
||||
{},
|
||||
{
|
||||
action: 'arrangement_hotel',
|
||||
data: {
|
||||
arrangement: {
|
||||
mode: 'update',
|
||||
resource: { id: '100', name: '原酒店' },
|
||||
start_date: '2026-09-15',
|
||||
end_date: '2026-09-16',
|
||||
room_count: 8,
|
||||
changes: { resource: { id: '270', name: '测试新酒店' }, room_count: 9 }
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
assert.equal(reply?.text, '酒店安排成功\n团号:LW-TEST-A\n资源:测试新酒店\n入住日期:2026-09-15\n退房日期:2026-09-16\n房数:9间');
|
||||
assert.equal(reply?.fields.resource, '测试新酒店');
|
||||
assert.equal(reply?.fields.quantity, 9);
|
||||
});
|
||||
|
||||
test('batch newbooking receipt preserves every group number', () => {
|
||||
const reply = buildCustomerSuccessReply(
|
||||
{ success: true, group_number: 'LW-260603A-A', group_numbers: ['LW-260603A-A', 'LW-260605A-A'] },
|
||||
@@ -191,3 +214,21 @@ test('legacy ERP mojibake filenames are repaired when a stored receipt is read',
|
||||
download_url: '/api/tasks/TASK-LEGACY/artifacts/ART-LEGACY'
|
||||
}]);
|
||||
});
|
||||
|
||||
test('arrangement replacement receipts report the new resource and quantity with original dates', () => {
|
||||
for (const action of ['arrangement_guide', 'arrangement_vehicle', 'arrangement_transport', 'arrangement_other']) {
|
||||
const guide = action === 'arrangement_guide';
|
||||
const reply = buildCustomerSuccessReply({ success: true, group_number: 'LW-TEST-UPDATE' }, {}, {
|
||||
action,
|
||||
data: { arrangement: {
|
||||
mode: 'update', [guide ? 'resource' : 'supplier']: { name: '原资源' }, quantity: 3,
|
||||
date: '2026-09-15',
|
||||
changes: { [guide ? 'resource' : 'supplier']: { name: '新资源' }, ...(!guide ? { quantity: 8, item: '新项目' } : {}) }
|
||||
} }
|
||||
});
|
||||
assert.equal(reply?.fields.resource, '新资源');
|
||||
assert.equal(reply?.fields.date, '2026-09-15');
|
||||
if (!guide) { assert.equal(reply?.fields.quantity, 8); assert.equal(reply?.fields.item, '新项目'); }
|
||||
assert.doesNotMatch(reply?.text || '', /原资源/);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user