WEXT1F 增加正式模板外出加班取证门禁

这个提交包含在:
2026-05-22 15:08:04 +08:00
父节点 91dbeadb02
当前提交 234fc29534
共修改 14 个文件,包含 744 行新增和 17 行删除
+1 -1
查看文件
@@ -23,7 +23,7 @@ Read only what is relevant:
- WROUTE2 locks the post-WFULLSMOKE1 route: first close out test-account evidence, then ask for WREMOTE1/WFULLSMOKE2 remote closeout and WEXT1-FORMAL formal-template outing+overtime manual test, then ask WREAL1 with exactly 1 real employee + 1 approver/admin observer, then prioritize `wecom_attendance_approval_mirror_v1` minimum complete mirror. WTCLOSE1 adds the small-team execution rule: because the company has fewer than 10 people, WAPPR1 targets option A for leave/missed-punch/outing/overtime; formal HR/attendance approvals use one direct manager or configured approval role with admin fallback/blocking when the approver chain is missing, unavailable, invisible, unbound, or self-approval. WRULE1 enforces this as an OA-side validator over archived WeCom approval details; `PASS_RULE_MATCH` or explainable `PASS_WITH_ADMIN_FALLBACK` can support test-account closeout, while `FAIL_APPLICANT_CAN_CHOOSE`, `FAIL_SELF_APPROVAL`, `FAIL_APPROVER_MISMATCH`, missing manual gates, or external config blockers stop WEXT1-FORMAL/WREAL1. WTCLOSE1 only authorizes WAUTO1/WFULLSMOKE1 aggregation and WEXT1LD dry-run; WTCLOSE2 only performs final test-account evidence closeout. Neither authorizes WeCom writes, real employees, outing/overtime fixture creation, emergency mode, writeback, or RuleOps.
- WAPPR1 2026-05-21 manual gate result: the user set the test employee's direct manager to the test supervisor and confirmed on a real device that leave, missed punch, outing, and overtime all auto-fill the test supervisor and the applicant cannot delete/change/add approvers. Treat all four templates as A / `PASS_APPROVER_RULES_READY`, but still require WRULE1 actual-chain validation, WROAD2-BIZ1 business evidence, WTCLOSE2 final test-account closeout, and explicit WEXT1-FORMAL/WREAL1 question gates before any WeCom write or real-employee trial.
- WTCLOSE2/WTCLOSE2F adds `scripts/qa/run-wecom-test-account-final-closeout.sh` as the final automatic test-account closeout before WEXT1-FORMAL. Enterprise WeChat API checks must run from the deployment server trusted IP, so use `bash scripts/qa/run-wecom-test-account-final-closeout.sh --remote`; local direct execution is blocked by default and may only be used for syntax/static gates. The remote run reruns `/ready`, WAUTO1, WFULLSMOKE1 with `WFULLSMOKE_ALLOW_PUBLIC_WRITES=0`, outing/overtime dry-run without live flags, and WRULE1 with `--manual-gate=all:A`; it must not set WeCom write switches, create templates/approvals, enable real employees, or treat WAPPR1=A as live approval. Do not treat previous local missing-env/DB WTCLOSE2 output as a WeCom business blocker.
- WREMOTE1/WFULLSMOKE2/WEXT1-FORMAL supersedes the earlier fixture-creation route for the next step: first deploy/sync and run WTCLOSE2 from the deployment server trusted IP, then run WFULLSMOKE2 from the deployment server with `WFULLSMOKE_ALLOW_PUBLIC_WRITES=1` when OA `TEST-SMOKE-WFULL` public test data is approved, then use existing formal WeCom outing/overtime templates by having `SMOKE002` manually submit and `SMOKE101` review details before approving. Do not auto-create outing/overtime OA-POC templates or auto-submit approvals in this route. WREAL1 uses `/tmp/hyb-oa-wreal1.env` for real employee/approver values and must not write real employee identifiers to repo docs, Issue CSV, or chat.
- WREMOTE1/WFULLSMOKE2/WEXT1-FORMAL supersedes the earlier fixture-creation route for the next step: first deploy/sync and run WTCLOSE2 from the deployment server trusted IP, then run WFULLSMOKE2 from the deployment server with `WFULLSMOKE_ALLOW_PUBLIC_WRITES=1` when OA `TEST-SMOKE-WFULL` public test data is approved, then use existing formal WeCom outing/overtime templates by having `SMOKE002` manually submit and `SMOKE101` review details before approving. WEXT1F adds `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote` for the post-manual evidence run: it explicitly pulls `--target=outing --target=overtime` with `--filter-creator-employee-no=SMOKE002`, may persist only to OA archive via `WECOM_ARCHIVE_WRITE=1` + `--persist-to-db`, and then runs archive offline read + WRULE1. Missing manual confirmation must produce `PAUSED_MANUAL_CONFIRMATION` and no WeCom API calls. Do not auto-create outing/overtime OA-POC templates, auto-submit approvals, write WeCom, broadly pull formal-template records from other employees, or run WeCom API evidence locally in this route. WREAL1 uses `/tmp/hyb-oa-wreal1.env` for real employee/approver values and must not write real employee identifiers to repo docs, Issue CSV, or chat.
- WAPC2FLIVE supersedes the WAPC2F dry-run gap for OA-POC samples: after user approval, the runner was corrected to the official `template_name` + `property` controls create-template payload, deployed with `SYNC_ENV=0`, and the public live report `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` reached `PASS_FIXTURE_CREATED`; leave/missed-punch OA-POC templates and low-risk test approvals were created and retained for audit, with WAPC2 auto-rerun `PASS_APPROVAL_POC`. WCBK1 has added and deployed read-only `GET/POST /api/wecom/approval/callback`, `wecomCallbackCrypto`, `npm run check:wecom-approval-callback-poc`, and the `wecom-callback` process gate. After remote callback config was set, public report `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` reached `PASS_CALLBACK_URL_VERIFIED`; after `SMOKE101` approved an existing OA-POC missed-punch test approval, `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` reached `PASS_CALLBACK_EVENT_CONFIRMED` with one redacted event. WACP3/WACP3R has added and deployed `npm run check:wecom-approval-compensation-poc`, `POST /cgi-bin/oa/getapprovalinfo` read support, and automatic template discovery by `SMOKE002` creator; WREAD1 deploy reran WACP3R at `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T125515Z/summary.json` and then generated `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T125532Z/summary.json` as `PAUSED_MANUAL_CONFIRMATION`. WREAD1F then fixed duplicate OA-POC template names by adding timestamp uniqueness and created fresh leave/missed-punch approvals at `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T131958Z/summary.json`; final WREAD1 manual report `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` is `PASS_READABLE`. This clears leave/missed-punch readability only; mirror data model, minimum archive, emergency mode, writeback, RuleOps, and outing/overtime sample decisions remain later gates.
- WEGOV1 changes the current WeCom route to "Enterprise WeChat owns mature HR/attendance flows; OA stores self-owned history, audit, entry, messages, and emergency fallback." Treat four domains explicitly: WeCom-owned domain is check-in, leave, missed punch, outing, overtime, leave balance, and HR assistant; OA-owned domain remains reimbursement, procurement, contracts, projects, payments, invoices, bank receipts, sensitive attachments, and operating audit; OA archive domain stores WeCom business history, full external keys, and necessary snapshots; OA emergency domain is manually enabled only when WeCom is unavailable. Do not assume a full isolated WeCom sandbox; use the controlled test domain `SMOKE002 -> SMOKE101`, `OA-POC` templates, low-risk approvals, synthetic tests, public live runners, and limited real-device confirmation. WARCH1 now supplies the minimum leave/missed-punch archive implementation, WARCH2 supplies the formal read-only history query surface, and WROAD2 supplies controlled ops daily checks. Do not start deep mirror until WROAD2-CLOSEOUT, WROAD2-BIZ1, WAPPR1, WRULE1, WAUTO1/WEXT1LD, WFULLSMOKE1, WTCLOSE2, WEXT1-FORMAL, WREAL1 question gates or explicit approval, and the mirror stage's own contract are complete.
- For reimbursement, invoice, OCR, duplicate invoice, or attachment work, read `docs/报销发票附件规则.md` and `references/reimbursement.md`.
+12
查看文件
@@ -580,6 +580,18 @@ Go 结论边界:
| 外出/加班 | 路线修正 | 不自动创建 OA-POC 外出/加班模板;由 `SMOKE002` 在正式模板真机提交,`SMOKE101` 先看详情再审批,系统只读补偿、归档和 WRULE1。 |
| 真实试用 | 待问答 | 首批仍为 1 名真实员工 + 1 名审批人/管理员观察;真实员工号只通过 `/tmp/hyb-oa-wreal1.env` 临时文件提供,不进仓库。 |
## WEXT1F 正式模板外出/加班证据脚本
执行时间:2026-05-22 建档并实现。本轮承接用户确认“可以”进入正式模板外出/加班测试准备,但系统仍不自动写企业微信、不创建模板、不自动提交审批单。
| 项目 | 状态 | 证据与边界 |
| --- | --- | --- |
| 执行合同 | 已建档 | 新增 `issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv`,ID 为 `WEXT1F-0..WEXT1F-7`。 |
| 只读证据脚本 | 已新增 | 新增 `scripts/qa/run-wecom-formal-outing-overtime-evidence.sh`,真实业务运行必须使用 `--remote` 从部署服务器 server 容器执行;本地直接执行默认拒绝,即使伪造远端环境变量也会因非 `/app` 容器路径拒绝。 |
| 人工前置 | 待执行 | `SMOKE002` 需在企业微信正式外出和加班模板提交低风险测试单,并确认审批人自动带出测试主管且不能改人;`SMOKE101` 需先打开详情页再同意或驳回。 |
| 系统校验 | 待人工后远端执行 | 脚本必须带 `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED`,否则只输出 `PAUSED_MANUAL_CONFIRMATION`,不跑企微 API;正式拉取显式使用 `--target=outing --target=overtime` 和 `--filter-creator-employee-no=SMOKE002`,只收 `SMOKE002` 相关单据,在 `WECOM_ARCHIVE_WRITE=1` + `--persist-to-db` 双开关下写 OA 自建留存,然后执行外出/加班离线归档读取、WRULE1 审批链校验和隐私扫描。 |
| 写入边界 | 已固定 | 不设置 `WECOM_APPROVAL_FIXTURE_WRITE=1`,不调用 create_template/applyevent,不创建 OA-POC 外出/加班样件,不启用真实员工;报告不得泄露完整 `sp_no`、template_id、userid、手机号、邮箱、token、Secret、EncodingAESKey、表单值、附件、评论或原始 payload。 |
## 历史恢复
需要追溯 R70-R91 的详细流水时,使用:
+4 -3
查看文件
@@ -2,8 +2,8 @@
**文档状态**:当前开发路线图事实源。
**分类**:开发治理 / 路线图。
**适用时间**: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 / WARCH2 / WROAD2 / WARCH3 / WCHECK1 / WEXT1 / WOPS1 / WROAD2-CLOSEOUT / WROAD2-BIZ1 / WAPPR1 / WAUTO1 / WEXT1LD / WFULLSMOKE1 / WROUTE2 / WTCLOSE1 / WRULE1 / WTCLOSE2 / WTCLOSE2F / WREMOTE1 / WFULLSMOKE2 / WEXT1-FORMAL / WREAL1-PREP 路线选择之后。
**关联 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,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WROUTE2,WTCLOSE1,WRULE1,WTCLOSE2,WTCLOSE2F,WREMOTE1,WFULLSMOKE2,WEXT1-FORMAL,WREAL1P`。
**适用时间**: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 / WARCH2 / WROAD2 / WARCH3 / WCHECK1 / WEXT1 / WOPS1 / WROAD2-CLOSEOUT / WROAD2-BIZ1 / WAPPR1 / WAUTO1 / WEXT1LD / WFULLSMOKE1 / WROUTE2 / WTCLOSE1 / WRULE1 / WTCLOSE2 / WTCLOSE2F / WREMOTE1 / WFULLSMOKE2 / WEXT1-FORMAL / WEXT1F / WREAL1-PREP 路线选择之后。
**关联 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,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WROUTE2,WTCLOSE1,WRULE1,WTCLOSE2,WTCLOSE2F,WREMOTE1,WFULLSMOKE2,WEXT1-FORMAL,WEXT1F,WREAL1P`。
**最近审计**:2026-05-22。
**不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。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/WARCH2 的留存、审计、提示或操作规程中继续处理。WADM1 只完成自建留存/镜像数据模型设计;WARCH1 只实现请假/补卡最小审批历史留存、回调事件入库、补偿拉取入库和 QA-only 离线读取证明;WARCH2 只提供 OA DB 只读查询 API 和 PC/移动历史页,不调用企微 API、不读取企微 Secret、不代表完整镜像、外出/加班、应急接管、恢复回写或 RuleOps 已完成。WROAD2 后当前策略是测试账号受控上线和每日脚本化巡检,允许补偿归档写 OA 自建留存库,但不写企业微信、不扩大真实员工范围;WARCH3 将每日巡检升级为服务器 `systemd timer` 与脱敏企微告警,WCHECK1 增加打卡规范化明细、日/月摘要和规则快照留存,WEXT1 只完成外出/加班 dry-run 支持。WOPS1 已公网验证巡检告警降噪:`HYD000006` 是管理员 `wangshunli` 的 employee_no,脚本不再把姓名字符串当员工号;已知 finding 不重复发送,只有阻断、脚本失败、新增 finding 或状态恶化才告警。WROAD2-BIZ1 在三天内补充测试账号人工业务冒烟;WAPPR1 将申请人任意选择审批人列为真实试用前阻断 finding。WAUTO1 只做只读证据汇总,WEXT1LD 只做外出/加班 live 前 dry-run,均不授权 live 创建。WFULLSMOKE1 是三天全域最终目标测试总控,只聚合企微 HR/考勤、OA 主办域、移动端和运维巡检证据;它允许在公网试运行环境创建带 `TEST-SMOKE-WFULL` 标记的 OA 测试业务数据,但不写企业微信、不创建外出/加班样件、不做真实付款、不接真实支付、不写 ERP、不导入银行流水、不启用真实员工。WREAL1-PREP 只准备真实小范围试用清单,不启用真实员工;后续顺序收紧为 WROAD2-CLOSEOUT 三天验收 -> WROAD2-BIZ1 三天业务冒烟 -> WAPPR1 审批规则规范化 -> WAUTO1 自动证据汇总 -> WEXT1LD 外出/加班 live 前 dry-run -> WFULLSMOKE1 三天全域最终目标测试 -> WEXT1-FORMAL 正式模板外出/加班测试 -> 请假/补卡/外出/加班四类测试号证据和审批规则门禁通过后再问真实小范围试用。上述阶段仍不代表完整深度镜像、应急接管、恢复回写或 RuleOps 已完成。
**WTCLOSE2F 顺序修正**:上一条长段中的“后续顺序”以本条为准:WROAD2-CLOSEOUT -> WROAD2-BIZ1 -> WAPPR1 -> WRULE1 -> WAUTO1 -> WEXT1LD -> WFULLSMOKE1 -> WTCLOSE2/WTCLOSE2F 远端可信 IP 收口 -> WREMOTE1/WFULLSMOKE2 远端收口 -> WEXT1-FORMAL 正式模板人工提交问答 -> WREAL1 问答 -> 最小完整镜像。
@@ -13,6 +13,7 @@
**WRULE1 执行口径**:采用“单一组织规则,两个执行域”。OA 维护员工、直属主管、业务角色和管理员兜底事实;企业微信只执行 HR/考勤审批;OA 通过 `check:wecom-approval-rule-validator` 读取已归档企微审批详情,计算理论审批人并对比实际审批链。WRULE1 是只读校验器,不新增 HR/考勤审批引擎,不写企微,不替代 WAPPR1 的后台人工确认和真机提交页确认。
**WTCLOSE2/WTCLOSE2F 执行口径**:新增 `scripts/qa/run-wecom-test-account-final-closeout.sh` 汇总测试号最终证据,复跑 WAUTO1、WFULLSMOKE1、外出/加班 dry-run 和 WRULE1。由于企业微信 API 已配置可信 IP,只允许部署服务器访问,WTCLOSE2 运行证据必须来自部署服务器:本地直接执行脚本默认拒绝跑企微链路,正确命令为 `bash scripts/qa/run-wecom-test-account-final-closeout.sh --remote`,由 SSH 到部署服务器并在 server 容器中执行;本地只做合同、语法、文档和静态门禁。WFULLSMOKE1 在 WTCLOSE2 中以 `WFULLSMOKE_ALLOW_PUBLIC_WRITES=0` 运行,不新增 OA 测试业务数据;如子 runner 触发既有归档写库,只能走 WARCH3/WCHECK1 已批准的 OA 自建留存路径。脚本不写企业微信、不创建外出/加班样件、不启用真实员工。WTCLOSE2 结论只进入 WREMOTE1/WFULLSMOKE2 和 WEXT1-FORMAL 问答,不自动提交审批;此前本地缺 env/DB 造成的 WTCLOSE2 阻断不能作为企微业务阻断证据。
**WREMOTE1/WFULLSMOKE2/WEXT1-FORMAL 执行口径**:WTCLOSE2F 后下一步先 `SYNC_ENV=0` 部署或确认远端代码同步,再从部署服务器执行 `bash scripts/qa/run-wecom-test-account-final-closeout.sh --remote`。全域 smoke 也必须从部署服务器执行:`WFULLSMOKE_ALLOW_PUBLIC_WRITES=1 bash scripts/qa/run-three-day-full-goal-smoke.sh --remote`,允许创建带 `TEST-SMOKE-WFULL` 标记的 OA 测试业务数据,不写企业微信。外出/加班不再自动创建 OA-POC 测试模板;改为使用现有正式模板,由 `SMOKE002` 真机人工提交,`SMOKE101` 先看详情再审批,系统只读补偿拉取、归档和 WRULE1 校验。WREAL1 真实员工和审批人只通过 `/tmp/hyb-oa-wreal1.env` 临时文件提供,不写入仓库文档或聊天。
**WEXT1F 执行口径**:正式模板外出/加班证据由 `scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote` 从部署服务器执行。该脚本只在用户完成 `SMOKE002` 人工提交、`SMOKE101` 先看详情再处理后运行;远端命令还必须带 `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED`,否则只输出 `PAUSED_MANUAL_CONFIRMATION`,不跑企微 API。它不创建模板、不自动提交审批、不写企业微信,只使用 `--target=outing --target=overtime` 的补偿拉取读取详情,并强制带 `--filter-creator-employee-no=SMOKE002`,避免按正式模板 ID 宽泛读取其他员工单据;在 `WECOM_ARCHIVE_WRITE=1` + `--persist-to-db` 双开关下写 OA 自建留存,再执行外出/加班离线归档读取和 WRULE1。报告结论限定为 `PASS_FORMAL_OUTING_OVERTIME_READY`、`PASS_WITH_FINDINGS`、`PAUSED_MANUAL_CONFIRMATION` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`;未形成外出/加班正式模板归档前不得进入 WREAL1。
## 1. 路线图原则
@@ -110,7 +111,7 @@
| 26 | `WREAL1-PREP / WROUTE2 真实试用准备和路线锁定` | 固化候选员工、审批人、允许流程、暂停条件、回滚联系人和验收标准,不启用真实员工,并锁定测试号收口到最小完整镜像的后续顺序。 | 用户选择先收口测试账号,不提前启用真实员工。 | 文档和合同明确真实试用需等四类测试号证据、WAPPR1/WRULE1 审批规则门禁和 WFULLSMOKE1 可解释通过后再问;当前继续测试账号,首批真实试用固定为 1 名真实员工 + 1 名审批人/管理员观察。 |
| 27 | `WTCLOSE2/WTCLOSE2F 测试号最终收口与证据复核` | 在部署服务器可信 IP 上自动复跑测试号收口证据,确认是否可以进入 WREMOTE1/WFULLSMOKE2 和 WEXT1-FORMAL 问答。 | WAPPR1 四类模板均为 A;WRULE1 已实现;WAUTO1/WEXT1LD/WFULLSMOKE1 已存在;远端代码与本地合同同步。 | 通过 `bash scripts/qa/run-wecom-test-account-final-closeout.sh --remote` 输出 `PASS_TEST_ACCOUNT_CLOSEOUT_READY`、`PASS_WITH_FINDINGS`、`PAUSED_MANUAL_CONFIRMATION` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`;不写企业微信、不创建样件、不启用真实员工;本地直接执行不作为业务证据。 |
| 28 | `WREMOTE1/WFULLSMOKE2 远端收口和全域 smoke` | 从部署服务器可信 IP 执行 WTCLOSE2,再远端运行允许 `TEST-SMOKE-WFULL` OA 测试数据的全域 smoke。 | WTCLOSE2F 已完成;允许 `SYNC_ENV=0` 部署;用户批准 OA 主办域公网测试数据。 | 远端报告输出 `PASS_TEST_ACCOUNT_CLOSEOUT_READY`、`PASS_FULL_GOAL_SMOKE_READY`、`PASS_WITH_FINDINGS`、`PAUSED_MANUAL_CONFIRMATION` 或真实 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`;不写企业微信、不启用真实员工。 |
| 29 | `WEXT1-FORMAL 正式模板外出/加班测试` | 使用现有正式外出/加班模板,由测试账号人工提交,系统只读校验补偿、归档和审批链。 | WREMOTE1/WFULLSMOKE2 可解释通过;再次问答确认人工提交窗口。 | 不创建 OA-POC 模板、不自动提交审批单;`SMOKE002` 提交,`SMOKE101` 先看详情再审批;若可改审批人、自审或审批链不符,阻断 WREAL1。 |
| 29 | `WEXT1-FORMAL / WEXT1F 正式模板外出/加班测试` | 使用现有正式外出/加班模板,由测试账号人工提交,系统只读校验补偿、归档和审批链。 | WREMOTE1/WFULLSMOKE2 可解释通过;再次问答确认人工提交窗口;证据脚本必须从部署服务器执行。 | 不创建 OA-POC 模板、不自动提交审批单;`SMOKE002` 提交,`SMOKE101` 先看详情再审批;`run-wecom-formal-outing-overtime-evidence.sh --remote` 输出脱敏报告;若可改审批人、自审或审批链不符,阻断 WREAL1。 |
| 30 | `WREAL1 小范围真实试用` | 在 1 名真实员工 + 1 名审批人/管理员观察范围验证企微主办 HR/考勤和 OA 留存。 | 请假、补卡、外出、加班四类测试号证据通过;WAPPR1/WRULE1 规则门禁通过;WREMOTE1/WFULLSMOKE2/WEXT1-FORMAL 可解释通过;用户通过 `/tmp/hyb-oa-wreal1.env` 提供真实员工和审批人。 | 试用报告可解释通过或暂停;仍不直接全员推广。 |
| 31 | `wecom_attendance_approval_mirror_v1 最小完整镜像` | 企微打卡、请假、补卡、外出、加班、假期余额和审批历史镜像到 OA。 | WREAL1 通过后优先规划;不得仅凭测试号证据进入,也不先做应急接管、恢复回写或 RuleOps。 | OA 可查询企微 HR/考勤镜像、附件摘要、审批历史、考勤解释和审计;不把 OA 主办高敏审批迁到企微。 |
| 32 | `oa_hr_emergency_mode_v1` | 企业微信不可用时,由管理员人工开启 OA HR/考勤应急接管。 | 自建历史留存和镜像数据可支撑对账;OA 应急入口、权限和审计方案明确。 | 管理员可填写原因、范围、预计结束时间和责任人后开启/关闭;应急单据带 `EMERGENCY` 标记。 |
+1
查看文件
@@ -107,6 +107,7 @@
| `WTCLOSE2` | `issues/2026-05-22_10-21-12-wecom-test-account-final-closeout-v1.csv` | `CURRENT` | 测试号最终收口与证据复核合同:WAPPR1 四类模板均为 A 后,自动复跑 `/ready`、WAUTO1、WFULLSMOKE1、外出/加班 dry-run 和 WRULE1;不写企业微信、不创建模板或审批单、不启用真实员工;WTCLOSE2F 后只进入 WREMOTE1/WEXT1-FORMAL 问答 |
| `WTCLOSE2F` | `issues/2026-05-22_11-10-06-wecom-test-account-closeout-remote-only-v1.csv` | `CURRENT` | WTCLOSE2 远端可信 IP 执行口径修正:企微 API 只能由部署服务器访问,WTCLOSE2 真实运行必须使用 `--remote` 在 server 容器内执行;本地直接执行默认阻断,只保留静态门禁用途 |
| `WREMOTE1` | `issues/2026-05-22_12-34-41-wecom-remote-closeout-and-full-smoke-v1.csv` | `CURRENT` | 远端可信 IP 收口、WFULLSMOKE2 全域 smoke 和 WEXT1-FORMAL 正式模板外出/加班测试合同:先远端 WTCLOSE2,再远端全域 smoke;外出/加班不自动建模板,改为 `SMOKE002` 正式模板人工提交、`SMOKE101` 审批后系统只读补偿/归档/WRULE1 |
| `WEXT1F` | `issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv` | `CURRENT` | 正式模板外出/加班证据脚本合同:`SMOKE002` 人工提交、`SMOKE101` 先看详情再审批后,使用 `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote` 从部署服务器只读拉取 outing/overtime;补偿拉取必须按 `creator=SMOKE002` 过滤,只写 OA 留存、离线读取和 WRULE1;不创建模板、不自动提交审批、不写企业微信 |
| `WREAL1-PREP` | `issues/2026-05-20_14-35-42-wecom-real-trial-prep-v1.csv` | `CURRENT` | 真实小范围试用准备合同:只固化候选员工、非自审审批人、回滚联系人、允许流程、暂停条件和验收标准;当前不启用真实员工,真实试用必须等请假/补卡/外出/加班四类测试号证据、WAPPR1 审批规则门禁和 WFULLSMOKE1 全域主路径可解释通过后再问 |
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
+2 -1
查看文件
@@ -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 自建留存模型设计 -> WARCH1 最小审批历史留存 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检 -> WARCH3 归档稳定化自动巡检 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班 dry-run -> WOPS1 巡检告警降噪 -> WROAD2-CLOSEOUT 三天验收 -> WROAD2-BIZ1 三天业务冒烟 -> WAPPR1 审批规则规范化 -> WRULE1 审批链校验器 POC -> WAUTO1 自动证据汇总 -> WEXT1LD live 前 dry-run -> WFULLSMOKE1 三天全域最终目标测试 -> WROUTE2 测试号收口到最小镜像路线锁定 -> WTCLOSE1 小团队自动审批规则与测试号全域收口 dry-run -> WTCLOSE2 测试号最终收口与证据复核 -> WTCLOSE2F 远端可信 IP 执行口径修正 -> WREMOTE1 远端可信 IP 收口 -> WFULLSMOKE2 全域 smoke -> WEXT1-FORMAL 正式模板外出/加班测试 -> 四类测试号证据和审批规则门禁通过后问答 WREAL1 小范围真实试用(首批 1 名真实员工 + 1 名审批人/管理员观察) -> wecom_attendance_approval_mirror_v1 最小完整镜像。
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 最小审批历史留存 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检 -> WARCH3 归档稳定化自动巡检 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班 dry-run -> WOPS1 巡检告警降噪 -> WROAD2-CLOSEOUT 三天验收 -> WROAD2-BIZ1 三天业务冒烟 -> WAPPR1 审批规则规范化 -> WRULE1 审批链校验器 POC -> WAUTO1 自动证据汇总 -> WEXT1LD live 前 dry-run -> WFULLSMOKE1 三天全域最终目标测试 -> WROUTE2 测试号收口到最小镜像路线锁定 -> WTCLOSE1 小团队自动审批规则与测试号全域收口 dry-run -> WTCLOSE2 测试号最终收口与证据复核 -> WTCLOSE2F 远端可信 IP 执行口径修正 -> WREMOTE1 远端可信 IP 收口 -> WFULLSMOKE2 全域 smoke -> WEXT1-FORMAL/WEXT1F 正式模板外出/加班测试 -> 四类测试号证据和审批规则门禁通过后问答 WREAL1 小范围真实试用(首批 1 名真实员工 + 1 名审批人/管理员观察) -> wecom_attendance_approval_mirror_v1 最小完整镜像。
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
@@ -168,6 +168,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| `WTCLOSE2` | `issues/2026-05-22_10-21-12-wecom-test-account-final-closeout-v1.csv` | 当前测试号最终收口与证据复核:WEXT1-FORMAL 前最后自动收口 | 新增 `scripts/qa/run-wecom-test-account-final-closeout.sh`,复跑 `/ready`、WAUTO1、WFULLSMOKE1、外出/加班 dry-run 和 WRULE1;WFULLSMOKE1 在该脚本中关闭公网写流程,WRULE1 使用 `--manual-gate=all:A` | 不写企业微信;不新增 OA 测试业务数据;不创建外出/加班模板或审批单;不启用真实员工;结论只进入 WEXT1-FORMAL 问答 |
| `WTCLOSE2F` | `issues/2026-05-22_11-10-06-wecom-test-account-closeout-remote-only-v1.csv` | 当前 WTCLOSE2 远端可信 IP 执行口径修正 | WTCLOSE2 真实运行命令改为 `bash scripts/qa/run-wecom-test-account-final-closeout.sh --remote`,由部署服务器 server 容器执行;本地直接执行默认拒绝跑企微链路 | 本地只做合同、语法、文档和静态门禁;此前本地缺 env/DB 的 WTCLOSE2 输出不作为企微业务阻断证据 |
| `WREMOTE1` | `issues/2026-05-22_12-34-41-wecom-remote-closeout-and-full-smoke-v1.csv` | 当前远端收口、全域 smoke 和正式模板外出/加班测试路线 | WFULLSMOKE2 必须使用 `--remote` 在部署服务器执行;外出/加班使用正式模板由 `SMOKE002` 人工提交、`SMOKE101` 审批,系统只读补偿、归档和 WRULE1 | 允许 OA `TEST-SMOKE-WFULL` 测试数据;不写企业微信、不自动创建外出/加班模板或审批单;真实员工信息只通过 `/tmp/hyb-oa-wreal1.env` 临时文件 |
| `WEXT1F` | `issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv` | 当前正式模板外出/加班证据脚本:人工提交后从部署服务器只读收证据 | 新增 `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote`;显式拉取 `outing/overtime` 且按 `creator=SMOKE002` 过滤,写 OA 留存仅限 `WECOM_ARCHIVE_WRITE=1` + `--persist-to-db`;随后做离线归档读取和 WRULE1 | 不创建模板、不自动提交审批、不写企业微信、不启用真实员工;未有人工作业或缺确认标记时只可得到人工缺口结论 |
| `WREAL1-PREP` | `issues/2026-05-20_14-35-42-wecom-real-trial-prep-v1.csv` | 当前真实试用准备:不启用真实员工 | 固化候选员工、非自审审批人、回滚联系人、允许流程、暂停条件和验收标准;真实试用必须等请假、补卡、外出、加班四类测试号证据、WAPPR1 和 WFULLSMOKE1 通过后再问 | 当前不创建外出/加班 live 样件;真实员工名单不写入 Issue 或文档,后续通过问答确认 |
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
+1
查看文件
@@ -7,6 +7,7 @@
**WAPPR1 2026-05-21 真机结论**:测试员工直属上级已设置为测试主管;请假、补卡、外出、加班提交页均自动带出测试主管,且申请人不能删除、改人或新增审批人,四类模板均按 A 记录。该结论仍需结合提交后的 WRULE1 实际审批链校验和三天业务冒烟证据,不单独放行真实员工试用。
**WRULE1 审批链校验**:管理员后台规则和真机提交页仍由 WAPPR1 人工确认;系统只能用已归档审批详情校验实际审批链。校验命令为 `npm run check:wecom-approval-rule-validator -- --template=<类型> --manual-gate=<类型>:A`,缺人工门禁时结论为 `PAUSED_MANUAL_CONFIRMATION`;自审、实际审批人不符或申请人可手选审批人均阻断 WEXT1-FORMAL、WREAL1 和最小镜像。
**WTCLOSE2/WTCLOSE2F 测试号最终收口**:企业微信 API 已限制只能由部署服务器可信 IP 访问,因此命令必须使用 `bash scripts/qa/run-wecom-test-account-final-closeout.sh --remote`。本地直接执行脚本会拒绝跑企微链路,只能用于 `bash -n`、合同和文档静态门禁;不得把本地缺 env/DB 的结果当作业务阻断。远端脚本复跑 WAUTO1、WFULLSMOKE1、外出/加班 dry-run 和 WRULE1,并汇总 WROAD2/WARCH3 既有报告;WFULLSMOKE1 在该脚本中关闭公网业务写流程,不新增 OA 测试业务数据;如子 runner 触发既有归档写库,只能走 WARCH3/WCHECK1 已批准的 OA 自建留存路径;脚本不写企业微信、不创建审批样件、不启用真实员工。
**WEXT1F 正式模板外出/加班证据脚本**:管理员或 QA 只在人工测试完成后执行 `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote`。人工步骤固定为:`SMOKE002` 在企业微信正式外出模板提交低风险测试单,确认审批人自动带出测试主管且不能改人;再在正式加班模板提交低风险测试单并做同样确认;`SMOKE101` 必须先打开详情页再同意或驳回,不能直接点快捷同意。缺少该人工确认标记时脚本只输出 `PAUSED_MANUAL_CONFIRMATION`,不跑企微 API。脚本从部署服务器可信 IP 读取企微审批 API,不创建模板、不自动提交审批、不写企业微信;补偿拉取必须同时按 `template_id` 和 `creator=SMOKE002` 过滤,避免读取同一正式模板下其他员工单据;仅在既有 `WECOM_ARCHIVE_WRITE=1` + `--persist-to-db` 双开关下把拉到的外出/加班详情写入 OA 自建留存,然后做离线归档读取和 WRULE1 审批链校验。
## 1. 适用范围
- 适用于 `small_team_internal` 工作区模式。
+4
查看文件
@@ -456,7 +456,11 @@ WTCLOSE2F 后,企业微信审批相关业务证据必须来自部署服务器
- WREMOTE1 先部署/同步代码并执行 `bash scripts/qa/run-wecom-test-account-final-closeout.sh --remote`;全域 smoke 使用 `WFULLSMOKE_ALLOW_PUBLIC_WRITES=1 bash scripts/qa/run-three-day-full-goal-smoke.sh --remote`。
- WEXT1-FORMAL 人工操作为 `SMOKE002` 在正式外出、加班模板提交低风险测试单,`SMOKE101` 先打开详情页再审批。
- 系统只读校验 runner 为 `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote`;只能从部署服务器可信 IP 执行。本地直接执行必须拒绝业务运行,缺少人工确认标记时不得跑企微 API。
- 该 runner 显式使用 `--target=outing --target=overtime` 拉取正式模板外出/加班详情,并同时设置 `--filter-creator-employee-no=SMOKE002`;默认请假/补卡巡检范围不得因此扩大,也不得按正式模板 ID 宽泛读取同模板其他员工单据。
- OA 归档写入只允许走既有双开关:`WECOM_ARCHIVE_WRITE=1` + `--persist-to-db`;这只写 OA 自建留存库,不写企业微信。
- 系统只读校验为补偿拉取、审批详情读取、OA 归档读取、WRULE1 审批链校验和隐私扫描;不得调用创建模板、提交审批或其他企微写接口。
- WEXT1F 结论限定为 `PASS_FORMAL_OUTING_OVERTIME_READY`、`PASS_WITH_FINDINGS`、`PAUSED_MANUAL_CONFIRMATION` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。
- 若正式模板出现申请人可任意改审批人、自审、审批人不一致、可见范围或可信 IP 阻断,结论记录为 blocking finding,不进入 WREAL1。
- WREAL1 真实员工和审批人信息只通过 `/tmp/hyb-oa-wreal1.env` 临时文件提供,不写入 Issue CSV、仓库文档或聊天。
@@ -0,0 +1,9 @@
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
"WEXT1F-0","建立正式模板外出加班证据合同","建立 WEXT1-FORMAL 执行合同,承接 WREMOTE1 后的路线:不再自动创建 OA-POC 外出/加班模板,不自动提交审批单;由 SMOKE002 在企业微信正式模板人工提交外出和加班,SMOKE101 先看详情再审批,系统只从部署服务器可信 IP 做只读拉取、OA 归档和审批链校验;审批读取 Secret 来源固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback、不允许运行时猜 Secret。","1) Issue ID 使用 WEXT1F-0..WEXT1F-7;2) 明确企业微信 API 证据只从部署服务器可信 IP 获取;3) 明确本轮不写企业微信、不创建模板、不自动提交审批、不启用真实员工;4) 明确 OA 写入只限 WECOM_ARCHIVE_WRITE=1 + --persist-to-db 的既有归档路径。","受影响流程:正式模板外出/加班人工提交 -> 企微补偿只读拉取 -> OA 归档 -> WRULE1 审批链校验 -> WREAL1 准入;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv --profile wecom --json。","python3|node","DONE","DONE","DONE","issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv","[]","回滚/清理:删除本合同;不修改 Prisma schema;不写企业微信。"
"WEXT1F-1","扩展补偿拉取显式目标","扩展 check-wecom-approval-compensation-poc,使其默认仍只覆盖请假和补卡,但支持显式 --target=outing --target=overtime;支持从正式模板提交记录按 SMOKE002 创建人只读发现外出/加班模板 ID,并在正式模板详情拉取时强制使用 --filter-creator-employee-no=SMOKE002,避免按 template_id 宽泛读取同模板其他员工单据。","1) 默认无 --target 时仍为 leave + missed_punch;2) 显式 --target=outing --target=overtime 才纳入外出/加班;3) 支持 WECOM_APPROVAL_OUTING_TEMPLATE_ID/WECOM_APPROVAL_OVERTIME_TEMPLATE_ID 或 --discover-template-ids 发现;4) WEXT1F 正式模板拉取必须带 creator=SMOKE002 过滤,无法解析则不拉取;5) 报告只输出脱敏前缀和计数;6) 不新增企微写入开关。","受影响流程:正式模板人工提交后 -> getapprovalinfo 按创建人发现模板 -> 按 template_id + creator 拉取外出/加班详情;验证步骤:cd server && npm run test -- tests/unit/scripts/check-wecom-approval-compensation-poc.test.js。","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","[""WEXT1F-0""]","回滚/清理:恢复补偿 runner;默认范围保持不变,避免误扩大 WARCH3/WAUTO1 现有巡检。"
"WEXT1F-2","扩展 OA 归档离线读取目标","扩展 check-wecom-approval-archive 离线读取目标,支持 --template=outing 和 --template=overtime,用于证明正式模板外出/加班详情已经进入 OA 自建留存。","1) 默认离线读取仍为 leave + missed_punch;2) 显式模板参数可读 outing/overtime;3) 不调用企微 API;4) 报告不输出完整 sp_no、template_id、userid、表单值、附件、评论或原始 payload。","受影响流程:OA 归档库 -> 外出/加班历史只读查看;验证步骤:cd server && npm run test -- tests/unit/scripts/check-wecom-approval-archive.test.js。","node|vitest","DONE","DONE","DONE","server/src/scripts/check-wecom-approval-archive.js|server/tests/unit/scripts/check-wecom-approval-archive.test.js","[""WEXT1F-1""]","回滚/清理:恢复离线读取目标集合;不改业务 API 或前端页面。"
"WEXT1F-3","新增远端正式模板证据脚本","新增 QA-only 脚本 scripts/qa/run-wecom-formal-outing-overtime-evidence.sh;本地默认拒绝业务执行,--remote 通过 SSH 到部署服务器容器运行;容器内只读拉取外出/加班、可选归档到 OA、自建留存离线读、WRULE1 校验和隐私扫描;脚本必须要求人工确认标记 WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED,否则只输出 PAUSED,不跑企微 API。","1) 本地直接执行必须提示 --remote;2) 即使伪造远端环境变量,本地非 /app 容器路径也必须拒绝;3) 远端执行使用 WEXT1F_EXECUTION_MODE=remote 和 WEXT1F_REMOTE_CONTEXT=deployment-server;4) 缺人工确认标记时不跑企微 API;5) 不设置任何企微写入开关;6) 归档写 OA 只通过 WECOM_ARCHIVE_WRITE=1 + --persist-to-db;7) 输出 artifacts/qa-flow/wecom-formal-outing-overtime-evidence-<timestamp>/summary.json。","受影响流程:正式模板人工提交后 -> 远端只读收证据;验证步骤:bash -n scripts/qa/run-wecom-formal-outing-overtime-evidence.sh;bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --help;本地直接执行返回非 0 且提示 --remote;WEXT1F_EXECUTION_MODE=remote WEXT1F_REMOTE_CONTEXT=deployment-server bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh 仍应因非 /app 容器路径拒绝。","bash|ssh|node","DONE","DONE","DONE","scripts/qa/run-wecom-formal-outing-overtime-evidence.sh","[""WEXT1F-2""]","回滚/清理:删除脚本;脚本不创建模板、不提交审批、不启用真实员工。"
"WEXT1F-4","更新事实源和人工步骤","更新现有事实源,记录 WEXT1-FORMAL 的正式模板人工操作清单、可信 IP 边界、外出/加班证据准入和 WREAL1 后置条件;不新建独立说明文档。","1) 文档明确 SMOKE002 人工提交正式模板外出/加班;2) 文档明确 SMOKE101 先看详情再审批;3) 文档明确系统只能只读拉取、归档和校验;4) 文档明确 WREAL1 仍需另行问答。","受影响流程:管理员按文档执行 WEXT1-FORMAL;验证步骤:rg -n ""WEXT1-FORMAL|正式模板|SMOKE002|SMOKE101|run-wecom-formal-outing-overtime-evidence|WREAL1"" docs .agents/skills/hyb-oa-delivery/SKILL.md;node scripts/qa/audit-docs.mjs --check-registry;quick_validate.py .agents/skills/hyb-oa-delivery。","rg|node|python3","DONE","DONE","DONE","docs/企微优先开发路线图.md|docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/需求-统一审批中心规约.md|.agents/skills/hyb-oa-delivery/SKILL.md","[""WEXT1F-3""]","回滚/清理:恢复文档和 skill;不记录真实员工信息。"
"WEXT1F-5","本地门禁与只读评审","执行合同校验、process gate、脚本语法、focused 单测、文档审计、skill 校验、diff 检查和最小回归;涉及审批规则和权限边界,实施后启动只读 reviewer subagent。","1) validate_issues_csv 通过;2) baseline 和 issue-specific process gate 通过;3) bash -n 通过;4) focused vitest 通过;5) docs/skill/diff/minimal gates 通过;6) reviewer 无 blocker 或 blocker 已修复。","受影响流程:脚本/规则边界 -> 独立评审 -> 部署前门禁;验证步骤:validate_issues_csv.py <csv>;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue <csv> --profile wecom --json;cd server && npm run test -- tests/unit/scripts/check-wecom-approval-compensation-poc.test.js tests/unit/scripts/check-wecom-approval-archive.test.js tests/unit/scripts/check-wecom-approval-rule-validator.test.js;bash scripts/check-minimal.sh。","python3|node|bash|subagent|git","DONE","DONE","DONE","server/src/scripts/*|scripts/qa/run-wecom-formal-outing-overtime-evidence.sh|docs/*|.agents/skills/hyb-oa-delivery/SKILL.md","[""WEXT1F-4""]","本地门禁已通过;只读 reviewer 已复核 creator 过滤、本地伪远端拒绝和人工确认门禁无 blocker;部署/人工提交/远端取证仍在 WEXT1F-6/7。"
"WEXT1F-6","部署并等待人工提交","本地门禁通过后 SYNC_ENV=0 部署,复检 /ready;然后给出 SMOKE002/SMOKE101 真机操作步骤,等待用户确认已提交/已审批后再跑远端证据脚本。","1) SYNC_ENV=0 部署成功;2) /ready 正常;3) 不在用户人工提交前跑正式模板证据通过结论;4) 给出按账号、模板、时间、确认点排列的人工步骤。","受影响流程:部署 -> 人工正式模板外出/加班 -> 远端证据脚本;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;curl -fsS https://oa.hyinda.com/ready;人工回复已提交/已审批。","bash|curl|manual","TODO","TODO","TODO","deploy/trialrun/*|scripts/qa/run-wecom-formal-outing-overtime-evidence.sh","[""WEXT1F-5""]","回滚/清理:若用户暂停,不跑远端证据;已部署代码可由后续提交回滚。"
"WEXT1F-7","远端收证据并回填提交","用户完成正式模板外出/加班人工测试后,从部署服务器执行 WEXT1F 证据脚本,回填报告路径和真实结论,中文提交。","1) 远端 summary 结论限定 PASS_FORMAL_OUTING_OVERTIME_READY、PASS_WITH_FINDINGS、PAUSED_MANUAL_CONFIRMATION、SKIP/BLOCKED_BY_EXTERNAL_CONFIG;2) 报告包含补偿拉取、OA 归档离线读、WRULE1、隐私扫描;3) 不泄露完整 sp_no/template_id/userid/手机号/邮箱/token/Secret/EncodingAESKey/表单值/附件/评论/原始 payload;4) commit message 使用中文并引用 WEXT1F。","受影响流程:正式模板外出/加班证据 -> WREAL1 问答准入;验证步骤:bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote;git status --short;git log -1 --oneline。","bash|ssh|git","TODO","TODO","TODO","issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv|server/src/scripts/*|scripts/qa/run-wecom-formal-outing-overtime-evidence.sh|docs/*|.agents/skills/hyb-oa-delivery/SKILL.md","[""WEXT1F-6""]","回滚/清理:不删除远端审计报告;若正式模板不满足 A 或实际审批链不一致,记录 blocker 并不进入 WREAL1。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 WEXT1F-0 建立正式模板外出加班证据合同 建立 WEXT1-FORMAL 执行合同,承接 WREMOTE1 后的路线:不再自动创建 OA-POC 外出/加班模板,不自动提交审批单;由 SMOKE002 在企业微信正式模板人工提交外出和加班,SMOKE101 先看详情再审批,系统只从部署服务器可信 IP 做只读拉取、OA 归档和审批链校验;审批读取 Secret 来源固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback、不允许运行时猜 Secret。 1) Issue ID 使用 WEXT1F-0..WEXT1F-7;2) 明确企业微信 API 证据只从部署服务器可信 IP 获取;3) 明确本轮不写企业微信、不创建模板、不自动提交审批、不启用真实员工;4) 明确 OA 写入只限 WECOM_ARCHIVE_WRITE=1 + --persist-to-db 的既有归档路径。 受影响流程:正式模板外出/加班人工提交 -> 企微补偿只读拉取 -> OA 归档 -> WRULE1 审批链校验 -> WREAL1 准入;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv --profile wecom --json。 python3|node DONE DONE DONE issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv [] 回滚/清理:删除本合同;不修改 Prisma schema;不写企业微信。
3 WEXT1F-1 扩展补偿拉取显式目标 扩展 check-wecom-approval-compensation-poc,使其默认仍只覆盖请假和补卡,但支持显式 --target=outing --target=overtime;支持从正式模板提交记录按 SMOKE002 创建人只读发现外出/加班模板 ID,并在正式模板详情拉取时强制使用 --filter-creator-employee-no=SMOKE002,避免按 template_id 宽泛读取同模板其他员工单据。 1) 默认无 --target 时仍为 leave + missed_punch;2) 显式 --target=outing --target=overtime 才纳入外出/加班;3) 支持 WECOM_APPROVAL_OUTING_TEMPLATE_ID/WECOM_APPROVAL_OVERTIME_TEMPLATE_ID 或 --discover-template-ids 发现;4) WEXT1F 正式模板拉取必须带 creator=SMOKE002 过滤,无法解析则不拉取;5) 报告只输出脱敏前缀和计数;6) 不新增企微写入开关。 受影响流程:正式模板人工提交后 -> getapprovalinfo 按创建人发现模板 -> 按 template_id + creator 拉取外出/加班详情;验证步骤:cd server && npm run test -- tests/unit/scripts/check-wecom-approval-compensation-poc.test.js。 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 ["WEXT1F-0"] 回滚/清理:恢复补偿 runner;默认范围保持不变,避免误扩大 WARCH3/WAUTO1 现有巡检。
4 WEXT1F-2 扩展 OA 归档离线读取目标 扩展 check-wecom-approval-archive 离线读取目标,支持 --template=outing 和 --template=overtime,用于证明正式模板外出/加班详情已经进入 OA 自建留存。 1) 默认离线读取仍为 leave + missed_punch;2) 显式模板参数可读 outing/overtime;3) 不调用企微 API;4) 报告不输出完整 sp_no、template_id、userid、表单值、附件、评论或原始 payload。 受影响流程:OA 归档库 -> 外出/加班历史只读查看;验证步骤:cd server && npm run test -- tests/unit/scripts/check-wecom-approval-archive.test.js。 node|vitest DONE DONE DONE server/src/scripts/check-wecom-approval-archive.js|server/tests/unit/scripts/check-wecom-approval-archive.test.js ["WEXT1F-1"] 回滚/清理:恢复离线读取目标集合;不改业务 API 或前端页面。
5 WEXT1F-3 新增远端正式模板证据脚本 新增 QA-only 脚本 scripts/qa/run-wecom-formal-outing-overtime-evidence.sh;本地默认拒绝业务执行,--remote 通过 SSH 到部署服务器容器运行;容器内只读拉取外出/加班、可选归档到 OA、自建留存离线读、WRULE1 校验和隐私扫描;脚本必须要求人工确认标记 WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED,否则只输出 PAUSED,不跑企微 API。 1) 本地直接执行必须提示 --remote;2) 即使伪造远端环境变量,本地非 /app 容器路径也必须拒绝;3) 远端执行使用 WEXT1F_EXECUTION_MODE=remote 和 WEXT1F_REMOTE_CONTEXT=deployment-server;4) 缺人工确认标记时不跑企微 API;5) 不设置任何企微写入开关;6) 归档写 OA 只通过 WECOM_ARCHIVE_WRITE=1 + --persist-to-db;7) 输出 artifacts/qa-flow/wecom-formal-outing-overtime-evidence-<timestamp>/summary.json。 受影响流程:正式模板人工提交后 -> 远端只读收证据;验证步骤:bash -n scripts/qa/run-wecom-formal-outing-overtime-evidence.sh;bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --help;本地直接执行返回非 0 且提示 --remote;WEXT1F_EXECUTION_MODE=remote WEXT1F_REMOTE_CONTEXT=deployment-server bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh 仍应因非 /app 容器路径拒绝。 bash|ssh|node DONE DONE DONE scripts/qa/run-wecom-formal-outing-overtime-evidence.sh ["WEXT1F-2"] 回滚/清理:删除脚本;脚本不创建模板、不提交审批、不启用真实员工。
6 WEXT1F-4 更新事实源和人工步骤 更新现有事实源,记录 WEXT1-FORMAL 的正式模板人工操作清单、可信 IP 边界、外出/加班证据准入和 WREAL1 后置条件;不新建独立说明文档。 1) 文档明确 SMOKE002 人工提交正式模板外出/加班;2) 文档明确 SMOKE101 先看详情再审批;3) 文档明确系统只能只读拉取、归档和校验;4) 文档明确 WREAL1 仍需另行问答。 受影响流程:管理员按文档执行 WEXT1-FORMAL;验证步骤:rg -n "WEXT1-FORMAL|正式模板|SMOKE002|SMOKE101|run-wecom-formal-outing-overtime-evidence|WREAL1" docs .agents/skills/hyb-oa-delivery/SKILL.md;node scripts/qa/audit-docs.mjs --check-registry;quick_validate.py .agents/skills/hyb-oa-delivery。 rg|node|python3 DONE DONE DONE docs/企微优先开发路线图.md|docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/需求-统一审批中心规约.md|.agents/skills/hyb-oa-delivery/SKILL.md ["WEXT1F-3"] 回滚/清理:恢复文档和 skill;不记录真实员工信息。
7 WEXT1F-5 本地门禁与只读评审 执行合同校验、process gate、脚本语法、focused 单测、文档审计、skill 校验、diff 检查和最小回归;涉及审批规则和权限边界,实施后启动只读 reviewer subagent。 1) validate_issues_csv 通过;2) baseline 和 issue-specific process gate 通过;3) bash -n 通过;4) focused vitest 通过;5) docs/skill/diff/minimal gates 通过;6) reviewer 无 blocker 或 blocker 已修复。 受影响流程:脚本/规则边界 -> 独立评审 -> 部署前门禁;验证步骤:validate_issues_csv.py <csv>;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue <csv> --profile wecom --json;cd server && npm run test -- tests/unit/scripts/check-wecom-approval-compensation-poc.test.js tests/unit/scripts/check-wecom-approval-archive.test.js tests/unit/scripts/check-wecom-approval-rule-validator.test.js;bash scripts/check-minimal.sh。 python3|node|bash|subagent|git DONE DONE DONE server/src/scripts/*|scripts/qa/run-wecom-formal-outing-overtime-evidence.sh|docs/*|.agents/skills/hyb-oa-delivery/SKILL.md ["WEXT1F-4"] 本地门禁已通过;只读 reviewer 已复核 creator 过滤、本地伪远端拒绝和人工确认门禁无 blocker;部署/人工提交/远端取证仍在 WEXT1F-6/7。
8 WEXT1F-6 部署并等待人工提交 本地门禁通过后 SYNC_ENV=0 部署,复检 /ready;然后给出 SMOKE002/SMOKE101 真机操作步骤,等待用户确认已提交/已审批后再跑远端证据脚本。 1) SYNC_ENV=0 部署成功;2) /ready 正常;3) 不在用户人工提交前跑正式模板证据通过结论;4) 给出按账号、模板、时间、确认点排列的人工步骤。 受影响流程:部署 -> 人工正式模板外出/加班 -> 远端证据脚本;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;curl -fsS https://oa.hyinda.com/ready;人工回复已提交/已审批。 bash|curl|manual TODO TODO TODO deploy/trialrun/*|scripts/qa/run-wecom-formal-outing-overtime-evidence.sh ["WEXT1F-5"] 回滚/清理:若用户暂停,不跑远端证据;已部署代码可由后续提交回滚。
9 WEXT1F-7 远端收证据并回填提交 用户完成正式模板外出/加班人工测试后,从部署服务器执行 WEXT1F 证据脚本,回填报告路径和真实结论,中文提交。 1) 远端 summary 结论限定 PASS_FORMAL_OUTING_OVERTIME_READY、PASS_WITH_FINDINGS、PAUSED_MANUAL_CONFIRMATION、SKIP/BLOCKED_BY_EXTERNAL_CONFIG;2) 报告包含补偿拉取、OA 归档离线读、WRULE1、隐私扫描;3) 不泄露完整 sp_no/template_id/userid/手机号/邮箱/token/Secret/EncodingAESKey/表单值/附件/评论/原始 payload;4) commit message 使用中文并引用 WEXT1F。 受影响流程:正式模板外出/加班证据 -> WREAL1 问答准入;验证步骤:bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote;git status --short;git log -1 --oneline。 bash|ssh|git TODO TODO TODO issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv|server/src/scripts/*|scripts/qa/run-wecom-formal-outing-overtime-evidence.sh|docs/*|.agents/skills/hyb-oa-delivery/SKILL.md ["WEXT1F-6"] 回滚/清理:不删除远端审计报告;若正式模板不满足 A 或实际审批链不一致,记录 blocker 并不进入 WREAL1。
+341
查看文件
@@ -0,0 +1,341 @@
#!/usr/bin/env bash
set -u -o pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SERVER_DIR="$ROOT_DIR/server"
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
REPORT_DIR="${WEXT1F_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/wecom-formal-outing-overtime-evidence-$(date +%Y%m%d-%H%M%S)}"
SUMMARY_TXT="$REPORT_DIR/summary.txt"
STATUS=0
REQUIRED_MANUAL_CONFIRMATION="SMOKE002_SUBMITTED_SMOKE101_REVIEWED"
usage() {
cat <<'EOF'
Usage:
bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote
WEXT1-FORMAL must run on the deployment server because Enterprise WeChat API
access is restricted by trusted IP. Local execution is intentionally blocked;
use bash -n for syntax/static checks.
This runner assumes SMOKE002 has manually submitted formal outing/overtime
approval records in Enterprise WeChat. It does not create templates, submit
approvals, or write Enterprise WeChat. It only reads WeCom approval APIs from
the deployment server and persists pulled details to the OA archive behind the
existing WECOM_ARCHIVE_WRITE=1 + --persist-to-db gate.
EOF
}
run_remote() {
local deploy_server="${DEPLOY_SERVER:-deploy@118.89.77.218}"
local remote_dir="${REMOTE_DIR:-/opt/hyb-oa-trialrun}"
local remote_env_file="${REMOTE_ENV_FILE:-${remote_dir}/deploy/trialrun/.env}"
local compose_file="${COMPOSE_FILE:-deploy/trialrun/docker-compose.yml}"
local public_base_url="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}"
local manual_confirmation=""
if [[ "${WEXT1F_MANUAL_CONFIRMATION:-}" == "$REQUIRED_MANUAL_CONFIRMATION" ]]; then
manual_confirmation="$REQUIRED_MANUAL_CONFIRMATION"
fi
ssh "$deploy_server" \
"REMOTE_DIR='${remote_dir}' REMOTE_ENV_FILE='${remote_env_file}' COMPOSE_FILE='${compose_file}' LIVE_SMOKE_BASE_URL='${public_base_url}' WEXT1F_MANUAL_CONFIRMATION='${manual_confirmation}' bash -s" <<'REMOTE'
set -euo pipefail
cd "$REMOTE_DIR"
if docker compose version >/dev/null 2>&1; then
compose_cmd=(docker compose)
else
compose_cmd=(docker-compose)
fi
"${compose_cmd[@]}" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" exec -T server sh -lc \
"cd /app && LIVE_SMOKE_BASE_URL='$LIVE_SMOKE_BASE_URL' WEXT1F_MANUAL_CONFIRMATION='$WEXT1F_MANUAL_CONFIRMATION' WEXT1F_EXECUTION_MODE=remote WEXT1F_REMOTE_CONTEXT=deployment-server bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh"
REMOTE
}
case "${1:-}" in
--remote)
run_remote
exit $?
;;
--help|-h)
usage
exit 0
;;
"")
;;
*)
printf '[wext1f][ERROR] unknown argument: %s\n' "$1" >&2
usage >&2
exit 2
;;
esac
if [[ "${WEXT1F_EXECUTION_MODE:-}" != "remote" || "${WEXT1F_REMOTE_CONTEXT:-}" != "deployment-server" ]]; then
printf '[wext1f][ERROR] Enterprise WeChat API checks must run on the deployment server trusted IP. Use --remote.\n' >&2
usage >&2
exit 2
fi
if [[ "$ROOT_DIR" != "/app" ]]; then
printf '[wext1f][ERROR] remote execution must run inside the deployment server container at /app. Use --remote.\n' >&2
exit 2
fi
mkdir -p "$REPORT_DIR"
: >"$SUMMARY_TXT"
log() {
printf '%s\n' "$*" | tee -a "$SUMMARY_TXT"
}
run_step() {
local name="$1"
shift
local log_file="$REPORT_DIR/${name}.log"
log ">>> [wext1f] $name"
if "$@" > >(tee "$log_file") 2>&1; then
log "[wext1f][PASS] $name"
else
local code="$?"
log "[wext1f][FAIL] $name exit=$code"
STATUS=1
fi
}
run_server_step() {
local name="$1"
shift
local log_file="$REPORT_DIR/${name}.log"
log ">>> [wext1f] $name"
if (cd "$SERVER_DIR" && "$@") > >(tee "$log_file") 2>&1; then
log "[wext1f][PASS] $name"
else
local code="$?"
log "[wext1f][FAIL] $name exit=$code"
STATUS=1
fi
}
run_ready() {
run_step "public-ready" curl -fsS "$PUBLIC_BASE_URL/ready"
}
run_formal_compensation_archive() {
run_server_step "formal-outing-overtime-compensation-archive" env \
WECOM_ARCHIVE_WRITE=1 \
npm run check:wecom-approval-compensation-poc -- \
--live \
--format=json \
--target=outing \
--target=overtime \
--discover-template-ids \
--discover-applicant-employee-no=SMOKE002 \
--filter-creator-employee-no=SMOKE002 \
--persist-to-db \
--report-dir="$REPORT_DIR/wecom-approval-compensation-poc"
}
run_archive_offline() {
run_server_step "formal-outing-overtime-archive-offline" npm run check:wecom-approval-archive -- \
--offline \
--format=json \
--template=outing \
--template=overtime \
--limit=50 \
--report-dir="$REPORT_DIR/wecom-approval-archive"
}
run_rule_validator() {
run_server_step "formal-outing-overtime-rule-validator" npm run check:wecom-approval-rule-validator -- \
--format=json \
--template=outing \
--template=overtime \
--manual-gate=all:A \
--limit=50 \
--report-dir="$REPORT_DIR/wecom-approval-rule-validator"
}
run_privacy_smoke() {
local sensitive_pattern='(["'\'']?(WECOM_[A-Z0-9_]*(SECRET|TOKEN)|[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|ENCODING_AES_KEY|ENCODINGAESKEY|CORP_SECRET|ACCESS_TOKEN)[A-Z0-9_]*)["'\'']?[[:space:]]*[:=][[:space:]]*["'\'']?[A-Za-z0-9._:/+=-]{8,}["'\'']?)|Authorization:[[:space:]]*Bearer[[:space:]]+[A-Za-z0-9._-]{8,}|access_token=[A-Za-z0-9._-]{8,}|1[3-9][0-9]{9}|[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}|-----BEGIN|BEGIN [A-Z ]*KEY|"[^"]*(token|secret|password|encodingAESKey|encoding_aes_key|sp_no|spNo|template_id|templateId|userid|userId|external_userid|externalUserId|external_user_id)[^"]*"[[:space:]]*:[[:space:]]*"[^"]{8,}"|\b20[0-9]{12,}\b|"(raw|rawPayload|raw_payload|payload|xml|ciphertext|Encrypt|formValue|formValues|attachments|comments)"[[:space:]]*:'
local safe_metadata_pattern='"[^"]*(secret|token)[^"]*"[[:space:]]*:[[:space:]]*"WECOM_[A-Z0-9_]*(SECRET|TOKEN)([[:space:]]|["]|$)|"[^"]*(secret|token)[^"]*"[[:space:]]*:[[:space:]]*"WECOM_[A-Z0-9_]*(SECRET|TOKEN)[^"]*(source|path|read paths|fallback)'
local safe_redacted_prefix_pattern='"[^"]*(sp_no|spNo|template_id|templateId|userid|userId|external_userid|externalUserId|external_user_id)[^"]*Prefixes?"[[:space:]]*:[[:space:]]*"[^"]{1,16}\*\*\*[^"]{0,8}"'
local matches
matches="$(grep -RInEi "$sensitive_pattern" "$REPORT_DIR" | grep -vE 'HRWDTEST-[0-9]{14}' | grep -vEi "$safe_metadata_pattern" | grep -vEi "$safe_redacted_prefix_pattern" || true)"
if [[ -n "$matches" ]]; then
printf '%s\n' "$matches"
log "[wext1f][FAIL] report-privacy-smoke matched sensitive pattern"
STATUS=1
else
log "[wext1f][PASS] report-privacy-smoke"
fi
}
write_summary_json() {
node - "$ROOT_DIR" "$REPORT_DIR" "$PUBLIC_BASE_URL" "$STATUS" <<'NODE' || return 1
const fs = require("node:fs");
const path = require("node:path");
const { execSync } = require("node:child_process");
const [rootDir, reportDir, baseURL, statusInput] = process.argv.slice(2);
function readJson(file) {
if (!file || !fs.existsSync(file)) return null;
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return null;
}
}
function child(relativePath) {
const file = path.join(reportDir, relativePath, "summary.json");
const json = readJson(file);
return {
status: json?.status || json?.conclusion || "MISSING",
conclusion: json?.conclusion || null,
reportPath: fs.existsSync(file) ? path.resolve(file) : null,
mainBlockers: Array.isArray(json?.mainBlockers) ? json.mainBlockers : [],
configGaps: Array.isArray(json?.configGaps) ? json.configGaps : [],
templateDataGaps: Array.isArray(json?.checks?.templateDataGaps) ? json.checks.templateDataGaps : [],
findingCount: Number(json?.findingCount || 0),
};
}
function safeStatus(value) {
return String(value || "UNKNOWN").slice(0, 120);
}
function isPassLike(value) {
return /^(PASS|PASS_|PASS_RULE_MATCH|PASS_WITH_ADMIN_FALLBACK|PASS_ARCHIVE_HISTORY_READABLE)/i.test(safeStatus(value));
}
function isFindingPass(value) {
return /^PASS_WITH_FINDINGS$/i.test(safeStatus(value));
}
function isNoDataGap(report) {
const reasons = [
...report.configGaps.map((item) => item?.reason || item?.id),
...report.mainBlockers.map((item) => item?.reason || item?.id),
...report.templateDataGaps.map((item) => item?.reason || item?.id),
].filter(Boolean).join(" ");
return /no_wecom_approval_archive_rows|no_existing_approval_in_window|archive_history_data|approval_compensation_window_empty/i.test(reasons);
}
let commit = "unknown";
try {
commit = execSync("git rev-parse --short HEAD", { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
} catch {}
const childReports = {
publicReady: {
status: Number(statusInput || 0) === 0 ? "PASS" : "FAIL",
reportPath: path.resolve(path.join(reportDir, "public-ready.log")),
},
compensationArchive: child("wecom-approval-compensation-poc"),
archiveOffline: child("wecom-approval-archive"),
ruleValidator: child("wecom-approval-rule-validator"),
};
const findings = [];
let hasBlocker = Number(statusInput || 0) !== 0;
let hasManualGap = false;
let hasFinding = false;
for (const [source, report] of Object.entries(childReports)) {
const status = safeStatus(report.status);
if (isPassLike(status)) {
if (isFindingPass(status) || Number(report.findingCount || 0) > 0) {
hasFinding = true;
findings.push({ source, status, severity: "finding", reportPath: report.reportPath });
}
continue;
}
if (/PARTIAL|PAUSED|MISSING|NEEDS/i.test(status) || isNoDataGap(report)) {
hasManualGap = true;
findings.push({ source, status, severity: "manual_or_data_gap", reportPath: report.reportPath });
continue;
}
hasBlocker = true;
findings.push({ source, status, severity: "blocker", reportPath: report.reportPath });
}
let status = "PASS_FORMAL_OUTING_OVERTIME_READY";
if (hasBlocker) {
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
} else if (hasManualGap) {
status = "PAUSED_MANUAL_CONFIRMATION";
} else if (hasFinding) {
status = "PASS_WITH_FINDINGS";
}
const summary = {
issueId: "WEXT1F",
mode: "wecom-formal-outing-overtime-evidence",
status,
conclusion: status,
baseURL,
commit,
executionMode: process.env.WEXT1F_EXECUTION_MODE || "unknown",
trustedIpBoundary: "Enterprise WeChat API checks must run on the deployment server; local runs are blocked by default",
checkedAt: new Date().toISOString(),
reportDir: path.resolve(reportDir),
accounts: {
applicant: "SMOKE002",
approver: "SMOKE101",
adminObserverEmployeeNo: "HYD000006",
},
childReports,
findingCount: findings.length,
findings,
manualPrerequisites: [
"SMOKE002 has manually submitted formal outing and overtime approval records.",
"SMOKE101 has opened detail pages before approving or rejecting.",
"WAPPR1 manual gate remains all:A for outing and overtime.",
],
manualConfirmation: {
required: "SMOKE002_SUBMITTED_SMOKE101_REVIEWED",
provided: process.env.WEXT1F_MANUAL_CONFIRMATION === "SMOKE002_SUBMITTED_SMOKE101_REVIEWED",
},
boundaries: {
noWeComWrites: true,
noWeComTemplateCreation: true,
noWeComApprovalAutoSubmission: true,
noRealEmployees: true,
onlyAllowedOaWrites: "approval archive persistence via WECOM_ARCHIVE_WRITE=1 + --persist-to-db",
},
nextQuestions: {
wreal1: [
"A:1 名真实员工 + 1 名审批人/管理员观察,试用 3 天。",
"B:继续测试账号,不引入真实员工。",
"C:暂停,先处理指定 finding。",
],
},
privacy: "status/count/report-path summaries only; no phone/email/token/Secret/EncodingAESKey/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload/XML/ciphertext",
};
fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
console.log(`[wext1f] summary=${path.join(reportDir, "summary.json")}`);
NODE
}
finish() {
write_summary_json || STATUS=1
run_privacy_smoke
write_summary_json || STATUS=1
log "[wext1f] report=$REPORT_DIR"
exit "$STATUS"
}
trap finish EXIT
if [[ "${WEXT1F_MANUAL_CONFIRMATION:-}" != "$REQUIRED_MANUAL_CONFIRMATION" ]]; then
log "[wext1f][PAUSED] missing manual confirmation: set WEXT1F_MANUAL_CONFIRMATION=$REQUIRED_MANUAL_CONFIRMATION only after SMOKE002 submitted formal outing/overtime and SMOKE101 reviewed details."
exit 0
fi
run_ready
run_formal_compensation_archive
run_archive_offline
run_rule_validator
@@ -9,6 +9,7 @@ const { maskApprovalIdentifier } = require("../services/integrations/wecomApprov
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const DEFAULT_TARGETS = Object.freeze(["leave", "missed_punch"]);
const SUPPORTED_TARGETS = Object.freeze(["leave", "missed_punch", "outing", "overtime"]);
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";
@@ -24,6 +25,8 @@ Options:
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-archive-<timestamp>。
--template=leave 只检查请假留存。
--template=missed_punch 只检查补卡留存。
--template=outing 只检查外出留存。
--template=overtime 只检查加班留存。
--status=<status> 按规范化状态过滤。
--limit=<1..100> 最大读取条数,默认 20。
--help 显示帮助。
@@ -102,7 +105,7 @@ function parseArgs(argv) {
}
const uniqueTemplates = Array.from(new Set(options.templates.filter(Boolean)));
for (const template of uniqueTemplates) {
if (!DEFAULT_TARGETS.includes(template)) {
if (!SUPPORTED_TARGETS.includes(template)) {
throw new Error(`Unsupported --template: ${template}`);
}
}
@@ -9,6 +9,7 @@ const { getConfig } = require("../config");
const { createPrismaClient } = require("../prismaClient");
const {
APPROVAL_GET_INFO_ENDPOINT,
APPROVAL_HR_ATTENDANCE_TARGETS,
APPROVAL_POC_TARGETS,
fetchWecomApprovalDetail,
fetchWecomApprovalInfoList,
@@ -35,7 +36,10 @@ const REPORT_PRIVACY =
const TEMPLATE_ENV = Object.freeze({
leave: "WECOM_APPROVAL_LEAVE_TEMPLATE_ID",
missed_punch: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID",
outing: "WECOM_APPROVAL_OUTING_TEMPLATE_ID",
overtime: "WECOM_APPROVAL_OVERTIME_TEMPLATE_ID",
});
const DEFAULT_TARGET_KEYS = Object.freeze(APPROVAL_POC_TARGETS.map((item) => item.key));
function printHelp() {
process.stdout.write(`Usage: node src/scripts/check-wecom-approval-compensation-poc.js [options]
@@ -51,9 +55,15 @@ Options:
--end-time=<iso|epoch> 窗口结束时间,默认当前时间。
--template=leave:<id> 指定请假模板 ID,用于 template_id filter。
--template=missed_punch:<id> 指定补卡模板 ID,用于 template_id filter。
--template=outing:<id> 指定外出模板 ID,用于 WEXT1-FORMAL 正式模板只读证据。
--template=overtime:<id> 指定加班模板 ID,用于 WEXT1-FORMAL 正式模板只读证据。
--target=<key> 显式目标,可重复;默认 leave + missed_punch。
--targets=<a,b> 显式目标列表;支持 leave, missed_punch, outing, overtime。
--discover-template-ids 缺模板 ID 时,按申请人只读发现 OA-POC 模板 ID。
--discover-applicant-employee-no=<no>
自动发现使用的 OA 申请人员工号,默认 SMOKE002。
--filter-creator-employee-no=<no>
拉取模板详情时同时按创建人员工号过滤;正式模板证据必须设置。
--size=<1..100> 单页审批单号数量,默认 100。
--persist-to-db 写入 OA 自建留存表;还必须设置 WECOM_ARCHIVE_WRITE=1。
--help 显示帮助。
@@ -86,7 +96,26 @@ function safeMessage(value) {
}
function supportedTargetKeys() {
return APPROVAL_POC_TARGETS.map((item) => item.key);
return APPROVAL_HR_ATTENDANCE_TARGETS.map((item) => item.key);
}
function selectedTargetKeys(options) {
const rawTargets = Array.isArray(options.targets) && options.targets.length
? options.targets
: DEFAULT_TARGET_KEYS;
const uniqueTargets = Array.from(new Set(rawTargets.map((item) => toSafeString(item, 40)).filter(Boolean)));
const supported = supportedTargetKeys();
for (const target of uniqueTargets) {
if (!supported.includes(target)) {
throw new Error(`unsupported target: ${target}`);
}
}
return uniqueTargets.length ? uniqueTargets : [...DEFAULT_TARGET_KEYS];
}
function selectedTargetDescriptors(options) {
const selected = new Set(selectedTargetKeys(options));
return APPROVAL_HR_ATTENDANCE_TARGETS.filter((item) => selected.has(item.key));
}
function parseTargetPair(value, flagName) {
@@ -114,8 +143,10 @@ function parseArgs(argv) {
startTime: "",
endTime: "",
templates: {},
targets: [],
discoverTemplateIds: false,
discoverApplicantEmployeeNo: DEFAULT_DISCOVERY_APPLICANT_EMPLOYEE_NO,
filterCreatorEmployeeNo: "",
size: DEFAULT_PAGE_SIZE,
maxPages: DEFAULT_MAX_PAGES,
persistToDb: false,
@@ -157,6 +188,16 @@ function parseArgs(argv) {
} else if (arg.startsWith("--template=")) {
const pair = parseTargetPair(arg.slice("--template=".length), "--template");
options.templates[pair.target] = pair.value;
} else if (arg === "--target") {
options.targets.push(toSafeString(argv[index + 1], 40));
index += 1;
} else if (arg.startsWith("--target=")) {
options.targets.push(toSafeString(arg.slice("--target=".length), 40));
} else if (arg === "--targets") {
options.targets.push(...toSafeString(argv[index + 1], 200).split(",").map((item) => item.trim()).filter(Boolean));
index += 1;
} else if (arg.startsWith("--targets=")) {
options.targets.push(...toSafeString(arg.slice("--targets=".length), 200).split(",").map((item) => item.trim()).filter(Boolean));
} else if (arg === "--discover-template-ids") {
options.discoverTemplateIds = true;
} else if (arg === "--discover-applicant-employee-no") {
@@ -164,6 +205,11 @@ function parseArgs(argv) {
index += 1;
} else if (arg.startsWith("--discover-applicant-employee-no=")) {
options.discoverApplicantEmployeeNo = toSafeString(arg.slice("--discover-applicant-employee-no=".length), 80);
} else if (arg === "--filter-creator-employee-no") {
options.filterCreatorEmployeeNo = toSafeString(argv[index + 1], 80);
index += 1;
} else if (arg.startsWith("--filter-creator-employee-no=")) {
options.filterCreatorEmployeeNo = toSafeString(arg.slice("--filter-creator-employee-no=".length), 80);
} else if (arg === "--size") {
options.size = Number(argv[index + 1] || options.size);
index += 1;
@@ -187,6 +233,7 @@ function parseArgs(argv) {
if (!options.discoverApplicantEmployeeNo) {
throw new Error("--discover-applicant-employee-no requires non-empty value");
}
options.targets = selectedTargetKeys(options);
options.size = Math.floor(options.size);
return options;
}
@@ -226,7 +273,7 @@ function envValue(env, name) {
}
function buildInputs(options, env = process.env) {
return APPROVAL_POC_TARGETS.map((target) => {
return selectedTargetDescriptors(options).map((target) => {
const cliTemplateId = toSafeString(options.templates[target.key], 500);
const envTemplateId = envValue(env, TEMPLATE_ENV[target.key]);
const templateId = cliTemplateId || envTemplateId;
@@ -327,6 +374,8 @@ function inferTargetFromApprovalDetail(detailResult) {
].filter(Boolean).join(" ");
if (/补卡|missed[_ -]?punch/i.test(text)) return "missed_punch";
if (/请假|leave/i.test(text)) return "leave";
if (/外出|outing|outgoing/i.test(text)) return "outing";
if (/加班|overtime/i.test(text)) return "overtime";
return null;
}
@@ -615,11 +664,98 @@ function buildDryRunTargetCheck(input) {
};
}
function buildCreatorFilterNotReadyTargetCheck(input, creatorFilterCheck) {
return {
id: "approval_compensation_pull",
target: input.key,
label: input.label,
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
source: "WECOM_AGENT_SECRET",
status: "NEEDS_CONFIG",
reason: creatorFilterCheck?.reason || "creator_filter_not_ready",
missingEnv: creatorFilterCheck?.missing || [],
templateIdPrefix: input.templateIdPrefix,
configuredFrom: input.templateSource,
creatorFilter: {
requested: true,
status: creatorFilterCheck?.status || "NEEDS_CONFIG",
employeeNo: creatorFilterCheck?.employeeNo || null,
userIdPrefix: creatorFilterCheck?.userIdPrefix || null,
},
pageCount: 0,
spNoCount: 0,
details: [],
};
}
function buildSkippedCreatorFilterCheck(reason) {
return {
id: "approval_creator_filter",
status: "SKIPPED",
reason,
employeeNo: null,
applicant: null,
userIdPrefix: null,
};
}
async function buildCreatorFilterCheck({ options, queryApplicantActorImpl }) {
const employeeNo = toSafeString(options.filterCreatorEmployeeNo, 80);
if (!employeeNo) return buildSkippedCreatorFilterCheck("creator_filter_not_requested");
let applicant;
try {
applicant = await queryApplicantActorImpl(employeeNo);
} catch (error) {
return {
id: "approval_creator_filter",
status: "NEEDS_CONFIG",
reason: "creator_filter_lookup_failed",
message: safeMessage(error?.message || error),
employeeNo,
applicant: null,
userIdPrefix: null,
missing: ["active_employee_wecom_binding"],
};
}
const gaps = [];
if (!applicant) gaps.push("creator_employee_missing");
if (applicant && !isOaActive(applicant)) gaps.push("creator_employee_inactive");
if (applicant && !applicant.wecomExternalUserId) gaps.push("creator_employee_missing_wecom_binding");
if (gaps.length) {
return {
id: "approval_creator_filter",
status: "NEEDS_CONFIG",
reason: "creator_filter_not_ready",
employeeNo,
applicant: sanitizeDiscoveryActor(applicant),
userIdPrefix: null,
gaps,
missing: ["active_employee_wecom_binding"],
};
}
return attachHiddenValue({
id: "approval_creator_filter",
status: "OK",
reason: null,
employeeNo,
applicant: sanitizeDiscoveryActor(applicant),
userIdPrefix: maskApprovalIdentifier(applicant.wecomExternalUserId),
filter: {
key: "creator",
useridPrefix: maskApprovalIdentifier(applicant.wecomExternalUserId),
},
}, "userid", applicant.wecomExternalUserId);
}
async function runLiveTarget({
config,
input,
timeWindow,
options,
creatorFilterCheck,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
@@ -632,13 +768,17 @@ async function runLiveTarget({
do {
pageIndex += 1;
const filters = [{ key: "template_id", value: input.templateId }];
if (creatorFilterCheck?.status === "OK" && creatorFilterCheck.userid) {
filters.push({ key: "creator", value: creatorFilterCheck.userid });
}
const result = await fetchApprovalInfoListImpl({
config,
startTime: timeWindow.startTime,
endTime: timeWindow.endTime,
size: options.size,
nextCursor: cursor,
filters: [{ key: "template_id", value: input.templateId }],
filters,
});
pages.push({
page: pageIndex,
@@ -672,6 +812,10 @@ async function runLiveTarget({
message: blocked.message ? safeMessage(blocked.message) : null,
templateIdPrefix: input.templateIdPrefix,
configuredFrom: input.templateSource,
creatorFilter: creatorFilterCheck?.status === "OK" ? {
employeeNo: creatorFilterCheck.employeeNo,
userIdPrefix: creatorFilterCheck.userIdPrefix,
} : null,
pageCount: pages.length,
spNoCount: 0,
pages,
@@ -727,6 +871,10 @@ async function runLiveTarget({
reason: detailBlocked ? detailBlocked.reason : null,
templateIdPrefix: input.templateIdPrefix,
configuredFrom: input.templateSource,
creatorFilter: creatorFilterCheck?.status === "OK" ? {
employeeNo: creatorFilterCheck.employeeNo,
userIdPrefix: creatorFilterCheck.userIdPrefix,
} : null,
pageCount: pages.length,
spNoCount: uniqueSpNos.length,
pages,
@@ -740,6 +888,7 @@ async function runTargetChecks({
inputs,
timeWindow,
options,
creatorFilterCheck,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
@@ -748,6 +897,8 @@ async function runTargetChecks({
for (const input of inputs) {
if (!input.templateId) {
targetChecks.push(buildMissingTemplateCheck(input));
} else if (options.filterCreatorEmployeeNo && creatorFilterCheck?.status !== "OK") {
targetChecks.push(buildCreatorFilterNotReadyTargetCheck(input, creatorFilterCheck));
} else if (!options.live) {
targetChecks.push(buildDryRunTargetCheck(input));
} else {
@@ -756,6 +907,7 @@ async function runTargetChecks({
input,
timeWindow,
options,
creatorFilterCheck,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
@@ -956,7 +1108,7 @@ function collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compar
gaps.push({
id: "approval_compensation_window_empty",
status: "NEEDS_CONFIG",
reason: "no_existing_oa_poc_approval_in_window",
reason: "no_existing_approval_in_window_for_selected_targets",
});
}
return gaps;
@@ -1148,6 +1300,10 @@ async function executeCompensationPoc(options, deps = {}) {
fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail,
queryApplicantActorImpl: deps.queryApplicantActorImpl || queryApplicantActorRowReadOnly,
});
const creatorFilterCheck = await buildCreatorFilterCheck({
options,
queryApplicantActorImpl: deps.queryApplicantActorImpl || queryApplicantActorRowReadOnly,
});
const resolvedInputs = applyDiscoveredTemplateIds(inputs, discoveryCheck);
if (archivePersistenceCheck.status === "READY") {
try {
@@ -1183,6 +1339,7 @@ async function executeCompensationPoc(options, deps = {}) {
inputs: resolvedInputs,
timeWindow,
options,
creatorFilterCheck,
fetchApprovalInfoListImpl: deps.fetchApprovalInfoListImpl || fetchWecomApprovalInfoList,
fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail,
archivePersistence,
@@ -1245,7 +1402,7 @@ async function executeCompensationPoc(options, deps = {}) {
privacy: REPORT_PRIVACY,
scope: {
issue: "WACP3",
targets: APPROVAL_POC_TARGETS.map((item) => item.key),
targets: selectedTargetKeys(options),
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",
@@ -1265,10 +1422,15 @@ async function executeCompensationPoc(options, deps = {}) {
pageSize: options.size,
maxPages: options.maxPages,
discoveryApplicantEmployeeNo: options.discoverTemplateIds ? options.discoverApplicantEmployeeNo : null,
filterCreatorEmployeeNo: options.filterCreatorEmployeeNo || null,
},
checks: {
secretMatrix: secretCheck,
templateDiscovery: sanitizeDiscoveryCheck(discoveryCheck),
creatorFilter: {
...creatorFilterCheck,
userid: undefined,
},
targets: targetChecks,
callbackEvidence: {
...evidenceCheck,
@@ -9,6 +9,11 @@ const APPROVAL_POC_TARGETS = Object.freeze([
{ key: "leave", label: "请假" },
{ key: "missed_punch", label: "补卡" },
]);
const APPROVAL_HR_ATTENDANCE_TARGETS = Object.freeze([
...APPROVAL_POC_TARGETS,
{ key: "outing", label: "外出" },
{ key: "overtime", label: "加班" },
]);
class WecomApprovalPocError extends Error {
constructor(code, message, details = {}) {
@@ -650,6 +655,7 @@ async function fetchWecomApprovalApplyEvent({
}
module.exports = {
APPROVAL_HR_ATTENDANCE_TARGETS,
APPROVAL_POC_TARGETS,
APPROVAL_APPLY_EVENT_ENDPOINT,
APPROVAL_CREATE_TEMPLATE_ENDPOINT,
@@ -77,7 +77,8 @@ describe("check-wecom-approval-archive", () => {
status: "approved",
limit: 50,
});
expect(() => parseArgs(["--template=outing"])).toThrow(/Unsupported --template/);
expect(parseArgs(["--template=outing", "--template=overtime"]).templates).toEqual(["outing", "overtime"]);
expect(() => parseArgs(["--template=unknown"])).toThrow(/Unsupported --template/);
expect(() => parseArgs(["--limit=101"])).toThrow(/--limit/);
});
@@ -89,8 +89,11 @@ describe("check-wecom-approval-compensation-poc", () => {
"--size=50",
"--template=leave:tpl-leave",
"--template=missed_punch:tpl-missed",
"--target=leave",
"--targets=missed_punch",
"--discover-template-ids",
"--discover-applicant-employee-no=SMOKE002",
"--filter-creator-employee-no=SMOKE002",
]);
expect(options.live).toBe(true);
@@ -98,12 +101,16 @@ describe("check-wecom-approval-compensation-poc", () => {
expect(options.size).toBe(50);
expect(options.discoverTemplateIds).toBe(true);
expect(options.discoverApplicantEmployeeNo).toBe("SMOKE002");
expect(options.filterCreatorEmployeeNo).toBe("SMOKE002");
expect(options.persistToDb).toBe(false);
expect(options.targets).toEqual(["leave", "missed_punch"]);
expect(options.templates).toEqual({
leave: "tpl-leave",
missed_punch: "tpl-missed",
});
expect(() => parseArgs(["--template=outing:tpl"])).toThrow(/unsupported target/);
expect(parseArgs(["--target=outing", "--targets=overtime"]).targets).toEqual(["outing", "overtime"]);
expect(parseArgs(["--template=outing:tpl"]).templates).toMatchObject({ outing: "tpl" });
expect(() => parseArgs(["--target=unknown"])).toThrow(/unsupported target/);
expect(() => parseArgs(["--size=101"])).toThrow(/--size/);
expect(parseArgs(["--persist-to-db"]).persistToDb).toBe(true);
});
@@ -133,6 +140,29 @@ describe("check-wecom-approval-compensation-poc", () => {
});
});
it("builds explicit WEXT1-FORMAL outing and overtime inputs without changing defaults", () => {
const defaultInputs = buildInputs(baseOptions({ templates: {} }), {});
expect(defaultInputs.map((item) => item.key)).toEqual(["leave", "missed_punch"]);
const inputs = buildInputs(baseOptions({
targets: ["outing", "overtime"],
templates: { outing: "cli-outing-template" },
}), {
WECOM_APPROVAL_OUTING_TEMPLATE_ID: "env-outing-template",
WECOM_APPROVAL_OVERTIME_TEMPLATE_ID: "env-overtime-template",
});
expect(inputs.map((item) => item.key)).toEqual(["outing", "overtime"]);
expect(inputs.find((item) => item.key === "outing")).toMatchObject({
templateSource: "cli",
templateIdPrefix: maskApprovalIdentifier("cli-outing-template"),
});
expect(inputs.find((item) => item.key === "overtime")).toMatchObject({
templateSource: "WECOM_APPROVAL_OVERTIME_TEMPLATE_ID",
templateIdPrefix: maskApprovalIdentifier("env-overtime-template"),
});
});
it("documents WECOM_AGENT_SECRET as the only Secret source", () => {
const check = buildSecretCheck(baseConfig());
expect(check).toMatchObject({
@@ -227,6 +257,8 @@ describe("check-wecom-approval-compensation-poc", () => {
it("auto-discovers missing template IDs by applicant creator without leaking full identifiers", async () => {
const discoveredLeaveTemplateId = "tpllv-auto-discovered-leave-sensitive";
const discoveredMissedTemplateId = "tplmp-auto-discovered-missed-sensitive";
const discoveredOutingTemplateId = "tplout-auto-discovered-outing-sensitive";
const discoveredOvertimeTemplateId = "tplot-auto-discovered-overtime-sensitive";
const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => {
if (filters[0].key === "creator") {
return {
@@ -234,10 +266,15 @@ describe("check-wecom-approval-compensation-poc", () => {
tokenSource: "WECOM_AGENT_SECRET",
response: {
errcode: 0,
spNoCount: 2,
spNoPrefixes: [maskApprovalIdentifier("sp-discovery-leave"), maskApprovalIdentifier("sp-discovery-missed")],
spNoCount: 4,
spNoPrefixes: [
maskApprovalIdentifier("sp-discovery-leave"),
maskApprovalIdentifier("sp-discovery-missed"),
maskApprovalIdentifier("sp-discovery-outing"),
maskApprovalIdentifier("sp-discovery-overtime"),
],
},
spNos: ["sp-discovery-leave", "sp-discovery-missed"],
spNos: ["sp-discovery-leave", "sp-discovery-missed", "sp-discovery-outing", "sp-discovery-overtime"],
nextCursor: "",
};
}
@@ -274,13 +311,37 @@ describe("check-wecom-approval-compensation-poc", () => {
response: { errcode: 0, spStatus: "2" },
};
}
if (spNo === "sp-discovery-outing") {
return {
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
templateId: discoveredOutingTemplateId,
spName: "外出",
summary: detailSummary(spNo, { templateIdPrefix: maskApprovalIdentifier(discoveredOutingTemplateId) }),
response: { errcode: 0, spStatus: "2" },
};
}
if (spNo === "sp-discovery-overtime") {
return {
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
templateId: discoveredOvertimeTemplateId,
spName: "加班",
summary: detailSummary(spNo, { templateIdPrefix: maskApprovalIdentifier(discoveredOvertimeTemplateId) }),
response: { errcode: 0, spStatus: "2" },
};
}
return {
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
summary: detailSummary(spNo, {
templateIdPrefix: spNo.startsWith(discoveredLeaveTemplateId)
? maskApprovalIdentifier(discoveredLeaveTemplateId)
: maskApprovalIdentifier(discoveredMissedTemplateId),
: spNo.startsWith(discoveredMissedTemplateId)
? maskApprovalIdentifier(discoveredMissedTemplateId)
: spNo.startsWith(discoveredOutingTemplateId)
? maskApprovalIdentifier(discoveredOutingTemplateId)
: maskApprovalIdentifier(discoveredOvertimeTemplateId),
}),
response: { errcode: 0, spStatus: "2" },
};
@@ -288,6 +349,7 @@ describe("check-wecom-approval-compensation-poc", () => {
const report = await executeCompensationPoc(baseOptions({
templates: {},
targets: ["leave", "missed_punch", "outing", "overtime"],
discoverTemplateIds: true,
discoverApplicantEmployeeNo: "SMOKE002",
}), {
@@ -315,6 +377,18 @@ describe("check-wecom-approval-compensation-poc", () => {
statusLabel: "已通过",
spNoPrefix: maskApprovalIdentifier(`${discoveredMissedTemplateId}-sp`),
},
{
callbackKind: "approval_status_change",
spStatus: "2",
statusLabel: "已通过",
spNoPrefix: maskApprovalIdentifier(`${discoveredOutingTemplateId}-sp`),
},
{
callbackKind: "approval_status_change",
spStatus: "2",
statusLabel: "已通过",
spNoPrefix: maskApprovalIdentifier(`${discoveredOvertimeTemplateId}-sp`),
},
]),
});
@@ -328,13 +402,23 @@ describe("check-wecom-approval-compensation-poc", () => {
expect(fetchApprovalInfoListImpl.mock.calls[2][0].filters).toEqual([
{ key: "template_id", value: discoveredMissedTemplateId },
]);
expect(fetchApprovalInfoListImpl.mock.calls[3][0].filters).toEqual([
{ key: "template_id", value: discoveredOutingTemplateId },
]);
expect(fetchApprovalInfoListImpl.mock.calls[4][0].filters).toEqual([
{ key: "template_id", value: discoveredOvertimeTemplateId },
]);
const serialized = JSON.stringify(report);
expect(serialized).not.toContain(discoveredLeaveTemplateId);
expect(serialized).not.toContain(discoveredMissedTemplateId);
expect(serialized).not.toContain(discoveredOutingTemplateId);
expect(serialized).not.toContain(discoveredOvertimeTemplateId);
expect(serialized).not.toContain(SENSITIVE_USER_ID);
expect(report.checks.templateDiscovery.discoveredTemplatePrefixes).toEqual({
leave: maskApprovalIdentifier(discoveredLeaveTemplateId),
missed_punch: maskApprovalIdentifier(discoveredMissedTemplateId),
outing: maskApprovalIdentifier(discoveredOutingTemplateId),
overtime: maskApprovalIdentifier(discoveredOvertimeTemplateId),
});
});
@@ -402,6 +486,107 @@ describe("check-wecom-approval-compensation-poc", () => {
expect(report.checks.archivePersistence.status).toBe("SKIPPED");
});
it("filters WEXT1-FORMAL template pulls by creator employee to avoid broad formal-template reads", async () => {
const creatorUserId = "formal_creator_userid_should_not_leak";
const outingTemplateId = "outing-template-sensitive";
const overtimeTemplateId = "overtime-template-sensitive";
const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => {
expect(filters).toHaveLength(2);
expect(filters[0].key).toBe("template_id");
expect(filters[1]).toEqual({ key: "creator", value: creatorUserId });
return {
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 }) => ({
ok: true,
tokenSource: "WECOM_AGENT_SECRET",
summary: detailSummary(spNo, { spStatus: "2" }),
response: { errcode: 0, spStatus: "2" },
}));
const report = await executeCompensationPoc(baseOptions({
targets: ["outing", "overtime"],
templates: {
outing: outingTemplateId,
overtime: overtimeTemplateId,
},
filterCreatorEmployeeNo: "SMOKE002",
}), {
config: baseConfig(),
env: {},
generatedAt: "2026-05-20T00:00:00.000Z",
queryApplicantActorImpl: vi.fn(async () => ({
employeeNo: "SMOKE002",
status: "active",
archived: false,
wecomExternalUserId: creatorUserId,
})),
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
readCallbackEvidenceImpl: vi.fn(async () => [
{
callbackKind: "approval_status_change",
spStatus: "2",
statusLabel: "已通过",
spNoPrefix: maskApprovalIdentifier(`${outingTemplateId}-sp`),
},
{
callbackKind: "approval_status_change",
spStatus: "2",
statusLabel: "已通过",
spNoPrefix: maskApprovalIdentifier(`${overtimeTemplateId}-sp`),
},
]),
});
expect(report.status).toBe("PASS_COMPENSATION_POC");
expect(report.checks.creatorFilter).toMatchObject({
status: "OK",
employeeNo: "SMOKE002",
userIdPrefix: maskApprovalIdentifier(creatorUserId),
});
expect(fetchApprovalInfoListImpl).toHaveBeenCalledTimes(2);
const serialized = JSON.stringify(report);
expect(serialized).not.toContain(creatorUserId);
expect(serialized).not.toContain(outingTemplateId);
expect(serialized).not.toContain(overtimeTemplateId);
});
it("does not pull formal templates when the requested creator filter cannot be resolved", async () => {
const fetchApprovalInfoListImpl = vi.fn();
const report = await executeCompensationPoc(baseOptions({
targets: ["outing"],
templates: { outing: "formal-outing-template-sensitive" },
filterCreatorEmployeeNo: "SMOKE002",
}), {
config: baseConfig(),
env: {},
generatedAt: "2026-05-20T00:00:00.000Z",
queryApplicantActorImpl: vi.fn(async () => ({
employeeNo: "SMOKE002",
status: "active",
archived: false,
wecomExternalUserId: "",
})),
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl: vi.fn(),
readCallbackEvidenceImpl: vi.fn(async () => []),
});
expect(report.status).toBe("PARTIAL_COMPENSATION_NEEDS_CONFIG");
expect(fetchApprovalInfoListImpl).not.toHaveBeenCalled();
expect(report.checks.targets[0]).toMatchObject({
target: "outing",
status: "NEEDS_CONFIG",
reason: "creator_filter_not_ready",
});
});
it("requires both --persist-to-db and WECOM_ARCHIVE_WRITE=1 before OA archive writes", async () => {
const report = await executeCompensationPoc(baseOptions({
live: true,