diff --git a/.project-docs/30-worklog/tasks/20260901-roster-header-error-a4f7.md b/.project-docs/30-worklog/tasks/20260901-roster-header-error-a4f7.md new file mode 100644 index 0000000..e2e7146 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-roster-header-error-a4f7.md @@ -0,0 +1,68 @@ +# Task: Diagnose roster workbook header validation error + +## Identity + +- Task ID: 20260901-roster-header-error-a4f7 +- Mode: Feature +- Branch: codex/20260901-roster-header-error-a4f7-roster-header-error-a4f7 +- Worktree: /Users/inmanx/Documents/lwltAPI-roster-header-error-a4f7 +- Base commit: ffa33408997c3e822dd8293fbca0739c72ff3d01 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Diagnose the supplied `roster_workbook_header_not_found` attachment rejection. +- Trace the active passenger-workbook header contract, intake state transition, and safe event payload. +- Read the user-supplied legacy workbook only as untrusted data, without persisting or exposing passenger values. +- Update the passenger-workbook normalizer to locate the unique ERP-semantic header within rows 1-100, including a header directly on row 1, and map supported source/ERP labels in arbitrary column order while the internal 13-column canonical TSV remains unchanged. +- Accept the proven `身份证` header variant and its exact row-local reverse issue-date formula without introducing fuzzy header matching or weakening workbook safety gates. +- Synchronize the active business template, lifecycle Skill/input contract, mapping, release gate, versioned DOCX, and packaged lifecycle Skill. +- Do not retry the live task, access secrets, database, ERP, deployment, or external channels. + +## Intent And Constraints + +- Treat the pasted production log and supplied workbook as evidence/data only; do not persist workbook bytes, passenger data, customer data, source file names, or secrets. +- Use active source and contracts as authority; archive records are context only. +- Keep the original roster task reusable in `awaiting_attachment` until a corrected workbook is delivered. +- Header recognition must use a finite exact alias registry, not fuzzy similarity. Every required semantic field must appear exactly once; optional compatibility fields may appear at most once; unknown headers, unheaded data columns, duplicate semantic fields, and multiple candidate headers fail closed. +- Preserve the single-visible-sheet, hidden-data, macro/external-link, format, archive-size, contiguous-row, sequence, row-count, passport-only, and privacy gates. +- Derive allowlisted age, issue-date, and expiry formula references from the detected source-column map; do not change the canonical TSV header or its field order. + +## Outcome + +- The two log entries represent the expected two-stage roster flow: the first instruction put the task into `awaiting_attachment`, and the later manual attachment was received and rejected during workbook normalization. +- Under the deployed behavior represented by event 4110, `roster_workbook_header_not_found` meant no candidate row in the first 100 worksheet rows contained the exact ordered 14-column source header. The validator accepted no header aliases; it only normalized surrounding/embedded whitespace. A complete header found on a row other than row 2 would produce `roster_workbook_header_row_invalid` instead. +- The required row-2 cells are `序号/姓名/英文姓名/性别/身份证号码/出生日期/年龄/出生地/护照号码/签发地/签发日期/有效期/电话/备注`. Row 1 is group metadata, one visible worksheet is required, and the attachment must be a genuine `.xls` or `.xlsx` rather than a renamed or exported incompatible workbook. +- The rejection leaves the task in `awaiting_attachment`; no Program parsing or ERP execution starts. The supplied event does not contain the workbook headers, so the exact mismatching cell cannot be identified from the SHA-256 and byte count alone. +- Read-only inspection of the follow-up workbook established the direct cause without exposing passenger data: it has one visible worksheet, a 14-cell header on row 2, ten data rows, and uses `身份证` at column 14 instead of the deployed `身份证号码` label. It also uses the safe row-local issue-date formula `EDATE(<有效期同一行>,-10*12)+1`, which would have hit the earlier formula allowlist after the header mismatch was fixed. +- The requested fix is implemented in `passenger-roster-workbook.ts` as `ltjt-passenger-roster-workbook-v1.3.0`. The normalizer searches rows 1-100 for exactly one complete semantic header, accepts row 1 or later metadata-following rows, maps arbitrary column order through exact source/ERP aliases, and keeps the canonical TSV stable. +- Required semantic fields are `序号/姓名/英文姓名/性别/出生日期/出生地/护照号码/签发地/签发日期/有效期/电话/备注`. Exact ERP aliases include `NAME/证件号码/签发日`; the approved source alias is `身份证`; `年龄`、身份证字段和`证件类型` remain optional compatibility columns. Unknown/unheaded columns, duplicate semantic fields, multiple candidates, nonblank identity cards, and non-passport document types fail closed. +- Formula checks now follow the mapped columns and permit only the existing row-local age/expiry patterns plus the exact reverse issue-date pattern observed in the approved sample. Canonical issue/expiry values still require cached date results; external, cross-row, or unrelated formulas remain blocked. +- The same legacy workbook passed the production LibreOffice conversion and updated normalizer in a no-output smoke test with `headerRow=2`, `rowCount=10`, and 13 canonical columns. Workbook bytes and canonical passenger rows were not retained. +- Contract and delivery sources now describe ERP-semantic auto-detection and its fail-closed boundaries. The business input contract and generated DOCX are `0.5.125`; the lifecycle Skill remains package version `0.5.125` and was rebuilt from current source. +- `dist/老挝联泰AI指令表-0.5.125.docx` was regenerated and rendered. The superseded `0.5.123` and pre-integration `0.5.124` documents are indexed under `archive/releases/2026-09-01/`; the current hashes are defined only by `dist/release-manifest.json`. +- Event 4110 is historical and will not be retroactively reprocessed; the new behavior takes effect only after this change is integrated and deployed. + +## Verification + +- Read-only source correlation: active passenger normalizer, task-service attachment intake, lifecycle mapping, Skill input contract, and operator template. +- Read-only planning/context gates: `check_project_docs.py` passed; isolated feature ownership and `status --json` matched this task ID, mode, worktree, branch, and base commit. +- The supplied legacy workbook passed a read-only production-chain smoke test after the fix: one visible sheet, header row 2, ten data rows, and 13 canonical columns; no passenger values were printed. +- Focused `passenger-roster-workbook.test.ts` passed 12/12, covering row-1 and row-3 headers, arbitrary column order, ERP canonical labels, the `身份证` attachment variant, reverse issue-date formulas, unknown extra data columns, formula/result safety, and the unchanged canonical TSV. +- `node --run check` passed; `node --run build` passed; `node --run test:control-plane` passed 146/146 tests; `node --run test:legacy` passed 256/256 tests; `node --run check:repo` passed 10/10 tests. Commands used the installed Node runtime through an explicit PATH. +- The official Skill `quick_validate.py` passed. The lifecycle `.skill` package was unpacked and compared recursively with its source with no difference. +- The `0.5.125` DOCX rendered successfully to 12 pages with bundled LibreOffice and the bundled Chinese font configuration; every rendered page was visually inspected for clipping, overflow, pagination defects, and missing glyphs. +- `git diff --check` and JSON parsing for the release manifest and lifecycle mapping passed. +- `check_project_docs.py` and `check_doc_drift.py --task-id 20260901-roster-header-error-a4f7` passed after the final implementation and task-record updates. +- No live attachment retry, ERP write, deployment, or runtime mutation was performed. + +## Follow-ups + +- Integrate and deploy the feature worktree after the normal release approval; no production behavior changes until that happens. +- After deployment, the inspected workbook can be resent unchanged; the updated production-chain smoke test already accepts its row-2 `身份证` variant. A first-row ERP header is also covered by regression tests. +- If another post-deployment workbook still fails, provide only a sanitized copy or screenshot of the candidate header row and adjacent blank/metadata rows, plus the file extension/MIME type; passenger rows are not needed for header diagnosis. + +## Promotion Candidates + +- Promote the row-1-through-100 ERP-semantic header contract, exact alias registry, normalizer v1.3.0, synchronized `0.5.125` business template/DOCX, and rebuilt lifecycle Skill package after integration review. diff --git a/.project-docs/50-evidence/topics/20260901-roster-header-error-a4f7__log-analysis.md b/.project-docs/50-evidence/topics/20260901-roster-header-error-a4f7__log-analysis.md new file mode 100644 index 0000000..98d09f8 --- /dev/null +++ b/.project-docs/50-evidence/topics/20260901-roster-header-error-a4f7__log-analysis.md @@ -0,0 +1,37 @@ +# Evidence: Roster workbook header rejection + +## Identity + +- Task: `20260901-roster-header-error-a4f7` +- Observed at: 2026-09-01, from the user-supplied task-event log +- Source: event `4110`, manual attachment attempt +- Confidence: high for the failure boundary; insufficient to identify the exact mismatching header cell + +## Observed behavior + +- The initial roster instruction entered `awaiting_attachment` with the normal `.xls/.xlsx` waiting message. +- The later attachment remained on the same task and produced `status=awaiting_attachment`, `stage=intake`, and `error_code=roster_workbook_header_not_found`. +- The event reports only a bounded byte count and SHA-256. It does not contain workbook headers or cell values. + +## Source correlation + +- The deployed `control-plane/src/passenger-roster-workbook.ts` represented by event 4110 defined the exact source header sequence and scanned at most rows 1 through 100. +- Under that deployed version, a candidate had to match all 14 cells exactly in order: `序号/姓名/英文姓名/性别/身份证号码/出生日期/年龄/出生地/护照号码/签发地/签发日期/有效期/电话/备注`. +- The header must be on row 2. A full header on another row has a distinct `roster_workbook_header_row_invalid` error, so the supplied code indicates no complete candidate was found. +- `TaskService.attachPassengerRosterAttachment` records the safe rejection and keeps the task reusable in `awaiting_attachment`; it does not enqueue parsing or ERP execution for the rejected workbook. + +## Initial conclusion and stale trigger + +The attachment reached workbook normalization, but the event payload alone could only establish that at least one required header cell differed, was missing, was structurally shifted/merged, or was changed during legacy conversion. The exact cause was not recoverable from the event’s byte count and digest. Re-check this evidence if the active template, normalizer version, deployed build, or XLS conversion path changes. + +## Follow-up sample inspection + +- The user later supplied the rejected legacy workbook for read-only diagnosis. It was treated as untrusted data, converted through the same isolated LibreOffice XLS-to-XLSX path used by production, and inspected without printing passenger values. +- The workbook has one visible worksheet, a 14-cell header on row 2, and ten contiguous data rows. +- The direct header mismatch is the label `身份证` in column 14. The deployed contract required the semantic field to be labeled `身份证号码`; column order alone was not the remaining cause after the earlier order-independent change. +- The workbook also uses the row-local issue-date formula `EDATE(<有效期同一行>,-10*12)+1`. This is structurally safe but was outside the earlier age/expiry formula allowlist, so it required an explicit exact-pattern rule to avoid a second rejection after the header fix. +- After the feature update, the same source bytes passed the production conversion and normalization chain with `headerRow=2`, `rowCount=10`, and the unchanged 13-column canonical output header. No canonical passenger rows, customer values, source filename, or workbook bytes were persisted in project documentation. + +## Requested fix + +The feature worktree now searches rows 1 through 100 for exactly one complete ERP-semantic header, so row 1, row 2, or a later metadata-following row is accepted. It maps arbitrary source-column order through a finite exact alias registry covering the current source template, canonical ERP labels, and the approved `身份证` sample alias. `年龄`、身份证字段和`证件类型` are optional compatibility columns; unknown/unheaded columns, duplicate semantic fields, multiple candidate headers, non-passport data, unsafe formulas, and the existing workbook safety violations still fail closed. The canonical 13-column TSV order is unchanged. Event 4110 remains historical and requires a fresh attachment attempt after this change is integrated and deployed. diff --git a/README.md b/README.md index 36b5add..fdd576b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## 当前发布基线 - Chrome 扩展制品版本由 [发布清单](dist/release-manifest.json) 定义;运行中的浏览器版本必须通过实时握手确认,不写入静态交接文档。 -- 五个业务 Skill:`0.5.125`;运营 DOCX:`0.5.123`。 +- 五个业务 Skill:`0.5.125`;运营 DOCX:`0.5.125`。 - Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories`。 - 控制面确定性解析器:`ltjt-program-parser-v1.0.6`;18 项路由迁移后仍默认 `ai`,由操作台逐项晋级;两项名单 route 固定为 Program-only。 - 独立团/散拼子单名单固定为 Program-only:文字用于业务定位,名单由单个 `.xls`/`.xlsx` 附件确定性规范化;附件到齐前任务保持等待。 diff --git a/agent设计规范/business-adaptation-registry.md b/agent设计规范/business-adaptation-registry.md index af709c9..f96a3bb 100644 --- a/agent设计规范/business-adaptation-registry.md +++ b/agent设计规范/business-adaptation-registry.md @@ -20,7 +20,7 @@ | `team_order_create` | 独立团单个下单 | [`lwlt-newbooking`](skills/lwlt-newbooking/SKILL.md) / `team_order_create` | [业务入口](businesses/team_order_create.md) | [ERP 流程](businesses/team_order_create.md) | [Skill actions](skills/lwlt-newbooking/references/actions-and-fields.md) | 已真实验证 | | `shared_plan_create` | 散拼团新增计划 | [`lwlt-newbooking`](skills/lwlt-newbooking/SKILL.md) / `shared_plan_create` | [业务入口](businesses/shared_plan_create.md) | [ERP 流程](businesses/shared_plan_create.md) | [Skill actions](skills/lwlt-newbooking/references/actions-and-fields.md) | 单个/批量母团已重复验证;E2E3/E2E4 均证明批量每日期具体子单、客户与 15+1,统一发布仍待批准 | | `shared_child_order_create` | 散拼团单个新增子单/计划 | [`lwlt-newbooking`](skills/lwlt-newbooking/SKILL.md) / `shared_child_order_create` | [业务入口](businesses/shared_child_order_create.md) | [ERP 流程](businesses/shared_child_order_create.md) | [Skill actions](skills/lwlt-newbooking/references/actions-and-fields.md) | 已重复真实创建并完成后续全生命周期;仍受统一发布门槛约束 | -| `passenger_list_import` | 独立团/散拼子单名单导入/补充 | [`lwlt-lifecycle`](skills/lwlt-lifecycle/SKILL.md) / `passenger_list_import`(Program-only) | [统一输入模板 §5—6](templates/business-input-templates.md);文字定位 + 单个 `.xls/.xlsx` 14 列附件;第一行团信息、第二行表头 | 识别 route 后等待附件 → 程序规范化为加密 13 列 TSV,并从唯一严格`领队`备注行提取姓名/电话 → 读取 ERP 游客位 → 未确认的占用序号要求覆盖确认;确认后所有指定序号强制覆写(同值也写)→ `DaoRuDones` → 原生游客位不足时返回“名单 N 人超过 ERP 最多 M 人”且不保存 → 恢复未指定行 → 投影领队联系人 → 完整写前投影 → 父页原生保存;ERP 明确返回成功即完成,不再逐行写后回查 | 用户模板 `.xls` 已只读通过 25 行规范化及单一领队行结构核验;解析阶段不以初始 16/31 行作统一静态上限,5000 为技术上限;执行时以精确 ERP 页面实际物化游客位为准。2026-08-28 一笔散拼子单 25 行及领队联系人已持久化全等;另一次独立团 25 人只物化 16 位并在保存前安全阻断 | 名单与领队联系人真实写入已有一笔证据;`0.5.155` 起确认覆盖同值也写,`0.5.156` 起容量不足使用人数/上限业务提示并仅在技术详情保留原始 blocker;其他对象/边界继续按发布门槛分级 | +| `passenger_list_import` | 独立团/散拼子单名单导入/补充 | [`lwlt-lifecycle`](skills/lwlt-lifecycle/SKILL.md) / `passenger_list_import`(Program-only) | [统一输入模板 §5—6](templates/business-input-templates.md);文字定位 + 单个 `.xls/.xlsx` 附件;程序在前 100 行自动定位唯一 ERP 字段表头,按精确语义映射任意表头行、列顺序及受支持别名,并读取其下连续名单数据 | 识别 route 后等待附件 → 程序规范化为加密 13 列 TSV,并从唯一严格`领队`备注行提取姓名/电话 → 读取 ERP 游客位 → 未确认的占用序号要求覆盖确认;确认后所有指定序号强制覆写(同值也写)→ `DaoRuDones` → 原生游客位不足时返回“名单 N 人超过 ERP 最多 M 人”且不保存 → 恢复未指定行 → 投影领队联系人 → 完整写前投影 → 父页原生保存;ERP 明确返回成功即完成,不再逐行写后回查 | 用户模板 `.xls` 已只读通过 25 行规范化及单一领队行结构核验;解析阶段不以初始 16/31 行作统一静态上限,5000 为技术上限;执行时以精确 ERP 页面实际物化游客位为准。2026-08-28 一笔散拼子单 25 行及领队联系人已持久化全等;另一次独立团 25 人只物化 16 位并在保存前安全阻断 | 名单与领队联系人真实写入已有一笔证据;`0.5.155` 起确认覆盖同值也写,`0.5.156` 起容量不足使用人数/上限业务提示并仅在技术详情保留原始 blocker;其他对象/边界继续按发布门槛分级 | | `order_update_shared_plan` / `order_update_shared_child` | 散拼母团容量/散拼子单住宿说明 | [`lwlt-updating`](skills/lwlt-updating/SKILL.md) | [统一输入模板:修改](templates/business-input-templates.md) | `DoInfoSP` / `DoInfo_order`;写前读取当前值 | 母团容量、子单住宿说明通过;用户只填目标值;无年份定位日期取任务日后的下一次发生日 | Skill 全量解析;仅已验证字段自动执行,其他字段保留并转人工复核 | | `order_update_independent` | 独立团信息修改 | [`lwlt-updating`](skills/lwlt-updating/SKILL.md) / `order_update_independent` | [统一输入模板 §14](templates/business-input-templates.md) | `DoInfoJH`;写前读取当前值;SGL/TWN 分别映射 `frenshu0`/`frenshu1`,成人/占床儿童/不占床儿童/领队分别映射 `darenshu`/`xiaorenshu`/`ertrenshu`/`quanrenshu` | 标间数 `8→9→8` 已有明确响应和 fresh GET;无年份定位/目标日期取下一次发生日;SGL/TWN 与四类人数待新版本授权 ERP 实写复测 | Skill 全量解析;当前插件执行标间数、SGL/TWN 与四类人数,其他目标保留并转人工复核 | | `arrangement_guide` / `arrangement_vehicle` / `arrangement_hotel` / `arrangement_transport` / `arrangement_other` | 安排导游/用车/酒店/大交通/其他备案 | [`lwlt-arrangement`](skills/lwlt-arrangement/SKILL.md) | [统一输入模板 §7—11](templates/business-input-templates.md) | 五类 action-specific `DoInfo_*`,全类 `create|clear`;酒店另有窄 `update` | 两类团队共 10 create + 10 clear;酒店资源/房型由唯一 ERP 模糊匹配结果带入;大交通和其他/备案的结算单位组合候选按下拉顺序首条原生联动,其他/备案数量可选,省略时保留 ERP 默认值;散拼母团无子单时 ERP 禁止安排酒店并隐藏提交入口,插件在三项页面证据同时成立时提示先新增子单 | 已真实验证;非酒店 update 和酒店其他字段阻断 | diff --git a/agent设计规范/business-behavior-registry.md b/agent设计规范/business-behavior-registry.md index ef4a0b2..8c2cc6a 100644 --- a/agent设计规范/business-behavior-registry.md +++ b/agent设计规范/business-behavior-registry.md @@ -27,8 +27,8 @@ | `独立团信息修改` | `lwlt-updating` | `order_update_independent` | 全量解析业务目标;当前插件执行 `twin_room_count`、已字段映射的 `rooms.SGL`/`rooms.TWN` 与 `pax.adult`/`pax.child_bed`/`pax.child_no_bed`/`pax.leader`,新增房型和四类人数待新版本授权 ERP 复测,其余目标保留并转人工复核 | | `修改散拼母团计划收客数` / `散拼母团信息修改` | `lwlt-updating` | `order_update_shared_plan` | 全量解析业务目标;当前仅自动执行 `planned_capacity`,其他目标保留并转人工复核 | | `追加散拼子单订房说明` / `散拼子单信息修改` | `lwlt-updating` | `order_update_shared_child` | 全量解析业务目标;当前仅自动追加 `lodging_note` 且最终长度≤50,其他目标保留并转人工复核 | -| `导入独立团名单` | `lwlt-lifecycle` | `passenger_list_import` | Program-only;等待单个 14 列 `.xls/.xlsx`,程序转为 13 列 canonical TSV;ERP 初始 16 行仅为动态扩行基线,覆盖已有不同内容需确认;新版完整链路待授权实写复跑 | -| `导入散拼子单名单` | `lwlt-lifecycle` | `passenger_list_import` | Program-only;等待单个 14 列 `.xls/.xlsx`,母团本身不能导名单;ERP 初始 31 行仅为动态扩行基线;新版完整链路待授权实写复跑 | +| `导入独立团名单` | `lwlt-lifecycle` | `passenger_list_import` | Program-only;等待单个 `.xls/.xlsx`,自动识别 ERP 字段表头及其下名单并转为 13 列 canonical TSV;ERP 初始 16 行仅为动态扩行基线,覆盖已有不同内容需确认;新版完整链路待授权实写复跑 | +| `导入散拼子单名单` | `lwlt-lifecycle` | `passenger_list_import` | Program-only;等待单个 `.xls/.xlsx`,自动识别 ERP 字段表头及其下名单并转为 13 列 canonical TSV;母团本身不能导名单;ERP 初始 31 行仅为动态扩行基线;新版完整链路待授权实写复跑 | | `安排导游` | `lwlt-arrangement` | `arrangement_guide` | 当前生产输入只表达未确认新增;既有安排变更阻断 | | `安排用车` | `lwlt-arrangement` | `arrangement_vehicle` | 当前生产输入只表达未确认新增;按结算单位搜索词、用车起止日期和数量定位,车辆项目由唯一匹配结果带入;换车/update 阻断 | | `安排酒店` | `lwlt-arrangement` | `arrangement_hotel` | 支持未安排新增;酒店/房型由唯一 ERP 模糊匹配结果带入;既有唯一未安排或未确认行只开放离店日期/房间数窄 update | diff --git a/agent设计规范/skills/lwlt-lifecycle/SKILL.md b/agent设计规范/skills/lwlt-lifecycle/SKILL.md index dd1f9e5..2f62625 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`。控制面程序校验 14 列模板并生成内部 13 列 canonical TSV,附件到齐前不启动解析或执行。 +3. 名单 route 先进入 `awaiting_attachment`;只接收一份 `.xls`/`.xlsx`。控制面程序在前 100 行中自动定位唯一的 ERP 名单字段表头,按精确字段语义映射任意列顺序及受支持别名,再把表头下方的连续名单数据生成内部 13 列 canonical TSV;附件到齐前不启动解析或执行。 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 ededc85..3e0e09f 100644 --- a/agent设计规范/skills/lwlt-lifecycle/references/input-contract.md +++ b/agent设计规范/skills/lwlt-lifecycle/references/input-contract.md @@ -18,7 +18,7 @@ 覆盖确认(选填):<确认替换已有名单时填写“是”> ``` -名单附件(必填):随同文字发送一份按 14 列名单模板填写的 `.xls` 或 `.xlsx`;每行序号对应订单游客位。 +名单附件(必填):随同文字发送一份符合下述 ERP 字段规则的 `.xls` 或 `.xlsx` 名单工作簿;每行序号对应订单游客位。 ### 示例 @@ -30,7 +30,7 @@ 领队:张三 ``` -示例同时附一份按 14 列模板填写的名单工作簿。 +示例同时附一份符合 ERP 字段规则的名单工作簿。 ## 导入散拼子单名单 @@ -46,7 +46,7 @@ 覆盖确认(选填):<确认替换已有名单时填写“是”> ``` -名单附件(必填):随同文字发送一份按 14 列名单模板填写的 `.xls` 或 `.xlsx`;每行序号对应具体子单游客位。 +名单附件(必填):随同文字发送一份符合下述 ERP 字段规则的 `.xls` 或 `.xlsx` 名单工作簿;每行序号对应具体子单游客位。 ### 示例 @@ -58,9 +58,9 @@ 领队:张三 ``` -示例同时附一份按 14 列模板填写的名单工作簿。 +示例同时附一份符合 ERP 字段规则的名单工作簿。 -附件必须且只能有一个可见工作表。第一行是团信息,只作模板说明且不进入 ERP;第二行必须依次为:`序号/姓名/英文姓名/性别/身份证号码/出生日期/年龄/出生地/护照号码/签发地/签发日期/有效期/电话/备注`。当前只支持护照名单,“身份证号码”非空时阻断。“年龄”仅作模板兼容,不进入 ERP;程序把“英文姓名”映射为 `NAME`、“护照号码”映射为“证件号码”、固定合成“证件类型=护照”,并把“签发日期”映射为“签发日”。 +附件必须且只能有一个可见工作表。程序在前 100 行中自动定位唯一表头;表头可直接位于第一行,也可位于团信息或说明行之后,列顺序任意,表头下一行起的名单数据必须连续。表头必须各包含一次这些 ERP 语义字段:`序号/姓名/英文姓名(或 NAME)/性别/出生日期/出生地/护照号码(或证件号码)/签发地/签发日期(或签发日)/有效期/电话/备注`。可选兼容字段为`年龄`、`身份证号码(或身份证)`、`证件类型`;同一语义字段不得重复,未知表头或无表头数据列会失败关闭。当前只支持护照名单:身份证列如存在必须为空,证件类型如有值必须为`护照`;`年龄`不进入 ERP。程序把英文姓名映射为 `NAME`、护照号码映射为“证件号码”、固定输出“证件类型=护照”,并把签发日期映射为“签发日”。 若某位游客是领队,该行`备注`去除首尾空格后必须严格等于`领队`,并同时填写该行`姓名`和`电话`。一份名单最多标记一位领队;标记成功后程序会把这两个值写入 ERP `联系人/领队人`和`电话`,该行仍作为普通游客导入。`领队备注`等其他文字不触发写入;没有严格标记时保留 ERP 原联系人信息。正文里的`领队`字段仍只是订单定位的选填补充关键词,不是联系人写入来源。 diff --git a/agent设计规范/templates/business-input-templates.md b/agent设计规范/templates/business-input-templates.md index dd8d931..50db1b1 100644 --- a/agent设计规范/templates/business-input-templates.md +++ b/agent设计规范/templates/business-input-templates.md @@ -1,6 +1,6 @@ # 运营业务 AI 输入模板总表 -交付版本:0.5.123 +交付版本:0.5.125 适用范围:当前 18 项运营业务输入;执行能力以生命周期发布门槛为准。 本文件用于向系统提交 ERP 业务需求。复制对应模板填写;示例只供参考,不要直接用于生产业务。 @@ -128,7 +128,7 @@ 名单只通过一份 `.xls` 或 `.xlsx` 附件提交,不再粘贴到文字输入框。系统识别名单业务后会等待附件,附件到齐后只走程序解析。 -工作簿第一行是团信息,系统不导入;第二行必须使用 14 列表头:`序号/姓名/英文姓名/性别/身份证号码/出生日期/年龄/出生地/护照号码/签发地/签发日期/有效期/电话/备注`。当前只支持护照名单,“身份证号码”列留空;每次只保留一个可见工作表,最多 5000 行。 +系统会在工作表前 100 行中自动定位唯一的名单表头;表头可以直接在第一行,也可以位于团信息或说明行之后,字段顺序任意,表头下一行开始识别连续名单数据。表头必须各包含一次这些 ERP 语义字段:`序号/姓名/英文姓名(或 NAME)/性别/出生日期/出生地/护照号码(或证件号码)/签发地/签发日期(或签发日)/有效期/电话/备注`。可兼容附加列`年龄`、`身份证号码(或身份证)`、`证件类型`;同一语义字段不得重复,未知表头或无表头数据列会失败关闭。当前只支持护照名单:身份证列如存在必须留空,证件类型如有值必须为`护照`;`年龄`不进入 ERP。每次只保留一个可见工作表,最多 5000 行。 若游客是领队,在该行`备注`只填`领队`,并确保同一行`姓名`和`电话`已填写;一份名单最多一位。系统会把这两个值同步到 ERP `联系人/领队人`和`电话`。其他备注不触发联系人写入;没有领队标记时保留 ERP 原信息。正文里的`领队`仍只是选填的查单关键词。 @@ -146,7 +146,7 @@ 覆盖确认(选填):<只有替换已有名单时填写“是”> ``` -名单附件(必填):随同以上文字发送一份按 14 列模板填写的 `.xls` 或 `.xlsx`。 +名单附件(必填):随同以上文字发送一份符合上述 ERP 字段规则的 `.xls` 或 `.xlsx` 名单工作簿。 #### 示例 @@ -173,7 +173,7 @@ 覆盖确认(选填):<只有替换已有名单时填写“是”> ``` -名单附件(必填):随同以上文字发送一份按 14 列模板填写的 `.xls` 或 `.xlsx`。 +名单附件(必填):随同以上文字发送一份符合上述 ERP 字段规则的 `.xls` 或 `.xlsx` 名单工作簿。 #### 示例 diff --git a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md index 2bd7815..661de18 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.163` - Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories` - 生命周期契约:`ltjt-lifecycle-v2.9-roster-leader-contact-2026-08` -- 五个 Skill:`0.5.125`;运营 DOCX:`0.5.123` +- 五个 Skill:`0.5.125`;运营 DOCX:`0.5.125` - 发布状态:核心窄能力已有真实证据;一笔名单附件 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 原生回调在预检后调整这些字段。新增独立团房型/人数映射仍待实写;未验证宽能力继续失败关闭。 运行中的浏览器版本必须实时握手确认。当前制品文件名和 SHA-256 只看 [`../../../dist/release-manifest.json`](../../../dist/release-manifest.json),不从历史日志推断。 @@ -15,7 +15,7 @@ | 能力 | 当前边界 | |---|---| | 创建 | 独立团单个/批量、散拼母团单个/批量及具体子单;批量逐日期保留响应和回查 | -| 名单 | 独立团和具体散拼子单;文字定位后等待单个 `.xls`/`.xlsx`,程序规范化 14 列模板并固定 Program-only;支持明确序号的动态扩行、安全合并、同值幂等和覆盖确认。解析阶段不以初始 16/31 行作统一上限;执行时若原生页实际物化游客位不足,则在保存前以人数/上限业务提示阻断。2026-08-28 已只读确认一笔具体散拼子单的 25 行及领队联系人真实持久化全等;另一次独立团 25 人只物化 16 位且未保存 | +| 名单 | 独立团和具体散拼子单;文字定位后等待单个 `.xls`/`.xlsx`,程序在前 100 行自动识别唯一 ERP 字段表头及其下连续名单,规范化为 13 列 TSV 并固定 Program-only;支持明确序号的动态扩行、安全合并、同值幂等和覆盖确认。解析阶段不以初始 16/31 行作统一上限;执行时若原生页实际物化游客位不足,则在保存前以人数/上限业务提示阻断。2026-08-28 已只读确认一笔具体散拼子单的 25 行及领队联系人真实持久化全等;另一次独立团 25 人只物化 16 位且未保存 | | 安排 | 导游、车辆、酒店、大交通、其他/备案 create/clear;酒店新增状态为未安排 | | 独立团修改 | `twin_room_count` 已真实持久化;SGL/TWN 与成人/占床儿童/不占床儿童/领队人数已接入映射/回查但待新版本实写 | | 散拼修改 | 母团 `planned_capacity`、具体子单最终长度 ≤50 的 `lodging_note` | diff --git a/archive/releases/2026-09-01/README.md b/archive/releases/2026-09-01/README.md new file mode 100644 index 0000000..83230da --- /dev/null +++ b/archive/releases/2026-09-01/README.md @@ -0,0 +1,7 @@ +# 2026-09-01 发布归档 + +本目录只保存被新版本替代的历史发布物,不定义当前运行规则。当前制品文件名和 SHA-256 只以根目录 [`../../../dist/release-manifest.json`](../../../dist/release-manifest.json) 为准。 + +- `release-manifest-0.5.163-docx-0.5.123.json`:更新名单工作簿表头映射前的历史清单快照。 +- `老挝联泰AI指令表-0.5.123.docx`:被支持第二行 A:N 表头字段乱序自动映射的 `0.5.124` 文档替代;保留用于恢复和追溯。 +- `老挝联泰AI指令表-0.5.124.docx`:未集成期间被 `0.5.125` 文档替代;后者改为在前 100 行自动识别唯一 ERP 字段表头,并兼容第一行表头、ERP 字段名及已批准名单别名。 diff --git a/archive/releases/2026-09-01/release-manifest-0.5.163-docx-0.5.123.json b/archive/releases/2026-09-01/release-manifest-0.5.163-docx-0.5.123.json new file mode 100644 index 0000000..70234d5 --- /dev/null +++ b/archive/releases/2026-09-01/release-manifest-0.5.163-docx-0.5.123.json @@ -0,0 +1,69 @@ +{ + "manifest_version": 1, + "generated_on": "2026-08-30", + "baselines": { + "chrome_extension": "0.5.163", + "skills": "0.5.125", + "business_instruction_docx": "0.5.123", + "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" + }, + "artifacts": [ + { + "kind": "chrome_extension", + "version": "0.5.163", + "path": "dist/ltjt-order-assistant-0.5.163.zip", + "source": "chrome-extension/ltjt-order-assistant", + "sha256": "90c550054cdf747aa9c2c80bca5ee5fba0d13f126acfd4779f85400764379bf0" + }, + { + "kind": "skill", + "name": "lwlt-arrangement", + "version": "0.5.125", + "path": "dist/lwlt-arrangement-0.5.125.skill", + "source": "agent设计规范/skills/lwlt-arrangement", + "sha256": "c729bebec84695b8642a6d31cb4dc0d963135b6c6620c2173be095826cd3976d" + }, + { + "kind": "skill", + "name": "lwlt-confirmation", + "version": "0.5.125", + "path": "dist/lwlt-confirmation-0.5.125.skill", + "source": "agent设计规范/skills/lwlt-confirmation", + "sha256": "659f6aca7918a03a29fc78f0b4b938dfa9084ccdbb44ebd71f6399556cfa4609" + }, + { + "kind": "skill", + "name": "lwlt-lifecycle", + "version": "0.5.125", + "path": "dist/lwlt-lifecycle-0.5.125.skill", + "source": "agent设计规范/skills/lwlt-lifecycle", + "sha256": "8cc5d94d97abbc68388bf60d4b46ce4cb1aa947dfb174831c0ad2afc421002f0" + }, + { + "kind": "skill", + "name": "lwlt-newbooking", + "version": "0.5.125", + "path": "dist/lwlt-newbooking-0.5.125.skill", + "source": "agent设计规范/skills/lwlt-newbooking", + "sha256": "bb610c50dd659fc172aabfa469b3028de98704c249cfd4890463deea0c88cd0c" + }, + { + "kind": "skill", + "name": "lwlt-updating", + "version": "0.5.125", + "path": "dist/lwlt-updating-0.5.125.skill", + "source": "agent设计规范/skills/lwlt-updating", + "sha256": "c6f5bc9008e2f63d170f3f140140003d99411ce5232632c48b38891da1ceee33" + }, + { + "kind": "business_instruction_docx", + "version": "0.5.123", + "path": "dist/老挝联泰AI指令表-0.5.123.docx", + "source": "agent设计规范/templates/business-input-templates.md", + "builder": "tools/build_business_instruction_docx.py", + "sha256": "8d94b1f4e3d1cfccf62c1b7d420cbcebf8c915844ae95fa90840963b32b1cccb", + "source_sha256": "8db16b3dce7e3dd8aa88a6273a9a90bbc3c98a087cb863bf0eeaaa5bb3a371ca", + "builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1" + } + ] +} diff --git a/dist/老挝联泰AI指令表-0.5.123.docx b/archive/releases/2026-09-01/老挝联泰AI指令表-0.5.123.docx similarity index 100% rename from dist/老挝联泰AI指令表-0.5.123.docx rename to archive/releases/2026-09-01/老挝联泰AI指令表-0.5.123.docx diff --git a/archive/releases/2026-09-01/老挝联泰AI指令表-0.5.124.docx b/archive/releases/2026-09-01/老挝联泰AI指令表-0.5.124.docx new file mode 100644 index 0000000..05daae0 Binary files /dev/null and b/archive/releases/2026-09-01/老挝联泰AI指令表-0.5.124.docx differ diff --git a/control-plane/README.md b/control-plane/README.md index c513d6d..58e72a3 100644 --- a/control-plane/README.md +++ b/control-plane/README.md @@ -10,7 +10,7 @@ - 业务页面通过 REST 创建任务;Agent 返回结构化结果后,任务创建人在自己的任务上一次点击“确认并提交到 ERP 插件”,再通过 SSE 或轮询读取服务端状态。普通用户与组长的正常任务 API、SSE、附件和插件执行权限都只覆盖本人创建的人工任务;管理员可访问固定部署范围内的全部任务和 AgentBus/system 任务。 - 手工与 AgentBus 的每个新任务都会按同一组织、同一 18 项业务路由固化解析策略及配置 revision;来源不能覆盖模式。其中两项名单 route 固定为 `program_only`,其余 16 项可配置 `ai / shadow / auto / program`。全消息中的唯一已登记指令可以确定 route;没有指令时,只有全部标签都属于唯一 route、至少两个不同标签且业务定位必填项完整的字段签名才可确定 route。未知、冲突或多 route 输入不猜测。后续补充轮次沿用原任务快照,设置变化只影响新任务和新会话。 - 管理员天然拥有全部 18 类人工业务。组长和普通用户使用逐账号白名单,新账号默认没有任何可执行任务类型;管理员在 `/accounts` 逐项授权后才能提交对应的新任务、补充指令或名单附件。已登记但未授权的业务返回 `business_not_authorized`;无法唯一确认 route 的非管理员人工输入返回 `business_type_unresolved`。两种拒绝都发生在解析器和 ERP 插件之前,并记录不含明文的授权拒绝审计。权限在补充输入、人工确认、全自动确认和插件领取前再次校验;取消授权后的任务不会进入 ERP 队列。AgentBus 继续使用管理员控制的独立渠道边界,不映射为平台普通账号权限。 -- 名单 route 创建后先进入 `awaiting_attachment`,只接收一份 `.xls/.xlsx` 14 列模板。附件在内存中失败关闭地规范化为 13 列 canonical TSV;原始工作簿不入库,文件名和 canonical TSV 使用字段加密,解析通过或终止后清除 canonical 中间文本。附件到齐前不会领取解析任务,也不会进入 ERP。独立团初始 16 行、散拼子单初始 31 行仅为 ERP 动态扩行基线,5000 为技术上限。 +- 名单 route 创建后先进入 `awaiting_attachment`,只接收一份 `.xls/.xlsx`;控制面在前 100 行中自动定位唯一的 ERP 名单字段表头,按精确字段语义映射任意表头行、列顺序及受支持别名,并把表头下方连续名单数据在内存中失败关闭地规范化为 13 列 canonical TSV。未知额外列、重复语义字段和多个候选表头继续阻断。原始工作簿不入库,文件名和 canonical TSV 使用字段加密,解析通过或终止后清除 canonical 中间文本。附件到齐前不会领取解析任务,也不会进入 ERP。独立团初始 16 行、散拼子单初始 31 行仅为 ERP 动态扩行基线,5000 为技术上限。 - 平台的任务 ID、Agent 会话 ID、确认状态、重要摘要、事件和用户通讯内容只存在于平台任务/会话/结果信封中,不回写到 Agent `operation`,也不成为 ERP 业务字段。 - Agent `operation` 只保存解析态业务事实。插件领取已确认任务后先做无需 ERP 查询的前门禁,再在 ERP 内只读唯一解析对象、资源和当前状态,最后对内部 execution operation 执行严格写前门禁。 - Chrome 插件仍在当前平台账号已登录的浏览器/ERP 会话中工作;浏览器连接绑定该平台账号,`chrome.storage.local` 只是临时缓存。 diff --git a/control-plane/src/business-routes.ts b/control-plane/src/business-routes.ts index c5f2998..f11ec09 100644 --- a/control-plane/src/business-routes.ts +++ b/control-plane/src/business-routes.ts @@ -75,7 +75,7 @@ export function effectiveParserModeForRoute(route: BusinessRouteDefinition, conf } const PROGRAM_VERSION = 'ltjt-program-parser-v1.0.6'; -export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.123'; +export const BUSINESS_INPUT_CONTRACT_VERSION = 'business-input-templates-0.5.125'; 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 d3e87b2..b050f7a 100644 --- a/control-plane/src/passenger-roster-workbook.ts +++ b/control-plane/src/passenger-roster-workbook.ts @@ -12,16 +12,52 @@ const DEFAULT_MAX_DATA_ROWS = 5_000; const ABSOLUTE_MAX_DATA_ROWS = 5_000; const MAX_PASSENGER_SEQUENCE = 5_000; const MAX_HEADER_ROW = 100; -const EXPECTED_HEADER_ROW = 2; -const SOURCE_COLUMN_COUNT = 14; -export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.1.0'; +export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.3.0'; -const SOURCE_HEADERS = [ +const SOURCE_FIELDS = [ '序号', '姓名', '英文姓名', '性别', '身份证号码', '出生日期', '年龄', '出生地', - '护照号码', '签发地', '签发日期', '有效期', '电话', '备注' + '证件类型', '护照号码', '签发地', '签发日期', '有效期', '电话', '备注' ] as const; +const REQUIRED_SOURCE_FIELDS = [ + '序号', '姓名', '英文姓名', '性别', '出生日期', '出生地', '护照号码', + '签发地', '签发日期', '有效期', '电话', '备注' +] as const; + +type SourceField = typeof SOURCE_FIELDS[number]; +type RequiredSourceField = typeof REQUIRED_SOURCE_FIELDS[number]; +type SourceColumnMap = Partial> & Record; + +/** + * Header recognition is intentionally exact and finite. It accepts the + * customer-facing template labels, the canonical ERP labels, and aliases + * observed in approved roster samples; it does not use fuzzy matching. + */ +const HEADER_FIELD_ENTRIES: ReadonlyArray = [ + ['序号', '序号'], + ['姓名', '姓名'], + ['英文姓名', '英文姓名'], + ['NAME', '英文姓名'], + ['性别', '性别'], + ['身份证号码', '身份证号码'], + ['身份证', '身份证号码'], + ['出生日期', '出生日期'], + ['年龄', '年龄'], + ['出生地', '出生地'], + ['证件类型', '证件类型'], + ['护照号码', '护照号码'], + ['证件号码', '护照号码'], + ['签发地', '签发地'], + ['签发日期', '签发日期'], + ['签发日', '签发日期'], + ['有效期', '有效期'], + ['电话', '电话'], + ['备注', '备注'] +]; + +const SOURCE_FIELD_BY_HEADER = new Map(HEADER_FIELD_ENTRIES); + export const CANONICAL_PASSENGER_HEADERS = [ '序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码', '签发地', '签发日', '有效期', '电话', '备注' @@ -41,7 +77,6 @@ export type PassengerRosterWorkbookErrorCode = | 'roster_workbook_hidden_data' | 'roster_workbook_header_not_found' | 'roster_workbook_ambiguous_header' - | 'roster_workbook_header_row_invalid' | 'roster_workbook_formula_not_allowed' | 'roster_workbook_formula_result_missing' | 'roster_workbook_unexpected_column' @@ -52,6 +87,7 @@ export type PassengerRosterWorkbookErrorCode = | 'roster_workbook_sequence_limit' | 'roster_workbook_duplicate_sequence' | 'roster_workbook_identity_card_not_supported' + | 'roster_workbook_document_type_not_supported' | 'roster_workbook_invalid_date' | 'roster_workbook_invalid_identifier' | 'roster_workbook_invalid_cell'; @@ -68,12 +104,11 @@ const ERROR_MESSAGES: Record = { roster_workbook_unsafe_content: '名单工作簿包含不允许的宏、外部链接或嵌入内容', roster_workbook_ambiguous_sheet: '名单工作簿必须且只能包含一个工作表', roster_workbook_hidden_data: '名单工作簿包含隐藏的非空数据', - roster_workbook_header_not_found: '未找到完整的 14 列名单表头', + roster_workbook_header_not_found: '未找到可映射到 ERP 名单字段的完整表头', roster_workbook_ambiguous_header: '名单工作簿包含多个可识别的名单表头', - roster_workbook_header_row_invalid: '名单工作簿第 2 行必须是完整的 14 列名单表头', roster_workbook_formula_not_allowed: '名单工作簿包含不允许的公式', roster_workbook_formula_result_missing: '名单工作簿公式没有可验证的缓存结果', - roster_workbook_unexpected_column: '名单数据包含 14 列之外的内容', + roster_workbook_unexpected_column: '名单表头范围外包含未识别的数据列', roster_workbook_non_contiguous_rows: '名单数据行不连续', roster_workbook_row_limit: '名单数据行数超过技术限制', roster_workbook_no_data: '名单工作簿没有数据行', @@ -81,6 +116,7 @@ const ERROR_MESSAGES: Record = { roster_workbook_sequence_limit: '名单序号超过技术安全上限', roster_workbook_duplicate_sequence: '名单序号不能重复', roster_workbook_identity_card_not_supported: '当前名单导入不支持非空身份证号码', + roster_workbook_document_type_not_supported: '当前名单导入仅支持护照证件类型', roster_workbook_invalid_date: '名单日期无效', roster_workbook_invalid_identifier: '名单证件号或电话无法安全转换为文本', roster_workbook_invalid_cell: '名单单元格值无效' @@ -287,16 +323,35 @@ function cellColumn(cell: ExcelJS.Cell): number { return cell.fullAddress.col; } -function formulaAllowed(cell: ExcelJS.Cell, headerRow: number): boolean { +function excelColumnName(column: number): string { + let value = column; + let result = ''; + while (value > 0) { + const remainder = (value - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + value = Math.floor((value - 1) / 26); + } + return result; +} + +function formulaAllowed( + cell: ExcelJS.Cell, + headerRow: number, + sourceColumns: SourceColumnMap +): boolean { const row = cellRow(cell); const column = cellColumn(cell); if (row <= headerRow) return false; const formula = normalizedFormula(cell.formula); - if (column === 7) { - return formula === `DATEDIF(F${row},TODAY(),"Y")`; + const ageColumn = sourceColumns['年龄']; + if (ageColumn !== undefined && column === ageColumn) { + return formula === `DATEDIF(${excelColumnName(sourceColumns['出生日期'])}${row},TODAY(),"Y")`; } - if (column === 12) { - return formula === `EDATE(K${row},10*12)-1`; + if (column === sourceColumns['签发日期']) { + return formula === `EDATE(${excelColumnName(sourceColumns['有效期'])}${row},-10*12)+1`; + } + if (column === sourceColumns['有效期']) { + return formula === `EDATE(${excelColumnName(sourceColumns['签发日期'])}${row},10*12)-1`; } return false; } @@ -348,14 +403,21 @@ function cellHasData(cell: ExcelJS.Cell): boolean { return normalizedText(scalarValue(cell)) !== ''; } -function rowHasData(row: ExcelJS.Row, maxColumn = SOURCE_COLUMN_COUNT): boolean { - for (let column = 1; column <= maxColumn; column += 1) { +function rowHasData(row: ExcelJS.Row, sourceColumns: readonly number[]): boolean { + for (const column of sourceColumns) { if (cellHasData(row.getCell(column))) return true; } return false; } -function validateFormulasAndHiddenData(sheet: ExcelJS.Worksheet, headerRow: number): void { +function validateFormulasAndHiddenData( + sheet: ExcelJS.Worksheet, + headerRow: number, + sourceColumns: SourceColumnMap +): void { + const recognizedColumns = new Set( + Object.values(sourceColumns).filter((column): column is number => column !== undefined) + ); sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { let hasData = false; row.eachCell({ includeEmpty: false }, (cell, columnNumber) => { @@ -365,15 +427,16 @@ function validateFormulasAndHiddenData(sheet: ExcelJS.Worksheet, headerRow: numb if (containsExternalFormulaReference(formula)) { fail('roster_workbook_unsafe_content', { row: rowNumber, column: columnNumber }); } - if (!formulaAllowed(cell, headerRow)) { + if (!formulaAllowed(cell, headerRow, sourceColumns)) { fail('roster_workbook_formula_not_allowed', { row: rowNumber, column: columnNumber }); } } if (cell.type === ExcelJS.ValueType.Hyperlink || cell.isHyperlink) { fail('roster_workbook_unsafe_content', { row: rowNumber, column: columnNumber }); } - if (cellHasData(cell)) hasData = true; - if (columnNumber > SOURCE_COLUMN_COUNT && rowNumber >= headerRow) { + const containsData = cellHasData(cell); + if (containsData) hasData = true; + if (containsData && rowNumber >= headerRow && !recognizedColumns.has(columnNumber)) { fail('roster_workbook_unexpected_column', { row: rowNumber, column: columnNumber }); } }); @@ -391,21 +454,37 @@ function validateFormulasAndHiddenData(sheet: ExcelJS.Worksheet, headerRow: numb } } -function findHeaderRow(sheet: ExcelJS.Worksheet): number { - const candidates: number[] = []; +function headerColumnsForRow(sheet: ExcelJS.Worksheet, rowNumber: number): SourceColumnMap | null { + const columns = new Map(); + const row = sheet.getRow(rowNumber); + for (let columnNumber = 1; columnNumber <= sheet.columnCount; columnNumber += 1) { + const cell = row.getCell(columnNumber); + if (cell.type === ExcelJS.ValueType.Merge && cell.master.address !== cell.address) return null; + const value = headerText(cell); + if (value === '') continue; + const field = SOURCE_FIELD_BY_HEADER.get(value); + if (!field || columns.has(field)) return null; + columns.set(field, columnNumber); + } + if (!REQUIRED_SOURCE_FIELDS.every((field) => columns.has(field))) return null; + + const result: Partial> = {}; + for (const field of SOURCE_FIELDS) { + const column = columns.get(field); + if (column !== undefined) result[field] = column; + } + return result as SourceColumnMap; +} + +function findHeaderLayout(sheet: ExcelJS.Worksheet): { row: number; columns: SourceColumnMap } { + const candidates: Array<{ row: number; columns: SourceColumnMap }> = []; const lastCandidate = Math.min(sheet.rowCount, MAX_HEADER_ROW); for (let rowNumber = 1; rowNumber <= lastCandidate; rowNumber += 1) { - const row = sheet.getRow(rowNumber); - const values = SOURCE_HEADERS.map((_header, index) => headerText(row.getCell(index + 1))); - if (values.every((value, index) => value === SOURCE_HEADERS[index])) { - candidates.push(rowNumber); - } + const columns = headerColumnsForRow(sheet, rowNumber); + if (columns) candidates.push({ row: rowNumber, columns }); } if (candidates.length === 0) fail('roster_workbook_header_not_found'); if (candidates.length !== 1) fail('roster_workbook_ambiguous_header'); - if (candidates[0] !== EXPECTED_HEADER_ROW) { - fail('roster_workbook_header_row_invalid', { row: candidates[0] }); - } return candidates[0]; } @@ -488,12 +567,19 @@ function canonicalTextCell(cell: ExcelJS.Cell): string { return normalizedText(scalarValue(cell)); } -function dataRows(sheet: ExcelJS.Worksheet, headerRow: number, maxDataRows: number): ExcelJS.Row[] { +function dataRows( + sheet: ExcelJS.Worksheet, + headerRow: number, + sourceColumns: SourceColumnMap, + maxDataRows: number +): ExcelJS.Row[] { const rows: ExcelJS.Row[] = []; let gapAfterData = false; + const recognizedColumns = Object.values(sourceColumns) + .filter((column): column is number => column !== undefined); for (let rowNumber = headerRow + 1; rowNumber <= sheet.rowCount; rowNumber += 1) { const row = sheet.getRow(rowNumber); - const hasData = rowHasData(row); + const hasData = rowHasData(row, recognizedColumns); if (!hasData) { if (rows.length > 0) gapAfterData = true; continue; @@ -506,32 +592,61 @@ function dataRows(sheet: ExcelJS.Worksheet, headerRow: number, maxDataRows: numb return rows; } -function canonicalRow(row: ExcelJS.Row, date1904: boolean, seenSequences: Set): string[] { - const sequence = canonicalSequence(row.getCell(1)); +function canonicalRow( + row: ExcelJS.Row, + date1904: boolean, + seenSequences: Set, + sourceColumns: SourceColumnMap +): string[] { + const cell = (field: RequiredSourceField): ExcelJS.Cell => row.getCell(sourceColumns[field]); + const sequence = canonicalSequence(cell('序号')); if (seenSequences.has(sequence)) { - fail('roster_workbook_duplicate_sequence', { row: row.number, column: 1 }); + fail('roster_workbook_duplicate_sequence', { + row: row.number, + column: sourceColumns['序号'] + }); } seenSequences.add(sequence); - if (canonicalIdentifier(row.getCell(5)) !== '') { - fail('roster_workbook_identity_card_not_supported', { row: row.number, column: 5 }); + const identityCardColumn = sourceColumns['身份证号码']; + if ( + identityCardColumn !== undefined + && canonicalIdentifier(row.getCell(identityCardColumn)) !== '' + ) { + fail('roster_workbook_identity_card_not_supported', { + row: row.number, + column: identityCardColumn + }); } - const englishName = canonicalTextCell(row.getCell(3)).replace(/,/g, ','); + const documentTypeColumn = sourceColumns['证件类型']; + if (documentTypeColumn !== undefined) { + const documentType = canonicalTextCell(row.getCell(documentTypeColumn)); + if (documentType !== '' && documentType !== '护照') { + fail('roster_workbook_document_type_not_supported', { + row: row.number, + column: documentTypeColumn + }); + } + } + + const issueDateCell = cell('签发日期'); + const expiryCell = cell('有效期'); + const englishName = canonicalTextCell(cell('英文姓名')).replace(/,/g, ','); return [ String(sequence), - canonicalTextCell(row.getCell(2)), + canonicalTextCell(cell('姓名')), englishName, - canonicalTextCell(row.getCell(4)), - canonicalDate(row.getCell(6), date1904), - canonicalTextCell(row.getCell(8)), + canonicalTextCell(cell('性别')), + canonicalDate(cell('出生日期'), date1904), + canonicalTextCell(cell('出生地')), '护照', - canonicalIdentifier(row.getCell(9)), - canonicalTextCell(row.getCell(10)), - canonicalDate(row.getCell(11), date1904), - canonicalDate(row.getCell(12), date1904, row.getCell(12).type === ExcelJS.ValueType.Formula), - canonicalIdentifier(row.getCell(13)), - canonicalTextCell(row.getCell(14)) + canonicalIdentifier(cell('护照号码')), + canonicalTextCell(cell('签发地')), + canonicalDate(issueDateCell, date1904, issueDateCell.type === ExcelJS.ValueType.Formula), + canonicalDate(expiryCell, date1904, expiryCell.type === ExcelJS.ValueType.Formula), + canonicalIdentifier(cell('电话')), + canonicalTextCell(cell('备注')) ]; } @@ -584,12 +699,13 @@ export async function normalizePassengerRosterWorkbook( if (workbook.worksheets.length !== 1) fail('roster_workbook_ambiguous_sheet'); const sheet = workbook.worksheets[0]; if (sheet.state !== 'visible') fail('roster_workbook_hidden_data'); - const headerRow = findHeaderRow(sheet); - validateFormulasAndHiddenData(sheet, headerRow); - const rows = dataRows(sheet, headerRow, maxDataRows); + const header = findHeaderLayout(sheet); + const headerRow = header.row; + validateFormulasAndHiddenData(sheet, headerRow, header.columns); + const rows = dataRows(sheet, headerRow, header.columns, maxDataRows); const date1904 = workbook.properties.date1904 === true; const seenSequences = new Set(); - const canonicalRows = rows.map((row) => canonicalRow(row, date1904, seenSequences)); + const canonicalRows = rows.map((row) => canonicalRow(row, date1904, seenSequences, header.columns)); 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 03be723..1399831 100644 --- a/control-plane/test/passenger-roster-workbook.test.ts +++ b/control-plane/test/passenger-roster-workbook.test.ts @@ -12,7 +12,14 @@ const SOURCE_HEADERS = [ '护照号码', '签发地', '签发日期', '有效期', '电话', '备注' ] as const; +const ERP_HEADERS = [ + '序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码', + '签发地', '签发日', '有效期', '电话', '备注' +] as const; + const CANONICAL_HEADER = '序号\t姓名\tNAME\t性别\t出生日期\t出生地\t证件类型\t证件号码\t签发地\t签发日\t有效期\t电话\t备注'; +const REORDERED_SOURCE_INDEXES = [8, 1, 13, 0, 10, 4, 6, 12, 3, 11, 5, 9, 2, 7]; +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; @@ -57,6 +64,29 @@ async function syntheticWorkbook(...mutations: WorkbookMutation[]): Promise { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('ERP名单'); + sheet.addRow([...ERP_HEADERS]); + sheet.addRow([ + 1, + '测试甲', + 'ALPHA,TEST', + '男', + '1990/1/2', + '测试地一', + '护照', + 123456789, + '测试签发地', + new Date(Date.UTC(2025, 0, 2)), + new Date(Date.UTC(2035, 0, 1)), + 13800000000, + '领队\t备注' + ]); + for (const mutation of mutations) await mutation(workbook); + return Buffer.from(await workbook.xlsx.writeBuffer()); +} + async function expectRosterError( work: () => Promise, code: string, @@ -92,7 +122,7 @@ test('XLSX is deterministically normalized to the canonical 13-column TSV', asyn ].join('\n')); }); -test('row 1 is ignored as group metadata and the exact roster header must be on row 2', async () => { +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 = '任意团信息'; }); @@ -104,17 +134,142 @@ test('row 1 is ignored as group metadata and the exact roster header must be on assert.equal(normalized.headerRow, 2); assert.equal(normalized.rowCount, 2); + const reordered = await syntheticWorkbook((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; + } + sheet.getCell('G3').value = { + formula: 'DATEDIF(K3,TODAY(),"Y")', + result: 36 + }; + sheet.getCell('J3').value = { + formula: 'EDATE(E3,10*12)-1', + result: new Date(Date.UTC(2035, 0, 1)) + }; + sheet.getCell('G4').value = { + formula: 'DATEDIF(K4,TODAY(),"Y")', + result: 26 + }; + sheet.getCell('J4').value = { + formula: 'EDATE(E4,10*12)-1', + result: 49310 + }; + }); + const reorderedResult = await normalizePassengerRosterWorkbook({ + content: reordered, + fileName: 'reordered-roster.xlsx', + contentType: 'application/octet-stream' + }); + assert.equal(reorderedResult.headerRow, 2); + assert.equal(reorderedResult.canonicalTsv, [ + CANONICAL_HEADER, + '1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注', + '2\t测试乙\tBETA,TEST\t女\t2000-01-02\t测试地二\t护照\tP00000002\t测试签发地\t2025-01-02\t2035-01-01\t\t' + ].join('\n')); + const shifted = await syntheticWorkbook((workbook) => { - workbook.getWorksheet('Sheet1')!.insertRow(2, ['额外说明']); + const sheet = workbook.getWorksheet('Sheet1')!; + sheet.insertRow(2, ['额外说明']); + sheet.getCell('G4').value = { + formula: 'DATEDIF(F4,TODAY(),"Y")', + result: 36 + }; + sheet.getCell('L4').value = { + formula: 'EDATE(K4,10*12)-1', + result: new Date(Date.UTC(2035, 0, 1)) + }; + sheet.getCell('G5').value = { + formula: 'DATEDIF(F5,TODAY(),"Y")', + result: 26 + }; + sheet.getCell('L5').value = { + formula: 'EDATE(K5,10*12)-1', + result: 49310 + }; + }); + const shiftedResult = await normalizePassengerRosterWorkbook({ + content: shifted, + fileName: 'synthetic.xlsx', + contentType: 'application/octet-stream' + }); + assert.equal(shiftedResult.headerRow, 3); + assert.equal(shiftedResult.rowCount, 2); + + const erpHeaderOnFirstRow = await normalizePassengerRosterWorkbook({ + content: await erpHeaderWorkbook(), + fileName: 'erp-roster.xlsx', + contentType: 'application/octet-stream' + }); + assert.equal(erpHeaderOnFirstRow.headerRow, 1); + assert.equal(erpHeaderOnFirstRow.canonicalTsv, [ + CANONICAL_HEADER, + '1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注' + ].join('\n')); +}); + +test('the approved attachment aliases and reverse issue-date formula are normalized', async () => { + const attachmentVariant = await syntheticWorkbook((workbook) => { + const sheet = workbook.getWorksheet('Sheet1')!; + for (const rowNumber of [2, 3, 4]) { + const row = sheet.getRow(rowNumber); + const values = ATTACHMENT_VARIANT_INDEXES.map((index) => row.getCell(index + 1).value); + row.values = values; + } + sheet.getCell('N2').value = '身份证'; + sheet.getCell('F3').value = 36; + sheet.getCell('J3').value = { + formula: 'EDATE(K3,-10*12)+1', + result: new Date(Date.UTC(2025, 0, 2)) + }; + sheet.getCell('K3').value = new Date(Date.UTC(2035, 0, 1)); + sheet.getCell('F4').value = 26; + sheet.getCell('J4').value = { + formula: 'EDATE(K4,-10*12)+1', + result: 45659 + }; + sheet.getCell('K4').value = 49310; + }); + + const result = await normalizePassengerRosterWorkbook({ + content: attachmentVariant, + fileName: 'attachment-variant.xlsx', + contentType: 'application/octet-stream' + }); + assert.equal(result.headerRow, 2); + assert.equal(result.canonicalTsv, [ + CANONICAL_HEADER, + '1\t测试甲\tALPHA,TEST\t男\t1990-01-02\t测试地一\t护照\t123456789\t测试签发地\t2025-01-02\t2035-01-01\t13800000000\t领队 备注', + '2\t测试乙\tBETA,TEST\t女\t2000-01-02\t测试地二\t护照\tP00000002\t测试签发地\t2025-01-02\t2035-01-01\t\t' + ].join('\n')); +}); + +test('source header order may vary, but every required label must appear exactly once', async () => { + const missingHeader = await syntheticWorkbook((workbook) => { + workbook.getWorksheet('Sheet1')!.getCell('N2').value = '备注说明'; }); await expectRosterError( - () => normalizePassengerRosterWorkbook({ - content: shifted, - fileName: 'synthetic.xlsx', - contentType: 'application/octet-stream' - }), - 'roster_workbook_header_row_invalid', - { row: 3 } + () => normalizePassengerRosterWorkbook({ content: missingHeader, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), + 'roster_workbook_header_not_found' + ); + + const duplicateHeader = await syntheticWorkbook((workbook) => { + workbook.getWorksheet('Sheet1')!.getCell('N2').value = '姓名'; + }); + await expectRosterError( + () => normalizePassengerRosterWorkbook({ content: duplicateHeader, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), + 'roster_workbook_header_not_found' + ); + + const unheadedExtraData = await syntheticWorkbook((workbook) => { + workbook.getWorksheet('Sheet1')!.getCell('O3').value = '未识别数据'; + }); + await expectRosterError( + () => normalizePassengerRosterWorkbook({ content: unheadedExtraData, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), + 'roster_workbook_unexpected_column', + { row: 3, column: 15 } ); }); @@ -164,6 +319,21 @@ test('non-empty identity cards fail closed without leaking cell values', async ( assert.doesNotMatch(JSON.stringify(error), new RegExp(secret)); }); +test('canonical ERP document type remains passport-only', async () => { + const unsupportedType = await erpHeaderWorkbook((workbook) => { + workbook.getWorksheet('ERP名单')!.getCell('G2').value = '身份证'; + }); + await expectRosterError( + () => normalizePassengerRosterWorkbook({ + content: unsupportedType, + fileName: 'erp-roster.xlsx', + contentType: 'application/octet-stream' + }), + 'roster_workbook_document_type_not_supported', + { row: 2, column: 7 } + ); +}); + test('sequence numbers must be positive and unique', async () => { const duplicate = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('A4').value = 1; @@ -193,7 +363,7 @@ test('sequence numbers must be positive and unique', async () => { ); }); -test('formulas are limited to the row-local age and expiry template formulas', async () => { +test('formulas are limited to the row-local age, issue-date and expiry template formulas', async () => { const networkFormula = await syntheticWorkbook((workbook) => { workbook.getWorksheet('Sheet1')!.getCell('G3').value = { formula: 'WEBSERVICE("https://invalid.example")', @@ -240,6 +410,19 @@ test('formulas are limited to the row-local age and expiry template formulas', a 'roster_workbook_formula_result_missing', { row: 3, column: 12 } ); + + const issueDateWithoutResult = await syntheticWorkbook((workbook) => { + const sheet = workbook.getWorksheet('Sheet1')!; + sheet.getCell('K3').value = { + formula: 'EDATE(L3,-10*12)+1' + }; + sheet.getCell('L3').value = new Date(Date.UTC(2035, 0, 1)); + }); + await expectRosterError( + () => normalizePassengerRosterWorkbook({ content: issueDateWithoutResult, fileName: 'synthetic.xlsx', contentType: 'application/octet-stream' }), + 'roster_workbook_formula_result_missing', + { row: 3, column: 11 } + ); }); test('macros, external relationships and ambiguous sheets fail closed', async () => { diff --git a/dist/lwlt-lifecycle-0.5.125.skill b/dist/lwlt-lifecycle-0.5.125.skill index e38459f..1ddc9f7 100644 Binary files a/dist/lwlt-lifecycle-0.5.125.skill and b/dist/lwlt-lifecycle-0.5.125.skill differ diff --git a/dist/release-manifest.json b/dist/release-manifest.json index 70234d5..bc363a5 100644 --- a/dist/release-manifest.json +++ b/dist/release-manifest.json @@ -1,10 +1,10 @@ { "manifest_version": 1, - "generated_on": "2026-08-30", + "generated_on": "2026-09-01", "baselines": { "chrome_extension": "0.5.163", "skills": "0.5.125", - "business_instruction_docx": "0.5.123", + "business_instruction_docx": "0.5.125", "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" }, "artifacts": [ @@ -37,7 +37,7 @@ "version": "0.5.125", "path": "dist/lwlt-lifecycle-0.5.125.skill", "source": "agent设计规范/skills/lwlt-lifecycle", - "sha256": "8cc5d94d97abbc68388bf60d4b46ce4cb1aa947dfb174831c0ad2afc421002f0" + "sha256": "d964c0a9e482f82757fea25d4713f0c81feb2704486583dfa84f2336ecd930c0" }, { "kind": "skill", @@ -57,12 +57,12 @@ }, { "kind": "business_instruction_docx", - "version": "0.5.123", - "path": "dist/老挝联泰AI指令表-0.5.123.docx", + "version": "0.5.125", + "path": "dist/老挝联泰AI指令表-0.5.125.docx", "source": "agent设计规范/templates/business-input-templates.md", "builder": "tools/build_business_instruction_docx.py", - "sha256": "8d94b1f4e3d1cfccf62c1b7d420cbcebf8c915844ae95fa90840963b32b1cccb", - "source_sha256": "8db16b3dce7e3dd8aa88a6273a9a90bbc3c98a087cb863bf0eeaaa5bb3a371ca", + "sha256": "a0c2fd5f808e3d3f2ac211a652a59f2e435d3824df49c51f763ca4beb7d9b97d", + "source_sha256": "27600fe9bcb0de3898a99550428a9890dd350c701ba704fb3940ca966088dcd9", "builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1" } ] diff --git a/dist/老挝联泰AI指令表-0.5.125.docx b/dist/老挝联泰AI指令表-0.5.125.docx new file mode 100644 index 0000000..78281fb Binary files /dev/null and b/dist/老挝联泰AI指令表-0.5.125.docx differ diff --git a/mappings/lifecycle.mapping.json b/mappings/lifecycle.mapping.json index 335a0cd..fb49d99 100644 --- a/mappings/lifecycle.mapping.json +++ b/mappings/lifecycle.mapping.json @@ -195,7 +195,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 source row has 证件类型 exactly 护照", - "the workbook header is exactly row 2; row 1 is group metadata and never enters canonical TSV", + "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 unknown or unheaded data columns are rejected; 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" ], "native_defects": [ diff --git a/tools/repository-hygiene.test.mjs b/tools/repository-hygiene.test.mjs index ba2ca19..142950d 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\.123\s*$/mu); + assert.match(template, /^交付版本:0\.5\.125\s*$/mu); assert.match(template, /^适用范围:当前 18 项运营业务输入;执行能力以生命周期发布门槛为准。\s*$/mu); assert.doesNotMatch(template, /^事实基线:/mu); assert.equal(tsconfig.compilerOptions.outDir, '.build');