diff --git a/.agents/skills/hyb-oa-delivery/SKILL.md b/.agents/skills/hyb-oa-delivery/SKILL.md index 6bf14650..91b18a04 100644 --- a/.agents/skills/hyb-oa-delivery/SKILL.md +++ b/.agents/skills/hyb-oa-delivery/SKILL.md @@ -17,7 +17,7 @@ Read only what is relevant: - For approval workflow, roles, self-approval, notification, deeplink, reminder, or escalation work, read `docs/需求-统一审批中心规约.md`. - For HR lifecycle, employee onboarding/offboarding, Enterprise WeChat directory sync, account disablement, or Enterprise WeChat API work, read `docs/需求-企业微信基础设施规约.md`, `docs/企微API能力目录.md`, `docs/管理员手册.md`, and the latest HCLT/HRWD/WAPI/WRRA Issue CSV evidence first. - For any Enterprise WeChat capability, Secret, callback, approval, check-in, HR assistant, or contact-directory task, read `docs/企微API能力目录.md` before planning or coding. WAPI fixes the current Secret matrix: contacts use `WECOM_CONTACT_SECRET`; workbench/OAuth/messages/HR assistant/roster/check-in/approval use `WECOM_AGENT_SECRET`; `WECOM_CHECKIN_SECRET` is only an explicit check-in override; `WECOM_HR_ASSISTANT_SECRET` is historical compatibility and not a default. Do not write runtime code that tries multiple Secrets to guess the correct one. -- For Enterprise WeChat-first HR/attendance runtime migration, read `docs/企微优先开发路线图.md` before planning or coding. WDRG makes the roadmap authoritative for stage order; WAPI adds the runtime-first stage before deep mirror work; WRRA states that WOUA `PASS_WITH_FINDINGS` does not clear deep mirror work; WOUA-FIX has now soft-archived the 7 OA active test accounts missing in WeCom, fixed the `T0000` seed reactivation behavior, transferred the `SMOKE201` test-manager side effects, and rerun WOUA with `oaActiveMissingInWecom=0`. WCPC has rerun the capability POC with an existing active test account and reached `GO_TO_HR_MASTER_SYNC`; WHMS/WHMSLIVE has implemented, deployed, and publicly verified `wecom_hr_master_sync_v1`, with the production flag restored to `force_off` and production real-employee batch apply not approved. WMSG1 proves real app-message delivery and Deeplink return only; WAPC1 fixes the approval configuration preparation checklist before mirror work. Do not start `wecom_attendance_approval_mirror_v1`, `oa_hr_emergency_mode_v1`, `wecom_recovery_writeback_v1`, or `wecom_ruleops_v1` without the next stage's own Issue CSV. +- For Enterprise WeChat-first HR/attendance runtime migration, read `docs/企微优先开发路线图.md` before planning or coding. WDRG makes the roadmap authoritative for stage order; WAPI adds the runtime-first stage before deep mirror work; WRRA states that WOUA `PASS_WITH_FINDINGS` does not clear deep mirror work; WOUA-FIX has now soft-archived the 7 OA active test accounts missing in WeCom, fixed the `T0000` seed reactivation behavior, transferred the `SMOKE201` test-manager side effects, and rerun WOUA with `oaActiveMissingInWecom=0`. WCPC has rerun the capability POC with an existing active test account and reached `GO_TO_HR_MASTER_SYNC`; WHMS/WHMSLIVE has implemented, deployed, and publicly verified `wecom_hr_master_sync_v1`, with the production flag restored to `force_off` and production real-employee batch apply not approved. WMSG1 proves real app-message delivery and Deeplink return only; WAPC1 fixes the approval configuration preparation checklist before mirror work; WAPC2 adds the leave/missed-punch approval template and low-risk `sp_no` read-only POC runner. Do not start `wecom_attendance_approval_mirror_v1`, `oa_hr_emergency_mode_v1`, `wecom_recovery_writeback_v1`, or `wecom_ruleops_v1` without the next stage's own Issue CSV. - For reimbursement, invoice, OCR, duplicate invoice, or attachment work, read `docs/报销发票附件规则.md` and `references/reimbursement.md`. - For local/WeCom/public trial-run, OCR runtime, Caddy, or DB drift work, read `docs/试运行与自动化验证手册.md` and `references/trialrun.md`. - For how this skill should evolve, read `docs/Agent开发交付实践.md`. @@ -106,9 +106,10 @@ Final responses should include: - R6 scope defaults to reimbursement, leave, procurement, approval, Enterprise WeChat entry/notification, finance payment/export. - Do not expand to budget, ERP, AI, full travel, or complex BPM during R6; record those as follow-up Issues. - Enterprise WeChat personal app message is the formal approval notification channel after R5; mock and group bots are not acceptance evidence for R6 business notification. -- After WAPI, ERP is not a future OA integration target. HR/attendance employee daily approvals (leave, missed punch, outing, overtime) should reuse mature Enterprise WeChat capabilities first, while OA provides domain-based mirror, manual takeover, recovery writeback, finance closeout, and audit. Reimbursement, payment batches, AP, accounting handoff, procurement, expenses, contracts, projects, sensitive attachments, invoices, bank receipts, and accounting handoff materials remain OA-primary unless a later Issue changes that domain explicitly. OA-primary approvals may send Enterprise WeChat personal-app notifications, but messages must contain only dehydrated title/type/no/status/deeplink; do not upload procurement forms, contract bodies, quotes, supplier files, invoices, bank receipts, or accounting materials to WeCom native approvals or WeCom attachments. Before runtime migration, read `docs/企微API能力目录.md` and `docs/企微优先开发路线图.md`, run or check `WECOM-CAPABILITY-POC-V1`/WCPC, and classify each capability as `CAN_REUSE_NOW`, `NEEDS_CONFIG`, `NEEDS_API_POC`, `KEEP_OA_PRIMARY`, or `NOT_SUPPORTED_BY_API`; missing Secret, callback, template, trusted IP, visible scope, or API permission must be recorded as `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`, not PASS. Current WAPI rule: contacts read with `WECOM_CONTACT_SECRET` and `POST /cgi-bin/user/list_id`; HR assistant/roster uses `WECOM_AGENT_SECRET` and `POST /cgi-bin/hr/get_staff_info`; check-in defaults to `WECOM_AGENT_SECRET` with optional explicit `WECOM_CHECKIN_SECRET`; no runtime Secret guessing. Current WCPC status: existing active test account `SMOKE002/测试员工` (reported only as `oa_smoke***`) reran successfully with report `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json`, `status=PASS`, `conclusion=GO_TO_HR_MASTER_SYNC`, and `blockingCapabilities=[]`. Current WHMSLIVE status: `wecom_hr_master_sync_v1` is implemented and deployed with feature flag default/final `force_off`, HR/contact read service, dry-run/apply, sync run/item records, People management card, and QA-only runner `scripts/qa/check-wecom-hr-master-sync.mjs`; public verification applied migration `20260518184319_wecom_hr_master_sync_v1`, temporarily forced the flag on, ran dry-run and explicit apply for `SMOKE002` (`oa_smoke***`), verified ordinary employee API denial with 403, then restored `force_off`. The SMOKE002 apply did not write OA master data because WeCom lacks phone/email/department and was correctly skipped as `missing_required_field`; production real-employee batch apply is not approved. WHMS rules: missing OA-required fields are skipped as `missing_required_field`, inactive WeCom status becomes `needs_offboarding_confirmation`, and OA accounts are not auto-disabled. WOUA-FIX rerun result remains `PASS_WITH_FINDINGS` only because 5 matched WeCom users lack accepted fields and one archived test binding is retained; `oaActiveMissingInWecom=0`, active OA employees 5/5 match WeCom. WRV1 adds QA-only `npm run check:wecom-runtime-ready` to verify entry, OAuth/bindings, dehydrated app-message dry-run, OA deeplink, contact `user/list_id`, HR assistant `get_staff_info`, and check-in short-window reads; WRA1 fixes the check-in gate so `WECOM_CHECKIN_SYNC_ENABLED` remains only the formal sync-batch switch, while QA-only runtime checks pass `requireSyncEnabled=false` for read-only probing and still block on real Secret, trusted IP, visible scope, permission, or WeCom API failures. WRA1 public report `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` is `PASS_WITH_CONFIG_GAPS`: entry/OAuth, Secret matrix, OA active bindings, contact, HR assistant, message dry-run, and check-in read passed, with check-in recorded as `readable_sync_disabled`. Real message sending still requires both `--send-test-message` and `WECOM_RUNTIME_TEST_SEND=1`. Approval template ID, approval `sp_no`, callback config, attendance/approval mirror, emergency mode, recovery writeback, RuleOps, and HR write APIs remain later-stage gaps. WAPC1 fixed the approval configuration preparation checklist but did not implement callbacks or mirror. Do not skip stage order: `WECOM-CAPABILITY-POC-V1`/WCPC -> `wecom_hr_master_sync_v1`/WHMSLIVE -> `runtime_first_wecom_enablement`/WAPI+WOUA rerun -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`. UI and API work must distinguish `OA 主办`, `企微镜像`, and `OA 应急接管`. OA-driven Enterprise WeChat directory create/update/disable is live verified; for later HR/WeCom directory issues, first check configuration, permission, department, and data-specific causes before treating the capability as unfinished. +- After WAPI, ERP is not a future OA integration target. HR/attendance employee daily approvals (leave, missed punch, outing, overtime) should reuse mature Enterprise WeChat capabilities first, while OA provides domain-based mirror, manual takeover, recovery writeback, finance closeout, and audit. Reimbursement, payment batches, AP, accounting handoff, procurement, expenses, contracts, projects, sensitive attachments, invoices, bank receipts, and accounting handoff materials remain OA-primary unless a later Issue changes that domain explicitly. OA-primary approvals may send Enterprise WeChat personal-app notifications, but messages must contain only dehydrated title/type/no/status/deeplink; do not upload procurement forms, contract bodies, quotes, supplier files, invoices, bank receipts, or accounting materials to WeCom native approvals or WeCom attachments. Before runtime migration, read `docs/企微API能力目录.md` and `docs/企微优先开发路线图.md`, run or check `WECOM-CAPABILITY-POC-V1`/WCPC, and classify each capability as `CAN_REUSE_NOW`, `NEEDS_CONFIG`, `NEEDS_API_POC`, `KEEP_OA_PRIMARY`, or `NOT_SUPPORTED_BY_API`; missing Secret, callback, template, trusted IP, visible scope, or API permission must be recorded as `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`, not PASS. Current WAPI rule: contacts read with `WECOM_CONTACT_SECRET` and `POST /cgi-bin/user/list_id`; HR assistant/roster uses `WECOM_AGENT_SECRET` and `POST /cgi-bin/hr/get_staff_info`; check-in defaults to `WECOM_AGENT_SECRET` with optional explicit `WECOM_CHECKIN_SECRET`; approval template/detail reads use `WECOM_AGENT_SECRET`; no runtime Secret guessing. Current WCPC status: existing active test account `SMOKE002/测试员工` (reported only as `oa_smoke***`) reran successfully with report `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json`, `status=PASS`, `conclusion=GO_TO_HR_MASTER_SYNC`, and `blockingCapabilities=[]`. Current WHMSLIVE status: `wecom_hr_master_sync_v1` is implemented and deployed with feature flag default/final `force_off`, HR/contact read service, dry-run/apply, sync run/item records, People management card, and QA-only runner `scripts/qa/check-wecom-hr-master-sync.mjs`; public verification applied migration `20260518184319_wecom_hr_master_sync_v1`, temporarily forced the flag on, ran dry-run and explicit apply for `SMOKE002` (`oa_smoke***`), verified ordinary employee API denial with 403, then restored `force_off`. The SMOKE002 apply did not write OA master data because WeCom lacks phone/email/department and was correctly skipped as `missing_required_field`; production real-employee batch apply is not approved. WHMS rules: missing OA-required fields are skipped as `missing_required_field`, inactive WeCom status becomes `needs_offboarding_confirmation`, and OA accounts are not auto-disabled. WOUA-FIX rerun result remains `PASS_WITH_FINDINGS` only because 5 matched WeCom users lack accepted fields and one archived test binding is retained; `oaActiveMissingInWecom=0`, active OA employees 5/5 match WeCom. WRV1 adds QA-only `npm run check:wecom-runtime-ready` to verify entry, OAuth/bindings, dehydrated app-message dry-run, OA deeplink, contact `user/list_id`, HR assistant `get_staff_info`, and check-in short-window reads; WRA1 fixes the check-in gate so `WECOM_CHECKIN_SYNC_ENABLED` remains only the formal sync-batch switch, while QA-only runtime checks pass `requireSyncEnabled=false` for read-only probing and still block on real Secret, trusted IP, visible scope, permission, or WeCom API failures. WRA1 public report `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` is `PASS_WITH_CONFIG_GAPS`: entry/OAuth, Secret matrix, OA active bindings, contact, HR assistant, message dry-run, and check-in read passed, with check-in recorded as `readable_sync_disabled`. Real message sending still requires both `--send-test-message` and `WECOM_RUNTIME_TEST_SEND=1`. Approval template ID, approval `sp_no`, callback config, attendance/approval mirror, emergency mode, recovery writeback, RuleOps, and HR write APIs remain later-stage gaps. WAPC1 fixed the approval configuration preparation checklist but did not implement callbacks or mirror. WAPC2 adds `npm run check:wecom-approval-poc` for leave/missed-punch `gettemplatedetail` and low-risk `getapprovaldetail`; missing template ID or `sp_no` must remain `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`, and API/permission/trusted-IP/scope failures remain `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`. Do not skip stage order: `WECOM-CAPABILITY-POC-V1`/WCPC -> `wecom_hr_master_sync_v1`/WHMSLIVE -> `runtime_first_wecom_enablement`/WAPI+WOUA rerun -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`. UI and API work must distinguish `OA 主办`, `企微镜像`, and `OA 应急接管`. OA-driven Enterprise WeChat directory create/update/disable is live verified; for later HR/WeCom directory issues, first check configuration, permission, department, and data-specific causes before treating the capability as unfinished. - WMSG1 has verified real Enterprise WeChat personal-app delivery and Deeplink return: report `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` has `checks.message.status=OK`, `errcode=0`, and `recipientCount=1`; the user confirmed receiving "OA 运行就绪检查" and opening the OA notifications page from the message. This only clears message/deeplink runtime evidence; approval template ID, approval `sp_no`, callback config, and deep mirror work remain separate later gates. - WAPC1 has fixed the WeCom approval configuration preparation gate: first templates are leave, missed punch, outing, and overtime; each needs template ID, scope, fields, approver rules, visible scope, and self-built app permission; low-risk test `sp_no` must not contain procurement, contract, reimbursement, invoice, bank receipt, sensitive attachment, or real sensitive business material; callback URL/Token/EncodingAESKey are deployment-only values and must not be written to repo docs or Issue CSV. Do not implement real approval callback handling, deep mirror, emergency mode, recovery writeback, or RuleOps until a later approved Issue covers the POC or implementation. +- WAPC2 has added the WeCom approval template/detail read-only POC runner for leave and missed punch. Use `npm run check:wecom-approval-poc`; pass template IDs with `--template=leave:` / `--template=missed_punch:` and low-risk test approval numbers with `--sp-no=leave:` / `--sp-no=missed_punch:`. The runner must use only `WECOM_AGENT_SECRET`, redact full template IDs and `sp_no`, and never output form values, attachments, comments, full userid, token, Secret, or raw payload. - Approval-chain correctness can be proven by PC or automation, while Enterprise WeChat validates entry, binding,免登录, notification, deeplink, and mobile layout. - If real-device mobile validation is paused after automation passes, record the gate as `PAUSED`; do not close RS6/UI6/R6 mobile blockers until representative real-device confirmation is recorded. - Role-specific pages should use one shared OA/mobile framework and tailor summaries, entries, actions, and sensitive fields by role. Do not create unrelated UI systems for employee, approver, finance, admin, or CEO views. diff --git a/docs/trialrun/人工试运行报告.md b/docs/trialrun/人工试运行报告.md index 73d85122..19a3f64a 100644 --- a/docs/trialrun/人工试运行报告.md +++ b/docs/trialrun/人工试运行报告.md @@ -53,6 +53,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 - 2026-05-19 WRA1 修正 WRV1 运行验收门禁并完成公网复跑:`WECOM_CHECKIN_SYNC_ENABLED` 保留为正式考勤同步批次开关,但 QA-only 打卡短窗口读取显式使用 `requireSyncEnabled=false` 做只读探测,不再把同步开关未启用误判为运行主链路阻断。远端报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` 结论为 `PASS_WITH_CONFIG_GAPS`:入口/OAuth、Secret 矩阵、OA active 绑定、通讯录、人事助手、消息 dry-run 和打卡只读链路均通过,打卡读取记录 `readable_sync_disabled`;审批模板、测试 `sp_no` 和真实回调继续为 `NEEDS_CONFIG`。未发送真实消息,未写 OA/企微用户资料。 - 2026-05-19 WMSG1 完成企微应用消息实发与 Deeplink 真机验证:使用现有 `check:wecom-runtime-ready` runner,在远端容器内仅对单次命令设置 `WECOM_RUNTIME_TEST_SEND=1`,向管理员本人对应 OA 员工号 `HYD000006` 发送一条脱敏运行就绪消息。第一次按 `wangshunli` 参数执行未命中 OA `employee_no`,`recipientCount=0` 且未实际发出;随后按 `HYD000006` 重跑成功,报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` 显示 `checks.message.status=OK`、`code=wecom_app_sent`、`errcode=0`、`recipientCount=1`、收件企微前缀 `WangShun***`。用户确认企业微信可收到“OA 运行就绪检查”,点击“打开 OA”后可进入 OA 通知页面,结论 `PASS_MESSAGE_DELIVERY_CONFIRMED`。本轮不补企微资料、不配置审批模板/`sp_no`/真实回调、不进入深度镜像。 - 2026-05-19 WAPC1 完成企微审批配置准备清单回写:WMSG1 只证明消息和 Deeplink 可用,不放行 `wecom_attendance_approval_mirror_v1`。本轮固化首批模板为请假、补卡、外出、加班;每个模板需准备模板 ID、适用范围、字段项、审批人规则、可见范围和所属自建应用权限;低风险测试 `sp_no` 不得包含采购、合同、报销、发票、银行回单或高敏附件;真实回调 Token/EncodingAESKey 只允许保存在部署环境,不写入文档或 Issue。后续 POC 需验证模板详情、审批详情、回调验签/解密/幂等、重试语义和补偿拉取。本轮不改业务代码、不部署、不接真实回调、不调用企微写接口。 +- 2026-05-19 WAPC2 新增企微审批模板与低风险 `sp_no` 只读 POC:新增 `server/src/scripts/check-wecom-approval-poc.js` / `npm run check:wecom-approval-poc` 和只读集成服务,范围先锁定请假、补卡;固定 `WECOM_AGENT_SECRET` 调用 `POST /cgi-bin/oa/gettemplatedetail` 与 `POST /cgi-bin/oa/getapprovaldetail`,报告只输出脱敏模板 ID/`sp_no` 前缀、字段类型计数、审批节点计数和错误分类。本地 focused vitest 已覆盖 Secret 固定选择、无 fallback、缺配置、API 错误分类和脱敏输出。已以 `SYNC_ENV=0` 部署并远端执行 live 只读 POC,报告 `/app/artifacts/qa-flow/wecom-approval-poc-20260519T020429Z/summary.json` 结论为 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`:远端 `WECOM_AGENT_SECRET` 可用,但缺请假/补卡模板 ID 和两条低风险 `sp_no`;未伪造成 PASS。本轮不接真实回调、不调用企微写接口、不写 OA/企微数据、不进入深度镜像。 ## 关键证据索引 @@ -104,6 +105,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | WRV1 | 已部署并公网只读验证:`check-wecom-runtime-ready` 返回 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`,阻断点为 `WECOM_CHECKIN_SYNC_ENABLED=true` 未启用;入口/OAuth、Secret 矩阵、OA active 绑定、通讯录、人事助手和消息 dry-run 已通过;审批模板、测试 `sp_no`、真实回调缺口记为 `NEEDS_CONFIG`,不放行深度镜像 | `issues/2026-05-19_01-10-32-wecom-runtime-ready-v1.csv`、`server/src/scripts/check-wecom-runtime-ready.js`、`/app/artifacts/qa-flow/wecom-runtime-ready-20260518T173402Z/summary.json` | | WMSG1 | 已完成:企微应用消息真实发送和 Deeplink 真机确认通过;报告 `checks.message.status=OK`、`errcode=0`、`recipientCount=1`,用户确认收到消息并可点击进入 OA 通知页面 | `issues/2026-05-19_07-41-37-wecom-message-live-verify-v1.csv`、`/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` | | WAPC1 | 已完成:企微审批配置准备清单已回写;首批模板为请假、补卡、外出、加班;低风险 `sp_no`、真实回调和后续 POC 准入项已固定;不代表审批镜像或回调接收已实现 | `issues/2026-05-19_08-44-58-wecom-approval-config-prep-v1.csv`、`docs/企微优先开发路线图.md`、`docs/企微API能力目录.md` | +| WAPC2 | 已部署并公网只读复跑:新增请假/补卡模板详情和低风险审批详情 QA-only 只读 POC runner;远端 Secret 矩阵 OK,因缺请假/补卡模板 ID 和两条低风险 `sp_no` 返回 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`;不代表真实回调或审批镜像已实现 | `issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv`、`server/src/scripts/check-wecom-approval-poc.js`、`server/src/services/integrations/wecomApproval.js`、`/app/artifacts/qa-flow/wecom-approval-poc-20260519T020429Z/summary.json` | ## 当前门禁 diff --git a/docs/企微API能力目录.md b/docs/企微API能力目录.md index 3d95bd5b..683ad566 100644 --- a/docs/企微API能力目录.md +++ b/docs/企微API能力目录.md @@ -2,8 +2,8 @@ **文档状态**:当前企微 API 调研与本项目使用事实源。 **分类**:集成事实源 / API 能力目录。 -**适用时间**:WAPI / WAPC1 之后。 -**关联 Issue**:`WAPI,WAPC1`。 +**适用时间**:WAPI / WAPC1 / WAPC2 之后。 +**关联 Issue**:`WAPI,WAPC1,WAPC2`。 **最近核对**:2026-05-19。 **不得误读**:本文只记录本项目会用到的企业微信官方 API 摘要、链接和项目决策,不复制官方文档全文,不保存 Secret、access token、完整 userid、手机号、邮箱或企微原始 payload。官方文档和企业微信后台权限配置始终是最终依据。 @@ -52,8 +52,8 @@ | 员工打卡规则 | [获取员工打卡规则](https://developer.work.weixin.qq.com/document/path/90263) | `/cgi-bin/checkin/getcheckinoption` | `POST` | `WECOM_AGENT_SECRET` 或显式 `WECOM_CHECKIN_SECRET` | `CAN_REUSE_NOW_FOR_READ` | 可作为后续考勤解释和规则快照依据。 | | 打卡日报/月报/排班 | [日报](https://developer.work.weixin.qq.com/document/path/93374)、[月报](https://developer.work.weixin.qq.com/document/path/93387)、[排班](https://developer.work.weixin.qq.com/document/path/93380) | 官方打卡扩展接口 | `POST` 为主 | `WECOM_AGENT_SECRET` 或显式 `WECOM_CHECKIN_SECRET` | `NEEDS_API_POC` | 不进入当前运行优先主线;后续考勤镜像合同时再做只读 POC。 | | 审批概述 | [审批](https://developer.work.weixin.qq.com/document/path/91854) | 模板、实例、回调、假期 | 混合 | `WECOM_AGENT_SECRET` | `PARTIAL_NEEDS_CONFIG` | HR/考勤员工日常审批可优先在企微处理,但 OA 镜像尚未实现。 | -| 审批模板详情 | [获取审批模板详情](https://developer.work.weixin.qq.com/document/path/91982) | `/cgi-bin/oa/gettemplatedetail` | `POST` | `WECOM_AGENT_SECRET` | `NEEDS_CONFIG` | WAPC1 首批需准备请假、补卡、外出、加班模板 ID;缺模板不得进入镜像实现。 | -| 审批申请详情 | [获取审批申请详情](https://developer.work.weixin.qq.com/document/path/91983) | `/cgi-bin/oa/getapprovaldetail` | `POST` | `WECOM_AGENT_SECRET` | `NEEDS_CONFIG` | 需要低风险测试审批单号 `sp_no`;不得上传采购、合同、报销、发票、银行回单或高敏附件做测试资料。 | +| 审批模板详情 | [获取审批模板详情](https://developer.work.weixin.qq.com/document/path/91982) | `/cgi-bin/oa/gettemplatedetail` | `POST` | `WECOM_AGENT_SECRET` | `POC_RUNNER_READY_NEEDS_CONFIG` | WAPC2 已新增请假/补卡只读 POC runner;缺模板 ID 时报告 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`,不得进入镜像实现。 | +| 审批申请详情 | [获取审批申请详情](https://developer.work.weixin.qq.com/document/path/91983) | `/cgi-bin/oa/getapprovaldetail` | `POST` | `WECOM_AGENT_SECRET` | `POC_RUNNER_READY_NEEDS_CONFIG` | WAPC2 已新增低风险 `sp_no` 只读 POC runner;测试单不得包含采购、合同、报销、发票、银行回单或高敏附件。 | | 审批状态回调 | [审批申请状态变化回调通知](https://developer.work.weixin.qq.com/document/path/91815) | 事件回调 | `POST` 事件 | `WECOM_AGENT_SECRET` 所属应用 | `NEEDS_CONFIG` | 需要真实回调 URL、Token、EncodingAESKey、验签、解密、幂等和重试设计;文档和报告只记录配置项名称,不保存密钥值。 | | 批量获取审批单号 | [批量获取审批单号](https://developer.work.weixin.qq.com/document/path/91816) | 官方审批单查询接口 | `POST` | `WECOM_AGENT_SECRET` | `NEEDS_API_POC` | 作为补偿拉取候选,WAPC1 后续 POC 前不得假定可用。 | | 提交审批申请 | [提交审批申请](https://developer.work.weixin.qq.com/document/path/91853) | 官方提交审批接口 | `POST` | `WECOM_AGENT_SECRET` | `NOT_IN_CURRENT_SCOPE_WRITE` | 本项目当前不把 OA 主办报销、采购、合同、项目迁到企微原生审批。 | @@ -72,13 +72,26 @@ WAPC1 后,企微审批相关能力进入“配置准备”状态。该状态 - 回调配置:公网 Callback URL、Token、EncodingAESKey 只进入部署环境;本地文档只记录变量名和配置状态。 - 后续 POC:模板详情读取、审批详情读取、回调验签/解密、事件幂等、重试语义和补偿拉取方案。 -## 5. 运行优先结论 +## 5. WAPC2 请假/补卡只读 POC + +WAPC2 将 WAPC1 的准备项先落为可运行的 QA-only 只读 POC,范围只包括请假和补卡。 + +- runner:`server/src/scripts/check-wecom-approval-poc.js` / `npm run check:wecom-approval-poc`。 +- 模板参数:`--template=leave:`、`--template=missed_punch:` 或同义临时 env。 +- 审批单参数:`--sp-no=leave:`、`--sp-no=missed_punch:` 或同义临时 env。 +- Secret:固定 `WECOM_AGENT_SECRET`;不使用通讯录、打卡或历史 HR assistant Secret 兜底。 +- 报告:只输出模板类型、配置状态、脱敏模板 ID/`sp_no` 前缀、字段类型计数、审批节点计数和错误分类;不输出表单值、附件、评论、完整 `sp_no` 或原始 payload。 +- 结论:全部可读为 `PASS_APPROVAL_POC`;缺模板或 `sp_no` 为 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`;权限、可信 IP、可见范围、Secret 或企微 API 错误为 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 + +WAPC2 不代表真实回调、审批镜像、应急接管、恢复回写或 RuleOps 已实现。 + +## 6. 运行优先结论 - 近期优先利用:企业微信工作台入口、OAuth/免登、绑定、个人应用消息、OA deeplink、通讯录 userid 清单、可选成员详情、人事助手花名册读取、打卡记录和打卡规则读取。 -- 暂缓深度镜像:`wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1`、`wecom_ruleops_v1` 均等 WOUA 差异收口或 WHMS 同步窗口决策、WAPC1 审批配置准备清单、审批模板 ID、测试 `sp_no` 和真实回调配置明确后再拆。 +- 暂缓深度镜像:`wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1`、`wecom_ruleops_v1` 均等 WOUA 差异收口或 WHMS 同步窗口决策、WAPC1 审批配置准备清单、WAPC2 请假/补卡只读 POC、外出/加班模板测试、真实回调配置和补偿拉取方案明确后再拆。 - OA 主办边界不变:报销、采购、费用、合同、项目、付款批次、AP、代账交接、审计和高敏附件继续在 OA 内完成,只向企业微信发送脱敏提醒和 OA 链接。 -## 6. 后续使用要求 +## 7. 后续使用要求 - 新增企微能力前,先在本文补一行能力记录,再写 Issue CSV。 - POC 输出统一使用 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY`、`NOT_SUPPORTED_BY_API`、`SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 diff --git a/docs/企微优先开发路线图.md b/docs/企微优先开发路线图.md index 21341223..81fbf341 100644 --- a/docs/企微优先开发路线图.md +++ b/docs/企微优先开发路线图.md @@ -2,10 +2,10 @@ **文档状态**:当前开发路线图事实源。 **分类**:开发治理 / 路线图。 -**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 之后。 -**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1`。 +**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 / WAPC2 之后。 +**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2`。 **最近审计**:2026-05-19。 -**不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。WHMSLIVE 后只代表企微 HR 主数据镜像已部署并受控验证;WAPI 后进一步固定企微 API/Secret 口径并转为运行优先;WRRA 后明确 `PASS_WITH_FINDINGS` 只代表只读对账成功且存在差异,不代表企微考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成运行时实现。WMSG1 只证明企业微信应用消息可达和 Deeplink 可回到 OA;WAPC1 只固化审批配置准备清单,不代表审批模板、`sp_no`、真实回调或镜像能力已完成。 +**不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。WHMSLIVE 后只代表企微 HR 主数据镜像已部署并受控验证;WAPI 后进一步固定企微 API/Secret 口径并转为运行优先;WRRA 后明确 `PASS_WITH_FINDINGS` 只代表只读对账成功且存在差异,不代表企微考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成运行时实现。WMSG1 只证明企业微信应用消息可达和 Deeplink 可回到 OA;WAPC1 只固化审批配置准备清单;WAPC2 只新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC,不代表真实回调或镜像能力已完成。 ## 1. 路线图原则 @@ -34,6 +34,7 @@ | `WRA1` | 已部署并公网复跑 | 修正 WRV1 门禁误判:`WECOM_CHECKIN_SYNC_ENABLED` 保留为正式考勤同步开关,但 QA-only 运行验收打卡只读探测显式使用 `requireSyncEnabled=false`。公网报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` 为 `PASS_WITH_CONFIG_GAPS`:打卡 API 只读可读且记录 `readable_sync_disabled`,审批模板、测试 `sp_no` 和真实回调仍为 `NEEDS_CONFIG`。 | | `WMSG1` | 已完成真机确认 | 使用现有 `check:wecom-runtime-ready` runner 单次发送脱敏企微应用消息;报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` 显示 `checks.message.status=OK`、`errcode=0`、`recipientCount=1`,用户确认收到“OA 运行就绪检查”并可点击进入 OA 通知页面。 | | `WAPC1` | 配置准备清单已固化 | WMSG1 后的下一步改为审批配置准备清单:首批关注请假、补卡、外出、加班模板;准备模板 ID、低风险测试 `sp_no`、真实回调 URL/Token/EncodingAESKey 和后续 POC 准入项。本阶段不接真实回调、不调用写接口、不进入深度镜像。 | +| `WAPC2` | 已部署并公网只读复跑 | 新增 QA-only `npm run check:wecom-approval-poc`,范围先锁定请假和补卡;固定 `WECOM_AGENT_SECRET` 调用 `POST /cgi-bin/oa/gettemplatedetail` 与 `POST /cgi-bin/oa/getapprovaldetail`,报告只输出脱敏模板 ID/`sp_no` 前缀、字段/节点计数和错误分类。公网报告 `/app/artifacts/qa-flow/wecom-approval-poc-20260519T020429Z/summary.json` 为 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`:Secret 可用,但缺请假/补卡模板 ID 和两条低风险 `sp_no`,未伪造成 PASS。 | ## 3. 阶段路线 @@ -42,7 +43,7 @@ | 1 | `WECOM-CAPABILITY-POC-V1` | 只读或低风险探测企微 API 能力,输出能力矩阵。 | WDRG 完成;企微后台权限、Secret、可信 IP、模板和可见范围按现状记录。 | 人事助手、通讯录、打卡、审批、假期余额、回调和权限配置均有 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY` 或 `NOT_SUPPORTED_BY_API` 结论。 | | 2 | `wecom_hr_master_sync_v1` | 企微/人事助手员工、部门、主管、职位、联系方式和在职状态镜像到 OA。 | POC 证明人事助手/通讯录读取和回调能力可用,或明确缺口处理方式。 | WHMSLIVE 后已完成本地门禁、部署和受控公网验证;OA 可自动创建/更新员工档案、账号、角色、企微绑定和审计;最终 `force_off`,真实员工批量 apply 仍需单独批准;缺配置时记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 | | 3 | `runtime_first_wecom_enablement` | 先把已可用企微能力用于系统运行:入口、免登/绑定、脱敏消息、deeplink、通讯录 userid 清单、人事助手读取、打卡读取和只读对账。 | WAPI API 目录和 Secret 矩阵完成;WOUA 按新读取策略复跑。 | WOUA-FIX 后 OA active 缺企微已清零;WRV1/WRA1 通过 `check:wecom-runtime-ready` 给出运行优先结论;WMSG1 已证明真实消息和 Deeplink 可用。`PASS_WITH_CONFIG_GAPS` 只允许继续入口、免登、消息和只读读取主链路,不放行深度镜像;审批模板、测试 `sp_no`、真实回调仍阻断 `wecom_attendance_approval_mirror_v1`。 | -| 4 | `wecom_attendance_approval_mirror_v1` | 企微打卡、请假、补卡、外出、加班、假期余额和审批历史镜像到 OA。 | 运行优先门禁完成且 WOUA 差异已收口或有明确处理决策;WAPC1 审批配置准备清单完成;审批模板、低风险测试审批单号 `sp_no`、真实回调配置、打卡规则和只读 POC 均明确。 | OA 可查询企微 HR/考勤镜像、附件摘要、审批历史、考勤解释和审计;不把 OA 主办高敏审批迁到企微。 | +| 4 | `wecom_attendance_approval_mirror_v1` | 企微打卡、请假、补卡、外出、加班、假期余额和审批历史镜像到 OA。 | 运行优先门禁完成且 WOUA 差异已收口或有明确处理决策;WAPC1 审批配置准备清单完成;WAPC2 请假/补卡模板详情与低风险 `sp_no` 只读 POC 完成;外出/加班模板和测试单、真实回调配置、打卡规则细项和补偿拉取 POC 均明确。 | OA 可查询企微 HR/考勤镜像、附件摘要、审批历史、考勤解释和审计;不把 OA 主办高敏审批迁到企微。 | | 5 | `oa_hr_emergency_mode_v1` | 企业微信不可用时,由管理员人工开启 OA HR/考勤应急接管。 | 企微镜像数据可支撑对账;OA 应急入口、权限和审计方案明确。 | 管理员可填写原因、范围、预计结束时间和责任人后开启/关闭;应急单据带 `EMERGENCY` 标记。 | | 6 | `wecom_recovery_writeback_v1` | 企微恢复后,OA 应急数据先 dry-run,再回写企微。 | 应急接管模式完成;企微写回接口、冲突规则和回滚策略已单独批准。 | 系统生成成功、失败、跳过和冲突清单;无冲突项可自动回写,冲突进入人工确认。 | | 7 | `wecom_ruleops_v1` | AI 草拟企微规则、生成差异报告和 dry-run,人工确认后写入企微。 | 前述同步/回写链路稳定;企微规则 API 能力和权限已验证。 | 每次写入保存前后快照、确认人、API `errcode`、失败原因、回滚建议和审计记录。 | @@ -190,3 +191,18 @@ WMSG1 已证明企业微信应用消息和 OA Deeplink 能真实到达,但这 - 真实回调 URL、Token、EncodingAESKey 由运维在部署环境配置;文档和报告只记录配置项名称,不保存密钥值。 - 回调接入前必须明确验签、解密、幂等键、企业微信重试语义、失败重试和补偿拉取方案。 - 批量获取审批单号先作为补偿拉取候选,标记 `NEEDS_API_POC`,不得在镜像合同前假定可用。 + +## 12. WAPC2 审批模板与低风险 sp_no 只读 POC + +WAPC2 承接 WAPC1 的配置准备清单,但范围先收窄到请假和补卡。该阶段只证明企业微信审批模板详情和审批申请详情可被自建应用只读获取,不开发真实回调、不创建 OA 镜像表、不导入审批历史、不调用企微写接口。 + +执行口径: + +- runner 为 `server/src/scripts/check-wecom-approval-poc.js` / `npm run check:wecom-approval-poc`。 +- 模板详情固定调用 `POST /cgi-bin/oa/gettemplatedetail`;审批详情固定调用 `POST /cgi-bin/oa/getapprovaldetail`。 +- Secret 固定为 `WECOM_AGENT_SECRET`;不得 fallback 到 `WECOM_CONTACT_SECRET`、`WECOM_CHECKIN_SECRET` 或 `WECOM_HR_ASSISTANT_SECRET`。 +- 支持 `--template=leave:`、`--template=missed_punch:`、`--sp-no=leave:`、`--sp-no=missed_punch:`,也支持同义临时 env。 +- 报告输出到 `artifacts/qa-flow/wecom-approval-poc-/summary.json`,只记录类型、配置状态、脱敏 ID 前缀、字段类型计数、审批节点计数和错误分类。 +- 若缺模板 ID 或低风险测试 `sp_no`,结论必须为 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`;若权限、可信 IP、可见范围、Secret 或企微 API 错误阻断,结论必须为 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 + +WAPC2 通过后,仍需补外出/加班模板 POC、真实回调 URL/Token/EncodingAESKey 配置验证、事件验签/解密/幂等和补偿拉取方案,才能拆 `wecom_attendance_approval_mirror_v1`。 diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md index 5ed2a5d6..93dd9f35 100644 --- a/docs/文档事实源状态矩阵.md +++ b/docs/文档事实源状态矩阵.md @@ -77,6 +77,7 @@ | `WRA1` | `issues/2026-05-19_01-55-43-wecom-runtime-acceptance-v1.csv` | `EVIDENCE` | 企微运行验收门禁修正已部署并公网复跑:`WECOM_CHECKIN_SYNC_ENABLED` 保留为正式考勤同步开关,QA-only 打卡只读探测显式使用 `requireSyncEnabled=false`;报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` 为 `PASS_WITH_CONFIG_GAPS`,打卡只读可读,审批模板、测试 `sp_no` 和真实回调仍为 `NEEDS_CONFIG` | | `WMSG1` | `issues/2026-05-19_07-41-37-wecom-message-live-verify-v1.csv` | `EVIDENCE` | 企微应用消息实发与 Deeplink 真机验证已完成:远端单次设置 `WECOM_RUNTIME_TEST_SEND=1`,报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` 显示消息 `errcode=0`、`recipientCount=1`;用户确认收到“OA 运行就绪检查”并可点击进入 OA 通知页面;审批模板、测试 `sp_no` 和真实回调仍为后续配置缺口 | | `WAPC1` | `issues/2026-05-19_08-44-58-wecom-approval-config-prep-v1.csv` | `EVIDENCE` | 企微审批配置准备清单已完成:WMSG1 后不直接进入深度镜像;首批模板为请假、补卡、外出、加班;模板 ID、低风险 `sp_no`、真实回调配置项、验签/解密/幂等/重试和补偿拉取准入已固化;本轮不改业务代码、不部署、不接真实回调、不调用企微写接口 | +| `WAPC2` | `issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv` | `CURRENT` | 企微审批模板与低风险 `sp_no` 只读 POC 已部署并公网复跑:新增 `check:wecom-approval-poc` QA-only runner 和只读集成服务,范围请假/补卡,固定 `WECOM_AGENT_SECRET` 调用 `gettemplatedetail`/`getapprovaldetail`;远端报告 `/app/artifacts/qa-flow/wecom-approval-poc-20260519T020429Z/summary.json` 为 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`,缺模板或 `sp_no` 不放行深度镜像 | 旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。 @@ -84,28 +85,28 @@ | Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,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,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1` | `rg` | none | 2026-05-19 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 | -| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,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,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1` | `audit-docs` | none | 2026-05-19 | `audit-docs --check-registry` | medium | 判断文档权威性 | +| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,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,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2` | `rg` | none | 2026-05-19 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 | +| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,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,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2` | `audit-docs` | none | 2026-05-19 | `audit-docs --check-registry` | medium | 判断文档权威性 | | `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1` | Issue CSV | `docs/requirements-specification.md` | 2026-05-19 | `rg` | high | WAPC1 后定义 HR 主数据镜像读取口径、运行优先、WOUA 差异收口、消息实发和审批配置准备边界;ERP 未来不接入 OA | | `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1` | `rg` | none | 2026-05-19 | `rg` | high | 记录已拍板产品边界、企微/OA 设计门禁、开发路线图、WCPC 准入结论、WHMS 部署验证、WAPI 运行优先、WRRA 差异收口和 WAPC1 审批配置准备口径 | | `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9,WFDG` | Playwright/视觉审查/DG8/DG9/WFDG | none | 2026-05-18 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型;WFDG 后区分 OA 主办、企微镜像和 OA 应急接管 | -| `ROADMAP-WECOM` | `docs/企微优先开发路线图.md` | 开发治理 | 企微优先开发路线图事实源 | `CURRENT` | product/dev | `WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1` | Issue CSV | none | 2026-05-19 | `rg` | high | WRV1/WRA1/WMSG1/WAPC1 后记录企微 HR/考勤路线阶段顺序、API/Secret 口径、运行优先门禁、消息实发真机验证、审批配置准备、用户一致性差异收口和禁止跳阶段 | -| `WECOM-API-CATALOG` | `docs/企微API能力目录.md` | 集成事实源 | 企微 API 能力目录和 Secret 矩阵事实源 | `CURRENT` | product/backend/qa | `WAPI,WRRA,WAPC1` | 官方企业微信开发者中心链接/Issue CSV | none | 2026-05-19 | `rg` | high | 企微开发先读;只保存摘要、链接和项目决策,不保存 Secret、token、完整 userid、手机号、邮箱或原始 payload;WAPC1 后审批配置准备清单固化,深度镜像等待模板、低风险 sp_no 和真实回调 POC | +| `ROADMAP-WECOM` | `docs/企微优先开发路线图.md` | 开发治理 | 企微优先开发路线图事实源 | `CURRENT` | product/dev | `WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2` | Issue CSV | none | 2026-05-19 | `rg` | high | WRV1/WRA1/WMSG1/WAPC1/WAPC2 后记录企微 HR/考勤路线阶段顺序、API/Secret 口径、运行优先门禁、消息实发真机验证、审批配置准备、请假/补卡只读 POC、用户一致性差异收口和禁止跳阶段 | +| `WECOM-API-CATALOG` | `docs/企微API能力目录.md` | 集成事实源 | 企微 API 能力目录和 Secret 矩阵事实源 | `CURRENT` | product/backend/qa | `WAPI,WRRA,WAPC1,WAPC2` | 官方企业微信开发者中心链接/Issue CSV | none | 2026-05-19 | `rg` | high | 企微开发先读;只保存摘要、链接和项目决策,不保存 Secret、token、完整 userid、手机号、邮箱或原始 payload;WAPC2 后审批模板/审批详情只读 POC 已建立,深度镜像等待模板、低风险 sp_no、真实回调和补偿拉取 POC | | `DELIVERY` | `docs/开发交付与变更管理规约.md` | 开发治理 | 开发交付事实源 | `CURRENT` | dev/qa | `GOV8,WDRG,WRRA,WAPC1` | `rg` | none | 2026-05-19 | `rg` | high | Issue、review、commit、交付规则;WAPC1 后采用路线图门禁 + 运行优先 + WOUA 差异收口 + 审批配置准备 + 单功能 Issue CSV | | `QA-GATE` | `docs/测试规约与回归门禁.md` | 测试验收 | 测试和回归门禁事实源 | `CURRENT` | qa | `QAF8,GOV8` | 回归脚本/Playwright | none | 2026-05-13 | `rg` | high | 测试计划和 DONE 门禁 | | `AGENT-PRACTICE` | `docs/Agent开发交付实践.md` | Agent 治理 | Agent 协作事实源 | `CURRENT` | dev | `GOV8,DG8` | Skill 校验 | none | 2026-05-13 | `rg` | medium | Agent 协作开发实践和设计主参考层级 | | `AGENTS` | `AGENTS.md` | 执行硬约束 | 项目硬规则 | `CURRENT` | lead | `GOV8` | 人工核对 | none | 2026-05-13 | `rg` | high | 不可违反约束 | -| `SKILL-HYB` | `.agents/skills/hyb-oa-delivery/SKILL.md` | Skill | 项目交付 Skill | `CURRENT` | dev | `GOV8,DG8,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1` | quick_validate | none | 2026-05-19 | `quick_validate.py` | medium | 每轮交付操作入口;企微/OA 后续先读 API 能力目录、Secret 矩阵、路线图和 WHMSLIVE/WOUA/WAPI/WRRA/WRV1/WRA1/WMSG1/WAPC1 状态,不得跳过单功能合同直接做考勤审批镜像 | -| `REQ-WF` | `docs/需求-统一审批中心规约.md` | 需求事实源 | 审批模块规约 | `CURRENT` | product/backend | `AP6,WF8,WFOC,DBWFOC,WFDG,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1` | workflow 回归 | none | 2026-05-19 | `rg` | high | OA 主办审批、企微镜像、WRV1/WRA1/WMSG1/WAPC1 运行优先和审批配置门禁、WOUA 差异收口前置和 OA 应急接管边界 | +| `SKILL-HYB` | `.agents/skills/hyb-oa-delivery/SKILL.md` | Skill | 项目交付 Skill | `CURRENT` | dev | `GOV8,DG8,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2` | quick_validate | none | 2026-05-19 | `quick_validate.py` | medium | 每轮交付操作入口;企微/OA 后续先读 API 能力目录、Secret 矩阵、路线图和 WHMSLIVE/WOUA/WAPI/WRRA/WRV1/WRA1/WMSG1/WAPC1/WAPC2 状态,不得跳过单功能合同直接做考勤审批镜像 | +| `REQ-WF` | `docs/需求-统一审批中心规约.md` | 需求事实源 | 审批模块规约 | `CURRENT` | product/backend | `AP6,WF8,WFOC,DBWFOC,WFDG,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2` | workflow 回归 | none | 2026-05-19 | `rg` | high | OA 主办审批、企微镜像、WRV1/WRA1/WMSG1/WAPC1/WAPC2 运行优先、审批配置和只读 POC 门禁、WOUA 差异收口前置和 OA 应急接管边界 | | `REQ-OCR` | `docs/需求-报销发票OCR规约.md` | 需求事实源 | OCR 模块规约 | `CURRENT` | product/backend | `RI6,OCR8,BIZ8` | OCR 回归 | none | 2026-05-13 | `rg` | high | OCR 和发票边界 | | `RULE-REIMB` | `docs/报销发票附件规则.md` | 需求事实源 | 报销发票附件规则 | `CURRENT` | product/finance | `RI6,BIZ8` | 报销回归 | none | 2026-05-13 | `rg` | high | 报销、发票、附件细则 | -| `REQ-WECOM` | `docs/需求-企业微信基础设施规约.md` | 需求事实源 | 企业微信边界规约 | `CURRENT` | product/backend | `R7E,ORG8,WF8,ATTV1,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1` | live smoke/真机反馈/Issue CSV | none | 2026-05-19 | `rg` | high | 企微优先承担 HR/考勤员工日常审批和人事/打卡能力;WRV1/WRA1/WMSG1/WAPC1 固定运行优先、消息实发和审批配置准备口径;采购、合同、项目、报销和财务继续 OA 主办 | +| `REQ-WECOM` | `docs/需求-企业微信基础设施规约.md` | 需求事实源 | 企业微信边界规约 | `CURRENT` | product/backend | `R7E,ORG8,WF8,ATTV1,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2` | live smoke/真机反馈/Issue CSV | none | 2026-05-19 | `rg` | high | 企微优先承担 HR/考勤员工日常审批和人事/打卡能力;WRV1/WRA1/WMSG1/WAPC1/WAPC2 固定运行优先、消息实发、审批配置准备和请假/补卡只读 POC 口径;采购、合同、项目、报销和财务继续 OA 主办 | | `RUNBOOK-TRIAL` | `docs/试运行与自动化验证手册.md` | 操作手册 | 试运行验证手册 | `CURRENT` | qa/ops | `R7E,QAOPS8` | live readiness | none | 2026-05-13 | `rg` | medium | 本地、公网、企业微信和真实 OCR 验证 | -| `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E,WFOC,DBWFOC,WFDG,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1` | 人工验收 | none | 2026-05-19 | `rg` | medium | 管理员上线、企微优先边界、Secret 矩阵、OA 主办数据边界、HR 主数据镜像、WOUA 差异收口、WRV1/WRA1 运行门禁、WMSG1 消息实发、WAPC1 审批配置准备、OA 应急接管和恢复回写 | +| `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E,WFOC,DBWFOC,WFDG,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2` | 人工验收 | none | 2026-05-19 | `rg` | medium | 管理员上线、企微优先边界、Secret 矩阵、OA 主办数据边界、HR 主数据镜像、WOUA 差异收口、WRV1/WRA1 运行门禁、WMSG1 消息实发、WAPC1 审批配置准备、WAPC2 请假/补卡只读 POC、OA 应急接管和恢复回写 | | `MANUAL-EMPLOYEE` | `docs/员工快速上手.md` | 操作手册 | 员工手册 | `CURRENT` | product | `R7E,WFOC,DBWFOC,WFDG` | 人工验收 | none | 2026-05-18 | `rg` | low | 员工侧使用说明,HR/考勤优先企业微信,采购/合同/财务仍在 OA;区分企微镜像和 OA 应急接管 | | `R7-DRAFT` | `docs/trialrun/R7扩大试运行草案.md` | 草案 | R7 准备材料 | `DRAFT` | product/qa | `R7E` | R7EP 报告 | none | 2026-05-13 | `rg` | medium | 不代表当前 R7E 执行状态 | | `OPS-RUNBOOK` | `docs/trialrun/OPS7运维交接Runbook.md` | 运维草案 | OPS 准备材料 | `DRAFT` | ops | `OPS8,QAOPS8` | 只读检查 | none | 2026-05-13 | `rg` | medium | 不代表恢复演练已执行 | -| `TRIAL-REPORT` | `docs/trialrun/人工试运行报告.md` | 证据材料 | 试运行证据入口 | `EVIDENCE` | qa | `R7EP,OPS8,QAF8,WOUA,WAPI,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1` | artifacts/qa-flow | none | 2026-05-19 | `rg` | medium | 只证明发生过;记录 WOUA-FIX 受控写入、WRV1/WRA1 运行优先门禁、WMSG1 消息实发真机确认和 WAPC1 配置准备清单,不直接放行深度镜像 | +| `TRIAL-REPORT` | `docs/trialrun/人工试运行报告.md` | 证据材料 | 试运行证据入口 | `EVIDENCE` | qa | `R7EP,OPS8,QAF8,WOUA,WAPI,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2` | artifacts/qa-flow | none | 2026-05-19 | `rg` | medium | 只证明发生过;记录 WOUA-FIX 受控写入、WRV1/WRA1 运行优先门禁、WMSG1 消息实发真机确认、WAPC1 配置准备清单和 WAPC2 请假/补卡只读 POC,不直接放行深度镜像 | | `FBA-AUDIT` | `docs/trialrun/R7E后功能边界审计.md` | 证据材料 | R7E 后功能边界审计 | `EVIDENCE` | product/dev/qa | `FBA,R7E,QAOPS8,P2T` | Issue CSV | none | 2026-05-15 | `rg` | medium | 只作为功能边界、P2 候选和 Issue 草案证据,不直接派工 | | `HISTORY-MATRIX` | `docs/历史功能状态矩阵.md` | 历史材料 | 历史功能索引 | `HISTORICAL` | product | `DCL7` | git 历史 | none | 2026-05-13 | `rg` | low | 只追溯历史 | | `OLD-IMPL-PLAN` | `docs/implementation_plan.md` | 已移除历史 | 早期实施计划 | `REMOVED` | product | `DCL7` | git history | none | 2026-05-13 | manual | low | 从 git 历史恢复 | @@ -118,14 +119,14 @@ | `docs/文档事实源状态矩阵.md` | 当前事实源 | 文档分类和恢复规则 | | `docs/海音达OA-MVP需求规格说明书.md` | 当前事实源 | PRD 主事实源 | | `docs/产品设计规则.md` | 当前事实源 | PC、移动、企业微信设计规则;DG8 主 UI/布局参考层级 | -| `docs/企微优先开发路线图.md` | 当前事实源 | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA 后企微优先 HR/考勤改造阶段顺序、API/Secret 口径、运行优先、WOUA 差异收口和禁止跳阶段 | -| `docs/企微API能力目录.md` | 当前事实源 | WAPI/WRRA 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界和运行优先决策 | +| `docs/企微优先开发路线图.md` | 当前事实源 | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WAPC2 后企微优先 HR/考勤改造阶段顺序、API/Secret 口径、运行优先、审批只读 POC 和禁止跳阶段 | +| `docs/企微API能力目录.md` | 当前事实源 | WAPI/WRRA/WAPC2 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界、运行优先和审批只读 POC 决策 | | `docs/产品口径.md` | 当前事实源 | 产品边界和业务规则 | | `docs/需求-统一审批中心规约.md` | 当前事实源 | 审批、催办、超时、效率摘要 | | `docs/报销发票附件规则.md` | 当前事实源 | 报销、发票、附件细则 | | `docs/需求-报销发票OCR规约.md` | 当前事实源 | OCR 和报销模块规约 | | `docs/需求-企业微信基础设施规约.md` | 当前事实源 | 企业微信入口、身份、消息、deeplink | -| `docs/需求-请假考勤修正规约.md` | 当前事实源 | 请假和考勤修正 | +| `docs/需求-请假考勤修正规约.md` | 当前事实源 | 请假、补卡、考勤修正和 WAPC2 只读 POC | | `docs/需求-采购费用审批规约.md` | 当前事实源 | 采购和费用审批 | | `docs/需求-合同附件归档规约.md` | 当前事实源 | 合同台账和附件归档 | | `docs/需求-项目轻管理规约.md` | 当前事实源 | 项目轻管理 | @@ -194,6 +195,7 @@ - `ME6-3` 代表性企业微信真机门禁已关闭。 - MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。 -- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障、REQ-COMPLETE-AUDIT 需求完成度复核、ATTV1 考勤统一入口、AUTO-REMAINING-CLOSEOUT 剩余自动化收口、HRWD-CONTACT-LIVE-TEST 公网企微通讯录真实写入闭环、WFOC 需求重构、DBWFOC 数据边界修正、WFDG 设计门禁、WDRG 路线图门禁、WCPC 配置复跑、WHMSLIVE、WAPI、WRRA、WOUA-FIX、WRA1、WMSG1 和 WAPC1 均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同。LAUNCH-AUTO-OPS-V1 发现的公网 `payment_batch_v1=force_off` 漂移已由 AUTOREM 恢复为 `force_on`,正式自动巡检结论为 `GO_TO_OPERATE`;`erp_sync=force_off`,ERP 未来不接入 OA,正式支付接口、图片 OCR 和银行流水导入继续不进入当前路线。WCP1 已完成企业微信能力 QA-only 只读 POC,历史结论为 `PAUSE_FOR_CONFIG`;WCPC 使用既有在职测试号 `SMOKE002/测试员工`(脱敏 `oa_smoke***`)完成远端只读复跑,报告 `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json`,结论 `GO_TO_HR_MASTER_SYNC`,`nextStageAllowed=true`,`blockingCapabilities=[]`。WHMS 已实现并部署验证 `wecom_hr_master_sync_v1`、同步批次/逐员工记录、dry-run/apply、管理员 API、People 管理入口和 QA-only runner;默认和最终均为 `force_off`,公网 `SMOKE002` 因缺手机号、邮箱和部门按 `missing_required_field` 跳过,未执行生产真实员工批量 apply。WAPI 已将企微官方 API 能力目录、Secret 矩阵和运行优先路线落为当前事实源:通讯录读取使用 `WECOM_CONTACT_SECRET` 和 `user/list_id`,成员详情只作可选富化,人事助手/花名册、打卡、消息、OAuth 默认使用 `WECOM_AGENT_SECRET`,`WECOM_CHECKIN_SECRET` 只作专用打卡 override,`WECOM_HR_ASSISTANT_SECRET` 不参与默认读取;后续企微能力不清楚时先查官方 API 和 [企微 API 能力目录](./企微API能力目录.md),不得靠运行时多个 Secret 试错。WOUA-FIX 已按用户确认执行受控写入并复检:7 个 OA active 缺企微测试账号软归档,`T0000` seed 复活问题修正,`SMOKE201` 测试主管和待办副作用已转交,最终 WOUA 报告 `/app/artifacts/qa-flow/wecom-oa-user-consistency-20260518T161716Z/summary.json` 为 `PASS_WITH_FINDINGS`,但 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余 5 个企微缺字段先接受不补,`HRWDTEST-20260517214548` 归档绑定保留审计。WRV1 原始报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T173402Z/summary.json` 曾因同步开关误阻断;WRA1 已修正并公网复跑,报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` 为 `PASS_WITH_CONFIG_GAPS`,入口/OAuth、Secret 矩阵、OA active 绑定、通讯录、人事助手、消息 dry-run 和打卡只读链路均通过,打卡记录 `readable_sync_disabled`。WMSG1 已证明企业微信应用消息和 Deeplink 可用;WAPC1 已固定首批审批模板(请假、补卡、外出、加班)、低风险 `sp_no`、真实回调配置项、验签/解密/幂等/重试和补偿拉取准入清单。HR/考勤员工日常审批优先复用企业微信,OA 目标是按域镜像、人工接管、恢复回写和 RuleOps;采购、费用、合同、项目、报销、付款、AP、代账交接、审计和高敏附件继续 OA 主办。考勤/审批镜像、审批回调、OA 应急接管、恢复回写和 RuleOps 尚未实现;审批模板 ID、审批单号 `sp_no`、真实回调配置和 HR 写接口仍需后续独立合同处理;不得直接跳到 `wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1`。HRWD 已完成 OA 驱动企业微信通讯录同步,并通过 `HRWDTEST-20260517214548` 真实公网 create/update/disable 验证;测试成员按审计要求保留离职/禁用状态。 +- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障、REQ-COMPLETE-AUDIT 需求完成度复核、ATTV1 考勤统一入口、AUTO-REMAINING-CLOSEOUT 剩余自动化收口、HRWD-CONTACT-LIVE-TEST 公网企微通讯录真实写入闭环、WFOC 需求重构、DBWFOC 数据边界修正、WFDG 设计门禁、WDRG 路线图门禁、WCPC 配置复跑、WHMSLIVE、WAPI、WRRA、WOUA-FIX、WRA1、WMSG1、WAPC1 和 WAPC2 均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同。LAUNCH-AUTO-OPS-V1 发现的公网 `payment_batch_v1=force_off` 漂移已由 AUTOREM 恢复为 `force_on`,正式自动巡检结论为 `GO_TO_OPERATE`;`erp_sync=force_off`,ERP 未来不接入 OA,正式支付接口、图片 OCR 和银行流水导入继续不进入当前路线。WCP1 已完成企业微信能力 QA-only 只读 POC,历史结论为 `PAUSE_FOR_CONFIG`;WCPC 使用既有在职测试号 `SMOKE002/测试员工`(脱敏 `oa_smoke***`)完成远端只读复跑,报告 `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json`,结论 `GO_TO_HR_MASTER_SYNC`,`nextStageAllowed=true`,`blockingCapabilities=[]`。WHMS 已实现并部署验证 `wecom_hr_master_sync_v1`、同步批次/逐员工记录、dry-run/apply、管理员 API、People 管理入口和 QA-only runner;默认和最终均为 `force_off`,公网 `SMOKE002` 因缺手机号、邮箱和部门按 `missing_required_field` 跳过,未执行生产真实员工批量 apply。WAPI 已将企微官方 API 能力目录、Secret 矩阵和运行优先路线落为当前事实源:通讯录读取使用 `WECOM_CONTACT_SECRET` 和 `user/list_id`,成员详情只作可选富化,人事助手/花名册、打卡、消息、OAuth 和审批读取默认使用 `WECOM_AGENT_SECRET`,`WECOM_CHECKIN_SECRET` 只作专用打卡 override,`WECOM_HR_ASSISTANT_SECRET` 不参与默认读取;后续企微能力不清楚时先查官方 API 和 [企微 API 能力目录](./企微API能力目录.md),不得靠运行时多个 Secret 试错。WOUA-FIX 已按用户确认执行受控写入并复检:7 个 OA active 缺企微测试账号软归档,`T0000` seed 复活问题修正,`SMOKE201` 测试主管和待办副作用已转交,最终 WOUA 报告 `/app/artifacts/qa-flow/wecom-oa-user-consistency-20260518T161716Z/summary.json` 为 `PASS_WITH_FINDINGS`,但 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余 5 个企微缺字段先接受不补,`HRWDTEST-20260517214548` 归档绑定保留审计。WRV1 原始报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T173402Z/summary.json` 曾因同步开关误阻断;WRA1 已修正并公网复跑,报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` 为 `PASS_WITH_CONFIG_GAPS`,入口/OAuth、Secret 矩阵、OA active 绑定、通讯录、人事助手、消息 dry-run 和打卡只读链路均通过,打卡记录 `readable_sync_disabled`。WMSG1 已证明企业微信应用消息和 Deeplink 可用;WAPC1 已固定首批审批模板(请假、补卡、外出、加班)、低风险 `sp_no`、真实回调配置项、验签/解密/幂等/重试和补偿拉取准入清单;WAPC2 已新增请假/补卡模板详情和审批详情只读 POC runner。HR/考勤员工日常审批优先复用企业微信,OA 目标是按域镜像、人工接管、恢复回写和 RuleOps;采购、费用、合同、项目、报销、付款、AP、代账交接、审计和高敏附件继续 OA 主办。考勤/审批镜像、审批回调、OA 应急接管、恢复回写和 RuleOps 尚未实现;审批模板 ID、审批单号 `sp_no`、真实回调配置和 HR 写接口仍需后续独立合同处理;不得直接跳到 `wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1`。HRWD 已完成 OA 驱动企业微信通讯录同步,并通过 `HRWDTEST-20260517214548` 真实公网 create/update/disable 验证;测试成员按审计要求保留离职/禁用状态。 - WMSG1 已完成企微应用消息实发与 Deeplink 真机验证:远端报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` 显示 `checks.message.status=OK`、`errcode=0`、`recipientCount=1`,用户确认收到“OA 运行就绪检查”并可点击进入 OA 通知页面;本证据只证明消息和 Deeplink 可用,不放行审批模板、测试 `sp_no`、真实回调或深度镜像。 - WAPC1 已完成企微审批配置准备清单:首批只准备请假、补卡、外出、加班模板;每个模板需准备模板 ID、适用范围、字段项、审批人规则、可见范围和自建应用权限;测试 `sp_no` 必须低风险且不得含采购、合同、报销、发票、银行回单或高敏附件;真实回调 Token/EncodingAESKey 不写入仓库或文档。本证据只代表配置准备口径已固化,不代表真实回调、审批镜像、应急接管、恢复回写或 RuleOps 已实现。 +- WAPC2 已完成企微审批模板与低风险 `sp_no` 只读 POC 本地实现、部署和公网只读复跑:范围只包括请假和补卡,固定 `WECOM_AGENT_SECRET`,远端报告为 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`;本证据只代表模板详情/审批详情只读 runner 已具备且远端 Secret 可用,不代表模板 ID、测试 `sp_no`、真实回调或深度镜像已配置完成。 diff --git a/docs/文档索引.md b/docs/文档索引.md index c6e050c6..d006d608 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。 +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。 - R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。 - ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。 @@ -69,6 +69,8 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M - WRV1 是 WOUA-FIX 后的企业微信运行优先门禁合同:新增 `server/src/scripts/check-wecom-runtime-ready.js` 和 `npm run check:wecom-runtime-ready`,检查工作台入口、OAuth/绑定、脱敏消息、OA deeplink、通讯录 `user/list_id`、人事助手 `get_staff_info` 和打卡短窗口读取;已部署并远端 live 只读验证,报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T173402Z/summary.json` 为 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`,阻断点是 `WECOM_CHECKIN_SYNC_ENABLED=true` 未启用;审批模板、测试 `sp_no` 和真实回调缺口记录为 `NEEDS_CONFIG`,不放行深度镜像。 - WRA1 是 WRV1 后的运行验收门禁修正合同:确认 `WECOM_CHECKIN_SYNC_ENABLED` 是正式考勤同步开关,不应阻断 QA-only 打卡只读探测;runner 显式使用 `requireSyncEnabled=false` 复跑公网运行验收。远端报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` 为 `PASS_WITH_CONFIG_GAPS`,打卡 API 只读可读,审批模板、测试 `sp_no` 和真实回调仍为 `NEEDS_CONFIG`。 - WMSG1 是 WRA1 后的企微应用消息实发与 Deeplink 真机验证合同:使用现有 runner 单次设置 `WECOM_RUNTIME_TEST_SEND=1`,向管理员本人对应 OA 员工号 `HYD000006` 发送脱敏消息;报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` 显示 `errcode=0`、`recipientCount=1`,用户确认收到消息并可点击进入 OA 通知页面。审批模板、测试 `sp_no` 和真实回调仍需后续配置。 +- WAPC1 是 WMSG1 后的企微审批配置准备清单合同:只固化请假、补卡、外出、加班模板准备项、低风险 `sp_no`、真实回调配置项和 POC 准入,不开发镜像。 +- WAPC2 是 WAPC1 后的请假/补卡审批只读 POC 合同:新增 `server/src/scripts/check-wecom-approval-poc.js` 和 `npm run check:wecom-approval-poc`,固定 `WECOM_AGENT_SECRET` 读取 `gettemplatedetail`/`getapprovaldetail`;缺模板 ID 或低风险 `sp_no` 时报告 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`,不放行深度镜像。 ## 当前活跃治理流 @@ -126,6 +128,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M | `WRA1` | `issues/2026-05-19_01-55-43-wecom-runtime-acceptance-v1.csv` | 已部署验证:企微运行验收门禁修正 | QA-only 打卡只读探测不再要求正式同步开关;公网报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` 为 `PASS_WITH_CONFIG_GAPS`,打卡只读可读且正式同步未启用 | 不改公开 API/schema/UI/远端 env;不发送真实消息;不补企微资料;不进入深度镜像 | | `WMSG1` | `issues/2026-05-19_07-41-37-wecom-message-live-verify-v1.csv` | 已完成:企微应用消息实发与 Deeplink 真机验证 | 远端单次设置 `WECOM_RUNTIME_TEST_SEND=1`,向管理员本人对应 OA 员工号 `HYD000006` 发送脱敏消息;报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` 为 `PASS_WITH_CONFIG_GAPS`,消息 `errcode=0`、`recipientCount=1`;用户确认收到并可点击进入 OA 通知页面 | 不改代码/schema/UI/远端持久 env;不补企微资料;不配置审批模板/`sp_no`/真实回调;不进入深度镜像 | | `WAPC1` | `issues/2026-05-19_08-44-58-wecom-approval-config-prep-v1.csv` | 已完成:企微审批配置准备清单 | 固化 WMSG1 后下一步为审批配置准备,不直接进入深度镜像;首批模板为请假、补卡、外出、加班;准备模板 ID、低风险 `sp_no`、真实回调配置项和后续 POC 准入 | 不改业务代码/API/schema/UI;不部署;不接真实回调;不调用企微写接口;不配置模板或 `sp_no` | +| `WAPC2` | `issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv` | 已部署并公网只读复跑:企微审批模板与低风险 `sp_no` 只读 POC | 新增 QA-only runner、只读集成服务和 focused 单测;范围请假+补卡;固定 `WECOM_AGENT_SECRET` 调用 `gettemplatedetail` / `getapprovaldetail`;远端报告 `/app/artifacts/qa-flow/wecom-approval-poc-20260519T020429Z/summary.json` 为 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG` | 不新增公开 API/schema/UI;不接真实回调;不调用企微写接口;不写 OA/企微数据;不进入深度镜像 | 旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。 @@ -145,17 +148,17 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M | 文档 | 用途 | | --- | --- | -| [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源;WAPC1 后包含企微审批配置准备前置 | +| [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源;WAPC1/WAPC2 后包含企微审批配置准备和只读 POC 前置 | | [文档事实源状态矩阵](./文档事实源状态矩阵.md) | 文档分类、删除恢复和冲突规则 | | [产品设计规则](./产品设计规则.md) | PC、移动、企业微信 WebView 设计事实源;DG8 后飞书工作台/飞书审批为唯一主 UI/布局参考;DG9 后按页面分层和样板选型 | -| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WOUA-FIX/WRV1/WRA1/WMSG1/WAPC1 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先、消息实发和审批配置准备事实源 | -| [企微 API 能力目录](./企微API能力目录.md) | WAPI/WAPC1 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界、运行优先和审批配置准备决策事实源 | -| [产品口径](./产品口径.md) | 已拍板产品边界和业务规则;WAPC1 后包含审批配置准备阶段顺序 | +| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WOUA-FIX/WRV1/WRA1/WMSG1/WAPC1/WAPC2 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先、消息实发、审批配置准备和只读 POC 事实源 | +| [企微 API 能力目录](./企微API能力目录.md) | WAPI/WAPC1/WAPC2 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界、运行优先、审批配置准备和只读 POC 决策事实源 | +| [产品口径](./产品口径.md) | 已拍板产品边界和业务规则;WAPC1/WAPC2 后包含审批配置准备和只读 POC 阶段顺序 | | [需求 - 统一审批中心规约](./需求-统一审批中心规约.md) | OA 主办审批、企微主办 HR/考勤员工日常审批镜像、催办、超时提醒、审批效率 | | [报销发票附件规则](./报销发票附件规则.md) | 报销、发票、OCR、附件细则 | | [需求 - 报销发票 OCR 规约](./需求-报销发票OCR规约.md) | 报销发票 OCR 模块规约 | | [需求 - 企业微信基础设施规约](./需求-企业微信基础设施规约.md) | 企业微信入口、身份、消息、deeplink、HR/考勤员工日常审批、打卡、人事助手、按域镜像、OA 接管和恢复回写 | -| [需求 - 请假考勤修正规约](./需求-请假考勤修正规约.md) | 企微请假/补卡/外出/加班、WAPC1 审批配置准备、考勤镜像、OA 应急接管和恢复回写 | +| [需求 - 请假考勤修正规约](./需求-请假考勤修正规约.md) | 企微请假/补卡/外出/加班、WAPC1 审批配置准备、WAPC2 请假/补卡只读 POC、考勤镜像、OA 应急接管和恢复回写 | | [需求 - 采购费用审批规约](./需求-采购费用审批规约.md) | 采购、费用、附件、审批规则 | | [需求 - 合同附件归档规约](./需求-合同附件归档规约.md) | 合同台账和附件归档边界 | | [需求 - 项目轻管理规约](./需求-项目轻管理规约.md) | 项目轻管理边界 | diff --git a/docs/管理员手册.md b/docs/管理员手册.md index 86354546..6f93ea64 100644 --- a/docs/管理员手册.md +++ b/docs/管理员手册.md @@ -1,6 +1,6 @@ # hyb-oa 管理员手册(10 人以下团队版) -**当前状态(2026-05-19 / WAPC1)**:R7E、PRELAUNCH、CASHIER-VOUCHER-V1、LAUNCH-DEPLOY-FINANCE、FIN-PROD-ENABLE、ACCOUNTING-HANDOFF-V1、ATTV1、HR-WECOM-DIRECTORY-V1、HRWD-CONTACT-LIVE-TEST、DBWFOC、WFDG、WDRG、WCPC、WHMSLIVE、WAPI、WRRA、WOUA-FIX、WRV1、WRA1、WMSG1 和 WAPC1 均已收口;SYSTEM-COMPLETE-GATE 结论为 `GO_TO_USE`。WCP1 历史结论为 `PAUSE_FOR_CONFIG`,WCPC 已使用既有在职测试号 `SMOKE002/测试员工`(报告只记录 `oa_smoke***`)复跑通过,结论为 `GO_TO_HR_MASTER_SYNC`。WHMS 已实现并部署验证 `wecom_hr_master_sync_v1`:企微/人事助手 HR 主数据可 dry-run/apply 到 OA 员工镜像、账号、角色、绑定和审计;默认和最终均为 `force_off`,公网 `SMOKE002` 因企微侧缺手机号、邮箱和部门按 `missing_required_field` 跳过,未对真实员工执行生产批量 apply。WAPI 后固定企微 API 能力目录和 Secret 矩阵:通讯录使用 `WECOM_CONTACT_SECRET`,人事助手/打卡/消息/OAuth 使用 `WECOM_AGENT_SECRET`,运行时代码不得靠多个 Secret 试错;WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0,剩余 5 个企微缺字段先接受不补,1 个归档绑定保留审计。WRV1 新增并部署 `npm run check:wecom-runtime-ready` 运行优先门禁;WRA1 已修正 QA-only 打卡只读门禁并公网复跑,报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` 为 `PASS_WITH_CONFIG_GAPS`:入口、OAuth/绑定、脱敏消息、OA deeplink、通讯录、人事助手读取和打卡只读链路已通过,打卡读取记录 `readable_sync_disabled`;WMSG1 已完成真实企业微信应用消息和 Deeplink 真机确认;WAPC1 已固化审批配置准备清单。审批模板、测试 `sp_no` 和真实回调缺口仍阻断 `wecom_attendance_approval_mirror_v1`。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。 +**当前状态(2026-05-19 / WAPC2)**:R7E、PRELAUNCH、CASHIER-VOUCHER-V1、LAUNCH-DEPLOY-FINANCE、FIN-PROD-ENABLE、ACCOUNTING-HANDOFF-V1、ATTV1、HR-WECOM-DIRECTORY-V1、HRWD-CONTACT-LIVE-TEST、DBWFOC、WFDG、WDRG、WCPC、WHMSLIVE、WAPI、WRRA、WOUA-FIX、WRV1、WRA1、WMSG1、WAPC1 和 WAPC2 均已收口;SYSTEM-COMPLETE-GATE 结论为 `GO_TO_USE`。WCP1 历史结论为 `PAUSE_FOR_CONFIG`,WCPC 已使用既有在职测试号 `SMOKE002/测试员工`(报告只记录 `oa_smoke***`)复跑通过,结论为 `GO_TO_HR_MASTER_SYNC`。WHMS 已实现并部署验证 `wecom_hr_master_sync_v1`:企微/人事助手 HR 主数据可 dry-run/apply 到 OA 员工镜像、账号、角色、绑定和审计;默认和最终均为 `force_off`,公网 `SMOKE002` 因企微侧缺手机号、邮箱和部门按 `missing_required_field` 跳过,未对真实员工执行生产批量 apply。WAPI 后固定企微 API 能力目录和 Secret 矩阵:通讯录使用 `WECOM_CONTACT_SECRET`,人事助手/打卡/消息/OAuth/审批读取使用 `WECOM_AGENT_SECRET`,运行时代码不得靠多个 Secret 试错;WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0,剩余 5 个企微缺字段先接受不补,1 个归档绑定保留审计。WRV1 新增并部署 `npm run check:wecom-runtime-ready` 运行优先门禁;WRA1 已修正 QA-only 打卡只读门禁并公网复跑,报告 `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` 为 `PASS_WITH_CONFIG_GAPS`;WMSG1 已完成真实企业微信应用消息和 Deeplink 真机确认;WAPC1 已固化审批配置准备清单;WAPC2 已新增请假/补卡模板详情和低风险 `sp_no` 只读 POC runner。审批模板、测试 `sp_no` 和真实回调缺口仍阻断 `wecom_attendance_approval_mirror_v1`。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。 ## 1. 适用范围 - 适用于 `small_team_internal` 工作区模式。 @@ -198,7 +198,7 @@ WCP1/WCPC/WHMS 后续推进: 2. WCP1 历史结论为 `PAUSE_FOR_CONFIG`;WCPC 已用既有在职测试号完成远端只读复跑,报告 `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json`,结论 `GO_TO_HR_MASTER_SYNC`。 3. WCPC 已证明 HR 主数据镜像准入项可用:通讯录成员读取、人事助手花名册读取、打卡短窗口记录/规则读取和假期余额读取均达到可复用状态;审批模板、审批实例和回调配置仍是后续考勤/审批镜像前置项。 4. WHMS 已实现并部署验证 `wecom_hr_master_sync_v1`,包括同步批次、逐员工结果、dry-run/apply、People 管理入口和 QA-only runner;公网验证后最终保持 `force_off`。 -5. 正式开发仍按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1` 分合同实施;WOUA-FIX 已收口测试账号差异和处理决策,WRV1/WRA1/WMSG1 已验证运行入口、OAuth/绑定、脱敏消息、Deeplink、通讯录、人事助手和打卡读取主链路。审批模板 ID、测试 `sp_no`、真实回调配置、验签/解密/幂等和补偿拉取未明确前,不得进入深度考勤/审批镜像。 +5. 正式开发仍按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1` 分合同实施;WOUA-FIX 已收口测试账号差异和处理决策,WRV1/WRA1/WMSG1 已验证运行入口、OAuth/绑定、脱敏消息、Deeplink、通讯录、人事助手和打卡读取主链路。审批模板 ID、测试 `sp_no`、真实回调配置、验签/解密/幂等和补偿拉取未明确前,不得进入深度考勤/审批镜像。 6. 任何生产写接口、企微规则写入、应急模式开启或恢复回写都必须有单独批准、dry-run 或可审计记录。 WCPC 配置复跑清单: @@ -237,6 +237,14 @@ WAPC1 审批配置准备清单: 5. 后续进入 POC 前必须确认:模板详情读取、审批详情读取、回调 URL 验证、事件验签、解密、幂等键、重试语义和补偿拉取方案。 6. 批量获取审批单号只作为补偿拉取候选,先标记 `NEEDS_API_POC`。 +WAPC2 请假/补卡只读 POC 操作口径: + +1. 管理员先在企业微信后台确认请假和补卡模板 ID,并各创建一条低风险测试审批单。 +2. 测试单 `sp_no` 不得包含采购、合同、报销、发票、银行回单、高敏附件或真实敏感业务资料。 +3. QA 在服务器容器内执行 `npm run check:wecom-approval-poc -- --live --format=json`,并通过 CLI 或临时 env 传入请假/补卡模板 ID 和 `sp_no`。 +4. 报告结论 `PASS_APPROVAL_POC` 只代表模板详情和审批详情只读链路可用;`PARTIAL_APPROVAL_POC_NEEDS_CONFIG` 代表缺模板 ID 或 `sp_no`;`SKIP/BLOCKED_BY_EXTERNAL_CONFIG` 代表 Secret、权限、可信 IP、可见范围或企微 API 阻断。 +5. 报告只看脱敏模板 ID/`sp_no` 前缀、字段类型计数、审批节点计数和错误分类;不得截图或转存完整 `sp_no`、表单值、附件、评论、Secret、token 或原始 payload。 + ### 3.3 正式财务闭环与代账交接 公司当前没有 ERP 或独立财务系统,OA 不生成 ERP 实时凭证,也不接真实支付接口。正式财务闭环如下: @@ -411,7 +419,7 @@ WAPC1 审批配置准备清单: - `WECOM_ALLOWED_RETURN_HOSTS=oa.hyinda.com` 5. 先让管理员用账号密码登录,在 `个人设置` 完成一次绑定,再测试企业微信工作台直达;已绑定用户从手机企业微信进入时应直接看到移动工作台。 6. 若启用 OA 同步企业微信打卡数据,在企业微信打卡 API「可调用接口的应用」中授权 `海音达工作台`。OA 默认可复用 `WECOM_AGENT_SECRET`;只有单独创建考勤同步应用时才额外配置 `WECOM_CHECKIN_SECRET`。 -7. HR/考勤员工日常审批后续配置企业微信原生审批模板作为主流程;按 WAPC1 先准备请假、补卡、外出、加班模板 ID、低风险测试 `sp_no` 和回调配置,再进入后续 POC。报销、财务、采购、合同和项目类 OA 主办审批仍使用企业微信消息提醒并回到 OA 页面完成。 +7. HR/考勤员工日常审批后续配置企业微信原生审批模板作为主流程;按 WAPC1 先准备请假、补卡、外出、加班模板 ID、低风险测试 `sp_no` 和回调配置,WAPC2 先只读验证请假/补卡模板详情和审批详情,再进入后续 POC。报销、财务、采购、合同和项目类 OA 主办审批仍使用企业微信消息提醒并回到 OA 页面完成。 ### 员工会看到的典型状态 - 未绑定:登录页会提示“当前企业微信账号尚未绑定”,回退到账号密码登录。 diff --git a/docs/需求-企业微信基础设施规约.md b/docs/需求-企业微信基础设施规约.md index 8e137bb0..e9e7008e 100644 --- a/docs/需求-企业微信基础设施规约.md +++ b/docs/需求-企业微信基础设施规约.md @@ -6,7 +6,7 @@ **上级事实源**:[海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) **相关事实源**:[需求 - 统一审批中心规约](./需求-统一审批中心规约.md)、[企微 API 能力目录](./企微API能力目录.md) -本规约细化企业微信在 `hyb-oa` 中的基础设施边界、入口、免登/绑定、通讯录身份、消息通知、deeplink、打卡、人事助手、原生审批、OA 镜像、人工接管和恢复回写要求。WFOC 起产品方向调整为“企微优先 + OA 无痛接管”;DBWFOC 后进一步固定为:HR/考勤员工日常审批优先使用企业微信,OA 做按域镜像、备份接管、财务闭环和审计中台;采购、合同、项目、财务和高敏附件继续由 OA 主办。WAPI 后,所有企微相关开发必须先读取本规约和 [企微 API 能力目录](./企微API能力目录.md),确认官方 API、Secret、权限、可信 IP、回调/模板和读写边界,再拆 Issue CSV。WMSG1 已证明企业微信应用消息和 Deeplink 真机回流可用;WAPC1 后,审批相关下一步是配置准备清单,不是直接进入审批镜像实现。 +本规约细化企业微信在 `hyb-oa` 中的基础设施边界、入口、免登/绑定、通讯录身份、消息通知、deeplink、打卡、人事助手、原生审批、OA 镜像、人工接管和恢复回写要求。WFOC 起产品方向调整为“企微优先 + OA 无痛接管”;DBWFOC 后进一步固定为:HR/考勤员工日常审批优先使用企业微信,OA 做按域镜像、备份接管、财务闭环和审计中台;采购、合同、项目、财务和高敏附件继续由 OA 主办。WAPI 后,所有企微相关开发必须先读取本规约和 [企微 API 能力目录](./企微API能力目录.md),确认官方 API、Secret、权限、可信 IP、回调/模板和读写边界,再拆 Issue CSV。WMSG1 已证明企业微信应用消息和 Deeplink 真机回流可用;WAPC1 后审批进入配置准备清单,WAPC2 后先做请假/补卡模板详情与低风险 `sp_no` 只读 POC,不是直接进入审批镜像实现。 --- @@ -35,7 +35,7 @@ | 通讯录 | 成员创建、读取、更新、禁用或删除 | 近期已验证 OA 写企微;后续改为企微主源同步 OA,OA 保留修复和审计 | `CAN_REUSE_NOW`:HCLT create/update/disable、部门读取和 WCPC 既有在职测试号 `contact.member_read` 均已通过 | | 人事助手 | 员工字段配置、花名册读取、花名册更新 | 作为员工主数据源,驱动 OA 员工、账号、角色、绑定和状态镜像 | `LOCAL_IMPLEMENTED_FOR_READ_SYNC`:字段配置和既有在职测试号花名册读取已通过;WHMS 已本地实现只读同步到 OA;资料更新写接口仍需单独 POC | | 打卡 | 打卡记录、员工规则、日报、月报、排班、补卡、规则管理 | 企微主办规则和采集,OA 同步按域镜像和应急对账 | `CAN_REUSE_NOW_FOR_READ`:WCPC 短窗口打卡记录和规则读取通过;日报/月报/排班等细项留到考勤镜像合同继续验证 | -| 审批 | 模板详情、提交审批、审批状态回调、审批详情、模板创建/更新、假期余额 | HR/考勤员工日常审批企微主办,OA 同步镜像;OA 主办财务、采购、合同、项目和报销审批不迁移 | `PARTIAL_NEEDS_CONFIG`:假期余额读取通过;WAPC1 已固化请假/补卡/外出/加班的配置准备清单,审批模板 ID、低风险测试审批单号和回调配置仍需在考勤/审批镜像前补齐 | +| 审批 | 模板详情、提交审批、审批状态回调、审批详情、模板创建/更新、假期余额 | HR/考勤员工日常审批企微主办,OA 同步镜像;OA 主办财务、采购、合同、项目和报销审批不迁移 | `POC_RUNNER_READY_NEEDS_CONFIG`:假期余额读取通过;WAPC1 已固化配置准备清单,WAPC2 已新增请假/补卡模板详情和审批详情只读 POC runner;模板 ID、低风险测试审批单号和回调配置仍需在考勤/审批镜像前补齐 | | 工作台和消息 | 自建应用入口、应用消息、deeplink | 继续作为 OA 移动入口和通知渠道 | `CAN_REUSE_NOW`;WRV1 新增 `check:wecom-runtime-ready` 对入口、OAuth、脱敏消息 dry-run 和 deeplink 做运行就绪门禁 | | 财务/报销 | 企微不提供本项目需要的 PDF OCR、付款批次、银行回单和代账闭环 | OA 主办 | `KEEP_OA_PRIMARY` | | 真实支付、ERP、图片 OCR、银行流水导入 | 非本路线目标 | 不进入当前路线 | `NOT_IN_SCOPE` | @@ -223,9 +223,9 @@ REQAUD 复核后,ATTV1 已完成统一入口实现,企业微信打卡边界 ## 7.8 WDRG 开发路线图门禁 - WDRG 后,企业微信相关运行时迁移采用“路线图门禁 + 单功能 Issue CSV”,路线图事实源为 [企微优先开发路线图](./企微优先开发路线图.md)。 -- WAPC1 后阶段顺序调整为 `WECOM-CAPABILITY-POC-V1` -> `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。 +- WAPC2 后阶段顺序调整为 `WECOM-CAPABILITY-POC-V1` -> `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。 - `WECOM-CAPABILITY-POC-V1` 已由 WCPC 收口,WHMS/WHMSLIVE 已完成 HR 主数据镜像实现和受控公网验证;后续不得跳过运行优先门禁和 WOUA 差异处理决策直接开发企微 HR/考勤审批镜像、OA 应急接管、恢复回写或 RuleOps 正式写入。 -- 运行优先阶段先利用入口、免登/绑定、脱敏消息、deeplink、通讯录 userid 清单、人事助手花名册读取和打卡读取;WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 `oaActiveMissingInWecom=0`;剩余企微缺字段先接受不补,归档测试绑定保留审计。WRV1 用 `server/src/scripts/check-wecom-runtime-ready.js` 固化运行优先门禁,WRA1 修正 QA-only 打卡只读门禁,WMSG1 完成真实消息和 Deeplink 真机确认。报告结论只能是 `PASS_RUNTIME_READY`、`PASS_WITH_CONFIG_GAPS` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。该结论仍不能直接放行深度镜像,深度考勤/审批镜像需在 WAPC1 配置准备清单完成、审批模板、测试 `sp_no` 和真实回调配置明确后再拆。 +- 运行优先阶段先利用入口、免登/绑定、脱敏消息、deeplink、通讯录 userid 清单、人事助手花名册读取和打卡读取;WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 `oaActiveMissingInWecom=0`;剩余企微缺字段先接受不补,归档测试绑定保留审计。WRV1 用 `server/src/scripts/check-wecom-runtime-ready.js` 固化运行优先门禁,WRA1 修正 QA-only 打卡只读门禁,WMSG1 完成真实消息和 Deeplink 真机确认。报告结论只能是 `PASS_RUNTIME_READY`、`PASS_WITH_CONFIG_GAPS` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。该结论仍不能直接放行深度镜像,深度考勤/审批镜像需在 WAPC1 配置准备清单完成、WAPC2 请假/补卡只读 POC 和后续回调配置明确后再拆。 - 线上缺陷、正式使用阻断、真实反馈扫尾和 QA-only 脚本维护可另开合同并行;采购、合同、报销、付款、AP、代账和高敏附件继续 OA 主办,不混入企微 HR/考勤路线。 - 本路线图只代表开发节奏和门禁,不代表任何企微 HR/考勤镜像、OA 应急接管、恢复回写或 RuleOps 运行时能力已完成。 @@ -361,6 +361,25 @@ WMSG1 已完成企微应用消息实发与 Deeplink 真机确认。WAPC1 只把 - 后续 POC 必须证明模板详情读取、审批详情读取、回调 URL 验证、事件验签、消息解密、幂等键、企业微信重试语义和补偿拉取方案。 - 批量获取审批单号只作为补偿拉取候选,先标记 `NEEDS_API_POC`。 +## 7.14 WAPC2 企微审批只读 POC + +WAPC2 将审批配置准备先收窄到请假和补卡两类低风险链路,新增 QA-only runner `server/src/scripts/check-wecom-approval-poc.js` / `npm run check:wecom-approval-poc`。 + +只读 POC 范围: + +- 请假模板详情:`--template=leave:`。 +- 补卡模板详情:`--template=missed_punch:`。 +- 请假低风险审批详情:`--sp-no=leave:`。 +- 补卡低风险审批详情:`--sp-no=missed_punch:`。 + +运行边界: + +- `POST /cgi-bin/oa/gettemplatedetail` 和 `POST /cgi-bin/oa/getapprovaldetail` 固定使用 `WECOM_AGENT_SECRET`。 +- 缺模板 ID 或测试 `sp_no` 输出 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`,不伪造成 `PASS_APPROVAL_POC`。 +- 缺 Secret、权限、可信 IP、可见范围或企微 API 错误输出 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 +- 报告只输出脱敏模板 ID/`sp_no` 前缀、字段类型计数、审批节点计数和状态摘要,不输出表单值、附件、评论、完整 `sp_no` 或原始 payload。 +- 本阶段不接真实回调、不创建 OA 镜像表、不写 OA/企微数据、不进入 `wecom_attendance_approval_mirror_v1`。 + --- ## 8. 验收场景 diff --git a/docs/需求-统一审批中心规约.md b/docs/需求-统一审批中心规约.md index f0b60513..a06c506d 100644 --- a/docs/需求-统一审批中心规约.md +++ b/docs/需求-统一审批中心规约.md @@ -354,9 +354,9 @@ WF8-3、WF8-4、WF8-5 实现前必须基于本设计给技术方案;实现后 - 采购单附件、合同正文、报价、供应商资料、发票、银行回单、代账资料和其他高敏附件不进入企业微信原生审批或企业微信附件;企业微信只做提醒、deeplink 和免登录回流。 - 按域镜像是默认规则:企微主办 HR/考勤域镜像到 OA;OA 主办采购、合同、项目、财务和报销域不反向全量镜像到企微。 - WFDG 后,审批相关页面和接口必须区分 `OA 主办`、`企微镜像` 和 `OA 应急接管`;企微镜像不得被误用为 OA 日常新建入口,OA 应急接管单据必须带 `EMERGENCY` 并进入恢复回写队列。 -- WAPI/WRRA/WOUA-FIX/WAPC1 后,审批相关运行时迁移必须先读 [企微优先开发路线图](./企微优先开发路线图.md) 和 [企微 API 能力目录](./企微API能力目录.md),按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` 的顺序拆单功能 Issue CSV;WOUA-FIX 只收口了用户一致性测试账号差异和处理决策,WRV1/WRA1/WMSG1 只验证入口、OAuth/绑定、脱敏消息、deeplink、通讯录、人事助手、打卡只读主链路和真实消息回流。WAPC1 只固化审批配置准备清单;审批模板 ID、测试 `sp_no`、真实回调配置、验签/解密/幂等和补偿拉取未明确前,不得直接开发企微审批镜像或恢复回写。 +- WAPI/WRRA/WOUA-FIX/WAPC1/WAPC2 后,审批相关运行时迁移必须先读 [企微优先开发路线图](./企微优先开发路线图.md) 和 [企微 API 能力目录](./企微API能力目录.md),按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` 的顺序拆单功能 Issue CSV;WOUA-FIX 只收口了用户一致性测试账号差异和处理决策,WRV1/WRA1/WMSG1 只验证入口、OAuth/绑定、脱敏消息、deeplink、通讯录、人事助手、打卡只读主链路和真实消息回流。WAPC1 只固化审批配置准备清单;WAPC2 只读模板详情和低风险 `sp_no` 审批详情;真实回调配置、验签/解密/幂等和补偿拉取未明确前,不得直接开发企微审批镜像或恢复回写。 - WRV1/WRA1 runner `npm run check:wecom-runtime-ready` 中的审批模板、测试 `sp_no` 和真实回调检查只允许输出 `NEEDS_CONFIG`;这类缺口不阻断运行优先主链路的 `PASS_WITH_CONFIG_GAPS`,但必须阻断 `wecom_attendance_approval_mirror_v1`、应急恢复回写和 RuleOps。`WECOM_CHECKIN_SYNC_ENABLED` 只控制正式考勤同步批次,不阻断 QA-only 打卡只读能力探测。 -- WMSG1 已证明企业微信应用消息可达和 Deeplink 可进入 OA 通知页;该证据只清除通知链路,不清除审批模板、测试 `sp_no` 或真实回调前置。WAPC1 固化配置准备清单后,仍需人工确认模板 ID、低风险测试审批单和回调配置,再进入后续 POC 或镜像合同。 +- WMSG1 已证明企业微信应用消息可达和 Deeplink 可进入 OA 通知页;该证据只清除通知链路,不清除审批模板、测试 `sp_no` 或真实回调前置。WAPC1 固化配置准备清单后,WAPC2 先提供请假/补卡模板详情和低风险审批详情只读 POC;仍需人工提供模板 ID、低风险测试审批单,并在后续合同中验证真实回调。 - 后续企微审批同步的 POC 必须输出 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY`、`NOT_SUPPORTED_BY_API`,缺模板、回调、Secret 或权限时记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 - 若企业微信返回 `invaliduser`、`unlicenseduser`、不可见范围或权限类错误,业务提交和审批动作不应回滚,但通知记录必须可审计、可重试,并提示管理员修正企业微信应用可见范围或账号绑定。 - 真机验收以企业微信手机端收到应用消息并可点击回流为准;系统级弹窗是否出现受客户端通知权限影响,不作为 OA 通知链路硬门槛。 @@ -387,7 +387,18 @@ WAPC1 是进入 `wecom_attendance_approval_mirror_v1` 前的配置准备清单 - 回调验签、解密、幂等键、企业微信重试语义、失败重试和补偿拉取方案明确。 - 批量获取审批单号作为补偿拉取候选,先标记 `NEEDS_API_POC`。 -### 9.2 WF8 邮件通道决策 +### 9.2 WAPC2 企微审批只读 POC + +WAPC2 是 WAPC1 后的首个审批只读 POC,范围只包括请假和补卡,不是 `wecom_attendance_approval_mirror_v1`。 + +- 新增 QA-only runner `npm run check:wecom-approval-poc`,默认不写 OA/企微数据,不接真实回调。 +- 请假模板使用 `--template=leave:`,补卡模板使用 `--template=missed_punch:`。 +- 请假低风险测试单使用 `--sp-no=leave:`,补卡低风险测试单使用 `--sp-no=missed_punch:`。 +- `gettemplatedetail` 和 `getapprovaldetail` 均固定使用 `WECOM_AGENT_SECRET`,不得尝试通讯录、打卡或历史 HR assistant Secret。 +- 报告只记录脱敏模板 ID/`sp_no` 前缀、字段类型计数、审批节点计数和状态摘要;不得输出表单值、附件、评论、完整 `sp_no` 或原始 payload。 +- 缺模板 ID 或测试单号时结论为 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`;权限、可信 IP、可见范围或 Secret 阻断时结论为 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 + +### 9.3 WF8 邮件通道决策 WF8 当前正式验收通道为站内通知和企业微信个人应用消息。邮件通道先记录为冻结项,不作为当前 WF8 硬门禁;三渠道验收在本阶段按“两渠道验收 + 邮件冻结说明”处理。 diff --git a/docs/需求-请假考勤修正规约.md b/docs/需求-请假考勤修正规约.md index cdef5cc0..750def40 100644 --- a/docs/需求-请假考勤修正规约.md +++ b/docs/需求-请假考勤修正规约.md @@ -7,7 +7,7 @@ **相关事实源**:[需求 - 统一审批中心规约](./需求-统一审批中心规约.md)、[需求 - 企业微信基础设施规约](./需求-企业微信基础设施规约.md) **体验总依据**:[产品设计规则基线](./产品设计规则.md) -本规约细化请假、补卡、外出、加班与考勤修正的产品要求。WFOC 起,企业微信作为 HR/考勤员工日常审批和打卡规则主操作端;OA 负责按域镜像、应急接管、恢复回写、查询导出和审计。PC 和移动页面体验以产品设计规则基线为依据,不重复定义通用审批链和移动导航规则。 +本规约细化请假、补卡、外出、加班与考勤修正的产品要求。WFOC 起,企业微信作为 HR/考勤员工日常审批和打卡规则主操作端;OA 负责按域镜像、应急接管、恢复回写、查询导出和审计。WAPC2 后,请假和补卡先通过 QA-only runner 只读验证模板详情与低风险 `sp_no` 审批详情;这不代表真实回调或审批镜像已实现。PC 和移动页面体验以产品设计规则基线为依据,不重复定义通用审批链和移动导航规则。 --- @@ -49,7 +49,7 @@ REQAUD 复核后,ATTV1 已完成统一入口实现,当前运行时完成度 | 请假对团队状态/日历的影响 | `DONE` | 已批准请假可作为团队状态和日历依据。 | | 企业微信打卡作为原始采集 | `DONE` | 由企业微信侧承载定位、人脸、Wi-Fi、设备和防作弊能力;OA 不重造。 | | 企业微信打卡 API 同步 | `PARTIAL` | 已新增同步批次和企微打卡摘要表;有已授权调用打卡 API 的自建应用 Secret 和企微权限时同步,缺配置时记录 `SKIP`。`WECOM_CHECKIN_SECRET` 可作为单独考勤同步应用的显式覆盖;未配置时复用已授权工作台应用的 `WECOM_AGENT_SECRET`。 | -| 企微 HR/考勤审批镜像 | `PLANNED_BY_WFOC` | 后续通过 `wecom_attendance_approval_mirror_v1` 同步企微请假、补卡、外出、加班审批详情、历史、附件和状态;该命名承接旧口径 `wecom_native_approval_mirror_v1`。 | +| 企微 HR/考勤审批镜像 | `POC_RUNNER_READY_NEEDS_CONFIG` | WAPC2 已新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC;后续 `wecom_attendance_approval_mirror_v1` 仍需外出/加班 POC、真实回调配置、验签/解密/幂等和补偿拉取方案。 | | 员工补卡申请审批 | `DONE_AS_OA_FALLBACK` | 当前 OA 能提交补卡修正;WFOC 后正常入口迁往企微,OA 仅在应急模式开放并进入恢复回写。 | | 外出申请审批 | `DONE_AS_OA_FALLBACK` | 当前 OA 能提交外出修正;WFOC 后正常入口迁往企微,OA 仅在应急模式开放并进入恢复回写。 | | 加班申请审批 | `DONE_AS_OA_FALLBACK` | 当前 OA 能提交加班修正;WFOC 后正常入口迁往企微,OA 仅在应急模式开放并进入恢复回写。 | diff --git a/issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv b/issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv new file mode 100644 index 00000000..1d15c45a --- /dev/null +++ b/issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv @@ -0,0 +1,9 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"WAPC2-0","合同建档与边界冻结","建立 WAPC2 企微审批模板与低风险 sp_no 只读 POC 合同;承接 WMSG1/WAPC1 事实,本轮只验证请假和补卡模板详情/审批详情只读链路。","1) CSV 校验通过;2) 范围锁定请假和补卡;3) 固定使用 WECOM_AGENT_SECRET;4) 不接真实回调、不调用写接口、不写 OA/企微数据;5) 不进入 wecom_attendance_approval_mirror_v1。","受影响流程:WAPC1 配置准备 -> 审批模板/审批详情只读 POC -> 后续镜像准入判断;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv。","python3|manual","DONE","DONE","DONE","issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv","[]","CSV 校验通过;回滚路径:撤回本 CSV 和本轮代码/文档变更;无运行时状态需要回滚。" +"WAPC2-1","审批只读集成服务","新增企微审批只读集成服务,固定使用自建应用 WECOM_AGENT_SECRET 获取 access token,并调用 gettemplatedetail/getapprovaldetail。","1) 不使用 WECOM_CONTACT_SECRET/WECOM_CHECKIN_SECRET/WECOM_HR_ASSISTANT_SECRET;2) 缺 WECOM_AGENT_SECRET 返回配置阻断;3) API 错误按权限/可信 IP/可见范围/模板或审批单不可见归类;4) 只返回脱敏摘要。","受影响流程:企微审批模板/审批单读取;验证步骤:cd server && node --check src/services/integrations/wecomApproval.js;focused vitest 覆盖 Secret 固定选择、无 fallback 和错误分类。","node|vitest","DONE","DONE","DONE","server/src/services/integrations/wecomApproval.js","[""WAPC2-0""]","node --check 和 focused vitest 通过;只读集成;不调用企业微信写接口。" +"WAPC2-2","QA-only runner","新增 check-wecom-approval-poc runner 和 package script,支持 live/dry-run、JSON/text 输出、CLI/env 输入请假/补卡模板 ID 与低风险 sp_no,并输出脱敏 summary.json。","1) 支持 --template=leave:、--template=missed_punch:、--sp-no=leave:、--sp-no=missed_punch:;2) 支持 WECOM_APPROVAL_LEAVE_TEMPLATE_ID、WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID、WECOM_APPROVAL_LEAVE_TEST_SP_NO、WECOM_APPROVAL_MISSED_PUNCH_TEST_SP_NO;3) 缺配置返回 PARTIAL_APPROVAL_POC_NEEDS_CONFIG;4) live API 阻断返回 SKIP/BLOCKED_BY_EXTERNAL_CONFIG。","受影响流程:审批配置准备 -> QA-only 只读 POC 报告;验证步骤:cd server && node --check src/scripts/check-wecom-approval-poc.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-poc.test.js --reporter=verbose。","node|vitest","DONE","DONE","DONE","server/src/scripts/check-wecom-approval-poc.js|server/package.json","[""WAPC2-1""]","node --check 和 focused vitest 通过;报告不输出手机号、邮箱、token、secret、完整 userid、完整 sp_no、表单值、附件内容、评论内容或原始 payload。" +"WAPC2-3","只读 POC 单测","新增 focused vitest 覆盖 WAPC2 runner 和集成摘要行为。","1) 覆盖固定 WECOM_AGENT_SECRET;2) 覆盖模板详情读取和审批详情读取;3) 覆盖缺模板 ID、缺 sp_no;4) 覆盖企微 API 错误分类;5) 覆盖脱敏输出不泄露敏感字段。","受影响流程:WAPC2 本地质量门禁;验证步骤:cd server && npx vitest run tests/unit/scripts/check-wecom-approval-poc.test.js --reporter=verbose。","vitest","DONE","DONE","DONE","server/tests/unit/scripts/check-wecom-approval-poc.test.js","[""WAPC2-2""]","focused vitest 10 passed;单测使用 mock fetch,不访问公网,覆盖非 JSON 响应不得误判 PASS。" +"WAPC2-4","事实源文档回写","更新路线图、企微 API 能力目录、统一审批规约、企业微信基础设施规约、管理员手册、试运行报告、索引和事实源矩阵,说明 WAPC2 是请假/补卡只读 POC,不代表审批镜像或回调实现。","1) 文档明确 gettemplatedetail/getapprovaldetail 使用 WECOM_AGENT_SECRET;2) 明确 WAPC2 仅请假+补卡;3) 明确低风险 sp_no 各一条;4) 明确真实回调仍未实现;5) 不保存 Secret、Token、完整 sp_no 或原始 payload。","受影响流程:后续企微审批开发事实源;验证步骤:node scripts/qa/audit-docs.mjs --check-registry;rg -n 'WAPC2|gettemplatedetail|getapprovaldetail|WECOM_AGENT_SECRET|sp_no|真实回调|wecom_attendance_approval_mirror_v1' docs .agents/skills/hyb-oa-delivery/SKILL.md。","node|rg","DONE","DONE","DONE","docs|.agents/skills/hyb-oa-delivery/SKILL.md","[""WAPC2-3""]","audit-docs errors=0 warnings=1(既有未登记文档 warning);关键词检查命中;文档只保存摘要、链接和项目决策。" +"WAPC2-5","本地静态门禁","执行 CSV 校验、node --check、prisma validate、focused vitest、文档审计、skill 校验、关键词检查和 diff 检查。","1) 所有本地门禁通过;2) 若 audit-docs 仅保留既有非阻断 warning,Notes 记录;3) git diff --check 通过。","受影响流程:WAPC2 本地验收;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv;cd server && node --check src/scripts/check-wecom-approval-poc.js;cd server && npm run prisma:validate;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-poc.test.js --reporter=verbose;node scripts/qa/audit-docs.mjs --check-registry;python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check。","python3|node|npm|vitest|git","DONE","DONE","DONE","N/A","[""WAPC2-4""]","validate_issues_csv、node --check、prisma validate、focused vitest、audit-docs、quick_validate、关键词检查和 git diff --check 通过;audit-docs 仅既有 warnings=1。" +"WAPC2-6","公网只读复跑准备","在本地门禁通过后,以 SYNC_ENV=0 受控部署 QA-only 变更,并远端执行 check:wecom-approval-poc live;缺模板或 sp_no 时必须返回 PARTIAL_APPROVAL_POC_NEEDS_CONFIG。","1) 不同步本地 env;2) 不持久修改远端 env;3) 不写 OA/企微数据;4) 报告结果不伪造成 PASS;5) 复检 /ready 和公网 runtime smoke。","受影响流程:公网只读 POC 证据;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;远端容器 npm run check:wecom-approval-poc -- --live --format=json;/ready 与 public runtime smoke。","bash|ssh|node","DONE","DONE","DONE","deploy/trialrun/deploy.sh|server/src/scripts/check-wecom-approval-poc.js","[""WAPC2-5""]","SYNC_ENV=0 部署完成,部署脚本通过 /ready、external deps smoke、runtime smoke 和 public bundle scan;远端报告 /app/artifacts/qa-flow/wecom-approval-poc-20260519T020429Z/summary.json 为 PARTIAL_APPROVAL_POC_NEEDS_CONFIG,Secret OK,缺请假/补卡模板 ID 和两条低风险 sp_no;公网 /ready ok。回滚路径:按部署脚本回滚到上一提交。" +"WAPC2-7","状态回填与中文提交","根据本地门禁和公网复跑结果回填状态,中文提交引用 WAPC2,并在最终说明中列出人工仍需提供的模板 ID 与 sp_no。","1) 已完成项状态回填;2) 中文提交引用 WAPC2;3) 最终说明包含验证命令、部署状态、报告路径、剩余人工门禁。","受影响流程:WAPC2 交付闭环;验证步骤:git status --short;git log --oneline -1。","git","DONE","DONE","DONE","issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv|N/A","[""WAPC2-6""]","准备中文提交引用 WAPC2;公网配置未齐,后续由人工准备请假/补卡模板 ID 和两条低风险 sp_no 后复跑。" diff --git a/server/package.json b/server/package.json index b01d2f17..f91bebd5 100644 --- a/server/package.json +++ b/server/package.json @@ -29,6 +29,7 @@ "audit:smoke-data": "node src/scripts/audit-smoke-data.js", "audit:org-master-data": "node src/scripts/audit-org-master-data.js", "audit:wecom-oa-users": "node src/scripts/audit-wecom-oa-user-consistency.js", + "check:wecom-approval-poc": "node src/scripts/check-wecom-approval-poc.js", "check:wecom-runtime-ready": "node src/scripts/check-wecom-runtime-ready.js", "resolve:wecom-oa-users": "node src/scripts/resolve-wecom-oa-user-consistency.js", "test:watch": "vitest", diff --git a/server/src/scripts/check-wecom-approval-poc.js b/server/src/scripts/check-wecom-approval-poc.js new file mode 100644 index 00000000..e65807d2 --- /dev/null +++ b/server/src/scripts/check-wecom-approval-poc.js @@ -0,0 +1,507 @@ +#!/usr/bin/env node +require("dotenv/config"); + +const fs = require("node:fs"); +const path = require("node:path"); + +const { getConfig } = require("../config"); +const { + APPROVAL_POC_TARGETS, + fetchWecomApprovalDetail, + fetchWecomApprovalTemplateDetail, + maskApprovalIdentifier, +} = require("../services/integrations/wecomApproval"); + +const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow"); + +const REPORT_PRIVACY = + "approval type/counts/masked template and sp_no prefixes only; no phone/email/token/secret/full userid/full sp_no/form values/attachments/comments/raw payload"; + +const TEMPLATE_ENV = Object.freeze({ + leave: "WECOM_APPROVAL_LEAVE_TEMPLATE_ID", + missed_punch: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID", +}); + +const SP_NO_ENV = Object.freeze({ + leave: "WECOM_APPROVAL_LEAVE_TEST_SP_NO", + missed_punch: "WECOM_APPROVAL_MISSED_PUNCH_TEST_SP_NO", +}); + +function printHelp() { + process.stdout.write(`Usage: node src/scripts/check-wecom-approval-poc.js [options] + +QA-only 企业微信审批模板与低风险 sp_no 只读 POC。 +默认不写 OA/企微资料,不接真实回调,不进入审批镜像。 + +Options: + --live 启用企微 live 只读检查。 + --format=text|json 输出格式,默认 text。 + --report-dir= 报告目录,默认 artifacts/qa-flow/wecom-approval-poc-。 + --template=leave: 指定请假模板 ID。 + --template=missed_punch: 指定补卡模板 ID。 + --sp-no=leave: 指定请假低风险测试审批单号。 + --sp-no=missed_punch: 指定补卡低风险测试审批单号。 + --help 显示帮助。 + +Secret 固定规则: + 审批模板详情/审批详情:WECOM_AGENT_SECRET。 + 不使用 WECOM_CONTACT_SECRET、WECOM_CHECKIN_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。 + +隐私边界: + 报告只输出模板类型、计数、状态、脱敏模板 ID / sp_no 前缀和错误分类。 + 不输出手机号、邮箱、token、secret、完整 userid、完整 sp_no、表单值、附件、评论或企微原始 payload。 +`); +} + +function timestamp() { + return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} + +function toSafeString(value, maxLength = 500) { + if (value == null) return ""; + return String(value).trim().slice(0, maxLength); +} + +function safeMessage(value) { + return toSafeString(value, 500) + .replace(/access_token=[^&\s]+/gi, "access_token=***") + .replace(/[A-Za-z0-9_-]{24,}/g, "***") + .slice(0, 240); +} + +function supportedTargetKeys() { + return APPROVAL_POC_TARGETS.map((item) => item.key); +} + +function parseTargetPair(value, flagName) { + const raw = toSafeString(value, 500); + const separatorIndex = raw.indexOf(":"); + if (separatorIndex <= 0) { + throw new Error(`${flagName} requires :`); + } + const target = raw.slice(0, separatorIndex).trim(); + const itemValue = raw.slice(separatorIndex + 1).trim(); + if (!supportedTargetKeys().includes(target)) { + throw new Error(`${flagName} unsupported target: ${target}`); + } + if (!itemValue) { + throw new Error(`${flagName} requires non-empty value`); + } + return { target, value: itemValue }; +} + +function parseArgs(argv) { + const options = { + live: process.env.WECOM_APPROVAL_POC_LIVE === "1", + format: "text", + reportDir: process.env.WECOM_APPROVAL_POC_REPORT_DIR + || path.join(DEFAULT_REPORT_ROOT, `wecom-approval-poc-${timestamp()}`), + templates: {}, + spNos: {}, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") { + options.help = true; + } else if (arg === "--live") { + options.live = true; + } else if (arg === "--no-live") { + options.live = false; + } else if (arg === "--format") { + options.format = argv[index + 1] || options.format; + index += 1; + } else if (arg.startsWith("--format=")) { + options.format = arg.slice("--format=".length); + } else if (arg === "--report-dir") { + options.reportDir = argv[index + 1] || options.reportDir; + index += 1; + } else if (arg.startsWith("--report-dir=")) { + options.reportDir = arg.slice("--report-dir=".length); + } else if (arg === "--template") { + const pair = parseTargetPair(argv[index + 1], "--template"); + options.templates[pair.target] = pair.value; + index += 1; + } else if (arg.startsWith("--template=")) { + const pair = parseTargetPair(arg.slice("--template=".length), "--template"); + options.templates[pair.target] = pair.value; + } else if (arg === "--sp-no") { + const pair = parseTargetPair(argv[index + 1], "--sp-no"); + options.spNos[pair.target] = pair.value; + index += 1; + } else if (arg.startsWith("--sp-no=")) { + const pair = parseTargetPair(arg.slice("--sp-no=".length), "--sp-no"); + options.spNos[pair.target] = pair.value; + } else { + throw new Error(`Unsupported argument: ${arg}`); + } + } + + if (!["text", "json"].includes(options.format)) { + throw new Error(`Unsupported --format: ${options.format}`); + } + return options; +} + +function envValue(env, name) { + return toSafeString(env[name], 500); +} + +function buildInputs(options, env = process.env) { + return APPROVAL_POC_TARGETS.map((target) => { + const cliTemplateId = toSafeString(options.templates[target.key], 500); + const cliSpNo = toSafeString(options.spNos[target.key], 500); + const envTemplateId = envValue(env, TEMPLATE_ENV[target.key]); + const envSpNo = envValue(env, SP_NO_ENV[target.key]); + const templateId = cliTemplateId || envTemplateId; + const spNo = cliSpNo || envSpNo; + + return { + key: target.key, + label: target.label, + templateId, + templateIdPrefix: maskApprovalIdentifier(templateId), + templateSource: templateId ? (cliTemplateId ? "cli" : TEMPLATE_ENV[target.key]) : null, + templateMissingEnv: templateId ? [] : [TEMPLATE_ENV[target.key]], + spNo, + spNoPrefix: maskApprovalIdentifier(spNo), + spNoSource: spNo ? (cliSpNo ? "cli" : SP_NO_ENV[target.key]) : null, + spNoMissingEnv: spNo ? [] : [SP_NO_ENV[target.key]], + }; + }); +} + +function buildSecretCheck(config) { + const missing = []; + if (!config.wecomCorpId) missing.push("WECOM_CORP_ID"); + if (!config.wecomAgentSecret) missing.push("WECOM_AGENT_SECRET"); + return { + id: "approval_secret_matrix", + source: "WECOM_AGENT_SECRET", + endpoints: [ + "POST /cgi-bin/oa/gettemplatedetail", + "POST /cgi-bin/oa/getapprovaldetail", + ], + status: missing.length ? "BLOCKED" : "OK", + missing, + noFallback: [ + "WECOM_CONTACT_SECRET", + "WECOM_CHECKIN_SECRET", + "WECOM_HR_ASSISTANT_SECRET", + ], + }; +} + +function summarizeApiResult(result) { + if (!result || typeof result !== "object") return null; + const summary = {}; + for (const key of ["errcode", "errmsg", "fieldCount", "fieldTypeCounts", "spStatus", "applyFieldCount", "approvalNodeCount", "notifyerCount", "commentCount"]) { + if (result[key] != null) { + summary[key] = key === "errmsg" ? safeMessage(result[key]) : result[key]; + } + } + return Object.keys(summary).length ? summary : null; +} + +function buildMissingTemplateCheck(input) { + return { + id: "approval_template_detail", + target: input.key, + label: input.label, + endpoint: "POST /cgi-bin/oa/gettemplatedetail", + source: "WECOM_AGENT_SECRET", + status: "NEEDS_CONFIG", + missingEnv: input.templateMissingEnv, + templateIdPrefix: null, + summary: null, + }; +} + +function buildMissingApprovalCheck(input) { + return { + id: "approval_detail", + target: input.key, + label: input.label, + endpoint: "POST /cgi-bin/oa/getapprovaldetail", + source: "WECOM_AGENT_SECRET", + status: "NEEDS_CONFIG", + missingEnv: input.spNoMissingEnv, + spNoPrefix: null, + summary: null, + }; +} + +function buildDryRunTemplateCheck(input) { + return { + id: "approval_template_detail", + target: input.key, + label: input.label, + endpoint: "POST /cgi-bin/oa/gettemplatedetail", + source: "WECOM_AGENT_SECRET", + status: "DRY_RUN_READY", + templateIdPrefix: input.templateIdPrefix, + configuredFrom: input.templateSource, + summary: null, + }; +} + +function buildDryRunApprovalCheck(input) { + return { + id: "approval_detail", + target: input.key, + label: input.label, + endpoint: "POST /cgi-bin/oa/getapprovaldetail", + source: "WECOM_AGENT_SECRET", + status: "DRY_RUN_READY", + spNoPrefix: input.spNoPrefix, + configuredFrom: input.spNoSource, + summary: null, + }; +} + +function buildLiveTemplateCheck(input, result) { + return { + id: "approval_template_detail", + target: input.key, + label: input.label, + endpoint: "POST /cgi-bin/oa/gettemplatedetail", + source: result.tokenSource || "WECOM_AGENT_SECRET", + status: result.ok ? "OK" : "BLOCKED", + reason: result.ok ? null : result.code, + message: result.message ? safeMessage(result.message) : null, + templateIdPrefix: input.templateIdPrefix, + configuredFrom: input.templateSource, + summary: result.summary, + response: summarizeApiResult(result.response), + }; +} + +function buildLiveApprovalCheck(input, result) { + return { + id: "approval_detail", + target: input.key, + label: input.label, + endpoint: "POST /cgi-bin/oa/getapprovaldetail", + source: result.tokenSource || "WECOM_AGENT_SECRET", + status: result.ok ? "OK" : "BLOCKED", + reason: result.ok ? null : result.code, + message: result.message ? safeMessage(result.message) : null, + spNoPrefix: input.spNoPrefix, + configuredFrom: input.spNoSource, + summary: result.summary, + response: summarizeApiResult(result.response), + }; +} + +async function runTargetChecks({ + config, + input, + options, + fetchTemplateDetailImpl = fetchWecomApprovalTemplateDetail, + fetchApprovalDetailImpl = fetchWecomApprovalDetail, +}) { + let template = null; + let approval = null; + + if (!input.templateId) { + template = buildMissingTemplateCheck(input); + } else if (!options.live) { + template = buildDryRunTemplateCheck(input); + } else { + template = buildLiveTemplateCheck(input, await fetchTemplateDetailImpl({ + config, + templateId: input.templateId, + })); + } + + if (!input.spNo) { + approval = buildMissingApprovalCheck(input); + } else if (!options.live) { + approval = buildDryRunApprovalCheck(input); + } else { + approval = buildLiveApprovalCheck(input, await fetchApprovalDetailImpl({ + config, + spNo: input.spNo, + })); + } + + return { target: input.key, label: input.label, template, approval }; +} + +function collectConfigGaps(targetChecks) { + const gaps = []; + for (const item of targetChecks) { + for (const check of [item.template, item.approval]) { + if (check?.status === "NEEDS_CONFIG") { + gaps.push({ + id: check.id, + target: check.target, + status: check.status, + missingEnv: check.missingEnv || [], + }); + } + } + } + return gaps; +} + +function collectMainBlockers(secretCheck, targetChecks) { + const blockers = []; + if (secretCheck.status === "BLOCKED") { + blockers.push({ + id: secretCheck.id, + reason: "missing_required_wecom_approval_config", + missing: secretCheck.missing, + }); + } + + for (const item of targetChecks) { + for (const check of [item.template, item.approval]) { + if (check?.status === "BLOCKED") { + blockers.push({ + id: check.id, + target: check.target, + reason: check.reason || "wecom_approval_api_blocked", + missing: [], + }); + } + } + } + return blockers; +} + +function evaluateConclusion({ mainBlockers, configGaps, verificationGaps }) { + if (mainBlockers.length) { + return { + status: "SKIP", + conclusion: "BLOCKED_BY_EXTERNAL_CONFIG", + }; + } + if (configGaps.length || verificationGaps.length) { + return { + status: "PARTIAL_APPROVAL_POC_NEEDS_CONFIG", + conclusion: "PARTIAL_APPROVAL_POC_NEEDS_CONFIG", + }; + } + return { + status: "PASS_APPROVAL_POC", + conclusion: "PASS_APPROVAL_POC", + }; +} + +async function executeApprovalPoc(options, deps = {}) { + const env = deps.env || process.env; + const generatedAt = deps.generatedAt || new Date().toISOString(); + const config = deps.config || getConfig(); + const inputs = buildInputs(options, env); + const secretCheck = buildSecretCheck(config); + const targetChecks = []; + + for (const input of inputs) { + targetChecks.push(await runTargetChecks({ + config, + input, + options, + fetchTemplateDetailImpl: deps.fetchTemplateDetailImpl, + fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl, + })); + } + + const configGaps = collectConfigGaps(targetChecks); + const verificationGaps = options.live ? [] : [{ + id: "live_read", + status: "DRY_RUN", + reason: "run_with_--live_for_public_readonly_gate", + }]; + const mainBlockers = collectMainBlockers(secretCheck, targetChecks); + const conclusion = evaluateConclusion({ mainBlockers, configGaps, verificationGaps }); + + return { + mode: "qa_only", + generatedAt, + status: conclusion.status, + conclusion: conclusion.conclusion, + privacy: REPORT_PRIVACY, + scope: { + targets: APPROVAL_POC_TARGETS.map((item) => item.key), + writeBehavior: "no OA writes, no WeCom writes, no approval callbacks, no mirror import", + liveRead: Boolean(options.live), + deepMirror: "not_started", + }, + inputs: { + templates: Object.fromEntries(inputs.map((item) => [item.key, { + configured: Boolean(item.templateId), + configuredFrom: item.templateSource, + templateIdPrefix: item.templateIdPrefix, + }])), + spNos: Object.fromEntries(inputs.map((item) => [item.key, { + configured: Boolean(item.spNo), + configuredFrom: item.spNoSource, + spNoPrefix: item.spNoPrefix, + }])), + }, + checks: { + secretMatrix: secretCheck, + targets: targetChecks, + }, + configGaps, + verificationGaps, + mainBlockers, + }; +} + +function ensureReportDir(reportDir) { + fs.mkdirSync(reportDir, { recursive: true }); +} + +function writeReport(reportDir, report) { + ensureReportDir(reportDir); + const filePath = path.join(reportDir, "summary.json"); + fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + return filePath; +} + +function printText(report, reportPath) { + process.stdout.write(`[wecom-approval-poc] status=${report.status} conclusion=${report.conclusion}\n`); + process.stdout.write(`[wecom-approval-poc] report=${reportPath}\n`); + process.stdout.write(`[wecom-approval-poc] privacy=${report.privacy}\n`); + process.stdout.write(`[wecom-approval-poc] liveRead=${report.scope.liveRead} targets=${report.scope.targets.join(",")}\n`); + if (report.configGaps?.length) { + process.stdout.write(`[wecom-approval-poc] configGaps=${JSON.stringify(report.configGaps)}\n`); + } + if (report.mainBlockers?.length) { + process.stdout.write(`[wecom-approval-poc] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + const report = await executeApprovalPoc(options); + const reportPath = writeReport(options.reportDir, report); + if (options.format === "json") { + process.stdout.write(`${JSON.stringify({ ...report, reportPath }, null, 2)}\n`); + } else { + printText(report, reportPath); + } +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`[wecom-approval-poc][FAIL] ${safeMessage(error?.message || error)}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + buildInputs, + buildSecretCheck, + evaluateConclusion, + executeApprovalPoc, + parseArgs, + summarizeApiResult, +}; diff --git a/server/src/services/integrations/wecomApproval.js b/server/src/services/integrations/wecomApproval.js new file mode 100644 index 00000000..7b74242f --- /dev/null +++ b/server/src/services/integrations/wecomApproval.js @@ -0,0 +1,345 @@ +const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken"); + +const WECOM_API_BASE = "https://qyapi.weixin.qq.com"; + +const APPROVAL_POC_TARGETS = Object.freeze([ + { key: "leave", label: "请假" }, + { key: "missed_punch", label: "补卡" }, +]); + +class WecomApprovalPocError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = "WecomApprovalPocError"; + this.code = code; + this.details = details; + } +} + +function toSafeString(value, maxLength = 500) { + if (value == null) return ""; + return String(value).trim().slice(0, maxLength); +} + +function sanitizeErrorMessage(value) { + return toSafeString(value, 500) + .replace(/access_token=[^&\s]+/gi, "access_token=***") + .replace(/[A-Za-z0-9_-]{24,}/g, "***") + .slice(0, 240); +} + +function maskApprovalIdentifier(value) { + const raw = toSafeString(value, 160); + if (!raw) return null; + if (raw.length <= 6) return `${raw.slice(0, 2)}***`; + return `${raw.slice(0, Math.min(8, raw.length))}***`; +} + +function countBy(items, selector) { + const counts = {}; + for (const item of items) { + const key = toSafeString(selector(item), 80) || "unknown"; + counts[key] = (counts[key] || 0) + 1; + } + return counts; +} + +function normalizeControls(value) { + if (!value) return []; + if (Array.isArray(value)) return value.filter((item) => item && typeof item === "object"); + if (typeof value === "object") { + for (const key of ["controls", "contents", "items", "children"]) { + if (Array.isArray(value[key])) return normalizeControls(value[key]); + } + } + return []; +} + +function controlType(control) { + if (!control || typeof control !== "object") return "unknown"; + return ( + control.control + || control.control_type + || control.controlType + || control.type + || control.property?.control + || control.property?.control_type + || control.property?.type + || "unknown" + ); +} + +function summarizeTemplatePayload(payload) { + const templateId = payload?.template_id ?? payload?.templateId; + const controls = normalizeControls(payload?.template_content?.controls) + .concat(normalizeControls(payload?.template_content?.contents)) + .concat(normalizeControls(payload?.controls)); + const dedupedControls = controls.length ? controls : normalizeControls(payload?.template_content); + + return { + templateIdPrefix: maskApprovalIdentifier(templateId), + fieldCount: dedupedControls.length, + fieldTypeCounts: countBy(dedupedControls, controlType), + hasTemplateNames: Array.isArray(payload?.template_names) && payload.template_names.length > 0, + }; +} + +function summarizeApprovalPayload(payload) { + const info = payload?.info && typeof payload.info === "object" ? payload.info : {}; + const applyData = info.apply_data && typeof info.apply_data === "object" ? info.apply_data : {}; + const contents = Array.isArray(applyData.contents) ? applyData.contents : []; + const records = Array.isArray(info.sp_record) ? info.sp_record : []; + const notifyers = Array.isArray(info.notifyer) ? info.notifyer : []; + const comments = Array.isArray(info.comments) ? info.comments : []; + + return { + spNoPrefix: maskApprovalIdentifier(info.sp_no ?? payload?.sp_no), + templateIdPrefix: maskApprovalIdentifier(info.template_id), + spStatus: info.sp_status == null ? null : toSafeString(info.sp_status, 40), + applyFieldCount: contents.length, + applyFieldTypeCounts: countBy(contents, controlType), + approvalNodeCount: records.length, + notifyerCount: notifyers.length, + commentCount: comments.length, + }; +} + +function sanitizeApprovalApiResponse(payload) { + if (!payload || typeof payload !== "object") return null; + const result = sanitizeWecomResponse(payload) ?? {}; + if (payload.template_content) { + const summary = summarizeTemplatePayload(payload); + result.fieldCount = summary.fieldCount; + result.fieldTypeCounts = summary.fieldTypeCounts; + } + if (payload.info) { + const summary = summarizeApprovalPayload(payload); + result.spStatus = summary.spStatus; + result.applyFieldCount = summary.applyFieldCount; + result.approvalNodeCount = summary.approvalNodeCount; + result.notifyerCount = summary.notifyerCount; + result.commentCount = summary.commentCount; + } + return Object.keys(result).length ? result : null; +} + +function classifyWecomApprovalResult(result, fallbackCode = "wecom_approval_api_error") { + if (result?.networkError) { + return { + code: result.errorMessage === "timeout" ? "wecom_approval_timeout" : "wecom_approval_network_error", + message: result.errorMessage || "WeCom approval API network error", + response: null, + }; + } + + const errcode = Number(result?.payload?.errcode); + if (Number.isFinite(errcode) && errcode !== 0) { + return { + code: "wecom_approval_api_error", + message: `WeCom approval API errcode ${errcode}`, + response: sanitizeApprovalApiResponse(result.payload), + }; + } + + return { + code: result?.httpStatus ? `wecom_approval_http_${result.httpStatus}` : fallbackCode, + message: "WeCom approval API failed", + response: sanitizeApprovalApiResponse(result?.payload), + }; +} + +async function fetchWecomApprovalJson(pathName, accessToken, body, options = {}) { + const url = new URL(`${WECOM_API_BASE}${pathName}`); + url.searchParams.set("access_token", accessToken); + + const timeoutMs = Math.max(500, Number(options.timeoutMs ?? 10000)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await (options.fetchImpl ?? fetch)(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ?? {}), + signal: controller.signal, + }); + const payload = await response.json().catch(() => null); + const errcode = payload && typeof payload === "object" ? Number(payload.errcode) : NaN; + return { + ok: response.ok && Number.isFinite(errcode) && errcode === 0, + httpStatus: response.status, + payload, + }; + } catch (error) { + return { + ok: false, + httpStatus: null, + payload: null, + networkError: true, + errorMessage: error?.name === "AbortError" ? "timeout" : sanitizeErrorMessage(error?.message ?? error), + }; + } finally { + clearTimeout(timer); + } +} + +async function getApprovalAccessToken({ config, fetchImpl, timeoutMs }) { + const tokenResult = await getWecomAppAccessToken({ + config: { + ...config, + wecomRequireAgentId: false, + }, + timeoutMs, + fetchImpl, + }); + + if (!tokenResult.ok) { + throw new WecomApprovalPocError(tokenResult.code, tokenResult.message || "WeCom app token unavailable", { + response: tokenResult.response, + }); + } + + return { + accessToken: tokenResult.accessToken, + tokenSource: "WECOM_AGENT_SECRET", + response: tokenResult.response, + }; +} + +async function fetchWecomApprovalTemplateDetail({ + config, + templateId, + fetchImpl = fetch, + timeoutMs = config?.wecomRequestTimeoutMs ?? 10000, +}) { + const resolvedTemplateId = toSafeString(templateId, 160); + if (!resolvedTemplateId) { + return { + ok: false, + skipped: true, + retryable: false, + code: "approval_template_id_missing", + message: "Missing approval template id", + tokenSource: "WECOM_AGENT_SECRET", + response: null, + summary: null, + }; + } + + try { + const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs }); + const result = await fetchWecomApprovalJson("/cgi-bin/oa/gettemplatedetail", token.accessToken, { + template_id: resolvedTemplateId, + }, { fetchImpl, timeoutMs }); + + if (!result.ok) { + const error = classifyWecomApprovalResult(result, "wecom_approval_template_detail_failed"); + return { + ok: false, + skipped: false, + retryable: false, + code: error.code, + message: error.message, + tokenSource: token.tokenSource, + response: error.response, + summary: null, + }; + } + + return { + ok: true, + skipped: false, + retryable: false, + code: "wecom_approval_template_detail_fetched", + message: null, + tokenSource: token.tokenSource, + response: sanitizeApprovalApiResponse(result.payload), + summary: summarizeTemplatePayload(result.payload), + }; + } catch (error) { + return { + ok: false, + skipped: false, + retryable: Boolean(error?.details?.retryable), + code: error?.code || "wecom_approval_template_detail_failed", + message: sanitizeErrorMessage(error?.message || error), + tokenSource: "WECOM_AGENT_SECRET", + response: error?.details?.response || null, + summary: null, + }; + } +} + +async function fetchWecomApprovalDetail({ + config, + spNo, + fetchImpl = fetch, + timeoutMs = config?.wecomRequestTimeoutMs ?? 10000, +}) { + const resolvedSpNo = toSafeString(spNo, 160); + if (!resolvedSpNo) { + return { + ok: false, + skipped: true, + retryable: false, + code: "approval_sp_no_missing", + message: "Missing approval sp_no", + tokenSource: "WECOM_AGENT_SECRET", + response: null, + summary: null, + }; + } + + try { + const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs }); + const result = await fetchWecomApprovalJson("/cgi-bin/oa/getapprovaldetail", token.accessToken, { + sp_no: resolvedSpNo, + }, { fetchImpl, timeoutMs }); + + if (!result.ok) { + const error = classifyWecomApprovalResult(result, "wecom_approval_detail_failed"); + return { + ok: false, + skipped: false, + retryable: false, + code: error.code, + message: error.message, + tokenSource: token.tokenSource, + response: error.response, + summary: null, + }; + } + + return { + ok: true, + skipped: false, + retryable: false, + code: "wecom_approval_detail_fetched", + message: null, + tokenSource: token.tokenSource, + response: sanitizeApprovalApiResponse(result.payload), + summary: summarizeApprovalPayload(result.payload), + }; + } catch (error) { + return { + ok: false, + skipped: false, + retryable: Boolean(error?.details?.retryable), + code: error?.code || "wecom_approval_detail_failed", + message: sanitizeErrorMessage(error?.message || error), + tokenSource: "WECOM_AGENT_SECRET", + response: error?.details?.response || null, + summary: null, + }; + } +} + +module.exports = { + APPROVAL_POC_TARGETS, + WecomApprovalPocError, + fetchWecomApprovalDetail, + fetchWecomApprovalTemplateDetail, + maskApprovalIdentifier, + sanitizeApprovalApiResponse, + summarizeApprovalPayload, + summarizeTemplatePayload, +}; diff --git a/server/tests/unit/scripts/check-wecom-approval-poc.test.js b/server/tests/unit/scripts/check-wecom-approval-poc.test.js new file mode 100644 index 00000000..209df2e3 --- /dev/null +++ b/server/tests/unit/scripts/check-wecom-approval-poc.test.js @@ -0,0 +1,408 @@ +const { + buildInputs, + buildSecretCheck, + evaluateConclusion, + executeApprovalPoc, + parseArgs, +} = require("../../../src/scripts/check-wecom-approval-poc"); +const { + fetchWecomApprovalDetail, + fetchWecomApprovalTemplateDetail, + maskApprovalIdentifier, + summarizeApprovalPayload, + summarizeTemplatePayload, +} = require("../../../src/services/integrations/wecomApproval"); +const { clearWecomAppAccessTokenCache } = require("../../../src/services/integrations/wecomAccessToken"); + +const SENSITIVE_AGENT_CREDENTIAL = ["approval", "agent", "credential", "should", "never", "leak"].join("-"); +const SENSITIVE_CONTACT_CREDENTIAL = ["approval", "contact", "credential", "unused"].join("-"); +const SENSITIVE_CHECKIN_CREDENTIAL = ["approval", "checkin", "credential", "unused"].join("-"); +const SENSITIVE_HR_CREDENTIAL = ["approval", "hr", "credential", "unused"].join("-"); +const SENSITIVE_TEMPLATE_ID = ["tpl", "leave", "full", "identifier", "should", "not", "leak"].join("-"); +const SENSITIVE_SP_NO = ["sp", "leave", "full", "identifier", "should", "not", "leak"].join("-"); +const SENSITIVE_USER_ID = ["approval", "userid", "full", "should", "not", "leak"].join("_"); +const SENSITIVE_FORM_VALUE = "这里是低风险测试单中的表单值但报告不得输出"; + +function baseConfig(overrides = {}) { + return { + wecomCorpId: "corp-approval", + wecomAgentId: "1000002", + wecomAgentSecret: SENSITIVE_AGENT_CREDENTIAL, + wecomContactSecret: SENSITIVE_CONTACT_CREDENTIAL, + wecomCheckinSecret: SENSITIVE_CHECKIN_CREDENTIAL, + wecomHrAssistantSecret: SENSITIVE_HR_CREDENTIAL, + wecomRequestTimeoutMs: 1000, + ...overrides, + }; +} + +function baseOptions(overrides = {}) { + return { + live: true, + format: "json", + reportDir: "/tmp/wecom-approval-poc-test", + templates: {}, + spNos: {}, + help: false, + ...overrides, + }; +} + +function templatePayload(overrides = {}) { + return { + errcode: 0, + errmsg: "ok", + template_id: SENSITIVE_TEMPLATE_ID, + template_names: [{ text: "请假" }], + template_content: { + controls: [ + { property: { control: "Selector" } }, + { property: { control: "Textarea" } }, + ], + }, + ...overrides, + }; +} + +function approvalPayload(overrides = {}) { + return { + errcode: 0, + errmsg: "ok", + info: { + sp_no: SENSITIVE_SP_NO, + template_id: SENSITIVE_TEMPLATE_ID, + sp_status: 2, + applyer: { userid: SENSITIVE_USER_ID }, + apply_data: { + contents: [ + { control: "Textarea", value: { text: SENSITIVE_FORM_VALUE } }, + { property: { control: "Vacation" }, value: { text: "年假" } }, + ], + }, + sp_record: [{ userid: SENSITIVE_USER_ID }], + notifyer: [{ userid: SENSITIVE_USER_ID }], + comments: [{ comment: SENSITIVE_FORM_VALUE }], + }, + ...overrides, + }; +} + +function jsonResponse(payload, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + }; +} + +describe("check-wecom-approval-poc", () => { + beforeEach(() => { + clearWecomAppAccessTokenCache(); + }); + + it("parses CLI template and sp_no pairs", () => { + const options = parseArgs([ + "--live", + "--format=json", + "--template=leave:tpl-leave", + "--template=missed_punch:tpl-missed", + "--sp-no=leave:sp-leave", + "--sp-no=missed_punch:sp-missed", + ]); + + expect(options.live).toBe(true); + expect(options.format).toBe("json"); + expect(options.templates).toEqual({ + leave: "tpl-leave", + missed_punch: "tpl-missed", + }); + expect(options.spNos).toEqual({ + leave: "sp-leave", + missed_punch: "sp-missed", + }); + expect(() => parseArgs(["--template=outing:tpl"])).toThrow(/unsupported target/); + }); + + it("builds inputs from CLI first, then env, with masked previews", () => { + const inputs = buildInputs(baseOptions({ + templates: { leave: SENSITIVE_TEMPLATE_ID }, + spNos: { leave: SENSITIVE_SP_NO }, + }), { + WECOM_APPROVAL_LEAVE_TEMPLATE_ID: "env-should-not-win", + WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID: "env-missed-template", + WECOM_APPROVAL_MISSED_PUNCH_TEST_SP_NO: "env-missed-sp", + }); + + expect(inputs.find((item) => item.key === "leave")).toMatchObject({ + templateSource: "cli", + spNoSource: "cli", + templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID), + spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO), + }); + expect(inputs.find((item) => item.key === "missed_punch")).toMatchObject({ + templateSource: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID", + spNoSource: "WECOM_APPROVAL_MISSED_PUNCH_TEST_SP_NO", + }); + }); + + it("documents WECOM_AGENT_SECRET as the only approval Secret source", () => { + const check = buildSecretCheck(baseConfig()); + expect(check).toMatchObject({ + status: "OK", + source: "WECOM_AGENT_SECRET", + endpoints: [ + "POST /cgi-bin/oa/gettemplatedetail", + "POST /cgi-bin/oa/getapprovaldetail", + ], + }); + expect(check.noFallback).toEqual([ + "WECOM_CONTACT_SECRET", + "WECOM_CHECKIN_SECRET", + "WECOM_HR_ASSISTANT_SECRET", + ]); + expect(buildSecretCheck(baseConfig({ wecomAgentSecret: "" }))).toMatchObject({ + status: "BLOCKED", + missing: ["WECOM_AGENT_SECRET"], + }); + }); + + it("returns PARTIAL_APPROVAL_POC_NEEDS_CONFIG when template IDs and sp_no are missing", async () => { + const fetchTemplateDetailImpl = vi.fn(); + const fetchApprovalDetailImpl = vi.fn(); + const report = await executeApprovalPoc(baseOptions(), { + config: baseConfig(), + env: {}, + fetchTemplateDetailImpl, + fetchApprovalDetailImpl, + generatedAt: "2026-05-19T09:00:00.000Z", + }); + + expect(report.status).toBe("PARTIAL_APPROVAL_POC_NEEDS_CONFIG"); + expect(report.conclusion).toBe("PARTIAL_APPROVAL_POC_NEEDS_CONFIG"); + expect(report.configGaps).toHaveLength(4); + expect(report.mainBlockers).toEqual([]); + expect(fetchTemplateDetailImpl).not.toHaveBeenCalled(); + expect(fetchApprovalDetailImpl).not.toHaveBeenCalled(); + }); + + it("reads leave and missed-punch template/detail in live mode and returns PASS_APPROVAL_POC", async () => { + const options = baseOptions({ + templates: { + leave: "tpl-leave", + missed_punch: "tpl-missed", + }, + spNos: { + leave: "sp-leave", + missed_punch: "sp-missed", + }, + }); + const fetchTemplateDetailImpl = vi.fn(async ({ config, templateId }) => { + expect(config.wecomAgentSecret).toBe(SENSITIVE_AGENT_CREDENTIAL); + expect([SENSITIVE_CONTACT_CREDENTIAL, SENSITIVE_CHECKIN_CREDENTIAL, SENSITIVE_HR_CREDENTIAL]).not.toContain(config.wecomAgentSecret); + return { + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + summary: summarizeTemplatePayload(templatePayload({ template_id: templateId })), + response: { errcode: 0, fieldCount: 2, fieldTypeCounts: { Selector: 1, Textarea: 1 } }, + }; + }); + const fetchApprovalDetailImpl = vi.fn(async ({ spNo }) => ({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + summary: summarizeApprovalPayload(approvalPayload({ info: { ...approvalPayload().info, sp_no: spNo } })), + response: { errcode: 0, spStatus: 2, applyFieldCount: 2, approvalNodeCount: 1 }, + })); + + const report = await executeApprovalPoc(options, { + config: baseConfig(), + env: {}, + fetchTemplateDetailImpl, + fetchApprovalDetailImpl, + generatedAt: "2026-05-19T09:00:00.000Z", + }); + + expect(report.status).toBe("PASS_APPROVAL_POC"); + expect(report.configGaps).toEqual([]); + expect(report.mainBlockers).toEqual([]); + expect(fetchTemplateDetailImpl).toHaveBeenCalledTimes(2); + expect(fetchApprovalDetailImpl).toHaveBeenCalledTimes(2); + expect(report.checks.targets.every((item) => item.template.status === "OK" && item.approval.status === "OK")).toBe(true); + }); + + it("keeps WeCom API errors as BLOCKED_BY_EXTERNAL_CONFIG", async () => { + const report = await executeApprovalPoc(baseOptions({ + templates: { leave: "tpl-leave", missed_punch: "tpl-missed" }, + spNos: { leave: "sp-leave", missed_punch: "sp-missed" }, + }), { + config: baseConfig(), + env: {}, + fetchTemplateDetailImpl: vi.fn(async ({ templateId }) => ({ + ok: templateId !== "tpl-missed", + tokenSource: "WECOM_AGENT_SECRET", + code: "wecom_approval_api_error", + message: "WeCom approval API errcode 48002", + response: { errcode: 48002, errmsg: "api forbidden" }, + summary: templateId === "tpl-missed" ? null : summarizeTemplatePayload(templatePayload({ template_id: templateId })), + })), + fetchApprovalDetailImpl: vi.fn(async ({ spNo }) => ({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + summary: summarizeApprovalPayload(approvalPayload({ info: { ...approvalPayload().info, sp_no: spNo } })), + response: { errcode: 0, applyFieldCount: 2, approvalNodeCount: 1 }, + })), + generatedAt: "2026-05-19T09:00:00.000Z", + }); + + expect(report.status).toBe("SKIP"); + expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG"); + expect(report.mainBlockers).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: "approval_template_detail", + target: "missed_punch", + reason: "wecom_approval_api_error", + }), + ])); + }); + + it("uses WECOM_AGENT_SECRET for template/detail API calls without fallback", async () => { + const calls = []; + const fetchImpl = vi.fn(async (url, options = {}) => { + calls.push({ + url: String(url), + body: options.body ? JSON.parse(options.body) : null, + }); + if (String(url).includes("/cgi-bin/gettoken")) { + expect(String(url)).toContain(`corpsecret=${encodeURIComponent(SENSITIVE_AGENT_CREDENTIAL)}`); + expect(String(url)).not.toContain(SENSITIVE_CONTACT_CREDENTIAL); + expect(String(url)).not.toContain(SENSITIVE_CHECKIN_CREDENTIAL); + expect(String(url)).not.toContain(SENSITIVE_HR_CREDENTIAL); + return jsonResponse({ errcode: 0, access_token: "approval-token", expires_in: 7200 }); + } + if (String(url).includes("/cgi-bin/oa/gettemplatedetail")) { + return jsonResponse(templatePayload()); + } + if (String(url).includes("/cgi-bin/oa/getapprovaldetail")) { + return jsonResponse(approvalPayload()); + } + throw new Error(`unexpected url ${url}`); + }); + + const template = await fetchWecomApprovalTemplateDetail({ + config: baseConfig(), + templateId: SENSITIVE_TEMPLATE_ID, + fetchImpl, + }); + const detail = await fetchWecomApprovalDetail({ + config: baseConfig(), + spNo: SENSITIVE_SP_NO, + fetchImpl, + }); + + expect(template).toMatchObject({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + summary: { + fieldCount: 2, + }, + }); + expect(detail).toMatchObject({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + summary: { + applyFieldCount: 2, + approvalNodeCount: 1, + }, + }); + expect(calls.some((item) => item.url.includes("/cgi-bin/oa/gettemplatedetail") && item.body.template_id === SENSITIVE_TEMPLATE_ID)).toBe(true); + expect(calls.some((item) => item.url.includes("/cgi-bin/oa/getapprovaldetail") && item.body.sp_no === SENSITIVE_SP_NO)).toBe(true); + }); + + it("does not treat non-json approval API responses as success", async () => { + const fetchImpl = vi.fn(async (url) => { + if (String(url).includes("/cgi-bin/gettoken")) { + return jsonResponse({ errcode: 0, access_token: "approval-token", expires_in: 7200 }); + } + return { + ok: true, + status: 200, + json: async () => { + throw new Error("invalid json"); + }, + }; + }); + + const template = await fetchWecomApprovalTemplateDetail({ + config: baseConfig(), + templateId: SENSITIVE_TEMPLATE_ID, + fetchImpl, + }); + + expect(template).toMatchObject({ + ok: false, + code: "wecom_approval_http_200", + summary: null, + }); + }); + + it("keeps report output redacted", async () => { + const report = await executeApprovalPoc(baseOptions({ + templates: { leave: SENSITIVE_TEMPLATE_ID, missed_punch: "tpl-missed" }, + spNos: { leave: SENSITIVE_SP_NO, missed_punch: "sp-missed" }, + }), { + config: baseConfig(), + env: {}, + fetchTemplateDetailImpl: vi.fn(async ({ templateId }) => ({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + summary: summarizeTemplatePayload(templatePayload({ template_id: templateId })), + response: { errcode: 0, fieldCount: 2, fieldTypeCounts: { Selector: 1, Textarea: 1 } }, + })), + fetchApprovalDetailImpl: vi.fn(async ({ spNo }) => ({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + summary: summarizeApprovalPayload(approvalPayload({ info: { ...approvalPayload().info, sp_no: spNo } })), + response: { errcode: 0, spStatus: 2, applyFieldCount: 2, approvalNodeCount: 1 }, + })), + generatedAt: "2026-05-19T09:00:00.000Z", + }); + const serialized = JSON.stringify(report); + + expect(serialized).not.toContain(SENSITIVE_AGENT_CREDENTIAL); + expect(serialized).not.toContain(SENSITIVE_CONTACT_CREDENTIAL); + expect(serialized).not.toContain(SENSITIVE_CHECKIN_CREDENTIAL); + expect(serialized).not.toContain(SENSITIVE_HR_CREDENTIAL); + expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID); + expect(serialized).not.toContain(SENSITIVE_SP_NO); + expect(serialized).not.toContain(SENSITIVE_USER_ID); + expect(serialized).not.toContain(SENSITIVE_FORM_VALUE); + expect(serialized).toContain(maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID)); + expect(serialized).toContain(maskApprovalIdentifier(SENSITIVE_SP_NO)); + }); + + it("evaluates conclusion categories", () => { + expect(evaluateConclusion({ + mainBlockers: [{ id: "approval_secret_matrix" }], + configGaps: [], + verificationGaps: [], + })).toEqual({ + status: "SKIP", + conclusion: "BLOCKED_BY_EXTERNAL_CONFIG", + }); + expect(evaluateConclusion({ + mainBlockers: [], + configGaps: [{ id: "approval_detail" }], + verificationGaps: [], + })).toEqual({ + status: "PARTIAL_APPROVAL_POC_NEEDS_CONFIG", + conclusion: "PARTIAL_APPROVAL_POC_NEEDS_CONFIG", + }); + expect(evaluateConclusion({ + mainBlockers: [], + configGaps: [], + verificationGaps: [], + })).toEqual({ + status: "PASS_APPROVAL_POC", + conclusion: "PASS_APPROVAL_POC", + }); + }); +});