merge: integrate roster workbook header compatibility
# Conflicts: # control-plane/README.md
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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` 附件确定性规范化;附件到齐前任务保持等待。
|
||||
|
||||
@@ -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 和酒店其他字段阻断 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 原联系人信息。正文里的`领队`字段仍只是订单定位的选填补充关键词,不是联系人写入来源。
|
||||
|
||||
|
||||
@@ -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` 名单工作簿。
|
||||
|
||||
#### 示例
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
7
archive/releases/2026-09-01/README.md
Normal file
7
archive/releases/2026-09-01/README.md
Normal 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 字段名及已批准名单别名。
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
archive/releases/2026-09-01/老挝联泰AI指令表-0.5.124.docx
Normal file
BIN
archive/releases/2026-09-01/老挝联泰AI指令表-0.5.124.docx
Normal file
Binary file not shown.
@@ -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` 只是临时缓存。
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
'测试甲',
|
||||
'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<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 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 () => {
|
||||
|
||||
BIN
dist/lwlt-lifecycle-0.5.125.skill
vendored
BIN
dist/lwlt-lifecycle-0.5.125.skill
vendored
Binary file not shown.
14
dist/release-manifest.json
vendored
14
dist/release-manifest.json
vendored
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
BIN
dist/老挝联泰AI指令表-0.5.125.docx
vendored
Normal file
BIN
dist/老挝联泰AI指令表-0.5.125.docx
vendored
Normal file
Binary file not shown.
@@ -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": [
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user