WEXT1F 增加正式模板外出加班取证门禁
这个提交包含在:
@@ -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`.
|
||||
|
||||
@@ -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` 标记。 |
|
||||
|
||||
@@ -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,旧文件保留历史审计语义。
|
||||
|
||||
@@ -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` 工作区模式。
|
||||
|
||||
@@ -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。"
|
||||
|
@@ -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,
|
||||
|
||||
在新工单中引用
屏蔽一个用户