diff --git a/.project-docs/30-worklog/tasks/20260916-arrangement-ledger-01a0858b.md b/.project-docs/30-worklog/tasks/20260916-arrangement-ledger-01a0858b.md new file mode 100644 index 0000000..a923306 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260916-arrangement-ledger-01a0858b.md @@ -0,0 +1,48 @@ +# 团队安排记录与回执汇总 + +## Scope / 用户决定 + +- 当前账号 + 完整团号隔离汇总导游、用车、酒店、大交通、其他/备案五类安排。 +- 仅使用本系统成功执行记录,不查询或同步 ERP 外部修改;结果不确定不计作成功。 +- 保存安排明细及变更历史,回执展示已安排/未安排;多条安排的已安排表示至少一条有效记录。 +- 强制删除任务和归档均保留独立安排明细与历史;原任务删除后仍可保留来源编号和成功快照。 +- 本次实现服务端记录与回执汇总,详情展示入口后续扩展;不执行 ERP 写入、部署或重启。 + +## Worktree gate + +- Mode: Feature +- Branch: main +- Worktree: /Users/andy/IdeaProjects/LWLT-AIBOT +- Base commit: 364ef4ae304630f2fa028db52170a9cb38a3fbd3 +- Existing changes: untracked .idea/、dist/ltjt-order-assistant-0.5.185.zip,均保留且不触碰。 +- Other worktrees: 无。 +- Overlap result: Clear,既有改动与服务端记录/回执范围不重叠。 +- 单线程工作,无子 Agent。 + +## Plan / Progress + +- 已完成。迁移 `025_arrangement_ledger` 新增按组织/不可变任务账号隔离的初始化记录与成功事件明细,源任务/执行编号不设置 task 外键;任务物理删除保留明细与成功快照,标注来源任务已删除。 +- 明细使用现有字段密钥加密,保存实际选中资源、已验证日期/项目/数量/备注、原始安排字段和成功依据;团号规范化后完整匹配,保留后缀差异。数据库复合外键限制组织归属,并禁止管理员作为明细 owner。 +- `recordExecutionResult` 通过原执行权、终态、回执验证后,在原事务内写入事件并冻结五类安排汇总。每个执行编号最多记录一次,结果不确定、失败或解析成功不计入;只读核验最终成功按原执行开始时间排序,避免迟到旧结果覆盖后续安排。 +- 酒店/用车/大交通/其他按已验证 ERP 行编号追踪,清除只影响对应行;导游按真实单例处理。窄变更保留未提供的旧详情,已验证空值仍能覆盖,历史快照不变。 +- 本账号首次成功入账或首次强制删除前,分页补入仍保留的历史成功安排(包含归档任务);不读取 ERP,不补入其他账号、失败或无成功依据的记录,不改写旧回执。已物理删除的历史任务无源数据,无法恢复;无 ERP 行编号的旧证据保留独立执行记录,不猜测其与其他行的关联。 +- 平台与 AgentBus 复用现有成功消息,追加“本系统安排情况(当前账号)”;同一类型至少一条有效记录即已安排,不表示行程覆盖齐全。回执读取重建保持生成时汇总且不重复追加。 +- 更新强制删除确认说明,明确安排明细、历史和成功凭据保留。详情页面/新查询入口未在本次扩展,数据已独立持久化,可供后续实现。 +- Chrome 插件、输入模板、Skill 和发布包均未修改;无需新插件版本。 + +## Validation / Result + +- `node --run check`、`node --run build`、`git diff --check` 通过。 +- 控制面测试 266/266(新增 13 项安排记录测试);最终相关回归 21/21(包含 PG Date 毫秒精度、窄修改字段保留)。 +- 原工作区 `check:repo` 的 2 项失败来自既有 `.idea/`、`.DS_Store` 和旧证据 `.log`;这些文件保持原样。原 sandbox 的 legacy 另外 2 项 HTTP 测试因 localhost listen EPERM 失败。 +- 在只含当前 tracked 源码、新增文件和 release manifest 交付物的临时副本中复验:`check:repo` 10/10,允许临时 localhost 监听的 `test:legacy` 411/411;未修改检查规则、未清理未知用户文件。 +- 使用独立临时 PostgreSQL 16 数据库和随机 schema 执行现有 24 个迁移文件(编号 019 退役,最新 025),通过真实结果落库/平台和 AgentBus 消息、账号/组织隔离、重复结果去重、旧回执冻结、删除保留、按行清除、事务回滚、首次删除前归档历史补入以及管理员/跨组织 owner 约束验证。 +- 可复用验证入口:`tools/arrangement-ledger-postgres-smoke.mjs`,要求显式 `LTJT_ARRANGEMENT_TEST_DATABASE_URL` 指向 localhost 的 `ledger_test`,不读取 `.env`。测试 schema 和本次临时容器均已清理。 +- 证据:[验证记录](../../../archive/evidence/2026-09-16/arrangement-ledger-01a0858b/README.md)(仓库实际路径为 `archive/evidence/2026-09-16/arrangement-ledger-01a0858b/`)。 +- 未部署、未重启本地/生产服务、未运行项目数据库迁移、未触发 ERP 写入或外部发送。上线需先完成 025 迁移;当前 `npm run dev`/`npm start` 已含迁移与 schema readiness 门禁。 + +## Promotion Candidates + +- `40-domain/business-rules.md` / `20-architecture/data-flow.md`:新增当前账号完整团号范围内的成功安排事件记录与五类回执汇总,明细加密独立存储。 +- AUTH-002 / `00-brief/success-criteria.md` / `20-architecture/system-overview.md`:强制删除仍删除任务所属运行记录,新增明确例外为用户授权保留的独立安排明细、历史与成功凭据;不可再笼统描述所有业务内容全部消失。用户已在本会话明确同意此例外。 +- `30-worklog/current-state.md`:新 schema readiness 为 025,历史成功按账号延迟补入,旧回执不回写;插件仍为 0.5.185。本 Feature 未改 canonical 快照。 diff --git a/.project-docs/30-worklog/tasks/20260916-transport-description-match-01a0858b.md b/.project-docs/30-worklog/tasks/20260916-transport-description-match-01a0858b.md new file mode 100644 index 0000000..377a26b --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260916-transport-description-match-01a0858b.md @@ -0,0 +1,44 @@ +# 大交通车票说明匹配与候选提示 + +## Scope / 用户意图 + +- 修复车票说明在车次后紧接时刻时,空格归一化把 `C91 12:52` 连为 `C9112:52` 而误判零候选的问题。 +- 供应商和车票说明共同匹配同一候选;唯一时继续,多条时停止并展示有限候选,帮助用户补充车次或时刻。 +- 不改变省略车票说明时既有的首条候选规则,不扩大到自动选任意车次,不执行真实 ERP 写入、重试任务、重启或部署。 +- 前一任务的安排记录功能已完成,本次保留其未提交代码与验证记录。 + +## Worktree gate + +- Mode: Feature;单线程,无子 Agent。 +- Branch: main +- Worktree: /Users/andy/IdeaProjects/LWLT-AIBOT +- Base commit: 364ef4ae304630f2fa028db52170a9cb38a3fbd3 +- Existing changes: 前一已完成任务 `20260916-arrangement-ledger-01a0858b` 的控制面、测试、平台删除提示、任务/证据记录;既有 `.idea/` 与清单所引用的 `dist/ltjt-order-assistant-0.5.185.zip`。全部已知,保留既有内容。 +- Other worktrees: 无。 +- Overlap result: Clear。插件与相关测试无既有改动;平台仅递增最低插件版本,避开前一任务的删除提示。 + +## Plan + +1. 为说明匹配保留原字符数字边界,同时兼容空格、括号、大小写和全角输入;新增/变更共用同一规则。 +2. 零候选给出明确资源检索提示;多候选只展示有限供应商/说明,保留原生唯一性与写入前阻断。 +3. 补充业务回归(路线、车次、时刻、不同供应商、C91/C911、车辆数字边界、重复候选和旧省略行为)。 +4. 同步扩展版本、平台最低版本、mapping、测试及发布 ZIP/清单;执行仓库要求的验证,逐文件核对发布包。 + +## Progress / Validation + +- 修复前已使用当前函数复现:`拉邦-万荣 C91` 为零候选,带完整时刻为一条;仅路线在多车次数据下保持多候选阻断。 +- 已完成:说明检索继续忽略空格、括号、大小写及全角差异,数字边界通过归一化字符到原标签位置的映射检查;车次后的空格/括号与时间分隔保持有效,连续更长数字仍拒绝匹配。共用的用车类型筛选也完成数字边界回归。 +- 大交通新增与变更在供应商和说明的同一条候选上筛选。仅路线且唯一允许;仅路线/车次且多条,或不同供应商/不同目的地不满足条件时均不选择、不修改表单;填写说明不会退回首条,未填说明的旧行为保留。 +- 路由失败区分零匹配、候选未加载和多匹配。多匹配仅返回最多 5 条已匹配的供应商/说明,每字段最多 120 字符并移除控制字符,不返回内部 ID、整行原始元组或价格列。仅失败歧义才添加该候选证据,已有成功回执与安排记录未改变。 +- 扩展 `0.5.186`、MAIN-world 版本、平台最低版本、mapping 与固定版本测试同步;旧 `0.5.185` 包和清单按原字节归档,新 ZIP 的 20 个文件逐字节匹配源文件。Skill、输入模板和 DOCX 契约未修改。 +- 定向回归 31/31(本次新增 6 项覆盖新增/变更、时刻邻接、全角/括号/空白、C91/C911、不同供应商/目的地、路线唯一/歧义、重复候选、诊断边界和既有原生联动);控制面 266/266;类型检查与构建通过;`git diff --check` 通过。 +- 初次完整回归中遗漏的旧版本正则断言已同步修正。原工作区整洁检查仍有 2 项既有 `.idea/`、`.DS_Store`、旧证据日志失败;普通 sandbox 下两项临时 HTTP 服务器监听被 EPERM 阻断。未删除/移动这些用户工作区文件,未修改检查规则。 +- 在当前源码、任务文件和清单交付物的临时干净副本中,以允许 localhost 测试监听的环境完成 `test:legacy` 417/417、`check:repo` 10/10。副本不含 `.env`,测试未连接真实 ERP。临时日志只保留在系统临时目录。 +- 完成单线程最终只读差异审查;未创建子 Agent。保留前一任务的所有控制面实现和平台删除提示。 +- 验证证据:[本地验证记录](../../../archive/evidence/2026-09-16/transport-description-match-01a0858b/README.md)(仓库路径 `archive/evidence/2026-09-16/transport-description-match-01a0858b/`)。 +- 未提交/推送、未部署/重启、未重载实际插件、未自动重试任务、未访问或写入真实 ERP。本修复无需新数据库迁移;使用新功能需同步更新插件和平台静态资源并刷新已有页面。 + +## Promotion Candidates + +- 将车票说明数字边界修复与有限候选提示纳入下次 canonical 集成快照,标明合成验证与真实 ERP 待验收的边界。现有输入契约(说明选填,提供后供应商 AND 说明唯一匹配)保持。 +- 后续截图诊断必须逐条核对目的地,不能将“万象”与“万荣”视为同一线路;是否输入充分要以当前 matcher 与实际候选共同验证,避免把实现缺陷解释成用户信息不够。 diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 0aa4e4a..37dcacd 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -103,7 +103,7 @@ const persistedExtensionResultVersions = new Map(); let taskCreateInProgress = false; const AUTO_HANDOFF_RETRY_MS = 30_000; -const REQUIRED_EXTENSION_VERSION = '0.5.185'; +const REQUIRED_EXTENSION_VERSION = '0.5.186'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; const RECONCILE_LABEL = '只读回查 ERP 现有结果'; @@ -5265,7 +5265,7 @@ function confirmTaskHardDelete(tasks) { ? `任务 ${selectedTasks[0].task_id}` : `所选 ${selectedTasks.length} 个任务`; return window.confirm( - `确认永久强制删除${scope}?\n\n任务、原始输入、生命周期、执行记录、附件和回执都会被物理删除,无法恢复。此操作不受“正在处理”或“等待 ERP 执行”状态限制。\n\n如果 ERP 已经开始写入,删除平台记录不会撤销 ERP 中已经发生的操作;系统只会向任务所属账号的在线插件发送停止与清理指令。已经由外部 Webhook 受理并可能到达组长微信群的任务摘要也无法撤回。` + `确认永久强制删除${scope}?\n\n任务、原始输入、生命周期、执行记录、附件和任务回执都会被物理删除,无法恢复。独立保存的团队安排明细、变更历史及成功凭据会保留,安排状态不受影响。此操作不受“正在处理”或“等待 ERP 执行”状态限制。\n\n如果 ERP 已经开始写入,删除平台记录不会撤销 ERP 中已经发生的操作;系统只会向任务所属账号的在线插件发送停止与清理指令。已经由外部 Webhook 受理并可能到达组长微信群的任务摘要也无法撤回。` ); } diff --git a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md index 17c12be..13c0aac 100644 --- a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md +++ b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md @@ -2,7 +2,8 @@ ## 当前基线 -- Chrome 插件:`0.5.185` +- Chrome 插件:`0.5.186` +- `0.5.186` 修复车票说明中车次与时刻的数字边界,明确供应商与说明联合唯一匹配及多候选提示;新增/变更本地合成回归通过,真实 ERP 验收待部署方完成。见[任务记录](../../../.project-docs/30-worklog/tasks/20260916-transport-description-match-01a0858b.md)。 - Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories` - 生命周期契约:`ltjt-lifecycle-v2.9-roster-leader-contact-2026-08` - 五个 Skill:`0.5.127`;运营 DOCX:`0.5.134` diff --git a/archive/evidence/2026-09-16/arrangement-ledger-01a0858b/README.md b/archive/evidence/2026-09-16/arrangement-ledger-01a0858b/README.md new file mode 100644 index 0000000..95ad704 --- /dev/null +++ b/archive/evidence/2026-09-16/arrangement-ledger-01a0858b/README.md @@ -0,0 +1,12 @@ +# 团队安排记录与回执汇总验证 + +本目录为 2026-09-16 的不可变验证证据,不作为当前业务规则;当前实现以源码、迁移和任务记录为准。未保存真实客户数据、配置、连接密钥或业务附件。 + +- 源任务:`.project-docs/30-worklog/tasks/20260916-arrangement-ledger-01a0858b.md`。 +- 范围:按当前账号和完整团号记录成功安排明细,冻结回执五类状态;归档/强制删除保留明细及成功凭据。 +- TypeScript check/build 与 diff whitespace 检查通过;控制面 266/266,最终聚焦回归 21/21。 +- 干净源码副本:仓库检查 10/10,legacy 411/411。原目录既有 IDE/OS 元数据和历史日志会触发整洁检查;原 sandbox 不允许 HTTP 测试临时 localhost listen。副本保留所有受版本管理源码、任务新增文件以及 release manifest 的交付物,不读/复制 `.env`,原文件不变。 +- PostgreSQL 16 独立临时数据库:从 001 至 025 的全部现有迁移文件通过(019 已退役,共 24 个)。真实事务验证:成功落库与平台/AgentBus 回执、重复去重、组织及账号隔离、回执快照冻结、删除任务不级联删除业务明细、按行清除、失败完整回滚、首次删除前历史补入、管理员与跨组织 owner 拒绝。 +- 临时 schema 与容器均清理;未对本地项目数据库、生产数据库或 ERP 执行操作,未部署/重启服务。 +- `tools/arrangement-ledger-postgres-smoke.mjs` 提供可复用显式本地临时数据库验证。 +- `verification.json` 保存结果和本次相关源码哈希;不包含运行日志。 diff --git a/archive/evidence/2026-09-16/arrangement-ledger-01a0858b/verification.json b/archive/evidence/2026-09-16/arrangement-ledger-01a0858b/verification.json new file mode 100644 index 0000000..9a88d1b --- /dev/null +++ b/archive/evidence/2026-09-16/arrangement-ledger-01a0858b/verification.json @@ -0,0 +1,54 @@ +{ + "date": "2026-09-16", + "base_commit": "364ef4ae304630f2fa028db52170a9cb38a3fbd3", + "check": "PASS", + "build": "PASS", + "control_plane": { + "passed": 266, + "failed": 0 + }, + "focused_final": { + "passed": 21, + "failed": 0 + }, + "clean_snapshot": { + "repository": { + "passed": 10, + "failed": 0 + }, + "legacy": { + "passed": 411, + "failed": 0 + } + }, + "postgres_smoke": { + "ok": true, + "migrations": 24, + "verified": [ + "real migrations", + "result ingestion and public/AgentBus receipts", + "deduplication", + "account/org isolation", + "frozen receipts", + "force-delete retention", + "row-specific clear", + "transaction rollback", + "archived historical backfill before delete", + "admin and cross-org ownership constraints" + ] + }, + "erp_write": false, + "deployment": false, + "project_db_migration": false, + "source_sha256": { + "control-plane/migrations/025_arrangement_ledger.sql": "df493ca783113a4eb225657ef47f01eefd915b540b47e5f99f30140549125d51", + "control-plane/src/arrangement-ledger.ts": "75cd61d1e0fdfe418133f70ac2bd7498f3a396001cceb87e5db8ace3d544ca70", + "control-plane/src/task-service.ts": "81a42f623ff670f654b83d2953f066156ec937481dd744b98e5e9c0e9a167f98", + "control-plane/src/reply-contract.ts": "c744551327a2cb423cb9824ce2cd9014d2e00b503fd0bc969a38ad27bbe3d63a", + "control-plane/src/db.ts": "dd60baa03c450d28b554c3cdf1548d1d0a80bac18c32b92b2e1024ac692c637a", + "control-plane/test/arrangement-ledger.test.ts": "b083fd277f10a48723183d5c202b056b695b91127cfc1b73a10a5bc29fda8b08", + "control-plane/test/control-plane.test.ts": "890d89916d24d478edd86e033d35af23c90cf280d365bb6318bd65cb590266d7", + "LianSyn-platform/app.js": "0cdd8f059dc6ca0850187d05cf76f214dd104a95cee4876278a3a6d651a1044d", + "tools/arrangement-ledger-postgres-smoke.mjs": "555a83907a4c4c161d818e6d6acc9e22e8d412ddf75d980d24a38708a137441c" + } +} diff --git a/archive/evidence/2026-09-16/transport-description-match-01a0858b/README.md b/archive/evidence/2026-09-16/transport-description-match-01a0858b/README.md new file mode 100644 index 0000000..17c1c93 --- /dev/null +++ b/archive/evidence/2026-09-16/transport-description-match-01a0858b/README.md @@ -0,0 +1,14 @@ +# 大交通说明匹配修复:本地验证证据 + +本目录为日期化、只读历史证据,不定义当前业务规则;当前源码、mapping 与 `dist/release-manifest.json` 是发布依据。 + +- 修复前:空格删除后 `C91 12:52` 合并成 `C9112:52`,车次过滤误认为命中了更长数字,返回零候选。 +- 修复后:归一化检索保留原位置数字边界;线路+车次可唯一选中不同目的地/车次候选,连续更长数字仍不匹配。只写线路在唯一时允许、多条时停止,未填说明的原行为不变。 +- 失败提示分别说明没有匹配、候选未加载和候选不唯一;歧义最多显示 5 个供应商/说明,字段长度和控制字符受限,不返回 ID、价格列或完整元组。 +- 合成浏览器页面回归覆盖新增、变更、实际原生联动前复核,以及候选变化/多条时不操作字段;未执行真实 ERP 测试。 +- 定向 31/31,控制面 266/266,完整 legacy 417/417(干净源码副本),仓库检查 10/10(干净源码副本),类型检查、构建及差异空白检查通过。 +- 原工作区已有 IDE 元数据及旧日志影响整洁检查,普通 sandbox 禁止两项 localhost 测试监听;保留用户文件,临时副本使用获准的本地测试监听,不更改断言来回避问题。 +- 插件 0.5.186 发布包的 20 个文件与源文件逐字节一致;前版包和清单原样保存在 `archive/releases/2026-09-16/transport-description-match-01a0858b/`。 +- 未部署、未重启、未重载实际扩展、未重试业务任务、未读写生产 ERP。 + +机器可读版本、源码哈希和验证结果见 [verification.json](verification.json)。 diff --git a/archive/evidence/2026-09-16/transport-description-match-01a0858b/verification.json b/archive/evidence/2026-09-16/transport-description-match-01a0858b/verification.json new file mode 100644 index 0000000..fbf9aac --- /dev/null +++ b/archive/evidence/2026-09-16/transport-description-match-01a0858b/verification.json @@ -0,0 +1,53 @@ +{ + "task_id": "20260916-transport-description-match-01a0858b", + "date": "2026-09-16", + "extension_version": "0.5.186", + "artifact": { + "kind": "chrome_extension", + "version": "0.5.186", + "path": "dist/ltjt-order-assistant-0.5.186.zip", + "source": "chrome-extension/ltjt-order-assistant", + "sha256": "c79d108ce4cbed85a856b336e1643fd990d81f21d16a0bbd66be27510d1c2568" + }, + "source_sha256": { + "chrome-extension/ltjt-order-assistant/inpage.js": "6c1b62283834bf3b815b09532a975a2c62e6342f4636b55e105eb181ec75f569", + "chrome-extension/ltjt-order-assistant/background.js": "6e19bb6fa3fe06549dd073c191cd37c1dcbfb3358a64530b06e8a5c712235d4a", + "chrome-extension/ltjt-order-assistant/manifest.json": "029e919535b8545bb09fe99718e6fd900cc704dc1de97e449cd6efd2001bf201", + "chrome-extension/ltjt-order-assistant/team-batch-inpage.js": "b4e5d230ecafe32fbbc9bfc321b2b76b4627e3ed0a1c78f392283d134f28b6ea", + "chrome-extension/ltjt-order-assistant/README.md": "2bbbe76ba47c6905a6a6abdceb8578c9d307156d922d040361eef1e5e176b3db", + "LianSyn-platform/app.js": "6043f0b84777d3c976c1330f0b0e8e11b894cfbdff754f28613567e5cfed7340", + "mappings/lifecycle.mapping.json": "00873f0e63009e39e4a0dda6332aeca318891c338194f923e9439c48bfa8d577", + "tools/arrangement-update-unified.test.mjs": "ee2de0065a9beee105bec174bec04ada3bb781f5af56f9901d68538ea893b15f", + "tools/lifecycle-contract.test.mjs": "26764fa805c08bbdfdde9fe8b91fea7f7c7a3e5d2714e22856fc99302106cc7b", + "dist/release-manifest.json": "61ea6db9044928e68a7f31d7713ecbf415cd73b21fda37f8921002663bd081f2" + }, + "checks": { + "targeted_tests": { + "passed": 31, + "failed": 0 + }, + "control_plane_tests": { + "passed": 266, + "failed": 0 + }, + "legacy_tests_clean_source": { + "passed": 417, + "failed": 0 + }, + "repository_checks_clean_source": { + "passed": 10, + "failed": 0 + }, + "typecheck": "passed", + "build": "passed", + "diff_check": "passed", + "zip_source_files_verified": 20, + "final_review": "PASS (single-agent read-only review)" + }, + "limitations": [ + "Original worktree hygiene fails on pre-existing IDE metadata and ignored historical logs; these were preserved.", + "Temporary clean copy and permitted localhost listeners used for complete legacy regression.", + "Synthetic test evidence only; no real ERP access, task retry, extension reload, service restart or deployment." + ], + "prior_release_preserved": "archive/releases/2026-09-16/transport-description-match-01a0858b" +} diff --git a/archive/releases/2026-09-16/transport-description-match-01a0858b/README.md b/archive/releases/2026-09-16/transport-description-match-01a0858b/README.md new file mode 100644 index 0000000..cb81b90 --- /dev/null +++ b/archive/releases/2026-09-16/transport-description-match-01a0858b/README.md @@ -0,0 +1,3 @@ +# Historical release before transport description matching fix + +Historical, read-only material; not current business rules or release instructions. Extension 0.5.185 and its original release manifest are preserved byte-for-byte. Current deliverables are defined by `dist/release-manifest.json`. diff --git a/archive/releases/2026-09-16/transport-description-match-01a0858b/ltjt-order-assistant-0.5.185.zip b/archive/releases/2026-09-16/transport-description-match-01a0858b/ltjt-order-assistant-0.5.185.zip new file mode 100644 index 0000000..9b362e2 Binary files /dev/null and b/archive/releases/2026-09-16/transport-description-match-01a0858b/ltjt-order-assistant-0.5.185.zip differ diff --git a/archive/releases/2026-09-16/transport-description-match-01a0858b/release-manifest-before.json b/archive/releases/2026-09-16/transport-description-match-01a0858b/release-manifest-before.json new file mode 100644 index 0000000..d9d6a6d --- /dev/null +++ b/archive/releases/2026-09-16/transport-description-match-01a0858b/release-manifest-before.json @@ -0,0 +1,69 @@ +{ + "manifest_version": 1, + "generated_on": "2026-09-15", + "baselines": { + "chrome_extension": "0.5.185", + "skills": "0.5.127", + "business_instruction_docx": "0.5.134", + "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" + }, + "artifacts": [ + { + "kind": "chrome_extension", + "version": "0.5.185", + "path": "dist/ltjt-order-assistant-0.5.185.zip", + "source": "chrome-extension/ltjt-order-assistant", + "sha256": "42d12ea2a9e090af3202b683f66a4418fa7aac199686bfb7840251e1fb9b899c" + }, + { + "kind": "skill", + "name": "lwlt-arrangement", + "version": "0.5.127", + "path": "dist/lwlt-arrangement-0.5.127.skill", + "source": "agent设计规范/skills/lwlt-arrangement", + "sha256": "ae3fc66965d2a7968aeaa2ecf371d75e5365ef4cc4dfd8ef080f9343e0d0f09c" + }, + { + "kind": "skill", + "name": "lwlt-confirmation", + "version": "0.5.127", + "path": "dist/lwlt-confirmation-0.5.127.skill", + "source": "agent设计规范/skills/lwlt-confirmation", + "sha256": "c2f3689d8a0b4d6b250bb2cfce572fe3a9fca3f5519ea5fae5ef0d382b44dae4" + }, + { + "kind": "skill", + "name": "lwlt-lifecycle", + "version": "0.5.127", + "path": "dist/lwlt-lifecycle-0.5.127.skill", + "source": "agent设计规范/skills/lwlt-lifecycle", + "sha256": "f0b5cdea11e65657fb9b10e4f7d020769d5477cc8ceddfcfd6a284513345f6bc" + }, + { + "kind": "skill", + "name": "lwlt-newbooking", + "version": "0.5.127", + "path": "dist/lwlt-newbooking-0.5.127.skill", + "source": "agent设计规范/skills/lwlt-newbooking", + "sha256": "b64593d88145966cd4e702d2532ffe3ea85052e8d56fbe75d503a19ababa1715" + }, + { + "kind": "skill", + "name": "lwlt-updating", + "version": "0.5.127", + "path": "dist/lwlt-updating-0.5.127.skill", + "source": "agent设计规范/skills/lwlt-updating", + "sha256": "54f10dd6bd6d2ddf47c700f1bca4b1fcd3346dc6143bf27118a23587b1b11736" + }, + { + "kind": "business_instruction_docx", + "version": "0.5.134", + "path": "dist/老挝联泰AI指令表-0.5.134.docx", + "source": "agent设计规范/templates/business-input-templates.md", + "builder": "tools/build_business_instruction_docx.py", + "sha256": "b6c1f677b93cb4f7356cfb26c6af01860587be1288ffe9e8aff8295929074490", + "source_sha256": "7446e4560e2eaaf6f490c8ed7523d6aadcee6f94f58cb620f7e71db674404471", + "builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1" + } + ] +} diff --git a/chrome-extension/ltjt-order-assistant/README.md b/chrome-extension/ltjt-order-assistant/README.md index 8e0a6fe..32bfeff 100644 --- a/chrome-extension/ltjt-order-assistant/README.md +++ b/chrome-extension/ltjt-order-assistant/README.md @@ -11,9 +11,13 @@ Chrome Manifest V3 扩展,在用户已登录的 LTJT ERP 页面内执行经过 ## 当前版本 -当前源码版本为 `0.5.185`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。 +当前源码版本为 `0.5.186`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。 -0.5.185 当前重点: +0.5.186 当前重点: + +- 大交通选填车票说明在忽略空格/括号时保留原数字边界,修复 `C91 12:52` 无法用 `C91` 匹配的问题,继续阻止 `C91` 命中 `C911`。供应商与说明仍须匹配同一唯一候选;仅路线也可在唯一时使用,歧义时停止并显示最多 5 条供应商/说明,不暴露内部 ID 或价格。新增/变更以及原生联动前复核使用同一规则。零候选与候选未加载分别提示;未填车票说明的旧行为保持。回归为本地合成验证,真实 ERP 验收待部署方执行;需更新插件及平台最低版本,本修复无数据库迁移。 + +继续保留 0.5.185 的查询恢复修复: - 目标只读查询遇到 Chrome 主框架移除时,在同一执行仍处于写入前运行状态下,最多等待 8 秒确认同一主文档连续两次加载完成,再重新注入适配器并只读重查一次。持续切换、权限错误、取消或写入边界变化时停止;业务无匹配和多匹配不重试,原生保存不重试。异常按实际查询、路由准备或预检阶段记录;查询失败保留步骤和当时自动恢复时间快照,不记录业务值或恢复正常心跳刷屏。本地合成回归覆盖,真实 ERP 验收待部署方执行。需更新插件及平台最低版本,无数据库迁移。 diff --git a/chrome-extension/ltjt-order-assistant/background.js b/chrome-extension/ltjt-order-assistant/background.js index a682aa3..3746b0a 100644 --- a/chrome-extension/ltjt-order-assistant/background.js +++ b/chrome-extension/ltjt-order-assistant/background.js @@ -2552,6 +2552,33 @@ function erpResolutionFailure(operation, report = {}) { function lifecycleRouteFailure(operation, report = {}) { const blockers = Array.isArray(report.blockers) ? report.blockers.map(String) : []; + if (operation?.action === 'arrangement_transport') { + if (blockers.some((item) => ['arrangement_resource_lookup_missing', 'arrangement_resource_candidate_data_missing'].includes(item))) { + return { + errorCode: 'arrangement_resource_lookup_not_ready', + message: '未能读取 ERP 大交通结算单位候选,请确认页面加载完成后重试;未写入 ERP。' + }; + } + const resourceCount = blockers.find((item) => /^arrangement_resource_candidate_count:\d+$/.test(item)); + if (resourceCount) { + const count = Number(resourceCount.split(':').at(-1)); + const filtered = operation.data?.arrangement?.ticket_description !== undefined; + const criteria = filtered ? '同时匹配结算单位和车票说明' : '匹配结算单位搜索条件'; + const candidates = report.preflight?.resource_resolution?.candidates; + const text = (value) => String(value || '').replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 120); + const candidateText = count > 1 && Array.isArray(candidates) + ? candidates.slice(0, 5).map((row, index) => `${index + 1}. ${text(row?.resource)} ${text(row?.item)}`.trim()).join('\n') + : ''; + return { + errorCode: count === 0 ? 'arrangement_resource_not_found' : 'arrangement_resource_not_unique', + message: count === 0 + ? `ERP 中没有找到${criteria}的大交通候选,请核对供应商、线路和车次;未写入 ERP。` + : `ERP 中有 ${count} 条大交通候选${criteria},无法唯一选择;未写入 ERP。` + + (candidateText ? `\n候选${count > 5 ? '(仅显示前 5 条)' : ''}:\n${candidateText}` : '') + + '\n请在“车票说明”中补充可区分候选的线路、车次、时刻或票种;说明完全相同的候选请在 ERP 中核对。' + }; + } + } if (blockers.includes('arrangement_date_matcher_unavailable')) { return { errorCode: 'arrangement_date_matcher_unavailable', diff --git a/chrome-extension/ltjt-order-assistant/inpage.js b/chrome-extension/ltjt-order-assistant/inpage.js index e647327..c503938 100644 --- a/chrome-extension/ltjt-order-assistant/inpage.js +++ b/chrome-extension/ltjt-order-assistant/inpage.js @@ -2997,13 +2997,22 @@ } function candidateDescriptionMatches(item, query) { - const text = normalizedLookupSearchValue(String(item || '').normalize('NFKC')); + const original = String(item || '').normalize('NFKC').toLowerCase(); + const offsets = []; + let text = ''; + for (let index = 0; index < original.length; index += 1) { + if (/[\s【】[]\[\]()(){}<>《》]/u.test(original[index])) continue; + text += original[index]; + offsets.push(index); + } const keyword = normalizedLookupSearchValue(String(query || '').normalize('NFKC')); if (!keyword) return false; - // Numeric fragments must not match larger numbers, e.g. 15 vs 115 or D88 vs D888. + // Ignore formatting for lookup, but check numeric boundaries in the original + // label: C91 followed by " 12:52" is a train plus a time, not C9112. + // Contiguous larger numbers (15 vs 115, C91 vs C911) still must not match. for (let at = text.indexOf(keyword); at >= 0; at = text.indexOf(keyword, at + 1)) { - if (/\d/.test(keyword[0]) && /\d/.test(text[at - 1] || '')) continue; - if (/\d/.test(keyword.at(-1)) && /\d/.test(text[at + keyword.length] || '')) continue; + if (/\d/.test(keyword[0]) && /\d/.test(original[offsets[at] - 1] || '')) continue; + if (/\d/.test(keyword.at(-1)) && /\d/.test(original[offsets[at + keyword.length - 1] + 1] || '')) continue; return true; } return false; @@ -3074,6 +3083,12 @@ candidate: matches.length === 1 || firstMatchDefaulted ? matches[0] : null, match_count: matches.length, candidate_count: candidates.length, + ...(transport && ticketDescription !== undefined && matches.length > 1 ? { + candidates: matches.slice(0, 5).map((candidate) => ({ + resource: candidate.name.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 120), + item: candidate.item.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 120) + })) + } : {}), strategy: exact.length ? 'exact' : matches === tokenized && tokenized.length @@ -3299,7 +3314,10 @@ if (!coordinator.value || coordinator.mismatch) blockers.push('arrangement_guide_linked_coordinator_unresolved'); } delete arrangement.selection; - evidence = { mode: 'update', slot_index: slotIndex, current_row_count: 1, value_redacted: true }; + evidence = { + mode: 'update', slot_index: slotIndex, current_row_count: 1, value_redacted: !selected.candidates, + ...(transport && selected.candidates ? { candidates: selected.candidates, exact_match_count: selected.match_count } : {}) + }; } else if (hotel && arrangement.mode === 'update') { const selection = arrangement.selection; let slotIndex = 0; @@ -3403,8 +3421,9 @@ exact_match_count: selected.match_count, strategy: selected.strategy, selection: selected.selection, + ...(transport && selected.candidates ? { candidates: selected.candidates } : {}), coordinator_source: guide ? guideCoordinatorResolution(form, operation, form?.ownerDocument).source : '', - value_redacted: true + value_redacted: !selected.candidates }; } @@ -6600,7 +6619,7 @@ } window.LTJTOrderAssistant = { - version: '0.5.185', + version: '0.5.186', resolveNativeListSearchValues, lookupKeywordMatchesText, inspectLifecycleSearchCriteria: lifecycleSearchCriteria, diff --git a/chrome-extension/ltjt-order-assistant/manifest.json b/chrome-extension/ltjt-order-assistant/manifest.json index 616dffe..29b9a80 100644 --- a/chrome-extension/ltjt-order-assistant/manifest.json +++ b/chrome-extension/ltjt-order-assistant/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "联泰下单助手", - "version": "0.5.185", + "version": "0.5.186", "description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。", "permissions": [ "activeTab", diff --git a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js index 412fc9f..95809b7 100644 --- a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js +++ b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js @@ -944,7 +944,7 @@ window.LTJTOrderAssistant = { ...(window.LTJTOrderAssistant || {}), - version: '0.5.185', + version: '0.5.186', openTeamBatchForm, pingTeamBatchFrame, preflightTeamBatchNative, diff --git a/control-plane/migrations/025_arrangement_ledger.sql b/control-plane/migrations/025_arrangement_ledger.sql new file mode 100644 index 0000000..61cdd56 --- /dev/null +++ b/control-plane/migrations/025_arrangement_ledger.sql @@ -0,0 +1,40 @@ +-- Successful arrangement history belongs to an employee/group, not a task. +-- Source task/execution identifiers deliberately have NO task foreign key: +-- force deletion must not erase the business record or its receipt snapshot. +CREATE TABLE arrangement_ledger_owners ( + organization_id uuid NOT NULL REFERENCES organizations(id), + owner_user_id uuid NOT NULL, + backfilled_at timestamptz, + PRIMARY KEY (organization_id, owner_user_id), + FOREIGN KEY (organization_id, owner_user_id) REFERENCES users(organization_id, id) +); + +CREATE TABLE arrangement_ledger_events ( + id bigserial PRIMARY KEY, + organization_id uuid NOT NULL REFERENCES organizations(id), + owner_user_id uuid NOT NULL, + group_key text NOT NULL, + action text NOT NULL CHECK (action IN ( + 'arrangement_guide', 'arrangement_vehicle', 'arrangement_hotel', + 'arrangement_transport', 'arrangement_other' + )), + source_task_id text NOT NULL, + execution_id text NOT NULL, + effective_at timestamptz NOT NULL, + recorded_at timestamptz NOT NULL, + source_task_deleted_at timestamptz, + detail_ciphertext text NOT NULL, + UNIQUE (organization_id, owner_user_id, execution_id), + FOREIGN KEY (organization_id, owner_user_id) REFERENCES users(organization_id, id) +); +CREATE INDEX arrangement_ledger_group_idx ON arrangement_ledger_events + (organization_id, owner_user_id, group_key, effective_at, id); +CREATE INDEX arrangement_ledger_source_task_idx ON arrangement_ledger_events + (organization_id, owner_user_id, source_task_id); + +CREATE TRIGGER arrangement_ledger_owner_not_admin +BEFORE INSERT OR UPDATE OF organization_id, owner_user_id ON arrangement_ledger_owners +FOR EACH ROW EXECUTE FUNCTION reject_admin_task_principal('owner_user_id', 'arrangement_ledger_owner_not_admin'); +CREATE TRIGGER arrangement_ledger_event_owner_not_admin +BEFORE INSERT OR UPDATE OF organization_id, owner_user_id ON arrangement_ledger_events +FOR EACH ROW EXECUTE FUNCTION reject_admin_task_principal('owner_user_id', 'arrangement_ledger_event_owner_not_admin'); diff --git a/control-plane/src/arrangement-ledger.ts b/control-plane/src/arrangement-ledger.ts new file mode 100644 index 0000000..2f91466 --- /dev/null +++ b/control-plane/src/arrangement-ledger.ts @@ -0,0 +1,217 @@ +import type pg from 'pg'; +import type { AppConfig } from './config.js'; +import { decryptText, encryptText, sha256Text } from './crypto.js'; + +export const ARRANGEMENT_LABELS = { + arrangement_guide: '导游', arrangement_vehicle: '用车', arrangement_hotel: '酒店', + arrangement_transport: '大交通', arrangement_other: '其他/备案' +} as const; +export type ArrangementAction = keyof typeof ARRANGEMENT_LABELS; +type Json = Record; +type Client = Pick; +export interface ArrangementScope { organizationId: string; userId: string } +export interface ArrangementEvent { + version: 1; + group_number: string; + action: ArrangementAction; + mode: 'create' | 'update' | 'clear'; + record_key: string; + source_task_id: string; + execution_id: string; + effective_at: string; + recorded_at: string; + source_task_deleted_at?: string | null; + details: Json; + requested: Json; + evidence: Json; +} +export interface ArrangementSummary { + version: 1; + scope: 'current_account'; + group_number: string; + recorded_at: string; + items: Array<{ action: ArrangementAction; label: string; arranged: boolean; count: number }>; +} +function object(value: unknown): Json { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Json : {}; +} +function text(value: unknown): string { return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''; } +function first(...values: unknown[]): string { return values.map(text).find(Boolean) || ''; } +export function arrangementGroupNumber(value: unknown): string { + return text(value).normalize('NFKC').replace(/\s+/gu, '').toUpperCase(); +} +export function isArrangementAction(value: unknown): value is ArrangementAction { + return typeof value === 'string' && Object.hasOwn(ARRANGEMENT_LABELS, value); +} +function timestamp(value: unknown): string { + const date = value instanceof Date ? value : new Date(String(value || '')); + return Number.isFinite(date.getTime()) ? date.toISOString() : ''; +} +function dateValue(value: unknown): string { + return text(value).replace(/^(\d{4})-(\d{1,2})-(\d{1,2})$/, (_, y, m, d) => `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`); +} + +// Only confirmed, action-specific ERP evidence enters the ledger. Parser success, +// bare completed flags and uncertain results are never arrangements. +export function arrangementEventFromSuccess(input: { + taskId: string; executionId: string; effectiveAt: unknown; recordedAt: unknown; + operation: unknown; result: unknown; receipt: unknown; +}): ArrangementEvent | null { + const operation = object(input.operation); + if (!isArrangementAction(operation.action)) return null; + const result = object(input.result), report = object(result.report), receipt = object(input.receipt); + const requery = object(report.requery || result.requery); + const server = object(report.server_response || result.server_response); + if (result.status !== 'completed' || receipt.success !== true || server.completed !== true || requery.matched !== true + || requery.skipped === true || result.uncertain === true || result.manual_review_required === true + || report.manual_review_required === true + || [result.blockers, report.blockers].some(value => Array.isArray(value) && value.length)) return null; + const data = object(operation.data), arrangement = object(data.arrangement); + const refs = object(report.resolved_refs || result.resolved_refs); + const mode = text(arrangement.mode) || 'create'; + if (!['create', 'update', 'clear'].includes(mode)) return null; + const group = arrangementGroupNumber(first(refs.identifier, refs.group_no, receipt.group_number, object(data.existing_refs).identifier)); + const suppliedGroup = arrangementGroupNumber(object(data.existing_refs).identifier); + if (!group || (suppliedGroup && suppliedGroup !== group) || refs.kind === 'shared_child_order') return null; + const recordedAt = timestamp(input.recordedAt), effectiveAt = timestamp(input.effectiveAt) || recordedAt; + if (!recordedAt || !effectiveAt || !input.taskId || !input.executionId) return null; + const subpage = object(requery.subpage); + const target = object(subpage.arrangement_target || requery.arrangement_target || refs.arrangement_target); + const checks = (Array.isArray(subpage.checks) ? subpage.checks : Array.isArray(requery.checks) ? requery.checks : []).map(object); + const actual = (pattern: RegExp): string | undefined => { + const matches = checks.filter(check => check.matched === true && pattern.test(text(check.field)) && check.actual != null); + return matches.length === 1 ? text(matches[0].actual) : undefined; + }; + const rowId = first(target.row_id, object(arrangement.target).row_id); + // Guides are genuinely singleton. Indexed row position is not a stable identity. + const recordKey = operation.action === 'arrangement_guide' ? 'guide' + : rowId && rowId !== '0' ? `row:${rowId}` : `execution:${input.executionId}`; + const changes = object(arrangement.changes); + const quantity = first(target.quantity_value, actual(/^(?:jianshu|shuliang|tianshu)\d+$/u), changes.room_count, changes.quantity, arrangement.room_count, arrangement.quantity); + const details: Json = { row_id: rowId }; + // Absence is different from a verified empty value. Narrow updates must not + // discard details established by an earlier successful create/update. + const set = (key: string, value: unknown, date = false): void => { + if (value !== undefined && value !== null) details[key] = date ? dateValue(value) : value; + }; + set('resource_id', target.resource_id); + // Never label a requested search term as the actual selected resource. + set('resource_name', actual(/^(?:danwei|daoyou)\d+$/u)); + set('item', target.item_value ?? actual(/^(?:fangxing|chehao|shuoming)\d+$/u)); + if (operation.action === 'arrangement_hotel' || operation.action === 'arrangement_vehicle') { + set('start_date', actual(/^riqi\d+$/u) ?? arrangement.start_date, true); + set('end_date', actual(/^riqis\d+$/u) ?? changes.end_date ?? arrangement.end_date, true); + } else set('date', actual(/^riqi\d+$/u) ?? arrangement.date, true); + if (quantity && Number.isFinite(Number(quantity))) details.quantity = Number(quantity); + set('remark', actual(/^beizhu\d+$/u) ?? arrangement.remark); + set('phone', actual(/^dianhua\d+$/u)); + set('grade', actual(/^dengji\d+$/u)); + if (operation.action === 'arrangement_other') { + for (const [key, field] of Object.entries({ number: 'beianhao', date: 'beianri', entry_port: 'rujingkou', exit_port: 'chujingkou' })) { + set(`filing_${key}`, actual(new RegExp(`^${field}$`, 'u')) ?? object(arrangement.filing)[key], key === 'date'); + } + } + return { + version: 1, group_number: group, action: operation.action, mode: mode as ArrangementEvent['mode'], record_key: recordKey, + source_task_id: input.taskId, execution_id: input.executionId, effective_at: effectiveAt, recorded_at: recordedAt, + details, requested: arrangement, + evidence: { receipt, resolved_refs: refs, arrangement_target: target, checks, + server_response_completed: true, requery_matched: true } + }; +} + +export function currentArrangements(events: ArrangementEvent[]): ArrangementEvent[] { + const current = new Map(); + const seen = new Set(); + for (const event of [...events].sort((a, b) => a.effective_at.localeCompare(b.effective_at) + || a.recorded_at.localeCompare(b.recorded_at) || a.execution_id.localeCompare(b.execution_id))) { + if (seen.has(event.execution_id)) continue; + seen.add(event.execution_id); + const key = `${event.group_number}:${event.action}:${event.record_key}`; + if (event.mode === 'clear') current.delete(key); + else current.set(key, event.mode === 'update' ? { ...event, details: { ...current.get(key)?.details, ...event.details } } : event); + } + return [...current.values()]; +} + +export function arrangementSummary(events: ArrangementEvent[], groupNumber: string, recordedAt: string): ArrangementSummary { + const group = arrangementGroupNumber(groupNumber); + const current = currentArrangements(events.filter(event => event.group_number === group)); + return { version: 1, scope: 'current_account', group_number: group, recorded_at: recordedAt, + items: (Object.keys(ARRANGEMENT_LABELS) as ArrangementAction[]).map(action => { + const count = current.filter(event => event.action === action).length; + return { action, label: ARRANGEMENT_LABELS[action], arranged: count > 0, count }; + }) }; +} + +export function arrangementSummaryText(value: unknown): string { + const summary = object(value); + if (summary.version !== 1 || summary.scope !== 'current_account' || !Array.isArray(summary.items)) return ''; + const items = summary.items.map(object); + if (items.length !== 5 || Object.keys(ARRANGEMENT_LABELS).some(action => + items.filter(item => item.action === action && typeof item.arranged === 'boolean').length !== 1)) return ''; + return ['本系统安排情况(当前账号):', ...Object.entries(ARRANGEMENT_LABELS).map(([action, label]) => + `${label}:${items.find(item => item.action === action)?.arranged ? '已安排' : '未安排'}`), + '仅统计通过本系统成功执行的安排;已安排表示至少一条,不代表全行程已安排齐全。'].join('\n'); +} + +export class ArrangementLedger { + constructor(private readonly config: AppConfig) {} + + // Serialize only this employee's ledger, including lazy historical backfill. + // Callers already hold their task row lock; backfill never locks other tasks. + async prepare(client: Client, scope: ArrangementScope, receiptFromResult: (value: unknown) => Json | null): Promise { + if (!scope.organizationId || !scope.userId) throw new Error('arrangement_ledger_scope_missing'); + await client.query(`INSERT INTO arrangement_ledger_owners (organization_id, owner_user_id) + VALUES ($1, $2) ON CONFLICT DO NOTHING`, [scope.organizationId, scope.userId]); + const lock = await client.query(`SELECT backfilled_at FROM arrangement_ledger_owners + WHERE organization_id = $1 AND owner_user_id = $2 FOR UPDATE`, [scope.organizationId, scope.userId]); + if (lock.rows[0]?.backfilled_at) return; + let cursor = ''; + for (;;) { + const history = await client.query(`SELECT t.task_id, t.operation_ciphertext, t.operation, + t.execution_result_ciphertext, t.execution_result, t.success_receipt_at, t.updated_at, + a.id AS execution_id, a.started_at AS effective_at + FROM tasks t JOIN task_attempts a ON a.task_id = t.id AND a.phase = 'erp' + WHERE t.organization_id = $1 AND t.assigned_user_id = $2 AND t.status = 'completed' + AND t.task_id > $3 AND t.operation->>'action' = ANY($4::text[]) + ORDER BY t.task_id LIMIT 200`, [scope.organizationId, scope.userId, cursor, Object.keys(ARRANGEMENT_LABELS)]); + for (const row of history.rows) { + const decode = (encrypted: unknown, plain: unknown) => encrypted + ? JSON.parse(decryptText(this.config, text(encrypted))) : plain; + const result = decode(row.execution_result_ciphertext, row.execution_result); + const receipt = receiptFromResult(result); + const event = arrangementEventFromSuccess({ taskId: row.task_id, executionId: row.execution_id, + effectiveAt: row.effective_at, recordedAt: row.success_receipt_at || row.updated_at, + operation: decode(row.operation_ciphertext, row.operation), result, receipt }); + if (event) await this.insert(client, scope, event); + } + if (history.rows.length < 200) break; + cursor = String(history.rows.at(-1)?.task_id); + } + await client.query(`UPDATE arrangement_ledger_owners SET backfilled_at = now() + WHERE organization_id = $1 AND owner_user_id = $2`, [scope.organizationId, scope.userId]); + } + + async insert(client: Client, scope: ArrangementScope, event: ArrangementEvent): Promise { + await client.query(`INSERT INTO arrangement_ledger_events + (organization_id, owner_user_id, group_key, action, source_task_id, execution_id, effective_at, recorded_at, detail_ciphertext) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (organization_id, owner_user_id, execution_id) DO NOTHING`, + [scope.organizationId, scope.userId, sha256Text(event.group_number), event.action, event.source_task_id, + event.execution_id, event.effective_at, event.recorded_at, encryptText(this.config, JSON.stringify(event))]); + } + + async history(client: Client, scope: ArrangementScope, groupNumber: string): Promise { + const result = await client.query(`SELECT detail_ciphertext, source_task_deleted_at FROM arrangement_ledger_events + WHERE organization_id = $1 AND owner_user_id = $2 AND group_key = $3 + ORDER BY effective_at, recorded_at, id`, [scope.organizationId, scope.userId, sha256Text(arrangementGroupNumber(groupNumber))]); + return result.rows.map(row => ({ ...JSON.parse(decryptText(this.config, row.detail_ciphertext)), + source_task_deleted_at: row.source_task_deleted_at ? timestamp(row.source_task_deleted_at) : null })); + } + + async markTasksDeleted(client: Client, scope: ArrangementScope, taskIds: string[]): Promise { + await client.query(`UPDATE arrangement_ledger_events SET source_task_deleted_at = now() + WHERE organization_id = $1 AND owner_user_id = $2 AND source_task_id = ANY($3::text[]) + AND source_task_deleted_at IS NULL`, [scope.organizationId, scope.userId, taskIds]); + } +} diff --git a/control-plane/src/db.ts b/control-plane/src/db.ts index d8c5dd8..d92f372 100644 --- a/control-plane/src/db.ts +++ b/control-plane/src/db.ts @@ -5,7 +5,7 @@ import { writeEmergencyDiagnostic } from './diagnostics.js'; const { Pool } = pg; let pool: pg.Pool | null = null; -export const REQUIRED_SCHEMA_VERSION = '024_arrangement_update_routes'; +export const REQUIRED_SCHEMA_VERSION = '025_arrangement_ledger'; export interface DatabaseReadiness { ready: boolean; diff --git a/control-plane/src/reply-contract.ts b/control-plane/src/reply-contract.ts index ecc2851..d9c62a9 100644 --- a/control-plane/src/reply-contract.ts +++ b/control-plane/src/reply-contract.ts @@ -1,4 +1,5 @@ import { normalizeTaskArtifactFileName } from './artifact-name.js'; +import { arrangementSummaryText, isArrangementAction } from './arrangement-ledger.js'; export const AGENTBUS_REPLY_CONTRACT_VERSION = 'agentbus-user-reply-v1'; @@ -561,12 +562,14 @@ export function buildCustomerSuccessReceipt( ): Record { const reply = buildCustomerSuccessReply(baseReceipt, resultValue, operationValue); if (!reply) return baseReceipt; + const action = operationAction(operationValue, object(resultValue), resultContext(object(resultValue))); + const summaryText = isArrangementAction(action) ? arrangementSummaryText(baseReceipt.arrangement_summary) : ''; return { ...baseReceipt, reply: { contract: reply.contract, profile: reply.profile, - text: reply.text, + text: summaryText ? `${reply.text}\n\n${summaryText}` : reply.text, fields: reply.fields }, ...(reply.attachments.length ? { reply_attachments: reply.attachments } : {}) diff --git a/control-plane/src/task-service.ts b/control-plane/src/task-service.ts index 03ccf1e..590a78e 100644 --- a/control-plane/src/task-service.ts +++ b/control-plane/src/task-service.ts @@ -31,6 +31,7 @@ import { } from './reply-contract.js'; import { convertDocumentToPdf, convertDocumentToXlsx } from './document-converter.js'; import { sanitizeOperationTiming } from './operation-timing.js'; +import { ArrangementLedger, arrangementEventFromSuccess, arrangementSummary } from './arrangement-ledger.js'; import type { TaskInputAttachmentInput } from './input-attachment.js'; import { diagnosticMetadataKeys, @@ -6616,7 +6617,9 @@ export class TaskService { const executionFailure = failureSummary(resultObject, '', status, stage); const operation = decryptedJson(this.config, row.operation_ciphertext) || row.operation || null; const baseSuccessReceipt = status === 'completed' ? successReceiptFromResult(resultObject) : null; - const successReceipt = baseSuccessReceipt + // This snapshot is exclusively derived by the server, never by a plugin. + if (baseSuccessReceipt) delete baseSuccessReceipt.arrangement_summary; + let successReceipt = baseSuccessReceipt ? buildCustomerSuccessReceipt(baseSuccessReceipt, resultObject, operation) : null; const latestErrorSummary = isErrorSummaryStatus(status) ? errorSummaryPayload(executionFailure) : null; @@ -6652,6 +6655,21 @@ export class TaskService { if (text(row.lease_owner) !== leaseOwner && !canFinalizeTerminalReconciliation) { throw new TaskError('execution_owner_mismatch', '插件连接与任务领取记录不一致,回执已拒绝。'); } + const arrangementEvent = successReceipt ? arrangementEventFromSuccess({ + taskId, executionId, operation, result: resultObject, receipt: baseSuccessReceipt, + effectiveAt: attemptRow.started_at, recordedAt: new Date().toISOString() + }) : null; + if (arrangementEvent) { + const ledger = new ArrangementLedger(this.config); + await ledger.prepare(client, context, successReceiptFromResult); + await ledger.insert(client, context, arrangementEvent); + const history = await ledger.history(client, context, arrangementEvent.group_number); + // Freeze the account/group summary with this receipt in the SAME transaction. + successReceipt = buildCustomerSuccessReceipt({ + ...successReceipt, + arrangement_summary: arrangementSummary(history, arrangementEvent.group_number, arrangementEvent.recorded_at) + }, resultObject, operation); + } const updated = await client.query( `UPDATE tasks SET execution_result_ciphertext = $1, execution_result = $2, @@ -7277,6 +7295,12 @@ export class TaskService { const missingTaskIds = normalizedTaskIds.filter((taskId) => !foundTaskIds.has(taskId)); if (missingTaskIds.length) throw new TaskError('task_not_found', '任务不存在。', 404); + // Preserve historical successes before deleting their only original source. + // Business records deliberately survive task cascades and retain snapshots. + const ledger = new ArrangementLedger(this.config); + await ledger.prepare(client, context, successReceiptFromResult); + await ledger.markTasksDeleted(client, context, normalizedTaskIds); + // Hard delete intentionally has no status or handoff-state gate. The row // locks settle concurrent transitions; existing task foreign keys then // remove every task-owned record through ON DELETE CASCADE. diff --git a/control-plane/test/arrangement-ledger.test.ts b/control-plane/test/arrangement-ledger.test.ts new file mode 100644 index 0000000..0d67882 --- /dev/null +++ b/control-plane/test/arrangement-ledger.test.ts @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import type pg from 'pg'; +import { ArrangementLedger, ARRANGEMENT_LABELS, arrangementEventFromSuccess, arrangementGroupNumber, + arrangementSummary, currentArrangements, type ArrangementAction, type ArrangementEvent } from '../src/arrangement-ledger.js'; +import { loadConfig } from '../src/config.js'; +import { decryptText, encryptText } from '../src/crypto.js'; +import { closePool, getPool } from '../src/db.js'; +import { TaskService, successReceiptFromResult } from '../src/task-service.js'; +import { buildCustomerSuccessReceipt, replyTextFromReceipt } from '../src/reply-contract.js'; + +const config = loadConfig({ NODE_ENV: 'test', FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 29).toString('base64'), + DATABASE_URL: 'postgresql://fixture:fixture@127.0.0.1:1/fixture' }); +type Json = Record; +const scope = { organizationId: 'org-a', userId: 'user-a' }; + +function fixture(action: ArrangementAction = 'arrangement_hotel', mode: 'create' | 'update' | 'clear' = 'create', + rowId = '100', executionId = 'execution-1', time = '2026-09-16T01:00:00.000Z') { + const operation: Json = { action, data: { existing_refs: { identifier: 'LW-260901VIP-A' }, arrangement: { + mode, resource: { name: '搜索词TWN' }, start_date: '2026-09-04', end_date: '2026-09-05', room_count: 12, + target: { row_id: rowId, slot_index: 4 } + } } }; + const result: Json = { status: 'completed', execution_id: executionId, write_attempted: true, no_erp_write: false, + report: { status: 'lifecycle_completed', manual_review_required: false, blockers: [], + resolved_refs: { kind: 'independent_order', identifier: 'LW-260901VIP-A', tid: '900' }, + server_response: { completed: true }, requery: { matched: true, subpage: { matched: true, + arrangement_target: { row_id: rowId, slot_index: 0, resource_id: 'hotel-1', item_value: 'TWN H', quantity_value: '12', cleared: mode === 'clear' }, + checks: [ + { field: 'danwei0', actual: '实际酒店', expected: '实际酒店', matched: true }, + { field: 'riqi0', actual: '2026-9-4', matched: true }, + { field: 'riqis0', actual: '2026-9-5', matched: true }, + { field: 'beizhu0', actual: '原生备注', matched: true } + ] + } } + } }; + return { taskId: `TASK-${executionId}`, executionId, effectiveAt: time, recordedAt: time, + operation, result, receipt: successReceiptFromResult(result) }; +} +function event(...args: Parameters): ArrangementEvent { + const value = arrangementEventFromSuccess(fixture(...args)); + assert.ok(value); + return value; +} + +test('successful arrangements capture actual linked fields and retain separate original input/evidence', () => { + const item = event(); + assert.equal(item.details.resource_name, '实际酒店'); + assert.equal((item.requested.resource as Json).name, '搜索词TWN'); + assert.equal(item.details.start_date, '2026-09-04'); + assert.equal(item.details.end_date, '2026-09-05'); + assert.equal(item.details.item, 'TWN H'); + assert.equal(item.details.quantity, 12); + assert.equal(item.details.remark, '原生备注'); + assert.equal(item.record_key, 'row:100'); + assert.equal(item.evidence.requery_matched, true); + const dated = arrangementEventFromSuccess({ ...fixture(), effectiveAt: new Date('2026-09-16T01:00:00.123Z') }); + assert.equal(dated?.effective_at, '2026-09-16T01:00:00.123Z', 'pg Date values retain sub-second ordering'); +}); + +test('parser, failed, queued, dry-run, uncertain and incomplete proof never change arrangement state', () => { + for (const status of ['confirmed', 'queued', 'running', 'blocked', 'failed', 'dry_run', 'reconciliation_pending']) { + const input = fixture(); input.result.status = status; + assert.equal(arrangementEventFromSuccess(input), null, status); + } + for (const patch of [ + (i: ReturnType) => { i.result.report.server_response.completed = false; }, + (i: ReturnType) => { i.result.report.requery.matched = false; }, + (i: ReturnType) => { i.result.report.requery.skipped = true; }, + (i: ReturnType) => { i.result.report.blockers = ['blocked']; }, + (i: ReturnType) => { i.result.manual_review_required = true; }, + (i: ReturnType) => { i.result.uncertain = true; }, + (i: ReturnType) => { i.receipt = null; } + ]) { const input = fixture(); patch(input); assert.equal(arrangementEventFromSuccess(input), null); } +}); + +test('full group identity is normalized without collapsing suffixes or accepting a different ERP group', () => { + assert.equal(arrangementGroupNumber(' lw-260901vip-a '), 'LW-260901VIP-A'); + const input = fixture(); input.result.report.resolved_refs.identifier = 'LW-260901VIP-C'; + assert.equal(arrangementEventFromSuccess(input), null); + const summary = arrangementSummary([event()], 'LW-260901VIP-C', 'now'); + assert.ok(summary.items.every(item => !item.arranged)); +}); + +test('five arrangement types have independent status and guides remain singleton after replacement', () => { + const items = (Object.keys(ARRANGEMENT_LABELS) as ArrangementAction[]).map((action, i) => event(action, 'create', '100', `e-${i}`)); + items.push(event('arrangement_guide', 'update', 'different', 'e-guide-update', '2026-09-16T02:00:00Z')); + assert.equal(currentArrangements(items).length, 5); + assert.ok(arrangementSummary(items, items[0].group_number, 'now').items.every(item => item.arranged && item.count === 1)); +}); + +test('update and clear target stable row IDs, preserving other rows regardless of slot renumbering', () => { + const items = [event(), event('arrangement_hotel', 'create', '200', 'e-2')]; + const update = event('arrangement_hotel', 'update', '100', 'e-3', '2026-09-16T02:00:00Z'); + update.details.quantity = 8; + items.push(update, update); + assert.equal(currentArrangements(items).length, 2); + assert.equal(currentArrangements(items).find(item => item.record_key === 'row:100')?.details.quantity, 8); + items.push(event('arrangement_hotel', 'clear', '100', 'e-4', '2026-09-16T03:00:00Z')); + assert.deepEqual(currentArrangements(items).map(item => item.record_key), ['row:200']); + items.push(event('arrangement_hotel', 'clear', '200', 'e-5', '2026-09-16T04:00:00Z')); + assert.ok(arrangementSummary(items, items[0].group_number, 'now').items.every(item => !item.arranged)); + assert.equal(items.length, 6, 'history remains intact'); +}); + +test('late reconciliation replays by original execution time rather than overwriting newer arrangements', () => { + const early = event(); early.recorded_at = '2026-09-17T01:00:00.000Z'; + const clear = event('arrangement_hotel', 'clear', '100', 'e-clear', '2026-09-16T02:00:00Z'); + assert.equal(currentArrangements([clear, early]).length, 0); +}); + +test('narrow hotel updates retain existing resource/date details but honor a verified cleared remark', () => { + const original = event(); + const input = fixture('arrangement_hotel', 'update', '100', 'e-narrow', '2026-09-16T02:00:00Z'); + input.operation.data.arrangement = { mode: 'update', changes: { room_count: 8 } }; + input.result.report.requery.subpage.arrangement_target.quantity_value = '8'; + input.result.report.requery.subpage.checks = [{ field: 'beizhu0', actual: '', matched: true }]; + const update = arrangementEventFromSuccess(input)!; + const current = currentArrangements([original, update])[0]; + assert.equal(current.details.resource_name, '实际酒店'); + assert.equal(current.details.start_date, '2026-09-04'); + assert.equal(current.details.end_date, '2026-09-05'); + assert.equal(current.details.quantity, 8); + assert.equal(current.details.remark, ''); + assert.equal(original.details.quantity, 12, 'original history is immutable'); +}); + +test('clearing external/untracked rows does not erase this account recorded arrangements', () => { + assert.equal(currentArrangements([event(), event('arrangement_hotel', 'clear', '900', 'other-clear')]).length, 1); +}); + +test('receipt summary is a frozen snapshot and remains single when public receipts are rebuilt', () => { + const input = fixture(); const first = event(); + const base = { ...input.receipt, arrangement_summary: arrangementSummary([first], first.group_number, first.recorded_at) }; + const receipt = buildCustomerSuccessReceipt(base, input.result, input.operation); + const reply = replyTextFromReceipt(receipt); + assert.match(reply, /NEW BOOKING/); + assert.match(reply, /导游:未安排[\s\S]*酒店:已安排/); + assert.match(reply, /当前账号/); + const newer = arrangementSummary([first, event('arrangement_guide', 'create', '', 'e-guide')], first.group_number, 'later'); + assert.equal(newer.items[0].arranged, true); + const rebuilt = buildCustomerSuccessReceipt(receipt, input.result, input.operation); + assert.equal(replyTextFromReceipt(rebuilt), reply); + assert.equal(reply.match(/本系统安排情况/g)?.length, 1); + assert.ok(!replyTextFromReceipt(buildCustomerSuccessReceipt({ success: true }, {}, { action: 'order_cancel' })).includes('本系统安排情况')); +}); + +function memoryClient() { + const owners = new Map(); + const records: Json[] = [], tasks: Json[] = []; + const queries: string[] = []; + const answer = (rows: Json[] = []) => ({ rowCount: rows.length, rows }); + const client = { release() {}, async query(sql: string, p: any[] = []) { + queries.push(sql); + const ownerKey = `${p[0]}/${p[1]}`; + if (/INSERT INTO arrangement_ledger_owners/.test(sql)) { if (!owners.has(ownerKey)) owners.set(ownerKey, {}); return answer(); } + if (/SELECT backfilled_at/.test(sql)) { assert.match(sql, /FOR UPDATE/); return answer([owners.get(ownerKey)!]); } + if (/UPDATE arrangement_ledger_owners/.test(sql)) { owners.get(ownerKey)!.backfilled_at = 'done'; return answer(); } + if (/FROM tasks t JOIN task_attempts/.test(sql)) { + assert.match(sql, /t.organization_id = \$1 AND t.assigned_user_id = \$2/); + return answer(tasks.filter(t => t.organization_id === p[0] && t.assigned_user_id === p[1] && t.status === 'completed' + && t.task_id > p[2] && p[3].includes(t.operation.action)).sort((a,b) => a.task_id.localeCompare(b.task_id)).slice(0, 200)); + } + if (/INSERT INTO arrangement_ledger_events/.test(sql)) { + assert.match(sql, /ON CONFLICT \(organization_id, owner_user_id, execution_id\) DO NOTHING/); + if (!records.some(r => r.organization_id === p[0] && r.owner_user_id === p[1] && r.execution_id === p[5])) + records.push({ organization_id: p[0], owner_user_id: p[1], group_key: p[2], action: p[3], source_task_id: p[4], + execution_id: p[5], effective_at: p[6], recorded_at: p[7], detail_ciphertext: p[8], source_task_deleted_at: null }); + return answer(); + } + if (/SELECT detail_ciphertext/.test(sql)) { + assert.match(sql, /organization_id = \$1 AND owner_user_id = \$2 AND group_key = \$3/); + return answer(records.filter(r => r.organization_id === p[0] && r.owner_user_id === p[1] && r.group_key === p[2])); + } + if (/UPDATE arrangement_ledger_events/.test(sql)) { + for (const r of records) if (r.organization_id === p[0] && r.owner_user_id === p[1] && p[2].includes(r.source_task_id)) r.source_task_deleted_at = '2026-09-17T01:00:00Z'; + return answer(); + } + if (['BEGIN', 'COMMIT', 'ROLLBACK'].includes(sql)) return answer(); + throw new Error(`Unexpected SQL: ${sql}`); + } }; + return { client: client as unknown as pg.PoolClient, raw: client, owners, records, tasks, queries }; +} + +test('ledger storage encrypts details, deduplicates and scopes reads to both organization and account', async () => { + const db = memoryClient(), ledger = new ArrangementLedger(config), original = event(); + await ledger.prepare(db.client, scope, successReceiptFromResult); + await ledger.insert(db.client, scope, original); + await ledger.insert(db.client, scope, original); + assert.equal(db.records.length, 1); + assert.ok(!db.records[0].detail_ciphertext.includes('实际酒店')); + assert.equal(JSON.parse(decryptText(config, db.records[0].detail_ciphertext)).details.resource_name, '实际酒店'); + assert.equal((await ledger.history(db.client, scope, original.group_number)).length, 1); + assert.equal((await ledger.history(db.client, { ...scope, userId: 'user-b' }, original.group_number)).length, 0); + assert.equal((await ledger.history(db.client, { ...scope, organizationId: 'org-b' }, original.group_number)).length, 0); + await ledger.markTasksDeleted(db.client, scope, [original.source_task_id]); + const history = await ledger.history(db.client, scope, original.group_number); + assert.ok(history[0].source_task_deleted_at); + assert.equal(history[0].details.resource_name, '实际酒店'); + assert.equal(currentArrangements(history).length, 1); +}); + +test('backfill includes archived successes and skips failed/other-account tasks, preserving encrypted evidence', async () => { + const db = memoryClient(), ledger = new ArrangementLedger(config), input = fixture(); + db.tasks.push({ task_id: input.taskId, execution_id: input.executionId, operation: { action: input.operation.action }, + operation_ciphertext: encryptText(config, JSON.stringify(input.operation)), + execution_result_ciphertext: encryptText(config, JSON.stringify(input.result)), + organization_id: scope.organizationId, assigned_user_id: scope.userId, status: 'completed', archived_at: 'archived', + effective_at: input.effectiveAt, success_receipt_at: input.recordedAt }); + db.tasks.push({ ...db.tasks[0], task_id: 'TASK-failed', execution_id: 'failed', status: 'failed' }, + { ...db.tasks[0], task_id: 'TASK-other', execution_id: 'other', assigned_user_id: 'other' }); + await ledger.prepare(db.client, scope, successReceiptFromResult); + assert.equal(db.records.length, 1); + assert.equal(db.records[0].source_task_id, input.taskId); + await ledger.prepare(db.client, scope, successReceiptFromResult); + assert.equal(db.queries.filter(sql => /FROM tasks t JOIN task_attempts/.test(sql)).length, 1); +}); + +test('ledger migration has no cascading source-task dependency and blocks admin ownership', async () => { + const sql = await readFile(new URL('../migrations/025_arrangement_ledger.sql', import.meta.url), 'utf8'); + assert.doesNotMatch(sql, /REFERENCES\s+(tasks|task_attempts)\b/i); + assert.match(sql, /detail_ciphertext text NOT NULL/); + assert.match(sql, /UNIQUE \(organization_id, owner_user_id, execution_id\)/); + assert.match(sql, /reject_admin_task_principal/); +}); + +test('real success-result transaction saves summary/history once and force delete preserves details', async t => { + const db = memoryClient(), input = fixture(), pool = getPool(config); + const context = { ...scope, role: 'user' as const, requestId: 'ledger-test' }; + let row: Json = { id: 'row-1', task_id: input.taskId, organization_id: scope.organizationId, assigned_user_id: scope.userId, + status: 'running', operation: input.operation, lease_owner: 'browser:connection-1' }; + const attempt: Json = { id: input.executionId, started_at: input.effectiveAt, status: 'running', details: { connection_id: 'connection-1' } }; + const events: Json[] = []; + const delegate = db.raw.query.bind(db.raw); + db.raw.query = async (sql: string, p: any[] = []) => { + if (/SELECT \* FROM task_attempts/.test(sql)) return { rowCount: 1, rows: [attempt] }; + if (/UPDATE tasks\s/.test(sql)) { + row = { ...row, execution_result_ciphertext: p[0], execution_result: p[1], status: p[2], stage: p[3], message: p[4], success_receipt: p[9] }; + return { rowCount: 1, rows: [row] }; + } + if (/UPDATE task_attempts/.test(sql)) { attempt.status = p[0]; attempt.response_hash = p[1]; return { rowCount: 1, rows: [] }; } + if (/SELECT \* FROM tasks/.test(sql)) return { rowCount: 1, rows: [row] }; + if (/FROM task_artifacts artifact|DELETE FROM outbox_events/.test(sql)) return { rowCount: 0, rows: [] }; + if (/DELETE FROM tasks/.test(sql)) { const deleted = row; row = {}; return { rowCount: 1, rows: [deleted] }; } + return delegate(sql, p); + }; + t.mock.method(pool, 'connect', async () => db.client); + t.after(async () => { t.mock.restoreAll(); await closePool(); }); + const service = new TaskService(config); + Object.assign(service, { lockTaskForAccess: async () => row, requireBrowserConnection: async () => {}, getTask: async () => row, + emitEvent: async (_client: unknown, _row: unknown, event: Json) => { events.push(event); return event; }, + audit: async () => {}, notify: () => {}, notifyBrowserCommand: () => {} }); + await service.recordExecutionResult(context, input.taskId, input.result, 'connection-1', input.executionId, { skipArtifactPersistence: true }); + assert.equal(row.status, 'completed'); + assert.equal(db.records.length, 1); + assert.match(replyTextFromReceipt(row.success_receipt), /酒店:已安排/); + assert.deepEqual(events[0].payload.success_receipt, row.success_receipt); + await service.recordExecutionResult(context, input.taskId, input.result, 'connection-1', input.executionId, { skipArtifactPersistence: true }); + assert.equal(db.records.length, 1); + assert.equal(events.length, 1); + const deleted = await service.hardDeleteTask(context, input.taskId); + assert.equal(deleted.deleted, true); + assert.deepEqual(row, {}); + const history = await new ArrangementLedger(config).history(db.client, scope, 'LW-260901VIP-A'); + assert.ok(history[0].source_task_deleted_at); + assert.equal(currentArrangements(history).length, 1); + assert.match(replyTextFromReceipt(buildCustomerSuccessReceipt({ success: true, arrangement_summary: + arrangementSummary(history, 'LW-260901VIP-A', 'now') }, input.result, input.operation)), /酒店:已安排/); +}); diff --git a/control-plane/test/control-plane.test.ts b/control-plane/test/control-plane.test.ts index fa408ed..09098dc 100644 --- a/control-plane/test/control-plane.test.ts +++ b/control-plane/test/control-plane.test.ts @@ -443,11 +443,11 @@ test('migration contains the durable state tables and safety fields', async () = } }); -test('control plane requires the leader summary webhook migration before readiness', async () => { +test('control plane requires the arrangement ledger migration before readiness', async () => { const { readFile } = await import('node:fs/promises'); const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8'); const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8'); - assert.equal(REQUIRED_SCHEMA_VERSION, '024_arrangement_update_routes'); + assert.equal(REQUIRED_SCHEMA_VERSION, '025_arrangement_ledger'); assert.match(db, /schema_migrations/); assert.match(db, /databaseReadiness/); assert.match(db, /assertDatabaseSchema/); diff --git a/dist/ltjt-order-assistant-0.5.186.zip b/dist/ltjt-order-assistant-0.5.186.zip new file mode 100644 index 0000000..14a0eae Binary files /dev/null and b/dist/ltjt-order-assistant-0.5.186.zip differ diff --git a/dist/release-manifest.json b/dist/release-manifest.json index d9d6a6d..4b6afc2 100644 --- a/dist/release-manifest.json +++ b/dist/release-manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 1, - "generated_on": "2026-09-15", + "generated_on": "2026-09-16", "baselines": { - "chrome_extension": "0.5.185", + "chrome_extension": "0.5.186", "skills": "0.5.127", "business_instruction_docx": "0.5.134", "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" @@ -10,10 +10,10 @@ "artifacts": [ { "kind": "chrome_extension", - "version": "0.5.185", - "path": "dist/ltjt-order-assistant-0.5.185.zip", + "version": "0.5.186", + "path": "dist/ltjt-order-assistant-0.5.186.zip", "source": "chrome-extension/ltjt-order-assistant", - "sha256": "42d12ea2a9e090af3202b683f66a4418fa7aac199686bfb7840251e1fb9b899c" + "sha256": "c79d108ce4cbed85a856b336e1643fd990d81f21d16a0bbd66be27510d1c2568" }, { "kind": "skill", diff --git a/mappings/lifecycle.mapping.json b/mappings/lifecycle.mapping.json index b4c5689..94e27b1 100644 --- a/mappings/lifecycle.mapping.json +++ b/mappings/lifecycle.mapping.json @@ -1,7 +1,7 @@ { "contract_version": "ltjt-lifecycle-v2.9-roster-leader-contact-2026-08", "current_agent_parse_prompt_version": "ltjt-agent-prompt-v1.8-independent-headcount-categories", - "current_extension_version": "0.5.185", + "current_extension_version": "0.5.186", "original_arrangement_date_selection": "Hotel, vehicle, transport and other/filing update selection accepts MM-DD or YYYY-MM-DD; omitted year matches original ERP month/day within the exact group only. Require exactly one persisted row, then freeze its actual full dates and id. Full year is exact; mixed/cross-year endpoints are matched independently with actual range order checked. No match or ambiguity blocks before mutation; show up to five original candidates and ask to resubmit complete dates. Creation and legacy target-date changes retain existing semantics.", "historical_test_marker": "TEST-202609", "scope": "/System/Business/", @@ -384,7 +384,7 @@ "arrangement_transport": { "page": "/System/Business/teams_piao.asp", "action": "DoInfo_piao", - "ticket_description_filter": "Optional arrangement.ticket_description for create/update; normalized description substring AND supplier query on the same native candidate, unique result only. Omission retains native first-match behavior. Keyword is separate from ERP-derived item and original-date selection; revalidate before mutation. No new database migration; real ERP acceptance pending.", + "ticket_description_filter": "Optional arrangement.ticket_description for create/update; normalized description substring AND supplier query on the same native candidate, unique result only. Whitespace/brackets/case/full-width differences are ignored, but numeric boundaries use original label positions: C91 followed by a separated timetable can match C91, while C911 cannot. Route alone is sufficient only when unique; ambiguity stops before mutation and returns up to five bounded supplier/description candidates without internal IDs or prices. Omission retains native first-match behavior. Keyword is separate from ERP-derived item and original-date selection; revalidate before mutation. No new database migration; real ERP acceptance pending.", "fields": { "supplier.name": "danwei0", "supplier.id": "danweiid0", diff --git a/tools/arrangement-ledger-postgres-smoke.mjs b/tools/arrangement-ledger-postgres-smoke.mjs new file mode 100644 index 0000000..3ac369f --- /dev/null +++ b/tools/arrangement-ledger-postgres-smoke.mjs @@ -0,0 +1,116 @@ +// Explicit, disposable local PostgreSQL only. Never reads .env or uses the app DB. +// LTJT_ARRANGEMENT_TEST_DATABASE_URL=postgresql://...@127.0.0.1:PORT/ledger_test \ +// node --import tsx tools/arrangement-ledger-postgres-smoke.mjs +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { readFile, readdir } from 'node:fs/promises'; +import { loadConfig } from '../control-plane/src/config.ts'; +import { getPool, closePool, quoteIdentifier } from '../control-plane/src/db.ts'; +import { encryptText } from '../control-plane/src/crypto.ts'; +import { ArrangementLedger, currentArrangements } from '../control-plane/src/arrangement-ledger.ts'; +import { TaskService } from '../control-plane/src/task-service.ts'; +import { taskResultText } from '../control-plane/src/agentbus.ts'; + +const rawUrl = process.env.LTJT_ARRANGEMENT_TEST_DATABASE_URL; +if (!rawUrl) throw new Error('Set LTJT_ARRANGEMENT_TEST_DATABASE_URL to a disposable local ledger_test database.'); +const url = new URL(rawUrl); +assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) && url.pathname === '/ledger_test'); +const schema = `ledger_smoke_${randomUUID().replaceAll('-', '')}`; +const config = loadConfig({ NODE_ENV: 'test', DATABASE_URL: rawUrl, DATABASE_SCHEMA: schema, + FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 31).toString('base64') }); +const pool = getPool(config); +const ledger = new ArrangementLedger(config); +let created = false; +try { + await pool.query(`CREATE SCHEMA ${quoteIdentifier(schema)}`); + created = true; + const migrationDir = new URL('../control-plane/migrations/', import.meta.url); + const migrations = (await readdir(migrationDir)).filter(name => /^\d+_.+\.sql$/.test(name)).sort(); + for (const name of migrations) await pool.query(await readFile(new URL(name, migrationDir), 'utf8')); + const org = (await pool.query(`INSERT INTO organizations (slug, name) VALUES ('ledger-fixture', 'Ledger fixture') RETURNING id`)).rows[0].id; + const otherOrg = (await pool.query(`INSERT INTO organizations (slug, name) VALUES ('ledger-other', 'Other fixture') RETURNING id`)).rows[0].id; + const users = (await pool.query(`INSERT INTO users (organization_id, username, password_hash, role) + VALUES ($1,'ledger-a','fixture','user'),($1,'ledger-b','fixture','user'),($1,'ledger-admin','fixture','admin') RETURNING id, username`, [org])).rows; + const context = name => ({ organizationId: org, userId: users.find(u => u.username === name).id, role: 'user', requestId: 'ledger-smoke' }); + const ownerA = context('ledger-a'), ownerB = context('ledger-b'); + const service = new TaskService(config); + // No browser or ERP is involved. All remaining persistence and auth paths are real. + service.requireBrowserConnection = async () => {}; + + async function task(owner, action = 'arrangement_hotel', mode = 'create', rowId = '100', group = 'LW-TEST-A') { + const taskId = `TASK-${randomUUID()}`, executionId = randomUUID(); + const operation = { action, data: { existing_refs: { identifier: group }, arrangement: { + mode, target: { row_id: rowId }, start_date: '2026-09-16', end_date: '2026-09-17', room_count: 8 + } } }; + const result = { status: 'completed', execution_id: executionId, no_erp_write: false, write_attempted: true, + report: { status: 'lifecycle_completed', resolved_refs: { identifier: group, kind: 'independent_order' }, + server_response: { completed: true }, requery: { matched: true, subpage: { matched: true, + arrangement_target: { row_id: rowId, resource_id: '700', item_value: 'TWN', quantity_value: '8', cleared: mode === 'clear' }, + checks: [{ field: action === 'arrangement_guide' ? 'daoyou0' : 'danwei0', matched: true, actual: '实际资源' }] + } } } }; + const inserted = await pool.query(`INSERT INTO tasks + (organization_id, task_id, created_by, assigned_user_id, status, stage, lease_owner, operation, operation_ciphertext) + VALUES ($1,$2,$3,$3,'running','lifecycle_live','browser:fixture',$4,$5) RETURNING id`, + [org, taskId, owner.userId, { action }, encryptText(config, JSON.stringify(operation))]); + await pool.query(`INSERT INTO task_attempts (id, organization_id, task_id, attempt_no, phase, status, details) + VALUES ($1,$2,$3,1,'erp','running',$4)`, [executionId, org, inserted.rows[0].id, { connection_id: 'fixture' }]); + return { taskId, executionId, operation, result }; + } + const complete = (owner, item) => service.recordExecutionResult(owner, item.taskId, item.result, 'fixture', item.executionId, { skipArtifactPersistence: true }); + const first = await task(ownerA); + const receipt = await complete(ownerA, first); + assert.match(receipt.important_message.text, /酒店:已安排/); + assert.equal(taskResultText(receipt), receipt.important_message.text, 'AgentBus preserves the summary'); + await complete(ownerA, first); + assert.equal((await ledger.history(pool, ownerA, 'LW-TEST-A')).length, 1, 'idempotent result ingestion'); + assert.equal((await ledger.history(pool, ownerB, 'LW-TEST-A')).length, 0); + assert.equal((await ledger.history(pool, { ...ownerA, organizationId: otherOrg }, 'LW-TEST-A')).length, 0); + const guide = await task(ownerA, 'arrangement_guide'); + const next = await complete(ownerA, guide); + assert.match(next.important_message.text, /导游:已安排[\s\S]*酒店:已安排/); + assert.match((await service.getTask(org, first.taskId, ownerA)).important_message.text, /导游:未安排/, 'old receipt stays frozen'); + await service.hardDeleteTask(ownerA, first.taskId); + const retained = await ledger.history(pool, ownerA, 'LW-TEST-A'); + assert.ok(retained.find(e => e.source_task_id === first.taskId).source_task_deleted_at); + assert.equal(retained.find(e => e.source_task_id === first.taskId).details.resource_name, '实际资源'); + assert.equal((await pool.query('SELECT 1 FROM task_attempts WHERE id = $1', [first.executionId])).rowCount, 0); + assert.equal(currentArrangements(retained).length, 2, 'task cascade leaves ledger intact'); + + const secondHotel = await task(ownerA, 'arrangement_hotel', 'create', '200'); + await complete(ownerA, secondHotel); + await complete(ownerA, await task(ownerA, 'arrangement_hotel', 'clear', '100')); + assert.equal(currentArrangements(await ledger.history(pool, ownerA, 'LW-TEST-A')).filter(e => e.action === 'arrangement_hotel').length, 1); + + // A failure after ledger insertion must roll back both task outcome and ledger. + const rollback = await task(ownerA, 'arrangement_vehicle'); + const emit = service.emitEvent; + service.emitEvent = async () => { throw new Error('injected-event-failure'); }; + await assert.rejects(complete(ownerA, rollback), /injected-event-failure/); + service.emitEvent = emit; + assert.equal((await pool.query('SELECT status FROM tasks WHERE task_id = $1', [rollback.taskId])).rows[0].status, 'running'); + assert.ok(!(await ledger.history(pool, ownerA, 'LW-TEST-A')).some(e => e.execution_id === rollback.executionId)); + + // Deleting an archived, pre-feature success as the first interaction backfills it. + const historic = await task(ownerB, 'arrangement_transport'); + await pool.query(`UPDATE tasks SET status = 'completed', archived_at = now(), success_receipt_at = now(), + execution_result_ciphertext = $2 WHERE task_id = $1`, [historic.taskId, encryptText(config, JSON.stringify(historic.result))]); + await service.hardDeleteTask(ownerB, historic.taskId); + const backfill = await ledger.history(pool, ownerB, 'LW-TEST-A'); + assert.equal(backfill.length, 1); + assert.equal(backfill[0].action, 'arrangement_transport'); + assert.ok(backfill[0].source_task_deleted_at); + assert.ok(!(await ledger.history(pool, ownerA, 'LW-TEST-A')).some(e => e.execution_id === historic.executionId)); + + await assert.rejects(pool.query(`INSERT INTO arrangement_ledger_owners (organization_id, owner_user_id) VALUES ($1,$2)`, + [org, context('ledger-admin').userId]), e => e.code === '23514'); + await assert.rejects(pool.query(`INSERT INTO arrangement_ledger_owners (organization_id, owner_user_id) VALUES ($1,$2)`, + [otherOrg, ownerA.userId]), e => e.code === '23503'); + console.log(JSON.stringify({ ok: true, migrations: migrations.length, verified: [ + 'real migrations', 'result ingestion and public/AgentBus receipts', 'deduplication', 'account/org isolation', + 'frozen receipts', 'force-delete retention', 'row-specific clear', 'transaction rollback', + 'archived historical backfill before delete', 'admin and cross-org ownership constraints' + ] })); +} finally { + if (created) await pool.query(`DROP SCHEMA ${quoteIdentifier(schema)} CASCADE`); + await closePool(); +} diff --git a/tools/arrangement-update-unified.test.mjs b/tools/arrangement-update-unified.test.mjs index 7d64b5f..c2ca024 100644 --- a/tools/arrangement-update-unified.test.mjs +++ b/tools/arrangement-update-unified.test.mjs @@ -344,6 +344,131 @@ test('explicit ticket filter never drops a keyword or defaults to the first ambi assert.equal(duplicate.api.testResolve(duplicate.form, duplicate.input).ok, false, 'replacement filter must not disambiguate original rows'); }); +const timedTicketRows = [ + '270◆测试新资源◆拉邦-万荣 D83 18:11-19:02◆公司现付', + '271◆测试新资源◆拉邦-万荣【C91 12:52-13:44】◆公司现付', + '272◆测试新资源◆拉邦-万象【C91 12:28-14:31】◆公司现付', + '273◆测试新资源◆拉邦-万荣 C911 12:52-13:44◆公司现付', + '274◆另一票务◆拉邦-万荣 C91 12:52-13:44◆公司现付' +].join('◇'); + +for (const mode of ['create', 'update']) test(`transport ${mode} distinguishes route and train before an adjacent timetable`, async () => { + for (const [query, id] of [ + ['拉邦-万荣 C91', '271'], + ['拉邦-万象 C91', '272'], + ['拉邦-万荣 c91', '271'], + ['拉邦-万荣 C91 12:52-13:44', '271'], + ['拉邦-万象 C91 12:28-14:31', '272'] + ]) { + const f = filteredTransportFixture(mode, timedTicketRows); + f.input.data.arrangement.ticket_description = query; + const resolved = f.api.testResolve(f.form, f.input); + assert.equal(resolved.ok, true, JSON.stringify({ query, resolved })); + const op = JSON.parse(JSON.stringify(resolved.operation)); + const replacement = mode === 'update' ? op.data.arrangement.changes : op.data.arrangement; + assert.equal(replacement.supplier.id, id); + assert.equal(resolved.evidence.candidates, undefined, 'successful lookups do not add candidate details'); + assert.equal(f.api.testResource(f.form, op).matched, true); + const applied = await f.api.testApply(f.form, op); + assert.deepEqual(Array.from(applied.blockers), [], JSON.stringify(applied)); + assert.equal(f.selections(), 1); + assert.equal(f.fields.get(`danweiid${mode === 'create' ? 0 : 1}`).value, id); + assert.equal(f.api.testExpected(f.form, op).blockers.length, 0); + } +}); + +test('description normalization preserves original numeric boundaries and existing formatting compatibility', () => { + const { api } = filteredTransportFixture('create'); + for (const [item, query, expected] of [ + ['C91 12:52-13:44', 'C91', true], + ['【C91】12:52-13:44', 'c91', true], + ['C91\t12:52-13:44', 'C91', true], + ['C91\u00a012:52-13:44', 'C91', true], + ['C911 12:52-13:44', 'C91', false], + ['C9112:52-13:44', 'C91', false], + ['D888 12:52-13:44', 'D88', false], + ['115 120', '15', false], + ['15 120', '15', true], + ['【15座】 VIP 120', '15座vip', true], + ['C91 12:52-13:44', '【】', false] + ]) { + assert.equal(api.matchesTicketDescription(item, query), expected, `${item} / ${query}`); + assert.equal(api.matchesVehicleType(item, query), expected, `shared vehicle filter: ${item} / ${query}`); + } +}); + +test('route-only or train-only filters block with only the matching supplier candidates and no mutation', () => { + for (const mode of ['create', 'update']) for (const [query, count] of [['拉邦-万荣', 3], ['C91', 2]]) { + const f = filteredTransportFixture(mode, timedTicketRows); + f.input.data.arrangement.ticket_description = query; + const before = [...f.fields.values()].map(c => c.value); + const resolved = f.api.testResolve(f.form, f.input); + assert.equal(resolved.ok, false); + assert.ok(resolved.blockers.includes(`arrangement_resource_candidate_count:${count}`)); + assert.equal(resolved.evidence.candidates.length, count); + assert.equal(resolved.evidence.value_redacted, false); + for (const candidate of resolved.evidence.candidates) { + assert.deepEqual(Object.keys(candidate).sort(), ['item', 'resource']); + assert.equal(candidate.resource, '测试新资源'); + if (query === 'C91') assert.doesNotMatch(candidate.item, /C911|D83/); + } + assert.equal(f.selections(), 0); + assert.deepEqual([...f.fields.values()].map(c => c.value), before); + } + const single = filteredTransportFixture('create', '271◆测试新资源◆拉邦-万荣【C91 12:52-13:44】◆公司现付'); + single.input.data.arrangement.ticket_description = '拉邦-万荣'; + assert.equal(single.api.testResolve(single.form, single.input).ok, true, 'route alone is sufficient when unique'); +}); + +test('ambiguous timetable candidates stay bounded and cannot default to an identical first row', () => { + for (const mode of ['create', 'update']) { + const rows = Array.from({ length: 7 }, (_, i) => `${800 + i}◆测试新资源◆C91 12:52-13:44 ${'长'.repeat(150)}\n◆私有列`).join('◇'); + const f = filteredTransportFixture(mode, rows); + f.input.data.arrangement.ticket_description = 'C91'; + const resolved = f.api.testResolve(f.form, f.input); + assert.equal(resolved.ok, false); + assert.ok(resolved.blockers.includes('arrangement_resource_candidate_count:7')); + assert.equal(resolved.evidence.candidates.length, 5); + for (const row of resolved.evidence.candidates) assert.ok(row.item.length <= 120); + assert.doesNotMatch(JSON.stringify(resolved.evidence.candidates), /私有列|800|801|\\n/); + assert.equal(f.selections(), 0); + } +}); + +test('transport route failures explain zero matches, unavailable data and bounded ambiguous candidates', () => { + const source = readFileSync(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'); + const start = source.indexOf('function lifecycleRouteFailure('); + const end = source.indexOf('\nasync function executeLifecycleOperation', start); + const failure = vm.runInNewContext(`(${source.slice(start, end).trim()})`); + for (const mode of ['create', 'update']) { + const f = filteredTransportFixture(mode, timedTicketRows); + f.input.data.arrangement.ticket_description = 'C91'; + const resolved = f.api.testResolve(f.form, f.input); + const result = failure(f.input, { blockers: resolved.blockers, preflight: { resource_resolution: resolved.evidence } }); + assert.equal(result.errorCode, 'arrangement_resource_not_unique'); + assert.match(result.message, /2 条大交通候选/); + assert.match(result.message, /拉邦-万荣.*12:52-13:44/); + assert.match(result.message, /拉邦-万象.*12:28-14:31/); + assert.match(result.message, /车票说明.*时刻/); + assert.match(result.message, /未写入 ERP/); + assert.doesNotMatch(result.message, /C911|另一票务|未能按精确引用|271|272/); + const absent = failure(f.input, { blockers: ['arrangement_resource_candidate_count:0'] }); + assert.equal(absent.errorCode, 'arrangement_resource_not_found'); + assert.match(absent.message, /没有找到同时匹配结算单位和车票说明/); + const unloaded = failure(f.input, { blockers: ['arrangement_resource_candidate_data_missing', 'arrangement_resource_candidate_count:0'] }); + assert.equal(unloaded.errorCode, 'arrangement_resource_lookup_not_ready'); + assert.doesNotMatch(unloaded.message, /没有找到/); + } + const report = { blockers: ['arrangement_resource_candidate_count:7'], preflight: { resource_resolution: { candidates: + Array.from({length: 7}, (_, i) => ({ resource: '票务\n公司', item: `候选${i} ${'长'.repeat(150)}`, id: 'secret-id', raw: 'secret-raw' })) + } } }; + const bounded = failure({ action: 'arrangement_transport' }, report); + assert.match(bounded.message, /仅显示前 5 条/); + assert.match(bounded.message, /票务 公司/); + assert.doesNotMatch(bounded.message, /候选5|候选6|secret/); + assert.ok(bounded.message.length < 1500); +}); + test('changed ticket description or candidates block before any native call or mutation', async () => { for (const mode of ['create', 'update']) for (const mutation of ['description', 'candidate', 'duplicate']) { const f = filteredTransportFixture(mode); diff --git a/tools/lifecycle-contract.test.mjs b/tools/lifecycle-contract.test.mjs index 4cd621b..fc5e3c9 100644 --- a/tools/lifecycle-contract.test.mjs +++ b/tools/lifecycle-contract.test.mjs @@ -1374,7 +1374,7 @@ test('passenger explicit server success is terminal without a post-save row requ assert.doesNotMatch(passengerBranch, /passengerRequery|verifyLifecycleOperation/); assert.doesNotMatch(background, /attemptAutomaticPassengerReconciliation/); assert.match(background, /名单已取得 ERP 明确成功响应,按业务规则确认录入成功/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.185'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.186'/); }); test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => { @@ -1584,7 +1584,7 @@ test('lifecycle execution keeps the MV3 worker alive until the durable task sett assert.match(background, /arrangement_resource_candidate_data_missing/); }); -test('extension 0.5.185 keeps reload dormant unless its durable execution state is fully idle', async () => { +test('extension 0.5.186 keeps reload dormant unless its durable execution state is fully idle', async () => { const [background, bridge] = await Promise.all([ readFile(new URL('../chrome-extension/ltjt-order-assistant/background.js', import.meta.url), 'utf8'), readFile(new URL('../chrome-extension/ltjt-order-assistant/business-bridge.js', import.meta.url), 'utf8') @@ -2035,7 +2035,7 @@ test('delete guard distinguishes independent, shared child, and shared parent ro for (const operation of operations) assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; ')); const mapping = JSON.parse(await readFile(new URL('../mappings/lifecycle.mapping.json', import.meta.url), 'utf8')); - assert.equal(mapping.current_extension_version, '0.5.185'); + assert.equal(mapping.current_extension_version, '0.5.186'); assert.equal( mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'], 'ertrenshu' @@ -2419,10 +2419,10 @@ test('schema and browser adapters contain the v2 safety fields and no confirm ov assert.match(inpage, /`ys_danweiid\$\{index\}`, resolvedCustomerId/); assert.doesNotMatch(inpage, /product_customer_source_region|sourceRegionCheck|source_reference/); assert.doesNotMatch(teamBatchInpage, /product_customer_source_region|sourceRegionCheck/); - assert.equal(extensionManifest.version, '0.5.185'); - assert.match(inpage, /version: '0\.5\.185'/); - assert.match(teamBatchInpage, /version: '0\.5\.185'/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.185'/); + assert.equal(extensionManifest.version, '0.5.186'); + assert.match(inpage, /version: '0\.5\.186'/); + assert.match(teamBatchInpage, /version: '0\.5\.186'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.186'/); assert.match(inpage, /function strictIsoDate\(value\)/); assert.match(inpage, /const startDate = strictIsoDate\(current\('riqi'\)\)/); assert.match(inpage, /const endDate = strictIsoDate\(current\('riqis'\)\)/);