diff --git a/.project-docs/30-worklog/tasks/20260919-product-name-normalization-01a0b77e.md b/.project-docs/30-worklog/tasks/20260919-product-name-normalization-01a0b77e.md new file mode 100644 index 0000000..efe02a5 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260919-product-name-normalization-01a0b77e.md @@ -0,0 +1,58 @@ +# 独立团产品名称格式匹配修复 + +## 范围与授权 + +- 用户在完成截图、日志及本地复现排查后同意:保留原始名称,比较时输入与 ERP 候选使用相同归一化,选中后使用 ERP 原名执行。 +- 同时修复产品匹配前置失败后继续产生联动、客户恢复和必填字段连带报错的问题。 +- 本地源码、测试、版本化发布物与任务记录属于本任务;不执行真实 ERP 写入、部署、重启、插件重载、提交或推送。 + +## Worktree 检查 + +- Branch:`main`。 +- Worktree:`/Users/andy/IdeaProjects/LWLT-AIBOT`。 +- Base:`6eeaee07d4fdeef3d29dd7cf46ac33929699eb8b`;仅一个 worktree。 +- 既有未跟踪 `.idea/` 与 `dist/ltjt-order-assistant-0.5.187.zip`;保留 `.idea/`,在发布物处理前核验 ZIP 与当前清单及源码的关系,不覆盖未知内容。 +- 源码范围无既有改动;Overlap:Clear(旧 ZIP 的发布归属待哈希核验)。 +- Feature 模式,仅维护本任务项目记忆;按用户项目约束不启用子 Agent。 + +## 已有诊断证据 + +- 用户提供的失败日志:Program `v1.0.13`,单个下单;客户精确匹配 1 条,产品响应 166 行但精确/包含匹配均为 0;保存未尝试。 +- 产品联动请求数及表单赋值数均为 0,后续错误由未执行阶段继续校验引出。 +- 本地合成复现证明括号、内部空白差异可导致相同报错;线上候选原始文本未提供,不能将具体字符差异宣称为已取得原始数据证明。 + +## Project Context Loaded / Planning Gate + +- 已按入口读取 task、project-positioning、current-state、decision-index、system-overview、doc-update-policy、业务登记、单个/批量下单业务页、newbooking Skill/字段说明、orders_add mapping/Schema、业务规则、成功标准和证据索引;本会话前序只读诊断已完成大部分上下文加载。 +- 定位:解析器提供业务事实,插件负责唯一 ERP 候选与原生联动。遵守 ARCH-001、SAFETY-001、RELEASE-001;无业务范围/架构冲突。 +- canonical current-state 停留在 09-09 集成快照;当前版本以源码、清单与 09-17 任务记录为准。没有其他本地 worktree,也没有重叠源码改动。 +- 既有 ZIP 已核验清单 SHA-256、20 个文件的集合与每个字节均等于当前源码,确认其为已登记的 0.5.187 发布物;可按治理规则保留原字节归档。`.idea/` 原样保留。 +- Gate:Passed;Feature 模式,变更只写本任务记录及业务源/实现/契约/发布物,不提升 canonical 项目记忆。 + +## 实施计划 + +1. Program named reference 的 `keyword` 保留输入原文(仅去首尾空白);`name` 保持既有格式归一化及登记别名,避免改变其他执行器已有契约。 +2. 单个下单产品对输入与 ERP 候选统一 NFKC 与空白比较,保留其他标点及业务文字。归一后精确优先、唯一包含兜底;任何歧义阻断,使用选中行的 ERP 原名联动。 +3. 初始查询/校验有 blocker 时直接返回只读预检结果,后续依赖阶段标记跳过;零/多产品匹配使用明确中文错误且保留候选统计。 +4. 增加真实公共预检入口的合成回归与解析器原文保留测试;同步插件 0.5.188、Program v1.0.14、mapping、业务说明、平台最低版本、清单和 ZIP。Skill 与运营输入契约不变,不重发无改动交付物。 +5. 执行五项必需检查、逐文件发布物核验和最终 diff review;既有 `.idea/` 引起的根目录检查差异用完整源码临时快照验证并如实记录。 + +## 实施与验证 + +- 已完成:Program 的原文 keyword 保留;单个下单的双侧 NFKC/空白归一比较及 ERP 原名联动;归一后碰撞仍阻断;初始失败返回清晰原因并跳过后续依赖校验。 +- 新增 8 项真实公共预检入口的合成测试,覆盖双向括号/空白、原名联动及拦截载荷、精确/包含优先级、归一碰撞、零/多候选、保留标点与限定词、查询/客户失败、原生联动不完整。Program 测试新增 keyword 原文保留及 DBL 解析断言。 +- 当前插件/平台最低版本 `0.5.188`,Program `v1.0.14`;输入模板、五个 Skill 和 Schema 结构保持既有契约,无迁移。同步业务入口、mapping、发布门槛、版本断言、ZIP 和清单。 +- 五项必需检查在当前源码和交付物的完整临时快照通过:`check:repo` 10/10、`check`、控制面 273/273、legacy 431/431、`build`。排除既有未跟踪 `.idea/` 和秘密配置,不读取或复制 `.env`;依赖沿用 node_modules。`.idea/` 原样保留。 +- 首次 legacy 发现两项遗漏的旧版本正则断言及两项沙箱回环监听限制;已同步断言,并允许测试本地模拟 HTTP 端口后完整重跑通过,未跳过测试。 +- 新 ZIP 全部 20 个文件逐字节与源码一致,清单哈希有效;既有 0.5.187 ZIP 在确认与旧清单及源码完全一致后按原字节归档,未覆盖未知内容。新旧发布说明均明确当前/历史边界。 +- 主线程完成最终 diff 与调用方兼容核对,未启用子 Agent;任务只修改本任务项目记忆,没有提升 canonical 文件。`git diff --check` 通过。 +- 验证证据:[本地合成验证摘要](../../../archive/evidence/2026-09-19/product-name-normalization-01a0b77e/README.md)。 + +## 交付与后续 + +- 源码与版本化发布物已就绪,未提交或推送。更新服务端可启用 Program v1.0.14 的原文关键词保留;产品格式匹配和前置失败提示需更新插件至 0.5.188,并同步平台最低版本、刷新 ERP 页面。 +- 未进行部署、重启、插件重载或真实 ERP 保存。本次测试证明格式兼容与唯一候选门禁;线上实际原名的字符级确认及新版本 ERP 验收仍待部署方执行。 + +## Promotion Candidates + +- 下一次 Integration 可将“单个下单产品双侧格式归一、ERP 原名联动、初始失败停止依赖校验及 Program keyword 保留原文”纳入 current-state / business-rules。依据为本任务合成回归和不可变证据;生产验收继续标记待验证,不扩大业务能力。 diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 9eb46e6..4320e3f 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -103,7 +103,7 @@ const persistedExtensionResultVersions = new Map(); let taskCreateInProgress = false; const AUTO_HANDOFF_RETRY_MS = 30_000; -const REQUIRED_EXTENSION_VERSION = '0.5.187'; +const REQUIRED_EXTENSION_VERSION = '0.5.188'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; const RECONCILE_LABEL = '只读回查 ERP 现有结果'; diff --git a/agent设计规范/businesses/team_order_create.md b/agent设计规范/businesses/team_order_create.md index 58c7969..44c090d 100644 --- a/agent设计规范/businesses/team_order_create.md +++ b/agent设计规范/businesses/team_order_create.md @@ -45,7 +45,7 @@ |---|---:|---|---| | 发团日期 | 是 | `data.departure_dates`,单元素数组 | 当前上下文 `09-03` 归一为 `2026-09-03`;年份不明确时以运行上下文为锚点 | | 预订客户 | 是 | `data.customer.name/keyword` | ERP 客户 SelectBox 精确优先、唯一包含兜底;零/多候选均阻断;历史`预定客户`为兼容别名 | -| 产品搜索 | 是 | `data.product.name/keyword` | ERP 产品候选内按产品名/天数做精确、连续或有序匹配;零/多候选均阻断 | +| 产品搜索 | 是 | `data.product.name/keyword` | 单个下单将输入与 ERP 产品名同时做 NFKC 和去空白比较;归一后精确优先、唯一包含兜底;零/多候选均阻断,保留其他标点和业务限定词 | | 预估人数 | 是 | `data.passenger_counts` | `15+1` → `adult: 15, leader: 1` | | 用房数量 | 是 | `data.room_counts` | `8标1单` → `{TWN: 8, SGL: 1}`;只写明确房型 | @@ -74,10 +74,13 @@ `人名币`是已登记的常见输入错误:`name` 用于匹配时归一为“人民币”,`keyword` 保留原文。该别名不代表可以在人民币、美金、老挝币三条客户记录中任选。 +Program `v1.0.14` 保留 `keyword` 中用户的原始括号和内部空白(仅去首尾空白);`name` 继续使用既有格式归一化和登记别名。插件 `0.5.188` 比较产品时对双方使用相同格式规则,例如 `单订房(测试市场)` 可匹配 `单订房 (测试市场)`。选择后始终使用 ERP 原始产品名填写及联动,不把比较键写回 ERP;归一后出现多个同名产品仍阻断,即使其中一个原始拼写与输入相同。 + ### 1.5 缺失和阻断示例 - 缺少预订客户:返回 `agent_parse_needs_input`,不能用产品模板客户代替。 - 客户或产品匹配到零个/多个候选:插件保存前阻断,不选择第一条。 +- 初始查询或校验失败时保持表单未填写,停止执行;产品零候选提示未找到,多候选提示数量。产品联动、客户恢复、最终表单与提交拦截均标记跳过,不再生成这些未执行阶段的连带错误。 - “更新行程”按钮缺失、点击后没有 `GetProduct` 联动、团号设置未回填:插件阻断。 - 产品联动把客户临时覆盖为产品模板客户:插件必须重新应用用户选择并校验名称、客户 ID、币种和收款客户;恢复失败则阻断。 - `用房数量:8` 没有房型分配:只询问实际房型,不询问价格或人员。 @@ -87,9 +90,9 @@ 当前页面为 `https://lwlt.hisy.cc/System/Mainlt.asp`,已登录主页面进入独立团计划表后的单个“下单”表单。 1. 打开独立团“下单”功能,进入 `/System/Business/orders_add.asp`。 -2. 填写发团日期,表单字段为 `chufa_ri`。 -3. 在预订客户字段 `zutuanshe` 中按用户关键字匹配当前 SelectBox 数据;点选唯一客户,同时写入页面自身映射的客户 ID、联系人/负责人和币种。 -4. 在产品字段 `chanpinming` 中按用户关键字匹配并点选唯一产品。 +2. 校验发团日期,目标表单字段为 `chufa_ri`,初始预检通过前不填写。 +3. 按预订客户关键字匹配当前 SelectBox 数据,确定唯一客户及页面自身映射的客户 ID、联系人/负责人和币种,初始预检通过前不应用这些值。 +4. 客户、产品的初始查询及校验均通过后,才应用日期和客户字段;产品比较统一两边的 NFKC/空白格式,并将唯一候选的 ERP 原名填写到 `chanpinming`。 5. 必须点击产品旁的“更新行程”按钮(当前 DOM 为 `#gengxinxingcheng`,`onclick="Find_product()"`),等待 `GetProduct` 的原生 Ajax 完成回调。该联动负责回填 `TianShu`、专线/业务名称、团号前缀/后缀、行程和产品模板价格等字段;不得用会受后台标签页节流影响的短周期页面定时轮询代替完成回调。 6. ERP 当前产品联动可能覆盖客户字段;插件重新应用第 3 步的唯一客户选择,并校验客户名称、`zutuansheid`、币种及收款客户行一致。 7. 填写预估人数:成人 `darenshu`、儿童/婴儿/领队对应字段;填写用房数量:`frenshu0`(SGL)、`frenshu1`(TWN)、`frenshu2`(TRP)、`frenshu3`(DBL)。 @@ -113,6 +116,7 @@ | 后台安全门与回执 | [background.js](../../chrome-extension/ltjt-order-assistant/background.js) | | 标准 operation Schema | [standard_system_operation.schema.json](../../schemas/standard_system_operation.schema.json) | | operation 测试 | [operation-plans.test.mjs](../../tools/operation-plans.test.mjs) | +| 单个下单产品格式与前置阻断回归 | [single-order-product-preflight.test.mjs](../../tools/single-order-product-preflight.test.mjs) | ## 3. Skill 适配 diff --git a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md index 492fff0..125eff9 100644 --- a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md +++ b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md @@ -2,7 +2,8 @@ ## 当前基线 -- Chrome 插件:`0.5.187` +- Chrome 插件:`0.5.188` +- `0.5.188` 与程序解析器 `v1.0.14` 修复单个下单的产品格式匹配:比较双方统一 NFKC/空白,保留标点及业务限定词,唯一候选使用 ERP 原名联动;keyword 保留输入原始格式。初始失败跳过后续依赖校验,零/多产品候选提供独立中文原因。真实公共预检入口的合成回归通过,生产 ERP 验收待部署方完成;需同步服务端、插件和平台,无迁移。见[验证证据](../../../archive/evidence/2026-09-19/product-name-normalization-01a0b77e/README.md)及[任务记录](../../../.project-docs/30-worklog/tasks/20260919-product-name-normalization-01a0b77e.md)。 - `0.5.187` 与程序解析器 `v1.0.13` 支持独立团“房间数更改为 12 标+2 大”,DBL 映射 `frenshu3` 并参与原生合计及保存后 fresh GET 核验;语法、保存前阻断和保存后不一致的本地合成回归通过,真实 ERP 实写待部署方验收。服务端及插件需同步更新,无迁移。见[任务记录](../../../.project-docs/30-worklog/tasks/20260916-independent-dbl-update-01a0858b.md)。 - `0.5.186` 修复车票说明中车次与时刻的数字边界,明确供应商与说明联合唯一匹配及多候选提示;新增/变更本地合成回归通过,真实 ERP 验收待部署方完成。见[任务记录](../../../.project-docs/30-worklog/tasks/20260916-transport-description-match-01a0858b.md)。 - Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories` diff --git a/archive/evidence/2026-09-19/README.md b/archive/evidence/2026-09-19/README.md new file mode 100644 index 0000000..ec05ac9 --- /dev/null +++ b/archive/evidence/2026-09-19/README.md @@ -0,0 +1,5 @@ +# 2026-09-19 验证证据 + +此目录仅保存日期化、只读的历史验证结论,不定义当前业务规则。当前规则以活动源码、mapping、业务入口和发布清单为准。 + +- [产品名称格式匹配与前置失败阻断](product-name-normalization-01a0b77e/README.md) diff --git a/archive/evidence/2026-09-19/product-name-normalization-01a0b77e/README.md b/archive/evidence/2026-09-19/product-name-normalization-01a0b77e/README.md new file mode 100644 index 0000000..50a1c88 --- /dev/null +++ b/archive/evidence/2026-09-19/product-name-normalization-01a0b77e/README.md @@ -0,0 +1,32 @@ +# 产品名称格式匹配修复:本地验证 + +日期:2026-09-19。此记录为只读历史证据,不是当前业务规则,也不代表生产 ERP 已执行成功。 + +## 结论与范围 + +- 独立团单个下单的输入名称与 ERP 候选均经 NFKC、去空白处理后比较;保留其他标点与业务限定词,精确优先、唯一包含兜底。 +- 归一后同名的多个候选阻断,即使其中一条原始拼写完全相同;零匹配和多匹配提供独立中文原因。 +- 选中候选后,原生 GetProduct 联动与拦截的 DoInfoJH 载荷均保留 ERP 原始名称。 +- 初始校验/查询失败时,表单、产品联动与提交均未执行;返回初始候选计数,并将依赖阶段标记为跳过。 +- Program keyword 保留原始括号与内部空白;name 保持既有格式/别名归一化。无新增数据库迁移,Skill 与运营输入模板未修改。 + +## 验证 + +| 检查 | 结果 | +|---|---| +| 新增公共预检入口合成回归 | 8 项通过:双向括号/空白、原名联动和载荷、精确优先、唯一包含、归一碰撞、多候选、零候选/限定词、HTTP/客户失败与联动不完整 | +| Program 解析器回归 | 30 项通过,包含新增原文 keyword 保留测试 | +| node --run check:repo | 10/10 通过 | +| node --run check | 通过 | +| node --run test:control-plane | 273/273 通过 | +| node --run test:legacy | 431/431 通过 | +| node --run build | 通过 | +| git diff --check | 通过 | +| 插件 0.5.188 ZIP | 20 个文件与当前发布源码集合及字节全等,清单 SHA-256 核验通过 | +| 旧插件 0.5.187 | 归档前 20 个文件与旧源码全等,归档保留旧清单对应原始字节 | + +完整检查使用当前源码与交付物的临时快照:排除既有未跟踪 `.idea/`、秘密配置及可重建输出,依赖沿用工作区 node_modules;未读取或复制 `.env`。原工作区 `.idea/` 保持原样。首次 legacy 执行发现两项旧版本正则断言及两项沙箱禁止回环端口监听;同步版本断言后,在允许本机模拟 HTTP 服务的执行环境中完整重跑通过。未跳过失败测试。 + +合成测试运行真实插件公共预检入口,ERP 查询与原生联动由合成表单/响应提供,保存请求被插件预检拦截,无真实 ERP 请求或写入。用户故障日志的候选名称已经脱敏,因此本次只证明格式兼容修复与边界,不声称已取得线上原始产品名的字符级根因证据。 + +交付为插件/平台最低版本 0.5.188、Program v1.0.14。部署、服务重启、插件重载与真实 ERP 验收均未执行。 diff --git a/archive/releases/2026-09-19/README.md b/archive/releases/2026-09-19/README.md new file mode 100644 index 0000000..39c3880 --- /dev/null +++ b/archive/releases/2026-09-19/README.md @@ -0,0 +1,5 @@ +# 2026-09-19 发布归档 + +此目录仅供历史追溯,不是当前业务规则或发布入口。 + +- [产品名称匹配修复前的插件 0.5.187](product-name-normalization-01a0b77e/README.md) diff --git a/archive/releases/2026-09-19/product-name-normalization-01a0b77e/README.md b/archive/releases/2026-09-19/product-name-normalization-01a0b77e/README.md new file mode 100644 index 0000000..2bd0582 --- /dev/null +++ b/archive/releases/2026-09-19/product-name-normalization-01a0b77e/README.md @@ -0,0 +1,3 @@ +# 产品名称匹配修复前的交付物 + +此目录为只读历史,不定义当前业务规则。保留插件 0.5.187 的原始发布 ZIP 和发布前清单;ZIP 已按旧清单 SHA-256 核验,归档前 20 个文件逐字节等于当时源码。清单中的其他交付物未由本次替换,因此不重复归档。当前交付物以项目 dist/release-manifest.json 为准。 diff --git a/archive/releases/2026-09-19/product-name-normalization-01a0b77e/ltjt-order-assistant-0.5.187.zip b/archive/releases/2026-09-19/product-name-normalization-01a0b77e/ltjt-order-assistant-0.5.187.zip new file mode 100644 index 0000000..34e5dc7 Binary files /dev/null and b/archive/releases/2026-09-19/product-name-normalization-01a0b77e/ltjt-order-assistant-0.5.187.zip differ diff --git a/archive/releases/2026-09-19/product-name-normalization-01a0b77e/release-manifest.json b/archive/releases/2026-09-19/product-name-normalization-01a0b77e/release-manifest.json new file mode 100644 index 0000000..ed8ab7e --- /dev/null +++ b/archive/releases/2026-09-19/product-name-normalization-01a0b77e/release-manifest.json @@ -0,0 +1,69 @@ +{ + "manifest_version": 1, + "generated_on": "2026-09-17", + "baselines": { + "chrome_extension": "0.5.187", + "skills": "0.5.129", + "business_instruction_docx": "0.5.136", + "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" + }, + "artifacts": [ + { + "kind": "chrome_extension", + "version": "0.5.187", + "path": "dist/ltjt-order-assistant-0.5.187.zip", + "source": "chrome-extension/ltjt-order-assistant", + "sha256": "a7c60c730921430260ee47a9f704f634ebf1aac25a8a44e1ae868b262f1aa2ee" + }, + { + "kind": "skill", + "name": "lwlt-arrangement", + "version": "0.5.129", + "path": "dist/lwlt-arrangement-0.5.129.skill", + "source": "agent设计规范/skills/lwlt-arrangement", + "sha256": "ae3fc66965d2a7968aeaa2ecf371d75e5365ef4cc4dfd8ef080f9343e0d0f09c" + }, + { + "kind": "skill", + "name": "lwlt-confirmation", + "version": "0.5.129", + "path": "dist/lwlt-confirmation-0.5.129.skill", + "source": "agent设计规范/skills/lwlt-confirmation", + "sha256": "c2f3689d8a0b4d6b250bb2cfce572fe3a9fca3f5519ea5fae5ef0d382b44dae4" + }, + { + "kind": "skill", + "name": "lwlt-lifecycle", + "version": "0.5.129", + "path": "dist/lwlt-lifecycle-0.5.129.skill", + "source": "agent设计规范/skills/lwlt-lifecycle", + "sha256": "fcf23bb0e12e7026f03dc3670fe35db77c740a9902a7a415e2efa464d4eceb1e" + }, + { + "kind": "skill", + "name": "lwlt-newbooking", + "version": "0.5.129", + "path": "dist/lwlt-newbooking-0.5.129.skill", + "source": "agent设计规范/skills/lwlt-newbooking", + "sha256": "b64593d88145966cd4e702d2532ffe3ea85052e8d56fbe75d503a19ababa1715" + }, + { + "kind": "skill", + "name": "lwlt-updating", + "version": "0.5.129", + "path": "dist/lwlt-updating-0.5.129.skill", + "source": "agent设计规范/skills/lwlt-updating", + "sha256": "c57e137c358fb6332af488d5985e52e88e5cf7439fa069a34edfdcb0d0b47336" + }, + { + "kind": "business_instruction_docx", + "version": "0.5.136", + "path": "dist/老挝联泰AI指令表-0.5.136.docx", + "source": "agent设计规范/templates/business-input-templates.md", + "builder": "tools/build_business_instruction_docx.py", + "sha256": "e6a669ef6f9a5b2acb00fba20988869d2c7da2663bb24527cc813853470a622f", + "source_sha256": "ecd51e09b75f56f64773b43a9305fb0ad62fbb3c8d2a0f1ec289eea3d074bdb5", + "builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1" + } + ] +} diff --git a/chrome-extension/ltjt-order-assistant/README.md b/chrome-extension/ltjt-order-assistant/README.md index 2877336..24cf22e 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.187`。版本化 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.188`。版本化 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.187 当前重点: +0.5.188 当前重点: + +- 独立团单个下单的产品名比较统一输入与 ERP 候选的 NFKC 和空白格式,精确优先、唯一包含兜底,保留其他标点及业务限定词;归一后多候选仍阻断。选中后以 ERP 原名填写及触发原生联动。初始匹配/校验失败保持表单未填写,后续阶段标记跳过,产品零/多候选分别给出中文原因。程序解析器 v1.0.14 保留 keyword 的原始格式,name 沿用既有归一化。需同步更新服务端、插件和平台最低版本,无迁移;本地合成验证通过,真实 ERP 验收待部署方完成。 + +继续保留 0.5.187 的房型修改: - 独立团修改新增 DBL 大床房到 frenshu3 的字段映射,与 SGL/TWN 一样按明确目标值填写,重新计算房间合计,并通过保存后 fresh GET 核对大床房和合计。程序解析器 v1.0.13 兼容“房间数更改为 12 标+2 大”;未指定房型保持原值,其他未实现字段继续整体阻断。需同步更新服务端、插件和平台最低版本,无数据库迁移;本地合成验证通过,真实 ERP 实写待验收。 diff --git a/chrome-extension/ltjt-order-assistant/inpage.js b/chrome-extension/ltjt-order-assistant/inpage.js index 09e8162..00f14a1 100644 --- a/chrome-extension/ltjt-order-assistant/inpage.js +++ b/chrome-extension/ltjt-order-assistant/inpage.js @@ -388,6 +388,38 @@ }; } + function selectUniqueProductOption(text, expected) { + // Normalize both sides for comparison only. Preserve punctuation and + // business qualifiers; the selected row still carries ERP's original + // name for Find_product/GetProduct and subsequent exact form checks. + const normalize = (value) => String(value || '').normalize('NFKC').replace(/\s+/gu, ''); + const target = normalize(expected); + const candidates = rows(text); + const exact = target ? candidates.filter((row) => normalize(row.columns[1]) === target) : []; + const contains = target ? candidates.filter((row) => normalize(row.columns[1]).includes(target)) : []; + const matches = exact.length ? exact : contains; + if (matches.length === 1) { + return { + match: matches[0], + rule: exact.length ? 'exact_normalized' : 'unique_contains_normalized', + exact_count: exact.length, + contains_count: contains.length, + blocker: '' + }; + } + return { + match: null, + rule: !target ? 'missing_keyword' : (matches.length ? 'ambiguous_normalized' : 'not_found'), + exact_count: exact.length, + contains_count: contains.length, + blocker: !target + ? '产品搜索不能为空。' + : matches.length + ? `产品搜索匹配到 ${matches.length} 个产品,无法唯一选择,请补充更完整的产品名称。` + : '未找到匹配的产品,请核对产品名称或搜索关键词。' + }; + } + async function sha256(text) { const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); return Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, '0')).join(''); @@ -5675,7 +5707,7 @@ summary: summarizeText(fetched.customer?.text || '') }; - const productSelection = selectUniqueExistingOption(fetched.product?.text || '', data.product?.name, 1, 'product'); + const productSelection = selectUniqueProductOption(fetched.product?.text || '', data.product?.name); if (!productSelection.match) blockers.push(productSelection.blocker); const productInitialCheck = { name: 'product_initial_selection', @@ -5687,6 +5719,32 @@ summary: summarizeText(fetched.product?.text || '') }; + // Nothing has been applied yet. A failed lookup/contract must not be + // reported again as failures of linkage, restoration or blank fields in + // the untouched form. Keep the initial evidence and stop before mutation. + if (blockers.length) { + const skippedStages = ['preflight_product_linkage', 'preflight_customer_restore', 'preflight_final_form_validation', 'preflight_submit_intercept']; + return { + ok: true, + status: 'raw_instruction_preflight_blocked', + page: { url: location.href, title: document.title, form_element_count: form.elements.length }, + lookup_checks: [customerInitialCheck, productInitialCheck], + product_side_effect: { status: 'skipped', ajax_records: [], changed_field_count: 0, changed_field_groups: {}, changed_field_names: [] }, + set_summary: { set_field_count: 0, set_field_names: [], set_results: [] }, + skipped_stages: skippedStages, + submit_intercept: null, + submit_safety: { live_submit_attempted: false, DoInfoJH_network_prevented: false, note: 'Initial preflight failed; form mutation and submission were not attempted.' }, + no_erp_write: true, + write_attempted: false, + blockers, + warnings, + __ltjt_page_action_details: [ + ...timingDetails, + ...skippedStages.map((step) => ({ step, status: 'skipped', duration_ms: 0 })) + ] + }; + } + const jq = window.jQuery || window.$; const originalAjax = jq?.ajax; let resolveProductEffectCompletion = null; @@ -6619,7 +6677,7 @@ } window.LTJTOrderAssistant = { - version: '0.5.187', + version: '0.5.188', resolveNativeListSearchValues, lookupKeywordMatchesText, inspectLifecycleSearchCriteria: lifecycleSearchCriteria, diff --git a/chrome-extension/ltjt-order-assistant/manifest.json b/chrome-extension/ltjt-order-assistant/manifest.json index 6ed06a1..130994c 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.187", + "version": "0.5.188", "description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。", "permissions": [ "activeTab", diff --git a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js index 73a11b6..3bbc3e7 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.187', + version: '0.5.188', openTeamBatchForm, pingTeamBatchFrame, preflightTeamBatchNative, diff --git a/control-plane/src/business-routes.ts b/control-plane/src/business-routes.ts index 8fb71d0..17d68a4 100644 --- a/control-plane/src/business-routes.ts +++ b/control-plane/src/business-routes.ts @@ -79,7 +79,7 @@ export function effectiveParserModeForRoute(route: BusinessRouteDefinition, conf return route.parserPolicy === 'program_only' ? 'program' : 'ai'; } -const PROGRAM_VERSION = 'ltjt-program-parser-v1.0.13'; +const PROGRAM_VERSION = 'ltjt-program-parser-v1.0.14'; export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.136'; const lookupFields = ({ customerRequired = true } = {}): ProgramFieldDefinition[] => [ diff --git a/control-plane/src/program-parser.ts b/control-plane/src/program-parser.ts index 78a656b..914b294 100644 --- a/control-plane/src/program-parser.ts +++ b/control-plane/src/program-parser.ts @@ -273,8 +273,10 @@ function parseDateList(rawValue: string, context: DateContext, nextOccurrence: b } function namedRef(rawValue: string): JsonRecord { - const keyword = compactText(rawValue); - return { name: keyword.replace(/人名币/gu, '人民币'), keyword }; + // Keep the supplied spelling for traceability; only the comparison name + // retains the existing width normalization and registered typo alias. + const keyword = String(rawValue || '').trim(); + return { name: compactText(keyword).replace(/人名币/gu, '人民币'), keyword }; } function positiveInteger(rawValue: string): FieldParseResult { diff --git a/control-plane/test/program-parser.test.ts b/control-plane/test/program-parser.test.ts index eb44c0c..d934749 100644 --- a/control-plane/test/program-parser.test.ts +++ b/control-plane/test/program-parser.test.ts @@ -189,7 +189,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.13'); + assert.equal(corpus.version, 'ltjt-program-parser-v1.0.14'); assert.ok(corpus.cases.length >= 28); for (const candidate of corpus.cases) { const parse = () => parseProgramInput({ @@ -410,6 +410,18 @@ test('independent headcount distributions keep occupied and non-occupied childre } }); +test('named references retain original fullwidth spelling and internal whitespace in keyword', async () => { + const parsed = await parseProgramInput({ + rawText: '独立团单个下单\n发团日期:09-19\n预订客户:测试客户(人名币)\n产品搜索:单订房 (测试市场)\n预估人数:1\n用房数量:1大床房', + receivedAt: '2026-09-18T21:16:59+08:00' + }); + assert.equal(parsed.status, 'agent_parse_passed', JSON.stringify(parsed)); + const data = (parsed.operation as any).data; + assert.deepEqual(data.product, { name: '单订房 (测试市场)', keyword: '单订房 (测试市场)' }); + assert.deepEqual(data.customer, { name: '测试客户(人民币)', keyword: '测试客户(人名币)' }); + assert.deepEqual(data.room_counts, { DBL: 1 }); +}); + test('registered customer typo is normalized only in name while keyword preserves source', async () => { const result = await parseProgramInput({ rawText: routeCases[0].input, receivedAt }); const customer = ((result.operation as any).data.customer); diff --git a/dist/release-manifest.json b/dist/release-manifest.json index ed8ab7e..f2f5924 100644 --- a/dist/release-manifest.json +++ b/dist/release-manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 1, - "generated_on": "2026-09-17", + "generated_on": "2026-09-19", "baselines": { - "chrome_extension": "0.5.187", + "chrome_extension": "0.5.188", "skills": "0.5.129", "business_instruction_docx": "0.5.136", "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" @@ -10,10 +10,10 @@ "artifacts": [ { "kind": "chrome_extension", - "version": "0.5.187", - "path": "dist/ltjt-order-assistant-0.5.187.zip", + "version": "0.5.188", + "path": "dist/ltjt-order-assistant-0.5.188.zip", "source": "chrome-extension/ltjt-order-assistant", - "sha256": "a7c60c730921430260ee47a9f704f634ebf1aac25a8a44e1ae868b262f1aa2ee" + "sha256": "e8288c03a3e16bffadecb00c53c92e2ee23cd5a18ecb638ca5c8d5ccb5157072" }, { "kind": "skill", diff --git a/mappings/lifecycle.mapping.json b/mappings/lifecycle.mapping.json index d5b9322..67a085b 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.187", + "current_extension_version": "0.5.188", "original_arrangement_date_selection": "Hotel, vehicle, transport and other/filing update selection accepts MM-DD or YYYY-MM-DD; omitted year matches original ERP month/day within the exact group only. Require exactly one persisted row, then freeze its actual full dates and id. Full year is exact; mixed/cross-year endpoints are matched independently with actual range order checked. No match or ambiguity blocks before mutation; show up to five original candidates and ask to resubmit complete dates. Creation and legacy target-date changes retain existing semantics.", "historical_test_marker": "TEST-202609", "scope": "/System/Business/", diff --git a/mappings/orders_add.mapping.json b/mappings/orders_add.mapping.json index f28ddf9..67a0b08 100644 --- a/mappings/orders_add.mapping.json +++ b/mappings/orders_add.mapping.json @@ -204,7 +204,9 @@ "pricing_metadata_target": "baojia" }, "endpoint_hint": "../dat/AjaxPublicFun.asp?Act=ProGetProductname&fls=1&fabudanwei=${fabudanwei}; ../dat/AjaxPublicFun.asp?Act=GetProduct&cpm=${chanpinming}", - "match_rule": "exact_name_or_configured_alias", + "match_rule": "nfkc_whitespace_normalized_exact_then_unique_contains", + "comparison_rule": "normalize both requested name and ERP candidate using NFKC and removal of whitespace; retain punctuation and business qualifiers; multiple normalized exact matches block even if one raw spelling is exact", + "selected_value_rule": "write the original ERP candidate name to chanpinming and invoke native Find_product/GetProduct; never write the normalized comparison key", "submit_blocker_if_unresolved": true, "source_region_rule": "derive for diagnostics only; never compare product and customer source-region tokens as a write gate" }, @@ -229,6 +231,7 @@ "row_delimiter": "◇", "column_delimiter": "◆", "strict_rule": "For SelectBox-bound fields, direct text assignment is not sufficient for production. Runtime must resolve exactly one existing row and apply the page's SetVal mapping.", + "initial_failure_rule": "If initial validation or lookup fails, return before form mutation; retain initial lookup counts, distinguish zero/multiple product matches and mark dependent linkage, restoration, final-form validation and submit interception as skipped instead of reporting cascading failures.", "critical_selectboxes": [ { "name": "trip_days", diff --git a/samples/business-parser-gold-corpus.json b/samples/business-parser-gold-corpus.json index d7bbddd..80c00db 100644 --- a/samples/business-parser-gold-corpus.json +++ b/samples/business-parser-gold-corpus.json @@ -1,5 +1,5 @@ { - "version": "ltjt-program-parser-v1.0.13", + "version": "ltjt-program-parser-v1.0.14", "received_at": "2026-08-25T09:00:00+08:00", "description": "脱敏的 19 路由确定性解析 gold corpus;只包含合成业务值。", "cases": [ diff --git a/tools/lifecycle-contract.test.mjs b/tools/lifecycle-contract.test.mjs index 765f096..c83546d 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\.187'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.188'/); }); 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.187 keeps reload dormant unless its durable execution state is fully idle', async () => { +test('extension 0.5.188 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') @@ -2037,7 +2037,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.187'); + assert.equal(mapping.current_extension_version, '0.5.188'); assert.equal( mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'], 'ertrenshu' @@ -2421,10 +2421,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.187'); - assert.match(inpage, /version: '0\.5\.187'/); - assert.match(teamBatchInpage, /version: '0\.5\.187'/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.187'/); + assert.equal(extensionManifest.version, '0.5.188'); + assert.match(inpage, /version: '0\.5\.188'/); + assert.match(teamBatchInpage, /version: '0\.5\.188'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.188'/); assert.match(inpage, /function strictIsoDate\(value\)/); assert.match(inpage, /const startDate = strictIsoDate\(current\('riqi'\)\)/); assert.match(inpage, /const endDate = strictIsoDate\(current\('riqis'\)\)/); diff --git a/tools/single-order-product-preflight.test.mjs b/tools/single-order-product-preflight.test.mjs new file mode 100644 index 0000000..1eed875 --- /dev/null +++ b/tools/single-order-product-preflight.test.mjs @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { loadPageActionRealm } from './page-action-test-realm.mjs'; + +// Exercise the public preflight with synthetic ERP lookup responses, native +// product linkage and submit interception. No real browser or ERP is used. +function fixture({ products = ['单订房 (测试市场)'], query = '单订房(测试市场)', customer = '测试客户', productStatus = 200, incompleteLinkage = false } = {}) { + const requests = []; + const linkedNames = []; + let submittedBody = ''; + const fields = new Map(); + const window = { location: new URL('https://erp.invalid/System/Business/orders_add.asp'), + setTimeout: (...args) => setTimeout(...args).unref(), clearTimeout }; + const document = { defaultView: window, querySelector: (selector) => selector === '#ListForm' ? form : null, + querySelectorAll: () => [] }; + const form = { elements: [], ownerDocument: document, + querySelectorAll: () => [updateButton], querySelector: () => null }; + const values = { + fabudanwei: '测试单位', zutuanshe: '', zutuansheid: '', lianxiren: '', zutuanshegzr: '', bizhong: 'CNY', + chufa_ri: '', chanpinming: '', TianShu: '', zhuanxianming: '', tuanxuhao1: '', tuanxuhao2: '', + gendanren: '测试员工', xiaoshouren: '测试员工', darenshu: '', xiaorenshu: '', ertrenshu: '', yingrenshu: '', quanrenshu: '', + frenshu0: '', frenshu1: '', frenshu2: '', frenshu3: '', frenshu4: '', frenshu5: '', frenshu6: '', + danzhuangtai: '', yaobeian: '', ys_danjia0: '', ys_danwei0: '', ys_danweiid0: '', ys_bizhong0: '', ys_shuliang0: '', ys_jine0: '' + }; + for (let i = 0; i < 800; i++) values[`unused${i}`] = ''; + for (const [name, value] of Object.entries(values)) { + const field = { name, value, tagName: 'INPUT', type: 'text', dispatchEvent() {} }; + fields.set(name, field); form.elements.push(field); form.elements[name] = field; + } + const serialize = () => new URLSearchParams(form.elements.map((field) => [field.name, field.value])).toString(); + const jq = () => ({ serialize }); + jq.ajax = (config) => { + requests.push({ native: true, url: config.url, data: config.data }); + assert.ok(config.url.includes('Act=GetProduct'), 'a real submit must never escape the preflight guard'); + config.success?.('synthetic product response', 'success'); + config.complete?.(); + }; + window.jQuery = jq; + window.Find_product = () => { + const selectedName = fields.get('chanpinming').value; + linkedNames.push(selectedName); + jq.ajax({ url: `../dat/AjaxPublicFun.asp?Act=GetProduct&cpm=${encodeURIComponent(selectedName)}`, + success() { + const effects = { TianShu: incompleteLinkage ? '' : '1D', zhuanxianming: '测试线路', tuanxuhao1: 'TEST', tuanxuhao2: 'A', ys_danjia0: '100' }; + for (const [name, value] of Object.entries(effects)) fields.get(name).value = value; + } }); + }; + window.SubmitInfoForm = () => { + submittedBody = `Act=DoInfoJH&${serialize()}`; + jq.ajax({ url: '/System/DAT/orders.asp', type: 'POST', data: submittedBody }); + }; + const updateButton = { innerText: '更新行程', getAttribute: () => '', click: () => window.Find_product() }; + const globals = { window, document, location: window.location, Event, URL, URLSearchParams, TextEncoder, + crypto: globalThis.crypto, Blob, performance, setTimeout: window.setTimeout, clearTimeout, + async fetch(url) { + requests.push({ native: false, url }); + const action = new URL(url).searchParams.get('Act'); + const responses = { + ProGetProductname: products.map((name, i) => `${i + 1}◆${name}`).join('◇'), + GetInformation: '1◆测试线路◆unused◆TEST', + ProTravel: 'CNY◆123◆测试客户◆测试联系人◆测试负责人◆unused', + ProDanwei_Yuangong: '测试员工◆1' + }; + assert.ok(Object.hasOwn(responses, action), `unexpected lookup ${action}`); + const status = action === 'ProGetProductname' ? productStatus : 200; + return { ok: status === 200, status, text: async () => responses[action] }; + } }; + loadPageActionRealm(window, globals); + const operation = { action: 'team_order_create', order_nature: 'formal', submit_mode: 'dry_run', data: { + customer: { name: customer, keyword: customer }, product: { name: query, keyword: query }, + departure_dates: ['2026-09-19'], passenger_counts: { adult: 1 }, room_counts: { DBL: 1 } + } }; + return { operation, fields, requests, linkedNames, submitted: () => submittedBody, + snapshot: serialize, preflight: () => window.LTJTOrderAssistant.preflightRawInstruction(operation, { interceptSubmit: true }) }; +} + +function productCheck(result) { + return result.lookup_checks.find((check) => check.name === 'product_initial_selection'); +} + +async function assertUntouched(f) { + const before = f.snapshot(); + const result = await f.preflight(); + assert.equal(result.status, 'raw_instruction_preflight_blocked'); + assert.equal(result.no_erp_write, true); + assert.equal(result.write_attempted, false); + assert.equal(result.set_summary.set_field_count, 0); + assert.equal(result.product_side_effect.status, 'skipped'); + assert.equal(f.snapshot(), before); + assert.equal(f.linkedNames.length, 0); + assert.equal(f.submitted(), ''); + assert.equal(f.requests.length, 4); + assert.ok(f.requests.every((request) => !request.native)); + assert.equal(result.submit_intercept, null); + assert.equal(result.skipped_stages.length, 4); + assert.doesNotMatch(result.blockers.join(), /GetProduct|after_effect|after_restore|Required fields still blank/); + return result; +} + +test('single-order preflight normalizes both sides and uses the exact ERP spelling through native linkage and intercepted submission', async () => { + for (const [query, erpName] of [ + ['单订房(测试市场)', '单订房 (测试市场)'], + ['单订房(测试市场)', '单订房 (测试市场)'], + ['单订房\u3000(测试市场)', '单订房\u00a0(测试市场)'], + ['AB12(测试市场)', 'AB12 (测试市场)'] + ]) { + const f = fixture({ products: [erpName], query }); + const result = await f.preflight(); + assert.equal(result.status, 'raw_instruction_preflight_passed', JSON.stringify(result.blockers)); + assert.equal(productCheck(result).match_rule, 'exact_normalized'); + assert.equal(productCheck(result).exact_match_count, 1); + assert.deepEqual(f.linkedNames, [erpName]); + assert.equal(f.fields.get('chanpinming').value, erpName); + assert.equal(new URLSearchParams(f.submitted()).get('chanpinming'), erpName); + assert.equal(new URLSearchParams(f.submitted()).get('frenshu3'), '1'); + assert.equal(result.submit_intercept.status, 'submit_intercept_captured'); + assert.equal(result.submit_safety.live_submit_attempted, false); + assert.equal(f.requests.filter((request) => request.native).length, 1); + } +}); + +test('normalized exact match takes precedence over longer containing product names', async () => { + const f = fixture({ products: ['单订房 (测试市场)', '单订房(测试市场)升级版'] }); + const result = await f.preflight(); + assert.equal(result.status, 'raw_instruction_preflight_passed', JSON.stringify(result.blockers)); + assert.equal(productCheck(result).exact_match_count, 1); + assert.equal(productCheck(result).contains_match_count, 2); + assert.deepEqual(f.linkedNames, ['单订房 (测试市场)']); +}); + +test('a unique shortened keyword still selects the ERP product', async () => { + const f = fixture({ query: '单订房', products: ['单订房 (测试市场)', '单订车(测试市场)'] }); + const result = await f.preflight(); + assert.equal(result.status, 'raw_instruction_preflight_passed', JSON.stringify(result.blockers)); + assert.equal(productCheck(result).match_rule, 'unique_contains_normalized'); + assert.deepEqual(f.linkedNames, ['单订房 (测试市场)']); +}); + +test('normalization collisions block even when one raw spelling matches exactly', async () => { + const f = fixture({ products: ['单订房(测试市场)', '单订房 (测试市场)'] }); + const result = await assertUntouched(f); + assert.equal(productCheck(result).exact_match_count, 2); + assert.equal(result.blockers.length, 1); + assert.match(result.blockers[0], /匹配到 2 个产品/); +}); + +test('multiple shortened-keyword matches block instead of selecting the first', async () => { + const f = fixture({ query: '单订房', products: ['单订房(市场甲)', '单订房(市场乙)'] }); + const result = await assertUntouched(f); + assert.equal(productCheck(result).exact_match_count, 0); + assert.equal(productCheck(result).contains_match_count, 2); + assert.equal(result.blockers.length, 1); + assert.match(result.blockers[0], /匹配到 2 个产品/); +}); + +test('unmatched product stops with one useful error and preserves punctuation and qualifiers', async () => { + for (const products of [[], ['单订房'], ['单订房(市场乙)'], ['单订房-测试市场'], ['单订房测试市场']]) { + const result = await assertUntouched(fixture({ products })); + assert.equal(productCheck(result).exact_match_count, 0); + assert.equal(productCheck(result).contains_match_count, 0); + assert.equal(result.blockers.length, 1); + assert.match(result.blockers[0], /未找到匹配的产品/); + } +}); + +test('failed lookup transport or customer selection never advances into product linkage', async () => { + const transport = await assertUntouched(fixture({ productStatus: 503 })); + assert.match(transport.blockers.join(), /HTTP 503/); + assert.equal(productCheck(transport).exact_match_count, 1); + const customer = await assertUntouched(fixture({ customer: '不存在的客户' })); + assert.match(customer.blockers.join(), /customer_initial_selection/); +}); + +test('successful name resolution still requires complete native linkage before submission', async () => { + const f = fixture({ incompleteLinkage: true }); + const result = await f.preflight(); + assert.equal(result.status, 'raw_instruction_preflight_blocked'); + assert.equal(productCheck(result).exact_match_count, 1); + assert.match(result.blockers.join(), /product_template_trip_nonempty/); + assert.equal(f.linkedNames.length, 1); + assert.equal(f.submitted(), ''); + assert.equal(result.submit_safety.live_submit_attempted, false); +});