merge: integrate roster workbook header compatibility

# Conflicts:
#	control-plane/README.md
This commit is contained in:
inman
2026-09-02 12:06:27 +08:00
22 changed files with 569 additions and 89 deletions

View File

@@ -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.

View File

@@ -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 events 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.

View File

@@ -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` 附件确定性规范化;附件到齐前任务保持等待。

View File

@@ -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 和酒店其他字段阻断 |

View File

@@ -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 TSVERP 初始 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 TSVERP 初始 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 |

View File

@@ -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`

View File

@@ -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 原联系人信息。正文里的`领队`字段仍只是订单定位的选填补充关键词,不是联系人写入来源。

View File

@@ -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` 名单工作簿
#### 示例

View File

@@ -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` |

View File

@@ -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 字段名及已批准名单别名。

View File

@@ -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"
}
]
}

View File

@@ -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` 只是临时缓存。

View File

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

View File

@@ -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<SourceField, number>> & Record<RequiredSourceField, number>;
/**
* 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<readonly [string, SourceField]> = [
['序号', '序号'],
['姓名', '姓名'],
['英文姓名', '英文姓名'],
['NAME', '英文姓名'],
['性别', '性别'],
['身份证号码', '身份证号码'],
['身份证', '身份证号码'],
['出生日期', '出生日期'],
['年龄', '年龄'],
['出生地', '出生地'],
['证件类型', '证件类型'],
['护照号码', '护照号码'],
['证件号码', '护照号码'],
['签发地', '签发地'],
['签发日期', '签发日期'],
['签发日', '签发日期'],
['有效期', '有效期'],
['电话', '电话'],
['备注', '备注']
];
const SOURCE_FIELD_BY_HEADER = new Map<string, SourceField>(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<PassengerRosterWorkbookErrorCode, string> = {
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<PassengerRosterWorkbookErrorCode, string> = {
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<SourceField, number>();
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<Record<SourceField, number>> = {};
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<number>): string[] {
const sequence = canonicalSequence(row.getCell(1));
function canonicalRow(
row: ExcelJS.Row,
date1904: boolean,
seenSequences: Set<number>,
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<number>();
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'))

View File

@@ -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<void>;
@@ -57,6 +64,29 @@ async function syntheticWorkbook(...mutations: WorkbookMutation[]): Promise<Buff
return Buffer.from(await workbook.xlsx.writeBuffer());
}
async function erpHeaderWorkbook(...mutations: WorkbookMutation[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('ERP名单');
sheet.addRow([...ERP_HEADERS]);
sheet.addRow([
1,
'测试甲',
'ALPHATEST',
'男',
'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<unknown>,
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 shifted = await syntheticWorkbook((workbook) => {
workbook.getWorksheet('Sheet1')!.insertRow(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
};
});
await expectRosterError(
() => normalizePassengerRosterWorkbook({
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) => {
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'
}),
'roster_workbook_header_row_invalid',
{ row: 3 }
});
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: 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 () => {

Binary file not shown.

View File

@@ -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"
}
]

Binary file not shown.

View File

@@ -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": [

View File

@@ -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');