diff --git a/.project-docs/30-worklog/tasks/20260915-roster-template-formulas-01a0858b.md b/.project-docs/30-worklog/tasks/20260915-roster-template-formulas-01a0858b.md new file mode 100644 index 0000000..cd304e8 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260915-roster-template-formulas-01a0858b.md @@ -0,0 +1,17 @@ +# 名单身份证模板公式兼容 + +- 模式:Feature;仅本任务记录写入项目记忆。 +- 用户授权:兼容新提供的南京 21+1 名单工作簿;仅本地解析验证,不创建导入任务、不写 ERP、不部署或重启。 +- Worktree:`/Users/andy/IdeaProjects/LWLT-AIBOT`;branch `main`;base `af85ea22ecc71b84613f58c1fdbb2cbd275e00eb`。 +- 初始改动:未跟踪 `.idea/`、`dist/ltjt-order-assistant-0.5.185.zip`,保留;无其他 worktree、无重叠源码修改。Overlap:Clear。 +- 现状:真实 XLS 转换成功;顶部说明合并单元格的错误值阻断表头发现,性别和出生日期的固定身份证推算公式未被支持。 +- 范围:控制面名单归一化器、脱敏单元测试和本任务记录。按需同步活动契约;不改 Chrome 插件或业务写入逻辑。 +- 方案:发现表头时不把错误值视为标签;实际名单错误仍阻断。有限匹配同一行身份证模板公式,校验源证件格式、真实日期及缓存结果一致性,再生成 canonical TSV;不运行任意公式、不导出身份证字段。 +- 验证计划:合成回归覆盖错误区分、公式引用/缓存/来源、列重排及旧模板;真实 XLS 仅内存转换和程序解析、只输出计数;运行仓库要求的全部检查。 +- 结果:归一化器升级 `v1.6.0`;原始 XLS 可识别 22 人,程序解析通过,领队序号 22;源文件未修改,未创建导入任务或执行 ERP 写入。 +- 交付同步:Skill/输入契约说明明确公式支持范围;五个 Skill 统一重新打包 `0.5.127`,DOCX `0.5.134` 重建并逐页检查全部 16 页;业务输入版本同步,README 指向唯一发布清单。旧 6 个文件经哈希校验归档。扩展保留 `0.5.185`。 +- 最终验证:`check:repo` 10/10;`check`、`build` 通过;控制面 253/253、legacy 411/411;五个 Skill 官方验证与发布包逐文件核对通过。隔离源码副本排除了秘密与原有 IDE/日志残留,最终核对源码字节一致。 +- 验证过程修正:同步 DOCX 版本的固定测试断言;合成 XLSX 写入器会删除 XML 不允许的控制字符,改用可保留的制表符验证原值限制。未放松缓存一致性条件。 +- 状态:完成本地兼容与验证;运行中的本地服务需用户重启,生产需更新服务端后生效,不需更换插件。 +- 证据:[验证记录](../../../archive/evidence/2026-09-15/roster-template-formulas-01a0858b/README.md)。 +- Promotion Candidates:仅对白名单内同一行身份证模板验证缓存,不泛化公式执行;标题错误值与实际名单错误分开处理;明确表格解析通过与 ERP 实际导入成功的边界。 diff --git a/README.md b/README.md index 935189b..d6b93d7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## 当前发布基线 - Chrome 扩展制品版本由 [发布清单](dist/release-manifest.json) 定义;运行中的浏览器版本必须通过实时握手确认,不写入静态交接文档。 -- 五个业务 Skill:`0.5.126`;运营 DOCX:`0.5.127`。 +- 五个业务 Skill 与运营 DOCX 的当前版本见 [发布清单](dist/release-manifest.json)。 - Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories`。 - 控制面确定性解析器:`ltjt-program-parser-v1.0.7`;19 项路由中三项固定为 Program-only(两项名单及散拼批量子单),其余路由由操作台逐项晋级;新增批量子单不进入外部 Agent 路由。 - 独立团/散拼子单名单固定为 Program-only:文字用于业务定位,名单由单个 `.xls`/`.xlsx` 附件确定性规范化;附件到齐前任务保持等待。 diff --git a/agent设计规范/skills/lwlt-lifecycle/SKILL.md b/agent设计规范/skills/lwlt-lifecycle/SKILL.md index 25dd8b5..21f3d7c 100644 --- a/agent设计规范/skills/lwlt-lifecycle/SKILL.md +++ b/agent设计规范/skills/lwlt-lifecycle/SKILL.md @@ -11,7 +11,7 @@ description: 定义 LTJT 的独立团或散拼子单名单导入、按序号补 1. 从首行和正文识别名单导入、取消或恢复。 2. 按 [input-contract.md](references/input-contract.md) 检查用户可见必填字段。 -3. 名单 route 先进入 `awaiting_attachment`;只接收一份 `.xls`/`.xlsx`。控制面程序在前 100 行中自动定位唯一的 ERP 名单字段表头,按精确字段语义从任意列顺序中只选择 12 个必需源字段,再把表头下方的连续名单数据生成内部 13 列 canonical TSV;身份证、年龄、源证件类型和其他普通额外列一律忽略且不进入输出。附件到齐前不启动解析或执行。 +3. 名单 route 先进入 `awaiting_attachment`;只接收一份 `.xls`/`.xlsx`。控制面程序在前 100 行中自动定位唯一的 ERP 名单字段表头,按精确字段语义从任意列顺序中选择 12 个必需源字段,再把表头下方的连续名单数据生成内部 13 列 canonical TSV。性别、出生日期采用受支持的身份证模板公式时,程序仅为验证公式读取同一行身份证并校验缓存结果;身份证不进入输出。年龄、源证件类型和其他普通额外列仍忽略。附件到齐前不启动解析或执行。具体公式范围见 `references/input-contract.md`。 4. Program Parser 校验内部 13 列表头、每行完整性、正整数唯一序号;允许用户只提供本次需要补录的一个或多个序号。备注去空格后严格等于`领队`的唯一行会生成结构化 `passenger_list.leader_contact`;姓名或电话为空、或出现多行领队时失败关闭。 5. 按 [operation-contract.md](references/operation-contract.md) 生成一个 operation。 6. 返回 `agent_parse_passed`、`agent_parse_needs_input` 或 `agent_parse_blocked`。 diff --git a/agent设计规范/skills/lwlt-lifecycle/references/input-contract.md b/agent设计规范/skills/lwlt-lifecycle/references/input-contract.md index 8c6b0ca..9e47c99 100644 --- a/agent设计规范/skills/lwlt-lifecycle/references/input-contract.md +++ b/agent设计规范/skills/lwlt-lifecycle/references/input-contract.md @@ -60,7 +60,16 @@ 示例同时附一份符合 ERP 字段规则的名单工作簿。 -附件必须且只能有一个可见工作表。程序在前 100 行中自动定位唯一表头;表头可直接位于第一行,也可位于团信息或说明行之后,列顺序任意,表头下一行起的名单数据必须连续。表头必须各包含一次这些 ERP 语义字段:`序号/姓名/英文姓名(或 NAME)/性别/出生日期/出生地/护照号码(或证件号码)/签发地/签发日期(或签发日)/有效期/电话/备注`。程序只选择和校验这些必需字段;`年龄`、`身份证号码(或身份证)`、源`证件类型`以及其他普通额外列均被忽略,不参与名单行识别,也不会进入 ERP 或内部 canonical TSV。程序把英文姓名映射为 `NAME`、护照号码映射为“证件号码”、固定输出“证件类型=护照”,并把签发日期映射为“签发日”。 +附件必须且只能有一个可见工作表。程序在前 100 行中自动定位唯一表头;表头可直接位于第一行,也可位于团信息或说明行之后,列顺序任意,表头下一行起的名单数据必须连续。表头必须各包含一次这些 ERP 语义字段:`序号/姓名/英文姓名(或 NAME)/性别/出生日期/出生地/护照号码(或证件号码)/签发地/签发日期(或签发日)/有效期/电话/备注`。程序选择和校验这些必需字段;`年龄`、源`证件类型`以及其他普通额外列均被忽略。`身份证号码(或身份证)`仅在性别或出生日期使用下述固定公式时作为校验来源,其余情况下忽略;身份证原值不会进入 ERP 或内部 canonical TSV。程序把英文姓名映射为 `NAME`、护照号码映射为“证件号码”、固定输出“证件类型=护照”,并把签发日期映射为“签发日”。 + +顶部团信息或说明区的 Excel 错误值(如 `#VALUE!`)不作为表头标签;名单字段中的错误值仍阻断。性别、出生日期兼容以下固定身份证推算公式(示例 `E3` 必须对应唯一身份证列的同一行;列重排后按实际位置匹配): + +```text +性别:IF(OR(LEN(E3)=15,LEN(E3)=18),IF(MOD(MID(E3,15,3)*1,2),"男","女"),NA()) +出生日期:DATE(MID(E3,7,VLOOKUP(LEN(E3),{15,2;18,4},2,0)),MID(E3,VLOOKUP(LEN(E3),{15,9;18,11},2,0),2),MID(E3,VLOOKUP(LEN(E3),{15,11;18,13},2,0),2)) +``` + +程序不执行任意公式,只接收上述模板的已有计算结果,并核对同一行 15 位或 18 位身份证的结构、有效出生日期以及推算性别/生日是否一致。18 位身份证应保存为文本,避免数字精度丢失;不进行身份证真实性或校验码认证。来源缺失、跨行引用、身份证列歧义、缓存缺失/错误/不一致均阻断。原有签发日与有效期之间的同一行十年期 `EDATE` 模板继续支持;宏、外链和超链接检查仍覆盖整个工作簿。 若某位游客是领队,该行`备注`去除首尾空格后必须严格等于`领队`,并同时填写该行`姓名`和`电话`。一份名单最多标记一位领队;标记成功后程序会把这两个值写入 ERP `联系人/领队人`和`电话`,该行仍作为普通游客导入。`领队备注`等其他文字不触发写入;没有严格标记时保留 ERP 原联系人信息。正文里的`领队`字段仍只是订单定位的选填补充关键词,不是联系人写入来源。 diff --git a/agent设计规范/templates/business-input-templates.md b/agent设计规范/templates/business-input-templates.md index ab1e6bd..7163462 100644 --- a/agent设计规范/templates/business-input-templates.md +++ b/agent设计规范/templates/business-input-templates.md @@ -1,6 +1,6 @@ # 运营业务 AI 输入模板总表 -交付版本:0.5.133 +交付版本:0.5.134 适用范围:当前 23 条业务路由,按 19 项运营场景分组;执行能力以生命周期发布门槛为准。 本文件用于向系统提交 ERP 业务需求。复制对应模板填写;示例只供参考,不要直接用于生产业务。 @@ -154,7 +154,9 @@ 名单只通过一份 `.xls` 或 `.xlsx` 附件提交,不再粘贴到文字输入框。系统识别名单业务后会等待附件,附件到齐后只走程序解析。 -系统会在工作表前 100 行中自动定位唯一的名单表头;表头可以直接在第一行,也可以位于团信息或说明行之后,字段顺序任意,表头下一行开始识别连续名单数据。表头必须各包含一次这些 ERP 语义字段:`序号/姓名/英文姓名(或 NAME)/性别/出生日期/出生地/护照号码(或证件号码)/签发地/签发日期(或签发日)/有效期/电话/备注`。系统只读取和校验这些必需字段;`年龄`、`身份证号码(或身份证)`、源`证件类型`以及其他普通额外列都会被忽略,不进入 ERP,也不会因为有值而导致整份名单失败。每次只保留一个可见工作表,最多 5000 行;宏、外部链接等主动内容仍会被安全阻断。 +系统会在工作表前 100 行中自动定位唯一的名单表头;表头可以直接在第一行,也可以位于团信息或说明行之后,字段顺序任意,表头下一行开始识别连续名单数据。表头必须各包含一次这些 ERP 语义字段:`序号/姓名/英文姓名(或 NAME)/性别/出生日期/出生地/护照号码(或证件号码)/签发地/签发日期(或签发日)/有效期/电话/备注`。系统读取和校验这些必需字段;`年龄`、源`证件类型`以及其他普通额外列会被忽略,不进入 ERP。每次只保留一个可见工作表,最多 5000 行;宏、外部链接等主动内容仍会被安全阻断。 + +名单模板中按同一行身份证自动推算性别和出生日期的固定公式可以保留。请保留唯一的`身份证号码`或`身份证`列,18 位号码使用文本格式保存;系统会核对公式计算结果,身份证本身不导入 ERP。公式结果缺失、错误或与身份证不一致时,需要先在表格中修正。直接填写性别和出生日期时,身份证列仍被忽略。顶部说明区的错误值不影响识别;旅客资料中的错误值需要修正。 若游客是领队,在该行`备注`只填`领队`,并确保同一行`姓名`和`电话`已填写;一份名单最多一位。系统会把这两个值同步到 ERP `联系人/领队人`和`电话`。其他备注不触发联系人写入;没有领队标记时保留 ERP 原信息。正文里的`领队`仍只是选填的查单关键词。 diff --git a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md index 33cb9e5..17c12be 100644 --- a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md +++ b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md @@ -5,7 +5,7 @@ - Chrome 插件:`0.5.185` - 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.133` +- 五个 Skill:`0.5.127`;运营 DOCX:`0.5.134` - 发布状态:核心窄能力已有真实证据;一笔名单附件 Program-only 任务已把 25 行及唯一领队联系人正确保存到 ERP,后续只读逐字段核验全等。两种写后逐行读取方式都曾在真实成功写入后产生假阴性;名单在全部写前门禁通过后,以父表单 ERP 明确成功响应作为终结证据,不再执行写后逐行回查或自动名单对账。`0.5.155` 规定确认覆盖的指定序号同值也写;`0.5.156` 把原生游客位不足统一映射为包含名单人数和 ERP 实际上限的业务提示,原技术 blocker 仅留技术详情。`0.5.157` 新增散拼母团“整团游客信息”的 `shared_plan + visitor-list + tid-only` 窄分支,静态契约与回归已通过,仍待授权后的新版运行态只读复测。`0.5.158` 把正式控制面 `https://lwlt.nianxx.cn` 同步加入 Popup、后台、Manifest 权限和 content script 精确白名单;其他 HTTPS origin 仍保持阻断。`0.5.159` 修复 MV3 后台恢复被中断后持久化 `running` 永久阻断插件派发的问题:仅在内存恢复 Promise 已不存在时收敛为 `interrupted`,真实活动恢复仍保持阻断,下一次成功只读保活把状态归零为 `idle`。`0.5.160` 为独立团单个下单写前漂移增加仅字段名诊断,并把数字后缀动态应收字段纳入保护。`0.5.161` 等待客户、跟单人和销售人原生 lookup 数据就绪后才开始单团预检;生产反馈证明仅检查 lookup 数据仍不足。`0.5.162` 进一步要求页面 jQuery Ajax 在预检前归零,并在 `GetProduct` 后等待原生 `ajaxStop` 再应用最终业务字段。`0.5.163` 按生产操作方明确要求恢复 `0.5.156` 的实际校验范围:核心订单字段继续写前阻断,带编号的动态 `ys_*` 应收行不再纳入保护哈希,允许 ERP 原生回调在预检后调整这些字段。`0.5.164` 新增平台账号、AgentBus 渠道、唯一云电脑 worker 与 ERP 登录身份四方一致门禁;旧未绑定渠道和历史未归属 AgentBus 任务默认不执行。`0.5.165` 让散拼新增计划和独立团批量下单在本地候选未命中时先使用表单原生非空产品检索,实测两类表单均能由完整关键词唯一返回目标产品;本轮未勾选或保存,自动选择与写入仍由原门禁保护。新增独立团房型/人数映射仍待实写;未验证宽能力继续失败关闭。 - `0.5.166` 在散拼母团列表入口加入立即检查、100ms 条件轮询和 15 秒总超时;每次探测都重新取得当前主 iframe document,按钮一旦可用便立即继续。该等待只发生在点击前,不固定拖慢快速页面,也不触发业务写入重试。 @@ -15,7 +15,7 @@ - `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 输出。必需字段完整性、宏/外链、全局超链接和外部公式等安全门槛保持失败关闭。 +- 名单工作簿 normalizer `v1.6.0` 继续选择 12 个必需源字段;新增固定身份证推算性别/生日公式的同一行来源和缓存一致性校验,顶部说明区错误值不作为表头。身份证只用于该公式校验,不进入 canonical 输出;其余普通额外列仍忽略。必需字段错误、宏/外链、全局超链接和外部公式等安全门槛保持失败关闭。验证范围见[验证记录](../../../archive/evidence/2026-09-15/roster-template-formulas-01a0858b/README.md),未执行真实 ERP 导入。 运行中的浏览器版本必须实时握手确认。当前制品文件名和 SHA-256 只看 [`../../../dist/release-manifest.json`](../../../dist/release-manifest.json),不从历史日志推断。 diff --git a/archive/evidence/2026-09-15/roster-template-formulas-01a0858b/README.md b/archive/evidence/2026-09-15/roster-template-formulas-01a0858b/README.md new file mode 100644 index 0000000..fdc8db9 --- /dev/null +++ b/archive/evidence/2026-09-15/roster-template-formulas-01a0858b/README.md @@ -0,0 +1,15 @@ +# 名单模板公式兼容验证 + +本目录记录 2026-09-15 本地验证结论,不是生产运行结果或后续版本规则。 + +- 归一化器:`ltjt-passenger-roster-workbook-v1.6.0`;业务输入契约 `0.5.134`。 +- 用户提供的真实 XLS 经过项目隔离转换器、名单归一化器和 Program Parser 完整校验:22 行、13 列、序号 1–22 连续,序号 22 的领队姓名/电话可识别,解析状态 `agent_parse_passed`。21 行电话为空,沿用现有允许空值的规则。 +- 真实附件只在内存和转换器受控临时目录中处理,源文件哈希前后相同;没有持久化旅客明细、调用数据库、创建任务或写入 ERP。程序验证使用合成定位文字,不代表真实 ERP 订单匹配或执行验收。 +- 回归:`node --run check`、`node --run build` 通过;`check:repo` 10/10,`test:control-plane` 253/253,`test:legacy` 411/411。仓库检查也是 legacy 套件的一部分,不重复计入总数。 +- 回归使用 `/private/tmp/ltjt-roster-formula-verify-20260915` 的逐文件一致源码副本,排除 `.env`、IDE 配置、可重建输出和既有日志等残留;保留原工作区既有文件。仅 legacy 的本机 HTTP fixture 使用回环端口权限。 +- 新增 8 个专项测试覆盖:说明区/表头/旅客错误值区分,15/18 位身份证模板,列重排/共享公式/1904 日期系统,缓存缺失及不一致,跨行/歧义/非模板/外部引用,来源精度/日期/空白字符,尾部局部数据保留,以及 22 人名单到 Program Parser 的完整路径。 +- 五个 Skill 官方 `quick_validate.py` 全部通过;所有 `.skill` 重新从活动源构建,逐文件字节一致。Skill 版本 `0.5.127`,扩展版本保持 `0.5.185`。 +- 指令表 `0.5.134` 从唯一 Markdown 源及既有布局模板重建,Documents renderer 输出 16 页 PNG 并逐页检查:中文正常、无截断/重叠。当前 DOCX、构建器、源 Markdown 哈希由 `dist/release-manifest.json` 定义。 +- 最终限制:只支持明确匹配的两种同一行身份证模板;不执行任意公式、不验证身份证真实性或校验码。错误值、来源不合法或缓存不一致继续阻断;身份证不写入 canonical TSV 或 ERP。 + +旧发布物已校验后归档在 [发布归档](../../../releases/2026-09-15/roster-template-formulas-01a0858b/README.md)。 diff --git a/archive/releases/2026-09-15/README.md b/archive/releases/2026-09-15/README.md index 8ad7195..9b7e2bb 100644 --- a/archive/releases/2026-09-15/README.md +++ b/archive/releases/2026-09-15/README.md @@ -5,3 +5,5 @@ Historical material, not current business rules or release instructions. - [0.5.183 before dispatch/readiness fix](dispatch-readiness-01a0858b/README.md) - [0.5.184 before resolution navigation recovery](resolution-navigation-01a0858b/README.md) + +- [名单模板公式兼容前的 Skill 和指令表](roster-template-formulas-01a0858b/README.md) diff --git a/archive/releases/2026-09-15/roster-template-formulas-01a0858b/README.md b/archive/releases/2026-09-15/roster-template-formulas-01a0858b/README.md new file mode 100644 index 0000000..c2dbca0 --- /dev/null +++ b/archive/releases/2026-09-15/roster-template-formulas-01a0858b/README.md @@ -0,0 +1,3 @@ +# 名单模板公式兼容前的发布物 + +这些文件是只读历史材料,不是当前业务规则或发布入口。旧版五个 Skill 0.5.126 和指令表 0.5.133 归档前后均核对 SHA-256。当前文件及哈希以项目 dist/release-manifest.json 为准。 diff --git a/dist/lwlt-arrangement-0.5.126.skill b/archive/releases/2026-09-15/roster-template-formulas-01a0858b/lwlt-arrangement-0.5.126.skill similarity index 100% rename from dist/lwlt-arrangement-0.5.126.skill rename to archive/releases/2026-09-15/roster-template-formulas-01a0858b/lwlt-arrangement-0.5.126.skill diff --git a/dist/lwlt-confirmation-0.5.126.skill b/archive/releases/2026-09-15/roster-template-formulas-01a0858b/lwlt-confirmation-0.5.126.skill similarity index 100% rename from dist/lwlt-confirmation-0.5.126.skill rename to archive/releases/2026-09-15/roster-template-formulas-01a0858b/lwlt-confirmation-0.5.126.skill diff --git a/dist/lwlt-lifecycle-0.5.126.skill b/archive/releases/2026-09-15/roster-template-formulas-01a0858b/lwlt-lifecycle-0.5.126.skill similarity index 100% rename from dist/lwlt-lifecycle-0.5.126.skill rename to archive/releases/2026-09-15/roster-template-formulas-01a0858b/lwlt-lifecycle-0.5.126.skill diff --git a/dist/lwlt-newbooking-0.5.126.skill b/archive/releases/2026-09-15/roster-template-formulas-01a0858b/lwlt-newbooking-0.5.126.skill similarity index 100% rename from dist/lwlt-newbooking-0.5.126.skill rename to archive/releases/2026-09-15/roster-template-formulas-01a0858b/lwlt-newbooking-0.5.126.skill diff --git a/dist/lwlt-updating-0.5.126.skill b/archive/releases/2026-09-15/roster-template-formulas-01a0858b/lwlt-updating-0.5.126.skill similarity index 100% rename from dist/lwlt-updating-0.5.126.skill rename to archive/releases/2026-09-15/roster-template-formulas-01a0858b/lwlt-updating-0.5.126.skill diff --git a/archive/releases/2026-09-15/roster-template-formulas-01a0858b/release-manifest-before.json b/archive/releases/2026-09-15/roster-template-formulas-01a0858b/release-manifest-before.json new file mode 100644 index 0000000..9a7ae86 --- /dev/null +++ b/archive/releases/2026-09-15/roster-template-formulas-01a0858b/release-manifest-before.json @@ -0,0 +1,69 @@ +{ + "manifest_version": 1, + "generated_on": "2026-09-15", + "baselines": { + "chrome_extension": "0.5.185", + "skills": "0.5.126", + "business_instruction_docx": "0.5.133", + "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" + }, + "artifacts": [ + { + "kind": "chrome_extension", + "version": "0.5.185", + "path": "dist/ltjt-order-assistant-0.5.185.zip", + "source": "chrome-extension/ltjt-order-assistant", + "sha256": "42d12ea2a9e090af3202b683f66a4418fa7aac199686bfb7840251e1fb9b899c" + }, + { + "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.133", + "path": "dist/老挝联泰AI指令表-0.5.133.docx", + "source": "agent设计规范/templates/business-input-templates.md", + "builder": "tools/build_business_instruction_docx.py", + "sha256": "879f1acc3a6a60842e59ae8396f72d39504ea1f3a4110a9f305d94553b38848c", + "source_sha256": "37b6b689c5c6a80683dd1deeff52a90ac8b4bd3ac0380e6b771dcb557c0918f7", + "builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1" + } + ] +} diff --git a/dist/老挝联泰AI指令表-0.5.133.docx b/archive/releases/2026-09-15/roster-template-formulas-01a0858b/老挝联泰AI指令表-0.5.133.docx similarity index 100% rename from dist/老挝联泰AI指令表-0.5.133.docx rename to archive/releases/2026-09-15/roster-template-formulas-01a0858b/老挝联泰AI指令表-0.5.133.docx diff --git a/control-plane/src/business-routes.ts b/control-plane/src/business-routes.ts index 4bd5f22..18b2d87 100644 --- a/control-plane/src/business-routes.ts +++ b/control-plane/src/business-routes.ts @@ -80,7 +80,7 @@ export function effectiveParserModeForRoute(route: BusinessRouteDefinition, conf } const PROGRAM_VERSION = 'ltjt-program-parser-v1.0.12'; -export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.133'; +export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.134'; const lookupFields = ({ customerRequired = true } = {}): ProgramFieldDefinition[] => [ { key: 'order_no', label: '单号', aliases: ['团号', '订单号'], kind: 'text' }, diff --git a/control-plane/src/passenger-roster-workbook.ts b/control-plane/src/passenger-roster-workbook.ts index aa15898..658351d 100644 --- a/control-plane/src/passenger-roster-workbook.ts +++ b/control-plane/src/passenger-roster-workbook.ts @@ -13,7 +13,7 @@ const ABSOLUTE_MAX_DATA_ROWS = 5_000; const MAX_PASSENGER_SEQUENCE = 5_000; const MAX_HEADER_ROW = 100; -export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.5.1'; +export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.6.0'; const REQUIRED_SOURCE_FIELDS = [ '序号', '姓名', '英文姓名', '性别', '出生日期', '出生地', '护照号码', @@ -71,6 +71,7 @@ export type PassengerRosterWorkbookErrorCode = | 'roster_workbook_ambiguous_header' | 'roster_workbook_formula_not_allowed' | 'roster_workbook_formula_result_missing' + | 'roster_workbook_formula_result_mismatch' | 'roster_workbook_non_contiguous_rows' | 'roster_workbook_incomplete_row' | 'roster_workbook_row_limit' @@ -98,6 +99,7 @@ const ERROR_MESSAGES: Record = { roster_workbook_ambiguous_header: '名单工作簿包含多个可识别的名单表头', roster_workbook_formula_not_allowed: '名单必需字段包含不允许的公式', roster_workbook_formula_result_missing: '名单必需字段公式没有可验证的缓存结果', + roster_workbook_formula_result_mismatch: '名单模板公式结果与同一行身份证信息不一致', roster_workbook_non_contiguous_rows: '名单数据行不连续', roster_workbook_incomplete_row: '名单中间存在未填写旅客资料的模板行', roster_workbook_row_limit: '名单数据行数超过技术限制', @@ -325,7 +327,8 @@ function excelColumnName(column: number): string { function formulaAllowed( cell: ExcelJS.Cell, headerRow: number, - sourceColumns: SourceColumnMap + sourceColumns: SourceColumnMap, + identityColumn?: number ): boolean { const row = cellRow(cell); const column = cellColumn(cell); @@ -337,7 +340,7 @@ function formulaAllowed( if (column === sourceColumns['有效期']) { return formula === `EDATE(${excelColumnName(sourceColumns['签发日期'])}${row},10*12)-1`; } - return false; + return identityFormulaField(cell, sourceColumns, identityColumn) !== undefined; } function scalarValue(cell: ExcelJS.Cell, allowFormulaResult = false): ScalarCellValue { @@ -378,12 +381,14 @@ function normalizedText(value: ScalarCellValue): string { } function headerText(cell: ExcelJS.Cell): string { - if (cell.type === ExcelJS.ValueType.Formula) return ''; + // An error in the title is not a header label. Errors in roster cells are + // still non-empty data and must pass scalar validation before export. + if (cell.type === ExcelJS.ValueType.Formula || cell.type === ExcelJS.ValueType.Error) return ''; return normalizedText(scalarValue(cell)); } function cellHasData(cell: ExcelJS.Cell): boolean { - if (cell.type === ExcelJS.ValueType.Formula) return true; + if (cell.type === ExcelJS.ValueType.Formula || cell.type === ExcelJS.ValueType.Error) return true; return normalizedText(scalarValue(cell)) !== ''; } @@ -402,7 +407,29 @@ const BROKEN_TEMPLATE_FORMULAS: Partial> = { 出生日期: 'DATE(MID(#REF!,7,VLOOKUP(LEN(#REF!),{15,2;18,4},2,0)),MID(#REF!,VLOOKUP(LEN(#REF!),{15,9;18,11},2,0),2),MID(#REF!,VLOOKUP(LEN(#REF!),{15,11;18,13},2,0),2))' }; -function unusedTemplateRow(row: ExcelJS.Row, headerRow: number, sourceColumns: SourceColumnMap): boolean { +function identityFormulaField( + cell: ExcelJS.Cell, + sourceColumns: SourceColumnMap, + identityColumn?: number +): '性别' | '出生日期' | undefined { + if (!identityColumn || cell.type !== ExcelJS.ValueType.Formula) return undefined; + const reference = `${excelColumnName(identityColumn)}${cellRow(cell)}`; + // Preserve array row/column separators: flattening the VLOOKUP table changes + // its meaning. This is a finite template match, never a formula evaluator. + const normalize = (formula: string): string => formula.replace(/^=/, '').replace(/\$/g, '').replace(/\s+/g, '').toUpperCase(); + for (const field of ['性别', '出生日期'] as const) { + if (cellColumn(cell) === sourceColumns[field] + && normalize(cell.formula) === normalize(BROKEN_TEMPLATE_FORMULAS[field]!.replaceAll('#REF!', reference))) return field; + } + return undefined; +} + +function unusedTemplateRow(row: ExcelJS.Row, headerRow: number, sourceColumns: SourceColumnMap, identityColumn?: number): boolean { + // A populated dependency cannot turn an unfinished passenger into an unused + // formula-only tail row. Static-field rosters still ignore identity columns. + if (identityColumn && (['性别', '出生日期'] as const).some(field => + identityFormulaField(row.getCell(sourceColumns[field]), sourceColumns, identityColumn)) + && cellHasData(row.getCell(identityColumn))) return false; const sequenceCell = row.getCell(sourceColumns['序号']); if (sequenceCell.type === ExcelJS.ValueType.Formula) return false; const sequence = normalizedText(scalarValue(sequenceCell)); @@ -412,7 +439,7 @@ function unusedTemplateRow(row: ExcelJS.Row, headerRow: number, sourceColumns: S const cell = row.getCell(sourceColumns[field]); if (cell.type === ExcelJS.ValueType.Formula) { const template = BROKEN_TEMPLATE_FORMULAS[field]; - if (!formulaAllowed(cell, headerRow, sourceColumns) + if (!formulaAllowed(cell, headerRow, sourceColumns, identityColumn) && (!template || normalizedFormula(cell.formula) !== normalizedFormula(template))) return false; } else if (normalizedText(scalarValue(cell)) !== '') { return false; @@ -435,12 +462,12 @@ function mergedTemplateNote(row: ExcelJS.Row, headerRow: number, sourceColumns: return onlyNote && /^(?:备注|说明|注意事项|温馨提示)[::]/.test(headerText(master)); } -function lastRosterRow(sheet: ExcelJS.Worksheet, headerRow: number, sourceColumns: SourceColumnMap): number { +function lastRosterRow(sheet: ExcelJS.Worksheet, headerRow: number, sourceColumns: SourceColumnMap, identityColumn?: number): number { let lastRow = sheet.rowCount; while (lastRow > headerRow) { const row = sheet.getRow(lastRow); if (!mergedTemplateNote(row, headerRow, sourceColumns) - && !unusedTemplateRow(row, headerRow, sourceColumns)) break; + && !unusedTemplateRow(row, headerRow, sourceColumns, identityColumn)) break; lastRow--; } return lastRow; @@ -450,7 +477,8 @@ function validateFormulasAndHiddenData( sheet: ExcelJS.Worksheet, headerRow: number, sourceColumns: SourceColumnMap, - lastDataRow: number + lastDataRow: number, + identityColumn?: number ): void { const selectedColumns = new Set(Object.values(sourceColumns)); sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { @@ -462,7 +490,7 @@ function validateFormulasAndHiddenData( if (containsExternalFormulaReference(formula)) { fail('roster_workbook_unsafe_content', { row: rowNumber, column: columnNumber }); } - if (rowNumber <= lastDataRow && selectedColumns.has(columnNumber) && !formulaAllowed(cell, headerRow, sourceColumns)) { + if (rowNumber <= lastDataRow && selectedColumns.has(columnNumber) && !formulaAllowed(cell, headerRow, sourceColumns, identityColumn)) { fail('roster_workbook_formula_not_allowed', { row: rowNumber, column: columnNumber }); } } @@ -521,6 +549,16 @@ function findHeaderLayout(sheet: ExcelJS.Worksheet): { row: number; columns: Sou return candidates[0]; } +function findIdentityColumn(sheet: ExcelJS.Worksheet, headerRow: number): number | undefined { + const candidates: number[] = []; + sheet.getRow(headerRow).eachCell(cell => { + if (cell.type === ExcelJS.ValueType.Merge && cell.master.address !== cell.address) return; + if (['身份证号码', '身份证'].includes(headerText(cell))) candidates.push(cellColumn(cell)); + }); + // An absent or ambiguous dependency permits only static gender/birth cells. + return candidates.length === 1 ? candidates[0] : undefined; +} + function excelSerialToDate(value: number, date1904: boolean): Date | undefined { if (!Number.isFinite(value) || value < 0) return undefined; const millisecondsPerDay = 86_400_000; @@ -577,6 +615,29 @@ function canonicalIdentifier(cell: ExcelJS.Cell): string { return normalizedText(value); } +function identityDerivedValues(cell: ExcelJS.Cell): { gender: string; birthDate: string } { + const value = scalarValue(cell); + // LEN/MID operate on the original characters. Trimming or removing control + // characters here could incorrectly approve a stale formula cache. + const identity = typeof value === 'string' ? value + : typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : ''; + const location = { row: cellRow(cell), column: cellColumn(cell) }; + if (![15, 18].includes(identity.length) || !/^(?:\d{15}|\d{17}[\dXx])$/.test(identity)) { + fail('roster_workbook_invalid_identifier', location); + } + const digits = identity.length === 18 ? identity.slice(6, 14) : `19${identity.slice(6, 12)}`; + const year = Number(digits.slice(0, 4)); + const month = Number(digits.slice(4, 6)); + const day = Number(digits.slice(6, 8)); + const date = new Date(Date.UTC(year, month - 1, day)); + if (year < 1900 || year > 2200 || date.getUTCFullYear() !== year + || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) fail('roster_workbook_invalid_date', location); + return { + gender: Number(identity[identity.length === 18 ? 16 : 14]) % 2 === 1 ? '男' : '女', + birthDate: `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}` + }; +} + function canonicalSequence(cell: ExcelJS.Cell): number { const value = scalarValue(cell); let sequence: number; @@ -605,7 +666,8 @@ function dataRows( headerRow: number, sourceColumns: SourceColumnMap, maxDataRows: number, - lastDataRow: number + lastDataRow: number, + identityColumn?: number ): ExcelJS.Row[] { const rows: ExcelJS.Row[] = []; let gapAfterData = false; @@ -618,7 +680,7 @@ function dataRows( continue; } if (gapAfterData) fail('roster_workbook_non_contiguous_rows', { row: rowNumber }); - if (unusedTemplateRow(row, headerRow, sourceColumns)) { + if (unusedTemplateRow(row, headerRow, sourceColumns, identityColumn)) { fail('roster_workbook_incomplete_row', { row: rowNumber, column: sourceColumns['姓名'] }); } rows.push(row); @@ -632,7 +694,8 @@ function canonicalRow( row: ExcelJS.Row, date1904: boolean, seenSequences: Set, - sourceColumns: SourceColumnMap + sourceColumns: SourceColumnMap, + identityColumn?: number ): string[] { const cell = (field: RequiredSourceField): ExcelJS.Cell => row.getCell(sourceColumns[field]); const sequence = canonicalSequence(cell('序号')); @@ -647,12 +710,29 @@ function canonicalRow( const issueDateCell = cell('签发日期'); const expiryCell = cell('有效期'); const englishName = canonicalTextCell(cell('英文姓名')).replace(/,/g, ','); + const genderCell = cell('性别'); + const birthCell = cell('出生日期'); + const genderFormula = identityFormulaField(genderCell, sourceColumns, identityColumn); + const birthFormula = identityFormulaField(birthCell, sourceColumns, identityColumn); + const gender = normalizedText(scalarValue(genderCell, Boolean(genderFormula))); + const birthDate = canonicalDate(birthCell, date1904, Boolean(birthFormula)); + if (genderFormula || birthFormula) { + const derived = identityDerivedValues(row.getCell(identityColumn!)); + for (const [formula, actual, expected, target] of [ + [genderFormula, gender, derived.gender, genderCell], + [birthFormula, birthDate, derived.birthDate, birthCell] + ] as const) { + if (formula && actual !== expected) { + fail('roster_workbook_formula_result_mismatch', { row: row.number, column: cellColumn(target) }); + } + } + } return [ String(sequence), canonicalTextCell(cell('姓名')), englishName, - canonicalTextCell(cell('性别')), - canonicalDate(cell('出生日期'), date1904), + gender, + birthDate, canonicalTextCell(cell('出生地')), '护照', canonicalIdentifier(cell('护照号码')), @@ -715,14 +795,15 @@ export async function normalizePassengerRosterWorkbook( if (sheet.state !== 'visible') fail('roster_workbook_hidden_data'); const header = findHeaderLayout(sheet); const headerRow = header.row; - const lastDataRow = lastRosterRow(sheet, headerRow, header.columns); + const identityColumn = findIdentityColumn(sheet, headerRow); + const lastDataRow = lastRosterRow(sheet, headerRow, header.columns, identityColumn); // Content safety and hidden-data checks still cover the entire workbook, // including discarded placeholders and footnotes. - validateFormulasAndHiddenData(sheet, headerRow, header.columns, lastDataRow); - const rows = dataRows(sheet, headerRow, header.columns, maxDataRows, lastDataRow); + validateFormulasAndHiddenData(sheet, headerRow, header.columns, lastDataRow, identityColumn); + const rows = dataRows(sheet, headerRow, header.columns, maxDataRows, lastDataRow, identityColumn); const date1904 = workbook.properties.date1904 === true; const seenSequences = new Set(); - const canonicalRows = rows.map((row) => canonicalRow(row, date1904, seenSequences, header.columns)); + const canonicalRows = rows.map((row) => canonicalRow(row, date1904, seenSequences, header.columns, identityColumn)); return { canonicalTsv: [CANONICAL_PASSENGER_HEADERS, ...canonicalRows] .map((row) => row.join('\t')) diff --git a/control-plane/test/passenger-roster-workbook.test.ts b/control-plane/test/passenger-roster-workbook.test.ts index e6551ff..df81e94 100644 --- a/control-plane/test/passenger-roster-workbook.test.ts +++ b/control-plane/test/passenger-roster-workbook.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import ExcelJS from 'exceljs'; import JSZip from 'jszip'; +import { parseProgramInput } from '../src/program-parser.js'; import { PassengerRosterWorkbookError, normalizePassengerRosterWorkbook @@ -23,6 +24,19 @@ const ATTACHMENT_VARIANT_INDEXES = [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 4 type WorkbookMutation = (workbook: ExcelJS.Workbook) => void | Promise; +const genderTemplate = (ref: string): string => `IF(OR(LEN(${ref})=15,LEN(${ref})=18),IF(MOD(MID(${ref},15,3)*1,2),"男","女"),NA())`; +const birthTemplate = (ref: string): string => `DATE(MID(${ref},7,VLOOKUP(LEN(${ref}),{15,2;18,4},2,0)),MID(${ref},VLOOKUP(LEN(${ref}),{15,9;18,11},2,0),2),MID(${ref},VLOOKUP(LEN(${ref}),{15,11;18,13},2,0),2))`; +const SYNTHETIC_ID = '11111119900102001X'; +const normalizeSynthetic = (content: Buffer) => normalizePassengerRosterWorkbook({ + content, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' +}); +const identityFormulas: WorkbookMutation = workbook => { + const sheet = workbook.getWorksheet('Sheet1')!; + sheet.getCell('E3').value = SYNTHETIC_ID; + sheet.getCell('D3').value = { formula: genderTemplate('E3'), result: '男' }; + sheet.getCell('F3').value = { formula: birthTemplate('E3'), result: new Date(Date.UTC(1990, 0, 2)) }; +}; + async function syntheticWorkbook(...mutations: WorkbookMutation[]): Promise { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('Sheet1'); @@ -122,6 +136,158 @@ test('XLSX is deterministically normalized to the canonical 13-column TSV', asyn ].join('\n')); }); +test('a merged title error is skipped, while header and passenger errors stay blocked', async () => { + const baseline = await normalizeSynthetic(await syntheticWorkbook()); + const titleError = await normalizeSynthetic(await syntheticWorkbook(workbook => { + const sheet = workbook.getWorksheet('Sheet1')!; + sheet.mergeCells('F1:N1'); + sheet.getCell('F1').value = { error: '#VALUE!' }; + })); + assert.equal(titleError.canonicalTsv, baseline.canonicalTsv); + for (const [address, code, row, column] of [ + ['F2', 'roster_workbook_header_not_found', undefined, undefined], + ['F3', 'roster_workbook_invalid_cell', 3, 6], + ['N4', 'roster_workbook_invalid_cell', 4, 14] + ] as const) { + const content = await syntheticWorkbook(workbook => { + workbook.getWorksheet('Sheet1')!.getCell(address).value = { error: '#VALUE!' }; + }); + await expectRosterError(() => normalizeSynthetic(content), code, { row, column }); + } +}); + +test('same-row identity templates support 18 and 15 digit sources without exporting them', async () => { + for (const legacy of [false, true]) { + const normalized = await normalizeSynthetic(await syntheticWorkbook(identityFormulas, workbook => { + const sheet = workbook.getWorksheet('Sheet1')!; + sheet.getCell('E2').value = '身份证'; + if (legacy) { + sheet.getCell('E3').value = 111111900102002; + sheet.getCell('D3').value = { formula: genderTemplate('E3'), result: '女' }; + } + })); + const row = normalized.canonicalTsv.split('\n')[1]!.split('\t'); + assert.deepEqual(row.slice(3, 5), [legacy ? '女' : '男', '1990-01-02']); + assert.equal(row[6], '护照'); + assert.equal(row[7], '123456789'); + assert.doesNotMatch(normalized.canonicalTsv, /111111|身份证/); + } +}); + +test('identity references follow reordered columns, shared formulas and the workbook date system', async () => { + const content = await syntheticWorkbook(identityFormulas, workbook => { + const sheet = workbook.getWorksheet('Sheet1')!; + for (const rowNumber of [2, 3, 4]) { + const row = sheet.getRow(rowNumber); + const values = REORDERED_SOURCE_INDEXES.map(index => row.getCell(index + 1).value); + row.values = values; + } + workbook.properties.date1904 = true; + const birthSerial = (Date.UTC(1990, 0, 2) - Date.UTC(1904, 0, 1)) / 86_400_000; + sheet.getCell('I3').value = { formula: genderTemplate('$F3').toLowerCase(), result: '男' }; + sheet.getCell('K3').value = { formula: birthTemplate('F3'), result: birthSerial }; + sheet.getCell('J3').value = new Date(Date.UTC(2035, 0, 1)); + sheet.getCell('F4').value = SYNTHETIC_ID; + sheet.getCell('I4').value = { sharedFormula: 'I3', result: '男' }; + sheet.getCell('K4').value = { sharedFormula: 'K3', result: birthSerial }; + }); + const result = await normalizeSynthetic(content); + for (const row of result.canonicalTsv.split('\n').slice(1)) { + assert.deepEqual(row.split('\t').slice(3, 5), ['男', '1990-01-02']); + } +}); + +test('identity formulas reject missing, stale or error results at the formula cell', async () => { + for (const [address, value, code, column] of [ + ['D3', { formula: genderTemplate('E3') }, 'roster_workbook_formula_result_missing', 4], + ['F3', { formula: birthTemplate('E3') }, 'roster_workbook_formula_result_missing', 6], + ['D3', { formula: genderTemplate('E3'), result: '女' }, 'roster_workbook_formula_result_mismatch', 4], + ['F3', { formula: birthTemplate('E3'), result: '1990-01-03' }, 'roster_workbook_formula_result_mismatch', 6], + ['F3', { formula: birthTemplate('E3'), result: { error: '#VALUE!' } }, 'roster_workbook_invalid_cell', 6] + ] as const) { + const content = await syntheticWorkbook(identityFormulas, workbook => { + workbook.getWorksheet('Sheet1')!.getCell(address).value = value; + }); + await expectRosterError(() => normalizeSynthetic(content), code, { row: 3, column }); + } +}); + +test('identity dependencies must be unambiguous same-row template references', async () => { + const variants: Array<[WorkbookMutation, string, number]> = [ + [workbook => { workbook.getWorksheet('Sheet1')!.getCell('E2').value = '普通额外列'; }, 'roster_workbook_formula_not_allowed', 4], + [workbook => { workbook.getWorksheet('Sheet1')!.getCell('O2').value = '身份证'; }, 'roster_workbook_formula_not_allowed', 4], + [workbook => { workbook.getWorksheet('Sheet1')!.getCell('D3').value = { formula: genderTemplate('E4'), result: '男' }; }, 'roster_workbook_formula_not_allowed', 4], + [workbook => { workbook.getWorksheet('Sheet1')!.getCell('D3').value = { formula: '"男"', result: '男' }; }, 'roster_workbook_formula_not_allowed', 4], + [workbook => { workbook.getWorksheet('Sheet1')!.getCell('F3').value = { formula: birthTemplate('E3').replaceAll(';', ','), result: '1990-01-02' }; }, 'roster_workbook_formula_not_allowed', 6], + [workbook => { workbook.getWorksheet('Sheet1')!.getCell('D3').value = { formula: genderTemplate("'[external.xlsx]Sheet1'!E3"), result: '男' }; }, 'roster_workbook_unsafe_content', 4] + ]; + for (const [mutation, code, column] of variants) { + const content = await syntheticWorkbook(identityFormulas, mutation); + await expectRosterError(() => normalizeSynthetic(content), code, { row: 3, column }); + } +}); + +test('invalid identity sources cannot validate a cached gender or birth date', async () => { + for (const [value, code] of [ + ['', 'roster_workbook_invalid_identifier'], + ['invalid source', 'roster_workbook_invalid_identifier'], + [` ${SYNTHETIC_ID}`, 'roster_workbook_invalid_identifier'], + [`${SYNTHETIC_ID}\n`, 'roster_workbook_invalid_identifier'], + [`111111\t${SYNTHETIC_ID.slice(6)}`, 'roster_workbook_invalid_identifier'], + [111111199001020010, 'roster_workbook_invalid_identifier'], + ['11111119900230001X', 'roster_workbook_invalid_date'], + [{ formula: '"11111119900102001X"', result: SYNTHETIC_ID }, 'roster_workbook_formula_not_allowed'] + ] as const) { + const content = await syntheticWorkbook(identityFormulas, workbook => { + workbook.getWorksheet('Sheet1')!.getCell('E3').value = value; + }); + const error = await expectRosterError(() => normalizeSynthetic(content), code, { row: 3, column: 5 }); + assert.doesNotMatch(JSON.stringify(error), /111111|invalid source/); + } +}); + +test('populated identity formula rows are not discarded as empty trailing templates', async () => { + const content = await syntheticWorkbook(identityFormulas, workbook => { + const sheet = workbook.getWorksheet('Sheet1')!; + sheet.getRow(5).values = [3, '', '', { formula: genderTemplate('E5'), result: '男' }, SYNTHETIC_ID, + { formula: birthTemplate('E5'), result: '1990-01-02' }]; + }); + const result = await normalizeSynthetic(content); + assert.equal(result.rowCount, 3); + // Retain the partial row for downstream import/supplement validation. + assert.deepEqual(result.canonicalTsv.split('\n')[3]!.split('\t').slice(0, 5), ['3', '', '', '男', '1990-01-02']); +}); + +test('a 22-person template passes normalization and program parsing with the last row as leader', async () => { + const content = await syntheticWorkbook(identityFormulas, workbook => { + const sheet = workbook.getWorksheet('Sheet1')!; + sheet.mergeCells('F1:N1'); + sheet.getCell('F1').value = { error: '#VALUE!' }; + const values = sheet.getRow(3).values; + for (let index = 1; index <= 22; index++) { + const rowNumber = index + 2; + const row = sheet.getRow(rowNumber); + row.values = values; + row.getCell('A').value = index; + row.getCell('I').value = `P${String(index).padStart(8, '0')}`; + row.getCell('D').value = { formula: genderTemplate(`E${rowNumber}`), result: '男' }; + row.getCell('F').value = { formula: birthTemplate(`E${rowNumber}`), result: '1990-01-02' }; + row.getCell('K').value = { formula: `EDATE(L${rowNumber},-10*12)+1`, result: '2025-01-02' }; + row.getCell('L').value = '2035-01-01'; + row.getCell('M').value = index === 22 ? '13800000000' : ''; + row.getCell('N').value = index === 22 ? '领队' : ''; + } + }); + const result = await normalizeSynthetic(content); + assert.equal(result.rowCount, 22); + assert.doesNotMatch(result.canonicalTsv, /111111|身份证|#VALUE!/); + const parsed = await parseProgramInput({ rawText: `导入散拼子单名单\n预订客户:测试客户\n出发日期:2026-09-21\n名单内容:\n${result.canonicalTsv}`, + receivedAt: new Date('2026-09-15T00:00:00Z') }); + assert.equal(parsed.status, 'agent_parse_passed'); + const data = parsed.operation?.data as { passenger_list?: { leader_contact?: { sequence?: number } } }; + assert.equal(data?.passenger_list?.leader_contact?.sequence, 22); +}); + test('the ERP field row is auto-detected and source columns may be reordered', async () => { const valid = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('N1').value = '任意团信息'; diff --git a/dist/lwlt-arrangement-0.5.127.skill b/dist/lwlt-arrangement-0.5.127.skill new file mode 100644 index 0000000..dea23f8 Binary files /dev/null and b/dist/lwlt-arrangement-0.5.127.skill differ diff --git a/dist/lwlt-confirmation-0.5.127.skill b/dist/lwlt-confirmation-0.5.127.skill new file mode 100644 index 0000000..63898bf Binary files /dev/null and b/dist/lwlt-confirmation-0.5.127.skill differ diff --git a/dist/lwlt-lifecycle-0.5.127.skill b/dist/lwlt-lifecycle-0.5.127.skill new file mode 100644 index 0000000..c5084c8 Binary files /dev/null and b/dist/lwlt-lifecycle-0.5.127.skill differ diff --git a/dist/lwlt-newbooking-0.5.127.skill b/dist/lwlt-newbooking-0.5.127.skill new file mode 100644 index 0000000..3b9aa9e Binary files /dev/null and b/dist/lwlt-newbooking-0.5.127.skill differ diff --git a/dist/lwlt-updating-0.5.127.skill b/dist/lwlt-updating-0.5.127.skill new file mode 100644 index 0000000..812cd45 Binary files /dev/null and b/dist/lwlt-updating-0.5.127.skill differ diff --git a/dist/release-manifest.json b/dist/release-manifest.json index 9a7ae86..d9d6a6d 100644 --- a/dist/release-manifest.json +++ b/dist/release-manifest.json @@ -3,8 +3,8 @@ "generated_on": "2026-09-15", "baselines": { "chrome_extension": "0.5.185", - "skills": "0.5.126", - "business_instruction_docx": "0.5.133", + "skills": "0.5.127", + "business_instruction_docx": "0.5.134", "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" }, "artifacts": [ @@ -18,51 +18,51 @@ { "kind": "skill", "name": "lwlt-arrangement", - "version": "0.5.126", - "path": "dist/lwlt-arrangement-0.5.126.skill", + "version": "0.5.127", + "path": "dist/lwlt-arrangement-0.5.127.skill", "source": "agent设计规范/skills/lwlt-arrangement", - "sha256": "83770befe3a8e4048eacaeacb567c5139bf8a0b3a49f2b1513c26383422f2a9c" + "sha256": "ae3fc66965d2a7968aeaa2ecf371d75e5365ef4cc4dfd8ef080f9343e0d0f09c" }, { "kind": "skill", "name": "lwlt-confirmation", - "version": "0.5.126", - "path": "dist/lwlt-confirmation-0.5.126.skill", + "version": "0.5.127", + "path": "dist/lwlt-confirmation-0.5.127.skill", "source": "agent设计规范/skills/lwlt-confirmation", - "sha256": "543507ed8b68c183d2df6722061b0e927ebb728de4bf254d15f83311c9264018" + "sha256": "c2f3689d8a0b4d6b250bb2cfce572fe3a9fca3f5519ea5fae5ef0d382b44dae4" }, { "kind": "skill", "name": "lwlt-lifecycle", - "version": "0.5.126", - "path": "dist/lwlt-lifecycle-0.5.126.skill", + "version": "0.5.127", + "path": "dist/lwlt-lifecycle-0.5.127.skill", "source": "agent设计规范/skills/lwlt-lifecycle", - "sha256": "fff3879d9a4143c34ae51e091b91411e55382ad3f5d77c99d6202a005b1ddbd9" + "sha256": "f0b5cdea11e65657fb9b10e4f7d020769d5477cc8ceddfcfd6a284513345f6bc" }, { "kind": "skill", "name": "lwlt-newbooking", - "version": "0.5.126", - "path": "dist/lwlt-newbooking-0.5.126.skill", + "version": "0.5.127", + "path": "dist/lwlt-newbooking-0.5.127.skill", "source": "agent设计规范/skills/lwlt-newbooking", - "sha256": "5eb6a7bf5649a0fcee3fbee488a124e1b1d018a642a3a49ebb5439ccf20ccc2f" + "sha256": "b64593d88145966cd4e702d2532ffe3ea85052e8d56fbe75d503a19ababa1715" }, { "kind": "skill", "name": "lwlt-updating", - "version": "0.5.126", - "path": "dist/lwlt-updating-0.5.126.skill", + "version": "0.5.127", + "path": "dist/lwlt-updating-0.5.127.skill", "source": "agent设计规范/skills/lwlt-updating", - "sha256": "3bd665207649cbb8e0e5ebb012c916720201fe160eea62bccdf1c3a1f0275d1d" + "sha256": "54f10dd6bd6d2ddf47c700f1bca4b1fcd3346dc6143bf27118a23587b1b11736" }, { "kind": "business_instruction_docx", - "version": "0.5.133", - "path": "dist/老挝联泰AI指令表-0.5.133.docx", + "version": "0.5.134", + "path": "dist/老挝联泰AI指令表-0.5.134.docx", "source": "agent设计规范/templates/business-input-templates.md", "builder": "tools/build_business_instruction_docx.py", - "sha256": "879f1acc3a6a60842e59ae8396f72d39504ea1f3a4110a9f305d94553b38848c", - "source_sha256": "37b6b689c5c6a80683dd1deeff52a90ac8b4bd3ac0380e6b771dcb557c0918f7", + "sha256": "b6c1f677b93cb4f7356cfb26c6af01860587be1288ffe9e8aff8295929074490", + "source_sha256": "7446e4560e2eaaf6f490c8ed7523d6aadcee6f94f58cb620f7e71db674404471", "builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1" } ] diff --git a/dist/老挝联泰AI指令表-0.5.134.docx b/dist/老挝联泰AI指令表-0.5.134.docx new file mode 100644 index 0000000..f47c41e Binary files /dev/null and b/dist/老挝联泰AI指令表-0.5.134.docx differ diff --git a/tools/repository-hygiene.test.mjs b/tools/repository-hygiene.test.mjs index 5e91722..b33b236 100644 --- a/tools/repository-hygiene.test.mjs +++ b/tools/repository-hygiene.test.mjs @@ -304,7 +304,7 @@ test('single-source and generated-output boundaries remain explicit', async () = const diagnoseServer = await readFile(path.join(ROOT, 'infra/diagnose-server.sh'), 'utf8'); const gitignore = await readFile(path.join(ROOT, '.gitignore'), 'utf8'); - assert.match(template, /^交付版本:0\.5\.133\s*$/mu); + assert.match(template, /^交付版本:0\.5\.134\s*$/mu); assert.match(template, /^适用范围:当前 23 条业务路由,按 19 项运营场景分组;执行能力以生命周期发布门槛为准。\s*$/mu); assert.match(template, /^### 5\. 散拼团多个新增子单\s*$/mu); assert.doesNotMatch(template, /^事实基线:/mu);