feat: add shared child batch creation

This commit is contained in:
inman
2026-09-09 17:24:16 +08:00
parent 515b545b32
commit 6ac90f838a
32 changed files with 958 additions and 90 deletions

View File

@@ -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');