WARCH1 实现企微审批最小历史留存

这个提交包含在:
2026-05-20 00:22:27 +08:00
父节点 56a8e2bb0c
当前提交 5cd960cf90
共修改 28 个文件,包含 3081 行新增和 261 行删除
文件差异因一行或多行过长而隐藏
+4 -2
查看文件
@@ -58,11 +58,12 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
- 2026-05-19 WAPC2FLIVE 完成企微审批 OA-POC 样件 live 创建和 WAPC2 自动复跑:用户批准一次 live 创建并选择保留审计后,先以 `SYNC_ENV=0` 部署修正后的 runner,再远端 dry-run 复核 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053335Z/summary.json` 为 `PASS_FIXTURE_DRY_RUN_READY`。首次 live 写入 API 因旧 `create_template` payload 返回 `301086 zh_CN text empty`,未创建任何模板或审批单;随后将 payload 修正为官方 `template_name` + `property` controls 结构并复跑有效 live,报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,请假/补卡 OA-POC 测试模板和低风险测试审批单均创建成功,`checks.autoRerun.status=OK` 且结论 `PASS_APPROVAL_POC`。报告只记录脱敏 template_id / `sp_no` 前缀;测试模板和测试审批记录保留审计,不自动删除。本轮不配置真实回调、不做补偿拉取、不建镜像表、不进入 `wecom_attendance_approval_mirror_v1`。
- 2026-05-19 WCBK1 实现并完成企微审批回调只读 POC:新增只读 `GET/POST /api/wecom/approval/callback`、企微回调验签/AES 解密/事件归一化服务、`npm run check:wecom-approval-callback-poc` 和 `wecom-callback` process gate。已以 `SYNC_ENV=0` 部署并复检 `/ready`;初始远端报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T065414Z/summary.json` 为 `PARTIAL_CALLBACK_NEEDS_CONFIG`,synthetic 回调验签、解密和审批事件解析通过。配置真实回调后,远端报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` 达到 `PASS_CALLBACK_URL_VERIFIED`;用户随后由 `SMOKE101` 同意一条现有 OA-POC 补卡测试审批单,复跑报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 达到 `PASS_CALLBACK_EVENT_CONFIRMED`,脱敏证据记录 1 条已通过审批状态事件。本轮不建镜像表、不写 OA 业务数据、不写企微数据、不创建新模板或审批单。
- 2026-05-19 WEGOV1 完成企微主办治理与 OA 自建留存路线修正:本轮只更新 Issue CSV 和事实源,不改业务代码、不部署、不写 OA/企微数据。结论固定为“企微成熟功能优先上线,OA 做自建数据留存、审计、入口、消息和应急兜底”;HR/考勤日常流程优先企微主办,报销、采购、合同、项目、付款、发票、银行回单、高敏附件和经营审计继续 OA 主办。后续顺序调整为 `WACP3 -> WREAD1 -> WADM1 -> WARCH1 -> wecom_attendance_approval_mirror_v1`,其中 WARCH1 先保证企微不可用时可查看已保存历史,再进入完整镜像增强。
- 2026-05-19 WACP3/WACP3R 完成企微审批补偿拉取只读 POC:新增 `server/src/scripts/check-wecom-approval-compensation-poc.js` / `npm run check:wecom-approval-compensation-poc`,固定 `WECOM_AGENT_SECRET` 调用 `POST /cgi-bin/oa/getapprovalinfo`,按时间窗口和模板过滤拉审批单号,再复用 `getapprovaldetail` 拉详情并与 WCBK1 回调证据脱敏比对。初始公网报告因缺模板 ID 为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`;WACP3R 改为按 `SMOKE002` creator 自动发现 OA-POC 请假/补卡模板并复跑,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`:请假/补卡各拉到 1 条详情,finding 为回调证据文件未持久化。本轮未写 OA/企微数据,未保存完整模板 ID。
- 2026-05-19 WACP3/WACP3R 完成企微审批补偿拉取只读 POC:新增 `server/src/scripts/check-wecom-approval-compensation-poc.js` / `npm run check:wecom-approval-compensation-poc`,固定 `WECOM_AGENT_SECRET` 调用 `POST /cgi-bin/oa/getapprovalinfo`,按时间窗口和模板过滤拉审批单号,再复用 `getapprovaldetail` 拉详情并与 WCBK1 回调证据脱敏比对。初始公网报告因缺模板 ID 为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`;WACP3R 改为按 `SMOKE002` creator 自动发现 OA-POC 请假/补卡模板并复跑,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`:请假/补卡各拉到 1 条详情,当时 finding 为回调证据文件未持久化,已由后续 WARCH1 DB 回调事件承接。本轮未写 OA/企微数据,未保存完整模板 ID。
- 2026-05-19 WREAD1 新增企微审批可读性与误审批防护门禁:新增 `server/src/scripts/check-wecom-approval-readability-gate.js` / `npm run check:wecom-approval-readability-gate`,只读汇总 OA-POC 请假/补卡证据并输出真机确认清单。本轮以 `SYNC_ENV=0` 部署,不同步本地 env;部署后先只读复跑 WACP3R 自动发现模板并生成 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T125515Z/summary.json`,再执行 WREAD1,报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T125532Z/summary.json` 为 `PAUSED_MANUAL_CONFIRMATION`。本轮未新建企微模板或审批单,未写 OA/企微数据;下一步需要审批人真机分别确认请假/补卡消息卡片、列表、详情页和快捷同意/驳回误点风险。
- 2026-05-19 WREAD1F 按用户选择重新生成新样例:复用 `check:wecom-approval-fixtures` 远端 dry-run,`SMOKE002 -> SMOKE101` 预检通过。第一次 live 因企微模板名按日期重复返回 `301086`,未创建模板或审批单;随后将 OA-POC 模板名修正为带时间戳的唯一名称,focused vitest 17 tests 通过并以 `SYNC_ENV=0` 重新部署。第二次 live 报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T131958Z/summary.json` 为 `PASS_FIXTURE_CREATED`,已创建新的请假/补卡 OA-POC 测试模板和两条低风险测试审批单,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`。随后只读复跑 WACP3R `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T132031Z/summary.json` 为 `PASS_WITH_FINDINGS`,并生成 WREAD1 报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T132054Z/summary.json`,结论仍为 `PAUSED_MANUAL_CONFIRMATION`,等待审批人真机确认。
- 2026-05-19 WREAD1 人工真机确认收口:用户确认请假和补卡在企业微信消息卡片、列表、详情页均可以看清;驳回有二次确认,但同意按钮单击即通过;当前暂无额外字段补充建议。已用 `check:wecom-approval-readability-gate` 录入人工结果,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`,下一阶段允许进入 `WADM1 自建留存/镜像数据模型设计`。同意单击风险需在后续提示、审计留存或操作规程中处理;本轮未写 OA/企微业务数据,未创建新样例。
- 2026-05-19 WADM1 完成企微自建留存/镜像数据模型设计:本轮只更新 Issue CSV 和事实源,不改 Prisma schema、不写业务代码、不部署、不写 OA/企微业务数据。设计固定 `WecomApprovalArchive`、参与人/动作/字段摘要、附件摘要、回调事件、补偿拉取 run/item、对账 finding、打卡/人事快照复用边界、隐私保留、权限和迁移回滚策略。WACP3R “回调证据文件未持久化”的 finding 进入 WARCH1 入库实现;同意单击即通过进入 WARCH1 历史留存、审批轨迹和操作规程提示。下一阶段为 `WARCH1 最小自建历史留存实现`,仍不能直接进入完整审批镜像、应急接管、恢复回写或 RuleOps。
- 2026-05-19 WADM1 完成企微自建留存/镜像数据模型设计:本轮只更新 Issue CSV 和事实源,不改 Prisma schema、不写业务代码、不部署、不写 OA/企微业务数据。设计固定 `WecomApprovalArchive`、参与人/动作/字段摘要、附件摘要、回调事件、补偿拉取 run/item、对账 finding、打卡/人事快照复用边界、隐私保留、权限和迁移回滚策略。WACP3R “回调证据文件未持久化”的 finding 进入 WARCH1 入库实现;同意单击即通过进入 WARCH1 历史留存、审批轨迹和操作规程提示。该阶段已由下一条 WARCH1 承接,仍不能直接进入完整审批镜像、应急接管、恢复回写或 RuleOps。
- 2026-05-19 WARCH1 实现企微审批最小自建历史留存:新增审批留存 Prisma 表、回调事件表、补偿 run/item/finding、`wecomApprovalArchive` 规范化服务、回调 DB 入库默认路径、补偿拉取 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关和 `npm run check:wecom-approval-archive` 离线读取 runner。已以 `SYNC_ENV=0` 部署并应用 migration;远端补偿持久化报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 为 `PASS_WITH_FINDINGS`,`archivePersistence.status=OK`、`storedCount=2`;离线读取报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 为 `PASS_WITH_FINDINGS`,可读请假/补卡 2 条已通过历史。finding 是 WARCH1 前旧回调未持久化到 DB 的历史 gap,已作为对账 finding 留存,不是当前写入链路阻断。范围仅请假/补卡;不写企微数据、不创建模板或审批单、不新增正式管理页、不进入完整镜像、应急接管、恢复回写或 RuleOps。报告和日志只输出脱敏 `sp_no`、template、userid 前缀,DB 内保存完整外部键用于审计和补偿。
## 关键证据索引
@@ -123,6 +124,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
| WREAD1 | 已完成人工真机确认:请假/补卡消息卡片、列表、详情页均可看清,远端人工结果为 `PASS_READABLE`;风险记录为驳回有二次确认、同意单击即通过,后续进入 WADM1/WARCH1 的提示、审计留存或操作规程设计 | `issues/2026-05-19_20-39-40-wecom-approval-readability-gate-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` |
| WREAD1F | 已按用户选择生成新真机样例:修正 OA-POC 模板唯一命名后,公网 live 创建新的请假/补卡测试模板和两条低风险审批单;新样例支撑 WREAD1 人工确认并收口为 `PASS_READABLE` | `issues/2026-05-19_21-15-47-wecom-readability-fresh-fixture-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T131958Z/summary.json`、`/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` |
| WADM1 | 已完成 docs-only 数据模型设计:固定企微审批留存、回调事件、补偿拉取、对账 finding、打卡/人事快照边界、隐私权限和 WARCH1 最小实现切片;不改 schema、不部署、不写业务数据 | `issues/2026-05-19_21-55-33-wecom-archive-mirror-data-model-design-v1.csv` |
| WARCH1 | 已实现并部署验证最小审批历史留存:新增 Prisma 留存表和 migration、回调 DB 入库、补偿拉取双开关入库、离线 OA DB 读取 runner;公网已写入 OA 留存 2 条请假/补卡历史并离线可读,历史回调 gap 已审计化 | `issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json`、`/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` |
## 当前门禁
+2 -2
查看文件
@@ -22,7 +22,7 @@
- 企业微信打卡用于原始采集,保留其定位、人脸、Wi-Fi、规则与设备能力。
- OA 后续同步企业微信打卡规则、排班、记录、日报、月报和 HR/考勤审批镜像,形成正式考勤口径。
- 请假、补卡、外出、加班等考勤修正正常在企业微信完成;OA 只在应急模式下接管,并在企微恢复后 dry-run 和自动回写。
- `wecom_hr_master_sync_v1` 已本地实现并部署公网完成受控验证;最终保持 `force_off`。WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余企微缺字段先接受不补,归档绑定保留审计。WCBK1 已完成审批回调 URL 验证和一条真实测试审批事件确认。WACP3R 已自动发现 OA-POC 请假/补卡模板并复跑补偿拉取到 `PASS_WITH_FINDINGS`,finding 为回调证据文件未持久化。WEGOV1 后,后续考勤/审批镜像、应急接管、恢复回写或 RuleOps 仍必须先完成 WREAD1 可读性门禁、WADM1 自建留存/镜像数据模型和 WARCH1 最小历史留存,再另建 `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1` 合同。
- `wecom_hr_master_sync_v1` 已本地实现并部署公网完成受控验证;最终保持 `force_off`。WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余企微缺字段先接受不补,归档绑定保留审计。WCBK1 已完成审批回调 URL 验证和一条真实测试审批事件确认。WACP3R 已自动发现 OA-POC 请假/补卡模板并复跑补偿拉取到 `PASS_WITH_FINDINGS`,当时 finding 为回调证据文件未持久化;WARCH1 已补齐最小历史留存入库和离线读取能力。WEGOV1 后,后续考勤/审批完整镜像、应急接管、恢复回写或 RuleOps 仍必须另建 `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1` 合同。
### P0-3 按域镜像定义
- 企微主办域镜像到 OA:通讯录/人事助手、打卡、请假、补卡、外出、加班、假期余额和 HR/考勤规则。
@@ -63,7 +63,7 @@
- WHMSLIVE 公网验证中,`SMOKE002/测试员工`(脱敏 `oa_smoke***`)因企微侧缺手机号、邮箱和部门按 `missing_required_field` 跳过,证明缺字段不会写入占位数据;普通员工访问同步 API 已验证为 403。
- 已有 OA 员工只更新安全主数据字段,不覆盖银行账户、身份证、工资、合同附件或历史审批。
- WAPI 后 WHMS/WOUA 读取策略修正为通讯录 `user/list_id` + 可选成员详情 + 人事助手 `get_staff_info`,通讯录用 `WECOM_CONTACT_SECRET`,人事助手用 `WECOM_AGENT_SECRET`,不再靠 fallback 试错。
- WHMS/WOUA-FIX 不代表考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成;WRA1 后 WRV1 的 `PASS_WITH_CONFIG_GAPS` 只代表运行优先主链路可用且曾有审批/回调配置缺口;WMSG1 只证明消息和 Deeplink 可用;WAPC1 只证明审批配置准备清单已固化;WAPC2 只证明只读 runner 已具备;WAPC2FLIVE 只证明 OA-POC 样件可创建并可读;WCBK1 已证明真实回调 URL 验证、验签/解密和一条现有 OA-POC 测试审批单事件脱敏留证;WACP3R 已证明补偿拉取和审批详情读取可用但发现回调证据文件未持久化;WEGOV1 只修正路线和治理。后续先做 WREAD1 可读性门禁、WADM1 自建留存/镜像模型设计和 WARCH1 最小历史留存,不直接另建 `wecom_attendance_approval_mirror_v1`。
- WHMS/WOUA-FIX 不代表考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成;WRA1 后 WRV1 的 `PASS_WITH_CONFIG_GAPS` 只代表运行优先主链路可用且曾有审批/回调配置缺口;WMSG1 只证明消息和 Deeplink 可用;WAPC1 只证明审批配置准备清单已固化;WAPC2 只证明只读 runner 已具备;WAPC2FLIVE 只证明 OA-POC 样件可创建并可读;WCBK1 已证明真实回调 URL 验证、验签/解密和一条现有 OA-POC 测试审批单事件脱敏留证;WACP3R 已证明补偿拉取和审批详情读取可用但曾发现回调证据文件未持久化;WEGOV1 只修正路线和治理;WADM1/WARCH1 已补齐最小自建留存设计和实现。后续仍不能直接扩大到完整 `wecom_attendance_approval_mirror_v1`,需另建正式查询、外出/加班、打卡细项和权限验收合同。
---
+10 -9
查看文件
@@ -2,8 +2,8 @@
**文档状态**:当前企微 API 调研与本项目使用事实源。
**分类**:集成事实源 / API 能力目录。
**适用时间**:WAPI / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 / WACP3 / WACP3R / WREAD1 / WADM1 之后。
**关联 Issue**:`WAPI,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1`。
**适用时间**:WAPI / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 / WACP3 / WACP3R / WREAD1 / WADM1 / WARCH1 之后。
**关联 Issue**:`WAPI,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1`。
**最近核对**:2026-05-19。
**不得误读**:本文只记录本项目会用到的企业微信官方 API 摘要、链接和项目决策,不复制官方文档全文,不保存 Secret、access token、完整 userid、手机号、邮箱或企微原始 payload。官方文档和企业微信后台权限配置始终是最终依据。
@@ -58,9 +58,9 @@
| 打卡日报/月报/排班 | [日报](https://developer.work.weixin.qq.com/document/path/93374)、[月报](https://developer.work.weixin.qq.com/document/path/93387)、[排班](https://developer.work.weixin.qq.com/document/path/93380) | 官方打卡扩展接口 | `POST` 为主 | `WECOM_AGENT_SECRET` 或显式 `WECOM_CHECKIN_SECRET` | `NEEDS_API_POC` | 不进入当前运行优先主线;后续考勤镜像合同时再做只读 POC。 |
| 审批概述 | [审批](https://developer.work.weixin.qq.com/document/path/91854) | 模板、实例、回调、假期 | 混合 | `WECOM_AGENT_SECRET` | `PARTIAL_NEEDS_CONFIG` | HR/考勤员工日常审批可优先在企微处理,但 OA 镜像尚未实现。 |
| 审批模板详情 | [获取审批模板详情](https://developer.work.weixin.qq.com/document/path/91982) | `/cgi-bin/oa/gettemplatedetail` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_FOR_POC_FIXTURES` | WAPC2 runner 已可读模板详情;WAPC2FLIVE 已自动创建 OA-POC 请假/补卡测试模板并复跑得到 `PASS_APPROVAL_POC`。正式模板和外出/加班仍需单独配置或 POC。 |
| 审批申请详情 | [获取审批申请详情](https://developer.work.weixin.qq.com/document/path/91983) | `/cgi-bin/oa/getapprovaldetail` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_FOR_POC_FIXTURES` | WAPC2 runner 已可读审批详情;WAPC2FLIVE 已提交低风险请假/补卡测试审批单并自动复跑通过。测试单不得包含采购、合同、报销、发票、银行回单或高敏附件。 |
| 审批状态回调 | [审批申请状态变化回调通知](https://developer.work.weixin.qq.com/document/path/91815) | 事件回调 | `GET` URL 验证 / `POST` 事件 | 自建应用回调 Token/EncodingAESKey | `CAN_REUSE_NOW_FOR_CALLBACK_POC` | WCBK1 已部署只读 `GET/POST /api/wecom/approval/callback` 和 `check:wecom-approval-callback-poc`;公网 synthetic 验签/解密/解析通过后,远端配置真实回调并完成 URL 验证,报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` 为 `PASS_CALLBACK_URL_VERIFIED`;`SMOKE101` 同意现有 OA-POC 补卡测试单后,报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 为 `PASS_CALLBACK_EVENT_CONFIRMED`。该能力仅证明只读回调链路和脱敏证据,不代表镜像入库已实现。 |
| 批量获取审批单号 | [批量获取审批单号](https://developer.work.weixin.qq.com/document/path/91816) | `/cgi-bin/oa/getapprovalinfo` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_WITH_FINDINGS` | WACP3/WACP3R 已新增 `check:wecom-approval-compensation-poc`,支持按 `creator` 自动发现 OA-POC 模板,再按 `template_id` filters 拉审批单号,并用 `getapprovaldetail` 拉详情;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`,证明请假/补卡补偿拉取和详情读取可用,finding 为回调证据文件未持久化导致当前无法跨部署比对历史回调。 |
| 审批申请详情 | [获取审批申请详情](https://developer.work.weixin.qq.com/document/path/91983) | `/cgi-bin/oa/getapprovaldetail` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_FOR_ARCHIVE` | WAPC2 runner 已可读审批详情;WAPC2FLIVE 已提交低风险请假/补卡测试审批单并自动复跑通过。WARCH1 复用该接口作为补偿拉取详情最终校准来源,可在 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关下写 OA 自建留存表;测试单不得包含采购、合同、报销、发票、银行回单或高敏附件。 |
| 审批状态回调 | [审批申请状态变化回调通知](https://developer.work.weixin.qq.com/document/path/91815) | 事件回调 | `GET` URL 验证 / `POST` 事件 | 自建应用回调 Token/EncodingAESKey | `CAN_REUSE_NOW_FOR_ARCHIVE` | WCBK1 已部署只读 `GET/POST /api/wecom/approval/callback` 和 `check:wecom-approval-callback-poc`;公网真实事件已确认。WARCH1 后 POST 回调默认把规范化事件写入 `WecomApprovalCallbackEvent` 和 `WecomApprovalArchive`,DB 写入失败返回非 200 触发企微重试;不保存 Token、EncodingAESKey、原始 XML、密文或原始 payload。 |
| 批量获取审批单号 | [批量获取审批单号](https://developer.work.weixin.qq.com/document/path/91816) | `/cgi-bin/oa/getapprovalinfo` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_FOR_ARCHIVE` | WACP3/WACP3R 已新增 `check:wecom-approval-compensation-poc`,支持按 `creator` 自动发现 OA-POC 模板,再按 `template_id` filters 拉审批单号,并用 `getapprovaldetail` 拉详情;WARCH1 增加 OA DB 持久化双开关、补偿 run/item 和对账 finding,用于解决回调证据文件跨部署丢失问题。 |
| 提交审批申请 | [提交审批申请](https://developer.work.weixin.qq.com/document/path/91853) | `/cgi-bin/oa/applyevent` | `POST` | `WECOM_AGENT_SECRET` | `DONE_LIVE_VERIFIED_FOR_OA_POC_FIXTURES` | 已接入 WAPC2F QA-only runner;仅可用于 OA-POC 低风险请假/补卡测试审批单自动样件。默认 dry-run 不获取写入 token、不提交审批;live 写入需显式 CLI、`WECOM_APPROVAL_FIXTURE_WRITE=1` 和人工批准,申请人/审批人必须非自审。WAPC2FLIVE 已提交请假/补卡测试审批单并保留审计;OA 主办报销、采购、合同、项目不迁到企微原生审批。 |
| 假期余额 | [获取成员假期余额](https://developer.work.weixin.qq.com/document/path/93376) | `/cgi-bin/oa/vacation/getuservacationquota` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_OR_NEEDS_PERMISSION` | WCPC 已证明可读但历史有权限错误;后续作为考勤镜像的只读项。 |
| 审批模板创建/更新 | [创建审批模板](https://developer.work.weixin.qq.com/document/path/97437)、[更新审批模板](https://developer.work.weixin.qq.com/document/path/97438) | `/cgi-bin/oa/approval/create_template` 等官方模板写接口 | `POST` | `WECOM_AGENT_SECRET` | `DONE_LIVE_VERIFIED_FOR_OA_POC_FIXTURES` | WAPC2FLIVE 已用 `template_name` + `property` controls 结构创建 OA-POC 请假/补卡测试模板并保留审计。正式模板更新、外出/加班模板、RuleOps 写入仍留后期合同。 |
@@ -109,22 +109,23 @@ WPROC1 将 WAPC2 之后的审批路线固定为:
- `WACP3R` 已把补偿拉取复跑改为自动发现:先按 `SMOKE002` creator 获取 OA-POC 审批单号和详情,识别请假/补卡模板,再按模板过滤拉取详情;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`。finding 是回调证据文件未持久化,不得误读为镜像入库或自建留存已完成。
- `WREAD1/WREAD1F` 已新增只读可读性 runner 并完成请假/补卡真机确认,最终公网报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json`,结论 `PASS_READABLE`。用户确认审批消息、审批列表和详情页可看清;驳回有二次确认,同意单击即通过,后续在 WADM1/WARCH1 中处理提示和审计留存风险。
- `WADM1` 必须先设计自建留存和镜像字段、状态机、幂等键、索引、隐私保留和迁移回滚方案,再进入 schema 或业务代码实现。
- `WARCH1` 必须先实现最小自建历史留存,让 OA 在企微 API 不可用时仍可查看已保存历史,再进入完整镜像增强。
- `WARCH1` 已把最小自建历史留存落到代码并完成公网验证:回调事件和补偿拉取可写 OA DB,离线 runner 可不调用企微 API 读取已保存历史。公网补偿持久化报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 为 `PASS_WITH_FINDINGS`,离线读取报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 为 `PASS_WITH_FINDINGS`;finding 是 WARCH1 前旧回调未入 DB 的历史 gap。该阶段仍只覆盖请假/补卡,不等于完整镜像增强。
### 6.1 WADM1 数据留存与 API 结果边界
WADM1 将已验证的审批能力固定为“可读可留存”的设计输入,但不新增企微 API:
WADM1/WARCH1 将已验证的审批能力固定为“可读可留存”的实现输入,但不新增企微 API:
- 模板详情、审批详情、审批状态回调和批量获取审批单号仍全部固定使用 `WECOM_AGENT_SECRET` 或自建应用回调 Token/EncodingAESKey;不得改为多 Secret 试错。
- `getapprovaldetail` 和回调解密结果进入 OA DB 前必须先规范化,保存业务必要字段、完整 `sp_no`、完整 `template_id`、企微 userid、`approval_archive_id` 和摘要 digest;报告和日志只输出脱敏前缀。
- WACP3R 的 `PASS_WITH_FINDINGS` 表明补偿拉取可用,但当前回调证据文件未持久化。WARCH1 必须把回调事件、补偿拉取 run/item 和对账 finding 入库,才能支撑跨部署比对。
- WACP3R 的 `PASS_WITH_FINDINGS` 表明补偿拉取可用,但当时回调证据文件未持久化。WARCH1 已新增 `WecomApprovalCallbackEvent`、`WecomApprovalCompensationRun`、`WecomApprovalCompensationItem` 和 `WecomApprovalReconcileFinding`,用于支撑跨部署比对。
- 不保存原始 XML、密文、access token、Secret、Token、EncodingAESKey 或无治理原始 payload。若后续业务要求保存原始 payload,必须另开加密存储和访问审计合同。
- WARCH1 的 QA-only 离线读取命令为 `npm run check:wecom-approval-archive -- --offline --format=json`;该命令只读 OA DB,不调用企微 API。当前公网离线报告可读取 2 条已通过请假/补卡历史,报告只含脱敏前缀、计数、状态和 finding 类型。
- 请假/补卡已通过 WREAD1 真机可读性;外出/加班仍需后续样例或处理决策,不能因接口相同而自动放行。
## 7. 运行优先结论
- 近期优先利用:企业微信工作台入口、OAuth/免登、绑定、个人应用消息、OA deeplink、通讯录 userid 清单、可选成员详情、人事助手花名册读取、打卡记录和打卡规则读取。
- 暂缓深度镜像:`wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1`、`wecom_ruleops_v1` 均等 WEGOV1/WACP3R 后的 WREAD1 可读性门禁、WADM1 自建留存/镜像模型设计、WARCH1 最小历史留存、外出/加班模板测试和打卡细项明确后再拆;WOUA-FIX、WAPC2FLIVE、WCBK1 和 WACP3R 只作为已完成前置证据,不直接放行深度镜像。
- 暂缓深度镜像:WREAD1、WADM1、WARCH1 已完成请假/补卡可读性、模型设计和最小历史留存;`wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1`、`wecom_ruleops_v1` 仍需等正式查询/权限边界、外出/加班模板测试和打卡细项明确后再拆;WOUA-FIX、WAPC2FLIVE、WCBK1、WACP3R 和 WARCH1 只作为已完成前置证据,不直接放行深度镜像。
- OA 主办边界不变:报销、采购、费用、合同、项目、付款批次、AP、代账交接、审计和高敏附件继续在 OA 内完成,只向企业微信发送脱敏提醒和 OA 链接。
## 8. 后续使用要求
+13 -5
查看文件
@@ -2,10 +2,10 @@
**文档状态**:当前开发路线图事实源。
**分类**:开发治理 / 路线图。
**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 / WACP3 / WACP3R / WREAD1 / WREAD1F / WADM1 之后。
**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1`。
**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 / WACP3 / WACP3R / WREAD1 / WREAD1F / WADM1 / WARCH1 之后。
**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1`。
**最近审计**:2026-05-19。
**不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。WHMSLIVE 后只代表企微 HR 主数据镜像已部署并受控验证;WAPI 后进一步固定企微 API/Secret 口径并转为运行优先;WRRA 后明确 `PASS_WITH_FINDINGS` 只代表只读对账成功且存在差异,不代表企微考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成运行时实现。WMSG1 只证明企业微信应用消息可达和 Deeplink 可回到 OA;WAPC1 只固化审批配置准备清单;WAPC2 只新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC,不代表真实回调或镜像能力已完成。WPROC1 后,WAPC2 不直接放行深度镜像,必须先完成自动样件、回调、补偿拉取和镜像数据模型设计门禁。WPROC2 只新增可执行 process gate。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,并自动复跑 WAPC2 得到 `PASS_APPROVAL_POC`。WCBK1 已完成真实回调 URL 验证和一条现有 OA-POC 测试审批单事件确认,公网报告为 `PASS_CALLBACK_EVENT_CONFIRMED`;WEGOV1 后路线明确为“企微成熟功能优先上线,OA 做自建留存、审计、入口、消息和应急兜底”。WACP3R 已将补偿拉取复跑修正为自动按 `SMOKE002` creator 发现 OA-POC 请假/补卡模板,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`。WREAD1F 新样例后,审批人真机确认请假和补卡消息卡片、列表、详情页均可看清;远端人工结果报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。但同意按钮单击即通过仍是操作风险,需在 WADM1/WARCH1 的留存、审计、提示或操作规程中继续处理。WADM1 只完成自建留存/镜像数据模型设计,不创建 Prisma migration、不写业务代码、不部署;它不代表最小历史留存、完整镜像、应急接管、恢复回写或 RuleOps 已完成。
**不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。WHMSLIVE 后只代表企微 HR 主数据镜像已部署并受控验证;WAPI 后进一步固定企微 API/Secret 口径并转为运行优先;WRRA 后明确 `PASS_WITH_FINDINGS` 只代表只读对账成功且存在差异,不代表企微考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成运行时实现。WMSG1 只证明企业微信应用消息可达和 Deeplink 可回到 OA;WAPC1 只固化审批配置准备清单;WAPC2 只新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC,不代表真实回调或镜像能力已完成。WPROC1 后,WAPC2 不直接放行深度镜像,必须先完成自动样件、回调、补偿拉取和镜像数据模型设计门禁。WPROC2 只新增可执行 process gate。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,并自动复跑 WAPC2 得到 `PASS_APPROVAL_POC`。WCBK1 已完成真实回调 URL 验证和一条现有 OA-POC 测试审批单事件确认,公网报告为 `PASS_CALLBACK_EVENT_CONFIRMED`;WEGOV1 后路线明确为“企微成熟功能优先上线,OA 做自建留存、审计、入口、消息和应急兜底”。WACP3R 已将补偿拉取复跑修正为自动按 `SMOKE002` creator 发现 OA-POC 请假/补卡模板,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`。WREAD1F 新样例后,审批人真机确认请假和补卡消息卡片、列表、详情页均可看清;远端人工结果报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。但同意按钮单击即通过仍是操作风险,需在 WARCH1 的留存、审计、提示或操作规程中继续处理。WADM1 只完成自建留存/镜像数据模型设计;WARCH1 只实现请假/补卡最小审批历史留存、回调事件入库、补偿拉取入库和 QA-only 离线读取证明,不代表完整镜像、外出/加班、应急接管、恢复回写或 RuleOps 已完成。
## 1. 路线图原则
@@ -47,6 +47,7 @@
| `WACP3/WACP3R` | 已实现并公网只读复跑,有 findings | 新增 QA-only `npm run check:wecom-approval-compensation-poc` 和 `POST /cgi-bin/oa/getapprovalinfo` 只读封装,固定 `WECOM_AGENT_SECRET`。WACP3R 改为按 `SMOKE002` creator 自动发现 OA-POC 请假/补卡模板 ID,再按模板过滤拉审批单号并用 `getapprovaldetail` 拉详情;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`:请假/补卡各拉到 1 条详情,发现回调证据文件未持久化导致当前无法跨部署比对历史回调。未写 OA/企微数据,未保存完整模板 ID。 |
| `WREAD1/WREAD1F` | 请假/补卡真机确认已通过 | 新增 QA-only `npm run check:wecom-approval-readability-gate`,只读汇总 OA-POC 请假/补卡证据并输出人工确认清单。WREAD1F 重新生成新样例后,审批人真机确认请假和补卡均可看清;远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。风险记录:驳回有二次确认,但同意单击即通过,后续 WADM1/WARCH1 需纳入提示、审计或操作规程。 |
| `WADM1` | 自建留存/镜像模型设计已固化 | docs-only 固定 OA 留存域数据模型:企微审批主表、参与人/节点/动作、字段摘要、附件摘要、回调事件、补偿拉取 run/item、对账 finding、打卡/人事快照边界、隐私保留、权限和 WARCH1 最小实现切片。WADM1 解决 WACP3R “回调证据文件未持久化”的模型设计缺口,但不创建迁移、不写入业务数据、不放行完整镜像。 |
| `WARCH1` | 最小审批历史留存已实现并公网验证 | 新增 `WecomApprovalArchive` 等审批留存表、回调事件表、补偿拉取 run/item/finding 表;回调 POST 默认写 OA DB,失败返回非 200 让企微重试;补偿 runner 支持 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关写 OA 留存;新增 `npm run check:wecom-approval-archive` 离线读取 OA DB 历史摘要。公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 与 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 均为 `PASS_WITH_FINDINGS`:请假/补卡 2 条历史可离线读取,finding 是 WARCH1 前旧回调未入 DB 的历史 gap,已作为对账 finding 留存。范围仅请假/补卡,不写企微、不做完整镜像、不做应急接管或恢复回写。 |
## 3. 阶段路线
@@ -63,7 +64,7 @@
| 9 | `WACP3 补偿拉取 POC` | 证明按时间窗口批量获取审批单号并可拉取审批详情,作为回调丢失补偿。 | WEGOV1 完成;WAPC2/WAPC2FLIVE/WCBK1 已提供低风险样例、详情读取和真实事件证据;官方 API 和权限明确。 | WACP3R 已自动发现 OA-POC 请假/补卡模板并复跑到 `PASS_WITH_FINDINGS`:补偿拉取和详情读取可用;findings 为当前回调证据文件未持久化,需在 WADM1/WARCH1 设计留存模型时解决。 |
| 10 | `WREAD1 企微审批可读性与误审批防护门禁` | 真机确认请假、补卡、外出、加班在企微里是否看得清再批,评估快捷同意/驳回误操作风险。 | WACP3 完成或并行准备低风险样例;审批模板可在企微手机端由代表审批人查看。 | 请假/补卡已由 WREAD1F 真机确认 `PASS_READABLE`;驳回有二次确认,同意单击即通过,后续作为操作风险进入 WADM1/WARCH1 设计。外出/加班仍需后续样例或处理决策;不可读模板不得进入正式镜像。 |
| 11 | `WADM1 自建留存/镜像数据模型设计` | 先设计 OA 留存字段、状态机、幂等键、索引、隐私保留、审计和迁移回滚方案。 | WACP3 证明补偿拉取可用;WREAD1 明确模板可读性或回退策略。 | 已完成 docs-only 设计:模型覆盖审批、打卡、人事关键快照、回调事件和补偿拉取记录;只允许在单独 WARCH1 合同批准后进入 schema 或业务代码实现。 |
| 12 | `WARCH1 最小自建历史留存实现` | 只读保存企微审批、打卡、人事关键快照,先满足企微不可用时可查看历史。 | WADM1 完成并批准 schema/接口实现。 | OA 可查看已保存历史摘要和完整外部关联键;报告和日志只输出脱敏前缀;不保存 Secret、token、EncodingAESKey、原始密文或无治理原始 payload。 |
| 12 | `WARCH1 最小自建历史留存实现` | 先保存企微审批最小历史,满足企微 API 不可用时可查看已保存请假/补卡摘要。 | WADM1 完成并批准 schema/接口实现。 | 已新增 OA DB 审批留存、回调事件、补偿 run/item/finding、双开关补偿入库和离线读取 runner;公网已持久化现有请假/补卡详情并离线读取 2 条已通过历史。报告和日志只输出脱敏前缀;不保存 Secret、token、EncodingAESKey、原始密文或无治理原始 payload。 |
| 13 | `wecom_attendance_approval_mirror_v1` | 企微打卡、请假、补卡、外出、加班、假期余额和审批历史镜像到 OA。 | WARCH1 稳定;外出/加班模板和打卡细项明确;运行优先门禁和用户一致性仍满足。 | OA 可查询企微 HR/考勤镜像、附件摘要、审批历史、考勤解释和审计;不把 OA 主办高敏审批迁到企微。 |
| 14 | `oa_hr_emergency_mode_v1` | 企业微信不可用时,由管理员人工开启 OA HR/考勤应急接管。 | 自建历史留存和镜像数据可支撑对账;OA 应急入口、权限和审计方案明确。 | 管理员可填写原因、范围、预计结束时间和责任人后开启/关闭;应急单据带 `EMERGENCY` 标记。 |
| 15 | `wecom_recovery_writeback_v1` | 企微恢复后,OA 应急数据先 dry-run,再回写企微。 | 应急接管模式完成;企微写回接口、冲突规则和回滚策略已单独批准。 | 系统生成成功、失败、跳过和冲突清单;无冲突项可自动回写,冲突进入人工确认。 |
@@ -293,4 +294,11 @@ WARCH1 最小实现切片:
3. 增加 QA-only backfill/verify runner:断开企微 API 时仍能从 OA DB 读取已保存的请假/补卡历史摘要。
4. 再做只读查询 API 或管理页入口;不在 WARCH1 内开启完整镜像增强、外出/加班正式样例、应急接管、恢复回写或 RuleOps。
WARCH1 实施前必须另建 Issue CSV,并在方案中列出 Prisma 表、索引、迁移回滚、权限、脱敏输出和 replay 策略。
WARCH1 已按独立 Issue CSV 进入最小实现。当前实现边界:
- Prisma 新增 `WecomApprovalArchive`、`WecomApprovalParticipant`、`WecomApprovalAction`、`WecomApprovalFieldSummary`、`WecomApprovalAttachmentSummary`、`WecomApprovalCallbackEvent`、`WecomApprovalCompensationRun`、`WecomApprovalCompensationItem` 和 `WecomApprovalReconcileFinding`,migration 只新增表、索引和外键。
- `GET /POST /api/wecom/approval/callback` 继续只读接收企微回调;POST 验签、解密、规范化后默认写入 OA DB,DB 写入失败返回非 200,让企业微信按官方机制重试;配置文件证据仅作为无 Prisma 时的 fallback。
- `npm run check:wecom-approval-compensation-poc` 默认仍只读不写库;只有同时传 `--persist-to-db` 且进程环境 `WECOM_ARCHIVE_WRITE=1` 时,才把现有请假/补卡补偿详情写入 OA 自建留存表。该路径不写企微、不创建模板、不提交审批。
- `npm run check:wecom-approval-archive -- --offline --format=json` 是 QA-only 离线证明:不调用企微 API,只从 OA DB 读取已保存历史摘要,并只输出脱敏 `sp_no`、template、userid 前缀、计数、字段类型和 finding 类型。
- 公网验证已执行 `SYNC_ENV=0` 部署并应用 migration;补偿持久化报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 为 `PASS_WITH_FINDINGS`,`archivePersistence.status=OK`、`storedCount=2`;离线读取报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 为 `PASS_WITH_FINDINGS`,可读 2 条已通过历史。finding 是 WARCH1 前旧回调未持久化到 DB 的历史 gap,已记录为 `callback_missing_for_pulled_detail`,不是当前写入链路阻断。
- WARCH1 仍不新增正式管理页,不开发外出/加班,不开启 `wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1`。
+2 -2
查看文件
@@ -22,7 +22,7 @@ WDRG 后,企微优先 HR/考勤改造增加一层路线图门禁;WAPI/WRRA
`需求事实源 -> 设计门禁 -> 路线图门禁 -> 企微能力 POC -> 单功能 Issue CSV -> 方案批准 -> 开发实现 -> review -> QA 回归 -> 部署 / 公网验证`
路线图只控制顺序、边界、准入和退出条件;具体接口、schema、页面、权限、审计和测试仍必须在单功能 Issue CSV 中声明。阶段未退出前不得跳阶段:WOUA-FIX 已完成测试账号差异收口,但 `PASS_WITH_FINDINGS` 的剩余缺字段/归档绑定不直接放行深度镜像;WRA1 已完成 `WECOM-RUNTIME-READY-V1` 的运行入口、OAuth/绑定、脱敏消息、通讯录、人事助手和打卡只读门禁修正;WMSG1 已完成真实应用消息和 Deeplink 确认。当前 `PASS_WITH_CONFIG_GAPS` 只表示运行优先主链路可用,WAPC1 只表示审批配置准备清单已固化;WAPC2 只证明请假/补卡模板详情和审批详情只读 runner 已具备。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试样件并自动复跑 WAPC2 通过;WCBK1 已完成回调只读代码、synthetic 验签/解密/解析、真实 URL 验证和一条现有 OA-POC 测试审批单事件确认;WACP3R 已完成补偿拉取公网只读复跑并发现回调证据未持久化;WREAD1 已完成请假/补卡真机可读性确认,结论 `PASS_READABLE`,同意单击风险进入后续设计。WEGOV1 后,审批镜像前仍必须先完成 WADM1 自建留存/镜像数据模型设计和 WARCH1 最小历史留存。
路线图只控制顺序、边界、准入和退出条件;具体接口、schema、页面、权限、审计和测试仍必须在单功能 Issue CSV 中声明。阶段未退出前不得跳阶段:WOUA-FIX 已完成测试账号差异收口,但 `PASS_WITH_FINDINGS` 的剩余缺字段/归档绑定不直接放行深度镜像;WRA1 已完成 `WECOM-RUNTIME-READY-V1` 的运行入口、OAuth/绑定、脱敏消息、通讯录、人事助手和打卡只读门禁修正;WMSG1 已完成真实应用消息和 Deeplink 确认。当前 `PASS_WITH_CONFIG_GAPS` 只表示运行优先主链路可用,WAPC1 只表示审批配置准备清单已固化;WAPC2 只证明请假/补卡模板详情和审批详情只读 runner 已具备。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试样件并自动复跑 WAPC2 通过;WCBK1 已完成回调只读代码、synthetic 验签/解密/解析、真实 URL 验证和一条现有 OA-POC 测试审批单事件确认;WACP3R 已完成补偿拉取公网只读复跑,当时发现的回调证据未持久化已由 WARCH1 DB 回调事件承接;WREAD1 已完成请假/补卡真机可读性确认,结论 `PASS_READABLE`,同意单击风险进入后续设计。WEGOV1 后,WADM1/WARCH1 已完成模型设计和最小历史留存;完整审批镜像仍必须另建合同。
## 2. Issue 生命周期
@@ -146,7 +146,7 @@ WPROC2 后,三层门禁先由可执行 process gate 承接:
- 真实回调先证明 URL 验证、验签、解密、幂等和重复投递。
- 补偿拉取先证明按时间窗口拿到审批单号并可拉取审批详情。
- 镜像表先设计字段、状态机、幂等键、索引和隐私保留边界;WADM1 已固定审批留存、回调事件、补偿拉取、对账 finding、打卡/人事快照边界和 WARCH1 最小实现切片。
- 镜像表先设计字段、状态机、幂等键、索引和隐私保留边界;WADM1 已固定审批留存、回调事件、补偿拉取、对账 finding、打卡/人事快照边界,WARCH1 已实现请假/补卡最小留存切片。
- 应急接管和恢复回写必须等镜像稳定后再做。
## 3. 开发边界
文件差异因一行或多行过长而隐藏
+9 -7
查看文件
@@ -20,7 +20,7 @@
## 最近事实源链
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST -> WCFB -> WFOC -> DBWFOC -> WFDG -> WDRG -> WCP1 -> WCPC -> WHMS -> WHMSLIVE -> WOUA -> WAPI -> WRRA -> WOUA-FIX -> WRV1 -> WRA1 -> WMSG1 -> WAPC1 -> WAPC2 -> WPROC1 -> WPROC2 -> WAPC2F dry-run -> WAPC2FLIVE -> WCBK1 -> WEGOV1 -> WACP3/WACP3R -> WREAD1 自动汇总 -> WREAD1F 新样例 -> WREAD1 真机确认 -> WADM1 自建留存模型设计。
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST -> WCFB -> WFOC -> DBWFOC -> WFDG -> WDRG -> WCP1 -> WCPC -> WHMS -> WHMSLIVE -> WOUA -> WAPI -> WRRA -> WOUA-FIX -> WRV1 -> WRA1 -> WMSG1 -> WAPC1 -> WAPC2 -> WPROC1 -> WPROC2 -> WAPC2F dry-run -> WAPC2FLIVE -> WCBK1 -> WEGOV1 -> WACP3/WACP3R -> WREAD1 自动汇总 -> WREAD1F 新样例 -> WREAD1 真机确认 -> WADM1 自建留存模型设计 -> WARCH1 最小审批历史留存。
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
@@ -78,6 +78,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
- WCBK1 是 WAPC2FLIVE 后的企微审批回调只读 POC 合同:已新增只读回调端点、验签/解密/归一化服务、QA runner 和 `wecom-callback` process gate;远端配置真实回调后报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` 为 `PASS_CALLBACK_URL_VERIFIED`,`SMOKE101` 同意现有 OA-POC 补卡测试单后报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 为 `PASS_CALLBACK_EVENT_CONFIRMED`。本证据不代表补偿拉取、镜像模型或深度镜像已完成。
- WEGOV1 是 WCBK1 后的企微主办治理与 OA 自建留存路线修正合同:只更新合同和事实源,不改业务代码、不部署、不写 OA/企微数据;固定混合主办边界、OA 自建历史留存、企业微信无完整 sandbox 假设、受控测试域、自动化与人工边界,并把后续顺序调整为 `WACP3/WACP3R -> WREAD1 -> WADM1 -> WARCH1 -> wecom_attendance_approval_mirror_v1`。
- WACP3 是 WEGOV1 后的企微审批补偿拉取 POC 合同:新增 `check:wecom-approval-compensation-poc` 和 `getapprovalinfo` 只读封装;初始公网报告缺模板 ID,WACP3R 已改为按 `SMOKE002` creator 自动发现 OA-POC 请假/补卡模板并复跑,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`,补偿拉取和详情读取可用,finding 为回调证据文件未持久化。
- WARCH1 是 WADM1 后的最小自建历史留存合同:新增审批留存 Prisma 表、回调事件入库、补偿拉取双开关入库和离线读取 runner;只覆盖请假/补卡历史留存,不写企微、不创建模板或审批单、不进入完整镜像、应急接管、恢复回写或 RuleOps。
## 当前活跃治理流
@@ -141,11 +142,12 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| `WAPC2F` | `issues/2026-05-19_10-24-43-wecom-approval-fixture-auto-generate-v1.csv` | 已完成:企微审批 POC 样件自动生成 runner 已部署并公网 dry-run 通过 | 新增 `check:wecom-approval-fixtures`,固定 `WECOM_AGENT_SECRET`,默认 dry-run 构建 OA-POC 请假/补卡模板和审批申请 payload;`SMOKE002 -> SMOKE101` 非自审预检通过;远端报告 `PASS_FIXTURE_DRY_RUN_READY` | 不新增公开 API/schema/UI;dry-run 不真实创建模板或审批单;不接回调;不进入深度镜像 |
| `WAPC2FLIVE` | `issues/2026-05-19_13-24-12-wecom-approval-fixture-live-create-v1.csv` | 当前审批样件 live:OA-POC 请假/补卡测试模板和测试审批单已创建 | 修正 `create_template` payload 后,远端报告 `PASS_FIXTURE_CREATED`,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`;测试模板和审批记录保留审计,只记录脱敏 template_id / `sp_no` 前缀 | 不改公开 API/schema/UI;不接真实回调;不做补偿拉取;不建镜像表;不进入深度镜像 |
| `WCBK1` | `issues/2026-05-19_14-11-20-wecom-approval-callback-readonly-poc-v1.csv` | 当前回调只读 POC:真实 URL 和事件已确认 | 新增只读 `GET/POST /api/wecom/approval/callback`、`check:wecom-approval-callback-poc` 和 `wecom-callback` process gate;远端报告 `PASS_CALLBACK_URL_VERIFIED` 后,`SMOKE101` 处理现有 OA-POC 测试单并复跑达到 `PASS_CALLBACK_EVENT_CONFIRMED` | 不改 schema/UI;不建镜像表;不写 OA/企微业务数据;不创建新模板或审批单;后续仍需 WEGOV1/WACP3/WREAD1/WADM1/WARCH1 |
| `WEGOV1` | `issues/2026-05-19_18-17-44-wecom-governance-owned-oa-archive-route-v1.csv` | 当前路线治理:企微主办 + OA 自建留存 | 固定 HR/考勤企微主办、财务经营和高敏业务 OA 主办、OA 留存关键企微历史、企业微信受控测试域、自动化与人工边界;后续新增 WREAD1 和 WARCH1 门禁 | docs-only;不改业务代码/API/schema/UI;不部署;不写 OA/企微数据;不进入深度镜像 |
| `WACP3/WACP3R` | `issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv`、`issues/2026-05-19_19-42-22-wecom-approval-compensation-config-rerun-v1.csv` | 当前补偿拉取 POC:runner 已部署并自动发现模板复跑 | 新增 `check:wecom-approval-compensation-poc`,固定 `WECOM_AGENT_SECRET` 只读调用 `getapprovalinfo` 和 `getapprovaldetail`;WACP3R 按 `SMOKE002` creator 自动发现 OA-POC 请假/补卡模板,远端报告为 `PASS_WITH_FINDINGS`,finding 为回调证据文件未持久化 | 不新增公开 API/schema/UI;不写 OA/企微数据;不保存完整模板 ID;不无过滤拉取高风险审批详情;不进入深度镜像 |
| `WREAD1` | `issues/2026-05-19_20-39-40-wecom-approval-readability-gate-v1.csv` | 当前可读性门禁:请假/补卡真机确认已通过 | 新增 `check:wecom-approval-readability-gate`,只读汇总请假/补卡 OA-POC 证据;用户确认两类模板消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE` | 不新增公开 API/schema/UI;不写 OA/企微业务数据;同意单击即通过作为操作风险进入 WADM1/WARCH1;仍不得跳过自建留存模型和最小历史留存直接做深度镜像 |
| `WEGOV1` | `issues/2026-05-19_18-17-44-wecom-governance-owned-oa-archive-route-v1.csv` | 当前路线治理:企微主办 + OA 自建留存 | 固定 HR/考勤企微主办、财务经营和高敏业务 OA 主办、OA 留存关键企微历史、企业微信受控测试域、自动化与人工边界;WREAD1/WADM1/WARCH1 已按该路线完成请假/补卡可读性、模型设计和最小历史留存 | docs-only;不改业务代码/API/schema/UI;不部署;不写 OA/企微数据;不进入深度镜像 |
| `WACP3/WACP3R` | `issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv`、`issues/2026-05-19_19-42-22-wecom-approval-compensation-config-rerun-v1.csv` | 当前补偿拉取 POC:runner 已部署并自动发现模板复跑 | 新增 `check:wecom-approval-compensation-poc`,固定 `WECOM_AGENT_SECRET` 只读调用 `getapprovalinfo` 和 `getapprovaldetail`;WACP3R 按 `SMOKE002` creator 自动发现 OA-POC 请假/补卡模板,远端报告为 `PASS_WITH_FINDINGS`,当时 finding 为回调证据文件未持久化,已由 WARCH1 DB 回调事件承接 | 不新增公开 API/schema/UI;不写 OA/企微数据;不保存完整模板 ID;不无过滤拉取高风险审批详情;不进入深度镜像 |
| `WREAD1` | `issues/2026-05-19_20-39-40-wecom-approval-readability-gate-v1.csv` | 当前可读性门禁:请假/补卡真机确认已通过 | 新增 `check:wecom-approval-readability-gate`,只读汇总请假/补卡 OA-POC 证据;用户确认两类模板消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE` | 不新增公开 API/schema/UI;不写 OA/企微业务数据;同意单击即通过已进入 WADM1/WARCH1 审计设计;仍不得直接做深度镜像 |
| `WREAD1F` | `issues/2026-05-19_21-15-47-wecom-readability-fresh-fixture-v1.csv` | 当前真机确认补充:已生成新 OA-POC 样例并支撑 WREAD1 收口 | 用户选择重新生成后,修正模板名唯一性并公网 live 创建新的请假/补卡测试模板和两条审批单;报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T131958Z/summary.json` 为 `PASS_FIXTURE_CREATED`,最终 WREAD1 人工报告为 `PASS_READABLE` | 写入仅限 OA-POC 测试模板和低风险审批单;不写 OA 业务表;不进入镜像;新样例保留审计 |
| `WADM1` | `issues/2026-05-19_21-55-33-wecom-archive-mirror-data-model-design-v1.csv` | 当前模型设计:企微自建留存/镜像数据模型已固化 | 固定审批留存、回调事件、补偿拉取、对账 finding、打卡/人事快照边界、隐私权限和 WARCH1 最小实现切片 | docs-only;不改 Prisma schema、不部署、不写 OA/企微业务数据;下一阶段 WARCH1 仍需单独 Issue CSV 和方案批准 |
| `WADM1` | `issues/2026-05-19_21-55-33-wecom-archive-mirror-data-model-design-v1.csv` | 当前模型设计:企微自建留存/镜像数据模型已固化 | 固定审批留存、回调事件、补偿拉取、对账 finding、打卡/人事快照边界、隐私权限和 WARCH1 最小实现切片;已由 WARCH1 承接实现请假/补卡最小历史留存 | docs-only;不改 Prisma schema、不部署、不写 OA/企微业务数据;完整镜像仍需单独 Issue CSV 和方案批准 |
| `WARCH1` | `issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv` | 当前最小历史留存实现:请假/补卡审批历史可入 OA DB 并离线读取 | 新增 `WecomApprovalArchive` 等 Prisma 表、回调事件持久化、补偿 run/item/finding、`check:wecom-approval-archive` 离线 runner;补偿入库需 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1`;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 持久化 2 条详情,`/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 离线读取 2 条历史,finding 为旧回调未入库历史 gap | 不写企微数据;不创建模板或审批单;不新增正式管理页;不做外出/加班、完整镜像、应急接管、恢复回写或 RuleOps |
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
@@ -168,8 +170,8 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源;WAPC2FLIVE 后包含企微审批配置、只读 POC、工程化前置、process gate、请假/补卡样例和深度镜像前置口径 |
| [文档事实源状态矩阵](./文档事实源状态矩阵.md) | 文档分类、删除恢复和冲突规则 |
| [产品设计规则](./产品设计规则.md) | PC、移动、企业微信 WebView 设计事实源;DG8 后飞书工作台/飞书审批为唯一主 UI/布局参考;DG9 后按页面分层和样板选型 |
| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WOUA-FIX/WRV1/WRA1/WMSG1/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R/WREAD1/WREAD1F 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先、消息实发、审批配置、只读 POC、process gate、自动样件 live 证据、真实回调事件证据、自建留存、补偿拉取、可读性真机确认和模型设计事实源 |
| [企微 API 能力目录](./企微API能力目录.md) | WAPI/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界、运行优先、审批配置、只读 POC、WAPC2F dry-run、WAPC2FLIVE live 创建、WCBK1 回调真实事件 POC、无完整 sandbox 假设、补偿拉取自动发现和 WREAD1/WADM1/WARCH1 决策事实源 |
| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WOUA-FIX/WRV1/WRA1/WMSG1/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R/WREAD1/WREAD1F/WARCH1 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先、消息实发、审批配置、只读 POC、process gate、自动样件 live 证据、真实回调事件证据、自建留存、补偿拉取、可读性真机确认、模型设计和最小历史留存事实源 |
| [企微 API 能力目录](./企微API能力目录.md) | WAPI/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R/WARCH1 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界、运行优先、审批配置、只读 POC、WAPC2F dry-run、WAPC2FLIVE live 创建、WCBK1 回调真实事件 POC、无完整 sandbox 假设、补偿拉取自动发现、WREAD1/WADM1/WARCH1 决策和最小留存事实源 |
| [产品口径](./产品口径.md) | 已拍板产品边界和业务规则;WEGOV1 后包含混合主办、自建留存、受控测试域、后续阶段顺序和深度镜像前置口径 |
| [需求 - 统一审批中心规约](./需求-统一审批中心规约.md) | OA 主办审批、企微主办 HR/考勤员工日常审批镜像、催办、超时提醒、审批效率 |
| [报销发票附件规则](./报销发票附件规则.md) | 报销、发票、OCR、附件细则 |
+2 -2
查看文件
@@ -2,7 +2,7 @@
**文档状态**:当前测试事实源。
**适用范围**:hyb-oa MVP 首轮开发 A3-A8,以及后续所有非微小改动。
**路线口径**:WFOC/DBWFOC/WFDG/WDRG/WEGOV1 后,HR/考勤员工日常审批优先复用企业微信成熟能力,OA 做自建留存、按域镜像、人工接管、恢复回写、财务闭环和审计中台;报销、采购、合同、项目、PDF OCR、付款批次、AP 台账、代账交接和高敏附件继续由 OA 主办;企业微信只做脱敏消息和 OA deeplink;飞书不进入当前路线。后续企微/OA 运行时迁移前必须先读 [企微优先开发路线图](./企微优先开发路线图.md),再做对应 POC。WEGOV1 后,WAPC2/WCBK1 不能直接进入 `wecom_attendance_approval_mirror_v1`,必须先完成 WACP3、WREAD1、WADM1 和 WARCH1;WCBK1 已完成真实 URL 验证和事件确认,WREAD1 已完成请假/补卡真机 `PASS_READABLE`;WPROC2 后先跑 `check-process-contract` process gate;缺配置或权限时记录 `NEEDS_CONFIG` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`,不得伪造成通过。
**路线口径**:WFOC/DBWFOC/WFDG/WDRG/WEGOV1 后,HR/考勤员工日常审批优先复用企业微信成熟能力,OA 做自建留存、按域镜像、人工接管、恢复回写、财务闭环和审计中台;报销、采购、合同、项目、PDF OCR、付款批次、AP 台账、代账交接和高敏附件继续由 OA 主办;企业微信只做脱敏消息和 OA deeplink;飞书不进入当前路线。后续企微/OA 运行时迁移前必须先读 [企微优先开发路线图](./企微优先开发路线图.md),再做对应 POC。WEGOV1 后,WAPC2/WCBK1 不能直接进入 `wecom_attendance_approval_mirror_v1`;WACP3、WREAD1、WADM1 和 WARCH1 已完成请假/补卡补偿、可读性、模型设计和最小历史留存,完整镜像仍需单独合同。WPROC2 后先跑 `check-process-contract` process gate;缺配置或权限时记录 `NEEDS_CONFIG` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`,不得伪造成通过。
本文回答“什么时候测试、测什么、怎么判定通过、怎么记录跳过”。具体业务验收仍以主 PRD、模块规约和最新 Issue CSV 为准。
@@ -70,7 +70,7 @@ Definition of Done:
- 人工只保留批准、密钥/回调配置、企微后台不可 API 化配置、真机确认和风险拍板。
- 若面向公网或企微可测,必须有 `/ready`、runtime smoke、public bundle scan 或对应 live runner 证据。
高风险企微能力的 POC 顺序固定为:WAPC2F 自动样件 -> WAPC2FLIVE live 创建与 WAPC2 自动复跑 -> WCBK1 回调只读 POC -> WEGOV1 企微主办治理与自建留存路线修正 -> WACP3/WACP3R 补偿拉取 POC -> WREAD1 审批可读性与误审批防护门禁 -> WADM1 自建留存/镜像数据模型设计 -> WARCH1 最小自建历史留存实现 -> `wecom_attendance_approval_mirror_v1`。当前 WAPC2FLIVE、WCBK1、WACP3R 和请假/补卡 WREAD1 均已完成,后续不得跳过自建留存模型和最小历史留存门禁。真实回调必须先证明验签、解密、幂等和重复投递;补偿拉取必须先证明按时间窗口拿到审批单号并可拉详情;可读性门禁必须确认审批消息、列表、详情页和快捷同意/驳回误操作风险;镜像实现前必须先有字段、状态机、幂等键、索引和隐私保留边界设计。
高风险企微能力的 POC 顺序固定为:WAPC2F 自动样件 -> WAPC2FLIVE live 创建与 WAPC2 自动复跑 -> WCBK1 回调只读 POC -> WEGOV1 企微主办治理与自建留存路线修正 -> WACP3/WACP3R 补偿拉取 POC -> WREAD1 审批可读性与误审批防护门禁 -> WADM1 自建留存/镜像数据模型设计 -> WARCH1 最小自建历史留存实现 -> `wecom_attendance_approval_mirror_v1`。当前 WAPC2FLIVE、WCBK1、WACP3R、请假/补卡 WREAD1、WADM1 和 WARCH1 均已完成;后续不得跳过正式查询/权限、外出/加班、打卡细项等独立门禁直接做完整镜像。真实回调必须先证明验签、解密、幂等和重复投递;补偿拉取必须先证明按时间窗口拿到审批单号并可拉详情;可读性门禁必须确认审批消息、列表、详情页和快捷同意/驳回误操作风险;镜像实现前必须先有字段、状态机、幂等键、索引和隐私保留边界设计。
WEGOV1 后的企业微信测试环境口径:不假设自建应用有完整隔离 sandbox。自动化优先覆盖 API、回调、补偿拉取、脱敏报告和 OA 留存校验;人工只确认后台不可 API 化配置、真机收到/点击、审批详情是否看得清、正式写入和应急风险拍板。
+1 -1
查看文件
@@ -128,7 +128,7 @@ WHMS 将企业微信通讯录/人事助手作为 HR 主数据源的读取能力
- 企微不在职状态记录 `needs_offboarding_confirmation`,不自动禁用 OA 账号;离职仍走 OA checklist。
- 已有 OA 员工只更新姓名、部门、职位、直属主管、手机号、邮箱和企微绑定,不覆盖银行账户、身份证、工资、合同附件或历史审批。
当前状态为已部署并完成受控公网验证:`WHMSLIVE` 已将 WHMS 部署到 `https://oa.hyinda.com`,远端应用 migration `20260518184319_wecom_hr_master_sync_v1`;验证窗口临时 `force_on` 后对 `SMOKE002/测试员工`(脱敏 `oa_smoke***`)执行 dry-run 和显式 userids apply,企微读取链路通过,因企微侧缺手机号、邮箱和部门按 `missing_required_field` 跳过,未写 OA 主数据;普通员工访问同步 API 返回 403;最终 `wecom_hr_master_sync_v1=force_off`。尚未对真实员工执行生产批量 apply。WOUA-FIX 后复检为 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余 5 个企微缺字段按用户决策先不补,1 个归档测试绑定保留审计。WACP3R 已证明请假/补卡补偿拉取和详情读取可用,但回调证据文件未持久化需在自建留存模型中解决;`wecom_attendance_approval_mirror_v1` 暂缓到 WREAD1 可读性门禁、WADM1 自建留存/镜像数据模型、WARCH1 最小历史留存和打卡细项明确后再另建合同。
当前状态为已部署并完成受控公网验证:`WHMSLIVE` 已将 WHMS 部署到 `https://oa.hyinda.com`,远端应用 migration `20260518184319_wecom_hr_master_sync_v1`;验证窗口临时 `force_on` 后对 `SMOKE002/测试员工`(脱敏 `oa_smoke***`)执行 dry-run 和显式 userids apply,企微读取链路通过,因企微侧缺手机号、邮箱和部门按 `missing_required_field` 跳过,未写 OA 主数据;普通员工访问同步 API 返回 403;最终 `wecom_hr_master_sync_v1=force_off`。尚未对真实员工执行生产批量 apply。WOUA-FIX 后复检为 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余 5 个企微缺字段按用户决策先不补,1 个归档测试绑定保留审计。WACP3R 已证明请假/补卡补偿拉取和详情读取可用;WARCH1 已补齐最小回调事件、补偿拉取和对账 finding 入库能力。`wecom_attendance_approval_mirror_v1` 仍暂缓到 WARCH1 验收、正式查询/权限边界、外出/加班样例和打卡细项明确后再另建合同。
---
+5 -5
查看文件
@@ -1,6 +1,6 @@
# hyb-oa 管理员手册(10 人以下团队版)
**当前状态(2026-05-19 / WADM1 设计后)**:R7E、PRELAUNCH、财务闭环、ATTV1、HR-WECOM-DIRECTORY-V1、WAPI、WOUA-FIX、WRV1/WRA1、WMSG1、WAPC1/WAPC2、WPROC1/WPROC2、WAPC2F dry-run、WAPC2FLIVE、WCBK1、WEGOV1、WACP3/WACP3R、WREAD1/WREAD1F 和 WADM1 均已收口;SYSTEM-COMPLETE-GATE 结论为 `GO_TO_USE`。WAPI 后固定企微 API 能力目录和 Secret 矩阵:通讯录使用 `WECOM_CONTACT_SECRET`,人事助手/打卡/消息/OAuth/审批读取使用 `WECOM_AGENT_SECRET`,运行时代码不得靠多个 Secret 试错。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`;测试模板和审批记录保留审计,不自动删除。WCBK1 已部署只读回调端点和 runner,远端完成回调 URL 验证并收到一条由 `SMOKE101` 同意现有 OA-POC 补卡测试单触发的真实审批状态事件,报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 为 `PASS_CALLBACK_EVENT_CONFIRMED`。WACP3R 已新增自动发现模板 ID 路径并公网只读复跑,报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`;WREAD1F 新样例后,用户确认请假/补卡在企业微信消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。WADM1 已完成自建留存/镜像数据模型设计,明确回调事件和补偿拉取证据必须入库解决跨部署丢失问题;后续仍需 WARCH1 最小历史留存实现,不能直接进入 `wecom_attendance_approval_mirror_v1`。同意单击即通过需在后续提示、审计或操作规程中处理。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。
**当前状态(2026-05-19 / WARCH1 实现后)**:R7E、PRELAUNCH、财务闭环、ATTV1、HR-WECOM-DIRECTORY-V1、WAPI、WOUA-FIX、WRV1/WRA1、WMSG1、WAPC1/WAPC2、WPROC1/WPROC2、WAPC2F dry-run、WAPC2FLIVE、WCBK1、WEGOV1、WACP3/WACP3R、WREAD1/WREAD1F、WADM1 和 WARCH1 均已收口到当前开发阶段;SYSTEM-COMPLETE-GATE 结论为 `GO_TO_USE`。WAPI 后固定企微 API 能力目录和 Secret 矩阵:通讯录使用 `WECOM_CONTACT_SECRET`,人事助手/打卡/消息/OAuth/审批读取使用 `WECOM_AGENT_SECRET`,运行时代码不得靠多个 Secret 试错。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`;测试模板和审批记录保留审计,不自动删除。WCBK1 已部署只读回调端点和 runner,远端完成回调 URL 验证并收到一条由 `SMOKE101` 同意现有 OA-POC 补卡测试单触发的真实审批状态事件,报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 为 `PASS_CALLBACK_EVENT_CONFIRMED`。WACP3R 已新增自动发现模板 ID 路径并公网只读复跑,报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`;WREAD1F 新样例后,用户确认请假/补卡在企业微信消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。WARCH1 已新增并部署请假/补卡最小审批历史留存:回调事件默认入 OA DB,补偿拉取可在 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关下写 OA 留存,离线 runner 可不调用企微 API 读取已保存历史摘要;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 和 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 均为 `PASS_WITH_FINDINGS`,2 条请假/补卡历史可离线读取,finding 是旧回调未入库历史 gap。同意单击即通过风险已进入归档审计事实,后续正式历史页和操作规程继续突出动作来源与补偿一致性。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。
## 1. 适用范围
- 适用于 `small_team_internal` 工作区模式。
@@ -178,9 +178,9 @@ ORDER BY e.employee_no;
6. 采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办;采购单附件、合同正文、报价、供应商资料、发票、银行回单和代账资料不进入企业微信原生审批或企业微信附件。
7. OA 主办审批仍可向企业微信个人应用发送脱敏消息;消息点击后通过免登录或账号绑定回到 OA 目标审批页,审批动作仍在 OA 完成。
WADM1 后的管理员关注点:
WARCH1 后的管理员关注点:
1. 后续 WARCH1 只做最小历史留存:先保存请假/补卡审批历史、回调事件、补偿拉取记录和必要员工映射,保证企微 API 不可用时仍可查看已保存历史。
1. WARCH1 已实现最小历史留存:先保存请假/补卡审批历史、回调事件、补偿拉取记录和必要员工映射,保证企微 API 不可用时仍可查看已保存历史摘要。
2. 回调 Token、EncodingAESKey、Secret 和 access token 只配置在部署环境,不写入文档、Issue、聊天或报告。
3. OA 数据库可保存完整 `sp_no`、`template_id` 和企微 userid 以便补偿和审计;对外报告、日志和导出默认脱敏。
4. 同意单击即通过是企业微信端交互特性,管理员应通过操作规程要求审批人先看详情再同意;OA 后续通过历史留存、审批轨迹和补偿对账提供追溯。
@@ -205,7 +205,7 @@ WCP1/WCPC/WHMS 后续推进:
2. WCP1 历史结论为 `PAUSE_FOR_CONFIG`;WCPC 已用既有在职测试号完成远端只读复跑,报告 `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json`,结论 `GO_TO_HR_MASTER_SYNC`。
3. WCPC 已证明 HR 主数据镜像准入项可用:通讯录成员读取、人事助手花名册读取、打卡短窗口记录/规则读取和假期余额读取均达到可复用状态;审批模板、审批实例和回调配置仍是后续考勤/审批镜像前置项。
4. WHMS 已实现并部署验证 `wecom_hr_master_sync_v1`,包括同步批次、逐员工结果、dry-run/apply、People 管理入口和 QA-only runner;公网验证后最终保持 `force_off`。
5. 正式开发仍按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1` 分合同实施;WOUA-FIX 已收口测试账号差异和处理决策,WRV1/WRA1/WMSG1 已验证运行入口、OAuth/绑定、脱敏消息、Deeplink、通讯录、人事助手和打卡读取主链路。WAPC2FLIVE 已生成请假/补卡低风险样例,WCBK1 已证明真实审批回调 URL、验签/解密和事件脱敏留证,WACP3R 已证明请假/补卡补偿拉取和详情读取可用,WREAD1 已确认请假/补卡可读性,WADM1 已明确自建留存模型;WARCH1 最小历史留存未实现前,不得进入深度考勤/审批镜像。
5. 正式开发仍按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1` 分合同实施;WOUA-FIX 已收口测试账号差异和处理决策,WRV1/WRA1/WMSG1 已验证运行入口、OAuth/绑定、脱敏消息、Deeplink、通讯录、人事助手和打卡读取主链路。WAPC2FLIVE 已生成请假/补卡低风险样例,WCBK1 已证明真实审批回调 URL、验签/解密和事件脱敏留证,WACP3R 已证明请假/补卡补偿拉取和详情读取可用,WREAD1 已确认请假/补卡可读性,WADM1 已明确自建留存模型,WARCH1 已实现最小请假/补卡历史留存;后续仍不得跳过正式查询、外出/加班、打卡细项等独立合同直接进入深度考勤/审批镜像。
6. 任何生产写接口、企微规则写入、应急模式开启或恢复回写都必须有单独批准、dry-run 或可审计记录。
WCPC 配置复跑清单:
@@ -443,7 +443,7 @@ WAPC2 请假/补卡只读 POC 操作口径:
- `WECOM_ALLOWED_RETURN_HOSTS=oa.hyinda.com`
5. 先让管理员用账号密码登录,在 `个人设置` 完成一次绑定,再测试企业微信工作台直达;已绑定用户从手机企业微信进入时应直接看到移动工作台。
6. 若启用 OA 同步企业微信打卡数据,在企业微信打卡 API「可调用接口的应用」中授权 `海音达工作台`。OA 默认可复用 `WECOM_AGENT_SECRET`;只有单独创建考勤同步应用时才额外配置 `WECOM_CHECKIN_SECRET`。
7. HR/考勤员工日常审批后续配置企业微信原生审批模板作为主流程;WAPC2FLIVE 已自动生成请假/补卡 OA-POC 低风险样件并复跑只读 POC,WCBK1 已完成真实回调事件确认,WACP3R 已完成补偿拉取,WREAD1 已确认请假/补卡在企微中可读,WADM1 已完成自建留存/镜像模型设计。后续仍需按 WEGOV1 顺序做 WARCH1 最小历史留存;同意单击即通过需纳入操作提示和审计设计。外出/加班模板仍需后续单独配置或 POC。报销、财务、采购、合同和项目类 OA 主办审批仍使用企业微信消息提醒并回到 OA 页面完成。
7. HR/考勤员工日常审批后续配置企业微信原生审批模板作为主流程;WAPC2FLIVE 已自动生成请假/补卡 OA-POC 低风险样件并复跑只读 POC,WCBK1 已完成真实回调事件确认,WACP3R 已完成补偿拉取,WREAD1 已确认请假/补卡在企微中可读,WADM1 已完成自建留存/镜像模型设计,WARCH1 已完成最小请假/补卡历史留存。同意单击即通过需继续纳入操作提示和审计设计。外出/加班模板仍需后续单独配置或 POC。报销、财务、采购、合同和项目类 OA 主办审批仍使用企业微信消息提醒并回到 OA 页面完成。
### 员工会看到的典型状态
- 未绑定:登录页会提示“当前企业微信账号尚未绑定”,回退到账号密码登录。
@@ -6,7 +6,7 @@
**上级事实源**:[海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md)
**相关事实源**:[需求 - 统一审批中心规约](./需求-统一审批中心规约.md)、[企微 API 能力目录](./企微API能力目录.md)
本规约细化企业微信在 `hyb-oa` 中的基础设施边界、入口、免登/绑定、通讯录身份、消息通知、deeplink、打卡、人事助手、原生审批、OA 自建留存、OA 镜像、人工接管和恢复回写要求。WFOC 起产品方向调整为“企微优先 + OA 无痛接管”;DBWFOC 后进一步固定为:HR/考勤员工日常审批优先使用企业微信,OA 做按域镜像、备份接管、财务闭环和审计中台;采购、合同、项目、财务和高敏附件继续由 OA 主办。WAPI 后,所有企微相关开发必须先读取本规约和 [企微 API 能力目录](./企微API能力目录.md),确认官方 API、Secret、权限、可信 IP、回调/模板和读写边界,再拆 Issue CSV。WMSG1 已证明企业微信应用消息和 Deeplink 真机回流可用;WAPC1 后审批进入配置准备清单,WAPC2 后先做请假/补卡模板详情与低风险 `sp_no` 只读 POC;WAPC2F 已实现 OA-POC 样件 dry-run 并公网验证 `PASS_FIXTURE_DRY_RUN_READY`。WAPC2FLIVE 已在用户批准后创建 OA-POC 请假/补卡测试模板和低风险测试审批单,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`。WCBK1 已完成真实回调 URL 验证和一条现有 OA-POC 测试审批单事件确认,公网结论 `PASS_CALLBACK_EVENT_CONFIRMED`。WEGOV1 后固定“企微主办 HR/考勤成熟能力,OA 保存关键历史和外部关联键”的路线。WACP3R 已自动发现 OA-POC 请假/补卡模板并完成公网补偿拉取复跑,报告为 `PASS_WITH_FINDINGS`;补偿拉取和详情读取可用,finding 为回调证据文件未持久化。WREAD1F 后,用户真机确认请假/补卡审批卡片、列表和详情页可读,远端报告为 `PASS_READABLE`;同意单击即通过作为操作风险进入后续设计。WADM1 已将自建留存/镜像数据模型固化为后续 WARCH1 的设计输入,但尚未创建迁移或运行时写入。后续仍必须完成最小历史留存,不是直接进入审批镜像实现。
本规约细化企业微信在 `hyb-oa` 中的基础设施边界、入口、免登/绑定、通讯录身份、消息通知、deeplink、打卡、人事助手、原生审批、OA 自建留存、OA 镜像、人工接管和恢复回写要求。WFOC 起产品方向调整为“企微优先 + OA 无痛接管”;DBWFOC 后进一步固定为:HR/考勤员工日常审批优先使用企业微信,OA 做按域镜像、备份接管、财务闭环和审计中台;采购、合同、项目、财务和高敏附件继续由 OA 主办。WAPI 后,所有企微相关开发必须先读取本规约和 [企微 API 能力目录](./企微API能力目录.md),确认官方 API、Secret、权限、可信 IP、回调/模板和读写边界,再拆 Issue CSV。WMSG1 已证明企业微信应用消息和 Deeplink 真机回流可用;WAPC1 后审批进入配置准备清单,WAPC2 后先做请假/补卡模板详情与低风险 `sp_no` 只读 POC;WAPC2F 已实现 OA-POC 样件 dry-run 并公网验证 `PASS_FIXTURE_DRY_RUN_READY`。WAPC2FLIVE 已在用户批准后创建 OA-POC 请假/补卡测试模板和低风险测试审批单,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`。WCBK1 已完成真实回调 URL 验证和一条现有 OA-POC 测试审批单事件确认,公网结论 `PASS_CALLBACK_EVENT_CONFIRMED`。WEGOV1 后固定“企微主办 HR/考勤成熟能力,OA 保存关键历史和外部关联键”的路线。WACP3R 已自动发现 OA-POC 请假/补卡模板并完成公网补偿拉取复跑,报告为 `PASS_WITH_FINDINGS`;补偿拉取和详情读取可用,当时 finding 为回调证据文件未持久化。WREAD1F 后,用户真机确认请假/补卡审批卡片、列表和详情页可读,远端报告为 `PASS_READABLE`;同意单击即通过作为操作风险进入后续设计。WADM1 已完成自建留存/镜像数据模型设计,WARCH1 已创建最小审批留存迁移和运行时入库路径;后续仍不能直接进入完整审批镜像、应急接管、恢复回写或 RuleOps。
---
@@ -36,7 +36,7 @@
| 通讯录 | 成员创建、读取、更新、禁用或删除 | 近期已验证 OA 写企微;后续改为企微主源同步 OA,OA 保留修复和审计 | `CAN_REUSE_NOW`:HCLT create/update/disable、部门读取和 WCPC 既有在职测试号 `contact.member_read` 均已通过 |
| 人事助手 | 员工字段配置、花名册读取、花名册更新 | 作为员工主数据源,驱动 OA 员工、账号、角色、绑定和状态镜像 | `LOCAL_IMPLEMENTED_FOR_READ_SYNC`:字段配置和既有在职测试号花名册读取已通过;WHMS 已本地实现只读同步到 OA;资料更新写接口仍需单独 POC |
| 打卡 | 打卡记录、员工规则、日报、月报、排班、补卡、规则管理 | 企微主办规则和采集,OA 同步按域镜像和应急对账 | `CAN_REUSE_NOW_FOR_READ`:WCPC 短窗口打卡记录和规则读取通过;日报/月报/排班等细项留到考勤镜像合同继续验证 |
| 审批 | 模板详情、提交审批、审批状态回调、审批详情、模板创建/更新、批量获取审批单号、假期余额 | HR/考勤员工日常审批企微主办,OA 同步镜像;OA 主办财务、采购、合同、项目和报销审批不迁移 | `WACP3_PASS_WITH_FINDINGS`:WAPC2FLIVE 已创建请假/补卡 OA-POC 样件并复跑通过;WCBK1 已证明审批状态回调 URL 验证、验签、解密和真实事件脱敏留证;WACP3R 已通过 `getapprovalinfo` 自动发现模板并拉取请假/补卡详情。finding 为回调证据文件未持久化,镜像数据模型和最小留存仍需在考勤/审批镜像前补齐 |
| 审批 | 模板详情、提交审批、审批状态回调、审批详情、模板创建/更新、批量获取审批单号、假期余额 | HR/考勤员工日常审批企微主办,OA 同步镜像;OA 主办财务、采购、合同、项目和报销审批不迁移 | `WARCH1_ARCHIVE_READY`:WAPC2FLIVE 已创建请假/补卡 OA-POC 样件并复跑通过;WCBK1 已证明审批状态回调 URL 验证、验签、解密和真实事件脱敏留证;WACP3R 已通过 `getapprovalinfo` 自动发现模板并拉取请假/补卡详情;WARCH1 已补齐回调事件、补偿拉取和对账 finding 的最小 OA 自建留存。完整镜像、外出/加班、应急接管和恢复回写仍需后续合同 |
| 工作台和消息 | 自建应用入口、应用消息、deeplink | 继续作为 OA 移动入口和通知渠道 | `CAN_REUSE_NOW`;WRV1 新增 `check:wecom-runtime-ready` 对入口、OAuth、脱敏消息 dry-run 和 deeplink 做运行就绪门禁 |
| 财务/报销 | 企微不提供本项目需要的 PDF OCR、付款批次、银行回单和代账闭环 | OA 主办 | `KEEP_OA_PRIMARY` |
| 真实支付、ERP、图片 OCR、银行流水导入 | 非本路线目标 | 不进入当前路线 | `NOT_IN_SCOPE` |
@@ -393,15 +393,15 @@ WAPC2 将审批配置准备先收窄到请假和补卡两类低风险链路,
- 报告只输出脱敏模板 ID/`sp_no` 前缀、字段类型计数、审批节点计数和状态摘要,不输出表单值、附件、评论、完整 `sp_no` 或原始 payload。
- 本阶段不接真实回调、不创建 OA 镜像表、不写 OA/企微数据、不进入 `wecom_attendance_approval_mirror_v1`。
## 7.15 WADM1 自建留存基础设施设计
## 7.15 WADM1/WARCH1 自建留存基础设施
WADM1 后,企业微信基础设施侧的数据职责调整为“先持久化关键历史,再增强镜像查询”。本节只描述设计,不代表 Prisma schema 已实现。
WADM1 后,企业微信基础设施侧的数据职责调整为“先持久化关键历史,再增强镜像查询”。WARCH1 已把请假/补卡最小审批历史留存落到 Prisma schema、回调入库、补偿拉取入库和 QA-only 离线读取 runner;打卡、人事快照仍按既有表和后续合同扩展。
自建留存 V1 基础对象:
- 企微审批历史:请假、补卡先入 `WecomApprovalArchive` 设计域,保存完整外部键、规范化状态、申请人/审批人映射、时间、字段摘要和审批动作。
- 回调事件:企微 POST 回调解密后不再只落文件报告,后续应入 `WecomApprovalCallbackEvent`,DB 内保存完整 `sp_no`、`template_id` 和 `approval_archive_id` 用于关联,以幂等键和摘要 digest 支撑重复投递识别;报告和日志只显示脱敏前缀。
- 补偿拉取:`getapprovalinfo` 拉取 run/item 进入 `WecomApprovalCompensationRun` / `WecomApprovalCompensationItem`,用于跨时间窗口补齐漏回调;`getapprovaldetail` 详情快照作为业务状态最终校准来源。
- 企微审批历史:请假、补卡先入 `WecomApprovalArchive`,保存完整外部键、规范化状态、申请人/审批人映射、时间、字段摘要和审批动作。
- 回调事件:企微 POST 回调解密后不再只落文件报告,WARCH1 默认入 `WecomApprovalCallbackEvent`,DB 内保存完整 `sp_no`、`template_id` 和 `approval_archive_id` 用于关联,以幂等键和摘要 digest 支撑重复投递识别;报告和日志只显示脱敏前缀。
- 补偿拉取:`getapprovalinfo` 拉取 run/item 进入 `WecomApprovalCompensationRun` / `WecomApprovalCompensationItem`,用于跨时间窗口补齐漏回调;`getapprovaldetail` 详情快照作为业务状态最终校准来源。写 OA DB 必须同时使用 `--persist-to-db` 和 `WECOM_ARCHIVE_WRITE=1`。
- 对账 finding:漏回调、重复回调、状态不一致、样例缺失、权限阻断等进入 `WecomApprovalReconcileFinding`,不得在 runner 中口头吞掉。
- 打卡和人事:优先复用 `WecomCheckinSyncRun`、`WecomCheckinRecord`、`WecomHrMasterSyncRun`、`WecomHrMasterSyncItem` 和 `WecomIdentityBinding`;WARCH1 不新建平行打卡主模型,不重造企微风控。
@@ -410,7 +410,7 @@ WADM1 后,企业微信基础设施侧的数据职责调整为“先持久化
- OA DB 可保存完整外部键用于补偿和恢复;所有日志、报告、Issue 和文档只输出脱敏前缀。
- 不保存 access token、Secret、回调 Token、EncodingAESKey、原始 XML、密文、人脸、完整位置、设备明文或无治理原始 payload。
- 企微 API 不可用时,OA 历史页应能查看已入库的历史摘要;V1 不承诺继续办理企微主办流程。
- WARCH1 实施必须单独批准 schema、索引、迁移回滚、权限和 replay 策略。
- WARCH1 的离线验证命令为 `npm run check:wecom-approval-archive -- --offline --format=json`,只读 OA DB,不调用企微 API;正式查询 API 或管理页仍需后续独立合同。
---
+8 -8
查看文件
@@ -5,7 +5,7 @@
**状态**:模块级开发规约
**上级事实源**:[海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md)
本规约细化 `hyb-oa` 审批中心和企业微信原生审批镜像的产品边界、角色权限、状态模型、异常规则、留痕要求和验收场景。WFOC 起,审批按主办方分层;DBWFOC 后边界进一步收窄:报销、财务、采购、合同和项目类审批继续由 OA 主办;请假、补卡、外出、加班等 HR/考勤员工日常审批优先由企业微信主办,OA 同步按域镜像并保留自建历史留存、人工接管和恢复回写能力。WMSG1 已验证企业微信应用消息和 Deeplink 回流,WAPC1 后审批镜像前置工作进入配置准备清单阶段,WAPC2 已提供请假/补卡只读 POC runner,WAPC2FLIVE 已自动生成请假/补卡 OA-POC 样件并复跑通过。WCBK1 已完成只读回调 URL 验证和真实审批事件确认,结论为 `PASS_CALLBACK_EVENT_CONFIRMED`。WEGOV1 后明确先利用企微成熟审批和考勤能力,同时由 OA 保存关键历史和外部关联键。WACP3R 已自动发现 OA-POC 请假/补卡模板并公网复跑到 `PASS_WITH_FINDINGS`:补偿拉取和详情读取可用,finding 是回调证据文件未持久化。WREAD1F 后已完成请假/补卡真机可读性确认:消息卡片、列表、详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`;但同意按钮单击即通过,需在 WARCH1 的审计留存、提示或操作规程中处理。WADM1 已完成自建留存/镜像数据模型设计;后续仍必须完成 WARCH1 最小历史留存,不能直接放行镜像实现。后续审批相关开发必须先从本规约拆分 Issue CSV,再进入技术方案和代码实现。
本规约细化 `hyb-oa` 审批中心和企业微信原生审批镜像的产品边界、角色权限、状态模型、异常规则、留痕要求和验收场景。WFOC 起,审批按主办方分层;DBWFOC 后边界进一步收窄:报销、财务、采购、合同和项目类审批继续由 OA 主办;请假、补卡、外出、加班等 HR/考勤员工日常审批优先由企业微信主办,OA 同步按域镜像并保留自建历史留存、人工接管和恢复回写能力。WMSG1 已验证企业微信应用消息和 Deeplink 回流,WAPC1 后审批镜像前置工作进入配置准备清单阶段,WAPC2 已提供请假/补卡只读 POC runner,WAPC2FLIVE 已自动生成请假/补卡 OA-POC 样件并复跑通过。WCBK1 已完成只读回调 URL 验证和真实审批事件确认,结论为 `PASS_CALLBACK_EVENT_CONFIRMED`。WEGOV1 后明确先利用企微成熟审批和考勤能力,同时由 OA 保存关键历史和外部关联键。WACP3R 已自动发现 OA-POC 请假/补卡模板并公网复跑到 `PASS_WITH_FINDINGS`:补偿拉取和详情读取可用,finding 是回调证据文件未持久化。WREAD1F 后已完成请假/补卡真机可读性确认:消息卡片、列表、详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`;但同意按钮单击即通过,需在归档审计、提示或操作规程中处理。WADM1 已完成自建留存/镜像数据模型设计;WARCH1 已实现请假/补卡最小历史留存,但完整镜像、应急接管、恢复回写和 RuleOps 仍不能直接放行。后续审批相关开发必须先从本规约拆分 Issue CSV,再进入技术方案和代码实现。
---
@@ -355,7 +355,7 @@ WF8-3、WF8-4、WF8-5 实现前必须基于本设计给技术方案;实现后
- 采购单附件、合同正文、报价、供应商资料、发票、银行回单、代账资料和其他高敏附件不进入企业微信原生审批或企业微信附件;企业微信只做提醒、deeplink 和免登录回流。
- 按域镜像是默认规则:企微主办 HR/考勤域镜像到 OA;OA 主办采购、合同、项目、财务和报销域不反向全量镜像到企微。
- WFDG 后,审批相关页面和接口必须区分 `OA 主办`、`企微镜像` 和 `OA 应急接管`;企微镜像不得被误用为 OA 日常新建入口,OA 应急接管单据必须带 `EMERGENCY` 并进入恢复回写队列。
- WAPI/WRRA/WOUA-FIX/WAPC1/WAPC2/WPROC1/WEGOV1 后,审批相关运行时迁移必须先读 [企微优先开发路线图](./企微优先开发路线图.md) 和 [企微 API 能力目录](./企微API能力目录.md),按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` 的顺序拆单功能 Issue CSV;WOUA-FIX 只收口了用户一致性测试账号差异和处理决策,WRV1/WRA1/WMSG1 只验证入口、OAuth/绑定、脱敏消息、deeplink、通讯录、人事助手、打卡只读主链路和真实消息回流。WAPC1 只固化审批配置准备清单;WAPC2 只读模板详情和低风险 `sp_no` 审批详情;WAPC2FLIVE 只生成 OA-POC 低风险测试样件并自动复跑只读 POC;WCBK1 只证明真实审批状态回调可验签、可解密、可脱敏留证;WACP3R 只证明补偿拉取和详情读取可用,并发现回调证据文件未持久化;WEGOV1 只修正混合主办、自建留存和测试治理口径;WREAD1 只证明请假/补卡当前样例可读;WADM1 只完成模型设计,不代表历史留存或镜像实现完成。WARCH1 最小历史留存未实现前,不得直接开发企微审批镜像或恢复回写。
- WAPI/WRRA/WOUA-FIX/WAPC1/WAPC2/WPROC1/WEGOV1 后,审批相关运行时迁移必须先读 [企微优先开发路线图](./企微优先开发路线图.md) 和 [企微 API 能力目录](./企微API能力目录.md),按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` 的顺序拆单功能 Issue CSV;WOUA-FIX 只收口了用户一致性测试账号差异和处理决策,WRV1/WRA1/WMSG1 只验证入口、OAuth/绑定、脱敏消息、deeplink、通讯录、人事助手、打卡只读主链路和真实消息回流。WAPC1 只固化审批配置准备清单;WAPC2 只读模板详情和低风险 `sp_no` 审批详情;WAPC2FLIVE 只生成 OA-POC 低风险测试样件并自动复跑只读 POC;WCBK1 只证明真实审批状态回调可验签、可解密、可脱敏留证;WACP3R 只证明补偿拉取和详情读取可用,并发现回调证据文件未持久化;WEGOV1 只修正混合主办、自建留存和测试治理口径;WREAD1 只证明请假/补卡当前样例可读;WADM1 只完成模型设计;WARCH1 已实现请假/补卡最小历史留存。完整企微审批镜像、恢复回写、应急接管和 RuleOps 仍必须另拆合同,不得用 WARCH1 结论直接放行。
- WRV1/WRA1 runner `npm run check:wecom-runtime-ready` 中的审批模板、测试 `sp_no` 和真实回调检查只允许输出 `NEEDS_CONFIG`;这类缺口不阻断运行优先主链路的 `PASS_WITH_CONFIG_GAPS`,但必须阻断 `wecom_attendance_approval_mirror_v1`、应急恢复回写和 RuleOps。`WECOM_CHECKIN_SYNC_ENABLED` 只控制正式考勤同步批次,不阻断 QA-only 打卡只读能力探测。
- WMSG1 已证明企业微信应用消息可达和 Deeplink 可进入 OA 通知页;该证据只清除通知链路,不清除审批模板、测试 `sp_no` 或真实回调前置。WAPC1 固化配置准备清单后,WAPC2 先提供请假/补卡模板详情和低风险审批详情只读 POC;WPROC1 后优先用 WAPC2F 自动生成 POC 样件,不默认要求人工手动创建模板或测试单。人工介入限于写入批准、密钥/回调配置、企微后台无法 API 化配置、真机确认和风险拍板。
- 后续企微审批同步的 POC 必须输出 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY`、`NOT_SUPPORTED_BY_API`,缺模板、回调、Secret 或权限时记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。
@@ -416,14 +416,14 @@ WAPC2F 是 WAPC2 后的自动化优先补缺阶段,目标是尽量用 QA-only
- `WCBK1 回调只读 POC`:已新增只读 `GET/POST /api/wecom/approval/callback` 和 `npm run check:wecom-approval-callback-poc`。公网 synthetic 验签、解密和解析通过后,部署环境完成回调配置并达到 `PASS_CALLBACK_URL_VERIFIED`;`SMOKE101` 随后处理现有 OA-POC 补卡测试单触发真实事件,复跑结论 `PASS_CALLBACK_EVENT_CONFIRMED`,报告只保存脱敏事件证据。
- `WEGOV1 企微主办治理与自建留存路线修正`:固定企微主办域、OA 主办域、OA 留存域和 OA 应急域;明确企业微信没有完整 sandbox 假设,默认使用受控测试域和自动化 runner。
- `WACP3 补偿拉取 POC`:已新增 `check:wecom-approval-compensation-poc` 和 `getapprovalinfo` 只读封装;WACP3R 公网复跑为 `PASS_WITH_FINDINGS`,已按 `SMOKE002` creator 自动发现请假/补卡 OA-POC 模板并拉取审批详情。finding 为回调证据文件未持久化,需在 WADM1/WARCH1 的自建留存模型中解决。
- `WREAD1 审批可读性与误审批防护门禁`:已新增 `check:wecom-approval-readability-gate` 只读 runner,并在 WREAD1F 新样例后完成真机确认。公网人工结果报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`:请假和补卡消息卡片、审批列表、详情页足够看清;驳回有二次确认,同意单击即通过。后续不要求回退 OA 主办,但需在 WADM1/WARCH1 中处理单击同意的审计和操作提示风险。
- `WADM1 自建留存/镜像数据模型设计`:已完成字段、状态机、幂等键、索引、隐私保留、审计和迁移回滚方案设计;本阶段不改 schema、不写业务代码。
- `WARCH1 最小自建历史留存实现`:先只读保存企微审批、打卡和人事关键快照,保证企微不可用时可查看历史,再进入完整镜像增强。
- `WACP3 补偿拉取 POC`:已新增 `check:wecom-approval-compensation-poc` 和 `getapprovalinfo` 只读封装;WACP3R 公网复跑为 `PASS_WITH_FINDINGS`,已按 `SMOKE002` creator 自动发现请假/补卡 OA-POC 模板并拉取审批详情。当时 finding 为回调证据文件未持久化,已由 WADM1 设计和 WARCH1 DB 回调事件入库承接。
- `WREAD1 审批可读性与误审批防护门禁`:已新增 `check:wecom-approval-readability-gate` 只读 runner,并在 WREAD1F 新样例后完成真机确认。公网人工结果报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`:请假和补卡消息卡片、审批列表、详情页足够看清;驳回有二次确认,同意单击即通过。后续不要求回退 OA 主办;WARCH1 已在归档审计中保留动作来源和补偿对账,正式历史页/操作规程仍需继续提示该风险。
- `WADM1 自建留存/镜像数据模型设计`:已完成字段、状态机、幂等键、索引、隐私保留、审计和迁移回滚方案设计。
- `WARCH1 最小自建历史留存实现`:已新增请假/补卡审批留存表、回调事件表、补偿 run/item/finding、补偿入库双开关和 QA-only 离线读取 runner;先证明企微不可用时可查看已保存历史,再进入完整镜像增强。
### 9.4.1 WADM1 自建审批留存模型
WADM1 只做模型设计,不修改 Prisma schema。后续 WARCH1 才能在单独合同中创建迁移和写入路径。
WADM1 只做模型设计。WARCH1 已在单独合同中创建最小迁移和写入路径,范围仍限定请假/补卡历史留存。
企微主办审批进入 OA 自建留存时,建议按以下模型拆分:
@@ -451,7 +451,7 @@ WADM1 只做模型设计,不修改 Prisma schema。后续 WARCH1 才能在单
请假/补卡 WARCH1 V1 至少应保存:申请类型、申请人、审批人、时间范围或补卡时间、时长、原因摘要、状态、审批历史、来源、外部关联键和同步证据。外出/加班仍需后续样例或处理决策;不可把请假/补卡可读性结论扩展为外出/加班已通过。
同意单击即通过的风险处理不要求回退 OA 主办,但 WARCH1 之后的 OA 历史页和操作规程必须突出:审批动作来源、操作者、时间、状态变化、是否由企微单击同意触发,以及后续补偿拉取是否与回调一致。
同意单击即通过的风险处理不要求回退 OA 主办。WARCH1 已在归档记录中保留动作来源、操作者、时间、状态变化、回调/补偿来源和对账 finding;后续正式历史页和操作规程必须继续突出是否由企微单击同意触发,以及补偿拉取是否与回调一致。
隐私边界:
+5 -5
查看文件
@@ -7,7 +7,7 @@
**相关事实源**:[需求 - 统一审批中心规约](./需求-统一审批中心规约.md)、[需求 - 企业微信基础设施规约](./需求-企业微信基础设施规约.md)
**体验总依据**:[产品设计规则基线](./产品设计规则.md)
本规约细化请假、补卡、外出、加班与考勤修正的产品要求。WFOC 起,企业微信作为 HR/考勤员工日常审批和打卡规则主操作端;OA 负责按域镜像、应急接管、恢复回写、查询导出和审计。WEGOV1 后进一步明确:企微成熟 HR/考勤功能优先上线,OA 不重造企业微信完整打卡/考勤/HR 产品,但必须做自建历史留存、审计、入口、消息和应急兜底。WAPC2 后,请假和补卡先通过 QA-only runner 只读验证模板详情与低风险 `sp_no` 审批详情;WAPC2FLIVE 已生成 OA-POC 样件并复跑通过;WCBK1 已完成真实回调 URL 验证和一条测试审批事件确认;WACP3R 已自动发现模板并证明补偿拉取和详情读取可用。WREAD1F 后,用户真机确认请假/补卡在企业微信消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`;驳回有二次确认,同意单击即通过。WADM1 已完成自建留存/镜像数据模型设计,下一步仍需 WARCH1 最小历史留存实现;当前不代表审批镜像已实现。PC 和移动页面体验以产品设计规则基线为依据,不重复定义通用审批链和移动导航规则。
本规约细化请假、补卡、外出、加班与考勤修正的产品要求。WFOC 起,企业微信作为 HR/考勤员工日常审批和打卡规则主操作端;OA 负责按域镜像、应急接管、恢复回写、查询导出和审计。WEGOV1 后进一步明确:企微成熟 HR/考勤功能优先上线,OA 不重造企业微信完整打卡/考勤/HR 产品,但必须做自建历史留存、审计、入口、消息和应急兜底。WAPC2 后,请假和补卡先通过 QA-only runner 只读验证模板详情与低风险 `sp_no` 审批详情;WAPC2FLIVE 已生成 OA-POC 样件并复跑通过;WCBK1 已完成真实回调 URL 验证和一条测试审批事件确认;WACP3R 已自动发现模板并证明补偿拉取和详情读取可用。WREAD1F 后,用户真机确认请假/补卡在企业微信消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`;驳回有二次确认,同意单击即通过。WARCH1 已实现请假/补卡最小审批历史留存、回调事件入库、补偿拉取入库和离线读取证明;当前仍不代表完整审批镜像、外出/加班、应急接管或恢复回写已实现。PC 和移动页面体验以产品设计规则基线为依据,不重复定义通用审批链和移动导航规则。
---
@@ -49,7 +49,7 @@ REQAUD 复核后,ATTV1 已完成统一入口实现,当前运行时完成度
| 请假对团队状态/日历的影响 | `DONE` | 已批准请假可作为团队状态和日历依据。 |
| 企业微信打卡作为原始采集 | `DONE` | 由企业微信侧承载定位、人脸、Wi-Fi、设备和防作弊能力;OA 不重造。 |
| 企业微信打卡 API 同步 | `PARTIAL` | 已新增同步批次和企微打卡摘要表;有已授权调用打卡 API 的自建应用 Secret 和企微权限时同步,缺配置时记录 `SKIP`。`WECOM_CHECKIN_SECRET` 可作为单独考勤同步应用的显式覆盖;未配置时复用已授权工作台应用的 `WECOM_AGENT_SECRET`。 |
| 企微 HR/考勤审批镜像 | `POC_CHAIN_PARTIAL_READY` | WAPC2 已新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC;WAPC2FLIVE 已完成自动样件与 WAPC2 自动复跑;WCBK1 已完成真实回调 URL 验证和一条测试审批事件确认;WACP3R 已证明请假/补卡补偿拉取和详情读取可用,finding 为回调证据文件未持久化;WREAD1F 已完成请假/补卡模板级真机确认,结论 `PASS_READABLE`,但同意单击即通过需作为操作风险处理。后续 `wecom_attendance_approval_mirror_v1` 仍需外出/加班 POC 或处理决策、WADM1 自建留存/镜像数据模型和 WARCH1 最小历史留存。 |
| 企微 HR/考勤审批镜像 | `ARCHIVE_MINIMUM_READY` | WAPC2 已新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC;WAPC2FLIVE 已完成自动样件与 WAPC2 自动复跑;WCBK1 已完成真实回调 URL 验证和一条测试审批事件确认;WACP3R 已证明请假/补卡补偿拉取和详情读取可用;WREAD1F 已完成请假/补卡模板级真机确认,结论 `PASS_READABLE`。WARCH1 已实现请假/补卡最小历史留存和离线读取。后续 `wecom_attendance_approval_mirror_v1` 仍需外出/加班 POC 或处理决策、正式查询 API/页面和打卡细项。 |
| 员工补卡申请审批 | `DONE_AS_OA_FALLBACK` | 当前 OA 能提交补卡修正;WFOC 后正常入口迁往企微,OA 仅在应急模式开放并进入恢复回写。 |
| 外出申请审批 | `DONE_AS_OA_FALLBACK` | 当前 OA 能提交外出修正;WFOC 后正常入口迁往企微,OA 仅在应急模式开放并进入恢复回写。 |
| 加班申请审批 | `DONE_AS_OA_FALLBACK` | 当前 OA 能提交加班修正;WFOC 后正常入口迁往企微,OA 仅在应急模式开放并进入恢复回写。 |
@@ -226,10 +226,10 @@ WFOC 后,正常审批规则优先以企业微信审批模板和打卡/假期
- 当前 A0.6:`WAPC2 请假/补卡只读 POC` 已实现 runner;缺模板 ID 或低风险 `sp_no` 时保持 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`。
- 已完成 A0.7/A0.8/A0.9:`WAPC2F`、`WAPC2FLIVE`、`WAPC2 自动复跑` 和 `WCBK1 回调只读 POC` 已证明请假/补卡 OA-POC 样件、只读审批详情、真实回调 URL、验签/解密和一条真实事件脱敏留证。
- 当前 A0.10:`WEGOV1 企微主办治理与自建留存路线修正`,固定企微主办域、OA 主办域、OA 留存域、OA 应急域、受控测试域和自动化/人工边界。
- 已完成 A0.11:`WACP3/WACP3R 补偿拉取 POC`,已证明按时间窗口获取审批单号并用 `getapprovaldetail` 拉详情;当前 finding 是 WCBK1 回调证据文件未持久化,需在 WADM1/WARCH1 的自建留存模型中解决。
- 已完成 A0.11:`WACP3/WACP3R 补偿拉取 POC`,已证明按时间窗口获取审批单号并用 `getapprovaldetail` 拉详情;当时 finding 是 WCBK1 回调证据文件未持久化,已由 WADM1 设计和 WARCH1 最小留存入库路径承接。
- 已完成 A0.12:`WREAD1 审批可读性与误审批防护门禁`,已新增只读 runner 并在 WREAD1F 新样例后完成人工真机确认;请假、补卡在企业微信消息、列表和详情页中均可看清,远端报告为 `PASS_READABLE`。风险记录:驳回有二次确认,同意单击即通过;外出/加班仍需后续样例或处理决策。
- 已完成 A0.13:`WADM1 自建留存/镜像数据模型设计`,已固定请假/补卡审批留存主表、参与人/动作/字段摘要、回调事件、补偿拉取 run/item、对账 finding、隐私保留、迁移回滚,以及同意单击风险对应的审计和提示策略。本阶段不创建 schema、不写业务数据。
- 后续 A0.14:`WARCH1 最小自建历史留存实现`,只读保存企微审批、打卡和人事关键快照,先满足企微不可用时可查看历史。
- 已完成 A0.13:`WADM1 自建留存/镜像数据模型设计`,已固定请假/补卡审批留存主表、参与人/动作/字段摘要、回调事件、补偿拉取 run/item、对账 finding、隐私保留、迁移回滚,以及同意单击风险对应的审计和提示策略。
- 已完成 A0.14:`WARCH1 最小自建历史留存实现`,已保存请假/补卡企微审批历史、回调事件、补偿拉取记录和对账 finding,并新增离线读取 runner。打卡和人事快照先复用既有表,后续按缺口扩展。
- 后续 A1:`wecom_attendance_approval_mirror_v1`,同步企微请假、补卡、外出、加班审批、附件、历史和状态回调;该命名承接旧口径 `wecom_native_approval_mirror_v1`。WAPC2FLIVE 已生成请假/补卡 OA-POC 模板和低风险 `sp_no` 样例,WACP3R 已证明请假/补卡补偿拉取;正式开发前仍必须补齐外出/加班样例或处理决策、可读性门禁、自建留存模型、最小历史留存和必要打卡细项 POC。
- 后续 A2:企微打卡规则、排班、记录、日报、月报按域镜像,覆盖 Secret/API 权限、短窗口同步、错误码和脱敏证据。
- 后续 A3:`oa_hr_emergency_mode_v1`,管理员人工接管、应急提交、应急审批和恢复队列。
@@ -1,9 +1,9 @@
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
"WARCH1-0","最小历史留存合同与边界冻结","建立 WARCH1 最小自建历史留存实现合同;本阶段只覆盖请假 + 补卡企微审批历史、回调证据、补偿拉取证据和 QA-only 离线读取证明,不进入完整镜像、正式管理页、外出/加班、应急接管、恢复回写或 RuleOps。","1) 明确 WARCH1 是 WADM1 后的最小实现切片;2) 只写 OA 自建留存表,不写企微数据、不创建企微模板、不提交企微审批单;3) 企微读取固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback;4) 缺 Secret、权限、可信 IP、可见范围、模板或回调配置时输出 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;5) 默认采用保守策略:原因摘要允许入 OA DB 受权限保护,报告/导出脱敏;回调 DB 写入失败返回非 200 让企微重试。","受影响流程:WADM1 设计 -> WARCH1 最小历史留存 -> 后续镜像准入;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv --profile wecom --json;人工确认本合同后再编码。","python3|node|manual","TODO","TODO","TODO","issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv","[]","需要用户批准后才进入编码。回滚/清理:本行仅合同,无 schema、无代码、无部署、无 OA/企微数据写入。"
"WARCH1-1","Prisma 留存表与迁移","新增最小审批留存 Prisma 模型和 migration:WecomApprovalArchive、WecomApprovalParticipant、WecomApprovalAction、WecomApprovalFieldSummary、WecomApprovalAttachmentSummary、WecomApprovalCallbackEvent、WecomApprovalCompensationRun、WecomApprovalCompensationItem、WecomApprovalReconcileFinding。","1) 主表以 corp_id_hash + sp_no 唯一,保存完整 sp_no、template_id、企微 userid、申请/完成/状态时间、last_source、last_detail_digest 和风险标记;2) 员工外键 onDelete SetNull,不因员工归档删除历史;3) 回调事件 idempotency_key 唯一,补偿 item run_id + sp_no 唯一,动作 archive_id + idempotency_key 唯一;4) 迁移只新增表和索引,不改旧表语义;5) 不保存 Secret、token、EncodingAESKey、原始 XML、密文或无治理原始 payload。","受影响流程:企微审批详情/回调 -> OA DB 留存 -> 断开企微 API 后历史可查;验证步骤:cd server && npm run prisma:format;cd server && npm run prisma:validate;cd server && npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script;检查 migration.sql 只新增 wecom_approval_* 表和索引。","prisma|node|manual","TODO","TODO","TODO","server/prisma/schema.prisma|server/prisma/migrations/*_wecom_approval_archive_minimum_v1/migration.sql","[""WARCH1-0""]","回滚路径:迁移上线前可删除新增 migration;上线后回滚代码时不删除已入库审计证据,必要时只停用写入入口。DB 内可保存完整外部键,日志、报告、Issue 和默认导出只输出脱敏前缀。"
"WARCH1-2","审批详情与回调规范化服务","新增企微审批留存规范化服务,内部保留完整外部键用于入库,外部报告只输出脱敏前缀;从 getapprovaldetail 和回调解密结果中抽取请假/补卡必要字段、参与人、动作、字段摘要和 digest。","1) getapprovaldetail 规范化结果包含完整 sp_no、template_id、申请人/审批人 userid、规范化状态、状态时间、字段摘要、动作历史和 last_detail_digest;2) 回调规范化结果从只含前缀升级为内部完整键 + 外部脱敏摘要;3) 字段摘要只保存业务必要字段:申请类型、时间范围或补卡时间、时长、原因摘要、状态和审批历史;4) 原因摘要入库但限长、标敏感级别,报告/导出不输出完整文本;5) 不保存原始 payload、附件内容或评论全文。","受影响流程:企微详情/回调 -> 规范化 -> 入库 upsert;验证步骤:cd server && node --check src/services/integrations/wecomApprovalArchive.js;cd server && npx vitest run tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose。","node|vitest","TODO","TODO","TODO","server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/services/wecomApprovalArchive.test.js|server/src/services/integrations/wecomCallbackCrypto.js|server/src/services/integrations/wecomApproval.js","[""WARCH1-1""]","默认选择:原因摘要完整入库到受权限保护业务表,默认 UI/export/report 脱敏或限权;如用户要求更严格,可改为前若干字 + digest。回滚/清理:规范化服务无外部副作用。"
"WARCH1-3","回调事件入库与重试语义","调整 WCBK1 回调 POST 路径:验签、解密、规范化后写入 WecomApprovalCallbackEvent,并尝试合并到 WecomApprovalArchive;DB 写入失败返回非 200 触发企微重试,不再把失败伪造成成功。","1) POST /api/wecom/approval/callback 保持验签/解密;2) 回调事件按 idempotency_key upsert,重复投递不重复写动作历史;3) 找不到 archive 时也按完整 sp_no/template_id 先保存事件,补偿详情后再关联;4) DB 写入失败返回 5xx 或明确错误,企微可重试;5) 日志只输出 sp_no/template/userid/idempotency 脱敏前缀,不输出原始 XML、密文、Token、EncodingAESKey、完整 sp_no、完整 userid、完整 template_id 或原始 payload。","受影响流程:企微真实审批状态回调 -> OA 持久证据 -> 后续补偿比对;验证步骤:cd server && node --check src/routes/wecomApprovalCallback.js;cd server && npx vitest run tests/unit/routes/wecomApprovalCallback.test.js tests/unit/services/wecomApprovalArchive.test.js tests/unit/services/wecomCallbackCrypto.test.js --reporter=verbose;synthetic 回调重复投递验证不重复动作。","node|vitest","TODO","TODO","TODO","server/src/routes/wecomApprovalCallback.js|server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/routes/wecomApprovalCallback.test.js","[""WARCH1-2""]","默认选择:DB 写入失败不返回 success,接受企微重试;这是避免证据丢失的保护。回滚路径:可临时关闭归档写入并恢复 JSONL evidence fallback,但不得删除已入库证据。"
"WARCH1-4","补偿拉取入库与对账 finding","扩展 WACP3 补偿 runner:按模板过滤只读调用 getapprovalinfo/getapprovaldetail 后,将 run/item/detail digest 和规范化审批历史写入 OA DB,并生成漏回调、重复回调、状态不一致等 WecomApprovalReconcileFinding。","1) 企微 API 仍只读,固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback;2) 默认 dry-run 只生成规范化计划,写 OA DB 必须显式 --persist-to-db 且 WECOM_ARCHIVE_WRITE=1;3) 同一 run_id + sp_no 只能有一条 item;4) getapprovaldetail 是最终校准来源,旧 pending/unknown 不覆盖较新终态;5) 跨来源终态冲突写 status_mismatch finding,不静默改成功。","受影响流程:补偿拉取 -> 详情校准 -> OA 历史留存/回调比对;验证步骤:cd server && node --check src/scripts/check-wecom-approval-compensation-poc.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-compensation-poc.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;dry-run 验证不写库,双开关验证才写库。","node|vitest","TODO","TODO","TODO","server/src/scripts/check-wecom-approval-compensation-poc.js|server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/scripts/check-wecom-approval-compensation-poc.test.js","[""WARCH1-2"",""WARCH1-3""]","写入保护:OA DB 持久化需要 --persist-to-db + WECOM_ARCHIVE_WRITE=1;不写企微数据。回滚/清理:可停止 runner 写入,已保存 run/item/finding 保留审计。"
"WARCH1-5","QA-only 离线历史读取 runner","新增 QA-only runner check-wecom-approval-archive,验证不调用企微 API 时可从 OA DB 读取已保存请假/补卡历史摘要,并输出脱敏报告。","1) 新增 npm run check:wecom-approval-archive;2) 支持 --offline --format=json;3) 输出 PASS_ARCHIVE_HISTORY_READABLE、PASS_WITH_FINDINGS、PARTIAL_ARCHIVE_NEEDS_DATA 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;4) 报告只输出计数、状态摘要、脱敏 sp_no/template/userid 前缀、字段类型和 finding,不输出手机号、邮箱、token、secret、完整 userid、完整 template_id、完整 sp_no、原因全文、附件、评论或原始 payload;5) 断开企微 API 仍能读取 OA DB 历史摘要。","受影响流程:企微不可用 -> OA 自建历史查看能力验证;验证步骤:cd server && node --check src/scripts/check-wecom-approval-archive.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-archive.test.js --reporter=verbose;执行 npm run check:wecom-approval-archive -- --offline --format=json。","node|vitest","TODO","TODO","TODO","server/src/scripts/check-wecom-approval-archive.js|server/package.json|server/tests/unit/scripts/check-wecom-approval-archive.test.js","[""WARCH1-4""]","该 runner 不调用企微 API、不写企微数据;默认只读 OA DB。回滚/清理:删除脚本和 package script 即可,DB 留存证据不删除。"
"WARCH1-6","权限、脱敏与同意单击风险测试","补齐权限与隐私边界测试,确保完整外部键只在 DB 内受控保存,默认 API、runner、日志和导出不泄露;把 WREAD1 的同意单击风险落成审计事实。","1) 单测覆盖日志/报告不泄露手机号、邮箱、token、Secret、EncodingAESKey、完整 userid、完整 sp_no、完整 template_id、原始 XML、密文、原始 payload;2) DB 可保存完整外部键,默认输出仅脱敏前缀;3) 普通员工、审批人、HR/管理员的未来权限边界写入事实源;4) 同意单击动作记录来源=企微、操作者、时间、状态变化、回调/补偿一致性;5) 原因摘要默认不进入脱敏报告。","受影响流程:历史留存 -> 权限/审计 -> 后续查询和导出;验证步骤:cd server && npx vitest run tests/unit/services/wecomApprovalArchive.test.js tests/unit/scripts/check-wecom-approval-archive.test.js --reporter=verbose;rg -n '完整 sp_no|原因摘要|同意单击|原始 payload|WARCH1' docs .agents/skills/hyb-oa-delivery/SKILL.md。","vitest|rg","TODO","TODO","TODO","server/tests/unit/services/wecomApprovalArchive.test.js|server/tests/unit/scripts/check-wecom-approval-archive.test.js|docs/需求-统一审批中心规约.md|docs/管理员手册.md","[""WARCH1-5""]","本阶段不新增正式管理页;权限先以服务/runner/事实源固定,为后续 UI/API 做准入。回滚/清理:文档可按 git 回滚,测试无数据副作用。"
"WARCH1-7","事实源回写、部署验证与提交","完成 WARCH1 实现后的文档回写、本地门禁、公网只读/受控写入验证和中文提交;若涉及远端写入,仅写 OA 自建留存表,不写企微数据。","1) Issue 状态按实测结果回填;2) 路线图、API 能力目录、统一审批规约、企业微信基础设施规约、请假考勤规约、管理员手册、试运行报告、文档索引、事实源矩阵和 skill 同步 WARCH1 结果;3) 本地 lint/syntax/Prisma/vitest/process gate/docs gate 通过;4) SYNC_ENV=0 受控部署,远端 /ready 正常;5) 远端先 dry-run,再在批准的双开关下持久化现有 OA-POC 请假/补卡历史,最后 offline runner 证明可读;6) 中文提交完成。","受影响流程:WARCH1 实现 -> 公网验证 -> 后续镜像准入;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv --profile wecom --json;cd server && npm run prisma:validate;cd server && npx vitest run tests/unit/services/wecomApprovalArchive.test.js tests/unit/scripts/check-wecom-approval-archive.test.js tests/unit/scripts/check-wecom-approval-compensation-poc.test.js tests/unit/routes/wecomApprovalCallback.test.js --reporter=verbose;node scripts/qa/audit-docs.mjs --check-registry;python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check;SYNC_ENV=0 bash deploy/trialrun/deploy.sh;远端执行 npm run check:wecom-approval-compensation-poc -- --live --format=json 和 npm run check:wecom-approval-archive -- --offline --format=json。","python3|node|prisma|vitest|bash|git","TODO","TODO","TODO","docs/企微优先开发路线图.md|docs/企微API能力目录.md|docs/需求-统一审批中心规约.md|docs/需求-企业微信基础设施规约.md|docs/需求-请假考勤修正规约.md|docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md","[""WARCH1-6""]","部署写入边界:SYNC_ENV=0,不同步本地 env;不写企微数据;如公网缺 Secret、权限、可信 IP、可见范围、模板或回调配置,记录 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG,不伪造成 PASS。提交信息建议:WARCH1 实现企微审批最小历史留存。"
"WARCH1-0","最小历史留存合同与边界冻结","建立 WARCH1 最小自建历史留存实现合同;本阶段只覆盖请假 + 补卡企微审批历史、回调证据、补偿拉取证据和 QA-only 离线读取证明,不进入完整镜像、正式管理页、外出/加班、应急接管、恢复回写或 RuleOps。","1) 明确 WARCH1 是 WADM1 后的最小实现切片;2) 只写 OA 自建留存表,不写企微数据、不创建企微模板、不提交企微审批单;3) 企微读取固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback;4) 缺 Secret、权限、可信 IP、可见范围、模板或回调配置时输出 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;5) 默认采用保守策略:原因摘要允许入 OA DB 受权限保护,报告/导出脱敏;回调 DB 写入失败返回非 200 让企微重试。","受影响流程:WADM1 设计 -> WARCH1 最小历史留存 -> 后续镜像准入;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv --profile wecom --json;人工确认本合同后再编码。","python3|node|manual","DONE","DONE","DONE","issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv","[]","已按用户批准进入 WARCH1 编码并完成本合同。回滚/清理:合同本身无副作用;实现回滚以 WARCH1-7 记录为准。"
"WARCH1-1","Prisma 留存表与迁移","新增最小审批留存 Prisma 模型和 migration:WecomApprovalArchive、WecomApprovalParticipant、WecomApprovalAction、WecomApprovalFieldSummary、WecomApprovalAttachmentSummary、WecomApprovalCallbackEvent、WecomApprovalCompensationRun、WecomApprovalCompensationItem、WecomApprovalReconcileFinding。","1) 主表以 corp_id_hash + sp_no 唯一,保存完整 sp_no、template_id、企微 userid、申请/完成/状态时间、last_source、last_detail_digest 和风险标记;2) 员工外键 onDelete SetNull,不因员工归档删除历史;3) 回调事件 idempotency_key 唯一,补偿 item run_id + sp_no 唯一,动作 archive_id + idempotency_key 唯一;4) 迁移只新增表和索引,不改旧表语义;5) 不保存 Secret、token、EncodingAESKey、原始 XML、密文或无治理原始 payload。","受影响流程:企微审批详情/回调 -> OA DB 留存 -> 断开企微 API 后历史可查;验证步骤:cd server && npm run prisma:format;cd server && npm run prisma:validate;cd server && npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script;检查 migration.sql 只新增 wecom_approval_* 表和索引。","prisma|node|manual","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/*_wecom_approval_archive_minimum_v1/migration.sql","[""WARCH1-0""]","回滚路径:迁移上线前可删除新增 migration;上线后回滚代码时不删除已入库审计证据,必要时只停用写入入口。DB 内可保存完整外部键,日志、报告、Issue 和默认导出只输出脱敏前缀。"
"WARCH1-2","审批详情与回调规范化服务","新增企微审批留存规范化服务,内部保留完整外部键用于入库,外部报告只输出脱敏前缀;从 getapprovaldetail 和回调解密结果中抽取请假/补卡必要字段、参与人、动作、字段摘要和 digest。","1) getapprovaldetail 规范化结果包含完整 sp_no、template_id、申请人/审批人 userid、规范化状态、状态时间、字段摘要、动作历史和 last_detail_digest;2) 回调规范化结果从只含前缀升级为内部完整键 + 外部脱敏摘要;3) 字段摘要只保存业务必要字段:申请类型、时间范围或补卡时间、时长、原因摘要、状态和审批历史;4) 原因摘要入库但限长、标敏感级别,报告/导出不输出完整文本;5) 不保存原始 payload、附件内容或评论全文。","受影响流程:企微详情/回调 -> 规范化 -> 入库 upsert;验证步骤:cd server && node --check src/services/integrations/wecomApprovalArchive.js;cd server && npx vitest run tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose。","node|vitest","DONE","DONE","DONE","server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/services/wecomApprovalArchive.test.js|server/src/services/integrations/wecomCallbackCrypto.js|server/src/services/integrations/wecomApproval.js","[""WARCH1-1""]","默认选择:原因摘要完整入库到受权限保护业务表,默认 UI/export/report 脱敏或限权;如用户要求更严格,可改为前若干字 + digest。回滚/清理:规范化服务无外部副作用。"
"WARCH1-3","回调事件入库与重试语义","调整 WCBK1 回调 POST 路径:验签、解密、规范化后写入 WecomApprovalCallbackEvent,并尝试合并到 WecomApprovalArchive;DB 写入失败返回非 200 触发企微重试,不再把失败伪造成成功。","1) POST /api/wecom/approval/callback 保持验签/解密;2) 回调事件按 idempotency_key upsert,重复投递不重复写动作历史;3) 找不到 archive 时也按完整 sp_no/template_id 先保存事件,补偿详情后再关联;4) DB 写入失败返回 5xx 或明确错误,企微可重试;5) 日志只输出 sp_no/template/userid/idempotency 脱敏前缀,不输出原始 XML、密文、Token、EncodingAESKey、完整 sp_no、完整 userid、完整 template_id 或原始 payload。","受影响流程:企微真实审批状态回调 -> OA 持久证据 -> 后续补偿比对;验证步骤:cd server && node --check src/routes/wecomApprovalCallback.js;cd server && npx vitest run tests/unit/routes/wecomApprovalCallback.test.js tests/unit/services/wecomApprovalArchive.test.js tests/unit/services/wecomCallbackCrypto.test.js --reporter=verbose;synthetic 回调重复投递验证不重复动作。","node|vitest","DONE","DONE","DONE","server/src/routes/wecomApprovalCallback.js|server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/routes/wecomApprovalCallback.test.js","[""WARCH1-2""]","默认选择:DB 写入失败不返回 success,接受企微重试;这是避免证据丢失的保护。回滚路径:可临时关闭归档写入并恢复 JSONL evidence fallback,但不得删除已入库证据。"
"WARCH1-4","补偿拉取入库与对账 finding","扩展 WACP3 补偿 runner:按模板过滤只读调用 getapprovalinfo/getapprovaldetail 后,将 run/item/detail digest 和规范化审批历史写入 OA DB,并生成漏回调、重复回调、状态不一致等 WecomApprovalReconcileFinding。","1) 企微 API 仍只读,固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback;2) 默认 dry-run 只生成规范化计划,写 OA DB 必须显式 --persist-to-db 且 WECOM_ARCHIVE_WRITE=1;3) 同一 run_id + sp_no 只能有一条 item;4) getapprovaldetail 是最终校准来源,旧 pending/unknown 不覆盖较新终态;5) 跨来源终态冲突写 status_mismatch finding,不静默改成功。","受影响流程:补偿拉取 -> 详情校准 -> OA 历史留存/回调比对;验证步骤:cd server && node --check src/scripts/check-wecom-approval-compensation-poc.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-compensation-poc.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;dry-run 验证不写库,双开关验证才写库。","node|vitest","DONE","DONE","DONE","server/src/scripts/check-wecom-approval-compensation-poc.js|server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/scripts/check-wecom-approval-compensation-poc.test.js","[""WARCH1-2"",""WARCH1-3""]","写入保护:OA DB 持久化需要 --persist-to-db + WECOM_ARCHIVE_WRITE=1;不写企微数据。回滚/清理:可停止 runner 写入,已保存 run/item/finding 保留审计。"
"WARCH1-5","QA-only 离线历史读取 runner","新增 QA-only runner check-wecom-approval-archive,验证不调用企微 API 时可从 OA DB 读取已保存请假/补卡历史摘要,并输出脱敏报告。","1) 新增 npm run check:wecom-approval-archive;2) 支持 --offline --format=json;3) 输出 PASS_ARCHIVE_HISTORY_READABLE、PASS_WITH_FINDINGS、PARTIAL_ARCHIVE_NEEDS_DATA 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;4) 报告只输出计数、状态摘要、脱敏 sp_no/template/userid 前缀、字段类型和 finding,不输出手机号、邮箱、token、secret、完整 userid、完整 template_id、完整 sp_no、原因全文、附件、评论或原始 payload;5) 断开企微 API 仍能读取 OA DB 历史摘要。","受影响流程:企微不可用 -> OA 自建历史查看能力验证;验证步骤:cd server && node --check src/scripts/check-wecom-approval-archive.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-archive.test.js --reporter=verbose;执行 npm run check:wecom-approval-archive -- --offline --format=json。","node|vitest","DONE","DONE","DONE","server/src/scripts/check-wecom-approval-archive.js|server/package.json|server/tests/unit/scripts/check-wecom-approval-archive.test.js","[""WARCH1-4""]","该 runner 不调用企微 API、不写企微数据;默认只读 OA DB。回滚/清理:删除脚本和 package script 即可,DB 留存证据不删除。"
"WARCH1-6","权限、脱敏与同意单击风险测试","补齐权限与隐私边界测试,确保完整外部键只在 DB 内受控保存,默认 API、runner、日志和导出不泄露;把 WREAD1 的同意单击风险落成审计事实。","1) 单测覆盖日志/报告不泄露手机号、邮箱、token、Secret、EncodingAESKey、完整 userid、完整 sp_no、完整 template_id、原始 XML、密文、原始 payload;2) DB 可保存完整外部键,默认输出仅脱敏前缀;3) 普通员工、审批人、HR/管理员的未来权限边界写入事实源;4) 同意单击动作记录来源=企微、操作者、时间、状态变化、回调/补偿一致性;5) 原因摘要默认不进入脱敏报告。","受影响流程:历史留存 -> 权限/审计 -> 后续查询和导出;验证步骤:cd server && npx vitest run tests/unit/services/wecomApprovalArchive.test.js tests/unit/scripts/check-wecom-approval-archive.test.js --reporter=verbose;rg -n '完整 sp_no|原因摘要|同意单击|原始 payload|WARCH1' docs .agents/skills/hyb-oa-delivery/SKILL.md。","vitest|rg","DONE","DONE","DONE","server/tests/unit/services/wecomApprovalArchive.test.js|server/tests/unit/scripts/check-wecom-approval-archive.test.js|docs/需求-统一审批中心规约.md|docs/管理员手册.md","[""WARCH1-5""]","本阶段不新增正式管理页;权限先以服务/runner/事实源固定,为后续 UI/API 做准入。回滚/清理:文档可按 git 回滚,测试无数据副作用。"
"WARCH1-7","事实源回写、部署验证与提交","完成 WARCH1 实现后的文档回写、本地门禁、公网只读/受控写入验证和中文提交;若涉及远端写入,仅写 OA 自建留存表,不写企微数据。","1) Issue 状态按实测结果回填;2) 路线图、API 能力目录、统一审批规约、企业微信基础设施规约、请假考勤规约、管理员手册、试运行报告、文档索引、事实源矩阵和 skill 同步 WARCH1 结果;3) 本地 lint/syntax/Prisma/vitest/process gate/docs gate 通过;4) SYNC_ENV=0 受控部署,远端 /ready 正常;5) 远端先 dry-run,再在批准的双开关下持久化现有 OA-POC 请假/补卡历史,最后 offline runner 证明可读;6) 中文提交完成。","受影响流程:WARCH1 实现 -> 公网验证 -> 后续镜像准入;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv --profile wecom --json;cd server && npm run prisma:validate;cd server && npx vitest run tests/unit/services/wecomApprovalArchive.test.js tests/unit/scripts/check-wecom-approval-archive.test.js tests/unit/scripts/check-wecom-approval-compensation-poc.test.js tests/unit/routes/wecomApprovalCallback.test.js --reporter=verbose;node scripts/qa/audit-docs.mjs --check-registry;python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check;SYNC_ENV=0 bash deploy/trialrun/deploy.sh;远端执行 npm run check:wecom-approval-compensation-poc -- --live --format=json 和 npm run check:wecom-approval-archive -- --offline --format=json。","python3|node|prisma|vitest|bash|git","DONE","DONE","DONE","docs/企微优先开发路线图.md|docs/企微API能力目录.md|docs/需求-统一审批中心规约.md|docs/需求-企业微信基础设施规约.md|docs/需求-请假考勤修正规约.md|docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md","[""WARCH1-6""]","实际结果:本地 CSV、process gate、Prisma、node --check、focused vitest、文档审计、skill 校验、git diff --check 和 scripts/check-minimal.sh 已通过;已以 SYNC_ENV=0 部署并应用 migration。远端补偿持久化报告 /app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json 为 PASS_WITH_FINDINGS,archivePersistence.status=OK,storedCount=2;远端离线读取报告 /app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json 为 PASS_WITH_FINDINGS,可读 2 条已通过请假/补卡历史。finding 为 WARCH1 前旧回调未持久化到 DB 的历史 gap,已审计留存;不写企微数据,不创建模板或审批单。提交信息:WARCH1 实现企微审批最小历史留存。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 WARCH1-0 最小历史留存合同与边界冻结 建立 WARCH1 最小自建历史留存实现合同;本阶段只覆盖请假 + 补卡企微审批历史、回调证据、补偿拉取证据和 QA-only 离线读取证明,不进入完整镜像、正式管理页、外出/加班、应急接管、恢复回写或 RuleOps。 1) 明确 WARCH1 是 WADM1 后的最小实现切片;2) 只写 OA 自建留存表,不写企微数据、不创建企微模板、不提交企微审批单;3) 企微读取固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback;4) 缺 Secret、权限、可信 IP、可见范围、模板或回调配置时输出 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;5) 默认采用保守策略:原因摘要允许入 OA DB 受权限保护,报告/导出脱敏;回调 DB 写入失败返回非 200 让企微重试。 受影响流程:WADM1 设计 -> WARCH1 最小历史留存 -> 后续镜像准入;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv --profile wecom --json;人工确认本合同后再编码。 python3|node|manual TODO DONE TODO DONE TODO DONE issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv [] 需要用户批准后才进入编码。回滚/清理:本行仅合同,无 schema、无代码、无部署、无 OA/企微数据写入。 已按用户批准进入 WARCH1 编码并完成本合同。回滚/清理:合同本身无副作用;实现回滚以 WARCH1-7 记录为准。
3 WARCH1-1 Prisma 留存表与迁移 新增最小审批留存 Prisma 模型和 migration:WecomApprovalArchive、WecomApprovalParticipant、WecomApprovalAction、WecomApprovalFieldSummary、WecomApprovalAttachmentSummary、WecomApprovalCallbackEvent、WecomApprovalCompensationRun、WecomApprovalCompensationItem、WecomApprovalReconcileFinding。 1) 主表以 corp_id_hash + sp_no 唯一,保存完整 sp_no、template_id、企微 userid、申请/完成/状态时间、last_source、last_detail_digest 和风险标记;2) 员工外键 onDelete SetNull,不因员工归档删除历史;3) 回调事件 idempotency_key 唯一,补偿 item run_id + sp_no 唯一,动作 archive_id + idempotency_key 唯一;4) 迁移只新增表和索引,不改旧表语义;5) 不保存 Secret、token、EncodingAESKey、原始 XML、密文或无治理原始 payload。 受影响流程:企微审批详情/回调 -> OA DB 留存 -> 断开企微 API 后历史可查;验证步骤:cd server && npm run prisma:format;cd server && npm run prisma:validate;cd server && npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script;检查 migration.sql 只新增 wecom_approval_* 表和索引。 prisma|node|manual TODO DONE TODO DONE TODO DONE server/prisma/schema.prisma|server/prisma/migrations/*_wecom_approval_archive_minimum_v1/migration.sql ["WARCH1-0"] 回滚路径:迁移上线前可删除新增 migration;上线后回滚代码时不删除已入库审计证据,必要时只停用写入入口。DB 内可保存完整外部键,日志、报告、Issue 和默认导出只输出脱敏前缀。
4 WARCH1-2 审批详情与回调规范化服务 新增企微审批留存规范化服务,内部保留完整外部键用于入库,外部报告只输出脱敏前缀;从 getapprovaldetail 和回调解密结果中抽取请假/补卡必要字段、参与人、动作、字段摘要和 digest。 1) getapprovaldetail 规范化结果包含完整 sp_no、template_id、申请人/审批人 userid、规范化状态、状态时间、字段摘要、动作历史和 last_detail_digest;2) 回调规范化结果从只含前缀升级为内部完整键 + 外部脱敏摘要;3) 字段摘要只保存业务必要字段:申请类型、时间范围或补卡时间、时长、原因摘要、状态和审批历史;4) 原因摘要入库但限长、标敏感级别,报告/导出不输出完整文本;5) 不保存原始 payload、附件内容或评论全文。 受影响流程:企微详情/回调 -> 规范化 -> 入库 upsert;验证步骤:cd server && node --check src/services/integrations/wecomApprovalArchive.js;cd server && npx vitest run tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose。 node|vitest TODO DONE TODO DONE TODO DONE server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/services/wecomApprovalArchive.test.js|server/src/services/integrations/wecomCallbackCrypto.js|server/src/services/integrations/wecomApproval.js ["WARCH1-1"] 默认选择:原因摘要完整入库到受权限保护业务表,默认 UI/export/report 脱敏或限权;如用户要求更严格,可改为前若干字 + digest。回滚/清理:规范化服务无外部副作用。
5 WARCH1-3 回调事件入库与重试语义 调整 WCBK1 回调 POST 路径:验签、解密、规范化后写入 WecomApprovalCallbackEvent,并尝试合并到 WecomApprovalArchive;DB 写入失败返回非 200 触发企微重试,不再把失败伪造成成功。 1) POST /api/wecom/approval/callback 保持验签/解密;2) 回调事件按 idempotency_key upsert,重复投递不重复写动作历史;3) 找不到 archive 时也按完整 sp_no/template_id 先保存事件,补偿详情后再关联;4) DB 写入失败返回 5xx 或明确错误,企微可重试;5) 日志只输出 sp_no/template/userid/idempotency 脱敏前缀,不输出原始 XML、密文、Token、EncodingAESKey、完整 sp_no、完整 userid、完整 template_id 或原始 payload。 受影响流程:企微真实审批状态回调 -> OA 持久证据 -> 后续补偿比对;验证步骤:cd server && node --check src/routes/wecomApprovalCallback.js;cd server && npx vitest run tests/unit/routes/wecomApprovalCallback.test.js tests/unit/services/wecomApprovalArchive.test.js tests/unit/services/wecomCallbackCrypto.test.js --reporter=verbose;synthetic 回调重复投递验证不重复动作。 node|vitest TODO DONE TODO DONE TODO DONE server/src/routes/wecomApprovalCallback.js|server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/routes/wecomApprovalCallback.test.js ["WARCH1-2"] 默认选择:DB 写入失败不返回 success,接受企微重试;这是避免证据丢失的保护。回滚路径:可临时关闭归档写入并恢复 JSONL evidence fallback,但不得删除已入库证据。
6 WARCH1-4 补偿拉取入库与对账 finding 扩展 WACP3 补偿 runner:按模板过滤只读调用 getapprovalinfo/getapprovaldetail 后,将 run/item/detail digest 和规范化审批历史写入 OA DB,并生成漏回调、重复回调、状态不一致等 WecomApprovalReconcileFinding。 1) 企微 API 仍只读,固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback;2) 默认 dry-run 只生成规范化计划,写 OA DB 必须显式 --persist-to-db 且 WECOM_ARCHIVE_WRITE=1;3) 同一 run_id + sp_no 只能有一条 item;4) getapprovaldetail 是最终校准来源,旧 pending/unknown 不覆盖较新终态;5) 跨来源终态冲突写 status_mismatch finding,不静默改成功。 受影响流程:补偿拉取 -> 详情校准 -> OA 历史留存/回调比对;验证步骤:cd server && node --check src/scripts/check-wecom-approval-compensation-poc.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-compensation-poc.test.js tests/unit/services/wecomApprovalArchive.test.js --reporter=verbose;dry-run 验证不写库,双开关验证才写库。 node|vitest TODO DONE TODO DONE TODO DONE server/src/scripts/check-wecom-approval-compensation-poc.js|server/src/services/integrations/wecomApprovalArchive.js|server/tests/unit/scripts/check-wecom-approval-compensation-poc.test.js ["WARCH1-2","WARCH1-3"] 写入保护:OA DB 持久化需要 --persist-to-db + WECOM_ARCHIVE_WRITE=1;不写企微数据。回滚/清理:可停止 runner 写入,已保存 run/item/finding 保留审计。
7 WARCH1-5 QA-only 离线历史读取 runner 新增 QA-only runner check-wecom-approval-archive,验证不调用企微 API 时可从 OA DB 读取已保存请假/补卡历史摘要,并输出脱敏报告。 1) 新增 npm run check:wecom-approval-archive;2) 支持 --offline --format=json;3) 输出 PASS_ARCHIVE_HISTORY_READABLE、PASS_WITH_FINDINGS、PARTIAL_ARCHIVE_NEEDS_DATA 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;4) 报告只输出计数、状态摘要、脱敏 sp_no/template/userid 前缀、字段类型和 finding,不输出手机号、邮箱、token、secret、完整 userid、完整 template_id、完整 sp_no、原因全文、附件、评论或原始 payload;5) 断开企微 API 仍能读取 OA DB 历史摘要。 受影响流程:企微不可用 -> OA 自建历史查看能力验证;验证步骤:cd server && node --check src/scripts/check-wecom-approval-archive.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-archive.test.js --reporter=verbose;执行 npm run check:wecom-approval-archive -- --offline --format=json。 node|vitest TODO DONE TODO DONE TODO DONE server/src/scripts/check-wecom-approval-archive.js|server/package.json|server/tests/unit/scripts/check-wecom-approval-archive.test.js ["WARCH1-4"] 该 runner 不调用企微 API、不写企微数据;默认只读 OA DB。回滚/清理:删除脚本和 package script 即可,DB 留存证据不删除。
8 WARCH1-6 权限、脱敏与同意单击风险测试 补齐权限与隐私边界测试,确保完整外部键只在 DB 内受控保存,默认 API、runner、日志和导出不泄露;把 WREAD1 的同意单击风险落成审计事实。 1) 单测覆盖日志/报告不泄露手机号、邮箱、token、Secret、EncodingAESKey、完整 userid、完整 sp_no、完整 template_id、原始 XML、密文、原始 payload;2) DB 可保存完整外部键,默认输出仅脱敏前缀;3) 普通员工、审批人、HR/管理员的未来权限边界写入事实源;4) 同意单击动作记录来源=企微、操作者、时间、状态变化、回调/补偿一致性;5) 原因摘要默认不进入脱敏报告。 受影响流程:历史留存 -> 权限/审计 -> 后续查询和导出;验证步骤:cd server && npx vitest run tests/unit/services/wecomApprovalArchive.test.js tests/unit/scripts/check-wecom-approval-archive.test.js --reporter=verbose;rg -n '完整 sp_no|原因摘要|同意单击|原始 payload|WARCH1' docs .agents/skills/hyb-oa-delivery/SKILL.md。 vitest|rg TODO DONE TODO DONE TODO DONE server/tests/unit/services/wecomApprovalArchive.test.js|server/tests/unit/scripts/check-wecom-approval-archive.test.js|docs/需求-统一审批中心规约.md|docs/管理员手册.md ["WARCH1-5"] 本阶段不新增正式管理页;权限先以服务/runner/事实源固定,为后续 UI/API 做准入。回滚/清理:文档可按 git 回滚,测试无数据副作用。
9 WARCH1-7 事实源回写、部署验证与提交 完成 WARCH1 实现后的文档回写、本地门禁、公网只读/受控写入验证和中文提交;若涉及远端写入,仅写 OA 自建留存表,不写企微数据。 1) Issue 状态按实测结果回填;2) 路线图、API 能力目录、统一审批规约、企业微信基础设施规约、请假考勤规约、管理员手册、试运行报告、文档索引、事实源矩阵和 skill 同步 WARCH1 结果;3) 本地 lint/syntax/Prisma/vitest/process gate/docs gate 通过;4) SYNC_ENV=0 受控部署,远端 /ready 正常;5) 远端先 dry-run,再在批准的双开关下持久化现有 OA-POC 请假/补卡历史,最后 offline runner 证明可读;6) 中文提交完成。 受影响流程:WARCH1 实现 -> 公网验证 -> 后续镜像准入;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv --profile wecom --json;cd server && npm run prisma:validate;cd server && npx vitest run tests/unit/services/wecomApprovalArchive.test.js tests/unit/scripts/check-wecom-approval-archive.test.js tests/unit/scripts/check-wecom-approval-compensation-poc.test.js tests/unit/routes/wecomApprovalCallback.test.js --reporter=verbose;node scripts/qa/audit-docs.mjs --check-registry;python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check;SYNC_ENV=0 bash deploy/trialrun/deploy.sh;远端执行 npm run check:wecom-approval-compensation-poc -- --live --format=json 和 npm run check:wecom-approval-archive -- --offline --format=json。 python3|node|prisma|vitest|bash|git TODO DONE TODO DONE TODO DONE docs/企微优先开发路线图.md|docs/企微API能力目录.md|docs/需求-统一审批中心规约.md|docs/需求-企业微信基础设施规约.md|docs/需求-请假考勤修正规约.md|docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md ["WARCH1-6"] 部署写入边界:SYNC_ENV=0,不同步本地 env;不写企微数据;如公网缺 Secret、权限、可信 IP、可见范围、模板或回调配置,记录 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG,不伪造成 PASS。提交信息建议:WARCH1 实现企微审批最小历史留存。 实际结果:本地 CSV、process gate、Prisma、node --check、focused vitest、文档审计、skill 校验、git diff --check 和 scripts/check-minimal.sh 已通过;已以 SYNC_ENV=0 部署并应用 migration。远端补偿持久化报告 /app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json 为 PASS_WITH_FINDINGS,archivePersistence.status=OK,storedCount=2;远端离线读取报告 /app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json 为 PASS_WITH_FINDINGS,可读 2 条已通过请假/补卡历史。finding 为 WARCH1 前旧回调未持久化到 DB 的历史 gap,已审计留存;不写企微数据,不创建模板或审批单。提交信息:WARCH1 实现企微审批最小历史留存。
+1
查看文件
@@ -32,6 +32,7 @@
"check:wecom-approval-fixtures": "node src/scripts/check-wecom-approval-fixtures.js",
"check:wecom-approval-callback-poc": "node src/scripts/check-wecom-approval-callback-poc.js",
"check:wecom-approval-compensation-poc": "node src/scripts/check-wecom-approval-compensation-poc.js",
"check:wecom-approval-archive": "node src/scripts/check-wecom-approval-archive.js",
"check:wecom-approval-readability-gate": "node src/scripts/check-wecom-approval-readability-gate.js",
"check:wecom-approval-poc": "node src/scripts/check-wecom-approval-poc.js",
"check:wecom-runtime-ready": "node src/scripts/check-wecom-runtime-ready.js",
@@ -0,0 +1,257 @@
CREATE TABLE "wecom_approval_archives" (
"id" UUID NOT NULL,
"corp_id_hash" VARCHAR(64) NOT NULL,
"sp_no" VARCHAR(160) NOT NULL,
"template_id" VARCHAR(160),
"template_type" VARCHAR(40) NOT NULL DEFAULT 'unknown',
"sp_name" VARCHAR(160),
"normalized_status" VARCHAR(32) NOT NULL DEFAULT 'unknown',
"wecom_status" VARCHAR(40),
"applicant_employee_id" UUID,
"applicant_external_user_id" VARCHAR(128),
"submitted_at" TIMESTAMP(3),
"applied_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"status_observed_at" TIMESTAMP(3),
"last_callback_at" TIMESTAMP(3),
"last_compensation_at" TIMESTAMP(3),
"last_source" VARCHAR(40),
"last_detail_digest" VARCHAR(64),
"readability_status" VARCHAR(40),
"single_tap_approve_risk" BOOLEAN NOT NULL DEFAULT false,
"template_adjustment_required" BOOLEAN NOT NULL DEFAULT false,
"external_config_status" VARCHAR(40),
"source_summary" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_approval_archives_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "wecom_approval_participants" (
"id" UUID NOT NULL,
"archive_id" UUID NOT NULL,
"role" VARCHAR(32) NOT NULL,
"node_index" INTEGER NOT NULL DEFAULT 0,
"external_user_id" VARCHAR(128) NOT NULL,
"employee_id" UUID,
"is_current" BOOLEAN NOT NULL DEFAULT false,
"mapping_status" VARCHAR(32) NOT NULL DEFAULT 'unknown',
"display_summary" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_approval_participants_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "wecom_approval_actions" (
"id" UUID NOT NULL,
"archive_id" UUID NOT NULL,
"action_type" VARCHAR(40) NOT NULL,
"operator_external_user_id" VARCHAR(128),
"operator_employee_id" UUID,
"action_time" TIMESTAMP(3),
"node_index" INTEGER NOT NULL DEFAULT 0,
"comment_summary" TEXT,
"source" VARCHAR(40) NOT NULL,
"idempotency_key" VARCHAR(96) NOT NULL,
"evidence_digest" VARCHAR(64),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_approval_actions_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "wecom_approval_field_summaries" (
"id" UUID NOT NULL,
"archive_id" UUID NOT NULL,
"field_key" VARCHAR(120) NOT NULL,
"field_index" INTEGER NOT NULL DEFAULT 0,
"field_name" VARCHAR(160),
"field_type" VARCHAR(80),
"normalized_value" TEXT,
"value_preview" TEXT,
"sensitive_level" VARCHAR(32) NOT NULL DEFAULT 'normal',
"display_allowed" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_approval_field_summaries_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "wecom_approval_attachment_summaries" (
"id" UUID NOT NULL,
"archive_id" UUID NOT NULL,
"attachment_index" INTEGER NOT NULL DEFAULT 0,
"attachment_type" VARCHAR(80),
"external_ref_summary" TEXT,
"downloaded" BOOLEAN NOT NULL DEFAULT false,
"sensitive_level" VARCHAR(32) NOT NULL DEFAULT 'restricted',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_approval_attachment_summaries_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "wecom_approval_callback_events" (
"id" UUID NOT NULL,
"archive_id" UUID,
"corp_id_hash" VARCHAR(64) NOT NULL,
"sp_no" VARCHAR(160) NOT NULL,
"template_id" VARCHAR(160),
"event_type" VARCHAR(80) NOT NULL,
"callback_kind" VARCHAR(80) NOT NULL,
"approval_type" VARCHAR(40) NOT NULL DEFAULT 'unknown',
"normalized_status" VARCHAR(32) NOT NULL DEFAULT 'unknown',
"wecom_status" VARCHAR(40),
"received_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"event_time" TIMESTAMP(3),
"apply_time" TIMESTAMP(3),
"processing_status" VARCHAR(40) NOT NULL DEFAULT 'accepted',
"processing_error_code" VARCHAR(80),
"processing_error_message" TEXT,
"retry_count" INTEGER NOT NULL DEFAULT 0,
"idempotency_key" VARCHAR(96) NOT NULL,
"event_digest" VARCHAR(64) NOT NULL,
"source_summary" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_approval_callback_events_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "wecom_approval_compensation_runs" (
"id" UUID NOT NULL,
"run_key" VARCHAR(64),
"mode" VARCHAR(32) NOT NULL DEFAULT 'live_read',
"status" VARCHAR(32) NOT NULL DEFAULT 'running',
"start_time" TIMESTAMP(3) NOT NULL,
"end_time" TIMESTAMP(3) NOT NULL,
"template_filter" JSONB,
"cursor_summary" TEXT,
"page_size" INTEGER NOT NULL DEFAULT 100,
"page_count" INTEGER NOT NULL DEFAULT 0,
"item_count" INTEGER NOT NULL DEFAULT 0,
"detail_count" INTEGER NOT NULL DEFAULT 0,
"finding_count" INTEGER NOT NULL DEFAULT 0,
"error_code" VARCHAR(80),
"error_message" TEXT,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_approval_compensation_runs_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "wecom_approval_compensation_items" (
"id" UUID NOT NULL,
"run_id" UUID NOT NULL,
"archive_id" UUID,
"sp_no" VARCHAR(160) NOT NULL,
"template_id" VARCHAR(160),
"template_type" VARCHAR(40) NOT NULL DEFAULT 'unknown',
"normalized_status" VARCHAR(32) NOT NULL DEFAULT 'unknown',
"detail_status" VARCHAR(40) NOT NULL DEFAULT 'pending',
"detail_digest" VARCHAR(64),
"error_code" VARCHAR(80),
"error_message" TEXT,
"source_summary" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_approval_compensation_items_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "wecom_approval_reconcile_findings" (
"id" UUID NOT NULL,
"archive_id" UUID,
"run_id" UUID,
"finding_type" VARCHAR(80) NOT NULL,
"status" VARCHAR(32) NOT NULL DEFAULT 'open',
"severity" VARCHAR(32) NOT NULL DEFAULT 'info',
"evidence_digest" VARCHAR(64) NOT NULL,
"source" VARCHAR(40) NOT NULL,
"description" TEXT,
"detected_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"resolved_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_approval_reconcile_findings_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "wecom_approval_archives_corp_sp_no_ux" ON "wecom_approval_archives"("corp_id_hash", "sp_no");
CREATE INDEX "idx_wecom_approval_arch_applicant_status_at" ON "wecom_approval_archives"("applicant_employee_id", "status_observed_at");
CREATE INDEX "idx_wecom_approval_arch_type_status" ON "wecom_approval_archives"("template_type", "normalized_status");
CREATE INDEX "idx_wecom_approval_arch_last_source" ON "wecom_approval_archives"("last_source");
CREATE UNIQUE INDEX "wecom_approval_participants_node_user_ux" ON "wecom_approval_participants"("archive_id", "role", "node_index", "external_user_id");
CREATE INDEX "idx_wecom_approval_part_employee_role" ON "wecom_approval_participants"("employee_id", "role");
CREATE INDEX "idx_wecom_approval_part_external_user" ON "wecom_approval_participants"("external_user_id");
CREATE UNIQUE INDEX "wecom_approval_actions_archive_key_ux" ON "wecom_approval_actions"("archive_id", "idempotency_key");
CREATE INDEX "idx_wecom_approval_actions_operator_time" ON "wecom_approval_actions"("operator_employee_id", "action_time");
CREATE INDEX "idx_wecom_approval_actions_type_time" ON "wecom_approval_actions"("action_type", "action_time");
CREATE UNIQUE INDEX "wecom_approval_fields_archive_key_ux" ON "wecom_approval_field_summaries"("archive_id", "field_key", "field_index");
CREATE INDEX "idx_wecom_approval_fields_key" ON "wecom_approval_field_summaries"("field_key");
CREATE INDEX "idx_wecom_approval_attach_archive_idx" ON "wecom_approval_attachment_summaries"("archive_id", "attachment_index");
CREATE UNIQUE INDEX "wecom_approval_callback_events_idempotency_key_key" ON "wecom_approval_callback_events"("idempotency_key");
CREATE INDEX "idx_wecom_approval_callbacks_sp_no" ON "wecom_approval_callback_events"("sp_no");
CREATE INDEX "idx_wecom_approval_callbacks_archive_at" ON "wecom_approval_callback_events"("archive_id", "received_at");
CREATE INDEX "idx_wecom_approval_callbacks_status_at" ON "wecom_approval_callback_events"("processing_status", "received_at");
CREATE INDEX "idx_wecom_approval_comp_runs_key" ON "wecom_approval_compensation_runs"("run_key");
CREATE INDEX "idx_wecom_approval_comp_runs_status" ON "wecom_approval_compensation_runs"("status", "started_at");
CREATE INDEX "idx_wecom_approval_comp_runs_window" ON "wecom_approval_compensation_runs"("start_time", "end_time");
CREATE UNIQUE INDEX "wecom_approval_comp_items_run_sp_no_ux" ON "wecom_approval_compensation_items"("run_id", "sp_no");
CREATE INDEX "idx_wecom_approval_comp_items_archive" ON "wecom_approval_compensation_items"("archive_id");
CREATE INDEX "idx_wecom_approval_comp_items_status" ON "wecom_approval_compensation_items"("detail_status");
CREATE UNIQUE INDEX "wecom_approval_findings_archive_digest_ux" ON "wecom_approval_reconcile_findings"("archive_id", "finding_type", "evidence_digest");
CREATE UNIQUE INDEX "wecom_approval_findings_run_digest_ux" ON "wecom_approval_reconcile_findings"("run_id", "finding_type", "evidence_digest");
CREATE INDEX "idx_wecom_approval_findings_status_at" ON "wecom_approval_reconcile_findings"("status", "detected_at");
CREATE INDEX "idx_wecom_approval_findings_run" ON "wecom_approval_reconcile_findings"("run_id");
ALTER TABLE "wecom_approval_archives"
ADD CONSTRAINT "wecom_approval_archives_applicant_employee_id_fkey"
FOREIGN KEY ("applicant_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "wecom_approval_participants"
ADD CONSTRAINT "wecom_approval_participants_archive_id_fkey"
FOREIGN KEY ("archive_id") REFERENCES "wecom_approval_archives"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "wecom_approval_participants"
ADD CONSTRAINT "wecom_approval_participants_employee_id_fkey"
FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "wecom_approval_actions"
ADD CONSTRAINT "wecom_approval_actions_archive_id_fkey"
FOREIGN KEY ("archive_id") REFERENCES "wecom_approval_archives"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "wecom_approval_actions"
ADD CONSTRAINT "wecom_approval_actions_operator_employee_id_fkey"
FOREIGN KEY ("operator_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "wecom_approval_field_summaries"
ADD CONSTRAINT "wecom_approval_field_summaries_archive_id_fkey"
FOREIGN KEY ("archive_id") REFERENCES "wecom_approval_archives"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "wecom_approval_attachment_summaries"
ADD CONSTRAINT "wecom_approval_attachment_summaries_archive_id_fkey"
FOREIGN KEY ("archive_id") REFERENCES "wecom_approval_archives"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "wecom_approval_callback_events"
ADD CONSTRAINT "wecom_approval_callback_events_archive_id_fkey"
FOREIGN KEY ("archive_id") REFERENCES "wecom_approval_archives"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "wecom_approval_compensation_items"
ADD CONSTRAINT "wecom_approval_compensation_items_run_id_fkey"
FOREIGN KEY ("run_id") REFERENCES "wecom_approval_compensation_runs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "wecom_approval_compensation_items"
ADD CONSTRAINT "wecom_approval_compensation_items_archive_id_fkey"
FOREIGN KEY ("archive_id") REFERENCES "wecom_approval_archives"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "wecom_approval_reconcile_findings"
ADD CONSTRAINT "wecom_approval_reconcile_findings_archive_id_fkey"
FOREIGN KEY ("archive_id") REFERENCES "wecom_approval_archives"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "wecom_approval_reconcile_findings"
ADD CONSTRAINT "wecom_approval_reconcile_findings_run_id_fkey"
FOREIGN KEY ("run_id") REFERENCES "wecom_approval_compensation_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+414 -163
查看文件
@@ -542,43 +542,43 @@ model Department {
parent Department? @relation("DepartmentToParent", fields: [parent_id], references: [id], onDelete: Restrict)
children Department[] @relation("DepartmentToParent")
employees Employee[]
reimbursement_forms ReimbursementForm[]
leave_requests LeaveRequest[]
purchase_requests PurchaseRequest[]
travel_requests TravelRequest[]
employees Employee[]
reimbursement_forms ReimbursementForm[]
leave_requests LeaveRequest[]
purchase_requests PurchaseRequest[]
travel_requests TravelRequest[]
attendance_corrections AttendanceCorrection[]
workflow_instances WorkflowInstance[]
budget_pools BudgetPool[]
travel_orders TravelOrder[]
card_authorizations CardAuthorization[]
card_transactions CardTransaction[]
workflow_instances WorkflowInstance[]
budget_pools BudgetPool[]
travel_orders TravelOrder[]
card_authorizations CardAuthorization[]
card_transactions CardTransaction[]
@@index([parent_id])
@@map("departments")
}
model Employee {
id String @id @default(uuid()) @db.Uuid
employee_no String @unique @default(dbgenerated("generate_employee_number()"))
name String
phone String @unique
email String @unique
department_id String @db.Uuid
legal_entity_id String? @db.Uuid
title String
direct_manager_id String? @db.Uuid
hire_date DateTime @db.Date
status EmployeeStatus @default(active)
employment_status EmploymentLifecycleStatus @default(active)
probation_end_date DateTime? @db.Date
last_working_date DateTime? @db.Date
work_location String?
emergency_contact_name String?
emergency_contact_phone String?
notes String?
avatar_path String?
notification_push_enabled Boolean @default(true)
id String @id @default(uuid()) @db.Uuid
employee_no String @unique @default(dbgenerated("generate_employee_number()"))
name String
phone String @unique
email String @unique
department_id String @db.Uuid
legal_entity_id String? @db.Uuid
title String
direct_manager_id String? @db.Uuid
hire_date DateTime @db.Date
status EmployeeStatus @default(active)
employment_status EmploymentLifecycleStatus @default(active)
probation_end_date DateTime? @db.Date
last_working_date DateTime? @db.Date
work_location String?
emergency_contact_name String?
emergency_contact_phone String?
notes String?
avatar_path String?
notification_push_enabled Boolean @default(true)
bank_account_encrypted String
bank_name String
@@ -611,31 +611,34 @@ model Employee {
audit_logs AuditLog[] @relation("AuditActor")
notifications Notification[]
submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter")
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
reimbursement_attachments_uploaded ReimbursementAttachment[] @relation("ReimbursementAttachmentUploader")
payment_batch_receipts_uploaded PaymentBatchReceiptAttachment[] @relation("PaymentBatchReceiptAttachmentUploader")
leave_requests LeaveRequest[]
leave_ledgers LeaveLedger[]
wecom_checkin_records WecomCheckinRecord[]
wecom_checkin_sync_runs WecomCheckinSyncRun[] @relation("WecomCheckinSyncRunTrigger")
wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunEmployee")
triggered_wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunTrigger")
wecom_hr_master_sync_items WecomHrMasterSyncItem[] @relation("WecomHrMasterSyncItemEmployee")
triggered_wecom_hr_master_sync_runs WecomHrMasterSyncRun[] @relation("WecomHrMasterSyncRunTrigger")
attendance_corrections AttendanceCorrection[]
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant")
travel_requests_applicant TravelRequest[] @relation("TravelRequestApplicant")
travel_requests_submitted TravelRequest[] @relation("TravelRequestSubmitter")
purchase_requests_submitted PurchaseRequest[] @relation("PurchaseRequestSubmitter")
purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader")
purchase_order_archives PurchaseOrderArchive[] @relation("PurchaseOrderArchiveArchivedBy")
assigned_asset_cards AssetCard[] @relation("AssetCardAssignee")
asset_assignments AssetAssignment[]
asset_transfers_from AssetTransfer[] @relation("AssetTransferFromEmployee")
asset_transfers_to AssetTransfer[] @relation("AssetTransferToEmployee")
submitted_reimbursements ReimbursementForm[] @relation("ReimbursementSubmitter")
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
reimbursement_attachments_uploaded ReimbursementAttachment[] @relation("ReimbursementAttachmentUploader")
payment_batch_receipts_uploaded PaymentBatchReceiptAttachment[] @relation("PaymentBatchReceiptAttachmentUploader")
leave_requests LeaveRequest[]
leave_ledgers LeaveLedger[]
wecom_checkin_records WecomCheckinRecord[]
wecom_checkin_sync_runs WecomCheckinSyncRun[] @relation("WecomCheckinSyncRunTrigger")
wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunEmployee")
triggered_wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunTrigger")
wecom_hr_master_sync_items WecomHrMasterSyncItem[] @relation("WecomHrMasterSyncItemEmployee")
triggered_wecom_hr_master_sync_runs WecomHrMasterSyncRun[] @relation("WecomHrMasterSyncRunTrigger")
wecom_approval_archives_applicant WecomApprovalArchive[] @relation("WecomApprovalArchiveApplicant")
wecom_approval_participants WecomApprovalParticipant[] @relation("WecomApprovalParticipantEmployee")
wecom_approval_actions_operator WecomApprovalAction[] @relation("WecomApprovalActionOperator")
attendance_corrections AttendanceCorrection[]
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant")
travel_requests_applicant TravelRequest[] @relation("TravelRequestApplicant")
travel_requests_submitted TravelRequest[] @relation("TravelRequestSubmitter")
purchase_requests_submitted PurchaseRequest[] @relation("PurchaseRequestSubmitter")
purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader")
purchase_order_archives PurchaseOrderArchive[] @relation("PurchaseOrderArchiveArchivedBy")
assigned_asset_cards AssetCard[] @relation("AssetCardAssignee")
asset_assignments AssetAssignment[]
asset_transfers_from AssetTransfer[] @relation("AssetTransferFromEmployee")
asset_transfers_to AssetTransfer[] @relation("AssetTransferToEmployee")
owned_invoices InvoicePool[] @relation("InvoiceOwner")
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
@@ -802,17 +805,17 @@ model Notification {
}
model NotificationDelivery {
id String @id @default(uuid()) @db.Uuid
notification_id String @db.Uuid
channel_id String @db.Uuid
status NotificationDeliveryStatus
attempt_no Int
result_code String? @db.VarChar(64)
error_message String?
response_body Json?
attempted_at DateTime @default(now())
id String @id @default(uuid()) @db.Uuid
notification_id String @db.Uuid
channel_id String @db.Uuid
status NotificationDeliveryStatus
attempt_no Int
result_code String? @db.VarChar(64)
error_message String?
response_body Json?
attempted_at DateTime @default(now())
notification Notification @relation(fields: [notification_id], references: [id], onDelete: Cascade)
notification Notification @relation(fields: [notification_id], references: [id], onDelete: Cascade)
channel NotificationChannel @relation(fields: [channel_id], references: [id], onDelete: Cascade)
@@index([notification_id, attempted_at], map: "idx_notification_deliveries_notification_attempted")
@@ -845,15 +848,15 @@ model ExpenseType {
}
model ExpenseStandard {
id String @id @default(uuid()) @db.Uuid
expense_type_id String @db.Uuid
name String @db.VarChar(100)
max_amount Decimal @db.Decimal(14, 2)
per_unit String @default("item") @db.VarChar(20)
control_mode String @default("warn") @db.VarChar(16)
scope_type String @default("global") @db.VarChar(32)
scope_key String? @db.Uuid
enabled Boolean @default(true)
id String @id @default(uuid()) @db.Uuid
expense_type_id String @db.Uuid
name String @db.VarChar(100)
max_amount Decimal @db.Decimal(14, 2)
per_unit String @default("item") @db.VarChar(20)
control_mode String @default("warn") @db.VarChar(16)
scope_type String @default("global") @db.VarChar(32)
scope_key String? @db.Uuid
enabled Boolean @default(true)
note String?
created_at DateTime @default(now())
@@ -1299,33 +1302,33 @@ model InventoryCheckItem {
}
model ReimbursementForm {
id String @id @default(uuid()) @db.Uuid
form_number String? @unique
submitter_id String @db.Uuid
owner_id String @db.Uuid
department_id String @db.Uuid
reimbursement_date DateTime @db.Date
total_amount Decimal @default(0) @db.Decimal(14, 2)
status ReimbursementStatus @default(draft)
description String?
payment_voucher_no String? @db.VarChar(50)
payment_date DateTime? @db.Date
payment_channel String? @db.VarChar(20)
paid_at DateTime?
paid_by String? @db.Uuid
project_id String? @db.Uuid
linked_travel_request_id String? @db.Uuid
id String @id @default(uuid()) @db.Uuid
form_number String? @unique
submitter_id String @db.Uuid
owner_id String @db.Uuid
department_id String @db.Uuid
reimbursement_date DateTime @db.Date
total_amount Decimal @default(0) @db.Decimal(14, 2)
status ReimbursementStatus @default(draft)
description String?
payment_voucher_no String? @db.VarChar(50)
payment_date DateTime? @db.Date
payment_channel String? @db.VarChar(20)
paid_at DateTime?
paid_by String? @db.Uuid
project_id String? @db.Uuid
linked_travel_request_id String? @db.Uuid
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
submitter Employee @relation("ReimbursementSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict)
owner Employee @relation("ReimbursementOwner", fields: [owner_id], references: [id], onDelete: Restrict)
paid_by_employee Employee? @relation("ReimbursementPaidBy", fields: [paid_by], references: [id], onDelete: SetNull)
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
submitter Employee @relation("ReimbursementSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict)
owner Employee @relation("ReimbursementOwner", fields: [owner_id], references: [id], onDelete: Restrict)
paid_by_employee Employee? @relation("ReimbursementPaidBy", fields: [paid_by], references: [id], onDelete: SetNull)
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
travel_request TravelRequest? @relation(fields: [linked_travel_request_id], references: [id], onDelete: SetNull)
items ReimbursementItem[]
@@ -1762,7 +1765,7 @@ model PaymentBatch {
updated_at DateTime @updatedAt
updated_by String? @db.Uuid
items PaymentBatchItem[]
items PaymentBatchItem[]
receipt_attachments PaymentBatchReceiptAttachment[]
@@index([entity_type, status, created_at], map: "idx_payment_batches_entity_status_created")
@@ -1907,18 +1910,18 @@ model EmployeePresence {
}
model AttendanceRecord {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
work_date DateTime @db.Date
clock_in_at DateTime?
clock_out_at DateTime?
work_minutes Int?
is_late Boolean @default(false)
late_minutes Int @default(0)
is_early_leave Boolean @default(false)
early_minutes Int @default(0)
note String?
source String @default("web") @db.VarChar(16)
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
work_date DateTime @db.Date
clock_in_at DateTime?
clock_out_at DateTime?
work_minutes Int?
is_late Boolean @default(false)
late_minutes Int @default(0)
is_early_leave Boolean @default(false)
early_minutes Int @default(0)
note String?
source String @default("web") @db.VarChar(16)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
@@ -1961,7 +1964,7 @@ model WecomCheckinSyncRun {
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
trigger Employee? @relation("WecomCheckinSyncRunTrigger", fields: [triggered_by], references: [id], onDelete: SetNull)
trigger Employee? @relation("WecomCheckinSyncRunTrigger", fields: [triggered_by], references: [id], onDelete: SetNull)
records WecomCheckinRecord[]
@@index([status, created_at], map: "idx_wecom_checkin_sync_runs_status_created")
@@ -2010,7 +2013,7 @@ model WecomDirectorySyncRun {
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
employee Employee @relation("WecomDirectorySyncRunEmployee", fields: [employee_id], references: [id], onDelete: Cascade)
employee Employee @relation("WecomDirectorySyncRunEmployee", fields: [employee_id], references: [id], onDelete: Cascade)
trigger Employee? @relation("WecomDirectorySyncRunTrigger", fields: [triggered_by], references: [id], onDelete: SetNull)
@@index([employee_id, created_at], map: "idx_wecom_directory_sync_runs_employee_created")
@@ -2115,23 +2118,23 @@ model WecomIdentityBinding {
}
model WecomHrMasterSyncRun {
id String @id @default(uuid()) @db.Uuid
mode String @db.VarChar(16)
status String @default("running") @db.VarChar(24)
token_source Json?
requested_userids Json?
limit Int?
summary Json?
error_code String? @db.VarChar(80)
error_message String?
triggered_by String? @db.Uuid
started_at DateTime @default(now())
finished_at DateTime?
id String @id @default(uuid()) @db.Uuid
mode String @db.VarChar(16)
status String @default("running") @db.VarChar(24)
token_source Json?
requested_userids Json?
limit Int?
summary Json?
error_code String? @db.VarChar(80)
error_message String?
triggered_by String? @db.Uuid
started_at DateTime @default(now())
finished_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
trigger Employee? @relation("WecomHrMasterSyncRunTrigger", fields: [triggered_by], references: [id], onDelete: SetNull)
trigger Employee? @relation("WecomHrMasterSyncRunTrigger", fields: [triggered_by], references: [id], onDelete: SetNull)
items WecomHrMasterSyncItem[]
@@index([status, created_at], map: "idx_wecom_hr_master_sync_runs_status_created")
@@ -2165,6 +2168,254 @@ model WecomHrMasterSyncItem {
@@map("wecom_hr_master_sync_items")
}
model WecomApprovalArchive {
id String @id @default(uuid()) @db.Uuid
corp_id_hash String @db.VarChar(64)
sp_no String @db.VarChar(160)
template_id String? @db.VarChar(160)
template_type String @default("unknown") @db.VarChar(40)
sp_name String? @db.VarChar(160)
normalized_status String @default("unknown") @db.VarChar(32)
wecom_status String? @db.VarChar(40)
applicant_employee_id String? @db.Uuid
applicant_external_user_id String? @db.VarChar(128)
submitted_at DateTime?
applied_at DateTime?
completed_at DateTime?
status_observed_at DateTime?
last_callback_at DateTime?
last_compensation_at DateTime?
last_source String? @db.VarChar(40)
last_detail_digest String? @db.VarChar(64)
readability_status String? @db.VarChar(40)
single_tap_approve_risk Boolean @default(false)
template_adjustment_required Boolean @default(false)
external_config_status String? @db.VarChar(40)
source_summary Json?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
applicant Employee? @relation("WecomApprovalArchiveApplicant", fields: [applicant_employee_id], references: [id], onDelete: SetNull)
participants WecomApprovalParticipant[]
actions WecomApprovalAction[]
field_summaries WecomApprovalFieldSummary[]
attachment_summaries WecomApprovalAttachmentSummary[]
callback_events WecomApprovalCallbackEvent[]
compensation_items WecomApprovalCompensationItem[]
reconcile_findings WecomApprovalReconcileFinding[]
@@unique([corp_id_hash, sp_no], map: "wecom_approval_archives_corp_sp_no_ux")
@@index([applicant_employee_id, status_observed_at], map: "idx_wecom_approval_arch_applicant_status_at")
@@index([template_type, normalized_status], map: "idx_wecom_approval_arch_type_status")
@@index([last_source], map: "idx_wecom_approval_arch_last_source")
@@map("wecom_approval_archives")
}
model WecomApprovalParticipant {
id String @id @default(uuid()) @db.Uuid
archive_id String @db.Uuid
role String @db.VarChar(32)
node_index Int @default(0)
external_user_id String @db.VarChar(128)
employee_id String? @db.Uuid
is_current Boolean @default(false)
mapping_status String @default("unknown") @db.VarChar(32)
display_summary String?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
archive WecomApprovalArchive @relation(fields: [archive_id], references: [id], onDelete: Cascade)
employee Employee? @relation("WecomApprovalParticipantEmployee", fields: [employee_id], references: [id], onDelete: SetNull)
@@unique([archive_id, role, node_index, external_user_id], map: "wecom_approval_participants_node_user_ux")
@@index([employee_id, role], map: "idx_wecom_approval_part_employee_role")
@@index([external_user_id], map: "idx_wecom_approval_part_external_user")
@@map("wecom_approval_participants")
}
model WecomApprovalAction {
id String @id @default(uuid()) @db.Uuid
archive_id String @db.Uuid
action_type String @db.VarChar(40)
operator_external_user_id String? @db.VarChar(128)
operator_employee_id String? @db.Uuid
action_time DateTime?
node_index Int @default(0)
comment_summary String?
source String @db.VarChar(40)
idempotency_key String @db.VarChar(96)
evidence_digest String? @db.VarChar(64)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
archive WecomApprovalArchive @relation(fields: [archive_id], references: [id], onDelete: Cascade)
operator Employee? @relation("WecomApprovalActionOperator", fields: [operator_employee_id], references: [id], onDelete: SetNull)
@@unique([archive_id, idempotency_key], map: "wecom_approval_actions_archive_key_ux")
@@index([operator_employee_id, action_time], map: "idx_wecom_approval_actions_operator_time")
@@index([action_type, action_time], map: "idx_wecom_approval_actions_type_time")
@@map("wecom_approval_actions")
}
model WecomApprovalFieldSummary {
id String @id @default(uuid()) @db.Uuid
archive_id String @db.Uuid
field_key String @db.VarChar(120)
field_index Int @default(0)
field_name String? @db.VarChar(160)
field_type String? @db.VarChar(80)
normalized_value String?
value_preview String?
sensitive_level String @default("normal") @db.VarChar(32)
display_allowed Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
archive WecomApprovalArchive @relation(fields: [archive_id], references: [id], onDelete: Cascade)
@@unique([archive_id, field_key, field_index], map: "wecom_approval_fields_archive_key_ux")
@@index([field_key], map: "idx_wecom_approval_fields_key")
@@map("wecom_approval_field_summaries")
}
model WecomApprovalAttachmentSummary {
id String @id @default(uuid()) @db.Uuid
archive_id String @db.Uuid
attachment_index Int @default(0)
attachment_type String? @db.VarChar(80)
external_ref_summary String?
downloaded Boolean @default(false)
sensitive_level String @default("restricted") @db.VarChar(32)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
archive WecomApprovalArchive @relation(fields: [archive_id], references: [id], onDelete: Cascade)
@@index([archive_id, attachment_index], map: "idx_wecom_approval_attach_archive_idx")
@@map("wecom_approval_attachment_summaries")
}
model WecomApprovalCallbackEvent {
id String @id @default(uuid()) @db.Uuid
archive_id String? @db.Uuid
corp_id_hash String @db.VarChar(64)
sp_no String @db.VarChar(160)
template_id String? @db.VarChar(160)
event_type String @db.VarChar(80)
callback_kind String @db.VarChar(80)
approval_type String @default("unknown") @db.VarChar(40)
normalized_status String @default("unknown") @db.VarChar(32)
wecom_status String? @db.VarChar(40)
received_at DateTime @default(now())
event_time DateTime?
apply_time DateTime?
processing_status String @default("accepted") @db.VarChar(40)
processing_error_code String? @db.VarChar(80)
processing_error_message String?
retry_count Int @default(0)
idempotency_key String @unique @db.VarChar(96)
event_digest String @db.VarChar(64)
source_summary Json?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
archive WecomApprovalArchive? @relation(fields: [archive_id], references: [id], onDelete: SetNull)
@@index([sp_no], map: "idx_wecom_approval_callbacks_sp_no")
@@index([archive_id, received_at], map: "idx_wecom_approval_callbacks_archive_at")
@@index([processing_status, received_at], map: "idx_wecom_approval_callbacks_status_at")
@@map("wecom_approval_callback_events")
}
model WecomApprovalCompensationRun {
id String @id @default(uuid()) @db.Uuid
run_key String? @db.VarChar(64)
mode String @default("live_read") @db.VarChar(32)
status String @default("running") @db.VarChar(32)
start_time DateTime
end_time DateTime
template_filter Json?
cursor_summary String?
page_size Int @default(100)
page_count Int @default(0)
item_count Int @default(0)
detail_count Int @default(0)
finding_count Int @default(0)
error_code String? @db.VarChar(80)
error_message String?
started_at DateTime @default(now())
finished_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
items WecomApprovalCompensationItem[]
findings WecomApprovalReconcileFinding[]
@@index([run_key], map: "idx_wecom_approval_comp_runs_key")
@@index([status, started_at], map: "idx_wecom_approval_comp_runs_status")
@@index([start_time, end_time], map: "idx_wecom_approval_comp_runs_window")
@@map("wecom_approval_compensation_runs")
}
model WecomApprovalCompensationItem {
id String @id @default(uuid()) @db.Uuid
run_id String @db.Uuid
archive_id String? @db.Uuid
sp_no String @db.VarChar(160)
template_id String? @db.VarChar(160)
template_type String @default("unknown") @db.VarChar(40)
normalized_status String @default("unknown") @db.VarChar(32)
detail_status String @default("pending") @db.VarChar(40)
detail_digest String? @db.VarChar(64)
error_code String? @db.VarChar(80)
error_message String?
source_summary Json?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
run WecomApprovalCompensationRun @relation(fields: [run_id], references: [id], onDelete: Cascade)
archive WecomApprovalArchive? @relation(fields: [archive_id], references: [id], onDelete: SetNull)
@@unique([run_id, sp_no], map: "wecom_approval_comp_items_run_sp_no_ux")
@@index([archive_id], map: "idx_wecom_approval_comp_items_archive")
@@index([detail_status], map: "idx_wecom_approval_comp_items_status")
@@map("wecom_approval_compensation_items")
}
model WecomApprovalReconcileFinding {
id String @id @default(uuid()) @db.Uuid
archive_id String? @db.Uuid
run_id String? @db.Uuid
finding_type String @db.VarChar(80)
status String @default("open") @db.VarChar(32)
severity String @default("info") @db.VarChar(32)
evidence_digest String @db.VarChar(64)
source String @db.VarChar(40)
description String?
detected_at DateTime @default(now())
resolved_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
archive WecomApprovalArchive? @relation(fields: [archive_id], references: [id], onDelete: SetNull)
run WecomApprovalCompensationRun? @relation(fields: [run_id], references: [id], onDelete: SetNull)
@@unique([archive_id, finding_type, evidence_digest], map: "wecom_approval_findings_archive_digest_ux")
@@unique([run_id, finding_type, evidence_digest], map: "wecom_approval_findings_run_digest_ux")
@@index([status, detected_at], map: "idx_wecom_approval_findings_status_at")
@@index([run_id], map: "idx_wecom_approval_findings_run")
@@map("wecom_approval_reconcile_findings")
}
model Project {
id String @id @default(uuid()) @db.Uuid
project_no String @unique
@@ -2519,10 +2770,10 @@ model Payslip {
}
model PayslipItem {
id String @id @default(uuid()) @db.Uuid
payslip_id String @db.Uuid
salary_item_id String @db.Uuid
amount Decimal @default(0) @db.Decimal(12, 2)
id String @id @default(uuid()) @db.Uuid
payslip_id String @db.Uuid
salary_item_id String @db.Uuid
amount Decimal @default(0) @db.Decimal(12, 2)
payslip Payslip @relation(fields: [payslip_id], references: [id], onDelete: Cascade)
salary_item SalaryItem @relation(fields: [salary_item_id], references: [id], onDelete: Restrict)
@@ -2555,11 +2806,11 @@ model PerformanceReviewCycle {
}
model PerformanceReview {
id String @id @default(uuid()) @db.Uuid
cycle_id String @db.Uuid
employee_id String @db.Uuid
reviewer_employee_id String @db.Uuid
overall_score Decimal @db.Decimal(3, 1)
id String @id @default(uuid()) @db.Uuid
cycle_id String @db.Uuid
employee_id String @db.Uuid
reviewer_employee_id String @db.Uuid
overall_score Decimal @db.Decimal(3, 1)
comment String?
created_at DateTime @default(now())
@@ -2567,9 +2818,9 @@ model PerformanceReview {
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
cycle PerformanceReviewCycle @relation(fields: [cycle_id], references: [id], onDelete: Cascade)
employee Employee @relation("PerformanceReviewEmployee", fields: [employee_id], references: [id], onDelete: Cascade)
reviewer_employee Employee @relation("PerformanceReviewReviewer", fields: [reviewer_employee_id], references: [id], onDelete: Restrict)
cycle PerformanceReviewCycle @relation(fields: [cycle_id], references: [id], onDelete: Cascade)
employee Employee @relation("PerformanceReviewEmployee", fields: [employee_id], references: [id], onDelete: Cascade)
reviewer_employee Employee @relation("PerformanceReviewReviewer", fields: [reviewer_employee_id], references: [id], onDelete: Restrict)
dimensions PerformanceReviewDimension[]
@@unique([cycle_id, employee_id], map: "performance_reviews_cycle_employee_ux")
@@ -2762,13 +3013,13 @@ model TravelRequest {
budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull)
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
segments TravelRequestSegment[]
travel_orders TravelOrder[]
segments TravelRequestSegment[]
travel_orders TravelOrder[]
linked_reimbursements ReimbursementForm[]
reconciliation_items TravelReconciliationItem[]
card_authorizations CardAuthorization[]
card_transactions CardTransaction[]
workflow_instances WorkflowInstance[]
reconciliation_items TravelReconciliationItem[]
card_authorizations CardAuthorization[]
card_transactions CardTransaction[]
workflow_instances WorkflowInstance[]
@@index([applicant_employee_id, start_date], map: "idx_travel_requests_applicant_start")
@@index([submitter_id], map: "idx_travel_requests_submitter")
@@ -3034,35 +3285,35 @@ model WorkflowDefinition {
}
model WorkflowInstance {
id String @id @default(uuid()) @db.Uuid
workflow_id String @db.Uuid
entity_type String
entity_id String @db.Uuid
reimbursement_id String? @db.Uuid
leave_request_id String? @db.Uuid
purchase_request_id String? @db.Uuid
travel_request_id String? @db.Uuid
attendance_correction_id String? @db.Uuid
submitter_id String @db.Uuid
current_status WorkflowInstanceStatus @default(pending)
current_step_id String
context_data Json
started_at DateTime @default(now())
completed_at DateTime?
id String @id @default(uuid()) @db.Uuid
workflow_id String @db.Uuid
entity_type String
entity_id String @db.Uuid
reimbursement_id String? @db.Uuid
leave_request_id String? @db.Uuid
purchase_request_id String? @db.Uuid
travel_request_id String? @db.Uuid
attendance_correction_id String? @db.Uuid
submitter_id String @db.Uuid
current_status WorkflowInstanceStatus @default(pending)
current_step_id String
context_data Json
started_at DateTime @default(now())
completed_at DateTime?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
workflow_definition WorkflowDefinition @relation(fields: [workflow_id], references: [id], onDelete: Restrict)
reimbursement_form ReimbursementForm? @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade)
leave_request LeaveRequest? @relation(fields: [leave_request_id], references: [id], onDelete: Cascade)
purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: Cascade)
travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: Cascade)
workflow_definition WorkflowDefinition @relation(fields: [workflow_id], references: [id], onDelete: Restrict)
reimbursement_form ReimbursementForm? @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade)
leave_request LeaveRequest? @relation(fields: [leave_request_id], references: [id], onDelete: Cascade)
purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: Cascade)
travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: Cascade)
attendance_correction AttendanceCorrection? @relation(fields: [attendance_correction_id], references: [id], onDelete: Cascade)
submitter Employee @relation("WorkflowInstanceSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict)
department Department? @relation(fields: [context_department_id], references: [id], onDelete: SetNull)
submitter Employee @relation("WorkflowInstanceSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict)
department Department? @relation(fields: [context_department_id], references: [id], onDelete: SetNull)
workflow_tasks WorkflowTask[]
workflow_history WorkflowHistory[]
+17 -1
查看文件
@@ -9,6 +9,7 @@ const {
sanitizeErrorMessage,
validateCallbackConfig,
} = require("../services/integrations/wecomCallbackCrypto");
const { recordApprovalCallbackEvent } = require("../services/integrations/wecomApprovalArchive");
function toSafeString(value, maxLength = 500) {
if (value == null) return "";
@@ -128,7 +129,22 @@ async function wecomApprovalCallbackRoutes(fastify, options = {}) {
});
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
const evidenceStore = options.evidenceStore || {
record: (eventSummary) => recordCallbackEvidence(eventSummary, { evidenceFile: runtimeConfig.evidenceFile }),
record: async (eventSummary) => {
let stored = null;
if (fastify.prisma) {
stored = await recordApprovalCallbackEvent({
prisma: fastify.prisma,
config: {
wecomCorpId: runtimeConfig.receiveId,
},
summary: eventSummary,
});
}
if (runtimeConfig.evidenceFile || !fastify.prisma) {
await recordCallbackEvidence(eventSummary, { evidenceFile: runtimeConfig.evidenceFile });
}
return stored || { recorded: true, duplicate: false };
},
};
const stored = await evidenceStore.record(summary);
fastify.log.info({
@@ -0,0 +1,430 @@
#!/usr/bin/env node
require("dotenv/config");
const fs = require("node:fs");
const path = require("node:path");
const { createPrismaClient } = require("../prismaClient");
const { maskApprovalIdentifier } = require("../services/integrations/wecomApproval");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const DEFAULT_TARGETS = Object.freeze(["leave", "missed_punch"]);
const REPORT_PRIVACY =
"OA DB archive counts/status summaries/masked sp_no/template/userid prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/reason text/attachments/comments/raw payload";
function printHelp() {
process.stdout.write(`Usage: node src/scripts/check-wecom-approval-archive.js [options]
QA-only 企业微信审批自建留存离线读取检查。
该脚本只读取 OA DB,不调用企业微信 API,不写 OA/企微数据。
Options:
--offline 明确以离线模式验证 OA 自建留存可读。
--format=text|json 输出格式,默认 text。
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-archive-<timestamp>。
--template=leave 只检查请假留存。
--template=missed_punch 只检查补卡留存。
--status=<status> 按规范化状态过滤。
--limit=<1..100> 最大读取条数,默认 20。
--help 显示帮助。
隐私边界:
报告只输出计数、状态摘要、字段类型、脱敏 sp_no/template/userid 前缀和 finding 类型。
不输出手机号、邮箱、token、secret、完整 userid、完整 template_id、完整 sp_no、原因全文、附件、评论或企微原始 payload。
`);
}
function timestamp() {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
function toSafeString(value, maxLength = 500) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function safeMessage(value) {
return toSafeString(value, 500)
.replace(/access_token=[^&\s]+/gi, "access_token=***")
.replace(/[A-Za-z0-9+/=_-]{24,}/g, "***")
.slice(0, 240);
}
function parseArgs(argv) {
const options = {
offline: false,
format: "text",
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-archive-${timestamp()}`),
templates: [],
status: "",
limit: 20,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
options.help = true;
} else if (arg === "--offline") {
options.offline = true;
} else if (arg === "--format") {
options.format = argv[index + 1] || options.format;
index += 1;
} else if (arg.startsWith("--format=")) {
options.format = arg.slice("--format=".length);
} else if (arg === "--report-dir") {
options.reportDir = argv[index + 1] || options.reportDir;
index += 1;
} else if (arg.startsWith("--report-dir=")) {
options.reportDir = arg.slice("--report-dir=".length);
} else if (arg === "--template") {
options.templates.push(toSafeString(argv[index + 1], 40));
index += 1;
} else if (arg.startsWith("--template=")) {
options.templates.push(toSafeString(arg.slice("--template=".length), 40));
} else if (arg === "--status") {
options.status = toSafeString(argv[index + 1], 40);
index += 1;
} else if (arg.startsWith("--status=")) {
options.status = toSafeString(arg.slice("--status=".length), 40);
} else if (arg === "--limit") {
options.limit = Number(argv[index + 1] || options.limit);
index += 1;
} else if (arg.startsWith("--limit=")) {
options.limit = Number(arg.slice("--limit=".length));
} else {
throw new Error(`Unsupported argument: ${arg}`);
}
}
if (!["text", "json"].includes(options.format)) {
throw new Error(`Unsupported --format: ${options.format}`);
}
const uniqueTemplates = Array.from(new Set(options.templates.filter(Boolean)));
for (const template of uniqueTemplates) {
if (!DEFAULT_TARGETS.includes(template)) {
throw new Error(`Unsupported --template: ${template}`);
}
}
options.templates = uniqueTemplates.length ? uniqueTemplates : [...DEFAULT_TARGETS];
if (!Number.isFinite(options.limit) || options.limit < 1 || options.limit > 100) {
throw new Error("--limit must be between 1 and 100");
}
options.limit = Math.floor(options.limit);
return options;
}
function countBy(items, selector) {
const counts = {};
for (const item of items || []) {
const key = toSafeString(selector(item), 80) || "unknown";
counts[key] = (counts[key] || 0) + 1;
}
return counts;
}
function buildWhere(options) {
const where = {
template_type: { in: options.templates },
};
if (options.status) {
where.normalized_status = options.status;
}
return where;
}
function sanitizeArchiveRow(row) {
const participants = (row.participants || []).map((item) => ({
role: item.role,
nodeIndex: item.node_index,
userIdPrefix: maskApprovalIdentifier(item.external_user_id),
mappingStatus: item.mapping_status,
current: Boolean(item.is_current),
}));
const actions = (row.actions || []).map((item) => ({
actionType: item.action_type,
operatorPrefix: maskApprovalIdentifier(item.operator_external_user_id),
actionTime: item.action_time ? new Date(item.action_time).toISOString() : null,
nodeIndex: item.node_index,
source: item.source,
}));
const fieldSummaries = (row.field_summaries || []).map((item) => ({
fieldName: item.display_allowed ? item.field_name : null,
fieldType: item.field_type || "unknown",
sensitiveLevel: item.sensitive_level,
displayAllowed: Boolean(item.display_allowed),
}));
const findings = (row.reconcile_findings || []).map((item) => ({
findingType: item.finding_type,
status: item.status,
severity: item.severity,
source: item.source,
detectedAt: item.detected_at ? new Date(item.detected_at).toISOString() : null,
}));
return {
archiveId: row.id,
spNoPrefix: maskApprovalIdentifier(row.sp_no),
templateIdPrefix: maskApprovalIdentifier(row.template_id),
templateType: row.template_type,
normalizedStatus: row.normalized_status,
submittedAt: row.submitted_at ? new Date(row.submitted_at).toISOString() : null,
statusObservedAt: row.status_observed_at ? new Date(row.status_observed_at).toISOString() : null,
completedAt: row.completed_at ? new Date(row.completed_at).toISOString() : null,
lastSource: row.last_source,
readabilityStatus: row.readability_status,
singleTapApproveRisk: Boolean(row.single_tap_approve_risk),
templateAdjustmentRequired: Boolean(row.template_adjustment_required),
participantRoleCounts: countBy(participants, (item) => item.role),
actionTypeCounts: countBy(actions, (item) => item.actionType),
fieldTypeCounts: countBy(fieldSummaries, (item) => item.fieldType),
openFindingCounts: countBy(findings, (item) => item.findingType),
participants,
actions,
fieldSummaries,
findings,
counts: row._count || {},
};
}
async function readArchiveRows(prisma, options) {
return prisma.wecomApprovalArchive.findMany({
where: buildWhere(options),
orderBy: [
{ status_observed_at: "desc" },
{ updated_at: "desc" },
],
take: options.limit,
select: {
id: true,
sp_no: true,
template_id: true,
template_type: true,
normalized_status: true,
submitted_at: true,
completed_at: true,
status_observed_at: true,
last_source: true,
readability_status: true,
single_tap_approve_risk: true,
template_adjustment_required: true,
participants: {
take: 20,
orderBy: [{ role: "asc" }, { node_index: "asc" }],
select: {
role: true,
node_index: true,
external_user_id: true,
mapping_status: true,
is_current: true,
},
},
actions: {
take: 30,
orderBy: [{ action_time: "asc" }, { created_at: "asc" }],
select: {
action_type: true,
operator_external_user_id: true,
action_time: true,
node_index: true,
source: true,
},
},
field_summaries: {
take: 30,
orderBy: [{ field_index: "asc" }],
select: {
field_name: true,
field_type: true,
sensitive_level: true,
display_allowed: true,
},
},
reconcile_findings: {
where: { status: "open" },
take: 20,
orderBy: [{ detected_at: "desc" }],
select: {
finding_type: true,
status: true,
severity: true,
source: true,
detected_at: true,
},
},
_count: {
select: {
participants: true,
actions: true,
field_summaries: true,
attachment_summaries: true,
callback_events: true,
compensation_items: true,
reconcile_findings: true,
},
},
},
});
}
function evaluateArchiveReport({ options, archives, error, configGaps }) {
if (error) {
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
}
if (configGaps.length || !archives.length) {
return { status: "PARTIAL_ARCHIVE_NEEDS_DATA", conclusion: "PARTIAL_ARCHIVE_NEEDS_DATA" };
}
if (archives.some((item) => item.findings?.length)) {
return { status: "PASS_WITH_FINDINGS", conclusion: "PASS_WITH_FINDINGS" };
}
if (!options.offline) {
return { status: "PARTIAL_ARCHIVE_NEEDS_DATA", conclusion: "PARTIAL_ARCHIVE_NEEDS_DATA" };
}
return { status: "PASS_ARCHIVE_HISTORY_READABLE", conclusion: "PASS_ARCHIVE_HISTORY_READABLE" };
}
async function executeArchiveCheck(options, deps = {}) {
const generatedAt = deps.generatedAt || new Date().toISOString();
const env = deps.env || process.env;
const configGaps = [];
const mainBlockers = [];
let ownedPrisma = null;
let ownedPool = null;
let rawRows = [];
let readError = null;
if (!options.offline) {
configGaps.push({
id: "offline_mode",
status: "NEEDS_CONFIG",
reason: "run_with_--offline_to_prove_no_wecom_api_dependency",
});
}
try {
let prisma = deps.prisma;
if (!prisma) {
if (!env.DATABASE_URL) {
throw new Error("DATABASE_URL is required for OA archive offline read");
}
const created = deps.createPrismaClientImpl ? deps.createPrismaClientImpl() : createPrismaClient();
ownedPrisma = created.prisma;
ownedPool = created.pool;
prisma = ownedPrisma;
}
rawRows = await (deps.readArchiveRowsImpl || readArchiveRows)(prisma, options);
} catch (error) {
readError = error;
mainBlockers.push({
id: "archive_db_read",
reason: "oa_archive_db_read_failed",
message: safeMessage(error?.message || error),
});
} finally {
if (ownedPrisma?.$disconnect) {
await ownedPrisma.$disconnect().catch(() => null);
}
if (ownedPool?.end) {
await ownedPool.end().catch(() => null);
}
}
const archives = rawRows.map(sanitizeArchiveRow);
if (!readError && !archives.length) {
configGaps.push({
id: "archive_history_data",
status: "NEEDS_CONFIG",
reason: "no_wecom_approval_archive_rows_for_scope",
});
}
const conclusion = evaluateArchiveReport({ options, archives, error: readError, configGaps });
return {
mode: "qa_only",
generatedAt,
status: conclusion.status,
conclusion: conclusion.conclusion,
privacy: REPORT_PRIVACY,
scope: {
issue: "WARCH1",
source: "OA_DB_ONLY",
offline: Boolean(options.offline),
wecomApiCalls: false,
writeBehavior: "read-only OA DB; no OA writes, no WeCom reads, no WeCom writes",
targets: options.templates,
deepMirror: "not_started",
nextStage: "wecom_attendance_approval_mirror_v1_after_WARCH1_acceptance",
},
filters: {
templates: options.templates,
status: options.status || null,
limit: options.limit,
},
checks: {
archiveRead: {
id: "archive_history_read",
status: readError ? "BLOCKED" : (archives.length ? "OK" : "NEEDS_DATA"),
count: archives.length,
statusCounts: countBy(archives, (item) => item.normalizedStatus),
templateCounts: countBy(archives, (item) => item.templateType),
singleTapApproveRiskCount: archives.filter((item) => item.singleTapApproveRisk).length,
},
archives,
},
configGaps,
mainBlockers,
};
}
function ensureReportDir(reportDir) {
fs.mkdirSync(reportDir, { recursive: true });
}
function writeReport(reportDir, report) {
ensureReportDir(reportDir);
const filePath = path.join(reportDir, "summary.json");
fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
return filePath;
}
function printText(report, reportPath) {
process.stdout.write(`[wecom-approval-archive] status=${report.status} conclusion=${report.conclusion}\n`);
process.stdout.write(`[wecom-approval-archive] report=${reportPath}\n`);
process.stdout.write(`[wecom-approval-archive] privacy=${report.privacy}\n`);
process.stdout.write(`[wecom-approval-archive] offline=${report.scope.offline} targets=${report.scope.targets.join(",")}\n`);
if (report.configGaps?.length) {
process.stdout.write(`[wecom-approval-archive] configGaps=${JSON.stringify(report.configGaps)}\n`);
}
if (report.mainBlockers?.length) {
process.stdout.write(`[wecom-approval-archive] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const report = await executeArchiveCheck(options);
const reportPath = writeReport(options.reportDir, report);
if (options.format === "json") {
process.stdout.write(`${JSON.stringify({ ...report, reportPath }, null, 2)}\n`);
} else {
printText(report, reportPath);
}
}
if (require.main === module) {
main().catch((error) => {
process.stderr.write(`[wecom-approval-archive][FAIL] ${safeMessage(error?.message || error)}\n`);
process.exitCode = 1;
});
}
module.exports = {
buildWhere,
evaluateArchiveReport,
executeArchiveCheck,
parseArgs,
sanitizeArchiveRow,
};
@@ -6,6 +6,7 @@ const path = require("node:path");
const { Pool } = require("pg");
const { getConfig } = require("../config");
const { createPrismaClient } = require("../prismaClient");
const {
APPROVAL_GET_INFO_ENDPOINT,
APPROVAL_POC_TARGETS,
@@ -13,6 +14,13 @@ const {
fetchWecomApprovalInfoList,
maskApprovalIdentifier,
} = require("../services/integrations/wecomApproval");
const {
corpHash,
createCompensationRun,
finishCompensationRun,
persistApprovalDetail,
recordReconcileFinding,
} = require("../services/integrations/wecomApprovalArchive");
const { readCallbackEvidence } = require("../services/integrations/wecomCallbackCrypto");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
@@ -47,11 +55,13 @@ Options:
--discover-applicant-employee-no=<no>
自动发现使用的 OA 申请人员工号,默认 SMOKE002。
--size=<1..100> 单页审批单号数量,默认 100。
--persist-to-db 写入 OA 自建留存表;还必须设置 WECOM_ARCHIVE_WRITE=1。
--help 显示帮助。
Secret 固定规则:
审批单号批量查询和审批详情读取:WECOM_AGENT_SECRET。
不使用 WECOM_CONTACT_SECRET、WECOM_CHECKIN_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。
OA DB 留存写入必须显式 --persist-to-db + WECOM_ARCHIVE_WRITE=1;不写企微数据。
隐私边界:
报告只输出审批类型、计数、状态摘要、脱敏 sp_no/template/userid 前缀和错误分类。
@@ -108,6 +118,7 @@ function parseArgs(argv) {
discoverApplicantEmployeeNo: DEFAULT_DISCOVERY_APPLICANT_EMPLOYEE_NO,
size: DEFAULT_PAGE_SIZE,
maxPages: DEFAULT_MAX_PAGES,
persistToDb: false,
help: false,
};
@@ -158,6 +169,10 @@ function parseArgs(argv) {
index += 1;
} else if (arg.startsWith("--size=")) {
options.size = Number(arg.slice("--size=".length));
} else if (arg === "--persist-to-db") {
options.persistToDb = true;
} else if (arg === "--no-persist-to-db") {
options.persistToDb = false;
} else {
throw new Error(`Unsupported argument: ${arg}`);
}
@@ -535,10 +550,21 @@ function statusLabelFromSpStatus(value) {
}[key] || (key ? `状态${key}` : null);
}
function attachHiddenValue(target, key, value) {
if (value != null && value !== "") {
Object.defineProperty(target, key, {
value,
enumerable: false,
configurable: true,
});
}
return target;
}
function summarizeDetailResult(target, spNo, result) {
const summary = result?.summary || {};
const spStatus = summary.spStatus == null ? null : toSafeString(summary.spStatus, 40);
return {
return attachHiddenValue({
target: target.key,
label: target.label,
status: result?.ok ? "OK" : "BLOCKED",
@@ -554,7 +580,7 @@ function summarizeDetailResult(target, spNo, result) {
notifyerCount: summary.notifyerCount,
commentCount: summary.commentCount,
response: summarizeApiResponse(result?.response),
};
}, "spNo", spNo);
}
function buildMissingTemplateCheck(input) {
@@ -596,6 +622,7 @@ async function runLiveTarget({
options,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
}) {
const pages = [];
const spNos = [];
@@ -655,8 +682,38 @@ async function runLiveTarget({
const uniqueSpNos = Array.from(new Set(spNos));
const details = [];
for (const spNo of uniqueSpNos) {
const detailResult = await fetchApprovalDetailImpl({ config, spNo });
details.push(summarizeDetailResult(input, spNo, detailResult));
const detailResult = await fetchApprovalDetailImpl({
config,
spNo,
includeRawPayload: Boolean(archivePersistence?.enabled),
});
const detailSummary = summarizeDetailResult(input, spNo, detailResult);
if (detailResult.ok && archivePersistence?.enabled) {
try {
const stored = await persistApprovalDetail({
prisma: archivePersistence.prisma,
config,
payload: detailResult.rawPayload,
spNo,
target: input.key,
source: "compensation_pull",
compensationRunId: archivePersistence.runId,
});
detailSummary.archivePersistence = {
status: "STORED",
archiveId: stored.archiveId,
spNoPrefix: stored.spNoPrefix,
templateIdPrefix: stored.templateIdPrefix,
};
} catch (error) {
detailSummary.archivePersistence = {
status: "FAILED",
reason: "archive_persist_failed",
message: safeMessage(error?.message || error),
};
}
}
details.push(detailSummary);
}
const detailBlocked = details.find((item) => item.status === "BLOCKED");
@@ -685,6 +742,7 @@ async function runTargetChecks({
options,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
}) {
const targetChecks = [];
for (const input of inputs) {
@@ -700,6 +758,7 @@ async function runTargetChecks({
options,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
}));
}
}
@@ -707,7 +766,7 @@ async function runTargetChecks({
}
function sanitizeEvidenceEvent(event) {
return {
return attachHiddenValue({
callbackKind: toSafeString(event.callbackKind, 80),
approvalType: toSafeString(event.approvalType, 80),
spStatus: event.spStatus == null ? null : toSafeString(event.spStatus, 40),
@@ -715,27 +774,85 @@ function sanitizeEvidenceEvent(event) {
spNoPrefix: toSafeString(event.spNoPrefix, 80) || null,
templateIdPrefix: toSafeString(event.templateIdPrefix, 80) || null,
recordedAt: toSafeString(event.recordedAt, 80) || null,
};
}, "spNo", event.spNo);
}
async function buildEvidenceCheck({ config, readCallbackEvidenceImpl = readCallbackEvidence }) {
const events = await readCallbackEvidenceImpl({
evidenceFile: config.wecomApprovalCallbackEvidenceFile,
function sanitizeDbCallbackEvent(event) {
return attachHiddenValue({
callbackKind: toSafeString(event.callback_kind, 80),
approvalType: toSafeString(event.approval_type, 80),
spStatus: event.wecom_status == null ? null : toSafeString(event.wecom_status, 40),
statusLabel: null,
spNoPrefix: maskApprovalIdentifier(event.sp_no),
templateIdPrefix: maskApprovalIdentifier(event.template_id),
recordedAt: event.received_at ? new Date(event.received_at).toISOString() : null,
}, "spNo", event.sp_no);
}
async function readDbCallbackEvidence({ prisma, config }) {
if (!prisma?.wecomApprovalCallbackEvent?.findMany) return [];
const where = {
callback_kind: "approval_status_change",
};
if (config?.wecomCorpId) {
where.corp_id_hash = corpHash(config);
}
const events = await prisma.wecomApprovalCallbackEvent.findMany({
where,
orderBy: [{ received_at: "asc" }],
take: 200,
});
return events.map(sanitizeDbCallbackEvent);
}
async function buildEvidenceCheck({
config,
prisma = null,
preferDb = false,
readDbCallbackEvidenceImpl = readDbCallbackEvidence,
readCallbackEvidenceImpl = readCallbackEvidence,
}) {
let evidenceSource = "file";
let events = [];
let dbReadError = null;
if (preferDb && prisma) {
try {
events = await readDbCallbackEvidenceImpl({ prisma, config });
if (events.length) {
evidenceSource = "db";
}
} catch (error) {
dbReadError = safeMessage(error?.message || error);
events = [];
}
}
if (!events.length) {
events = await readCallbackEvidenceImpl({
evidenceFile: config.wecomApprovalCallbackEvidenceFile,
});
}
const approvalEvents = (events || [])
.filter((item) => item?.callbackKind === "approval_status_change")
.map(sanitizeEvidenceEvent);
const bySpNoPrefix = {};
const byFullSpNo = {};
for (const event of approvalEvents) {
if (!event.spNoPrefix) continue;
bySpNoPrefix[event.spNoPrefix] = bySpNoPrefix[event.spNoPrefix] || [];
bySpNoPrefix[event.spNoPrefix].push(event);
if (event.spNo) {
byFullSpNo[event.spNo] = byFullSpNo[event.spNo] || [];
byFullSpNo[event.spNo].push(event);
}
}
return {
id: "callback_event_evidence",
status: approvalEvents.length ? "OK" : "PENDING",
source: evidenceSource,
dbReadError,
eventCount: approvalEvents.length,
latestEvent: approvalEvents[approvalEvents.length - 1] || null,
byFullSpNo,
bySpNoPrefix,
};
}
@@ -752,43 +869,46 @@ function compareCallbackEvidence(targetChecks, evidenceCheck) {
const findings = [];
const pulledDetails = collectPulledDetails(targetChecks);
const evidenceBySpNo = evidenceCheck.bySpNoPrefix || {};
const evidenceByFullSpNo = evidenceCheck.byFullSpNo || {};
const pulledPrefixes = new Set();
for (const detail of pulledDetails) {
if (!detail.spNoPrefix) continue;
pulledPrefixes.add(detail.spNoPrefix);
const events = evidenceBySpNo[detail.spNoPrefix] || [];
const events = detail.spNo
? evidenceByFullSpNo[detail.spNo] || evidenceBySpNo[detail.spNoPrefix] || []
: evidenceBySpNo[detail.spNoPrefix] || [];
if (!events.length) {
findings.push({
findings.push(attachHiddenValue({
id: "callback_missing_for_pulled_detail",
target: detail.target,
spNoPrefix: detail.spNoPrefix,
status: "FINDING",
});
}, "spNo", detail.spNo));
continue;
}
if (events.length > 1) {
findings.push({
findings.push(attachHiddenValue({
id: "callback_duplicate_for_pulled_detail",
target: detail.target,
spNoPrefix: detail.spNoPrefix,
count: events.length,
status: "FINDING",
});
}, "spNo", detail.spNo));
}
const hasStatusMatch = events.some((event) => (
(event.spStatus && detail.spStatus && event.spStatus === detail.spStatus)
|| (event.statusLabel && detail.statusLabel && event.statusLabel === detail.statusLabel)
));
if (!hasStatusMatch) {
findings.push({
findings.push(attachHiddenValue({
id: "callback_status_mismatch",
target: detail.target,
spNoPrefix: detail.spNoPrefix,
pulledStatus: detail.statusLabel || detail.spStatus,
callbackStatus: events[0]?.statusLabel || events[0]?.spStatus || null,
status: "FINDING",
});
}, "spNo", detail.spNo));
}
}
@@ -863,6 +983,132 @@ function collectMainBlockers(secretCheck, targetChecks) {
return blockers;
}
function buildArchivePersistencePlan({ options, env }) {
if (!options.persistToDb) {
return {
id: "archive_persistence",
status: "SKIPPED",
enabled: false,
reason: "default_no_oa_db_writes",
writeBehavior: "disabled unless --persist-to-db and WECOM_ARCHIVE_WRITE=1 are both present",
};
}
if (!options.live) {
return {
id: "archive_persistence",
status: "NEEDS_CONFIG",
enabled: false,
reason: "live_read_required_for_archive_persistence",
missing: ["--live"],
};
}
if (env.WECOM_ARCHIVE_WRITE !== "1") {
return {
id: "archive_persistence",
status: "NEEDS_CONFIG",
enabled: false,
reason: "missing_archive_write_switch",
missing: ["WECOM_ARCHIVE_WRITE=1"],
};
}
if (!env.DATABASE_URL) {
return {
id: "archive_persistence",
status: "BLOCKED",
enabled: false,
reason: "database_url_missing",
missing: ["DATABASE_URL"],
};
}
return {
id: "archive_persistence",
status: "READY",
enabled: false,
reason: null,
writeBehavior: "OA_DB_ONLY; WeCom remains read-only",
};
}
function summarizeArchivePersistence(targetChecks, archivePersistenceCheck) {
if (!archivePersistenceCheck?.enabled) return archivePersistenceCheck;
const details = collectPulledDetails(targetChecks);
const stored = details.filter((item) => item.archivePersistence?.status === "STORED");
const failed = details.filter((item) => item.archivePersistence?.status === "FAILED");
return {
...archivePersistenceCheck,
status: failed.length ? "BLOCKED" : "OK",
storedCount: stored.length,
failedCount: failed.length,
failures: failed.map((item) => ({
target: item.target,
spNoPrefix: item.spNoPrefix,
reason: item.archivePersistence?.reason || "archive_persist_failed",
message: item.archivePersistence?.message || null,
})),
};
}
async function recordCompareFindings({ prisma, runId, compareCheck, targetChecks }) {
if (!prisma || !runId || !compareCheck?.findings?.length) return 0;
const archiveBySpNo = new Map();
const archiveByTargetPrefix = new Map();
for (const detail of collectPulledDetails(targetChecks)) {
if (detail.spNo && detail.archivePersistence?.archiveId) {
archiveBySpNo.set(detail.spNo, detail.archivePersistence.archiveId);
}
if (detail.spNoPrefix && detail.archivePersistence?.archiveId) {
archiveByTargetPrefix.set(`${detail.target}:${detail.spNoPrefix}`, detail.archivePersistence.archiveId);
}
}
let recorded = 0;
for (const finding of compareCheck.findings) {
const archiveId = (finding.spNo ? archiveBySpNo.get(finding.spNo) : null)
|| archiveByTargetPrefix.get(`${finding.target}:${finding.spNoPrefix}`)
|| null;
await recordReconcileFinding({
prisma,
runId,
archiveId,
findingType: finding.id,
severity: finding.id === "callback_status_mismatch" ? "warning" : "info",
source: "compensation_compare",
description: finding.id,
evidence: {
...finding,
spNoPrefix: finding.spNoPrefix || null,
},
});
recorded += 1;
}
return recorded;
}
function appendArchivePersistenceGate({ mainBlockers, configGaps, archivePersistenceCheck }) {
if (!archivePersistenceCheck) return;
if (archivePersistenceCheck.status === "NEEDS_CONFIG") {
configGaps.push({
id: archivePersistenceCheck.id,
status: "NEEDS_CONFIG",
reason: archivePersistenceCheck.reason,
missing: archivePersistenceCheck.missing || [],
});
} else if (archivePersistenceCheck.status === "BLOCKED") {
mainBlockers.push({
id: archivePersistenceCheck.id,
reason: archivePersistenceCheck.reason || "archive_persistence_blocked",
missing: archivePersistenceCheck.missing || [],
});
}
}
function countTargetPages(targetChecks) {
return targetChecks.reduce((total, item) => total + (item.pageCount || 0), 0);
}
function countPulledItems(targetChecks) {
return targetChecks.reduce((total, item) => total + (item.spNoCount || 0), 0);
}
function evaluateConclusion({ mainBlockers, configGaps, compareCheck }) {
if (mainBlockers.length) {
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
@@ -884,6 +1130,13 @@ async function executeCompensationPoc(options, deps = {}) {
const generatedAt = deps.generatedAt || new Date().toISOString();
const config = deps.config || getConfig();
const timeWindow = buildTimeWindow(options, generatedAt);
let ownedPrisma = null;
let ownedPool = null;
let archivePersistence = { enabled: false, prisma: null, runId: null };
let archivePersistenceCheck = buildArchivePersistencePlan({ options, env });
let finishRunInput = null;
try {
const inputs = buildInputs(options, env);
const secretCheck = buildSecretCheck(config);
const discoveryCheck = await discoverTemplateIds({
@@ -896,6 +1149,35 @@ async function executeCompensationPoc(options, deps = {}) {
queryApplicantActorImpl: deps.queryApplicantActorImpl || queryApplicantActorRowReadOnly,
});
const resolvedInputs = applyDiscoveredTemplateIds(inputs, discoveryCheck);
if (archivePersistenceCheck.status === "READY") {
try {
let prisma = deps.prisma;
if (!prisma) {
const created = deps.createPrismaClientImpl ? deps.createPrismaClientImpl() : createPrismaClient();
ownedPrisma = created.prisma;
ownedPool = created.pool;
prisma = ownedPrisma;
}
const run = await createCompensationRun({ prisma, timeWindow, inputs: resolvedInputs, options });
archivePersistence = { enabled: true, prisma, runId: run.id };
archivePersistenceCheck = {
...archivePersistenceCheck,
status: "OK",
enabled: true,
runId: run.id,
startedAt: run.started_at ? new Date(run.started_at).toISOString() : null,
};
} catch (error) {
archivePersistenceCheck = {
...archivePersistenceCheck,
status: "BLOCKED",
enabled: false,
reason: "archive_persistence_unavailable",
message: safeMessage(error?.message || error),
};
archivePersistence = { enabled: false, prisma: null, runId: null };
}
}
const targetChecks = await runTargetChecks({
config,
inputs: resolvedInputs,
@@ -903,12 +1185,28 @@ async function executeCompensationPoc(options, deps = {}) {
options,
fetchApprovalInfoListImpl: deps.fetchApprovalInfoListImpl || fetchWecomApprovalInfoList,
fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail,
archivePersistence,
});
archivePersistenceCheck = summarizeArchivePersistence(targetChecks, archivePersistenceCheck);
const evidenceCheck = await buildEvidenceCheck({
config,
prisma: archivePersistence.prisma,
preferDb: archivePersistence.enabled,
readDbCallbackEvidenceImpl: deps.readDbCallbackEvidenceImpl,
readCallbackEvidenceImpl: deps.readCallbackEvidenceImpl,
});
const compareCheck = compareCallbackEvidence(targetChecks, evidenceCheck);
const recordedFindingCount = archivePersistence.enabled && archivePersistenceCheck.status === "OK"
? await recordCompareFindings({
prisma: archivePersistence.prisma,
runId: archivePersistence.runId,
compareCheck,
targetChecks,
})
: 0;
if (archivePersistenceCheck?.enabled) {
archivePersistenceCheck.recordedFindingCount = recordedFindingCount;
}
const verificationGaps = options.live ? [] : [{
id: "live_read",
status: "DRY_RUN",
@@ -922,7 +1220,22 @@ async function executeCompensationPoc(options, deps = {}) {
});
}
const configGaps = collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compareCheck);
appendArchivePersistenceGate({ mainBlockers, configGaps, archivePersistenceCheck });
const conclusion = evaluateConclusion({ mainBlockers, configGaps, compareCheck });
if (archivePersistence.enabled) {
finishRunInput = {
prisma: archivePersistence.prisma,
runId: archivePersistence.runId,
status: mainBlockers.length ? "blocked" : (configGaps.length ? "partial" : (compareCheck.findings.length ? "findings" : "completed")),
pageCount: countTargetPages(targetChecks),
itemCount: countPulledItems(targetChecks),
detailCount: collectPulledDetails(targetChecks).length,
findingCount: recordedFindingCount,
errorCode: mainBlockers[0]?.reason || null,
errorMessage: mainBlockers[0]?.reason || null,
};
await finishCompensationRun(finishRunInput);
}
return {
mode: "qa_only",
@@ -933,11 +1246,14 @@ async function executeCompensationPoc(options, deps = {}) {
scope: {
issue: "WACP3",
targets: APPROVAL_POC_TARGETS.map((item) => item.key),
writeBehavior: "read-only only; no OA writes, no WeCom writes, no template creation, no approval submission",
writeBehavior: options.persistToDb
? "WeCom read-only; OA archive DB persistence requires --persist-to-db + WECOM_ARCHIVE_WRITE=1"
: "read-only only; no OA writes, no WeCom writes, no template creation, no approval submission",
liveRead: Boolean(options.live),
autoDiscovery: Boolean(options.discoverTemplateIds),
archivePersistenceRequested: Boolean(options.persistToDb),
deepMirror: "not_started",
nextStage: "WREAD1",
nextStage: options.persistToDb ? "WARCH1_OFFLINE_ARCHIVE_READ" : "WREAD1_OR_WARCH1_PERSISTENCE",
},
timeWindow,
inputs: {
@@ -957,12 +1273,30 @@ async function executeCompensationPoc(options, deps = {}) {
callbackEvidence: {
...evidenceCheck,
bySpNoPrefix: undefined,
byFullSpNo: undefined,
},
callbackCompare: compareCheck,
archivePersistence: archivePersistenceCheck,
},
configGaps,
mainBlockers,
};
} finally {
if (finishRunInput && archivePersistence.enabled && archivePersistenceCheck.status === "BLOCKED") {
await finishCompensationRun({
...finishRunInput,
status: "blocked",
errorCode: archivePersistenceCheck.reason || "archive_persistence_blocked",
errorMessage: archivePersistenceCheck.reason || "archive_persistence_blocked",
}).catch(() => null);
}
if (ownedPrisma?.$disconnect) {
await ownedPrisma.$disconnect().catch(() => null);
}
if (ownedPool?.end) {
await ownedPool.end().catch(() => null);
}
}
}
function ensureReportDir(reportDir) {
@@ -1019,6 +1353,7 @@ module.exports = {
discoverTemplateIds,
evaluateConclusion,
executeCompensationPoc,
buildArchivePersistencePlan,
parseArgs,
statusLabelFromSpStatus,
};
@@ -316,6 +316,7 @@ async function fetchWecomApprovalDetail({
spNo,
fetchImpl = fetch,
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
includeRawPayload = false,
}) {
const resolvedSpNo = toSafeString(spNo, 160);
if (!resolvedSpNo) {
@@ -359,6 +360,7 @@ async function fetchWecomApprovalDetail({
message: null,
tokenSource: token.tokenSource,
response: sanitizeApprovalApiResponse(result.payload),
rawPayload: includeRawPayload ? result.payload : undefined,
templateId: toSafeString(result.payload?.info?.template_id ?? result.payload?.info?.templateId, 160),
spName: toSafeString(result.payload?.info?.sp_name ?? result.payload?.info?.spName, 120),
summary: summarizeApprovalPayload(result.payload),
@@ -0,0 +1,778 @@
const crypto = require("node:crypto");
const { maskApprovalIdentifier } = require("./wecomApproval");
const TERMINAL_STATUSES = new Set(["approved", "rejected", "revoked", "terminated", "changed"]);
function toSafeString(value, maxLength = 500) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function sha256(value) {
return crypto.createHash("sha256").update(String(value ?? "")).digest("hex");
}
function stableJson(value) {
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function digestObject(value) {
return sha256(stableJson(value));
}
function corpHash(config = {}) {
return sha256(toSafeString(config.wecomCorpId || config.corpId || "unknown-corp", 160));
}
function normalizeWecomApprovalStatus(value) {
const raw = toSafeString(value, 40);
return {
"1": "pending",
"2": "approved",
"3": "rejected",
"4": "revoked",
"6": "revoked",
"7": "terminated",
"10": "approved",
}[raw] || (raw ? "unknown" : "unknown");
}
function isTerminalStatus(value) {
return TERMINAL_STATUSES.has(toSafeString(value, 40));
}
function parseWecomTime(value) {
const raw = toSafeString(value, 80);
if (!raw) return null;
if (/^\d+$/.test(raw)) {
const numeric = Number(raw);
if (!Number.isFinite(numeric) || numeric <= 0) return null;
const millis = numeric > 100000000000 ? numeric : numeric * 1000;
const date = new Date(millis);
return Number.isNaN(date.getTime()) ? null : date;
}
const parsed = Date.parse(raw);
if (!Number.isFinite(parsed)) return null;
return new Date(parsed);
}
function pickText(source, names, maxLength = 500) {
if (!source || typeof source !== "object") return "";
for (const name of names) {
const value = source[name];
if (value == null) continue;
if (typeof value === "object") {
const nested = pickText(value, ["userid", "user_id", "UserId", "UserID", "name", "Name", "text", "value", "#text"], maxLength);
if (nested) return nested;
} else {
const text = toSafeString(value, maxLength);
if (text) return text;
}
}
return "";
}
function toArray(value) {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
function inferApprovalType({ spName, templateId, fallback }) {
const text = `${spName || ""} ${templateId || ""} ${fallback || ""}`;
if (/补卡|missed|punch|打卡/i.test(text)) return "missed_punch";
if (/请假|leave/i.test(text)) return "leave";
return fallback || "unknown";
}
function valueToText(value, maxLength = 4000) {
if (value == null) return "";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return toSafeString(value, maxLength);
}
if (Array.isArray(value)) {
return value.map((item) => valueToText(item, 400)).filter(Boolean).join("; ").slice(0, maxLength);
}
if (typeof value === "object") {
const preferred = pickText(value, ["text", "value", "new_value", "date", "time", "title", "name", "option"], maxLength);
if (preferred) return preferred;
try {
return JSON.stringify(value).slice(0, maxLength);
} catch {
return "";
}
}
return "";
}
function controlTitle(control, index) {
const title = control?.title || control?.name || control?.label || control?.property?.title;
if (typeof title === "string") return toSafeString(title, 160);
if (Array.isArray(title)) {
return toSafeString(title.map((item) => item?.text || item?.name || item?.value).filter(Boolean).join("/"), 160);
}
if (title && typeof title === "object") {
return pickText(title, ["text", "name", "value"], 160);
}
return `field_${index}`;
}
function controlType(control) {
return toSafeString(
control?.control
|| control?.control_type
|| control?.controlType
|| control?.type
|| control?.property?.control
|| control?.property?.control_type
|| control?.property?.type
|| "unknown",
80,
);
}
function normalizeField(control, index) {
const name = controlTitle(control, index);
const type = controlType(control);
const rawValue = control?.value ?? control?.new_value ?? control?.text ?? control?.title ?? control?.property?.value;
const normalizedValue = valueToText(rawValue || control, 4000);
const keyBase = toSafeString(control?.id || control?.key || control?.control_id || name || `field_${index}`, 100);
const sensitive = /原因|事由|说明|备注|reason|comment/i.test(name) ? "restricted" : "normal";
return {
field_key: `${keyBase || "field"}_${index}`.slice(0, 120),
field_index: index,
field_name: name,
field_type: type,
normalized_value: normalizedValue,
value_preview: normalizedValue ? `${normalizedValue.slice(0, 24)}${normalizedValue.length > 24 ? "..." : ""}` : null,
sensitive_level: sensitive,
display_allowed: sensitive === "normal",
};
}
function normalizeFields(info) {
const applyData = info?.apply_data && typeof info.apply_data === "object" ? info.apply_data : {};
const contents = toArray(applyData.contents || info?.contents || info?.applyData?.contents);
return contents
.map((control, index) => normalizeField(control, index))
.filter((item) => item.field_key);
}
function extractUserId(value) {
if (!value) return "";
if (typeof value === "string") return toSafeString(value, 128);
return pickText(value, ["userid", "user_id", "UserId", "UserID", "open_userid", "OpenUserId"], 128);
}
function normalizeParticipants(info) {
const participants = [];
const applyer = info?.applyer || info?.applicant || info?.creator;
const applicantUserId = extractUserId(applyer);
if (applicantUserId) {
participants.push({
role: "applicant",
node_index: 0,
external_user_id: applicantUserId,
is_current: false,
display_summary: null,
});
}
for (const [nodeIndex, record] of toArray(info?.sp_record || info?.spRecord).entries()) {
const details = toArray(record?.details || record?.detail || record?.approvers || record?.approver);
const users = details.length ? details : toArray(record?.userid || record?.user_id || record?.UserId);
for (const user of users) {
const userId = extractUserId(user);
if (!userId) continue;
participants.push({
role: "approver",
node_index: nodeIndex + 1,
external_user_id: userId,
is_current: false,
display_summary: null,
});
}
}
for (const [index, notifyer] of toArray(info?.notifyer || info?.notifier).entries()) {
const userId = extractUserId(notifyer);
if (!userId) continue;
participants.push({
role: "notifyer",
node_index: index,
external_user_id: userId,
is_current: false,
display_summary: null,
});
}
const seen = new Set();
return participants.filter((item) => {
const key = `${item.role}:${item.node_index}:${item.external_user_id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function actionTypeFromStatus(value) {
const status = normalizeWecomApprovalStatus(value);
if (status === "approved") return "approve";
if (status === "rejected") return "reject";
if (status === "revoked") return "revoke";
if (status === "terminated") return "terminate";
if (status === "pending") return "pending";
return "status_change";
}
function normalizeActions(info, source) {
const actions = [];
for (const [nodeIndex, record] of toArray(info?.sp_record || info?.spRecord).entries()) {
const details = toArray(record?.details || record?.detail || record?.approvers || record?.approver);
const actionItems = details.length ? details : [record];
for (const [detailIndex, detail] of actionItems.entries()) {
const operator = extractUserId(detail) || extractUserId(record);
const status = pickText(detail, ["sp_status", "status", "result", "Speech"], 40)
|| pickText(record, ["sp_status", "status", "result"], 40)
|| info?.sp_status;
const actionTime = parseWecomTime(
pickText(detail, ["speech_time", "operate_time", "time", "mtime"], 80)
|| pickText(record, ["operate_time", "time", "mtime"], 80)
|| info?.apply_time,
);
const comment = pickText(detail, ["speech", "comment", "opinion", "remark"], 500);
const actionType = actionTypeFromStatus(status);
const idempotencyKey = digestObject([source, info?.sp_no, actionType, operator, actionTime?.toISOString(), nodeIndex, detailIndex]).slice(0, 64);
actions.push({
action_type: actionType,
operator_external_user_id: operator || null,
action_time: actionTime,
node_index: nodeIndex + 1,
comment_summary: comment || null,
source,
idempotency_key: idempotencyKey,
evidence_digest: idempotencyKey,
});
}
}
if (!actions.length && info?.sp_no) {
const actionTime = parseWecomTime(info?.apply_time || info?.applyTime);
const actionType = actionTypeFromStatus(info?.sp_status);
const idempotencyKey = digestObject([source, info.sp_no, actionType, actionTime?.toISOString()]).slice(0, 64);
actions.push({
action_type: actionType,
operator_external_user_id: extractUserId(info.applyer) || null,
action_time: actionTime,
node_index: 0,
comment_summary: null,
source,
idempotency_key: idempotencyKey,
evidence_digest: idempotencyKey,
});
}
return actions;
}
function normalizeApprovalDetailPayload(payload, options = {}) {
const info = payload?.info && typeof payload.info === "object" ? payload.info : payload || {};
const spNo = toSafeString(info.sp_no ?? info.spNo ?? options.spNo, 160);
const templateId = toSafeString(info.template_id ?? info.templateId ?? options.templateId, 160);
const spName = toSafeString(info.sp_name ?? info.spName ?? options.spName, 160);
const wecomStatus = toSafeString(info.sp_status ?? info.spStatus, 40);
const templateType = inferApprovalType({ spName, templateId, fallback: options.target });
const fields = normalizeFields(info);
const participants = normalizeParticipants(info);
const actions = normalizeActions({ ...info, sp_no: spNo }, options.source || "compensation_pull");
const appliedAt = parseWecomTime(info.apply_time ?? info.applyTime);
const submittedAt = parseWecomTime(info.apply_time ?? info.applyTime ?? info.create_time);
const statusObservedAt = parseWecomTime(info.update_time ?? info.updateTime ?? info.apply_time ?? info.applyTime) || new Date();
const completedAt = isTerminalStatus(normalizeWecomApprovalStatus(wecomStatus)) ? statusObservedAt : null;
const applicantExternalUserId = extractUserId(info.applyer || info.applicant || info.creator) || null;
const normalized = {
sp_no: spNo,
template_id: templateId || null,
template_type: templateType,
sp_name: spName || null,
normalized_status: normalizeWecomApprovalStatus(wecomStatus),
wecom_status: wecomStatus || null,
applicant_external_user_id: applicantExternalUserId,
submitted_at: submittedAt,
applied_at: appliedAt,
completed_at: completedAt,
status_observed_at: statusObservedAt,
fields,
participants,
actions,
};
return {
...normalized,
detail_digest: digestObject({
sp_no: normalized.sp_no,
template_id: normalized.template_id,
normalized_status: normalized.normalized_status,
wecom_status: normalized.wecom_status,
fields: normalized.fields,
actions: normalized.actions,
}),
};
}
function publicArchiveSummary(detailOrRecord) {
return {
archiveId: detailOrRecord?.id || detailOrRecord?.archive_id || null,
spNoPrefix: maskApprovalIdentifier(detailOrRecord?.sp_no),
templateIdPrefix: maskApprovalIdentifier(detailOrRecord?.template_id),
templateType: detailOrRecord?.template_type || detailOrRecord?.approval_type || "unknown",
normalizedStatus: detailOrRecord?.normalized_status || "unknown",
source: detailOrRecord?.last_source || detailOrRecord?.source || null,
};
}
async function mapEmployeeIdByWecomUserId(tx, externalUserId) {
const userId = toSafeString(externalUserId, 128);
if (!userId || !tx?.wecomIdentityBinding) return null;
const binding = await tx.wecomIdentityBinding.findUnique({
where: { external_user_id: userId },
select: { employee_id: true },
}).catch(() => null);
return binding?.employee_id || null;
}
function shouldAdvanceStatus(current, incoming) {
if (!current?.status_observed_at) return true;
if (!incoming.status_observed_at) return false;
if (new Date(incoming.status_observed_at).getTime() < new Date(current.status_observed_at).getTime()
&& isTerminalStatus(current.normalized_status)
&& !isTerminalStatus(incoming.normalized_status)) {
return false;
}
return new Date(incoming.status_observed_at).getTime() >= new Date(current.status_observed_at).getTime();
}
async function upsertArchiveActions(tx, archiveId, actions = []) {
for (const action of actions) {
const operatorEmployeeId = await mapEmployeeIdByWecomUserId(tx, action.operator_external_user_id);
await tx.wecomApprovalAction.upsert({
where: {
archive_id_idempotency_key: {
archive_id: archiveId,
idempotency_key: action.idempotency_key,
},
},
update: {
action_type: action.action_type,
operator_external_user_id: action.operator_external_user_id,
operator_employee_id: operatorEmployeeId,
action_time: action.action_time,
node_index: action.node_index,
comment_summary: action.comment_summary,
source: action.source,
evidence_digest: action.evidence_digest,
},
create: {
...action,
archive_id: archiveId,
operator_employee_id: operatorEmployeeId,
},
});
}
}
async function replaceArchiveChildren(tx, archiveId, detail) {
await tx.wecomApprovalFieldSummary.deleteMany({ where: { archive_id: archiveId } });
await tx.wecomApprovalParticipant.deleteMany({ where: { archive_id: archiveId } });
if (detail.fields.length) {
await tx.wecomApprovalFieldSummary.createMany({
data: detail.fields.map((field) => ({ ...field, archive_id: archiveId })),
skipDuplicates: true,
});
}
if (detail.participants.length) {
const participants = [];
for (const participant of detail.participants) {
participants.push({
...participant,
archive_id: archiveId,
employee_id: await mapEmployeeIdByWecomUserId(tx, participant.external_user_id),
mapping_status: participant.external_user_id ? "mapped_or_external" : "unknown",
});
}
await tx.wecomApprovalParticipant.createMany({ data: participants, skipDuplicates: true });
}
await upsertArchiveActions(tx, archiveId, detail.actions);
}
async function upsertApprovalArchive(tx, config, detail, source) {
const corp_id_hash = corpHash(config);
const existing = await tx.wecomApprovalArchive.findUnique({
where: { corp_id_hash_sp_no: { corp_id_hash, sp_no: detail.sp_no } },
});
const applicantEmployeeId = await mapEmployeeIdByWecomUserId(tx, detail.applicant_external_user_id);
const shouldAdvance = shouldAdvanceStatus(existing, detail);
const baseData = {
template_id: detail.template_id,
template_type: detail.template_type,
sp_name: detail.sp_name,
applicant_employee_id: applicantEmployeeId,
applicant_external_user_id: detail.applicant_external_user_id,
submitted_at: detail.submitted_at,
applied_at: detail.applied_at,
completed_at: detail.completed_at,
last_source: source,
last_detail_digest: detail.detail_digest,
single_tap_approve_risk: detail.template_type === "leave" || detail.template_type === "missed_punch",
readability_status: "PASS_READABLE",
source_summary: {
source,
spNoPrefix: maskApprovalIdentifier(detail.sp_no),
templateIdPrefix: maskApprovalIdentifier(detail.template_id),
},
};
const statusData = shouldAdvance ? {
normalized_status: detail.normalized_status,
wecom_status: detail.wecom_status,
status_observed_at: detail.status_observed_at,
} : {};
return tx.wecomApprovalArchive.upsert({
where: { corp_id_hash_sp_no: { corp_id_hash, sp_no: detail.sp_no } },
update: {
...baseData,
...statusData,
last_compensation_at: source === "compensation_pull" ? new Date() : existing?.last_compensation_at,
last_callback_at: source === "callback" ? new Date() : existing?.last_callback_at,
},
create: {
corp_id_hash,
sp_no: detail.sp_no,
...baseData,
normalized_status: detail.normalized_status,
wecom_status: detail.wecom_status,
status_observed_at: detail.status_observed_at,
last_compensation_at: source === "compensation_pull" ? new Date() : null,
last_callback_at: source === "callback" ? new Date() : null,
},
});
}
function applyDefined(target, key, value, predicate = (item) => item != null) {
if (predicate(value)) target[key] = value;
}
async function upsertApprovalArchiveFromCallback(tx, config, detail) {
const corp_id_hash = corpHash(config);
const existing = await tx.wecomApprovalArchive.findUnique({
where: { corp_id_hash_sp_no: { corp_id_hash, sp_no: detail.sp_no } },
});
const shouldAdvance = shouldAdvanceStatus(existing, detail);
const updateData = {
last_source: "callback",
last_callback_at: new Date(),
source_summary: {
source: "callback",
spNoPrefix: maskApprovalIdentifier(detail.sp_no),
templateIdPrefix: maskApprovalIdentifier(detail.template_id),
},
};
if (shouldAdvance) {
updateData.normalized_status = detail.normalized_status;
updateData.wecom_status = detail.wecom_status;
updateData.status_observed_at = detail.status_observed_at;
}
applyDefined(updateData, "template_id", detail.template_id);
applyDefined(updateData, "template_type", detail.template_type, (item) => item && item !== "unknown");
applyDefined(updateData, "sp_name", detail.sp_name);
applyDefined(updateData, "submitted_at", detail.submitted_at);
applyDefined(updateData, "applied_at", detail.applied_at);
applyDefined(updateData, "completed_at", detail.completed_at);
applyDefined(updateData, "applicant_external_user_id", detail.applicant_external_user_id);
if (detail.applicant_external_user_id) {
updateData.applicant_employee_id = await mapEmployeeIdByWecomUserId(tx, detail.applicant_external_user_id);
}
return tx.wecomApprovalArchive.upsert({
where: { corp_id_hash_sp_no: { corp_id_hash, sp_no: detail.sp_no } },
update: updateData,
create: {
corp_id_hash,
sp_no: detail.sp_no,
template_id: detail.template_id,
template_type: detail.template_type,
sp_name: detail.sp_name,
normalized_status: detail.normalized_status,
wecom_status: detail.wecom_status,
applicant_employee_id: detail.applicant_external_user_id
? await mapEmployeeIdByWecomUserId(tx, detail.applicant_external_user_id)
: null,
applicant_external_user_id: detail.applicant_external_user_id,
submitted_at: detail.submitted_at,
applied_at: detail.applied_at,
completed_at: detail.completed_at,
status_observed_at: detail.status_observed_at,
last_callback_at: new Date(),
last_source: "callback",
last_detail_digest: null,
single_tap_approve_risk: detail.template_type === "leave" || detail.template_type === "missed_punch",
readability_status: detail.template_type === "leave" || detail.template_type === "missed_punch" ? "PASS_READABLE" : null,
source_summary: {
source: "callback",
spNoPrefix: maskApprovalIdentifier(detail.sp_no),
templateIdPrefix: maskApprovalIdentifier(detail.template_id),
},
},
});
}
async function persistApprovalDetail({ prisma, config, payload, spNo, target, source = "compensation_pull", compensationRunId = null }) {
if (!prisma) throw new Error("Prisma client is required for WeCom approval archive persistence");
const detail = normalizeApprovalDetailPayload(payload, { spNo, target, source });
if (!detail.sp_no) throw new Error("WeCom approval detail missing sp_no");
return prisma.$transaction(async (tx) => {
const archive = await upsertApprovalArchive(tx, config, detail, source);
await replaceArchiveChildren(tx, archive.id, detail);
if (compensationRunId) {
await tx.wecomApprovalCompensationItem.upsert({
where: {
run_id_sp_no: {
run_id: compensationRunId,
sp_no: detail.sp_no,
},
},
update: {
archive_id: archive.id,
template_id: detail.template_id,
template_type: detail.template_type,
normalized_status: detail.normalized_status,
detail_status: "stored",
detail_digest: detail.detail_digest,
source_summary: publicArchiveSummary(detail),
},
create: {
run_id: compensationRunId,
archive_id: archive.id,
sp_no: detail.sp_no,
template_id: detail.template_id,
template_type: detail.template_type,
normalized_status: detail.normalized_status,
detail_status: "stored",
detail_digest: detail.detail_digest,
source_summary: publicArchiveSummary(detail),
},
});
}
return publicArchiveSummary({ ...detail, id: archive.id, last_source: source });
});
}
function callbackDetailFromSummary(summary) {
return {
sp_no: toSafeString(summary?.spNo, 160),
template_id: toSafeString(summary?.templateId, 160) || null,
template_type: summary?.approvalType || "unknown",
sp_name: null,
normalized_status: normalizeWecomApprovalStatus(summary?.spStatus),
wecom_status: summary?.spStatus || null,
applicant_external_user_id: null,
submitted_at: parseWecomTime(summary?.applyTime),
applied_at: parseWecomTime(summary?.applyTime),
completed_at: isTerminalStatus(normalizeWecomApprovalStatus(summary?.spStatus)) ? parseWecomTime(summary?.createTime) || new Date() : null,
status_observed_at: parseWecomTime(summary?.createTime) || new Date(),
detail_digest: digestObject({
sp_no: summary?.spNo,
template_id: summary?.templateId,
status: summary?.spStatus,
event: summary?.eventType,
createTime: summary?.createTime,
}),
fields: [],
participants: [],
actions: [{
action_type: actionTypeFromStatus(summary?.spStatus),
operator_external_user_id: null,
action_time: parseWecomTime(summary?.createTime) || new Date(),
node_index: 0,
comment_summary: null,
source: "callback",
idempotency_key: summary?.idempotencyKey || digestObject(["callback", summary?.spNo, summary?.spStatus]).slice(0, 64),
evidence_digest: summary?.idempotencyKey || digestObject(["callback", summary?.spNo, summary?.spStatus]).slice(0, 64),
}],
};
}
async function recordApprovalCallbackEvent({ prisma, config, summary }) {
if (!prisma) throw new Error("Prisma client is required for WeCom callback archive persistence");
const detail = callbackDetailFromSummary(summary);
if (!detail.sp_no) throw new Error("WeCom callback missing full sp_no for archive persistence");
const corp_id_hash = corpHash(config);
const eventDigest = digestObject({
eventType: summary.eventType,
callbackKind: summary.callbackKind,
spNo: detail.sp_no,
templateId: detail.template_id,
spStatus: summary.spStatus,
createTime: summary.createTime,
});
return prisma.$transaction(async (tx) => {
const archive = await upsertApprovalArchiveFromCallback(tx, config, detail);
await upsertArchiveActions(tx, archive.id, detail.actions);
const event = await tx.wecomApprovalCallbackEvent.upsert({
where: { idempotency_key: summary.idempotencyKey },
update: {
retry_count: { increment: 1 },
archive_id: archive.id,
processing_status: "accepted",
},
create: {
archive_id: archive.id,
corp_id_hash,
sp_no: detail.sp_no,
template_id: detail.template_id,
event_type: summary.eventType || "unknown",
callback_kind: summary.callbackKind || "unknown_event",
approval_type: detail.template_type,
normalized_status: detail.normalized_status,
wecom_status: detail.wecom_status,
event_time: parseWecomTime(summary.createTime),
apply_time: parseWecomTime(summary.applyTime),
processing_status: "accepted",
idempotency_key: summary.idempotencyKey,
event_digest: eventDigest,
source_summary: {
spNoPrefix: maskApprovalIdentifier(detail.sp_no),
templateIdPrefix: maskApprovalIdentifier(detail.template_id),
statusLabel: summary.statusLabel,
},
},
});
return {
recorded: event.retry_count === 0,
duplicate: event.retry_count > 0,
archiveId: archive.id,
idempotencyKey: summary.idempotencyKey,
spNoPrefix: maskApprovalIdentifier(detail.sp_no),
};
});
}
async function createCompensationRun({ prisma, timeWindow, inputs, options }) {
if (!prisma) throw new Error("Prisma client is required for compensation run persistence");
const runKey = digestObject({
startTime: timeWindow.startTime,
endTime: timeWindow.endTime,
templates: inputs?.map((item) => [item.key, item.templateIdPrefix]),
size: options?.size,
}).slice(0, 64);
return prisma.wecomApprovalCompensationRun.create({
data: {
run_key: runKey,
mode: options?.live ? "live_read" : "dry_run",
status: "running",
start_time: new Date(timeWindow.startTime * 1000),
end_time: new Date(timeWindow.endTime * 1000),
template_filter: inputs?.map((item) => ({
key: item.key,
templateIdPrefix: item.templateIdPrefix,
configuredFrom: item.templateSource,
})) || [],
page_size: options?.size || 100,
},
});
}
async function finishCompensationRun({ prisma, runId, status = "completed", pageCount = 0, itemCount = 0, detailCount = 0, findingCount = 0, errorCode = null, errorMessage = null }) {
if (!prisma || !runId) return null;
return prisma.wecomApprovalCompensationRun.update({
where: { id: runId },
data: {
status,
page_count: pageCount,
item_count: itemCount,
detail_count: detailCount,
finding_count: findingCount,
error_code: errorCode,
error_message: errorMessage,
finished_at: new Date(),
},
});
}
async function recordReconcileFinding({ prisma, runId = null, archiveId = null, findingType, source = "compensation_compare", description = "", severity = "info", evidence = {} }) {
if (!prisma || !findingType) return null;
const evidenceDigest = digestObject({ findingType, archiveId, runId, evidence }).slice(0, 64);
const data = {
archive_id: archiveId,
run_id: runId,
finding_type: findingType,
status: "open",
severity,
evidence_digest: evidenceDigest,
source,
description: toSafeString(description, 1000) || null,
};
if (runId) {
return prisma.wecomApprovalReconcileFinding.upsert({
where: {
run_id_finding_type_evidence_digest: {
run_id: runId,
finding_type: findingType,
evidence_digest: evidenceDigest,
},
},
update: {
status: "open",
severity,
source,
description: data.description,
archive_id: archiveId,
},
create: data,
});
}
if (archiveId) {
return prisma.wecomApprovalReconcileFinding.upsert({
where: {
archive_id_finding_type_evidence_digest: {
archive_id: archiveId,
finding_type: findingType,
evidence_digest: evidenceDigest,
},
},
update: {
status: "open",
severity,
source,
description: data.description,
run_id: runId,
},
create: data,
});
}
return prisma.wecomApprovalReconcileFinding.create({ data });
}
module.exports = {
corpHash,
createCompensationRun,
digestObject,
finishCompensationRun,
isTerminalStatus,
normalizeApprovalDetailPayload,
normalizeWecomApprovalStatus,
persistApprovalDetail,
publicArchiveSummary,
recordApprovalCallbackEvent,
recordReconcileFinding,
};
@@ -280,7 +280,7 @@ function normalizeApprovalCallbackEvent(plainXml) {
applyTime,
]);
return {
const summary = {
eventType: eventType || "unknown",
msgType: msgType || "unknown",
approvalType,
@@ -294,6 +294,12 @@ function normalizeApprovalCallbackEvent(plainXml) {
callbackKind: CALLBACK_EVENT_TYPES.has(eventType) ? "approval_status_change" : "unknown_event",
idempotencyKey,
};
Object.defineProperties(summary, {
spNo: { value: spNo || "", enumerable: false },
templateId: { value: templateId || "", enumerable: false },
agentId: { value: agentId || "", enumerable: false },
});
return summary;
}
function validateCallbackConfig(config) {
@@ -10,7 +10,7 @@ const TOKEN = "callback-token";
const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
const CORP_ID = "ww-callback-corp";
async function buildApp({ config = {}, evidenceStore } = {}) {
async function buildApp({ config = {}, evidenceStore, prisma } = {}) {
const app = Fastify();
app.decorate("config", {
wecomCorpId: CORP_ID,
@@ -18,6 +18,9 @@ async function buildApp({ config = {}, evidenceStore } = {}) {
wecomCallbackEncodingAesKey: AES_KEY,
...config,
});
if (prisma) {
app.decorate("prisma", prisma);
}
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api", evidenceStore });
return app;
}
@@ -106,6 +109,61 @@ describe("wecomApprovalCallback routes", () => {
await app.close();
});
it("uses the default DB evidence store when prisma is decorated", async () => {
const prisma = buildArchiveMockPrisma();
const app = await buildApp({ prisma });
const sample = buildEncryptedApprovalEventSample({
token: TOKEN,
encodingAesKey: AES_KEY,
receiveId: CORP_ID,
timestamp: "1770000000",
nonce: "nonce",
spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK",
templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK",
});
const response = await app.inject({
method: "POST",
url: `/api/wecom/approval/callback?${urlQuery(sample)}`,
headers: { "content-type": "text/xml" },
payload: sample.encryptedXml,
});
expect(response.statusCode).toBe(200);
expect(prisma.wecomApprovalCallbackEvent.upsert).toHaveBeenCalledTimes(1);
expect(prisma.wecomApprovalArchive.upsert.mock.calls[0][0].create.sp_no).toBe("SPFULLIDENTIFIERSHOULDNOTLEAK");
await app.close();
});
it("returns non-200 when default DB evidence persistence fails so WeCom can retry", async () => {
const prisma = {
$transaction: vi.fn(async () => {
throw new Error("db unavailable");
}),
};
const app = await buildApp({ prisma });
const sample = buildEncryptedApprovalEventSample({
token: TOKEN,
encodingAesKey: AES_KEY,
receiveId: CORP_ID,
timestamp: "1770000000",
nonce: "nonce",
spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK",
templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK",
});
const response = await app.inject({
method: "POST",
url: `/api/wecom/approval/callback?${urlQuery(sample)}`,
headers: { "content-type": "text/xml" },
payload: sample.encryptedXml,
});
expect(response.statusCode).toBe(500);
expect(response.body).toBe("callback error");
await app.close();
});
it("reports missing callback config without treating it as pass", async () => {
const app = await buildApp({ config: { wecomCallbackToken: "" } });
const response = await app.inject({
@@ -118,3 +176,31 @@ describe("wecomApprovalCallback routes", () => {
await app.close();
});
});
function buildArchiveMockPrisma() {
const prisma = {
wecomIdentityBinding: {
findUnique: vi.fn(async () => null),
},
wecomApprovalArchive: {
findUnique: vi.fn(async () => null),
upsert: vi.fn(async (args) => ({ id: "archive-1", ...args.create })),
},
wecomApprovalFieldSummary: {
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
wecomApprovalParticipant: {
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
wecomApprovalAction: {
upsert: vi.fn(async () => ({ id: "action-1" })),
},
wecomApprovalCallbackEvent: {
upsert: vi.fn(async () => ({ id: "event-1", retry_count: 0 })),
},
};
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
return prisma;
}
@@ -0,0 +1,164 @@
const {
executeArchiveCheck,
parseArgs,
sanitizeArchiveRow,
} = require("../../../src/scripts/check-wecom-approval-archive");
const { maskApprovalIdentifier } = require("../../../src/services/integrations/wecomApproval");
const SENSITIVE_SP_NO = "SPARCHIVEREADFULLIDENTIFIERSHOULDNOTLEAK";
const SENSITIVE_TEMPLATE_ID = "TPLARCHIVEREADFULLIDENTIFIERSHOULDNOTLEAK";
const SENSITIVE_USER_ID = "archive_read_userid_full_should_not_leak";
const SENSITIVE_REASON = "原因全文不得进入离线报告";
function archiveRow(overrides = {}) {
return {
id: "archive-1",
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
template_type: "leave",
normalized_status: "approved",
submitted_at: new Date("2026-05-19T01:00:00.000Z"),
completed_at: new Date("2026-05-19T02:00:00.000Z"),
status_observed_at: new Date("2026-05-19T02:00:00.000Z"),
last_source: "compensation_pull",
readability_status: "PASS_READABLE",
single_tap_approve_risk: true,
template_adjustment_required: false,
participants: [{
role: "applicant",
node_index: 0,
external_user_id: SENSITIVE_USER_ID,
mapping_status: "mapped_or_external",
is_current: false,
}],
actions: [{
action_type: "approve",
operator_external_user_id: "approver_full_userid_should_not_leak",
action_time: new Date("2026-05-19T02:00:00.000Z"),
node_index: 1,
source: "compensation_pull",
comment_summary: SENSITIVE_REASON,
}],
field_summaries: [{
field_name: "请假原因",
field_type: "Textarea",
sensitive_level: "restricted",
display_allowed: false,
normalized_value: SENSITIVE_REASON,
}],
reconcile_findings: [],
_count: {
participants: 1,
actions: 1,
field_summaries: 1,
attachment_summaries: 0,
callback_events: 1,
compensation_items: 1,
reconcile_findings: 0,
},
...overrides,
};
}
describe("check-wecom-approval-archive", () => {
it("parses offline archive filters", () => {
const options = parseArgs([
"--offline",
"--format=json",
"--template=leave",
"--status=approved",
"--limit=50",
]);
expect(options).toMatchObject({
offline: true,
format: "json",
templates: ["leave"],
status: "approved",
limit: 50,
});
expect(() => parseArgs(["--template=outing"])).toThrow(/Unsupported --template/);
expect(() => parseArgs(["--limit=101"])).toThrow(/--limit/);
});
it("sanitizes archive rows without leaking full identifiers or restricted values", () => {
const sanitized = sanitizeArchiveRow(archiveRow());
const serialized = JSON.stringify(sanitized);
expect(sanitized.spNoPrefix).toBe(maskApprovalIdentifier(SENSITIVE_SP_NO));
expect(sanitized.templateIdPrefix).toBe(maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID));
expect(sanitized.participants[0].userIdPrefix).toBe(maskApprovalIdentifier(SENSITIVE_USER_ID));
expect(sanitized.fieldSummaries[0]).toMatchObject({
fieldName: null,
fieldType: "Textarea",
sensitiveLevel: "restricted",
displayAllowed: false,
});
expect(serialized).not.toContain(SENSITIVE_SP_NO);
expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID);
expect(serialized).not.toContain(SENSITIVE_USER_ID);
expect(serialized).not.toContain(SENSITIVE_REASON);
});
it("passes when OA DB archived history can be read offline", async () => {
const report = await executeArchiveCheck(parseArgs(["--offline", "--format=json"]), {
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: {},
readArchiveRowsImpl: vi.fn(async () => [archiveRow()]),
});
expect(report.status).toBe("PASS_ARCHIVE_HISTORY_READABLE");
expect(report.scope.wecomApiCalls).toBe(false);
expect(report.checks.archiveRead.status).toBe("OK");
expect(report.checks.archiveRead.count).toBe(1);
});
it("returns PASS_WITH_FINDINGS when open reconcile findings exist", async () => {
const report = await executeArchiveCheck(parseArgs(["--offline"]), {
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: {},
readArchiveRowsImpl: vi.fn(async () => [archiveRow({
reconcile_findings: [{
finding_type: "callback_missing_for_pulled_detail",
status: "open",
severity: "info",
source: "compensation_compare",
detected_at: new Date("2026-05-19T03:00:00.000Z"),
}],
})]),
});
expect(report.status).toBe("PASS_WITH_FINDINGS");
expect(report.checks.archives[0].openFindingCounts).toMatchObject({
callback_missing_for_pulled_detail: 1,
});
});
it("returns PARTIAL_ARCHIVE_NEEDS_DATA when no archive rows exist", async () => {
const report = await executeArchiveCheck(parseArgs(["--offline"]), {
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: {},
readArchiveRowsImpl: vi.fn(async () => []),
});
expect(report.status).toBe("PARTIAL_ARCHIVE_NEEDS_DATA");
expect(report.configGaps.map((item) => item.id)).toContain("archive_history_data");
});
it("classifies DB read errors as external blockers", async () => {
const report = await executeArchiveCheck(parseArgs(["--offline"]), {
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: {},
readArchiveRowsImpl: vi.fn(async () => {
throw new Error("database down with secret-like-value-should-be-redacted");
}),
});
expect(report.status).toBe("SKIP");
expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG");
expect(report.mainBlockers[0]).toMatchObject({
id: "archive_db_read",
reason: "oa_archive_db_read_failed",
});
});
});
@@ -98,12 +98,14 @@ describe("check-wecom-approval-compensation-poc", () => {
expect(options.size).toBe(50);
expect(options.discoverTemplateIds).toBe(true);
expect(options.discoverApplicantEmployeeNo).toBe("SMOKE002");
expect(options.persistToDb).toBe(false);
expect(options.templates).toEqual({
leave: "tpl-leave",
missed_punch: "tpl-missed",
});
expect(() => parseArgs(["--template=outing:tpl"])).toThrow(/unsupported target/);
expect(() => parseArgs(["--size=101"])).toThrow(/--size/);
expect(parseArgs(["--persist-to-db"]).persistToDb).toBe(true);
});
it("builds a default 72-hour time window", () => {
@@ -396,6 +398,195 @@ describe("check-wecom-approval-compensation-poc", () => {
expect(fetchApprovalInfoListImpl).toHaveBeenCalledTimes(2);
expect(fetchApprovalDetailImpl).toHaveBeenCalledTimes(2);
expect(report.checks.callbackCompare.findings).toEqual([]);
expect(fetchApprovalDetailImpl.mock.calls.every((call) => call[0].includeRawPayload === false)).toBe(true);
expect(report.checks.archivePersistence.status).toBe("SKIPPED");
});
it("requires both --persist-to-db and WECOM_ARCHIVE_WRITE=1 before OA archive writes", async () => {
const report = await executeCompensationPoc(baseOptions({
live: true,
persistToDb: true,
}), {
config: baseConfig(),
env: {
DATABASE_URL: "postgres://example",
},
generatedAt: "2026-05-20T00:00:00.000Z",
fetchApprovalInfoListImpl: vi.fn(async () => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
response: { errcode: 0, spNoCount: 0 },
spNos: [],
nextCursor: "",
})),
fetchApprovalDetailImpl: vi.fn(),
readCallbackEvidenceImpl: vi.fn(async () => []),
});
expect(report.status).toBe("PARTIAL_COMPENSATION_NEEDS_CONFIG");
expect(report.checks.archivePersistence).toMatchObject({
status: "NEEDS_CONFIG",
reason: "missing_archive_write_switch",
});
expect(report.configGaps.map((item) => item.id)).toContain("archive_persistence");
});
it("persists pulled details to OA archive only when the double switch is present", async () => {
const sensitiveLeaveSpNo = `${SENSITIVE_TEMPLATE_ID}-sp`;
const sensitiveMissedSpNo = "tpl-missed-punch-sp";
const mockPrisma = buildArchiveMockPrisma();
mockPrisma.wecomApprovalCallbackEvent.findMany.mockResolvedValueOnce([
{
callback_kind: "approval_status_change",
approval_type: "leave",
wecom_status: "2",
sp_no: sensitiveLeaveSpNo,
template_id: SENSITIVE_TEMPLATE_ID,
received_at: new Date("2026-05-20T00:00:00.000Z"),
},
{
callback_kind: "approval_status_change",
approval_type: "missed_punch",
wecom_status: "2",
sp_no: sensitiveMissedSpNo,
template_id: "tpl-missed-punch",
received_at: new Date("2026-05-20T00:00:01.000Z"),
},
]);
const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
response: {
errcode: 0,
spNoCount: 1,
spNoPrefixes: [maskApprovalIdentifier(`${filters[0].value}-sp`)],
},
spNos: [`${filters[0].value}-sp`],
nextCursor: "",
}));
const fetchApprovalDetailImpl = vi.fn(async ({ spNo, includeRawPayload }) => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
summary: detailSummary(spNo, {
spStatus: "2",
templateIdPrefix: spNo === sensitiveLeaveSpNo
? maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID)
: maskApprovalIdentifier("tpl-missed-punch"),
}),
rawPayload: includeRawPayload ? {
errcode: 0,
info: {
sp_no: spNo,
template_id: spNo === sensitiveLeaveSpNo ? SENSITIVE_TEMPLATE_ID : "tpl-missed-punch",
sp_name: spNo === sensitiveLeaveSpNo ? "OA-POC-请假" : "OA-POC-补卡",
sp_status: "2",
apply_time: "1779148800",
applyer: { userid: SENSITIVE_USER_ID },
apply_data: { contents: [] },
sp_record: [],
},
} : undefined,
response: { errcode: 0, spStatus: "2" },
}));
const readCallbackEvidenceImpl = vi.fn(async () => []);
const report = await executeCompensationPoc(baseOptions({
live: true,
persistToDb: true,
}), {
config: baseConfig(),
env: {
DATABASE_URL: "postgres://example",
WECOM_ARCHIVE_WRITE: "1",
},
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: mockPrisma,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
readCallbackEvidenceImpl,
});
expect(report.status).toBe("PASS_COMPENSATION_POC");
expect(report.checks.callbackEvidence.source).toBe("db");
expect(readCallbackEvidenceImpl).not.toHaveBeenCalled();
expect(fetchApprovalDetailImpl.mock.calls.every((call) => call[0].includeRawPayload === true)).toBe(true);
expect(mockPrisma.wecomApprovalCompensationRun.create).toHaveBeenCalledTimes(1);
expect(mockPrisma.wecomApprovalArchive.upsert).toHaveBeenCalledTimes(2);
expect(mockPrisma.wecomApprovalCompensationRun.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: "run-archive-1" },
data: expect.objectContaining({
status: "completed",
item_count: 2,
detail_count: 2,
}),
}));
expect(report.checks.archivePersistence).toMatchObject({
status: "OK",
storedCount: 2,
failedCount: 0,
});
const serialized = JSON.stringify(report);
expect(serialized).not.toContain(SENSITIVE_SP_NO);
expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID);
expect(serialized).not.toContain(SENSITIVE_USER_ID);
});
it("records compare findings on the correct archive when masked prefixes collide", async () => {
const leaveSpNo = "20260519-leave-approval-full-id";
const missedSpNo = "20260519-missed-punch-sp";
const mockPrisma = buildArchiveMockPrisma();
const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
response: { errcode: 0, spNoCount: 1 },
spNos: [filters[0].value === SENSITIVE_TEMPLATE_ID ? leaveSpNo : missedSpNo],
nextCursor: "",
}));
const fetchApprovalDetailImpl = vi.fn(async ({ spNo, includeRawPayload }) => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
summary: detailSummary(spNo, { spStatus: "2" }),
rawPayload: includeRawPayload ? {
errcode: 0,
info: {
sp_no: spNo,
template_id: spNo === leaveSpNo ? SENSITIVE_TEMPLATE_ID : "tpl-missed-punch",
sp_name: spNo === leaveSpNo ? "OA-POC-请假" : "OA-POC-补卡",
sp_status: "2",
apply_time: "1779148800",
applyer: { userid: SENSITIVE_USER_ID },
apply_data: { contents: [] },
sp_record: [],
},
} : undefined,
response: { errcode: 0, spStatus: "2" },
}));
const report = await executeCompensationPoc(baseOptions({
live: true,
persistToDb: true,
}), {
config: baseConfig(),
env: {
DATABASE_URL: "postgres://example",
WECOM_ARCHIVE_WRITE: "1",
},
generatedAt: "2026-05-20T00:00:00.000Z",
prisma: mockPrisma,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
readCallbackEvidenceImpl: vi.fn(async () => []),
});
expect(report.status).toBe("PASS_WITH_FINDINGS");
expect(report.checks.callbackCompare.findings).toHaveLength(2);
expect(report.checks.callbackCompare.findings.every((item) => item.spNoPrefix === "20260519***")).toBe(true);
const archiveIds = mockPrisma.wecomApprovalReconcileFinding.upsert.mock.calls
.map((call) => call[0].create.archive_id)
.sort();
expect(archiveIds).toEqual(["archive-leave", "archive-missed"]);
const serialized = JSON.stringify(report);
expect(serialized).not.toContain(leaveSpNo);
expect(serialized).not.toContain(missedSpNo);
});
it("returns PASS_WITH_FINDINGS for missing callback and status mismatch", async () => {
@@ -561,3 +752,47 @@ describe("check-wecom-approval-compensation-poc", () => {
expect(statusLabelFromSpStatus("2")).toBe("已通过");
});
});
function buildArchiveMockPrisma() {
const prisma = {
wecomIdentityBinding: {
findUnique: vi.fn(async () => null),
},
wecomApprovalCompensationRun: {
create: vi.fn(async () => ({
id: "run-archive-1",
started_at: new Date("2026-05-20T00:00:00.000Z"),
})),
update: vi.fn(async (args) => ({ id: args.where.id, ...args.data })),
},
wecomApprovalArchive: {
findUnique: vi.fn(async () => null),
upsert: vi.fn(async (args) => ({
id: args.create.sp_no.endsWith("missed-punch-sp") ? "archive-missed" : "archive-leave",
...args.create,
})),
},
wecomApprovalFieldSummary: {
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
wecomApprovalParticipant: {
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
wecomApprovalAction: {
upsert: vi.fn(async () => ({ id: "action-archive" })),
},
wecomApprovalCompensationItem: {
upsert: vi.fn(async () => ({ id: "item-archive" })),
},
wecomApprovalCallbackEvent: {
findMany: vi.fn(async () => []),
},
wecomApprovalReconcileFinding: {
upsert: vi.fn(async () => ({ id: "finding-archive" })),
},
};
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
return prisma;
}
@@ -0,0 +1,245 @@
const {
normalizeApprovalDetailPayload,
persistApprovalDetail,
publicArchiveSummary,
recordApprovalCallbackEvent,
recordReconcileFinding,
} = require("../../../src/services/integrations/wecomApprovalArchive");
const { maskApprovalIdentifier } = require("../../../src/services/integrations/wecomApproval");
const {
buildEncryptedApprovalEventSample,
decryptWecomMessage,
extractEncryptedFromXml,
normalizeApprovalCallbackEvent,
} = require("../../../src/services/integrations/wecomCallbackCrypto");
const SENSITIVE_SP_NO = "SPARCHIVEFULLIDENTIFIERSHOULDNOTLEAK";
const SENSITIVE_TEMPLATE_ID = "TPLARCHIVEFULLIDENTIFIERSHOULDNOTLEAK";
const SENSITIVE_USER_ID = "wecom_archive_userid_full_should_not_leak";
const SENSITIVE_REASON = "这里是请假原因摘要,允许入库但默认报告不得输出全文";
const TOKEN = "callback-token";
const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
const CORP_ID = "ww-archive-corp";
function approvalPayload(overrides = {}) {
return {
errcode: 0,
info: {
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
sp_name: "OA-POC-请假-2026-05-19",
sp_status: "2",
apply_time: "1779148800",
applyer: { userid: SENSITIVE_USER_ID },
apply_data: {
contents: [
{ id: "leave_type", control: "Text", title: "请假类型", value: "事假" },
{ id: "reason", control: "Textarea", title: "请假原因", value: SENSITIVE_REASON },
],
},
sp_record: [{
details: [{
userid: "approver_full_userid_should_not_leak",
sp_status: "2",
speech_time: "1779149800",
speech: "同意",
}],
}],
...overrides,
},
};
}
function buildMockPrisma() {
const prisma = {
wecomIdentityBinding: {
findUnique: vi.fn(async () => null),
},
wecomApprovalArchive: {
findUnique: vi.fn(async () => null),
upsert: vi.fn(async (args) => ({
id: "archive-1",
...args.create,
})),
},
wecomApprovalFieldSummary: {
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
wecomApprovalParticipant: {
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
},
wecomApprovalAction: {
upsert: vi.fn(async () => ({ id: "action-1" })),
},
wecomApprovalCompensationItem: {
upsert: vi.fn(async () => ({ id: "item-1" })),
},
wecomApprovalCallbackEvent: {
upsert: vi.fn(async () => ({ id: "event-1", retry_count: 0 })),
},
wecomApprovalReconcileFinding: {
upsert: vi.fn(async () => ({ id: "finding-1" })),
create: vi.fn(async () => ({ id: "finding-1" })),
},
};
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
return prisma;
}
describe("wecomApprovalArchive", () => {
it("normalizes approval detail while marking reason fields restricted", () => {
const detail = normalizeApprovalDetailPayload(approvalPayload(), {
source: "compensation_pull",
target: "leave",
});
expect(detail).toMatchObject({
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
template_type: "leave",
normalized_status: "approved",
applicant_external_user_id: SENSITIVE_USER_ID,
});
expect(detail.fields).toHaveLength(2);
expect(detail.fields.find((item) => item.field_key.startsWith("reason"))).toMatchObject({
sensitive_level: "restricted",
display_allowed: false,
});
expect(detail.detail_digest).toMatch(/^[0-9a-f]{64}$/);
});
it("returns only masked public archive summaries", () => {
const summary = publicArchiveSummary({
id: "archive-1",
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
template_type: "leave",
normalized_status: "approved",
last_source: "compensation_pull",
});
expect(summary).toEqual({
archiveId: "archive-1",
spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO),
templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID),
templateType: "leave",
normalizedStatus: "approved",
source: "compensation_pull",
});
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_SP_NO);
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_TEMPLATE_ID);
});
it("persists full keys internally but returns redacted output", async () => {
const prisma = buildMockPrisma();
const result = await persistApprovalDetail({
prisma,
config: { wecomCorpId: CORP_ID },
payload: approvalPayload(),
spNo: SENSITIVE_SP_NO,
target: "leave",
source: "compensation_pull",
compensationRunId: "run-1",
});
expect(prisma.wecomApprovalArchive.upsert).toHaveBeenCalled();
expect(prisma.wecomApprovalArchive.upsert.mock.calls[0][0].create.sp_no).toBe(SENSITIVE_SP_NO);
expect(prisma.wecomApprovalFieldSummary.createMany.mock.calls[0][0].data.some((item) => item.normalized_value === SENSITIVE_REASON)).toBe(true);
expect(result.spNoPrefix).toBe(maskApprovalIdentifier(SENSITIVE_SP_NO));
expect(JSON.stringify(result)).not.toContain(SENSITIVE_SP_NO);
expect(JSON.stringify(result)).not.toContain(SENSITIVE_TEMPLATE_ID);
expect(JSON.stringify(result)).not.toContain(SENSITIVE_REASON);
});
it("keeps callback full identifiers non-enumerable for internal persistence", () => {
const sample = buildEncryptedApprovalEventSample({
token: TOKEN,
encodingAesKey: AES_KEY,
receiveId: CORP_ID,
timestamp: "1770000000",
nonce: "nonce",
spNo: SENSITIVE_SP_NO,
templateId: SENSITIVE_TEMPLATE_ID,
});
const decrypted = decryptWecomMessage({
encrypted: extractEncryptedFromXml(sample.encryptedXml),
encodingAesKey: AES_KEY,
receiveId: CORP_ID,
});
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
expect(summary.spNo).toBe(SENSITIVE_SP_NO);
expect(summary.templateId).toBe(SENSITIVE_TEMPLATE_ID);
expect(Object.keys(summary)).not.toContain("spNo");
expect(Object.keys(summary)).not.toContain("templateId");
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_SP_NO);
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_TEMPLATE_ID);
});
it("does not erase detail children when recording callback evidence", async () => {
const prisma = buildMockPrisma();
prisma.wecomApprovalArchive.findUnique.mockResolvedValueOnce({
id: "archive-1",
corp_id_hash: "hash",
sp_no: SENSITIVE_SP_NO,
template_id: SENSITIVE_TEMPLATE_ID,
template_type: "leave",
applicant_external_user_id: SENSITIVE_USER_ID,
normalized_status: "approved",
status_observed_at: new Date("2026-05-19T10:00:00.000Z"),
last_detail_digest: "existing-detail-digest",
});
const sample = buildEncryptedApprovalEventSample({
token: TOKEN,
encodingAesKey: AES_KEY,
receiveId: CORP_ID,
timestamp: "1770000000",
nonce: "nonce",
spNo: SENSITIVE_SP_NO,
templateId: SENSITIVE_TEMPLATE_ID,
});
const decrypted = decryptWecomMessage({
encrypted: extractEncryptedFromXml(sample.encryptedXml),
encodingAesKey: AES_KEY,
receiveId: CORP_ID,
});
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
await recordApprovalCallbackEvent({
prisma,
config: { wecomCorpId: CORP_ID },
summary,
});
expect(prisma.wecomApprovalFieldSummary.deleteMany).not.toHaveBeenCalled();
expect(prisma.wecomApprovalParticipant.deleteMany).not.toHaveBeenCalled();
expect(prisma.wecomApprovalAction.upsert).toHaveBeenCalledTimes(1);
const update = prisma.wecomApprovalArchive.upsert.mock.calls[0][0].update;
expect(update).not.toHaveProperty("applicant_external_user_id", null);
expect(update).not.toHaveProperty("last_detail_digest");
expect(update.last_callback_at).toBeInstanceOf(Date);
});
it("upserts run-scoped reconcile findings without requiring archive id", async () => {
const prisma = buildMockPrisma();
await recordReconcileFinding({
prisma,
runId: "run-1",
archiveId: null,
findingType: "callback_missing_for_pulled_detail",
evidence: { spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO) },
});
expect(prisma.wecomApprovalReconcileFinding.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: {
run_id_finding_type_evidence_digest: expect.objectContaining({
run_id: "run-1",
finding_type: "callback_missing_for_pulled_detail",
}),
},
}));
});
});