收口(07-27-yang-jun-reimbursement-readiness): 确认报销闭环与推广就绪

这个提交包含在:
2026-08-23 13:25:03 +08:00
父节点 e6717378aa
当前提交 ebf0d38154
共修改 6 个文件,包含 506 行新增和 7 行删除
@@ -324,3 +324,37 @@ processingProgress.stages[] = {
- seed/smoke 只输出 readiness、计数和布尔状态,不输出完整邮箱、电话、企业微信 corp/agent 标识、redirect URI 或一次性密码。需要首次凭据时必须通过受控配置渠道提供,不从部署日志交付。
- preflight 失败不需要回滚,因为尚未同步。镜像构建或 smoke 失败时沿用旧容器/既有发布回退合同,不通过重建 `.env` 作为常规路径。
- 回归至少覆盖错误仓库根、缺失远端 env、无效 existing 数据目录都不会调用 rsync;正确合同可进入 rsync;Docker 缓存顺序与日志禁敏片段均有静态断言。
## 15. 五人推广前的采用扩展就绪检查
### 15.1 目标与证据层级
- 本阶段回答“当前生产是否具备让第二位及更多真实员工开始走报销的前置条件”,不再回答“mock 自动化能否走通界面”。代码测试证明合同实现,生产只读事实证明账号、权限、配置和历史业务当前状态,两类证据不得互相替代。
- 既有任务证据已确认至少一笔真实报销完成提交、主管审批、财务复核与付款批次复核;采用扩展查询须现场判断它是否继续完成到付款、回单,以及其他记录的实际停点,不能沿用旧快照假定完整闭环。
- `people_lifecycle` 仅是采用度模块口径;人员分母从 `employees` 及其认证/绑定关系读取。法体配置只作为现状字段输出,不进入 go/no-go 硬门禁。
### 15.2 只读数据合同
只读结果按六组输出,所有分组使用各自实体独立计数:
1. **人员与登录**:active 员工数、具备至少一种可登录标识的 active 员工数、无可登录标识数;只输出计数与布尔,不输出标识本身。
2. **关系与权限**:普通员工主管关系覆盖数;`expense.submit`、`invoice.upload`、`expense.approve` 分别按适用人群统计具备/缺失数量,不用角色名称代替有效权限计算。
3. **主数据与门禁**:active 费用类型数、active 报销 workflow 数、相关 feature flag/effective state、提交前检查所需配置;active 法体数量单列为观察项。
4. **票据能力**:上传路径是否启用、OCR provider/mode 是否为受支持现场值、必要 worker/队列状态是否可观察;只输出枚举、布尔和计数,不输出 Secret、文件或 OCR payload。
5. **生命周期漏斗**:报销单按 `draft/reviewing/approved/paid/rejected/cancelled`,票据池按 `pending_ocr/ocr_failed/available/bound/archived`,workflow instance/task 按各自枚举,付款批次按 `draft/reviewed/executed/failed/cancelled`,回单按存在/缺失独立聚合。
6. **闭环判定**:以关联关系证明是否存在至少一条“已提交 → workflow 完成 → 付款批次已执行 → 回单存在”的完整链;同时把其他记录归入明确停点。不得通过总量相等或时间接近推定关联。
本地汇总器必须 fail closed:预期枚举、模块或查询结果缺失,字段名不匹配,或一个预期有分类输入的过滤器产出空集合时,报告失败并保留原因;禁止将解析失败序列化为全零。
### 15.3 执行边界与停止条件
- 先完成本地静态合同复核,再单独申请生产只读 PRE。PRE 仅核对受控身份、运行 SHA、migration、回滚窗口及查询能力,不读取业务正文。
- 正式 readiness 查询严格只读,输出经本地脱敏后只保留计数、布尔和状态分布;姓名、邮箱、手机号、员工号、金额、单号、UUID、票据/附件内容和原始 payload 均不得进入任务文档、日志或对话。
- 查询失败、数据模型与预期不一致、闭环无法可靠关联或脱敏断言失败时停止;允许如实标记 supplemental read,但不得把补读包装成首次严格执行。
- 发现账号、权限、主数据、OCR 或历史状态缺口时仅形成 remediation 清单。任何修复、登录或业务 mutation 都需要新的明确批准,不因“只差一项”顺手执行。
### 15.4 真人采用验证
- readiness 通过后也不自动制造测试数据。等待第二位真实员工出现真实报销需求,由其本人使用真实账号和真实票据完成正常流程;观察者只记录,不代替操作。
- 若现实条件迫使熟练管理员代走,结果必须标记为“真人摩擦下限”,不能作为五人自然采用已验证的证据。
- 摩擦点全部记录,包含发生阶段、可复现现象和是否阻断;本轮最多选择三个进入后续处理。认证、授权或数据安全问题无论排序如何都立即停止并另批。
@@ -91,11 +91,11 @@
- [x] 在杨俊提交前通过现有审批规则管理接口把三档规则收敛为唯一 `[0,+∞)` 规则,审批链固定为 `department_manager → finance`;不扩角色、不保留 CEO 重复节点。
- [x] 规则写入前后只读核对 499.99 / 500 / 5000 三个金额,确认解析结果完全相同且 active `finance` 有唯一实际处理人。
- [ ] 请杨俊本人从企业微信进入 OA,确认身份解析成功并能看到报销入口。
- [x] 杨俊本人从企业微信进入 OA,身份解析成功并能看到报销入口;用户后续确认该差旅报销已经走完整个业务链路。
- [x] 杨俊使用真实业务数据创建报销,至少添加一条费用明细并上传/绑定状态有效的真实票据后提交。
- [ ] 确认马建勇能看到该真实审批待办并按业务事实处理。
- [ ] 审批通过后确认单据进入王芝玲负责的财务处理链路;后续付款批次继续遵守既有 maker-checker。
- [ ] 用户验收期间不在聊天、截图或任务证据中粘贴票据、金额、银行资料或完整账号。
- [x] 马建勇已看到真实审批待办并按业务事实处理;完成状态由用户确认,并由后续生产只读闭环查询交叉验证。
- [x] 单据已完成王芝玲负责的财务处理链路、付款批次 maker-checker、真实付款和回单留存;只读查询确认存在关联的 executed 批次与已批准回单。
- [x] 用户验收记录与任务证据只保留脱敏状态、计数和结论,未写入票据、金额、银行资料或完整账号。
## 6.1 审批详情可达性修复
@@ -150,7 +150,7 @@
- [x] 手工触发 automatic backup daily 和源端 watchdog,二者均为 `success/0`;daily 成功以异机接收端完成文件、manifest、哈希校验和原子 finalize 为既有合同。
- [x] 记录非阻断依赖 finding:本次镜像构建审计报告 client 18 个风险(10 high),server 32 个风险(20 high、2 critical);未在发布窗口执行自动升级,留待独立依赖升级和回归任务。
- [x] 记录证据最小化 finding:既有 external/runtime smoke 输出仍包含不必要的配置标识和测试管理员联系方式;本任务不复述这些值,后续应独立收敛为状态与计数输出。
- [ ] 请用户在企微自建应用刷新后先用无业务数据 PDF 验证原生预览,再由马建勇查看当前真实报销的发票与附件;材料真实可见前不得完成主管审批。
- [x] 用户已确认真实报销材料能够在企微查看;该报销后续已完成主管审批及完整业务闭环。
## 6.7 移动报销查看范围对齐
@@ -379,3 +379,42 @@
- 部署加固实现:新增既有部署包装入口,只从 automatic-backup systemd unit 读取 6 个 allowlist 参数且不回显值;远端仓库根、普通非 symlink `.env`、existing PGDATA 与 storage 在 `mkdir`/rsync 前检查。Dockerfile 把 `apk add`、client/server `npm ci` 移到 build SHA 之前;seed/smoke 不再打印完整登录标识、联系方式、企业微信标识、redirect URI 或生成凭据。
- Docker 双 SHA 实测:第一次冷构建 `server_build` 通过,基础包约 124 秒、client `npm ci` 约 66 秒、server `npm ci` 约 85 秒;仅把前后端构建 SHA 改为另一组值后,三层均明确显示 `CACHED`,第二次约 78 秒,主要剩余前端 typecheck/Vite/PWA 构建与薄的镜像导出。第二个镜像内完整 `BUILD_SHA` 与前端 12 位标识均命中预期。
- 加固验证:部署安全聚焦测试 7/7;server 全量 172 个文件、1136 项通过,退出码 0;Prisma validate、3 个 shell 语法、4 个 JS 语法、环境合同、task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 均通过。文档审计为 0 error、1 条既有汇总 warning(22 个历史未登记文件)。本步骤只完成本地流程加固,尚未提交、推送或部署。
## 9. 五人推广前的采用扩展阶段(本轮新增规划)
1. **本地静态合同复核**
- 核对当前报销创建/提交硬门禁、`resolveEntityPrecheck`、权限解析、Prisma 状态枚举及历史闭环关联键。
- 确认 `people_lifecycle` 不进入人员分母,法体不进入提交硬门禁;把各实体的计数口径和允许输出字段写成查询合同。
- 为脱敏汇总增加 fail-closed 判据:预期模块/枚举缺失、字段名不匹配或异常空摘要立即失败。
2. **申请并执行生产只读 PRE**
- 只读确认执行身份、运行 SHA、migration pending 状态、回滚窗口和数据库只读通道;保存脱敏基线。
- 本步骤不登录真人账号、不调用业务 mutation、不修改生产配置或数据。PRE 结果交用户复核后,才申请正式 readiness 查询批准。
3. **严格一次 readiness 与历史漏斗查询**
- 分别采集 active/可登录员工、主管覆盖及三项有效权限;active 费用类型、workflow、feature flag、票据上传/OCR 和提交阻断配置。
- 分别按报销、票据池、workflow instance/task、付款批次、回单聚合状态,并按真实关联判断完整闭环和各停点。
- 输出只含计数、布尔、枚举分布和脱敏结论;如需补读,明确登记为 supplemental,不改写首次执行次数。
4. **形成 go/no-go 矩阵**
- 将缺口分为:可直接推广、推广前必须处理、只记录不阻断;法体默认属于观察项,除非现场代码合同出现新证据。
- 任一需要账号、权限、主数据、OCR、历史状态或代码变化的处理项单独规划、单独批准。本阶段不修复。
5. **决定真人试走是否启动**
- 仅在 readiness 结论允许且用户另行批准后,等待第二位真实员工的真实报销需求;不创建合成业务。
- 由本人走上传票据、建单、明细、绑定、提交及后续真实审批/财务链;熟练管理员代走时标记为“真人摩擦下限”。
- 全部摩擦记录,本轮最多处理三个;登录、认证、权限、数据安全或需生产写入的异常立即停止,不现场绕过。
6. **本轮规划验证与停点**
- 运行 task validate、Trellis governance、process contract、docs registry audit、敏感内容扫描与 `git diff --check`。
- 本轮停在规划只读复核;不得因 task 已为 `in_progress` 而自动执行生产 PRE、readiness 查询或真人试走。
### 9.1 首次生产 PRE 尝试(不完整,停止待补采批准)
- 唯一一次生产执行已通过精确生产 origin/目录身份、运行构建与 `origin/main` 候选一致、migration `pending=none`,回滚窗口为 `OPEN / terminatedRows=0 / basis=terminated_status_count`。
- readiness 聚合器在 `BEGIN TRANSACTION READ ONLY` 内成功退出,但本地输出校验器错误地要求运行 `config.buildSha` 等于 40 位候选 SHA;当前合同实际为候选的 12 位公开前缀。校验因此返回 `QUERY_OUTPUT_INVALID`,安全摘要没有输出或留存,员工/权限/生命周期分布均不得从本次执行推断。
- 本次没有登录真人账号、调用业务 API mutation、修改配置或执行数据库写入;没有自动重试。校验器已按既有公开构建标识合同修正为比较 `expected.slice(0, 12)`,后续只允许在用户明确批准后以 supplemental read 重新采集,并如实记为第二次只读查询。
### 9.2 Supplemental 生产只读核验与任务收口
- 用户明确批准后于 2026-08-23 执行一次 supplemental read。生产身份、活动运行构建、`migration pending=none`、只读事务和回滚窗口均通过;查询执行 1 次、重试 0 次,未登录真人账号、调用业务 mutation、修改配置或写数据库。
- 人员与权限:6 名 active 员工均具有登录标识、密码凭据和企微绑定;6 人具备 `expense.submit` 与 `invoice.upload`,4 人具备 `expense.approve`。3 人具有有效且不同人的 active 直属主管;杨俊与马建勇的唯一性、active、审批权限和直属关系合同全部成立。
- 主数据与能力:38 个 active 费用类型、1 个 active 报销 workflow、1 个 active 法体观察项;票据上传已配置,生产 OCR provider 为 `tencent`,付款批次开关有效。发票验真开关关闭仅作为观察项,不冒充腾讯 OCR 未启用。
- 生命周期:报销 `paid=1`、`draft=1`,其余状态为 0;报销 workflow `approved=1`、`terminated=1`,任务 `completed=3` 且无 pending;付款批次 `executed=1`、`cancelled=1` 且无 draft/reviewed;回单 1 份且治理状态为 approved;票据均已 archived。
- 关联闭环查询确认 `completeSubmitApprovePayReceiptLoops=1`,因此杨俊差旅报销的“提交 → 审批 → 付款 → 回单”已走完,旧待办完成勘误。现存 1 条 draft 和 1 个 cancelled 批次作为历史停点保留,本轮不读取正文、不自动修复或删除。
- 推广矩阵结论为 `CONDITIONAL_GO`:登录、企微绑定、提交/上传权限、主数据、OCR、workflow、付款和历史闭环均为 GO;直属主管仅覆盖适用人员中的一部分,新增真人首次提交前必须逐人核对主管关系。第二位真实员工试走仍需在真实需求出现时另行建任务,不制造测试业务。
@@ -61,10 +61,17 @@
- R5 不得继续作为无人执行的孤立 spec。将其登记到现有本地候选门禁的 acceptance spec 清单,使 `run-local-candidate.sh` 每次候选回归都执行;其余未被任何门禁引用的历史 E2E 仅记录为后续测试资产治理范围,本轮不批量接入、跳过或删除。
- 本轮生产发布暴露出既有流程仍依赖人工解释 Compose `working_dir`,且远端 `.env`/数据目录检查发生在 rsync 之后;标准发布必须在任何远端同步前只读确认目标是仓库根目录、`SYNC_ENV=0` 的远端 `.env` 存在、existing 数据目录有效,任一不满足即停止且不执行 rsync。既有部署参数以受控 systemd unit 为 canonical source,不再从 Compose 标签临时拼装路径。
- Docker 生产镜像的依赖层不得因每次变化的 `BUILD_SHA/VITE_BUILD_SHA` 失效。基础系统包与 client/server lockfile 依赖安装必须位于构建标识注入之前;构建标识仍须进入后端运行时和前端 12 位公开标识。部署流程与 smoke 日志不得输出完整登录标识、联系方式、企业微信配置标识或 Secret。
- 在把报销从单人真实验证扩展到 5—10 人团队采用前,先做一次生产只读就绪检查;检查“人、权限、主数据、票据/OCR、提交门禁和历史生命周期”当前是否可用,不以新增自动化测试代替生产配置与真实数据事实。
- 员工规模必须从生产 `employees` 及其认证/绑定关系独立统计;`people_lifecycle` 只代表模块采用度,不能推导员工数量。法体只记录当前配置现状,不作为报销创建或提交硬门禁;当前提交前检查以 `resolveEntityPrecheck` 实际合同为准。
- 只读检查必须分别给出:active 员工与可登录标识覆盖、主管关系及 `expense.submit`/`invoice.upload`/`expense.approve` 权限覆盖、active 费用类型与报销 workflow、票据上传/OCR 当前运行模式,以及其他会阻断创建或提交的现场配置。
- 历史业务必须按实体独立计数并给出状态分布,至少覆盖报销单、票据池、workflow instance/task、付款批次与回单;不得把采用度普查中的聚合行数表述为“发票数”或“报销数”。需要判断是否已有“提交 → 审批 → 付款 → 回单”的完整闭环,以及其余记录停在哪个阶段。
- 生产只读输出只保留计数、布尔、状态分布和脱敏结论;不得输出姓名、完整邮箱/手机号/员工号、金额、单号、UUID、票据/附件内容或原始 payload。任一查询或本地脱敏汇总异常时 fail closed,不用空摘要冒充零数据。
- 真人试走优先由第二位真实员工在真实报销需求发生时,使用本人账号和真实票据正常完成;不再制造测试业务。若只能由熟练管理员试走,结论必须标记为“真人摩擦下限”。观察到的摩擦全部记录,但本轮最多选择三个进入处理。
- 只读检查只形成就绪矩阵和 go/no-go 建议,不自动修复账号、权限、费用类型、workflow、OCR 或历史业务状态。认证、权限、数据安全或任何需要生产写入的问题必须停止并另行批准;真人登录、上传、提交、审批、付款和回单也均不包含在本次只读批准中。
## Scope
本轮范围只包括:修复生产 automatic-backup/WARCH3 timer 的构建合同并取得新鲜异机恢复点;从通讯录导入杨俊及两个既有企微测试成员;设置各自真实或测试主管关系;用两个测试成员完成一条可清理的生产报销体验验收;清理测试业务数据并停用测试 OA 账号;最后由杨俊本人使用真实数据发起报销;修复真实审批任务详情到完整报销单的移动端和 PC 入口。所有真实报销内容由杨俊本人提供和提交,所有真实审批与财务处理由对应真实人员完成。
本任务原范围包括:修复生产 automatic-backup/WARCH3 timer 的构建合同并取得新鲜异机恢复点;从通讯录导入杨俊及两个既有企微测试成员;设置各自真实或测试主管关系;用两个测试成员完成一条可清理的生产报销体验验收;清理测试业务数据并停用测试 OA 账号;最后由杨俊本人使用真实数据发起报销;修复真实审批任务详情到完整报销单的移动端和 PC 入口。采用扩展阶段仅追加一次生产只读就绪与历史生命周期检查,并据此决定是否允许第二位真实员工试走;所有真实报销内容由本人提供和提交,所有真实审批与财务处理由对应真实人员完成。
## Acceptance Criteria
@@ -117,6 +124,21 @@
- [x] 标准部署在 rsync 前验证仓库根目录、远端 `.env` 与 existing 数据目录;错误根目录或缺失 `.env` 的合成回归与脚本顺序断言证明失败发生在 rsync 之前。
- [x] Dockerfile 顺序合同及双 SHA 实际构建证明提交 SHA 变化不会使 `apk add`、client `npm ci` 或 server `npm ci` 层失效,同时前端短 SHA 与运行镜像完整 SHA 仍可核对;`/ready` 合同未改动。
- [x] 部署 runbook/spec 固化既有部署参数来源、只读 preflight、失败停止点和脱敏日志边界;本地验证通过后单独提交,不把该未部署的流程加固冒充为已上线。
- [x] 采用扩展 PRE 以现场生产身份、运行构建、migration 和回滚窗口为只读基线,未执行登录、业务 API mutation、配置写入或数据库写入。
- [x] 就绪报告从 `employees` 独立给出 active/可登录员工与主管关系覆盖,并分别给出三项有效权限覆盖;不再使用 `people_lifecycle` 推导员工数量。
- [x] 就绪报告给出 active 费用类型、active 报销 workflow、票据上传/OCR 运行模式和提交阻断配置;法体只记录现状且不被误列为硬门禁。
- [x] 报销单、票据池、workflow instance/task、付款批次与回单分别按自身状态独立计数,并明确是否存在完整“提交 → 审批 → 付款 → 回单”闭环及其余记录的停点。
- [x] 只读输出仅含计数、布尔、状态分布和脱敏结论;预期模块缺失、条目数异常或后处理结果为空时明确失败,不把静默丢数报告为零。
- [x] 基于只读事实形成五人推广 go/no-go 矩阵;任何账号、权限、配置或数据修复均列为另批项,本阶段不顺手写入生产。
- [ ] 若后续获批真人试走,优先由第二位真实员工在真实需求中完成;全部摩擦被记录、本轮最多处理三个,管理员代走时明确标记为“真人摩擦下限”。
## Lifecycle Closeout
- 用户于 2026-08-23 确认杨俊差旅报销已走完;同日 supplemental 生产只读查询独立确认至少存在一条“已提交 → workflow 完成 → 付款批次 executed → 已批准回单”的完整关联闭环,旧“待主管审批/财务处理/付款/回单”条目不再作为当前待办。
- 杨俊本人企微登录、真实提交、主管审批、财务复核、maker-checker、付款与回单的原始目标已经完成;生产查询未执行登录、mutation、配置写入或数据库写入。
- 五人推广结论为 `CONDITIONAL_GO`:账号登录、企微绑定、提交/上传权限、费用类型、报销 workflow、腾讯 OCR 和付款功能均具备;新员工首次真实报销前仍须逐人确认其直属主管关系,不以全员聚合计数替代个人门禁。
- 两个测试成员的企微 SSO 人工确认和“第二位真实员工试走”仍未执行,保持未勾选;它们不再阻断杨俊首笔真实业务闭环,若后续确有采用验证需求应由新的 Trellis task 承接。
- 腾讯云控制台的 OCR 免费包、后付费关闭和费用/余额告警属于现有差旅/OCR 任务的外部运维确认,不在本任务中伪造完成证据。
## Out of Scope
@@ -128,6 +150,9 @@
- 不使用真实发票、真实金额、真实银行资料或真实员工个人资料作为测试材料;不让测试单进入“通过/待财务/付款”状态。
- 不把手机号缺失视为导入阻断项;该员工使用企微或企业邮箱登录,后续按需补手机号。
- 不在本轮顺带修复请假、采购、差旅等其他审批类型的同类“业务单据详情入口”缺口;将其记录为后续统一审批中心体验任务候选。
- 在采用扩展只读阶段创建测试报销/票据、登录真人账号、上传真实票据、提交/审批报销、创建/复核/执行付款批次或上传回单。
- 因检查发现缺口而直接新增员工、修改主管或角色权限、补费用类型/workflow、切换 OCR、修复历史单据,或据此开发新功能。
- 把现有自动化测试数量或 mock E2E 通过当作生产配置齐全和团队采用就绪的充分证据。
## Test Plan
@@ -0,0 +1,270 @@
"use strict";
const { createHash } = require("node:crypto");
const path = require("node:path");
const TARGET_NAME_SHA256 = "d6ad24a313331e19381654bab85b3a53de2ce2d518d7fe6a819124cca81c9cc6";
const MANAGER_NAME_SHA256 = "5da0f6dcab6e9b1e0aa52e06e18a7cc9e181ae51c4a10b64fb3411cfd607424a";
const REIMBURSEMENT_STATUSES = Object.freeze(["draft", "reviewing", "approved", "paid", "rejected", "cancelled"]);
const INVOICE_STATUSES = Object.freeze(["pending_ocr", "ocr_failed", "available", "bound", "archived"]);
const WORKFLOW_INSTANCE_STATUSES = Object.freeze(["pending", "approved", "rejected", "cancelled", "terminated"]);
const WORKFLOW_TASK_STATUSES = Object.freeze(["pending", "completed", "rejected", "cancelled", "skipped"]);
const PAYMENT_BATCH_STATUSES = Object.freeze(["draft", "reviewed", "executed", "failed", "cancelled"]);
const RECEIPT_GOVERNANCE_STATUSES = Object.freeze([
"draft", "under_approval", "pending_review", "approved", "archived", "returned", "rejected", "quarantined",
]);
function sha256(value) {
return createHash("sha256").update(String(value), "utf8").digest("hex");
}
function zeroFilled(rows, allowed) {
const result = Object.fromEntries(allowed.map((status) => [status, 0]));
for (const row of rows) {
const status = String(row.status ?? "");
if (!allowed.includes(status)) throw new Error("unexpected_status");
result[status] = Number(row.count ?? 0);
}
return result;
}
function rolesGranting(permissions, permission) {
return Object.entries(permissions)
.filter(([, values]) => values.includes("*") || values.includes(permission))
.map(([role]) => role)
.sort();
}
async function grouped(client, sql, allowed) {
const result = await client.query(sql);
return zeroFilled(result.rows, allowed);
}
async function run() {
const appRoot = path.resolve(process.env.REIMBURSEMENT_READINESS_APP_ROOT || process.cwd());
const { Client } = require(path.join(appRoot, "node_modules/pg"));
const { getConfig } = require(path.join(appRoot, "src/config.js"));
const { ROLE_PERMISSIONS } = require(path.join(appRoot, "src/constants/permissions.js"));
const config = getConfig();
const permissionRoles = {
expenseSubmit: rolesGranting(ROLE_PERMISSIONS, "expense.submit"),
invoiceUpload: rolesGranting(ROLE_PERMISSIONS, "invoice.upload"),
expenseApprove: rolesGranting(ROLE_PERMISSIONS, "expense.approve"),
};
if (Object.values(permissionRoles).some((roles) => roles.length === 0)) {
throw new Error("permission_role_contract_empty");
}
const client = new Client({ connectionString: process.env.DATABASE_URL });
let inTransaction = false;
try {
await client.connect();
await client.query("BEGIN TRANSACTION READ ONLY");
inTransaction = true;
await client.query("SET LOCAL statement_timeout = '30s'");
await client.query("SET LOCAL lock_timeout = '2s'");
const readOnly = await client.query("SELECT current_setting('transaction_read_only') AS value");
if (readOnly.rows[0]?.value !== "on") throw new Error("transaction_not_read_only");
const employees = await client.query(`
SELECT
e.id,
e.name,
e.status::text AS status,
e.archived_at,
e.email,
e.phone,
e.password_hash,
e.must_change_password,
e.login_locked_until,
e.direct_manager_id,
EXISTS (SELECT 1 FROM wecom_identity_bindings w WHERE w.employee_id = e.id) AS has_wecom_binding,
COALESCE(array_agg(er.role_code::text ORDER BY er.role_code::text)
FILTER (WHERE er.role_code IS NOT NULL), ARRAY[]::text[]) AS role_codes
FROM employees e
LEFT JOIN employee_roles er ON er.employee_id = e.id
GROUP BY e.id
ORDER BY e.id
`);
if (!Array.isArray(employees.rows) || employees.rows.length === 0) throw new Error("employees_empty");
const active = employees.rows.filter((item) => item.status === "active" && item.archived_at == null);
const permissionCount = (permission) => active.filter((item) => (
item.role_codes.some((role) => permissionRoles[permission].includes(role))
)).length;
const byId = new Map(employees.rows.map((item) => [item.id, item]));
const targetMatches = employees.rows.filter((item) => sha256(item.name) === TARGET_NAME_SHA256);
const managerMatches = employees.rows.filter((item) => sha256(item.name) === MANAGER_NAME_SHA256);
const target = targetMatches.length === 1 ? targetMatches[0] : null;
const manager = managerMatches.length === 1 ? managerMatches[0] : null;
const employeeSummary = {
total: employees.rows.length,
active: active.length,
activeWithLoginIdentifier: active.filter((item) => (
String(item.email ?? "").trim() || String(item.phone ?? "").trim()
)).length,
activeWithPasswordCredential: active.filter((item) => String(item.password_hash ?? "").trim()).length,
activeCurrentlyUnlocked: active.filter((item) => !item.login_locked_until || new Date(item.login_locked_until) <= new Date()).length,
activeWithoutInitialPasswordChange: active.filter((item) => item.must_change_password === false).length,
activeWithWecomBinding: active.filter((item) => item.has_wecom_binding === true).length,
activeWithDirectManager: active.filter((item) => item.direct_manager_id != null).length,
activeWithValidActiveDistinctManager: active.filter((item) => {
const linked = byId.get(item.direct_manager_id);
return linked && linked.id !== item.id && linked.status === "active" && linked.archived_at == null;
}).length,
permissions: {
expenseSubmit: permissionCount("expenseSubmit"),
invoiceUpload: permissionCount("invoiceUpload"),
expenseApprove: permissionCount("expenseApprove"),
},
targetManagerContract: {
targetUnique: targetMatches.length === 1,
managerUnique: managerMatches.length === 1,
distinct: Boolean(target && manager && target.id !== manager.id),
targetActive: Boolean(target && target.status === "active" && target.archived_at == null),
managerActive: Boolean(manager && manager.status === "active" && manager.archived_at == null),
managerCanApprove: Boolean(manager && manager.role_codes.some((role) => permissionRoles.expenseApprove.includes(role))),
relationshipMatches: Boolean(target && manager && target.direct_manager_id === manager.id),
},
};
const expenseTypes = await client.query("SELECT count(*)::int AS count FROM expense_types WHERE enabled = true");
const workflows = await client.query(`
SELECT count(*)::int AS count
FROM workflow_definitions
WHERE entity_type = 'reimbursement' AND is_active = true AND archived_at IS NULL
`);
const legalEntities = await client.query("SELECT count(*)::int AS count FROM legal_entities WHERE enabled = true AND archived_at IS NULL");
const paymentFlag = await client.query(`
SELECT override_mode, version
FROM feature_flags
WHERE flag_key = 'payment_batch_v1'
`);
if (paymentFlag.rows.length !== 1) throw new Error("payment_flag_not_unique");
const reimbursements = await grouped(
client,
"SELECT status::text AS status, count(*)::int AS count FROM reimbursement_forms GROUP BY status ORDER BY status",
REIMBURSEMENT_STATUSES,
);
const invoices = await grouped(
client,
"SELECT status::text AS status, count(*)::int AS count FROM invoice_pool GROUP BY status ORDER BY status",
INVOICE_STATUSES,
);
const workflowInstances = await grouped(
client,
"SELECT current_status::text AS status, count(*)::int AS count FROM workflow_instances WHERE entity_type = 'reimbursement' GROUP BY current_status ORDER BY current_status",
WORKFLOW_INSTANCE_STATUSES,
);
const workflowTasks = await grouped(
client,
`SELECT wt.status::text AS status, count(*)::int AS count
FROM workflow_tasks wt
JOIN workflow_instances wi ON wi.id = wt.instance_id
WHERE wi.entity_type = 'reimbursement'
GROUP BY wt.status ORDER BY wt.status`,
WORKFLOW_TASK_STATUSES,
);
const paymentBatches = await grouped(
client,
"SELECT status::text AS status, count(*)::int AS count FROM payment_batches WHERE entity_type = 'reimbursement' GROUP BY status ORDER BY status",
PAYMENT_BATCH_STATUSES,
);
const receiptGovernance = await grouped(
client,
`SELECT pra.governance_status AS status, count(*)::int AS count
FROM payment_batch_receipt_attachments pra
JOIN payment_batches pb ON pb.id = pra.payment_batch_id
WHERE pb.entity_type = 'reimbursement'
GROUP BY pra.governance_status ORDER BY pra.governance_status`,
RECEIPT_GOVERNANCE_STATUSES,
);
const receiptCounts = await client.query(`
SELECT
count(*)::int AS total,
count(DISTINCT pra.payment_batch_id)::int AS batches_with_receipt,
count(DISTINCT pra.payment_batch_id) FILTER (WHERE pb.status = 'executed')::int AS executed_batches_with_receipt
FROM payment_batch_receipt_attachments pra
JOIN payment_batches pb ON pb.id = pra.payment_batch_id
WHERE pb.entity_type = 'reimbursement'
`);
const closedLoop = await client.query(`
SELECT count(DISTINCT rf.id)::int AS count
FROM reimbursement_forms rf
JOIN workflow_instances wi
ON wi.reimbursement_id = rf.id
AND wi.entity_type = 'reimbursement'
AND wi.current_status = 'approved'
JOIN payment_batch_items pbi ON pbi.entity_id = rf.id
JOIN payment_batches pb
ON pb.id = pbi.payment_batch_id
AND pb.entity_type = 'reimbursement'
AND pb.status = 'executed'
WHERE rf.status = 'paid'
AND EXISTS (
SELECT 1 FROM payment_batch_receipt_attachments pra
WHERE pra.payment_batch_id = pb.id
AND pra.governance_status = 'approved'
)
`);
const ocrProvider = String(process.env.OCR_PROVIDER ?? "off").trim().toLowerCase();
if (!["off", "mock", "tencent"].includes(ocrProvider)) throw new Error("ocr_provider_unexpected");
const result = {
status: "PASS",
transactionReadOnly: true,
sourceBuildSha: config.buildSha,
employees: employeeSummary,
configuration: {
activeExpenseTypes: Number(expenseTypes.rows[0]?.count ?? -1),
activeReimbursementWorkflows: Number(workflows.rows[0]?.count ?? -1),
activeLegalEntitiesObservationOnly: Number(legalEntities.rows[0]?.count ?? -1),
invoiceUploadConfigured: Number(config.maxUploadSizeBytes ?? 0) > 0,
ocrProvider,
invoiceVerificationEnabled: config.invoiceVerificationEnabled === true,
paymentBatchFlag: {
overrideMode: String(paymentFlag.rows[0].override_mode),
version: Number(paymentFlag.rows[0].version),
},
},
lifecycle: {
reimbursements,
invoices,
workflow: { instances: workflowInstances, tasks: workflowTasks },
paymentBatches,
receipts: {
total: Number(receiptCounts.rows[0]?.total ?? 0),
batchesWithReceipt: Number(receiptCounts.rows[0]?.batches_with_receipt ?? 0),
executedBatchesWithReceipt: Number(receiptCounts.rows[0]?.executed_batches_with_receipt ?? 0),
byGovernanceStatus: receiptGovernance,
},
completeSubmitApprovePayReceiptLoops: Number(closedLoop.rows[0]?.count ?? 0),
},
};
if (result.configuration.activeExpenseTypes < 0 || result.configuration.activeReimbursementWorkflows < 0) {
throw new Error("configuration_count_invalid");
}
if (!result.employees.targetManagerContract.targetUnique || !result.employees.targetManagerContract.managerUnique) {
throw new Error("target_manager_not_unique");
}
await client.query("COMMIT");
inTransaction = false;
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} catch {
if (inTransaction) {
try { await client.query("ROLLBACK"); } catch { /* fail closed */ }
}
process.stderr.write("[reimbursement-readiness-pre][FAILED] code=QUERY_FAILED\n");
process.exitCode = 1;
} finally {
await client.end().catch(() => {});
}
}
if (require.main === module || process.argv[1] === "-") {
run();
}
module.exports = { zeroFilled, rolesGranting };
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
QUERY_SCRIPT="$ROOT_DIR/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/research/production-reimbursement-readiness-pre.cjs"
IDENTITY_MODULE="$ROOT_DIR/deploy/trialrun/production-identity-bootstrap.mjs"
IDENTITY_REMOTE="$ROOT_DIR/deploy/trialrun/production-identity-bootstrap-remote.sh"
PREFLIGHT_SCRIPT="$ROOT_DIR/deploy/trialrun/production-preflight.mjs"
CONTRACT_LIB="$ROOT_DIR/deploy/trialrun/deployment-contract-lib.sh"
CONTRACT_UNIT="hyb-oa-automatic-backup-daily.service"
CONFIRMATION="READ_ONLY_REIMBURSEMENT_READINESS_PRE"
# shellcheck source=../../../../../deploy/trialrun/deployment-contract-lib.sh
source "$CONTRACT_LIB"
fail() {
printf '[reimbursement-readiness-pre-runner][FAILED] code=%s\n' "$1" >&2
exit 1
}
shell_quote() {
printf "'%s'" "${1//\'/\'\\\'\'}"
}
[[ "$#" -eq 1 && "${1:-}" == "$CONFIRMATION" ]] || fail "EXPLICIT_CONFIRMATION_REQUIRED"
for file in "$QUERY_SCRIPT" "$IDENTITY_MODULE" "$IDENTITY_REMOTE" "$PREFLIGHT_SCRIPT"; do
[[ -f "$file" && ! -L "$file" ]] || fail "LOCAL_CONTRACT_MISSING"
done
cd "$ROOT_DIR"
identity_fields="$({
node --input-type=module - "$IDENTITY_MODULE" <<'NODE'
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const modulePath = process.argv[2];
const mod = await import(pathToFileURL(modulePath));
const targetSource = execFileSync("git", ["show", "34181e3:deploy/trialrun/deploy.sh"], { encoding: "utf8" });
const projectSource = execFileSync("git", ["show", "5e2d038938265c926e06d3cc6d0841f6dcc06148:deploy/trialrun/deploy.sh"], { encoding: "utf8" });
const identity = mod.parseHistoricalDeploymentIdentity(targetSource, projectSource);
const runbook = fs.readFileSync("docs/trialrun/OPS7运维交接Runbook.md", "utf8");
const origin = mod.parseExpectedProductionOrigin(runbook);
process.stdout.write(`${identity.deployServer}\t${identity.composeProjectName}\t${origin}`);
NODE
} 2>/dev/null)" || fail "LOCAL_IDENTITY_PARSE_FAILED"
IFS=$'\t' read -r deploy_server expected_project expected_origin <<<"$identity_fields"
deployment_contract_safe_ssh_target "$deploy_server" || fail "DEPLOY_SERVER_INVALID"
deployment_contract_safe_project "$expected_project" || fail "COMPOSE_PROJECT_INVALID"
[[ "$expected_origin" == https://* ]] || fail "PRODUCTION_ORIGIN_INVALID"
PRODUCTION_IDENTITY_ONLY=1 node "$IDENTITY_MODULE" || fail "PRODUCTION_IDENTITY_FAILED"
declare -A contract=()
deployment_contract_load "$deploy_server" "$CONTRACT_UNIT" contract || fail "SYSTEMD_CONTRACT_UNAVAILABLE"
[[ "${contract[COMPOSE_PROJECT_NAME]}" == "$expected_project" ]] || fail "COMPOSE_PROJECT_MISMATCH"
origin_hash="$(printf '%s' "$expected_origin" | sha256sum | awk '{print $1}')"
database_output="$(
ssh "$deploy_server" \
"IDENTITY_BOOTSTRAP_MODE='database' REMOTE_DIR=$(shell_quote "${contract[REMOTE_DIR]}") REMOTE_ENV_FILE=$(shell_quote "${contract[REMOTE_ENV_FILE]}") COMPOSE_FILE=$(shell_quote "${contract[COMPOSE_FILE]}") COMPOSE_PROJECT_NAME=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") EXPECTED_PRODUCTION_ORIGIN_SHA256='$origin_hash' bash -s" \
< "$IDENTITY_REMOTE"
)" || fail "DATABASE_IDENTITY_FAILED"
database_hash="$(printf '%s' "$database_output" | sed -n 's/^\[production-identity-bootstrap\]\[DB_SHA256\] \([0-9a-f]\{64\}\)$/\1/p')"
[[ "$database_hash" =~ ^[0-9a-f]{64}$ ]] || fail "DATABASE_IDENTITY_PAYLOAD_INVALID"
candidate_commit="$(git rev-parse origin/main)"
[[ "$candidate_commit" =~ ^[0-9a-f]{40,64}$ ]] || fail "DEPLOYED_COMMIT_INVALID"
[[ -z "$(git diff --name-only "$candidate_commit"..HEAD -- client/src server/src deploy/trialrun/docker-compose.yml)" ]] \
|| fail "PRODUCT_DELTA_AFTER_DEPLOYED_COMMIT"
runtime_check_command="
set -euo pipefail
server_ids=\"\$(docker ps --filter label=com.docker.compose.project=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") --filter label=com.docker.compose.service=server --format '{{.ID}}')\"
[[ \"\$(printf '%s\\n' \"\$server_ids\" | sed '/^\$/d' | wc -l)\" -eq 1 ]]
runtime_sha=\"\$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' \"\$server_ids\" | sed -n 's/^BUILD_SHA=//p' | tail -n 1)\"
[[ \"\$runtime_sha\" == $(shell_quote "$candidate_commit") ]]
printf '[reimbursement-readiness-pre-runner][PASS] runtime_build_exact\\n'
"
ssh "$deploy_server" "bash -c $(shell_quote "$runtime_check_command")" || fail "RUNTIME_BUILD_MISMATCH"
report_dir="$(mktemp -d)"
trap 'rm -rf -- "$report_dir"' EXIT
EXPECTED_PRODUCTION_ORIGIN="$expected_origin" \
EXPECTED_PRODUCTION_DB_NAME_SHA256="$database_hash" \
EXPECTED_PENDING_MIGRATIONS="" \
EXPECTED_DEPLOY_SERVER="$deploy_server" \
EXPECTED_REMOTE_DIR="${contract[REMOTE_DIR]}" \
EXPECTED_COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \
DEPLOY_SERVER="$deploy_server" \
REMOTE_DIR="${contract[REMOTE_DIR]}" \
REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \
COMPOSE_FILE="${contract[COMPOSE_FILE]}" \
COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \
POSTGRES_DATA_DIR="${contract[POSTGRES_DATA_DIR]}" \
STORAGE_HOST_DIR="${contract[STORAGE_HOST_DIR]}" \
CANDIDATE_COMMIT="$candidate_commit" \
PRODUCTION_PREFLIGHT_REPORT_DIR="$report_dir/preflight" \
node "$PREFLIGHT_SCRIPT" || fail "PRODUCTION_PREFLIGHT_FAILED"
remote_query_command="
set -euo pipefail
server_ids=\"\$(docker ps --filter label=com.docker.compose.project=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") --filter label=com.docker.compose.service=server --format '{{.ID}}')\"
[[ \"\$(printf '%s\\n' \"\$server_ids\" | sed '/^\$/d' | wc -l)\" -eq 1 ]]
docker exec -i -w /app/server \"\$server_ids\" node -
"
query_output="$(ssh "$deploy_server" "bash -c $(shell_quote "$remote_query_command")" < "$QUERY_SCRIPT")" \
|| fail "QUERY_EXECUTION_FAILED"
validated_output="$(printf '%s' "$query_output" | node -e '
const fs = require("node:fs");
const expected = process.argv[1];
const value = JSON.parse(fs.readFileSync(0, "utf8"));
if (value.status !== "PASS" || value.transactionReadOnly !== true) process.exit(1);
if (value.sourceBuildSha !== expected.slice(0, 12)) process.exit(1);
if (!value.employees || !value.configuration || !value.lifecycle) process.exit(1);
for (const key of [
"reimbursements",
"invoices",
"workflow",
"paymentBatches",
"receipts",
"completeSubmitApprovePayReceiptLoops",
]) {
if (!(key in value.lifecycle)) process.exit(1);
}
process.stdout.write(JSON.stringify(value, null, 2));
' "$candidate_commit")" || fail "QUERY_OUTPUT_INVALID"
printf '%s\n' "$validated_output"
printf '[reimbursement-readiness-pre-runner][PASS] queryExecuted=1 zeroRetry=1\n' >&2
@@ -32,6 +32,6 @@
".trellis/spec/hyb-oa/wecom-boundary.md",
".trellis/spec/hyb-oa/deployment-trialrun.md"
],
"notes": "真实报销已提交并完成主管审批、财务复核与付款批次复核;当前旧快照批次按既定安全口径永久不支持制单,剩余业务动作仅为人工转账、上传回单、登记付款完成。该任务同时承接付款完成通知与 executed 状态的首次真实观察,不取消重建批次。",
"notes": "用户确认杨俊差旅报销已走完;2026-08-23 supplemental 生产只读核验确认存在一条提交、workflow 完成、付款批次 executed、已批准回单的完整关联闭环。五人推广为 CONDITIONAL_GO,新员工首次真实提交前仍须逐人确认直属主管;测试成员企微 SSO 与第二位真人试走未执行,不阻断本任务收口。",
"meta": {}
}