feat: add shared child batch creation

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

No files matched your search

+6 -6
View File
@@ -8,7 +8,7 @@
- 平台账号使用 `admin`(管理员)、`team_lead`(组长)和 `user`(普通用户)三种固定角色登录;服务端会话使用 HttpOnly/Secure/SameSite Cookie。管理员维护账号、角色、状态、密码重置、会话撤销和员工可执行任务类型,但自身不进入任务数据面;平台不提供组织或租户选择。
- 登录会话默认跨浏览器重启持续有效,不按空闲时间或绝对时长自动失效;用户退出、修改密码、账号停用或管理员强制撤销时由服务端立即撤销。
- 业务页面通过 REST 创建任务;Agent 返回结构化结果后,任务归属人在自己的任务上一次点击“确认并提交到 ERP 插件”,再通过 SSE 或轮询读取服务端状态。普通用户与组长的任务 API、SSE、附件和插件执行权限只覆盖分配给本人的人工任务与 AgentBus 任务;管理员是纯管理面身份,不能创建、查看、修改、领取、接收事件或提交任何业务任务。
- 手工与 AgentBus 的每个新任务都会按同一组织、同一 18 项业务路由固化解析策略及配置 revision;来源不能覆盖模式。其中两项名单 route 固定为 `program_only`,其余 16 项可配置 `ai / shadow / auto / program`。全消息中的唯一已登记指令可以确定 route;没有指令时,只有全部标签都属于唯一 route、至少两个不同标签且业务定位必填项完整的字段签名才可确定 route。未知、冲突或多 route 输入不猜测。后续补充轮次沿用原任务快照,设置变化只影响新任务和新会话。
- 手工与 AgentBus 的每个新任务都会按同一组织、同一 19 项业务路由固化解析策略及配置 revision;来源不能覆盖模式。其中两项名单 route 与散拼批量子单 route 固定为 `program_only`,其余 16 项可配置 `ai / shadow / auto / program`。全消息中的唯一已登记指令可以确定 route;没有指令时,只有全部标签都属于唯一 route、至少两个不同标签且业务定位必填项完整的字段签名才可确定 route。未知、冲突或多 route 输入不猜测。后续补充轮次沿用原任务快照,设置变化只影响新任务和新会话。
- 组长和普通用户使用逐账号白名单,新账号默认没有任何可执行任务类型;管理员自身的任务白名单固定为空,只能在 `/accounts` 为员工逐项授权。已登记但未授权的业务返回 `business_not_authorized`;无法唯一确认 route 的员工输入返回 `business_type_unresolved`。两种拒绝都发生在解析器和 ERP 插件之前,并记录不含明文的授权拒绝审计。权限在补充输入、人工确认、全自动确认和插件领取前再次校验;取消授权后的任务不会进入 ERP 队列。AgentBus 入站使用渠道绑定员工的同一白名单,管理员账号不得成为员工渠道归属人。
- 名单 route 创建后先进入 `awaiting_attachment`,只接收一份 `.xls/.xlsx`;控制面在前 100 行中自动定位唯一的 ERP 名单字段表头,按精确字段语义从任意表头行和列顺序中只选择 12 个必需源字段,并把表头下方连续名单数据在内存中规范化为 13 列 canonical TSV。身份证、年龄、源证件类型和其他普通额外列不参与名单行识别、字段校验或输出;必需字段缺失/重复、多个候选表头、必需字段中的非法公式及宏、外链等主动内容仍失败关闭。原始工作簿不入库,文件名和 canonical TSV 使用字段加密,解析通过或终止后清除 canonical 中间文本。附件到齐前不会领取解析任务,也不会进入 ERP。独立团初始 16 行、散拼子单初始 31 行仅为 ERP 动态扩行基线,5000 为技术上限。
- 平台的任务 ID、Agent 会话 ID、确认状态、重要摘要、事件和用户通讯内容只存在于平台任务/会话/结果信封中,不回写到 Agent `operation`,也不成为 ERP 业务字段。
@@ -68,9 +68,9 @@ Content-Type: application/json
## 双轨解析与渐进切换
`business-routes.ts` 是 18 项路由的唯一机器注册表,手工与 AgentBus 共用同一个全消息 route resolver 和任务级模式快照;`program-parser.ts` 按“规范化 → 路由 → 字段拆分 → 类型解析 → 业务 builder → 统一 operation 校验”确定性编译输入。AI 和程序结果都经过 `external-agent-client.mjs` 导出的同一最终契约校验;operation Schema、插件执行契约和 ERP 写入逻辑不因解析模式或任务来源改变。
`business-routes.ts` 是 19 项路由的唯一机器注册表,手工与 AgentBus 共用同一个全消息 route resolver 和任务级模式快照;`program-parser.ts` 按“规范化 → 路由 → 字段拆分 → 类型解析 → 业务 builder → Program operation 校验”确定性编译输入。新增散拼批量子单只由 Program 校验入口接受,外部 Agent 仍保持原 18 项指令路由;两者下游继续进入同一标准 operation Schema 与插件执行契约。
两项名单 route 是硬性例外:`allowed_modes=['program']`、`mode_locked=true`,组织设置、紧急全部切 AI 和一次性 AI 重解析都不能覆盖;紧急开关只作用于其余 16 项可配置业务。
两项名单 route 与散拼批量子单 route 是硬性例外:`allowed_modes=['program']`、`mode_locked=true`,组织设置、紧急全部切 AI 和一次性 AI 重解析都不能覆盖;紧急开关只作用于其余 16 项可配置业务。
- `ai`:只运行原 AI 路径;迁移后的设置和历史任务均从该模式开始。
- `shadow`:AI 是唯一权威结果,程序并行解析;两份候选只加密保存,任务详情只按差异路径解密展示 operation 字段,不公开完整候选或传输元数据。
@@ -79,7 +79,7 @@ Content-Type: application/json
Auto 一旦发生 AI fallback,任务会永久绑定原 AI 会话。每次解析只有一个完整权威结果,不拼接候选;任务进入确认、插件领取或 ERP 链路后不再自动重解析。管理员任务隔离后不再从任务详情执行一次性 AI 重解析或解析差异判定。
操作台根路径 `/parser-routing` 提供逐业务观察统计、即时切换检查、revision 乐观并发控制和事务级“全部切回 AI”。连续天数、任务数量和 fallback 比率不作为等待门槛:18 项已实现业务均可独立进入 Shadow,多个 Shadow 可并行测试且不受固定前序约束;已出现的差异全部判定且本业务程序关键、契约、运行错误为零即可进入 Auto;程序下游失败和上述错误为零即可进入 Program。同一时间最多一个业务处于 Auto,晋级仍只能 `AI → Shadow → Auto → Program` 逐级进行,回退可直接执行。7/30 日统计仅供观察。组织已开启全自动化时,切入 Auto/Program 不增加人工确认期。
操作台根路径 `/parser-routing` 提供逐业务观察统计、即时切换检查、revision 乐观并发控制和事务级“全部切回 AI”。连续天数、任务数量和 fallback 比率不作为等待门槛:16 项可配置业务均可独立进入 Shadow,多个 Shadow 可并行测试且不受固定前序约束;已出现的差异全部判定且本业务程序关键、契约、运行错误为零即可进入 Auto;程序下游失败和上述错误为零即可进入 Program。同一时间最多一个业务处于 Auto,晋级仍只能 `AI → Shadow → Auto → Program` 逐级进行,回退可直接执行。7/30 日统计仅供观察。组织已开启全自动化时,切入 Auto/Program 不增加人工确认期。
主要管理接口:
@@ -89,7 +89,7 @@ Auto 一旦发生 AI fallback,任务会永久绑定原 AI 会话。每次解
旧的任务详情重解析和解析差异判定路由仍保留兼容响应,但已纳入任务数据面统一门禁;管理员调用会返回 `task_access_forbidden`,管理页面不再提供这两个任务级入口。
迁移 `013_business_parser_modes` 增加内部固定范围的路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引;迁移 `015_account_roles_and_task_audit` 增加账号角色、密码更新时间、输入/附件操作者、任务归档和账号级幂等(历史 `must_change_password` 列仅保留兼容,当前流程不启用首次强制改密);迁移 `016_team_lead_operations_dashboard` 增加组长角色与人工指令看板索引;迁移 `017_user_business_route_authorizations` 增加逐账号业务白名单、授权人和乐观并发 revision;迁移 `018_agentbus_account_workers` 增加 ERP 账号、渠道归属、任务执行归属、唯一在线 worker 与 ERP 身份核验字段;迁移 `020_leader_task_summary_notifications` 增加内部失败关闭的组长路由快照和独立加密通知 outbox;迁移 `021_admin_task_data_plane_isolation` 清除管理员的遗留任务执行绑定和待发组长摘要,并以数据库触发器阻止管理员成为任务创建人/归属人、渠道 owner、浏览器 worker、业务授权对象或任务摘要接收人。原文、完整程序/AI 候选、名单 canonical 中间文本、人工说明和待发组长摘要使用字段加密保存;统计、全局审计和运行日志不复制明文业务输入。
迁移 `013_business_parser_modes` 增加内部固定范围的路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引;迁移 `015_account_roles_and_task_audit` 增加账号角色、密码更新时间、输入/附件操作者、任务归档和账号级幂等(历史 `must_change_password` 列仅保留兼容,当前流程不启用首次强制改密);迁移 `016_team_lead_operations_dashboard` 增加组长角色与人工指令看板索引;迁移 `017_user_business_route_authorizations` 增加逐账号业务白名单、授权人和乐观并发 revision;迁移 `018_agentbus_account_workers` 增加 ERP 账号、渠道归属、任务执行归属、唯一在线 worker 与 ERP 身份核验字段;迁移 `020_leader_task_summary_notifications` 增加内部失败关闭的组长路由快照和独立加密通知 outbox;迁移 `021_admin_task_data_plane_isolation` 清除管理员的遗留任务执行绑定和待发组长摘要,并以数据库触发器阻止管理员成为任务创建人/归属人、渠道 owner、浏览器 worker、业务授权对象或任务摘要接收人;迁移 `022_shared_child_order_batch_create` 只扩展 Program-only 散拼批量子单的解析设置与员工授权 route 约束,不自动授予任何账号。原文、完整程序/AI 候选、名单 canonical 中间文本、人工说明和待发组长摘要使用字段加密保存;统计、全局审计和运行日志不复制明文业务输入。
## AgentBus Bot 接入
@@ -143,7 +143,7 @@ npm run data:retention
npm run dev
```
`npm run dev` 和 `npm start` 会先执行数据库迁移,再启动控制平面;直接运行 `control-plane/src/server.ts` 或构建后的 `server.js` 时,服务也会在启动前检查必需迁移 `021_admin_task_data_plane_isolation`,缺失时拒绝监听端口。`db:migrate` 和管理员初始化需要可连接的 PostgreSQL。开发机没有数据库时,可以运行 `npm run test:control-plane` 完成无数据库静态/健康烟测。
`npm run dev` 和 `npm start` 会先执行数据库迁移,再启动控制平面;直接运行 `control-plane/src/server.ts` 或构建后的 `server.js` 时,服务也会在启动前检查必需迁移 `022_shared_child_order_batch_create`,缺失时拒绝监听端口。`db:migrate` 和管理员初始化需要可连接的 PostgreSQL。开发机没有数据库时,可以运行 `npm run test:control-plane` 完成无数据库静态/健康烟测。
`/health/ready` 同时检查 PostgreSQL 可用性和必需 schema 版本;迁移未完成时返回 503,并标明 `required_migration`,避免任务在数据库结构未升级时进入解析队列。
@@ -0,0 +1,54 @@
-- Register the Program-only shared-child batch route without changing any
-- external Agent routing or automatically granting the new capability.
ALTER TABLE business_parser_settings
DROP CONSTRAINT IF EXISTS business_parser_settings_route_check;
ALTER TABLE business_parser_settings
ADD CONSTRAINT business_parser_settings_route_check CHECK (route_id IN (
'team_order_create',
'team_order_batch_create',
'shared_plan_create',
'shared_child_order_create',
'shared_child_order_batch_create',
'passenger_list_import_independent',
'passenger_list_import_shared_child',
'arrangement_guide_create',
'arrangement_vehicle_create',
'arrangement_hotel_create',
'arrangement_transport_create',
'arrangement_other_create',
'order_update_shared_plan',
'order_update_shared_child',
'order_update_independent',
'arrangement_hotel_update',
'order_cancel',
'order_restore',
'confirmation_export'
));
ALTER TABLE user_business_route_authorizations
DROP CONSTRAINT IF EXISTS user_business_route_authorizations_route_check;
ALTER TABLE user_business_route_authorizations
ADD CONSTRAINT user_business_route_authorizations_route_check CHECK (route_id IN (
'team_order_create',
'team_order_batch_create',
'shared_plan_create',
'shared_child_order_create',
'shared_child_order_batch_create',
'passenger_list_import_independent',
'passenger_list_import_shared_child',
'arrangement_guide_create',
'arrangement_vehicle_create',
'arrangement_hotel_create',
'arrangement_transport_create',
'arrangement_other_create',
'order_update_shared_plan',
'order_update_shared_child',
'order_update_independent',
'arrangement_hotel_update',
'order_cancel',
'order_restore',
'confirmation_export'
));
+20 -1
View File
@@ -6,6 +6,7 @@ export type BusinessRouteId =
| 'team_order_batch_create'
| 'shared_plan_create'
| 'shared_child_order_create'
| 'shared_child_order_batch_create'
| 'passenger_list_import_independent'
| 'passenger_list_import_shared_child'
| 'arrangement_guide_create'
@@ -74,7 +75,7 @@ export function effectiveParserModeForRoute(route: BusinessRouteDefinition, conf
return route.parserPolicy === 'program_only' ? 'program' : 'ai';
}
const PROGRAM_VERSION = 'ltjt-program-parser-v1.0.6';
const PROGRAM_VERSION = 'ltjt-program-parser-v1.0.7';
export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.126';
const lookupFields = ({ customerRequired = true } = {}): ProgramFieldDefinition[] => [
@@ -157,6 +158,24 @@ export const BUSINESS_ROUTES: readonly BusinessRouteDefinition[] = Object.freeze
{ key: 'special_requests', label: '特殊要求', kind: 'text' }
]
},
{
routeId: 'shared_child_order_batch_create',
releaseOrder: 19,
directive: '散拼团多个新增子单',
aliases: [],
action: 'shared_child_order_batch_create',
programVersion: PROGRAM_VERSION,
inputContractVersion: BUSINESS_INPUT_CONTRACT_VERSION,
parserPolicy: 'program_only',
fields: [
{ key: 'customer', label: '预订客户', aliases: ['预定客户'], required: true, kind: 'named_ref' },
{ key: 'departure_range', label: '出发日期', aliases: ['发团日期'], required: true, kind: 'date_range' },
{ key: 'product', label: '产品名称', aliases: ['产品搜索'], required: true, kind: 'named_ref' },
{ key: 'leader', label: '领队', aliases: ['领队姓名'], kind: 'named_ref' },
{ key: 'passenger_counts', label: '人数', aliases: ['预估人数'], required: true, kind: 'passenger_counts' },
{ key: 'special_requests', label: '特殊要求', kind: 'text' }
]
},
{
routeId: 'passenger_list_import_independent',
releaseOrder: 10,
+1 -1
View File
@@ -5,7 +5,7 @@ import { writeEmergencyDiagnostic } from './diagnostics.js';
const { Pool } = pg;
let pool: pg.Pool | null = null;
export const REQUIRED_SCHEMA_VERSION = '021_admin_task_data_plane_isolation';
export const REQUIRED_SCHEMA_VERSION = '022_shared_child_order_batch_create';
export interface DatabaseReadiness {
ready: boolean;
+2 -2
View File
@@ -3,7 +3,7 @@ import { resolve } from 'node:path';
import { createHash } from 'node:crypto';
interface ExternalContractModule {
validateAgentOperation(operation: unknown): string[];
validateProgramOperation(operation: unknown): string[];
}
let contractModulePromise: Promise<ExternalContractModule> | null = null;
@@ -18,7 +18,7 @@ async function loadContractModule(): Promise<ExternalContractModule> {
export async function validateParsedOperation(operation: unknown): Promise<string[]> {
const contract = await loadContractModule();
return contract.validateAgentOperation(operation);
return contract.validateProgramOperation(operation);
}
const EXPORT_TYPE_ORDER = [
+10
View File
@@ -64,6 +64,7 @@ const NEXT_OCCURRENCE_DATE_ROUTES = new Set<BusinessRouteId>([
'team_order_batch_create',
'shared_plan_create',
'shared_child_order_create',
'shared_child_order_batch_create',
'order_update_shared_plan',
'order_update_shared_child',
'order_update_independent',
@@ -773,6 +774,15 @@ function buildOperation(route: BusinessRouteDefinition, values: Map<string, unkn
if (parent) data.existing_refs = { kind: 'shared_plan', identifier: parent, parent_group_no: parent };
return { ok: true, operation: operation(route.action, data) };
}
case 'shared_child_order_batch_create':
return { ok: true, operation: operation(route.action, {
customer: values.get('customer'),
product: values.get('product'),
...(values.get('leader') ? { leader: values.get('leader') } : {}),
recurrence: values.get('departure_range'),
passenger_counts: values.get('passenger_counts'),
...(special ? { special_requests: special } : {})
}) };
case 'passenger_list_import_independent':
case 'passenger_list_import_shared_child': {
const parsedList = values.get('passenger_tsv') as JsonRecord;
+4 -2
View File
@@ -138,7 +138,8 @@ function identifierLine(
const groupNumbers = groupNumbersFrom(receipt, result, refs);
const orderNumbers = orderNumbersFrom(receipt, result, refs);
const explicit = firstText(receipt.identifier, result.identifier, refs.identifier);
const child = action === 'shared_child_order_create' || refs.kind === 'shared_child_order';
const child = ['shared_child_order_create', 'shared_child_order_batch_create'].includes(action)
|| refs.kind === 'shared_child_order';
if (child && orderNumbers.length) {
return {
text: `子单号:${orderNumbers.join('、')}`,
@@ -330,6 +331,7 @@ function actionTitle(action: string): string {
team_order_batch_create: '批量下单成功',
shared_plan_create: '散拼计划创建成功',
shared_child_order_create: '新子单成功',
shared_child_order_batch_create: '批量新增子单成功',
arrangement_guide: '导游安排成功',
arrangement_vehicle: '用车安排成功',
arrangement_hotel: '酒店安排成功',
@@ -529,7 +531,7 @@ export function buildCustomerSuccessReply(
textValue = [actionTitle(action), identity.text, names.length ? `文件:${names.join('、')}` : '文件已保存到任务附件'].filter(Boolean).join('\n');
profile = 'standard-file-delivery-v1';
fields = { ...fields, file_names: names, attachment_count: attachments.length };
} else if (['team_order_create', 'team_order_batch_create', 'shared_plan_create', 'shared_child_order_create'].includes(action)) {
} else if (['team_order_create', 'team_order_batch_create', 'shared_plan_create', 'shared_child_order_create', 'shared_child_order_batch_create'].includes(action)) {
textValue = [actionTitle(action), identity.text].filter(Boolean).join('\n');
profile = 'standard-newbooking-v1';
} else {
+1 -1
View File
@@ -447,7 +447,7 @@ test('control plane requires the administrator task-isolation migration before r
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, '021_admin_task_data_plane_isolation');
assert.equal(REQUIRED_SCHEMA_VERSION, '022_shared_child_order_batch_create');
assert.match(db, /schema_migrations/);
assert.match(db, /databaseReadiness/);
assert.match(db, /assertDatabaseSchema/);
@@ -11,18 +11,19 @@ import {
import { loadConfig } from '../src/config.js';
import { resolveParserModeSnapshot, TaskError, TaskService } from '../src/task-service.js';
const rosterRouteIds = [
const programOnlyRouteIds = [
'shared_child_order_batch_create',
'passenger_list_import_independent',
'passenger_list_import_shared_child'
] as const;
const parserModes: ParserMode[] = ['ai', 'shadow', 'auto', 'program'];
test('only the two roster routes are hard Program-only in the machine registry', () => {
test('the two roster routes and shared-child batch route 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), rosterRouteIds);
assert.deepEqual(programOnly.map((route) => route.routeId), programOnlyRouteIds);
for (const route of BUSINESS_ROUTES) {
const expectedModes = rosterRouteIds.includes(route.routeId as typeof rosterRouteIds[number])
const expectedModes = programOnlyRouteIds.includes(route.routeId as typeof programOnlyRouteIds[number])
? ['program']
: parserModes;
assert.deepEqual(allowedParserModesForRoute(route), expectedModes, route.routeId);
@@ -41,14 +42,14 @@ test('only the two roster routes are hard Program-only in the machine registry',
}
});
test('roster task snapshots stay Program with missing or hostile stored settings', async () => {
const routeText = (routeId: typeof rosterRouteIds[number]) => {
test('hard Program-only task snapshots stay Program with missing or hostile stored settings', async () => {
const routeText = (routeId: typeof programOnlyRouteIds[number]) => {
const route = BUSINESS_ROUTES.find((candidate) => candidate.routeId === routeId);
assert.ok(route);
return `${route.directive}\n单号:TEST-ORDER`;
};
for (const routeId of rosterRouteIds) {
for (const routeId of programOnlyRouteIds) {
for (const storedMode of [null, ...parserModes] as const) {
const client = {
async query() {
@@ -83,7 +84,7 @@ test('the public route mutation rejects AI, Shadow, and Auto before touching set
DATABASE_URL: 'postgresql://invalid:invalid@127.0.0.1:1/invalid'
}));
const context = { organizationId: 'test-org', userId: 'test-user', requestId: 'test-request' };
for (const routeId of rosterRouteIds) {
for (const routeId of programOnlyRouteIds) {
for (const mode of parserModes.filter((candidate) => candidate !== 'program')) {
await assert.rejects(
service.setParserRoutingMode(context, routeId, mode, 0),
+62 -28
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');