diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md index 51190496..48eb4ea5 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md @@ -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 通过后也不自动制造测试数据。等待第二位真实员工出现真实报销需求,由其本人使用真实账号和真实票据完成正常流程;观察者只记录,不代替操作。 +- 若现实条件迫使熟练管理员代走,结果必须标记为“真人摩擦下限”,不能作为五人自然采用已验证的证据。 +- 摩擦点全部记录,包含发生阶段、可复现现象和是否阻断;本轮最多选择三个进入后续处理。认证、授权或数据安全问题无论排序如何都立即停止并另批。 diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md index afdb6c8e..2e6da6da 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md @@ -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;直属主管仅覆盖适用人员中的一部分,新增真人首次提交前必须逐人核对主管关系。第二位真实员工试走仍需在真实需求出现时另行建任务,不制造测试业务。 diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md index 63a649a8..f8b9777b 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md @@ -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 diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/research/production-reimbursement-readiness-pre.cjs b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/research/production-reimbursement-readiness-pre.cjs new file mode 100644 index 00000000..44c5e4d4 --- /dev/null +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/research/production-reimbursement-readiness-pre.cjs @@ -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 }; diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/research/run-production-reimbursement-readiness-pre.sh b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/research/run-production-reimbursement-readiness-pre.sh new file mode 100644 index 00000000..55191afe --- /dev/null +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/research/run-production-reimbursement-readiness-pre.sh @@ -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 diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/task.json b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/task.json index 0c56eebb..6f7a75e5 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/task.json +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/task.json @@ -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": {} }