diff --git a/.agents/skills/hyb-oa-delivery/SKILL.md b/.agents/skills/hyb-oa-delivery/SKILL.md index 43ca391d..50cc9096 100644 --- a/.agents/skills/hyb-oa-delivery/SKILL.md +++ b/.agents/skills/hyb-oa-delivery/SKILL.md @@ -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`. diff --git a/docs/trialrun/人工试运行报告.md b/docs/trialrun/人工试运行报告.md index 7f099f8f..99a4cfa1 100644 --- a/docs/trialrun/人工试运行报告.md +++ b/docs/trialrun/人工试运行报告.md @@ -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 的详细流水时,使用: diff --git a/docs/企微优先开发路线图.md b/docs/企微优先开发路线图.md index 8b2de1ad..c7560719 100644 --- a/docs/企微优先开发路线图.md +++ b/docs/企微优先开发路线图.md @@ -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` 标记。 | diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md index c7f2ddce..d71f225b 100644 --- a/docs/文档事实源状态矩阵.md +++ b/docs/文档事实源状态矩阵.md @@ -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。 diff --git a/docs/文档索引.md b/docs/文档索引.md index e65f5544..c3edb8b3 100644 --- a/docs/文档索引.md +++ b/docs/文档索引.md @@ -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,旧文件保留历史审计语义。 diff --git a/docs/管理员手册.md b/docs/管理员手册.md index 6a8709c7..f34a8fe0 100644 --- a/docs/管理员手册.md +++ b/docs/管理员手册.md @@ -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` 工作区模式。 diff --git a/docs/需求-统一审批中心规约.md b/docs/需求-统一审批中心规约.md index afb4282d..aec27c91 100644 --- a/docs/需求-统一审批中心规约.md +++ b/docs/需求-统一审批中心规约.md @@ -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、仓库文档或聊天。 diff --git a/issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv b/issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv new file mode 100644 index 00000000..a49cb47c --- /dev/null +++ b/issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.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-/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 ;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue --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。" diff --git a/scripts/qa/run-wecom-formal-outing-overtime-evidence.sh b/scripts/qa/run-wecom-formal-outing-overtime-evidence.sh new file mode 100755 index 00000000..d3fa2dc3 --- /dev/null +++ b/scripts/qa/run-wecom-formal-outing-overtime-evidence.sh @@ -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 diff --git a/server/src/scripts/check-wecom-approval-archive.js b/server/src/scripts/check-wecom-approval-archive.js index f444a5a3..8fc8d4dc 100644 --- a/server/src/scripts/check-wecom-approval-archive.js +++ b/server/src/scripts/check-wecom-approval-archive.js @@ -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= 报告目录,默认 artifacts/qa-flow/wecom-approval-archive-。 --template=leave 只检查请假留存。 --template=missed_punch 只检查补卡留存。 + --template=outing 只检查外出留存。 + --template=overtime 只检查加班留存。 --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}`); } } diff --git a/server/src/scripts/check-wecom-approval-compensation-poc.js b/server/src/scripts/check-wecom-approval-compensation-poc.js index 501373c3..7f38c91d 100644 --- a/server/src/scripts/check-wecom-approval-compensation-poc.js +++ b/server/src/scripts/check-wecom-approval-compensation-poc.js @@ -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= 窗口结束时间,默认当前时间。 --template=leave: 指定请假模板 ID,用于 template_id filter。 --template=missed_punch: 指定补卡模板 ID,用于 template_id filter。 + --template=outing: 指定外出模板 ID,用于 WEXT1-FORMAL 正式模板只读证据。 + --template=overtime: 指定加班模板 ID,用于 WEXT1-FORMAL 正式模板只读证据。 + --target= 显式目标,可重复;默认 leave + missed_punch。 + --targets= 显式目标列表;支持 leave, missed_punch, outing, overtime。 --discover-template-ids 缺模板 ID 时,按申请人只读发现 OA-POC 模板 ID。 --discover-applicant-employee-no= 自动发现使用的 OA 申请人员工号,默认 SMOKE002。 + --filter-creator-employee-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, diff --git a/server/src/services/integrations/wecomApproval.js b/server/src/services/integrations/wecomApproval.js index d1989211..e414ce3f 100644 --- a/server/src/services/integrations/wecomApproval.js +++ b/server/src/services/integrations/wecomApproval.js @@ -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, diff --git a/server/tests/unit/scripts/check-wecom-approval-archive.test.js b/server/tests/unit/scripts/check-wecom-approval-archive.test.js index fc9e4d6d..cfdac472 100644 --- a/server/tests/unit/scripts/check-wecom-approval-archive.test.js +++ b/server/tests/unit/scripts/check-wecom-approval-archive.test.js @@ -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/); }); diff --git a/server/tests/unit/scripts/check-wecom-approval-compensation-poc.test.js b/server/tests/unit/scripts/check-wecom-approval-compensation-poc.test.js index 236d14e4..ae1aeee9 100644 --- a/server/tests/unit/scripts/check-wecom-approval-compensation-poc.test.js +++ b/server/tests/unit/scripts/check-wecom-approval-compensation-poc.test.js @@ -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,