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',