diff --git a/.project-docs/30-worklog/tasks/20260908-shared-child-batch-create-4e7c2a91.md b/.project-docs/30-worklog/tasks/20260908-shared-child-batch-create-4e7c2a91.md new file mode 100644 index 0000000..54c62d0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260908-shared-child-batch-create-4e7c2a91.md @@ -0,0 +1,60 @@ +# Task: Add batch shared-child order creation + +## Identity + +- Task ID: 20260908-shared-child-batch-create-4e7c2a91 +- Mode: Feature +- Branch: codex/20260908-shared-child-batch-create-4e7c2a91-shared-child-batch-create +- Worktree: /Users/inmanx/Documents/lwltAPI-shared-child-batch-create-4e7c2a91 +- Base commit: 515b545b32fcbb30311b56c5b90a36f3d3834d95 +- Owner: codex +- Status: Ready for integration + +## Scope + +- Add a Program-only `shared_child_order_batch_create` business route for the directive `散拼团多个新增子单`. +- Parse one inclusive departure date range plus customer, product, optional leader, passenger counts, and optional special requests. +- Resolve every matching shared mother plan in the range before the first write, then create one identical child order per resolved parent sequentially. +- Add strict execution validation, batch receipts, stop-on-first-failure behavior, database route constraints, tests, and the synchronized Chrome extension release artifact. + +## Intent And Constraints + +- Program processing only: do not add or modify any Skill, Agent prompt, external-Agent directive route, or Agent parsing action. +- The product is a required hard match against the visible shared-plan row; booking customer and leader are child-order fields and must not filter mother plans. +- Enumerate and validate the complete target set before the first ERP write. Zero targets, incomplete parent references, or duplicate/conflicting parent identity must fail closed. +- Execute targets in date/tid order, retain per-parent receipts, stop after the first blocked/failed/uncertain result, and never retry a write automatically. +- No live ERP write, deployment, restart, or external send is authorized by this task. + +## Outcome + +- Added the nineteenth machine business route, `shared_child_order_batch_create`, with the Program-only directive `散拼团多个新增子单`; the external Agent continues to expose and validate only its original eighteen routes. +- The deterministic parser now accepts the inclusive `出发日期` range and emits customer, product, optional leader, passenger counts, and optional special requests under parser version `ltjt-program-parser-v1.0.7`. +- The browser adapter performs a complete read-only, per-date shared-plan enumeration before the write boundary, hard-matches the product on concrete native parent rows, rejects empty/incomplete/conflicting/oversized target sets, and binds each target to its parent group number, numeric `tid`, and departure date. +- Batch execution reuses the verified single-child path once per resolved parent in deterministic order, records per-parent outcomes and child-order receipts, stops at the first blocked/failed/uncertain result, leaves remaining targets `not_started`, and never retries a write automatically. +- Added schema, mapping, UI label, reply profile, migration `022_shared_child_order_batch_create.sql`, fail-closed authorization compatibility, parser/browser/Agent-boundary regression coverage, and Chrome extension release `0.5.170`. +- Rebuilt `dist/ltjt-order-assistant-0.5.170.zip` from the final extension source and synchronized `dist/release-manifest.json`; the previously tracked `0.5.169` archive is replaced by the new version and remains recoverable from Git history. +- No Skill, Agent prompt, Agent directive registry, Agent parsing schema, live ERP data, deployed service, or running process was changed. + +## Verification + +- `node --check chrome-extension/ltjt-order-assistant/background.js` — passed. +- `node --check chrome-extension/ltjt-order-assistant/inpage.js` — passed. +- `node --check chrome-extension/ltjt-order-assistant/operation-plans.js` — passed. +- Targeted Program parser and Program-only route tests — passed, including the supplied `10-06到11-03` sample and the Agent rejection boundary. +- Targeted operation-plan, lifecycle-contract, and external-Agent tests — passed. +- `node --run check` — passed. +- `node --run test:control-plane` — passed. +- `node --run test:legacy` — passed (277 tests). +- `node --run build` — passed. +- `node --run check:repo` — passed, including exact release archive/source and SHA-256 consistency. +- `git diff --check` — passed. + +## Follow-ups + +- Deployment is intentionally out of scope. Before production use, apply migration 022, deploy the control plane/platform changes, install extension `0.5.170`, and explicitly grant the new route to each intended non-administrator account; the migration does not auto-grant it. +- Run a separately authorized, bounded ERP acceptance task before broad production rollout because this task performed no live ERP write. + +## Promotion Candidates + +- Integration should promote parser `v1.0.7`, machine route count 19, Program-only route count 3, migration 022, and extension `0.5.170` into canonical project state, architecture, business-rule, and release records. +- A later documentation-only integration may clarify references to “18 machine routes” as “18 external-Agent routes / 19 total machine routes” without adding this Program-only business to any Skill or Agent processing path. diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 860d1a0..832474b 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -100,7 +100,7 @@ const persistedExtensionResultVersions = new Map(); let taskCreateInProgress = false; const AUTO_HANDOFF_RETRY_MS = 30_000; -const REQUIRED_EXTENSION_VERSION = '0.5.169'; +const REQUIRED_EXTENSION_VERSION = '0.5.170'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; const RECONCILE_LABEL = '只读回查 ERP 现有结果'; @@ -1194,8 +1194,8 @@ function renderAccounts() { 'p', 'muted account-authorization-summary', account.role === 'admin' - ? `任务权限:全部 ${accountTaskTypes.length || 18} 类业务` - : `任务权限:已授权 ${authorizedCount}/${accountTaskTypes.length || 18} 类业务` + ? `任务权限:全部 ${accountTaskTypes.length || 19} 类业务` + : `任务权限:已授权 ${authorizedCount}/${accountTaskTypes.length || 19} 类业务` )); main.append(el('p', 'muted', `最近登录:${account.last_login_at ? new Date(account.last_login_at).toLocaleString('zh-CN') : '尚未登录'}`)); const actions = el('div', 'channel-row-actions'); @@ -2095,6 +2095,7 @@ function taskSummary(operation) { team_order_batch_create: '团队-批量下单', shared_plan_create: '散拼-创建母团计划', shared_child_order_create: '散拼-录入子单', + shared_child_order_batch_create: '散拼-批量录入子单', order_update: '已有订单更新', passenger_list_import: '旅客名单导入', confirmation_export: '导出团队文件', diff --git a/LianSyn-platform/external-agent-client.mjs b/LianSyn-platform/external-agent-client.mjs index ec82b9c..90c3b79 100644 --- a/LianSyn-platform/external-agent-client.mjs +++ b/LianSyn-platform/external-agent-client.mjs @@ -1316,6 +1316,7 @@ const STANDARD_OPERATION_ACTIONS = new Set([ 'team_order_batch_create', 'shared_plan_create', 'shared_child_order_create', + 'shared_child_order_batch_create', 'order_update', 'passenger_list_import', 'arrangement_guide', @@ -1372,6 +1373,9 @@ const AGENT_ACTION_DATA_KEYS = Object.freeze({ order_restore: withAgentLookupKeys('existing_refs', 'transition'), confirmation_export: withAgentLookupKeys('existing_refs', 'confirmation', 'export_types', 'visitor_name') }); +const PROGRAM_SHARED_CHILD_BATCH_DATA_KEYS = new Set([ + 'customer', 'product', 'leader', 'recurrence', 'passenger_counts', 'special_requests' +]); const STANDARD_OPERATION_KEYS = new Set(['action', 'order_nature', 'submit_mode', 'source', 'data']); const STANDARD_SOURCE_KEYS = new Set(['test_context']); const STANDARD_DATA_KEYS = new Set([ @@ -1412,7 +1416,8 @@ const STANDARD_DATA_KEYS = new Set([ 'confirmation', 'system_defaults', 'validation_notes', - 'resolution' + 'resolution', + 'batch_targets' ]); const NAMED_REF_KEYS = new Set(['name', 'id', 'cpid', 'keyword', 'ltjt_id', 'contact', 'origin', 'source_region', 'resolved']); const PASSENGER_KEYS = new Set(['adult', 'child_bed', 'child_no_bed', 'infant', 'leader', 'expected_total']); @@ -2322,6 +2327,34 @@ export function validateAgentOperation(operation) { return [...new Set(errors)]; } +// Program-only routes deliberately stay outside AGENT_OPERATION_ACTIONS and +// the external Agent prompt. The deterministic parser uses this narrower +// entry point so a Program-only operation can share the downstream standard +// contract without becoming an Agent-recognized action. +export function validateProgramOperation(operation) { + if (operation?.action !== 'shared_child_order_batch_create') return validateAgentOperation(operation); + const errors = []; + if (!isPlainObject(operation)) return ['operation 必须是 JSON 对象。']; + pushUnknownKeyError('operation', operation, AGENT_OPERATION_KEYS, errors); + if (operation.order_nature !== 'formal') errors.push('operation.order_nature 必须是 formal。'); + if (operation.submit_mode !== 'dry_run') errors.push('operation.submit_mode 必须是 dry_run。'); + if (!isPlainObject(operation.data)) { + errors.push('operation.data 必须是对象。'); + return [...new Set(errors)]; + } + const data = operation.data; + pushUnknownKeyError('operation.data', data, PROGRAM_SHARED_CHILD_BATCH_DATA_KEYS, errors); + validateAgentNamedRef(data.customer, 'operation.data.customer', errors, { required: true }); + validateAgentNamedRef(data.product, 'operation.data.product', errors, { required: true }); + validateAgentNamedRef(data.leader, 'operation.data.leader', errors); + validateRecurrence(data.recurrence, 'operation.data.recurrence', errors); + validatePassengerCounts(data.passenger_counts, 'operation.data.passenger_counts', errors); + if (data.special_requests !== undefined && (typeof data.special_requests !== 'string' || !data.special_requests.trim())) { + errors.push('operation.data.special_requests 必须是非空字符串。'); + } + return [...new Set(errors)]; +} + function validateExistingRefs(value, path, errors, { required = false } = {}) { if (value === undefined || value === null) { if (required) errors.push(`${path} 必须包含已有业务标识。`); @@ -2330,6 +2363,31 @@ function validateExistingRefs(value, path, errors, { required = false } = {}) { if (!isPlainObject(value)) errors.push(`${path} 必须是对象。`); } +function validateSharedChildBatchTargets(value, path, errors) { + if (value === undefined) return; + if (!Array.isArray(value) || !value.length) { + errors.push(`${path} 必须是非空母团目标数组。`); + return; + } + if (value.length > 500) errors.push(`${path} 不能超过 500 个母团目标。`); + const keys = new Set(['parent_group_no', 'tid', 'departure_date']); + const identities = new Set(); + value.forEach((target, index) => { + const targetPath = `${path}[${index}]`; + if (!isPlainObject(target)) { + errors.push(`${targetPath} 必须是对象。`); + return; + } + pushUnknownKeyError(targetPath, target, keys, errors); + if (typeof target.parent_group_no !== 'string' || !target.parent_group_no.trim()) errors.push(`${targetPath}.parent_group_no 必须是非空字符串。`); + if (typeof target.tid !== 'string' || !/^\d+$/.test(target.tid)) errors.push(`${targetPath}.tid 必须是 ERP 数字引用。`); + if (!isValidDateString(target.departure_date)) errors.push(`${targetPath}.departure_date 必须是有效的 YYYY-MM-DD 日期。`); + const identity = `${String(target.tid || '').trim()}:${String(target.parent_group_no || '').trim()}`; + if (identities.has(identity)) errors.push(`${path} 不能包含重复母团目标:${identity}。`); + identities.add(identity); + }); +} + function validateLifecycleTestContext(operation, errors, { requireAllowlist = false, required = true } = {}) { const context = operation?.source?.test_context; if (!isPlainObject(context)) { @@ -2651,6 +2709,7 @@ function validateCommonOperationData(data, errors) { if (data.departure_dates !== undefined) validateDateList(data.departure_dates, 'operation.data.departure_dates', errors); if (data.recurrence !== undefined) validateRecurrence(data.recurrence, 'operation.data.recurrence', errors); if (data.passenger_counts !== undefined) validatePassengerCounts(data.passenger_counts, 'operation.data.passenger_counts', errors); + if (data.batch_targets !== undefined) validateSharedChildBatchTargets(data.batch_targets, 'operation.data.batch_targets', errors); if (data.split_order !== undefined) validateSplitOrder(data.split_order, 'operation.data.split_order', errors); if (data.room_counts !== undefined) validateRoomCounts(data.room_counts, 'operation.data.room_counts', errors); if (data.attachments !== undefined && (!Array.isArray(data.attachments) || data.attachments.some((item) => !isPlainObject(item) || typeof item.name !== 'string' || !item.name.trim()))) { @@ -2679,7 +2738,7 @@ export function validateStandardOperation(operation) { const data = operation.data; validateCommonOperationData(data, errors); - if (['team_order_create', 'team_order_batch_create', 'shared_plan_create'].includes(operation.action)) { + if (['team_order_create', 'team_order_batch_create', 'shared_plan_create', 'shared_child_order_batch_create'].includes(operation.action)) { for (const field of ['prices', 'op_user', 'sales_user']) { if (data[field] !== undefined) errors.push(`新增业务不允许输出 operation.data.${field},由 ERP 产品/登录默认值解析。`); } @@ -2713,6 +2772,37 @@ export function validateStandardOperation(operation) { for (const key of ['groups_per_date', 'plans_per_date']) { if (data[key] !== undefined && data[key] !== 1) errors.push(`operation.data.${key} 必须是 1。`); } + } else if (operation.action === 'shared_child_order_batch_create') { + validateNamedRef(data.customer, 'operation.data.customer', errors, { required: true }); + validateNamedRef(data.product, 'operation.data.product', errors, { required: true }); + validateNamedRef(data.leader, 'operation.data.leader', errors); + validateRecurrence(data.recurrence, 'operation.data.recurrence', errors); + validatePassengerCounts(data.passenger_counts, 'operation.data.passenger_counts', errors); + if (Array.isArray(data.batch_targets)) { + const start = String(data.recurrence?.start_date || ''); + const end = String(data.recurrence?.end_date || ''); + for (const target of data.batch_targets) { + if (isValidDateString(target?.departure_date) + && isValidDateString(start) + && isValidDateString(end) + && (target.departure_date < start || target.departure_date > end)) { + errors.push(`母团 ${String(target.parent_group_no || target.tid || '')} 的出发日期不在请求范围内。`); + } + } + const resolution = data.resolution; + if (!isPlainObject(resolution) + || resolution.stage !== 'erp_readonly' + || resolution.target !== 'batch' + || resolution.target_kind !== 'shared_plan' + || resolution.write_attempted !== false + || resolution.candidate_count !== data.batch_targets.length) { + errors.push('operation.data.resolution 必须与 ERP 只读解析得到的完整母团目标数一致。'); + } + if (data.batch_targets.some((target) => String(target?.parent_group_no || '').includes(TEST_MARKER)) + && !String(data.special_requests || '').includes(TEST_MARKER)) { + errors.push(`测试母团下的批量子单必须在 operation.data.special_requests 中保留 ${TEST_MARKER} 标记。`); + } + } } else if (operation.action === 'shared_child_order_create') { validateNamedRef(data.customer, 'operation.data.customer', errors, { required: true }); validatePassengerCounts(data.passenger_counts, 'operation.data.passenger_counts', errors); diff --git a/LianSyn-platform/external-agent-client.test.mjs b/LianSyn-platform/external-agent-client.test.mjs index 5d2265e..55958ad 100644 --- a/LianSyn-platform/external-agent-client.test.mjs +++ b/LianSyn-platform/external-agent-client.test.mjs @@ -16,6 +16,7 @@ import { parseJsonObject, parseSseBlock, validateAgentOperation, + validateProgramOperation, validateStandardOperation } from './external-agent-client.mjs'; import { createBusinessServer } from './server.mjs'; @@ -1464,6 +1465,25 @@ describe('SSE and result helpers', () => { assert.doesNotMatch(message, /TEST-202609|test_context|server_response|native_request|requery|fresh|write_attempted|DoInfo|allowlist/); }); + it('keeps shared-child batch creation Program-only and outside the external Agent router', () => { + const operation = { + action: 'shared_child_order_batch_create', + order_nature: 'formal', + submit_mode: 'dry_run', + data: { + customer: { name: '合成客户', keyword: '合成客户' }, + product: { name: '合成产品', keyword: '合成产品' }, + leader: { name: '测试领队', keyword: '测试领队' }, + recurrence: { start_date: '2026-10-06', end_date: '2026-11-03' }, + passenger_counts: { adult: 20, leader: 1 } + } + }; + assert.match(validateAgentOperation(operation).join('\n'), /当前 18 类业务/); + assert.deepEqual(validateProgramOperation(operation), []); + assert.deepEqual(validateStandardOperation(operation), []); + assert.equal(STANDARD_DIRECTIVE_ROUTES.some((route) => route.includes('散拼团多个新增子单')), false); + }); + it('pins the optional other/filing fields in the per-task guidance', () => { const message = buildParserMessage({ rawText: '安排其他/备案\n团号:LW-260903-A\n业务日期:09-03\n结算单位搜索:美女', diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index dfd3ac3..f2273a8 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -298,8 +298,8 @@

PARSER ROUTING CONTROL

-

18 项业务解析策略

-

程序与夹具即时验证后逐业务手动切换;两项名单导入固定为 Program-only,不参与 AI 回退。

+

19 项业务解析策略

+

程序与夹具即时验证后逐业务手动切换;两项名单导入及散拼批量子单固定为 Program-only,不参与 AI 回退。

diff --git a/README.md b/README.md index 14ced80..9d6b132 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ - Chrome 扩展制品版本由 [发布清单](dist/release-manifest.json) 定义;运行中的浏览器版本必须通过实时握手确认,不写入静态交接文档。 - 五个业务 Skill:`0.5.126`;运营 DOCX:`0.5.126`。 - Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories`。 -- 控制面确定性解析器:`ltjt-program-parser-v1.0.6`;18 项路由迁移后仍默认 `ai`,由操作台逐项晋级;两项名单 route 固定为 Program-only。 +- 控制面确定性解析器:`ltjt-program-parser-v1.0.7`;19 项路由中三项固定为 Program-only(两项名单及散拼批量子单),其余路由由操作台逐项晋级;新增批量子单不进入外部 Agent 路由。 - 独立团/散拼子单名单固定为 Program-only:文字用于业务定位,名单由单个 `.xls`/`.xlsx` 附件确定性规范化;附件到齐前任务保持等待。 - 生命周期契约:`ltjt-lifecycle-v2.9-roster-leader-contact-2026-08`。 - 新增的 SGL/TWN 与成人/占床儿童/不占床儿童/领队人数映射仍需授权 ERP 实写复测;未验证宽字段继续转人工复核或失败关闭。 @@ -28,7 +28,7 @@ ```text 手工输入 / AgentBus 输入 - → 18 项机器路由 + 任务级模式快照 + → 19 项机器路由 + 任务级模式快照 → AI / Shadow / Auto / Program 解析编排 → AI Skill 或业务系统确定性解析器 → 统一 operation 契约校验 diff --git a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md index f522d16..649afa5 100644 --- a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md +++ b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md @@ -2,7 +2,7 @@ ## 当前基线 -- Chrome 插件:`0.5.169` +- Chrome 插件:`0.5.170` - Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories` - 生命周期契约:`ltjt-lifecycle-v2.9-roster-leader-contact-2026-08` - 五个 Skill:`0.5.126`;运营 DOCX:`0.5.126` @@ -11,7 +11,7 @@ - `0.5.166` 在散拼母团列表入口加入立即检查、100ms 条件轮询和 15 秒总超时;每次探测都重新取得当前主 iframe document,按钮一旦可用便立即继续。该等待只发生在点击前,不固定拖慢快速页面,也不触发业务写入重试。 - `0.5.167` 保留插件侧的空闲证明和受控重载安全协议,并继续包含 `0.5.166` 的散拼入口自适应等待。中央服务私有 OSS/ECS 云助手编排及迁移 019 已按用户决定从主线撤回,因此这些插件更新消息在当前主线没有服务端触发方,属于休眠能力;插件仍按现有人工发布/加载流程使用。 - `0.5.168` 将 ERP 身份门禁从整页子串搜索收紧为唯一 `#Lable_UserName` 登录节点的规范化精确全等;缺失、重复、空白、子串或其他页面文字命中均不再让云电脑进入可执行状态,观察到的账号文本不离开扩展。 -- `0.5.169` 保留全部执行门禁,并将 ERP 写入不确定后的人工只读核验责任明确绑定到任务归属账号;管理员仍完全不进入任务数据面。 +- `0.5.170` 保留全部执行门禁,并新增 Program-only 散拼批量子单的“完整只读枚举后逐单执行、首错停止、不自动重试”门禁;ERP 写入不确定后的人工只读核验责任仍明确绑定到任务归属账号,管理员仍完全不进入任务数据面。 - 名单工作簿 normalizer `v1.4.0` 与业务输入/Skill `0.5.126` 改为只选择 12 个必需源字段;身份证、年龄、源证件类型及其他普通额外列不参与行识别、字段校验或 canonical 输出。必需字段完整性、宏/外链、全局超链接和外部公式等安全门槛保持失败关闭。 运行中的浏览器版本必须实时握手确认。当前制品文件名和 SHA-256 只看 [`../../../dist/release-manifest.json`](../../../dist/release-manifest.json),不从历史日志推断。 diff --git a/chrome-extension/ltjt-order-assistant/README.md b/chrome-extension/ltjt-order-assistant/README.md index 7e7ebc4..3ecf955 100644 --- a/chrome-extension/ltjt-order-assistant/README.md +++ b/chrome-extension/ltjt-order-assistant/README.md @@ -11,9 +11,11 @@ Chrome Manifest V3 扩展,在用户已登录的 LTJT ERP 页面内执行经过 ## 当前版本 -当前源码版本为 `0.5.169`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。 +当前源码版本为 `0.5.170`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。 -0.5.169 当前重点: +0.5.170 当前重点: + +- 新增 Program-only `shared_child_order_batch_create`:先逐日只读枚举日期段内所有产品匹配母团并锁定完整 `tid` 清单,再复用单母团子单路径顺序创建;首个失败或不确定结果立即停止且不自动重试。 - ERP 写入结果不确定时,扩展停止自动重试并明确要求任务归属账号做人工只读核验,不再把责任错误指向无任务数据面权限的管理员。 diff --git a/chrome-extension/ltjt-order-assistant/background.js b/chrome-extension/ltjt-order-assistant/background.js index e7afc10..bb2dd69 100644 --- a/chrome-extension/ltjt-order-assistant/background.js +++ b/chrome-extension/ltjt-order-assistant/background.js @@ -27,6 +27,7 @@ const TIMED_PAGE_ACTIONS = new Set([ 'verifyLifecycleOperation', 'createSplitParentLive', 'createSplitChildLive', + 'createSplitChildBatchLive', 'exportConfirmationSources', 'preflightRawInstruction', 'liveSubmitApproved', @@ -2315,6 +2316,36 @@ function erpResolutionFailure(operation, report = {}) { && operation.data.departure_dates.length === 1; const targetLabel = identifier ? `单号“${identifier}”` : '当前业务条件'; const blockers = Array.isArray(report.blockers) ? report.blockers.map(String) : []; + if (operation?.action === 'shared_child_order_batch_create') { + if (blockers.includes('shared_child_batch_no_matching_parent')) { + return { + errorCode: 'erp_target_not_found', + message: 'ERP 中未找到日期范围内与产品匹配的散拼母团,未进行写入;请核对出发日期和产品名称。' + }; + } + if (blockers.some((item) => item.startsWith('shared_child_batch_targets_exceed_'))) { + return { + errorCode: 'erp_target_limit_exceeded', + message: '匹配的散拼母团数量超过单次任务安全上限,未进行写入;请缩小日期范围。' + }; + } + if (blockers.some((item) => item.includes('reference_incomplete') || item.includes('identity_conflict') || item.includes('duplicate_tid_conflict'))) { + return { + errorCode: 'erp_target_reference_incomplete', + message: 'ERP 返回的批量母团目标存在不完整或冲突引用,未进行写入;请人工核对母团列表。' + }; + } + if (blockers.some((item) => item.startsWith('shared_child_batch_resolution_failed:'))) { + return { + errorCode: 'erp_target_lookup_failed', + message: 'ERP 批量母团检索失败,未进行写入;请确认 ERP 已登录后重试。' + }; + } + return { + errorCode: 'erp_target_resolution_failed', + message: 'ERP 无法完成日期范围内散拼母团的整批只读解析,未进行写入。' + }; + } const candidateBlocker = blockers.find((item) => /^erp_resolution_candidate_count:\d+$/u.test(item)); const candidateCount = candidateBlocker ? Number(candidateBlocker.split(':').pop()) : null; if (candidateCount === 0) { @@ -2677,6 +2708,7 @@ function browserExecutionAction(execution) { return { browser_live_split_parent: 'createSplitParentLive', browser_live_split_child: 'createSplitChildLive', + browser_live_split_child_batch: 'createSplitChildBatchLive', browser_export_source: 'exportConfirmationSources' }[execution] || ''; } @@ -2703,10 +2735,13 @@ async function executeConnectedBrowserOperation(taskId, operation, operationPlan try { tab = await findOrOpenErpTab(taskId); if (executionPlan.requiresErpResolution === true) { + const resolutionVerb = executionOperation.action === 'shared_child_order_batch_create' + ? '完整枚举' + : '唯一解析'; await setResult(taskId, { stage: 'erp_resolution', status: 'running', - message: `正在 ERP 中唯一解析${publicPlan.label || '业务对象'};未写入 ERP。`, + message: `正在 ERP 中${resolutionVerb}${publicPlan.label || '业务对象'};未写入 ERP。`, operation_plan: publicPlan, no_erp_write: true, summary: summaryFromOperation(executionOperation) @@ -3275,7 +3310,7 @@ async function runAutoTask(taskId, executionId) { if (operationPlan.execution === 'browser_lifecycle') { return await executeLifecycleOperation(normalizedTaskId, operation, operationPlan); } - if (['browser_live_split_parent', 'browser_live_split_child', 'browser_export_source'].includes(operationPlan.execution)) { + if (['browser_live_split_parent', 'browser_live_split_child', 'browser_live_split_child_batch', 'browser_export_source'].includes(operationPlan.execution)) { return await executeConnectedBrowserOperation(normalizedTaskId, operation, operationPlan); } if (operationPlan.execution === 'browser_dry_run') { diff --git a/chrome-extension/ltjt-order-assistant/inpage.js b/chrome-extension/ltjt-order-assistant/inpage.js index e8968eb..9f70767 100644 --- a/chrome-extension/ltjt-order-assistant/inpage.js +++ b/chrome-extension/ltjt-order-assistant/inpage.js @@ -4,6 +4,8 @@ const SPLIT_PARENT_RESPONSE_REQUERY_DELAYS_MS = Object.freeze([0, 750, 2000]); const SPLIT_PARENT_ENTRY_READY_TIMEOUT_MS = 15000; const SPLIT_PARENT_ENTRY_READY_INTERVAL_MS = 100; + const SHARED_CHILD_BATCH_MAX_DAYS = 366; + const SHARED_CHILD_BATCH_MAX_TARGETS = 500; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -1730,7 +1732,7 @@ // ERP native primary lookup. Product and leader are optional facts whose // visible row text may narrow multiple candidates, but they must never // turn a valid primary lookup into a false zero-result native search. - if (action === 'shared_child_order_create') return ['product']; + if (action === 'shared_child_order_create' || action === 'shared_child_order_batch_create') return ['product']; if (action === 'order_update_shared_plan') return ['customer', 'product', 'leader']; const sharedChildTarget = criteria.target_kind === 'shared_child_order'; const independentTarget = !['shared_child_order', 'shared_plan'].includes(criteria.target_kind) @@ -1794,10 +1796,11 @@ const sharedChildTarget = criteria.target_kind === 'shared_child_order'; const sharedPlanUpdate = criteria.action === 'order_update_shared_plan'; const supplementalFields = new Set(supplementalLifecycleCandidateFields(criteria)); - const customerFilter = criteria.action === 'shared_child_order_create' || sharedChildTarget || sharedPlanUpdate + const childCreate = criteria.action === 'shared_child_order_create' || criteria.action === 'shared_child_order_batch_create'; + const customerFilter = childCreate || sharedChildTarget || sharedPlanUpdate ? '' : criteria.customer || ''; - const leaderFilter = criteria.action === 'shared_child_order_create' || supplementalFields.has('leader') + const leaderFilter = childCreate || supplementalFields.has('leader') ? '' : criteria.leader || ''; const searchValues = { @@ -1974,6 +1977,169 @@ }; } + function sharedPlanBatchCandidates(scopeDocument, departureDate, productKeyword) { + const candidatesByTid = new Map(); + const conflicts = []; + for (const row of Array.from(scopeDocument.querySelectorAll('tr'))) { + const links = linksForLifecycleRow(row); + const entryLinks = links.map((link) => ({ link, args: openUpdateArguments(link.element) })) + .filter((entry) => String(entry.link.element?.tagName || '').toLowerCase() === 'a' + && (!entry.link.element.closest || entry.link.element.closest('tr') === row) + && entry.args?.first + && entry.args.second === '0'); + if (!entryLinks.length) continue; + const rowText = textOf(row); + if (!rowText || !lookupKeywordMatchesText(rowText, productKeyword)) continue; + const groupNo = extractGroupNumber(rowText); + const tids = [...new Set(entryLinks.map((entry) => String(entry.args.first || '')).filter(Boolean))]; + if (tids.length !== 1) { + conflicts.push({ tid: tids.join(','), groups: [groupNo].filter(Boolean) }); + continue; + } + for (const tid of tids) { + const candidate = { + kind: 'shared_plan', + identifier: groupNo, + parent_group_no: groupNo, + plan_no: groupNo, + tid, + departure_date: departureDate, + row_id: row.id || '', + row_text: rowText.slice(0, 500), + internal_ref_complete: Boolean(groupNo && /^\d+$/.test(tid)) + }; + const previous = candidatesByTid.get(tid); + if (previous && previous.parent_group_no !== candidate.parent_group_no) { + conflicts.push({ tid, groups: [previous.parent_group_no, candidate.parent_group_no].filter(Boolean) }); + continue; + } + if (!previous || (!previous.internal_ref_complete && candidate.internal_ref_complete)) candidatesByTid.set(tid, candidate); + } + } + return { candidates: [...candidatesByTid.values()], conflicts }; + } + + function inclusiveDateRange(startDate, endDate) { + if (!isValidStrictDate(startDate) || !isValidStrictDate(endDate) || startDate > endDate) return []; + const dates = []; + let cursor = startDate; + while (cursor <= endDate && dates.length <= SHARED_CHILD_BATCH_MAX_DAYS) { + dates.push(cursor); + cursor = strictIsoDate(addDays(cursor, 1)); + } + return dates; + } + + async function resolveSharedChildBatchOperation(operation = {}) { + const data = operation.data || {}; + const recurrence = data.recurrence || {}; + const startDate = String(recurrence.start_date || ''); + const endDate = String(recurrence.end_date || ''); + const productKeyword = namedLookupKeyword(data.product); + const dates = inclusiveDateRange(startDate, endDate); + const blockers = []; + if (!productKeyword) blockers.push('shared_child_batch_product_missing'); + if (!dates.length) blockers.push('shared_child_batch_date_range_invalid'); + if (dates.length > SHARED_CHILD_BATCH_MAX_DAYS) blockers.push(`shared_child_batch_date_range_exceeds_${SHARED_CHILD_BATCH_MAX_DAYS}_days`); + if (blockers.length) return lifecycleRouteOutcome('erp_resolution_blocked', operation, { blockers }); + + const targets = []; + const evidence = []; + try { + for (const departureDate of dates) { + const criteria = { + action: 'shared_child_order_batch_create', + target_kind: 'shared_plan', + identifier: '', + departure_date: departureDate, + customer: '', + product: productKeyword, + leader: '', + mode: 'business_fields' + }; + const planDocument = await searchLifecycleList( + '/System/Business/plan.asp', + /\/system\/business\/plan\.asp$/i, + criteria, + 'plan', + '' + ); + const result = sharedPlanBatchCandidates(planDocument, departureDate, productKeyword); + evidence.push({ + departure_date: departureDate, + matching_parent_count: result.candidates.length, + native_list_search: planDocument.__ltjtNativeListSearchEvidence || null + }); + if (result.conflicts.length) blockers.push(`shared_child_batch_parent_identity_conflict:${departureDate}`); + if (result.candidates.some((candidate) => !candidate.internal_ref_complete)) { + blockers.push(`shared_child_batch_parent_reference_incomplete:${departureDate}`); + } + targets.push(...result.candidates); + if (targets.length > SHARED_CHILD_BATCH_MAX_TARGETS) { + blockers.push(`shared_child_batch_targets_exceed_${SHARED_CHILD_BATCH_MAX_TARGETS}`); + break; + } + } + } catch (error) { + blockers.push(`shared_child_batch_resolution_failed:${error?.message || String(error)}`); + } + + const uniqueTargets = new Map(); + for (const target of targets) { + const key = String(target.tid || ''); + const previous = uniqueTargets.get(key); + if (previous && (previous.parent_group_no !== target.parent_group_no || previous.departure_date !== target.departure_date)) { + blockers.push(`shared_child_batch_duplicate_tid_conflict:${key}`); + } else if (!previous) { + uniqueTargets.set(key, target); + } + } + if (!uniqueTargets.size) blockers.push('shared_child_batch_no_matching_parent'); + if (blockers.length) { + return lifecycleRouteOutcome('erp_resolution_blocked', operation, { + blockers: [...new Set(blockers)], + preflight: { + stage: 'erp_readonly_batch_resolution', + date_count: dates.length, + candidate_count: uniqueTargets.size, + evidence + } + }); + } + + const batchTargets = [...uniqueTargets.values()] + .sort((left, right) => left.departure_date.localeCompare(right.departure_date) + || Number(left.tid) - Number(right.tid) + || left.parent_group_no.localeCompare(right.parent_group_no)) + .map((target) => ({ + parent_group_no: target.parent_group_no, + tid: target.tid, + departure_date: target.departure_date + })); + const resolvedOperation = cloneOperation(operation); + resolvedOperation.data = { + ...(resolvedOperation.data || {}), + batch_targets: batchTargets, + resolution: { + stage: 'erp_readonly', + target: 'batch', + candidate_count: batchTargets.length, + target_kind: 'shared_plan', + write_attempted: false + } + }; + return lifecycleRouteOutcome('erp_resolution_ready', resolvedOperation, { + resolvedOperation, + preflight: { + stage: 'erp_readonly_batch_resolution', + date_count: dates.length, + candidate_count: batchTargets.length, + evidence + }, + requery: { matched: true, stage: 'erp_readonly_batch_resolution', candidate_count: batchTargets.length } + }); + } + function sharedChildCandidatesFromRows(rows, criteria, scopeDocument = null) { const selectedEntries = []; for (const row of rows) { @@ -2241,6 +2407,7 @@ } async function resolveLifecycleOperation(operation = {}) { + if (operation.action === 'shared_child_order_batch_create') return resolveSharedChildBatchOperation(operation); const criteria = lifecycleSearchCriteria(operation); const identifier = criteria.identifier; const kinds = allowedResolutionKinds(operation); @@ -4413,6 +4580,124 @@ }; } + async function createSplitChildBatchLive(operation = {}) { + const data = operation.data || {}; + const targets = Array.isArray(data.batch_targets) ? data.batch_targets : []; + const blockers = []; + if (operation.action !== 'shared_child_order_batch_create') blockers.push('split child batch adapter action mismatch'); + if (!targets.length) blockers.push('split child batch targets are missing'); + if (targets.length > SHARED_CHILD_BATCH_MAX_TARGETS) blockers.push('split child batch target limit exceeded'); + if (!data.customer?.name) blockers.push('split child batch customer is missing'); + if (!data.product?.name) blockers.push('split child batch product is missing'); + if (passengerTotal(data.passenger_counts || {}) <= 0) blockers.push('split child batch passenger total must be greater than zero'); + const identities = new Set(); + for (const target of targets) { + const parent = String(target?.parent_group_no || '').trim(); + const tid = String(target?.tid || '').trim(); + const departureDate = String(target?.departure_date || '').trim(); + if (!parent || !/^\d+$/.test(tid) || !isValidStrictDate(departureDate)) blockers.push('split child batch target is incomplete'); + const identity = `${tid}:${parent}`; + if (identities.has(identity)) blockers.push(`split child batch target is duplicated: ${identity}`); + identities.add(identity); + } + if (blockers.length) { + return { + status: 'split_child_batch_blocked', + blockers: [...new Set(blockers)], + batch_results: [], + no_erp_write: true, + write_attempted: false + }; + } + + const batchResults = []; + let writeAttempted = false; + for (let index = 0; index < targets.length; index += 1) { + const target = targets[index]; + const { + batch_targets: _batchTargets, + recurrence: _recurrence, + resolution: _resolution, + ...childData + } = data; + const childOperation = { + action: 'shared_child_order_create', + order_nature: operation.order_nature, + submit_mode: operation.submit_mode, + data: { + ...childData, + existing_refs: { + kind: 'shared_plan', + identifier: target.parent_group_no, + parent_group_no: target.parent_group_no, + tid: target.tid + }, + departure_dates: [target.departure_date] + } + }; + const result = await createSplitChildLive(childOperation); + writeAttempted = writeAttempted || result.write_attempted === true || result.no_erp_write === false; + const completed = result.status === 'split_child_completed'; + batchResults.push({ + index: index + 1, + status: completed ? 'completed' : (/uncertain/i.test(String(result.status || '')) ? 'uncertain' : 'stopped'), + parent_group_no: target.parent_group_no, + tid: target.tid, + departure_date: target.departure_date, + child_order_no: String(result.erp_receipt?.order_number || ''), + adapter_status: String(result.status || ''), + blockers: Array.isArray(result.blockers) ? result.blockers : [] + }); + if (!completed) { + for (let pending = index + 1; pending < targets.length; pending += 1) { + batchResults.push({ + index: pending + 1, + status: 'not_started', + parent_group_no: targets[pending].parent_group_no, + tid: targets[pending].tid, + departure_date: targets[pending].departure_date, + child_order_no: '', + adapter_status: '', + blockers: [] + }); + } + return { + status: writeAttempted ? 'split_child_batch_stopped_after_write' : 'split_child_batch_blocked', + blockers: Array.isArray(result.blockers) && result.blockers.length + ? result.blockers + : ['split child batch stopped because one target did not complete'], + batch_results: batchResults, + completed_count: batchResults.filter((item) => item.status === 'completed').length, + target_count: targets.length, + no_erp_write: !writeAttempted, + write_attempted: writeAttempted, + manual_review_required: writeAttempted || /uncertain/i.test(String(result.status || '')) + }; + } + } + + const orderNumbers = batchResults.map((item) => item.child_order_no).filter(Boolean); + return { + status: 'split_child_batch_completed', + blockers: [], + batch_results: batchResults, + completed_count: batchResults.length, + target_count: targets.length, + no_erp_write: false, + write_attempted: true, + erp_receipt: { + order_numbers: orderNumbers, + order_count: orderNumbers.length, + parent_count: targets.length + }, + verification: { + status: orderNumbers.length === targets.length ? 'all_child_orders_verified' : 'child_order_receipt_count_mismatch', + verified_count: orderNumbers.length, + target_count: targets.length + } + }; + } + function normalizeExportType(value) { const key = String(value || '').trim().toLowerCase(); return ({ @@ -6118,7 +6403,7 @@ } window.LTJTOrderAssistant = { - version: '0.5.169', + version: '0.5.170', resolveNativeListSearchValues, lookupKeywordMatchesText, inspectLifecycleSearchCriteria: lifecycleSearchCriteria, @@ -6142,6 +6427,8 @@ inspectSupplementalCandidateChecks(candidates, criteria = {}) { return applySupplementalCandidateChecks(candidates, criteria); }, + inspectSharedPlanBatchCandidates: sharedPlanBatchCandidates, + inspectInclusiveDateRange: inclusiveDateRange, inspectNativeListRequestMatches(settings, scopeDocument) { return nativeListRequestMatches(settings, scopeDocument); }, @@ -6165,6 +6452,7 @@ fillSplitChildLeaderTransfer, createSplitParentLive, createSplitChildLive, + createSplitChildBatchLive, exportConfirmationSources }; })(); diff --git a/chrome-extension/ltjt-order-assistant/manifest.json b/chrome-extension/ltjt-order-assistant/manifest.json index 1a434d5..af3e461 100644 --- a/chrome-extension/ltjt-order-assistant/manifest.json +++ b/chrome-extension/ltjt-order-assistant/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "联泰下单助手", - "version": "0.5.169", + "version": "0.5.170", "description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。", "permissions": [ "activeTab", diff --git a/chrome-extension/ltjt-order-assistant/operation-plans.js b/chrome-extension/ltjt-order-assistant/operation-plans.js index 5b5056d..a744d54 100644 --- a/chrome-extension/ltjt-order-assistant/operation-plans.js +++ b/chrome-extension/ltjt-order-assistant/operation-plans.js @@ -37,6 +37,14 @@ capabilityState: 'validated_single_parent_child_create', source: 'handoff-browser-validated' }, + shared_child_order_batch_create: { + label: '散拼-批量录入子单', + route: 'split_child_batch', + execution: 'browser_live_split_child_batch', + liveSubmit: 'guarded-production', + capabilityState: 'program_only_resolve_all_then_sequential_create', + source: 'program-browser-guarded' + }, passenger_list_import: { label: '旅客名单导入/补充', route: 'passenger_import', @@ -171,6 +179,7 @@ ]); const ERP_RESOLUTION_ACTIONS = new Set([ 'shared_child_order_create', + 'shared_child_order_batch_create', 'passenger_list_import', 'arrangement_guide', 'arrangement_vehicle', @@ -245,7 +254,8 @@ 'planned_capacity', 'cycle', 'groups_per_date', 'plans_per_date', 'parent_group_no', 'child_order_no', 'passenger_counts', 'split_order', 'room_counts', 'prices', 'op_user', 'sales_user', 'logistics', 'special_requests', 'passenger_list', 'attachments', 'existing_refs', 'updates', 'arrangement', 'transition', 'delete_guard', - 'receivable_fixture', 'export_types', 'recovery', 'confirmation', 'visitor_name', 'system_defaults', 'validation_notes', 'resolution' + 'receivable_fixture', 'export_types', 'recovery', 'confirmation', 'visitor_name', 'system_defaults', 'validation_notes', 'resolution', + 'batch_targets' ]); const DISPATCH_OPERATION_KEYS = new Set(['action', 'order_nature', 'submit_mode', 'source', 'data']); const DISPATCH_NAMED_REF_KEYS = new Set(['name', 'keyword', 'source_region']); @@ -254,6 +264,7 @@ const withBusinessLookupKeys = (...keys) => new Set([...keys, ...BUSINESS_LOOKUP_DATA_KEYS]); const DISPATCH_DATA_KEYS = Object.freeze({ shared_child_order_create: withBusinessLookupKeys('existing_refs', 'passenger_counts', 'special_requests'), + shared_child_order_batch_create: new Set(['customer', 'product', 'leader', 'recurrence', 'passenger_counts', 'special_requests']), passenger_list_import: withBusinessLookupKeys('existing_refs', 'passenger_list'), arrangement_guide: new Set(['existing_refs', 'arrangement']), arrangement_vehicle: new Set(['existing_refs', 'arrangement']), @@ -531,6 +542,54 @@ if (value.some((date) => !isValidDate(date))) blockers.push(`${label} 必须只包含有效的 YYYY-MM-DD 日期。`); } + function validateRecurrenceRange(value, label, blockers) { + if (!isPlainObject(value)) { + blockers.push(`${label} 必须包含有效的 start_date/end_date。`); + return; + } + if (Object.keys(value).some((key) => !new Set(['start_date', 'end_date']).has(key))) { + blockers.push(`${label} 只允许 start_date/end_date。`); + } + if (!isValidDate(value.start_date) || !isValidDate(value.end_date)) { + blockers.push(`${label} 必须包含有效的 start_date/end_date。`); + return; + } + if (value.start_date > value.end_date) blockers.push(`${label}.start_date 不能晚于 end_date。`); + } + + function validateSharedChildBatchTargets(value, recurrence, blockers) { + if (!Array.isArray(value) || !value.length) { + blockers.push('data.batch_targets 必须是 ERP 只读解析得到的非空母团目标数组。'); + return; + } + if (value.length > 500) blockers.push('data.batch_targets 不能超过 500 个母团目标。'); + const identities = new Set(); + value.forEach((target, index) => { + const label = `data.batch_targets[${index}]`; + if (!isPlainObject(target)) { + blockers.push(`${label} 必须是对象。`); + return; + } + if (Object.keys(target).some((key) => !new Set(['parent_group_no', 'tid', 'departure_date']).has(key))) { + blockers.push(`${label} 包含未声明字段。`); + } + const parent = String(target.parent_group_no || '').trim(); + const tid = String(target.tid || '').trim(); + if (!parent) blockers.push(`${label}.parent_group_no 必须是非空字符串。`); + if (!/^\d+$/.test(tid)) blockers.push(`${label}.tid 必须是 ERP 数字引用。`); + if (!isValidDate(target.departure_date)) blockers.push(`${label}.departure_date 必须是有效日期。`); + if (isValidDate(target.departure_date) + && isValidDate(recurrence?.start_date) + && isValidDate(recurrence?.end_date) + && (target.departure_date < recurrence.start_date || target.departure_date > recurrence.end_date)) { + blockers.push(`${label}.departure_date 不在请求日期范围内。`); + } + const identity = `${tid}:${parent}`; + if (identities.has(identity)) blockers.push(`data.batch_targets 包含重复母团目标:${identity}。`); + identities.add(identity); + }); + } + function validatePassengerCounts(value, blockers) { if (!isPlainObject(value)) { blockers.push('passenger_counts 必须是稀疏人数对象。'); @@ -1216,6 +1275,13 @@ validateDispatchNamedRef(data.customer, 'data.customer', blockers, { required: true }); validatePassengerCounts(data.passenger_counts, blockers); validateDispatchBusinessLookup(data, blockers); + } else if (isPlainObject(data) && action === 'shared_child_order_batch_create') { + validateDispatchNamedRef(data.customer, 'data.customer', blockers, { required: true }); + validateDispatchNamedRef(data.product, 'data.product', blockers, { required: true }); + validateDispatchNamedRef(data.leader, 'data.leader', blockers); + validateRecurrenceRange(data.recurrence, 'data.recurrence', blockers); + validatePassengerCounts(data.passenger_counts, blockers); + warnings.push('批量散拼子单先按日期范围逐日只读枚举所有产品匹配的母团,完整锁定目标后再按顺序逐单创建;遇到首个异常立即停止且不自动重试。'); } else if (isPlainObject(data) && action === 'passenger_list_import') { validateDispatchExistingRefs(data.existing_refs, 'data.existing_refs', blockers, { requireIdentifier: false, @@ -1394,6 +1460,31 @@ } if (data.departure_dates !== undefined) validateDates(data.departure_dates, 'departure_dates', blockers, { min: 1, max: 1 }); warnings.push('散拼子单使用已验证的浏览器提交路径;优先按母团团号、tid 和单一日期锁定唯一母团,再唯一匹配客户并回查具体子单引用。'); + } else if (action === 'shared_child_order_batch_create') { + validateNamedRef(data.customer, 'data.customer', blockers, { required: true }); + validateNamedRef(data.product, 'data.product', blockers, { required: true }); + validateNamedRef(data.leader, 'data.leader', blockers); + validateRecurrenceRange(data.recurrence, 'data.recurrence', blockers); + validatePassengerCounts(data.passenger_counts, blockers); + validateSharedChildBatchTargets(data.batch_targets, data.recurrence, blockers); + if (Array.isArray(data.batch_targets) + && data.batch_targets.some((target) => String(target?.parent_group_no || '').includes(TEST_MARKER)) + && !String(data.special_requests || '').includes(TEST_MARKER)) { + blockers.push(`测试母团下的批量子单必须在 special_requests 中保留 ${TEST_MARKER} 标记。`); + } + const resolution = data.resolution; + if (!isPlainObject(resolution) + || resolution.stage !== 'erp_readonly' + || resolution.target !== 'batch' + || resolution.target_kind !== 'shared_plan' + || resolution.write_attempted !== false + || resolution.candidate_count !== data.batch_targets?.length) { + blockers.push('data.resolution 必须证明全部母团已在 ERP 中完成只读解析。'); + } + if (data.prices !== undefined) blockers.push('新增业务不允许输出 prices,由 ERP 表单默认值处理。'); + if (data.op_user !== undefined) blockers.push('新增业务不允许输出 op_user,由 ERP 登录默认值解析。'); + if (data.sales_user !== undefined) blockers.push('新增业务不允许输出 sales_user,由 ERP 登录默认值解析。'); + warnings.push('批量散拼子单使用预解析的完整母团目标清单顺序执行;每个母团只创建一个子单,首个失败或不确定结果后停止且不会自动重试。'); } else if (action === 'passenger_list_import') { const testContext = validateOptionalLifecycleTestContext(operation, blockers); validateLifecycleRefs(data, blockers, action, testContext); @@ -1498,7 +1589,7 @@ exportTypes: action === 'confirmation_export' ? exportTypesForOperation(operation, data) : [], noErpWrite: execution === 'browser_lifecycle' ? false - : !['browser_team_single', 'browser_team_batch_native', 'browser_live_split_parent', 'browser_live_split_child'].includes(execution), + : !['browser_team_single', 'browser_team_batch_native', 'browser_live_split_parent', 'browser_live_split_child', 'browser_live_split_child_batch'].includes(execution), test_only: Boolean(definition.testOnly), }; } diff --git a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js index 4109694..98701d2 100644 --- a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js +++ b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js @@ -944,7 +944,7 @@ window.LTJTOrderAssistant = { ...(window.LTJTOrderAssistant || {}), - version: '0.5.169', + version: '0.5.170', openTeamBatchForm, pingTeamBatchFrame, preflightTeamBatchNative, diff --git a/control-plane/README.md b/control-plane/README.md index 3715c32..f3a44e1 100644 --- a/control-plane/README.md +++ b/control-plane/README.md @@ -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`,避免任务在数据库结构未升级时进入解析队列。 diff --git a/control-plane/migrations/022_shared_child_order_batch_create.sql b/control-plane/migrations/022_shared_child_order_batch_create.sql new file mode 100644 index 0000000..8b3246d --- /dev/null +++ b/control-plane/migrations/022_shared_child_order_batch_create.sql @@ -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' + )); diff --git a/control-plane/src/business-routes.ts b/control-plane/src/business-routes.ts index 2d35e60..c78ea3a 100644 --- a/control-plane/src/business-routes.ts +++ b/control-plane/src/business-routes.ts @@ -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, diff --git a/control-plane/src/db.ts b/control-plane/src/db.ts index 8d85eb4..e1c04d6 100644 --- a/control-plane/src/db.ts +++ b/control-plane/src/db.ts @@ -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; diff --git a/control-plane/src/parser-contract.ts b/control-plane/src/parser-contract.ts index 46ffe9f..934b333 100644 --- a/control-plane/src/parser-contract.ts +++ b/control-plane/src/parser-contract.ts @@ -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 | null = null; @@ -18,7 +18,7 @@ async function loadContractModule(): Promise { export async function validateParsedOperation(operation: unknown): Promise { const contract = await loadContractModule(); - return contract.validateAgentOperation(operation); + return contract.validateProgramOperation(operation); } const EXPORT_TYPE_ORDER = [ diff --git a/control-plane/src/program-parser.ts b/control-plane/src/program-parser.ts index 526b8c3..8629f18 100644 --- a/control-plane/src/program-parser.ts +++ b/control-plane/src/program-parser.ts @@ -64,6 +64,7 @@ const NEXT_OCCURRENCE_DATE_ROUTES = new Set([ '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 { +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), diff --git a/control-plane/test/program-parser.test.ts b/control-plane/test/program-parser.test.ts index 6ae1144..9ed6896 100644 --- a/control-plane/test/program-parser.test.ts +++ b/control-plane/test/program-parser.test.ts @@ -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'); diff --git a/dist/ltjt-order-assistant-0.5.169.zip b/dist/ltjt-order-assistant-0.5.169.zip deleted file mode 100644 index d52abf9..0000000 Binary files a/dist/ltjt-order-assistant-0.5.169.zip and /dev/null differ diff --git a/dist/ltjt-order-assistant-0.5.170.zip b/dist/ltjt-order-assistant-0.5.170.zip new file mode 100644 index 0000000..039df3b Binary files /dev/null and b/dist/ltjt-order-assistant-0.5.170.zip differ diff --git a/dist/release-manifest.json b/dist/release-manifest.json index b202e57..a38905f 100644 --- a/dist/release-manifest.json +++ b/dist/release-manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 1, - "generated_on": "2026-09-07", + "generated_on": "2026-09-08", "baselines": { - "chrome_extension": "0.5.169", + "chrome_extension": "0.5.170", "skills": "0.5.126", "business_instruction_docx": "0.5.126", "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" @@ -10,10 +10,10 @@ "artifacts": [ { "kind": "chrome_extension", - "version": "0.5.169", - "path": "dist/ltjt-order-assistant-0.5.169.zip", + "version": "0.5.170", + "path": "dist/ltjt-order-assistant-0.5.170.zip", "source": "chrome-extension/ltjt-order-assistant", - "sha256": "00ad06a1640d36cfe7df8c9e52d677131793c70d493392b551b7ecbd965c854b" + "sha256": "c041d13f468f46f1130668ae67701342b0dd920c5be20c5b47b97732384f48d8" }, { "kind": "skill", diff --git a/mappings/lifecycle.mapping.json b/mappings/lifecycle.mapping.json index d241bc3..13e16d3 100644 --- a/mappings/lifecycle.mapping.json +++ b/mappings/lifecycle.mapping.json @@ -1,7 +1,7 @@ { "contract_version": "ltjt-lifecycle-v2.9-roster-leader-contact-2026-08", "current_agent_parse_prompt_version": "ltjt-agent-prompt-v1.8-independent-headcount-categories", - "current_extension_version": "0.5.169", + "current_extension_version": "0.5.170", "historical_test_marker": "TEST-202609", "scope": "/System/Business/", "erp_session_keepalive": { @@ -94,7 +94,7 @@ "declared formats and narrow capability scope" ], "erp_resolution_requires": [ - "exactly one target object from a user-visible identifier", + "exactly one target object from a user-visible identifier, or one complete non-empty shared-plan target set for Program-only shared-child batch creation", "ERP-derived kind/tid and applicable ddid", "exactly one resource candidate when the operation uses a named ERP resource", "fresh current status and current values when required" @@ -136,6 +136,7 @@ "shared_plan_create without multi-date split_order facts", "multi-date shared_plan_create with split_order customer/passenger facts: E2E3 and E2E4 each produced one concrete child per date with persisted customer and 15+1", "shared_child_order_create for one exact parent", + "shared_child_order_batch_create resolves every product-matching parent in the inclusive date range before sequentially reusing the exact-parent child adapter", "passenger_list_import full-list first_import or confirmed full_replace", "five arrangement create/clear operations at status 未确认", "arrangement_hotel update for one exact 未确认 row: end_date and room_count only", diff --git a/samples/business-parser-gold-corpus.json b/samples/business-parser-gold-corpus.json index 3af97b5..280261b 100644 --- a/samples/business-parser-gold-corpus.json +++ b/samples/business-parser-gold-corpus.json @@ -1,7 +1,7 @@ { - "version": "ltjt-program-parser-v1.0.6", + "version": "ltjt-program-parser-v1.0.7", "received_at": "2026-08-25T09:00:00+08:00", - "description": "脱敏的 18 路由确定性解析 gold corpus;只包含合成业务值。", + "description": "脱敏的 19 路由确定性解析 gold corpus;只包含合成业务值。", "cases": [ { "id": "official-team-order-create", @@ -31,6 +31,13 @@ "expected_action": "shared_child_order_create", "input": "散拼团单个新增子单\n预订客户:合成客户乙\n出发日期:2026-09-15\n产品名称:合成产品丙\n领队:测试领队\n人数:15+1" }, + { + "id": "official-shared-child-batch-create", + "route_id": "shared_child_order_batch_create", + "expected_status": "agent_parse_passed", + "expected_action": "shared_child_order_batch_create", + "input": "散拼团多个新增子单\n预订客户:合成客户乙\n出发日期:10-06到11-03\n产品名称:合成产品丙\n领队:测试领队\n人数:20+1" + }, { "id": "official-passenger-independent", "route_id": "passenger_list_import_independent", diff --git a/schemas/standard_system_operation.schema.json b/schemas/standard_system_operation.schema.json index 4fdfd2d..2ca6934 100644 --- a/schemas/standard_system_operation.schema.json +++ b/schemas/standard_system_operation.schema.json @@ -15,6 +15,7 @@ "team_order_batch_create", "shared_plan_create", "shared_child_order_create", + "shared_child_order_batch_create", "order_update", "passenger_list_import", "arrangement_guide", @@ -235,6 +236,19 @@ } } }, + { + "if": { + "properties": { "action": { "const": "shared_child_order_batch_create" } }, + "required": ["action"] + }, + "then": { + "properties": { + "data": { + "required": ["customer", "product", "recurrence", "passenger_counts"] + } + } + } + }, { "if": { "properties": { "action": { "const": "order_update" } }, @@ -1032,6 +1046,13 @@ "items": { "$ref": "#/$defs/attachment" } }, "existing_refs": { "$ref": "#/$defs/existingRefs" }, + "batch_targets": { + "type": "array", + "minItems": 1, + "maxItems": 500, + "uniqueItems": true, + "items": { "$ref": "#/$defs/sharedPlanBatchTarget" } + }, "updates": { "type": "object", "description": "Structured update actions. The browser adapter narrows targets again before any write.", @@ -1137,12 +1158,22 @@ } }, "stage": { "const": "erp_readonly" }, - "target": { "const": "unique" }, - "candidate_count": { "const": 1 }, + "target": { "type": "string", "enum": ["unique", "batch"] }, + "candidate_count": { "type": "integer", "minimum": 1, "maximum": 500 }, "target_kind": { "type": "string", "enum": ["independent_order", "shared_plan", "shared_child_order"] }, "write_attempted": { "const": false } } }, + "sharedPlanBatchTarget": { + "type": "object", + "additionalProperties": false, + "required": ["parent_group_no", "tid", "departure_date"], + "properties": { + "parent_group_no": { "type": "string", "minLength": 1 }, + "tid": { "type": "string", "pattern": "^\\d+$" }, + "departure_date": { "type": "string", "format": "date" } + } + }, "namedRef": { "type": "object", "additionalProperties": false, diff --git a/tools/lifecycle-contract.test.mjs b/tools/lifecycle-contract.test.mjs index 8430b40..a43256c 100644 --- a/tools/lifecycle-contract.test.mjs +++ b/tools/lifecycle-contract.test.mjs @@ -405,6 +405,62 @@ test('identifier-only native list searches receive a calendar-safe plus/minus on assert.match(adapter, /listExpectedPresent && present && !ddidMatched/); }); +test('shared-child batch resolver expands the inclusive range and hard-matches concrete parent entry rows by product', async () => { + const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); + const sandbox = { window: {} }; + vm.runInNewContext(inpage, sandbox); + const assistant = sandbox.window.LTJTOrderAssistant; + const dates = Array.from(assistant.inspectInclusiveDateRange('2026-10-06', '2026-11-03')); + assert.equal(dates.length, 29); + assert.equal(dates[0], '2026-10-06'); + assert.equal(dates.at(-1), '2026-11-03'); + + const parentLink = { + tagName: 'A', + textContent: '新增子单', + closest() { return parentRow; }, + getAttribute(name) { + return name === 'onclick' ? "OPEN_update('61006','0')" : ''; + } + }; + const parentRow = { + id: 'tr_61006', + tagName: 'TR', + textContent: 'LW-261006-A 老挝行程--广东 2026-10-6', + getAttribute() { return ''; }, + querySelectorAll(selector) { return selector === 'a' ? [parentLink] : []; } + }; + const scopeDocument = { querySelectorAll: () => [parentRow] }; + const matched = assistant.inspectSharedPlanBatchCandidates(scopeDocument, '2026-10-06', '老挝行程--广东'); + assert.deepEqual(JSON.parse(JSON.stringify(matched.candidates)), [{ + kind: 'shared_plan', + identifier: 'LW-261006-A', + parent_group_no: 'LW-261006-A', + plan_no: 'LW-261006-A', + tid: '61006', + departure_date: '2026-10-06', + row_id: 'tr_61006', + row_text: 'LW-261006-A 老挝行程--广东 2026-10-6', + internal_ref_complete: true + }]); + assert.equal(assistant.inspectSharedPlanBatchCandidates(scopeDocument, '2026-10-06', '其他产品').candidates.length, 0); + + const resolutionBody = inpage.slice( + inpage.indexOf('async function resolveSharedChildBatchOperation'), + inpage.indexOf('async function resolveLifecycleOperation') + ); + const executionBody = inpage.slice( + inpage.indexOf('async function createSplitChildBatchLive'), + inpage.indexOf('function normalizeExportType') + ); + assert.match(resolutionBody, /for \(const departureDate of dates\)/); + assert.match(resolutionBody, /shared_child_batch_no_matching_parent/); + assert.match(resolutionBody, /batch_targets: batchTargets/); + assert.match(executionBody, /await createSplitChildLive\(childOperation\)/); + assert.match(executionBody, /status: 'not_started'/); + assert.doesNotMatch(executionBody, /retry/i); +}); + test('business lookup uses customer/date as the native primary lookup and keeps optional facts supplemental', async () => { const inpage = await readFile(new URL('../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); const sandbox = { window: {} }; @@ -1318,7 +1374,7 @@ test('passenger explicit server success is terminal without a post-save row requ assert.doesNotMatch(passengerBranch, /passengerRequery|verifyLifecycleOperation/); assert.doesNotMatch(background, /attemptAutomaticPassengerReconciliation/); assert.match(background, /名单已取得 ERP 明确成功响应,按业务规则确认录入成功/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.169'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.170'/); }); test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => { @@ -1528,7 +1584,7 @@ test('lifecycle execution keeps the MV3 worker alive until the durable task sett assert.match(background, /arrangement_resource_candidate_data_missing/); }); -test('extension 0.5.169 keeps reload dormant unless its durable execution state is fully idle', async () => { +test('extension 0.5.170 keeps reload dormant unless its durable execution state is fully idle', async () => { const [background, bridge] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8') @@ -1967,7 +2023,7 @@ test('delete guard distinguishes independent, shared child, and shared parent ro for (const operation of operations) assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; ')); const mapping = JSON.parse(await readFile(new URL('../mappings/lifecycle.mapping.json', import.meta.url), 'utf8')); - assert.equal(mapping.current_extension_version, '0.5.169'); + assert.equal(mapping.current_extension_version, '0.5.170'); assert.equal( mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'], 'ertrenshu' @@ -2351,10 +2407,10 @@ test('schema and browser adapters contain the v2 safety fields and no confirm ov assert.match(inpage, /`ys_danweiid\$\{index\}`, resolvedCustomerId/); assert.doesNotMatch(inpage, /product_customer_source_region|sourceRegionCheck|source_reference/); assert.doesNotMatch(teamBatchInpage, /product_customer_source_region|sourceRegionCheck/); - assert.equal(extensionManifest.version, '0.5.169'); - assert.match(inpage, /version: '0\.5\.169'/); - assert.match(teamBatchInpage, /version: '0\.5\.169'/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.169'/); + assert.equal(extensionManifest.version, '0.5.170'); + assert.match(inpage, /version: '0\.5\.170'/); + assert.match(teamBatchInpage, /version: '0\.5\.170'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.170'/); assert.match(inpage, /function strictIsoDate\(value\)/); assert.match(inpage, /const startDate = strictIsoDate\(controlCanonicalValue\(form, 'riqi0'\)\)/); assert.match(inpage, /const endDate = strictIsoDate\(controlCanonicalValue\(form, 'riqis0'\)\)/); diff --git a/tools/operation-plans.test.mjs b/tools/operation-plans.test.mjs index ed0275c..805c662 100644 --- a/tools/operation-plans.test.mjs +++ b/tools/operation-plans.test.mjs @@ -214,6 +214,71 @@ test('shared-child leader lookup survives the strict execution gate', () => { assert.deepEqual(validateStandardOperation(operation), []); }); +test('shared-child batch dispatch is Program-only, resolves all parents, and enters the guarded batch adapter', () => { + const parsed = { + action: 'shared_child_order_batch_create', + order_nature: 'formal', + submit_mode: 'dry_run', + data: { + customer: { name: '合成客户', keyword: '合成客户' }, + product: { name: '老挝行程--广东', keyword: '老挝行程--广东' }, + leader: { name: '张三', keyword: '张三' }, + recurrence: { start_date: '2026-10-06', end_date: '2026-11-03' }, + passenger_counts: { adult: 20, leader: 1 } + } + }; + const dispatch = plans.validateDispatchOperation(parsed); + assert.equal(dispatch.ok, true, dispatch.blockers.join('; ')); + assert.equal(dispatch.execution, 'browser_live_split_child_batch'); + assert.equal(dispatch.requiresErpResolution, true); + assert.equal(plans.validateOperation(parsed).ok, false, 'execution gate requires ERP-resolved batch targets'); + + const resolved = { + ...parsed, + data: { + ...parsed.data, + batch_targets: [ + { parent_group_no: 'LW-261006-A', tid: '61006', departure_date: '2026-10-06' }, + { parent_group_no: 'LW-261103-A', tid: '61103', departure_date: '2026-11-03' } + ], + resolution: { + stage: 'erp_readonly', target: 'batch', candidate_count: 2, + target_kind: 'shared_plan', write_attempted: false + } + } + }; + const execution = plans.validateOperation(resolved); + assert.equal(execution.ok, true, execution.blockers.join('; ')); + assert.equal(execution.execution, 'browser_live_split_child_batch'); + assert.equal(execution.noErpWrite, false); + assert.deepEqual(validateStandardOperation(resolved), []); +}); + +test('shared-child batch execution rejects duplicate or out-of-range resolved parents', () => { + const operation = { + action: 'shared_child_order_batch_create', + order_nature: 'formal', + submit_mode: 'dry_run', + data: { + customer: { name: '合成客户' }, + product: { name: '合成产品' }, + recurrence: { start_date: '2026-10-06', end_date: '2026-11-03' }, + passenger_counts: { adult: 20, leader: 1 }, + batch_targets: [ + { parent_group_no: 'LW-261006-A', tid: '61006', departure_date: '2026-10-06' }, + { parent_group_no: 'LW-261006-A', tid: '61006', departure_date: '2026-11-04' } + ], + resolution: { + stage: 'erp_readonly', target: 'batch', candidate_count: 2, + target_kind: 'shared_plan', write_attempted: false + } + } + }; + const result = plans.validateOperation(operation); + assert.equal(result.ok, false); + assert.match(result.blockers.join('\n'), /重复母团目标|不在请求日期范围/); +}); + test('blocks a marked split-child parent when the child remark loses the test marker', () => { const result = plans.validateOperation({ action: 'shared_child_order_create', diff --git a/tools/operation-timing.test.mjs b/tools/operation-timing.test.mjs index 878643e..0c33f4e 100644 --- a/tools/operation-timing.test.mjs +++ b/tools/operation-timing.test.mjs @@ -153,6 +153,7 @@ test('background timing hook covers every executor through the shared result and 'verifyLifecycleOperation', 'createSplitParentLive', 'createSplitChildLive', + 'createSplitChildBatchLive', 'exportConfirmationSources', 'preflightRawInstruction', 'liveSubmitApproved',