feat: add shared child batch creation
This commit is contained in:
@@ -44,6 +44,14 @@ const routeCases: Array<{ routeId: string; action: string; input: string }> = [
|
||||
产品名称:老挝广东8D
|
||||
领队:张三
|
||||
人数:15+1`
|
||||
},
|
||||
{
|
||||
routeId: 'shared_child_order_batch_create', action: 'shared_child_order_batch_create', input: `散拼团多个新增子单
|
||||
预订客户:LW衡阳国旅云南分社(广东市场)
|
||||
出发日期:10-06到11-03
|
||||
产品名称:老挝行程--广东
|
||||
领队:张三
|
||||
人数:20+1`
|
||||
},
|
||||
{
|
||||
routeId: 'passenger_list_import_independent', action: 'passenger_list_import', input: `导入独立团名单
|
||||
@@ -138,29 +146,38 @@ ${row}
|
||||
}
|
||||
];
|
||||
|
||||
test('the machine registry contains the 18 independent routes exactly once', () => {
|
||||
assert.equal(BUSINESS_ROUTES.length, 18);
|
||||
assert.equal(new Set(BUSINESS_ROUTES.map((route) => route.routeId)).size, 18);
|
||||
assert.equal(new Set(BUSINESS_ROUTES.map((route) => route.directive)).size, 18);
|
||||
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);
|
||||
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: 18 }, (_, index) => index + 1)
|
||||
Array.from({ length: 19 }, (_, 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');
|
||||
assert.equal(BUSINESS_ROUTES.find((route) => route.releaseOrder === 19)?.routeId, 'shared_child_order_batch_create');
|
||||
});
|
||||
|
||||
test('machine routes stay synchronized with the operator template and business registry', async () => {
|
||||
const [template, registry, migration] = await Promise.all([
|
||||
test('machine routes stay synchronized with their declared Program or Agent boundary', async () => {
|
||||
const [template, registry, initialMigration, batchMigration] = 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/013_business_parser_modes.sql', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../migrations/022_shared_child_order_batch_create.sql', import.meta.url), 'utf8')
|
||||
]);
|
||||
for (const route of BUSINESS_ROUTES) {
|
||||
assert.match(template, new RegExp(route.directive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), route.routeId);
|
||||
assert.match(registry, new RegExp(route.action.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), route.routeId);
|
||||
assert.match(migration, new RegExp(`'${route.routeId}'`), route.routeId);
|
||||
const directivePattern = new RegExp(route.directive.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const actionPattern = new RegExp(route.action.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
if (route.routeId === 'shared_child_order_batch_create') {
|
||||
assert.doesNotMatch(template, directivePattern, 'Program-only batch route must not be added to the Agent/operator template');
|
||||
assert.doesNotMatch(registry, actionPattern, 'Program-only batch route must not be added to the Agent adaptation registry');
|
||||
} else {
|
||||
assert.match(template, directivePattern, route.routeId);
|
||||
assert.match(registry, actionPattern, route.routeId);
|
||||
}
|
||||
assert.match(`${initialMigration}\n${batchMigration}`, new RegExp(`'${route.routeId}'`), route.routeId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -168,7 +185,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.6');
|
||||
assert.equal(corpus.version, 'ltjt-program-parser-v1.0.7');
|
||||
assert.ok(corpus.cases.length >= 28);
|
||||
for (const candidate of corpus.cases) {
|
||||
const parse = () => parseProgramInput({
|
||||
@@ -186,7 +203,7 @@ test('versioned sanitized gold corpus replays deterministically', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('all 18 official-format examples produce contract-valid operations', async () => {
|
||||
test('all 19 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);
|
||||
@@ -195,10 +212,27 @@ test('all 18 official-format examples produce contract-valid operations', async
|
||||
}
|
||||
});
|
||||
|
||||
test('shared-child batch input compiles the inclusive cross-month range without exposing an Agent action', async () => {
|
||||
const result = await parseProgramInput({ rawText: routeCases[4].input, receivedAt });
|
||||
assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result));
|
||||
assert.deepEqual(result.operation, {
|
||||
action: 'shared_child_order_batch_create',
|
||||
order_nature: 'formal',
|
||||
submit_mode: 'dry_run',
|
||||
data: {
|
||||
customer: { name: 'LW衡阳国旅云南分社(广东市场)', keyword: 'LW衡阳国旅云南分社(广东市场)' },
|
||||
product: { name: '老挝行程--广东', keyword: '老挝行程--广东' },
|
||||
leader: { name: '张三', keyword: '张三' },
|
||||
recurrence: { start_date: '2026-10-06', end_date: '2026-11-03' },
|
||||
passenger_counts: { adult: 20, leader: 1 }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('passenger TSV promotes only an exact leader remark into structured ERP contact data', async () => {
|
||||
const leaderRow = '1\t测试领队\tLEADER TEST\t男\t1980-01-01\t湖南\t护照\tE90000001\t中国\t2025-01-01\t2035-01-01\t13800000001\t 领队 ';
|
||||
const exact = await parseProgramInput({
|
||||
rawText: routeCases[4].input.replace(row, leaderRow),
|
||||
rawText: routeCases[5].input.replace(row, leaderRow),
|
||||
receivedAt
|
||||
});
|
||||
assert.equal(exact.status, 'agent_parse_passed', JSON.stringify(exact));
|
||||
@@ -209,7 +243,7 @@ test('passenger TSV promotes only an exact leader remark into structured ERP con
|
||||
});
|
||||
|
||||
const ordinaryRemark = await parseProgramInput({
|
||||
rawText: routeCases[4].input.replace(row, leaderRow.replace('\t 领队 ', '\t领队备注')),
|
||||
rawText: routeCases[5].input.replace(row, leaderRow.replace('\t 领队 ', '\t领队备注')),
|
||||
receivedAt
|
||||
});
|
||||
assert.equal(ordinaryRemark.status, 'agent_parse_passed', JSON.stringify(ordinaryRemark));
|
||||
@@ -220,7 +254,7 @@ test('passenger TSV rejects duplicate leaders or a leader without both name and
|
||||
const leader = '1\t测试领队甲\tLEADER ONE\t男\t1980-01-01\t湖南\t护照\tE90000001\t中国\t2025-01-01\t2035-01-01\t13800000001\t领队';
|
||||
const duplicate = '2\t测试领队乙\tLEADER TWO\t女\t1981-01-01\t湖南\t护照\tE90000002\t中国\t2025-01-01\t2035-01-01\t13800000002\t领队';
|
||||
const duplicated = await parseProgramInput({
|
||||
rawText: routeCases[4].input.replace(row, `${leader}\n${duplicate}`),
|
||||
rawText: routeCases[5].input.replace(row, `${leader}\n${duplicate}`),
|
||||
receivedAt
|
||||
});
|
||||
assert.equal(duplicated.status, 'agent_parse_needs_input');
|
||||
@@ -232,7 +266,7 @@ test('passenger TSV rejects duplicate leaders or a leader without both name and
|
||||
leader.replace('\t13800000001\t领队', '\t\t领队')
|
||||
]) {
|
||||
const invalid = await parseProgramInput({
|
||||
rawText: routeCases[4].input.replace(row, invalidRow),
|
||||
rawText: routeCases[5].input.replace(row, invalidRow),
|
||||
receivedAt
|
||||
});
|
||||
assert.equal(invalid.status, 'agent_parse_needs_input');
|
||||
@@ -243,7 +277,7 @@ test('passenger TSV rejects duplicate leaders or a leader without both name and
|
||||
|
||||
test('independent headcount distributions keep occupied and non-occupied children distinct', async () => {
|
||||
const parseHeadcount = (value: string) => parseProgramInput({
|
||||
rawText: routeCases[13].input.replace(
|
||||
rawText: routeCases[14].input.replace(
|
||||
'人数改为8+1(占)+1;用房数量改为3SGL+2TWN',
|
||||
`人数改为${value}`
|
||||
),
|
||||
@@ -389,14 +423,14 @@ test('business-value errors stay on the deterministic path instead of becoming c
|
||||
assert.equal(programFallbackAllowed(oneBatchDate), false);
|
||||
|
||||
const invalidHotelRange = await parseProgramInput({
|
||||
rawText: routeCases[8].input.replace('离店日期:09-09', '离店日期:09-03'),
|
||||
rawText: routeCases[9].input.replace('离店日期:09-09', '离店日期:09-03'),
|
||||
receivedAt
|
||||
});
|
||||
assert.equal(invalidHotelRange.error_code, 'program_invalid_value');
|
||||
assert.equal(programFallbackAllowed(invalidHotelRange), false);
|
||||
|
||||
const longLodgingNote = await parseProgramInput({
|
||||
rawText: routeCases[12].input.replace('领队单住', '订房说明'.repeat(20)),
|
||||
rawText: routeCases[13].input.replace('领队单住', '订房说明'.repeat(20)),
|
||||
receivedAt
|
||||
});
|
||||
assert.equal(longLodgingNote.error_code, 'program_invalid_value');
|
||||
@@ -405,7 +439,7 @@ test('business-value errors stay on the deterministic path instead of becoming c
|
||||
|
||||
test('independent updates require an exact registered target name', async () => {
|
||||
const unknownTarget = await parseProgramInput({
|
||||
rawText: routeCases[13].input.replace(
|
||||
rawText: routeCases[14].input.replace(
|
||||
'人数改为8+1(占)+1;用房数量改为3SGL+2TWN',
|
||||
'人数备注改为8+1'
|
||||
),
|
||||
@@ -415,7 +449,7 @@ test('independent updates require an exact registered target name', async () =>
|
||||
assert.equal(programFallbackAllowed(unknownTarget), true);
|
||||
|
||||
const invalidKnownTarget = await parseProgramInput({
|
||||
rawText: routeCases[13].input.replace(
|
||||
rawText: routeCases[14].input.replace(
|
||||
'人数改为8+1(占)+1;用房数量改为3SGL+2TWN',
|
||||
'行程天数改为0'
|
||||
),
|
||||
@@ -448,16 +482,16 @@ test('later turns overwrite prior fields while an unlabeled supplement fills exa
|
||||
|
||||
test('a second business directive cannot be merged into the same task', async () => {
|
||||
const result = await parseProgramInput({
|
||||
rawText: routeCases[15].input,
|
||||
rawText: routeCases[16].input,
|
||||
receivedAt,
|
||||
routeId: 'confirmation_export',
|
||||
history: [{ text: routeCases[17].input, receivedAt }]
|
||||
history: [{ text: routeCases[18].input, receivedAt }]
|
||||
});
|
||||
assert.equal(result.error_code, 'program_multiple_actions');
|
||||
assert.equal(programFallbackAllowed(result), false);
|
||||
|
||||
const sameTurn = await parseProgramInput({
|
||||
rawText: `${routeCases[17].input}\n取消订单\n单号:SYNTH-GROUP-001`,
|
||||
rawText: `${routeCases[18].input}\n取消订单\n单号:SYNTH-GROUP-001`,
|
||||
receivedAt
|
||||
});
|
||||
assert.equal(sameTurn.error_code, 'program_multiple_actions');
|
||||
@@ -465,7 +499,7 @@ test('a second business directive cannot be merged into the same task', async ()
|
||||
});
|
||||
|
||||
test('passenger TSV rejects duplicate sequence numbers without changing any value', async () => {
|
||||
const duplicate = routeCases[4].input.replace(row, `${row}\n${row}`);
|
||||
const duplicate = routeCases[5].input.replace(row, `${row}\n${row}`);
|
||||
const result = await parseProgramInput({ rawText: duplicate, receivedAt });
|
||||
assert.equal(result.status, 'agent_parse_needs_input');
|
||||
assert.equal(result.error_code, 'program_invalid_value');
|
||||
@@ -574,13 +608,13 @@ test('modification dates use the next occurrence and preserve explicit years', a
|
||||
assert.equal((reported.operation as any).data.departure_dates[0], '2026-11-15');
|
||||
|
||||
const crossed = await parseProgramInput({
|
||||
rawText: routeCases[12].input.replace('2026-09-15', '01-15'),
|
||||
rawText: routeCases[13].input.replace('2026-09-15', '01-15'),
|
||||
receivedAt: '2026-08-26T01:22:50.000Z'
|
||||
});
|
||||
assert.equal((crossed.operation as any).data.departure_dates[0], '2027-01-15');
|
||||
|
||||
const explicit = await parseProgramInput({
|
||||
rawText: routeCases[12].input.replace('2026-09-15', '2025-11-15'),
|
||||
rawText: routeCases[13].input.replace('2026-09-15', '2025-11-15'),
|
||||
receivedAt: '2026-08-26T01:22:50.000Z'
|
||||
});
|
||||
assert.equal((explicit.operation as any).data.departure_dates[0], '2025-11-15');
|
||||
|
||||
Reference in New Issue
Block a user