diff --git a/.project-docs/30-worklog/tasks/20260910-roster-trailing-empty-columns-01a0858b.md b/.project-docs/30-worklog/tasks/20260910-roster-trailing-empty-columns-01a0858b.md new file mode 100644 index 0000000..ab943a9 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260910-roster-trailing-empty-columns-01a0858b.md @@ -0,0 +1,51 @@ +# 名单 TSV 末尾空列修复 + +- Task ID: `20260910-roster-trailing-empty-columns-01a0858b` +- Mode: Feature +- Status: Complete (local implementation and validation) +- Authorization: 用户明确要求修复原始 XLS 名单解析失败;仅本地实现与验证。 + +## Concurrent Worktree Check + +- Branch: `main` +- Worktree: `/Users/andy/IdeaProjects/LWLT-AIBOT` +- Base commit: `be7d765018a7c3e5cdfa4631d962327457cf33bd` +- Existing changes: 未跟踪 `.idea/`,保留且不涉及;忽略的 `.DS_Store` 同样保留。 +- Other local worktrees: 无。 +- Overlap result: Clear;没有与名单校验相关的既有改动。 + +## Scope and constraints + +- 修复 TSV 校验/转换时整体 `trim()` 删除末尾空字段分隔符的问题。 +- 相关执行端存在同源问题,范围包含服务端统一校验、插件派发和原生名单合并;按发布矩阵同步插件 `0.5.173`、平台最低版本、mapping、测试、版本化 ZIP 与清单。 +- 保持 13 列、行数、证件类型、序号及领队联系人的现有契约。 +- 使用合成数据补回归测试;用户原始 XLS 仅作本地只读复现,不纳入仓库或日志。 +- 不部署、重启、操作 ERP 或发送外部通知;不修改数据库或 canonical 项目记忆。 + +## Findings + +- 原始 XLS 经现有转换器和 normalizer 得到 19 条完整的 13 列记录。 +- 最后一条备注为空,统一 operation 校验对整段 TSV 调用 `trim()` 后变为 12 列,复现生产错误。 +- 插件派发校验及原生名单合并也使用整体 `trim()`;只修服务端会在后续插件阶段再次阻断。 + +## Verification + +- 回归先行:新测试在旧代码上复现解析、派发和合并异常(8 项中 4 失败);实际 12/14 列仍需拒绝的测试也覆盖尾部分隔符不能被静默修正。 +- 修复后定向 8/8 通过,覆盖两类名单、19 条记录、尾部一/两列为空、CRLF、覆盖确认、严格列数及领队匹配。 +- 用户原始 XLS → 生产 converter/normalizer → Program Parser → 插件 dispatch validator → 本地空白游客位 merge:通过;19 条记录,输出每行均为 13 列,TSV 内容原样保留,原文件 SHA-256 未改变。合并使用本地模拟游客位,未接触真实 ERP。 +- `node --run check`:PASS;`node --run build`:PASS。 +- `node --run test:control-plane`:194/194 PASS。 +- `node --run test:legacy`:290 项全部已验证通过(含本任务新增 3 项)。首次 287 通过;1 项发现 ZIP 多套一层目录,已修正为 manifest 位于 ZIP 根目录并通过完整 10 项 repository-hygiene 检查;另 2 项因沙箱禁止监听 127.0.0.1 失败,获本地测试权限后定向补跑 2/2 PASS。没有连接真实服务。 +- `node --run check:repo`:10/10 PASS,验证副本 `/private/tmp/ltjt-roster-verify-uhqh_lyb` 使用当前 tracked 文件及本任务新文件,排除已有 `.idea/`、Finder 文件及秘密,复用依赖。原工作目录的 IDE/Finder 文件保持不动,仍构成 2 项无关 hygiene 问题。 +- 新 ZIP 完整 entry set 和全部 20 个文件(包括 README)与源码逐字节一致;清单哈希、旧版归档原始哈希均通过。`git diff --check`:PASS。 +- 最终本地复核:实现仅调整 TSV 换行/边界空行处理,未放宽 13 列规则、领队来源、占用游客位覆盖授权或原生保存门禁;其余源码改动为发布版本同步。未使用子 Agent,未声称独立 Agent 审核。 + +## Outcome and follow-ups + +- 服务端统一校验和插件执行链路均已修复;插件为 `0.5.173`,旧版 `0.5.172` 原包与清单已移至日期归档。 +- 部署范围:重建并部署包含 `LianSyn-platform/external-agent-client.mjs` 与平台最低版本更新的服务镜像,同时加载新版插件并刷新平台/ERP 页面。数据库、Parser/normalizer 输入契约及原始表格不需要修改。 +- 部署、浏览器插件重载、真实 ERP 写入验收由用户后续执行;建议重新提交名单指令并重新上传原始 Excel,不自动重放本次失败任务。 + +## Promotion Candidates + +- Target: canonical current state / 名单实现约束。集成时可登记插件 `0.5.173` 及服务端、插件 TSV 保留末尾空列的修复事实;证据为本任务回归及原始 XLS 只读链路验证。不增加业务能力或修改输入契约,真实 ERP 验收仍待部署方完成。本任务不更新共享索引或集成快照。 diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 617a662..feb40bf 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.172'; +const REQUIRED_EXTENSION_VERSION = '0.5.173'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; const RECONCILE_LABEL = '只读回查 ERP 现有结果'; diff --git a/LianSyn-platform/external-agent-client.mjs b/LianSyn-platform/external-agent-client.mjs index 90c3b79..117d1cb 100644 --- a/LianSyn-platform/external-agent-client.mjs +++ b/LianSyn-platform/external-agent-client.mjs @@ -1736,15 +1736,20 @@ function passengerCell(row, header) { return ''; } +function normalizePassengerTsvText(text) { + // Tabs represent columns, including empty final fields; trim only boundary newlines. + return String(text || '').replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, ''); +} + function buildPassengerTsv(value = {}) { - if (typeof value.text === 'string' && value.text.trim()) return value.text.trim().replace(/\r\n?/g, '\n'); + if (typeof value.text === 'string' && value.text.trim()) return normalizePassengerTsvText(value.text); const rows = Array.isArray(value.rows) ? value.rows : []; if (!rows.length) return ''; return [PASSENGER_HEADERS.join('\t'), ...rows.map((row) => PASSENGER_HEADERS.map((header) => passengerCell(row, header)).join('\t'))].join('\n'); } function validatePassengerLeaderContact(value, text, path, errors) { - const lines = String(text || '').trim().replace(/\r\n?/g, '\n').split('\n').filter((line) => line.length > 0); + const lines = normalizePassengerTsvText(text).split('\n').filter((line) => line.length > 0); const headers = String(lines[0] || '').split('\t').map((header) => header.trim()); if (headers.length !== PASSENGER_HEADERS.length || headers.some((header, index) => header !== PASSENGER_HEADERS[index])) return; @@ -1987,7 +1992,7 @@ function validateAgentPassengerList(value, errors) { errors.push(`${path}.text 必须包含本次提供的 13 列名单 TSV。`); return; } - const lines = value.text.trim().replace(/\r\n?/g, '\n').split('\n').filter((line) => line.length > 0); + const lines = normalizePassengerTsvText(value.text).split('\n').filter((line) => line.length > 0); const header = String(lines[0] || '').split('\t'); if (header.length !== PASSENGER_HEADERS.length || header.some((name, index) => name.trim() !== PASSENGER_HEADERS[index])) { diff --git a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md index a883eb5..301478c 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.172` +- Chrome 插件:`0.5.173` - 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.127` @@ -13,6 +13,7 @@ - `0.5.168` 将 ERP 身份门禁从整页子串搜索收紧为唯一 `#Lable_UserName` 登录节点的规范化精确全等;缺失、重复、空白、子串或其他页面文字命中均不再让云电脑进入可执行状态,观察到的账号文本不离开扩展。 - `0.5.170` 保留全部执行门禁,并新增 Program-only 散拼批量子单的“完整只读枚举后逐单执行、首错停止、不自动重试”门禁;ERP 写入不确定后的人工只读核验责任仍明确绑定到任务归属账号,管理员仍完全不进入任务数据面。 - `0.5.172` 按维护方要求将酒店新增 `DoInfo_jiudian` 的固定字段门槛从 195 调整为 150,恢复原来的预检流程;`0.5.171` 的结构判定方案已撤回并归档。其他模式/业务原门槛及后续执行检查保留,低于 150 的页面仍会阻断。尚未部署或进行真实 ERP 写入验收;验证范围见[任务记录](../../../.project-docs/30-worklog/tasks/20260909-hotel-form-readiness-01a0858b.md)。 +- `0.5.173` 修复名单 TSV 末尾空列被整体 `trim()` 删除的问题,覆盖服务端统一 operation 校验、插件派发及原生名单合并。13 列完整性、序号、覆盖确认及领队约束继续执行;服务端与插件需同步更新,数据库无需变更。该版本的本地验证与真实 ERP 验收边界见[任务记录](../../../.project-docs/30-worklog/tasks/20260910-roster-trailing-empty-columns-01a0858b.md)。 - 运营输入/DOCX `0.5.127` 补齐第 19 项`散拼团多个新增子单`的可复制模板与示例;该入口仍固定为 Program-only,不进入 Agent Prompt 或 Skill。 - 名单工作簿 normalizer `v1.4.0`、Skill `0.5.126` 与业务输入 `0.5.127` 继续只选择 12 个必需源字段;身份证、年龄、源证件类型及其他普通额外列不参与行识别、字段校验或 canonical 输出。必需字段完整性、宏/外链、全局超链接和外部公式等安全门槛保持失败关闭。 diff --git a/archive/releases/2026-09-10/README.md b/archive/releases/2026-09-10/README.md new file mode 100644 index 0000000..8d4410e --- /dev/null +++ b/archive/releases/2026-09-10/README.md @@ -0,0 +1,7 @@ +# 2026-09-10 发布归档 + +本目录仅供历史追溯,不定义当前业务规则或发布版本。当前交付以 [`../../../dist/release-manifest.json`](../../../dist/release-manifest.json) 为准。 + +- `ltjt-order-assistant-0.5.172.zip`:名单末尾空列修复前的插件原包,字节保持不变。 +- `release-manifest-0.5.172-skills-0.5.126-docx-0.5.127.json`:对应原始发布清单,原路径及哈希作为历史记录保留。 +- 替代版本 `0.5.173` 的本地修复与验证见[任务记录](../../../.project-docs/30-worklog/tasks/20260910-roster-trailing-empty-columns-01a0858b.md)。 diff --git a/dist/ltjt-order-assistant-0.5.172.zip b/archive/releases/2026-09-10/ltjt-order-assistant-0.5.172.zip similarity index 100% rename from dist/ltjt-order-assistant-0.5.172.zip rename to archive/releases/2026-09-10/ltjt-order-assistant-0.5.172.zip diff --git a/archive/releases/2026-09-10/release-manifest-0.5.172-skills-0.5.126-docx-0.5.127.json b/archive/releases/2026-09-10/release-manifest-0.5.172-skills-0.5.126-docx-0.5.127.json new file mode 100644 index 0000000..f818d93 --- /dev/null +++ b/archive/releases/2026-09-10/release-manifest-0.5.172-skills-0.5.126-docx-0.5.127.json @@ -0,0 +1,69 @@ +{ + "manifest_version": 1, + "generated_on": "2026-09-09", + "baselines": { + "chrome_extension": "0.5.172", + "skills": "0.5.126", + "business_instruction_docx": "0.5.127", + "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" + }, + "artifacts": [ + { + "kind": "chrome_extension", + "version": "0.5.172", + "path": "dist/ltjt-order-assistant-0.5.172.zip", + "source": "chrome-extension/ltjt-order-assistant", + "sha256": "5ded1ac1e5ed137f0997d1220ac3c76b66a7443f4fcda6c824e482f122d0c198" + }, + { + "kind": "skill", + "name": "lwlt-arrangement", + "version": "0.5.126", + "path": "dist/lwlt-arrangement-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-arrangement", + "sha256": "83770befe3a8e4048eacaeacb567c5139bf8a0b3a49f2b1513c26383422f2a9c" + }, + { + "kind": "skill", + "name": "lwlt-confirmation", + "version": "0.5.126", + "path": "dist/lwlt-confirmation-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-confirmation", + "sha256": "543507ed8b68c183d2df6722061b0e927ebb728de4bf254d15f83311c9264018" + }, + { + "kind": "skill", + "name": "lwlt-lifecycle", + "version": "0.5.126", + "path": "dist/lwlt-lifecycle-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-lifecycle", + "sha256": "fff3879d9a4143c34ae51e091b91411e55382ad3f5d77c99d6202a005b1ddbd9" + }, + { + "kind": "skill", + "name": "lwlt-newbooking", + "version": "0.5.126", + "path": "dist/lwlt-newbooking-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-newbooking", + "sha256": "5eb6a7bf5649a0fcee3fbee488a124e1b1d018a642a3a49ebb5439ccf20ccc2f" + }, + { + "kind": "skill", + "name": "lwlt-updating", + "version": "0.5.126", + "path": "dist/lwlt-updating-0.5.126.skill", + "source": "agent设计规范/skills/lwlt-updating", + "sha256": "3bd665207649cbb8e0e5ebb012c916720201fe160eea62bccdf1c3a1f0275d1d" + }, + { + "kind": "business_instruction_docx", + "version": "0.5.127", + "path": "dist/老挝联泰AI指令表-0.5.127.docx", + "source": "agent设计规范/templates/business-input-templates.md", + "builder": "tools/build_business_instruction_docx.py", + "sha256": "e1b8ae5a98d550ab0785692a01cf1c87b99e4edfaea39e4edbbab7341e585f29", + "source_sha256": "652af8ef645ae56af2dd9990f74b8078de230cb9b87acd13c289d1f4dae0738a", + "builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1" + } + ] +} diff --git a/chrome-extension/ltjt-order-assistant/README.md b/chrome-extension/ltjt-order-assistant/README.md index 7c8f163..042fd24 100644 --- a/chrome-extension/ltjt-order-assistant/README.md +++ b/chrome-extension/ltjt-order-assistant/README.md @@ -11,9 +11,13 @@ Chrome Manifest V3 扩展,在用户已登录的 LTJT ERP 页面内执行经过 ## 当前版本 -当前源码版本为 `0.5.172`。版本化 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.173`。版本化 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.172 当前重点: +0.5.173 当前重点: + +- 名单 TSV 在派发校验、领队提取和原生名单合并时保留末尾空列的制表符,只统一换行并移除首尾空行。最后一条电话或备注为空时仍按完整 13 列处理;真正少列、多列及领队不一致仍阻断。本次需同步部署服务端的统一 operation 校验修复,不涉及数据库变更。 + +继续保留 0.5.172 的酒店门槛修正: - 按维护方要求,将酒店新增 `DoInfo_jiudian` 的最少可序列化字段数从 195 调整为 150,恢复原有页面预检流程。154 字段页面可以通过数量检查;关键字段、loading、稳定 1 秒、资源、空槽、财务状态、归属和保存后回查检查保持原样。酒店修改/清空仍为 150,其他业务阈值不变。低于 150 的页面仍会阻断;真实 ERP 验收由部署方完成。 diff --git a/chrome-extension/ltjt-order-assistant/inpage.js b/chrome-extension/ltjt-order-assistant/inpage.js index b2f4dc2..2df4baf 100644 --- a/chrome-extension/ltjt-order-assistant/inpage.js +++ b/chrome-extension/ltjt-order-assistant/inpage.js @@ -6403,7 +6403,7 @@ } window.LTJTOrderAssistant = { - version: '0.5.172', + version: '0.5.173', resolveNativeListSearchValues, lookupKeywordMatchesText, inspectLifecycleSearchCriteria: lifecycleSearchCriteria, diff --git a/chrome-extension/ltjt-order-assistant/lifecycle-adapters.js b/chrome-extension/ltjt-order-assistant/lifecycle-adapters.js index db65ab1..599e257 100644 --- a/chrome-extension/ltjt-order-assistant/lifecycle-adapters.js +++ b/chrome-extension/ltjt-order-assistant/lifecycle-adapters.js @@ -2609,7 +2609,8 @@ } function buildPassengerTsv(value = {}) { - if (typeof value.text === 'string' && value.text.trim()) return value.text.trim().replace(/\r\n?/g, '\n'); + // Keep trailing tabs: they encode empty cells in the required 13-column roster. + if (typeof value.text === 'string' && value.text.trim()) return value.text.replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, ''); const rows = Array.isArray(value.rows) ? value.rows : []; if (!rows.length) return ''; return [PASSENGER_HEADERS.join('\t'), ...rows.map((row) => PASSENGER_HEADERS.map((header) => passengerCell(row, header)).join('\t'))].join('\n'); diff --git a/chrome-extension/ltjt-order-assistant/manifest.json b/chrome-extension/ltjt-order-assistant/manifest.json index 6979337..43ec2b1 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.172", + "version": "0.5.173", "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 a744d54..59573b5 100644 --- a/chrome-extension/ltjt-order-assistant/operation-plans.js +++ b/chrome-extension/ltjt-order-assistant/operation-plans.js @@ -457,9 +457,14 @@ return ''; } + function normalizePassengerTsvText(text) { + // Tabs represent columns, including empty final fields; trim only boundary newlines. + return String(text || '').replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, ''); + } + function buildPassengerTsv(passengerList = {}) { if (typeof passengerList.text === 'string' && passengerList.text.trim()) { - return passengerList.text.trim().replace(/\r\n?/g, '\n'); + return normalizePassengerTsvText(passengerList.text); } const rows = Array.isArray(passengerList.rows) ? passengerList.rows : []; if (!rows.length) return ''; @@ -470,7 +475,7 @@ } function passengerLeaderFromTsv(text) { - const lines = String(text || '').trim().replace(/\r\n?/g, '\n').split('\n').filter((line) => line.length > 0); + const lines = normalizePassengerTsvText(text).split('\n').filter((line) => line.length > 0); const headers = String(lines[0] || '').split('\t').map((header) => header.trim()); if (headers.length !== PASSENGER_HEADERS.length || headers.some((header, index) => header !== PASSENGER_HEADERS[index])) { @@ -1110,7 +1115,7 @@ blockers.push('data.passenger_list.text 必须包含本次提供的 13 列名单 TSV。'); return; } - const lines = value.text.trim().replace(/\r\n?/g, '\n').split('\n').filter((line) => line.length > 0); + const lines = normalizePassengerTsvText(value.text).split('\n').filter((line) => line.length > 0); const headers = String(lines[0] || '').split('\t'); if (headers.length !== PASSENGER_HEADERS.length || headers.some((header, index) => header.trim() !== PASSENGER_HEADERS[index])) blockers.push('名单 TSV 必须使用规定的 13 列表头和顺序。'); if (lines.length - 1 !== value.row_count) blockers.push('data.passenger_list.row_count 与 TSV 数据行数不一致。'); diff --git a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js index f5efb93..3c5bf01 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.172', + version: '0.5.173', openTeamBatchForm, pingTeamBatchFrame, preflightTeamBatchNative, diff --git a/control-plane/test/program-parser.test.ts b/control-plane/test/program-parser.test.ts index eea0f9d..20acd11 100644 --- a/control-plane/test/program-parser.test.ts +++ b/control-plane/test/program-parser.test.ts @@ -228,6 +228,40 @@ test('shared-child batch input compiles the inclusive cross-month range without }); }); +test('passenger TSV preserves 13 columns when the final passenger has empty optional fields', async () => { + for (const route of [routeCases[5], routeCases[6]]) { + for (const emptyPhone of [false, true]) { + const rows = Array.from({ length: 19 }, (_, i) => { + const cells = row.split('\t'); + cells[0] = String(i + 1); + cells[12] = i === 0 ? '领队' : ''; + if (emptyPhone && i === 18) cells[11] = ''; + return cells.join('\t'); + }); + const text = `${headers}\n${rows.join('\n')}`; + for (const crlf of [false, true]) { + const input = `${route.input.replace(row, rows.join('\n'))}\n\n`; + const result = await parseProgramInput({ rawText: crlf ? input.replace(/\n/g, '\r\n') : input, receivedAt }); + assert.equal(result.status, 'agent_parse_passed', JSON.stringify(result)); + const roster = (result.operation as any).data.passenger_list; + assert.equal(roster.row_count, 19); + assert.equal(roster.text, text); + assert.deepEqual(roster.leader_contact, { sequence: 1, name: '测试游客', phone: '13800000001' }); + } + } + } +}); + +test('passenger TSV rejects actual 12-column and 14-column data rows', async () => { + const cells = row.split('\t'); + for (const malformed of [cells.slice(0, 12).join('\t'), `${cells.join('\t')}\t`]) { + const result = await parseProgramInput({ rawText: routeCases[5].input.replace(row, malformed), receivedAt }); + assert.equal(result.status, 'agent_parse_needs_input'); + assert.equal(result.error_code, 'program_invalid_value'); + assert.match(String(result.reply), /13 列/); + } +}); + 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({ diff --git a/dist/release-manifest.json b/dist/release-manifest.json index f818d93..aa737fd 100644 --- a/dist/release-manifest.json +++ b/dist/release-manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 1, - "generated_on": "2026-09-09", + "generated_on": "2026-09-10", "baselines": { - "chrome_extension": "0.5.172", + "chrome_extension": "0.5.173", "skills": "0.5.126", "business_instruction_docx": "0.5.127", "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" @@ -10,10 +10,10 @@ "artifacts": [ { "kind": "chrome_extension", - "version": "0.5.172", - "path": "dist/ltjt-order-assistant-0.5.172.zip", + "version": "0.5.173", + "path": "dist/ltjt-order-assistant-0.5.173.zip", "source": "chrome-extension/ltjt-order-assistant", - "sha256": "5ded1ac1e5ed137f0997d1220ac3c76b66a7443f4fcda6c824e482f122d0c198" + "sha256": "449ad5d7394904e897b9a6245eb3b01a80e956a353422536c33d0907356bea8d" }, { "kind": "skill", diff --git a/mappings/lifecycle.mapping.json b/mappings/lifecycle.mapping.json index 4462228..3f46553 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.172", + "current_extension_version": "0.5.173", "historical_test_marker": "TEST-202609", "scope": "/System/Business/", "erp_session_keepalive": { @@ -207,6 +207,7 @@ "full_replace requires confirmed=true and forces a native overwrite of every supplied sequence even when its current persisted projection is equal; the plugin must not downgrade confirmed user intent to no_changes", "passenger_list.marker is exactly TEST-202609 only for an explicit historical test context", "every canonical row has implicit 证件类型 exactly 护照; any source 证件类型 column is ignored", + "each TSV row retains exactly 13 columns, including empty final phone or remark cells; normalization removes only boundary newlines and never trims tab delimiters", "exactly one workbook row within the first 100 rows maps to the required ERP passenger fields; the header may be row 1 or follow metadata rows, source order is arbitrary, approved source/ERP aliases map to one semantic field each, and ordinary non-import columns are ignored and excluded from canonical output; contiguous roster data is read below the detected header", "at most one row has trimmed 备注 exactly 领队; that row requires nonblank 姓名 and 电话 and must match passenger_list.leader_contact" ], diff --git a/tools/lifecycle-contract.test.mjs b/tools/lifecycle-contract.test.mjs index 6cbaeae..c4091f8 100644 --- a/tools/lifecycle-contract.test.mjs +++ b/tools/lifecycle-contract.test.mjs @@ -1374,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\.172'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.173'/); }); test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => { @@ -1584,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.172 keeps reload dormant unless its durable execution state is fully idle', async () => { +test('extension 0.5.173 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') @@ -2023,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.172'); + assert.equal(mapping.current_extension_version, '0.5.173'); assert.equal( mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'], 'ertrenshu' @@ -2407,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.172'); - assert.match(inpage, /version: '0\.5\.172'/); - assert.match(teamBatchInpage, /version: '0\.5\.172'/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.172'/); + assert.equal(extensionManifest.version, '0.5.173'); + assert.match(inpage, /version: '0\.5\.173'/); + assert.match(teamBatchInpage, /version: '0\.5\.173'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.173'/); 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/passenger-tsv.test.mjs b/tools/passenger-tsv.test.mjs new file mode 100644 index 0000000..2d4a746 --- /dev/null +++ b/tools/passenger-tsv.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import vm from 'node:vm'; +import { validateProgramOperation } from '../LianSyn-platform/external-agent-client.mjs'; + +const require = createRequire(import.meta.url); +const plans = require('../chrome-extension/ltjt-order-assistant/operation-plans.js'); +const headers = plans.PASSENGER_HEADERS.join('\t'); +const row = (sequence, phone = '13800000001', remark = '') => [ + sequence, `合成游客${sequence}`, 'SYNTHETIC GUEST', '男', '1990-01-01', '合成城市', + '护照', `TEST-PASS-${sequence}`, '合成城市', '2025-01-01', '2035-01-01', phone, remark +].join('\t'); +const operation = (text, rowCount = 1, kind = 'independent_order') => ({ + action: 'passenger_list_import', order_nature: 'formal', submit_mode: 'dry_run', + data: { + existing_refs: { kind, identifier: 'SYNTH-ORDER-001' }, + passenger_list: { operation: 'import', row_count: rowCount, text } + } +}); + +test('passenger TSV keeps trailing empty columns through server and plugin dispatch', () => { + for (const kind of ['independent_order', 'shared_child_order']) { + for (const phone of ['13800000001', '']) { + const text = [headers, ...Array.from({ length: 19 }, (_, i) => row(i + 1, phone))].join('\n'); + for (const input of [text, `\r\n${text.replace(/\n/g, '\r\n')}\r\n\r\n`]) { + for (const mode of ['import', 'full_replace']) { + const candidate = operation(input, 19, kind); + Object.assign(candidate.data.passenger_list, mode === 'full_replace' + ? { operation: mode, confirmed: true } : { operation: mode }); + assert.deepEqual(validateProgramOperation(candidate), []); + assert.deepEqual(plans.validateDispatchOperation(candidate).blockers, []); + assert.equal(plans.buildPassengerTsv(candidate.data.passenger_list), text); + assert.equal(candidate.data.passenger_list.text, input); + } + } + } + } +}); + +test('passenger TSV still rejects missing or surplus columns and keeps leader validation', () => { + for (const malformed of [row(1).slice(0, -1), `${row(1)}\t`]) { + const candidate = operation(`${headers}\n${malformed}`); + assert.match(validateProgramOperation(candidate).join('\n'), /完整 13 列/); + assert.match(plans.validateDispatchOperation(candidate).blockers.join('\n'), /13 列/); + } + const candidate = operation(`${headers}\n${row(1, '13800000001', '领队')}\n${row(2, '')}`, 2); + assert.match(validateProgramOperation(candidate).join('\n'), /leader_contact/); + assert.match(plans.validateDispatchOperation(candidate).blockers.join('\n'), /leader_contact/); + candidate.data.passenger_list.leader_contact = { sequence: 1, name: '合成游客1', phone: '13800000001' }; + assert.deepEqual(validateProgramOperation(candidate), []); + assert.deepEqual(plans.validateDispatchOperation(candidate).blockers, []); + candidate.data.passenger_list.leader_contact.phone = '13800000002'; + assert.match(validateProgramOperation(candidate).join('\n'), /完全一致/); + assert.match(plans.validateDispatchOperation(candidate).blockers.join('\n'), /完全一致/); +}); + +test('passenger merge accepts empty final fields without repairing malformed column counts', async () => { + const source = await readFile(new URL('../chrome-extension/ltjt-order-assistant/lifecycle-adapters.js', import.meta.url), 'utf8'); + const sandbox = { window: { LTJTOrderAssistant: {} } }; + vm.runInNewContext(source, sandbox); + const merge = sandbox.window.LTJTOrderAssistant.planPassengerMerge; + const blankRows = Array.from({ length: 19 }, (_, i) => Object.fromEntries(plans.PASSENGER_HEADERS.map(header => [ + header, header === '序号' ? i + 1 : header === '证件类型' ? '护照' : '' + ]))); + for (const phone of ['13800000001', '']) { + const text = [headers, ...Array.from({ length: 19 }, (_, i) => row(i + 1, phone))].join('\n'); + for (const input of [text, `\r\n${text.replace(/\n/g, '\r\n')}\r\n`]) { + const result = merge(blankRows, { operation: 'first_import', row_count: 19, text: input }, 19); + assert.deepEqual([...result.blockers], []); + assert.equal(result.merged_tsv, text); + assert.equal(result.input_rows[18]['电话'], phone); + assert.equal(result.input_rows[18]['备注'], ''); + assert.equal(result.changed_sequences.length, 19); + } + } + for (const malformed of [row(1).slice(0, -1), `${row(1)}\t`]) { + const result = merge(blankRows, { operation: 'first_import', row_count: 1, text: `${headers}\n${malformed}` }, 19); + assert.ok(result.blockers.includes('passenger_input_row_columns_incomplete')); + } +});