From 56a1fc3b1d1d7d850178193098d3570f34e2c725 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 19 May 2026 00:35:50 +0800 Subject: [PATCH] =?UTF-8?q?WOUAFIX=20=E6=94=B6=E5=8F=A3=20OA=20=E4=B8=8E?= =?UTF-8?q?=E4=BC=81=E5=BE=AE=E7=94=A8=E6=88=B7=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=E5=B7=AE=E5=BC=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/hyb-oa-delivery/SKILL.md | 4 +- docs/trialrun/人工试运行报告.md | 3 + docs/trialrun/真机公网测试清单.md | 21 +- docs/产品口径.md | 8 +- docs/企微优先开发路线图.md | 16 +- docs/开发交付与变更管理规约.md | 2 +- docs/文档事实源状态矩阵.md | 13 +- docs/文档索引.md | 10 +- docs/海音达OA-MVP需求规格说明书.md | 4 +- docs/管理员手册.md | 4 +- docs/需求-企业微信基础设施规约.md | 2 +- docs/需求-统一审批中心规约.md | 2 +- docs/需求-请假考勤修正规约.md | 6 +- ...-oa-user-consistency-fix-runtime-ready.csv | 9 + server/package.json | 1 + .../resolve-wecom-oa-user-consistency.js | 711 ++++++++++++++++++ server/src/scripts/seed-trialrun.js | 36 +- server/src/scripts/smoke-trialrun-runtime.js | 32 +- .../resolve-wecom-oa-user-consistency.test.js | 201 +++++ 19 files changed, 1034 insertions(+), 51 deletions(-) create mode 100644 issues/2026-05-18_23-45-45-wecom-oa-user-consistency-fix-runtime-ready.csv create mode 100644 server/src/scripts/resolve-wecom-oa-user-consistency.js create mode 100644 server/tests/unit/scripts/resolve-wecom-oa-user-consistency.test.js diff --git a/.agents/skills/hyb-oa-delivery/SKILL.md b/.agents/skills/hyb-oa-delivery/SKILL.md index c8a9193d..41c9f4cb 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 and the next default contract is `WOUA-FIX / WECOM-RUNTIME-READY-V1`; 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. 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. 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,7 +106,7 @@ 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 rerun result is `PASS_WITH_FINDINGS`, which means OA/WeCom users still differ and does not clear deep mirror work. Approval template ID, approval `sp_no`, callback config, attendance/approval mirror, emergency mode, recovery writeback, RuleOps, and HR write APIs remain later-stage gaps. 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` -> `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`; 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. Approval template ID, approval `sp_no`, callback config, attendance/approval mirror, emergency mode, recovery writeback, RuleOps, and HR write APIs remain later-stage gaps. 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` -> `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. - 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 6d890cd1..6b9cc93a 100644 --- a/docs/trialrun/人工试运行报告.md +++ b/docs/trialrun/人工试运行报告.md @@ -48,6 +48,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 - 2026-05-18 WCPC 已完成 WCP1 配置收口复跑:先完成未补配置前远端基线复跑,报告目录 `/tmp/wecom-capability-poc-wcpc-baseline`,结论为 `PAUSE_FOR_CONFIG`;随后按用户要求自动查找并使用既有在职低风险测试号 `SMOKE002/测试员工`(报告只记录 `oa_smoke***`)复跑,只读报告 `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json` 返回 `PASS / GO_TO_HR_MASTER_SYNC`,`nextStageAllowed=true`,`blockingCapabilities=[]`,状态计数为 `CAN_REUSE_NOW=15`、`NEEDS_CONFIG=3`、`NEEDS_API_POC=1`、`KEEP_OA_PRIMARY=1`。本轮不部署、不改业务代码、不持久写服务器 `.env`;下一步允许规划 `wecom_hr_master_sync_v1`,审批模板/实例、回调配置和 HR 写接口仍留后续合同。 - 2026-05-18 WHMS 已完成企微 HR 主数据镜像本地实现、部署和受控公网验证:新增 `wecom_hr_master_sync_v1` 默认 `force_off`,实现企业微信通讯录/人事助手读取、dry-run/apply、同步批次/逐员工记录、管理员 API、People 管理卡片和 QA-only runner。WHMSLIVE 部署到 `https://oa.hyinda.com` 并应用 migration `20260518184319_wecom_hr_master_sync_v1`;公网临时 `force_on` 后对 `SMOKE002/测试员工`(脱敏 `oa_smoke***`)执行 dry-run 和显式 userids apply,企微读取链路通过,因企微侧缺手机号、邮箱和部门按 `missing_required_field` 跳过,未写 OA 主数据;普通员工访问同步 API 返回 403;最终 `wecom_hr_master_sync_v1=force_off`。未执行生产真实员工批量 apply,不代表考勤/审批镜像、应急接管、恢复回写或 RuleOps 已完成。 - 2026-05-18 WOUA 已完成 OA active 员工 vs 企业微信通讯录 + 人事助手的只读对账 runner、本地门禁、受控部署和公网执行。初始公网报告因旧读取路径返回 `SKIP / BLOCKED_BY_EXTERNAL_CONFIG`;WAPI 固化 Secret 矩阵和 `user/list_id` + 人事助手读取后复跑成功,远端脱敏报告 `/app/artifacts/qa-flow/wecom-oa-user-consistency-20260518T142544Z/summary.json` 结论为 `PASS_WITH_FINDINGS`。当前只读计数:`oaEmployees=13`、`oaActiveEmployees=12`、`oaActiveWithBinding=5`、`wecomProfiles=5`、`matchedActive=5`、`oaActiveMissingInWecom=7`、`inactiveWithBinding=1`、`missingRequiredFields=5`。本轮未修改 OA 或企微用户,未执行真实员工批量 apply,不能据此判定“OA 和企微用户已一致”;下一步应按差异决定 WOUA-FIX 或 WHMS 批量同步窗口。 +- 2026-05-18 WOUA-FIX 已按用户确认执行受控写入:7 个 OA active 缺企微账号均确认为测试/Smoke 数据并软归档,`oaActiveMissingInWecom` 复检为 0;`T0000` seed 逻辑已修正为存在真实 active admin 时不再复活已归档 fallback 管理员;`SMOKE201` 归档后遗留的 `SMOKE101` 主管和 2 个测试请假待办已转交 `HYD000007`。复检报告 `/app/artifacts/qa-flow/wecom-oa-user-consistency-20260518T161716Z/summary.json`:OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余 `missingRequiredFields=5` 按用户决策先接受不补,`HRWDTEST-20260517214548` 归档绑定保留为审计项。本轮不写企微资料,不执行真实员工 WHMS 批量 apply,不代表考勤/审批镜像可直接开工。 ## 关键证据索引 @@ -95,6 +96,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | HR-WECOM-DIRECTORY-V1 | 已收口:人事生命周期与企业微信通讯录同步闭环已部署公网;ERP 改为未来不接入 OA;员工创建/入职/调岗/资料变更/离职可触发企微通讯录创建、更新、禁用并记录同步状态;缺外部配置时公网重试记录 `SKIP wecom_contact_sync_disabled`,不阻断 OA 人事流程;部署 seed 已修正为不覆盖正式 flag | `issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv`、`server/tests/unit/routes/employees.wecomDirectory.test.js`、`artifacts/qa-flow/live-smoke-readiness-20260517-205813/summary.json`、`docs/需求-企业微信基础设施规约.md` | | HRWD-CONTACT-LIVE-TEST | 已收口:公网企微通讯录真实写入闭环通过;测试员工 `HRWDTEST-20260517214548` 完成 create/update/disable,企微 `errcode=0`,OA 最终 archived/inactive,离职后登录负例、普通员工权限负例和审计动作均通过;最终保留离职/禁用,不删除成员 | `issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv`、`artifacts/qa-flow/hrwd-contact-live-test-20260517T134548Z/summary.json`、`artifacts/qa-flow/live-smoke-readiness-20260517-214632/summary.json` | | WOUA/WAPI | 已收口:OA/企微用户一致性只读审计 runner 已按 WAPI 读取口径复跑;公网报告 `PASS_WITH_FINDINGS`,OA 侧脱敏计数 13/12/5,企微可见 active 5,匹配 5,OA active 缺企微 7,已归档仍有绑定 1,缺必填字段 5;未修改 OA/企微用户,未执行真实员工批量 apply | `issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv`、`issues/2026-05-18_21-46-41-wecom-api-capability-catalog-runtime-first.csv`、`/app/artifacts/qa-flow/wecom-oa-user-consistency-20260518T142544Z/summary.json` | +| WOUA-FIX | 已受控写入并复检:7 个缺企微测试账号软归档,T0000 seed 不再复活,SMOKE201 归档后的测试主管和待办副作用已转交;复检 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余 5 个企微缺字段先接受不补,1 个归档绑定保留审计 | `issues/2026-05-18_23-45-45-wecom-oa-user-consistency-fix-runtime-ready.csv`、`/app/artifacts/qa-flow/wecom-oa-user-consistency-fix-20260518T160944Z/summary.json`、`/app/artifacts/qa-flow/wecom-oa-user-consistency-20260518T161716Z/summary.json` | ## 当前门禁 @@ -151,6 +153,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | R7EP 公网移动矩阵 | 通过 | `cd client && E2E_BASE_URL=https://oa.hyinda.com npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts --workers=1 --reporter=list`,5 passed | | R7EP 核心业务流 | 分段通过 | 长组合命令在当前工具会话中不稳定中断,不采用不完整 `summary.json` 作为通过证据;采用同一 groups 的可追溯分段结果:`business-flow-20260513-142857/summary.txt` 中 reimbursement/approval/leave/procurement PASS,`business-flow-20260513-145759/summary.txt` 中 approval PASS,`business-flow-20260513-144801/summary.txt` 中 governance PASS | | R7EP 账号角色只读核验 | 通过 | 只读 SQL 证据 `artifacts/qa-flow/r7ep-account-role-readonly-20260513-152121/`;`SMOKE002`、`SMOKE101`、`SMOKE201`、`SMOKE301`、`SMOKE302`、`SMOKE001`、`wangzhiling`、`wangshunli` 均为 `active`;角色、直属主管、企业微信绑定布尔状态已核验;`SMOKE001` 和 `admin` role 当前无 pending 审批任务,财务角色无 admin 混授 | +| WOUA-FIX 后账号事实 | 已更新 | 2026-05-18 后,`SMOKE001`、`SMOKE201`、`SMOKE301`、`SMOKE302`、`SMOKE401`、`T0000` 已作为缺企微测试数据软归档;R7EP 账号角色只读核验保留为 2026-05-13 历史证据,不再代表当前可执行测试账号清单。 | | OPS8 最小准备 | 已补文档并通过远端只读检查 | `docs/trialrun/OPS7运维交接Runbook.md` 已补当前部署版本追踪、`SYNC_ENV=0` 口径、备份/回滚占位和 live readiness 归档规则;OPS8-3 远端只读证据 `artifacts/qa-flow/ops8-remote-readonly-20260513-152024/`,Caddy active,compose db/server Up,Prisma migration up to date | | QAF8 覆盖刷新 | 已补文档 | `docs/测试规约与回归门禁.md` 已补当前最小回归组合、本地/公网匿名/带凭据门禁边界、R108/R109 历史专项分类 | | UX8 反馈池 | 阶段性收口 | 本轮未新增 UX8 代码实现;iPhone 12 间距、详情扫读性、财务摘要、移动采购创建和列表卡片反馈继续按真实反馈进入后续项 | diff --git a/docs/trialrun/真机公网测试清单.md b/docs/trialrun/真机公网测试清单.md index bc19d3b2..b39e9d9f 100644 --- a/docs/trialrun/真机公网测试清单.md +++ b/docs/trialrun/真机公网测试清单.md @@ -76,22 +76,21 @@ ## 审批与通知 - 正式审批口径:`majianyong` 是员工可见的直属主管;`wangshunli` 是后台公司负责人,配置为 `ceo + admin`,不配置为普通 `manager`;`admin` 不代表业务审批权。 -- 试运行验收不使用真实员工日常账号,使用测试替身账号模拟正式组织链路:`SMOKE002 -> SMOKE101 -> SMOKE201 -> SMOKE302/SMOKE301`。 -- `SMOKE001` 仅作为测试管理员,不具备业务审批权。 +- WOUA-FIX 后,`SMOKE001`、`SMOKE201`、`SMOKE301`、`SMOKE302`、`SMOKE401`、`T0000` 已作为缺企微测试数据软归档;后续真机或公网试运行不得再把这些账号作为当前执行账号。 - 审批链正确性是业务逻辑验收,优先通过 PC 公网网页或自动化证明,不要求所有审批角色绑定企业微信。 - 企业微信真机验收只覆盖工作台入口、绑定/免登录、移动布局、待办、详情、PDF 上传入口和通知 deeplink;当前 `oa_smoke_01 -> SMOKE002` 与 `oa_smoke_02 -> SMOKE101` 足够覆盖“提交人 + 审批人”的核心移动体验。 -- `SMOKE201`、`SMOKE301`、`SMOKE302` 可通过 PC 公网网页验证负责人、财务、财务主管流程,不再作为企业微信原生身份硬门槛。 +- 负责人、财务、财务主管深层链路需改用当前 active 角色账号或新建带企微绑定的测试账号;旧 `SMOKE201/301/302` 只保留为 2026-05-10 自动化历史证据,不再作为当前验收入口。 ### PC/自动化审批链验收矩阵 | 验收项 | 验收方式 | 账号角色 | 提交或处理 | 期望结果 | 结果 | 证据位置 | | --- | --- | --- | --- | --- | --- | --- | | 普通低/中额单据 | PC/自动化 | `SMOKE002` / 测试员工 | 提交低/中额单据 | 待办分配给 `SMOKE101` | 通过 | S4 自动化证据 | -| 高额/高风险/例外单据 | PC/自动化 | `SMOKE002` / 测试员工 | 提交高额/例外单据 | `SMOKE101 -> SMOKE201 -> SMOKE302/SMOKE301`,以现有金额和财务规则为准 | 通过 | S4 自动化证据 | -| 主管本人申请 | PC/自动化 | `SMOKE101` / 测试主管 | `SMOKE101` 提交本人申请 | 待办分配给 `SMOKE201` | 通过 | S4 自动化证据 | -| 公司负责人本人申请 | PC/自动化 | `SMOKE201` / 测试负责人 | `SMOKE201` 提交本人申请 | 不允许自审;无非本人兜底时阻断 | 通过 | S4 自动化证据 | -| 系统管理员无业务审批权 | PC/自动化 | `SMOKE001` / 测试管理员 | 尝试审批测试单据 | 不允许仅凭 `admin` 审批 | 通过 | S4 自动化证据 | -| 财务复核与付款/导出 | PC/自动化 | `SMOKE301` / `SMOKE302` | 处理已批准测试单据 | 按财务与财务主管权限处理 | 自动化已覆盖财务节点;PC 页面体验后续 R6 继续观察 | S4 自动化证据 | +| 高额/高风险/例外单据 | PC/自动化 | 当前 active 角色账号 | 提交高额/例外单据 | 按当前负责人、财务和财务主管配置流转 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | +| 主管本人申请 | PC/自动化 | 当前 active 主管账号 | 主管提交本人申请 | 待办分配给非本人上级或明确阻断 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | +| 公司负责人本人申请 | PC/自动化 | 当前 active 负责人账号 | 负责人提交本人申请 | 不允许自审;无非本人兜底时阻断 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | +| 系统管理员无业务审批权 | PC/自动化 | 当前 active admin | 尝试审批测试单据 | 不允许仅凭 `admin` 审批 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | +| 财务复核与付款/导出 | PC/自动化 | 当前 active 财务角色账号 | 处理已批准测试单据 | 按财务与财务主管权限处理 | 需按 WOUA-FIX 后账号重跑 | 后续新证据 | ### 企业微信/移动体验验收矩阵 @@ -106,9 +105,9 @@ 自动化补充证据(用于审批链业务验收): - `2026-05-10`:公网 live smoke、真实 OCR 和测试替身审批链已通过;证据目录 `artifacts/qa-flow/live-smoke-readiness-20260510-205136`。 -- `SMOKE002` 请假待办已分配给 `SMOKE101`;`SMOKE101` 本人请假待办已分配给 `SMOKE201`;`SMOKE201` 本人请假提交被 `no_actionable_approver` 阻断。 -- `SMOKE001` 仅凭 `admin` 审批业务待办被拒绝,HTTP `403/PERM_002`。 -- 小额报销经 `SMOKE101` 后进入 `finance` role;大额报销进入 `SMOKE101 -> ceo -> finance_mgr`,由 `SMOKE301/SMOKE302` 测试角色验证。 +- 2026-05-10 历史证据中:`SMOKE002` 请假待办分配给 `SMOKE101`;`SMOKE101` 本人请假待办分配给 `SMOKE201`;`SMOKE201` 本人请假提交被 `no_actionable_approver` 阻断。 +- 2026-05-10 历史证据中:`SMOKE001` 仅凭 `admin` 审批业务待办被拒绝,HTTP `403/PERM_002`。 +- 2026-05-10 历史证据中:小额报销经 `SMOKE101` 后进入 `finance` role;大额报销进入 `SMOKE101 -> ceo -> finance_mgr`,由 `SMOKE301/SMOKE302` 测试角色验证。WOUA-FIX 后这些账号中的缺企微测试账号已归档,后续需用当前 active 角色账号重跑。 - 企业微信工作台、OAuth 绑定和提交人/审批人核心移动链路已有 `oa_smoke_01/oa_smoke_02` 真机通过;移动布局、PDF 上传、通知 deeplink 已完成公网自动视觉和真机确认,R5/S5 可按合同回填 `DONE`。 - `2026-05-10` 真机补充:原记录为 iPhone 11(iOS 14),用户于 `2026-05-11` 更正实际设备为 iPhone 12 / iOS 14.1;与 Redmi K60(MIUI)企业微信最新版下,`oa_smoke_01 -> SMOKE002`、`oa_smoke_02 -> SMOKE101` 绑定、工作台进入、免登录、提交和审批详情/结果均通过。 - `2026-05-10` 阻断:真机发现移动底部导航和报销详情深层页在不同分辨率下存在底部区域不可见或控件挤压;发票上传正式口径改为 PDF-only。本地修复已完成,部署与真机复测完成前,S5/O3/R5 不标 `DONE`。 diff --git a/docs/产品口径.md b/docs/产品口径.md index ce8b84a8..08c8a2d7 100644 --- a/docs/产品口径.md +++ b/docs/产品口径.md @@ -15,13 +15,13 @@ - 企业微信作为手机工作台入口、免登/绑定、通讯录身份、消息通知、打卡、人事助手和 HR/考勤员工日常审批主操作端;OA 主办审批通过企业微信个人应用消息发送脱敏提醒并 deeplink 回 OA 处理。 - 采购单附件、合同正文、报价、供应商资料、发票、银行回单和代账资料不上传到企业微信原生审批或企业微信附件。 - 简写口径:采购不进企微原生审批,合同不进企微原生审批,附件不进企微附件;企微只做提醒、入口和免登录回 OA。 -- WAPI/WRRA 后固定“运行优先 + 差异收口先行”:近期先用入口、免登/绑定、脱敏消息、deeplink、通讯录 userid 清单、人事助手花名册读取和打卡读取;`PASS_WITH_FINDINGS` 不直接放行深度镜像,后续先做 WOUA 差异处理或 WHMS 同步窗口决策。 +- WAPI/WRRA/WOUA-FIX 后固定“运行优先 + 差异收口先行”:近期先用入口、免登/绑定、脱敏消息、deeplink、通讯录 userid 清单、人事助手花名册读取和打卡读取;WOUA-FIX 已清零 OA active 缺企微测试账号差异,但 `PASS_WITH_FINDINGS` 剩余缺字段和归档绑定不直接放行深度镜像。 ### P0-2 企业微信打卡与 OA 考勤口径 - 企业微信打卡用于原始采集,保留其定位、人脸、Wi-Fi、规则与设备能力。 - OA 后续同步企业微信打卡规则、排班、记录、日报、月报和 HR/考勤审批镜像,形成正式考勤口径。 - 请假、补卡、外出、加班等考勤修正正常在企业微信完成;OA 只在应急模式下接管,并在企微恢复后 dry-run 和自动回写。 -- `wecom_hr_master_sync_v1` 已本地实现并部署公网完成受控验证;最终保持 `force_off`。WAPI 已复跑用户一致性只读对账,结论为 `PASS_WITH_FINDINGS`,说明 OA/企微用户仍不一致;下一步先建 `WOUA-FIX / WECOM-RUNTIME-READY-V1` 或明确 WHMS 批量同步窗口,后续考勤/审批镜像、应急接管、恢复回写或 RuleOps 仍必须另建 `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1` 合同。 +- `wecom_hr_master_sync_v1` 已本地实现并部署公网完成受控验证;最终保持 `force_off`。WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余企微缺字段先接受不补,归档绑定保留审计。后续考勤/审批镜像、应急接管、恢复回写或 RuleOps 仍必须另建 `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1` 合同。 ### P0-3 按域镜像定义 - 企微主办域镜像到 OA:通讯录/人事助手、打卡、请假、补卡、外出、加班、假期余额和 HR/考勤规则。 @@ -37,7 +37,7 @@ ### P0-5 WDRG 开发路线图门禁 - WDRG 后采用“路线图门禁 + 单功能 Issue CSV”:路线图只控制阶段顺序、准入/退出门禁、并行规则和禁止跳阶段,具体功能仍单独方案、单独 Issue CSV、单独验证。 - 当前企微优先 HR/考勤路线顺序固定为:`WECOM-CAPABILITY-POC-V1` -> `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `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 差异分类和处理决策直接开发考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 正式写入。 +- `WECOM-CAPABILITY-POC-V1` 已由 WCPC 收口,WHMS/WHMSLIVE 已完成 HR 主数据镜像实现和受控公网验证,WOUA-FIX 已完成测试账号差异收口;后续不得跳过运行入口、OAuth/绑定、脱敏消息、打卡读取、审批模板/`sp_no` 和真实回调配置 POC,直接开发考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 正式写入。 - 线上缺陷、正式使用阻断、真实反馈扫尾和 QA-only 脚本维护可以另开合同并行;采购、合同、报销、付款、AP、代账和高敏附件继续 OA 主办,不混入企微 HR/考勤路线。 - 路线图事实源为 [企微优先开发路线图](./企微优先开发路线图.md);该路线图不代表任何运行时迁移能力已完成。 - API 和 Secret 事实源为 [企微 API 能力目录](./企微API能力目录.md):通讯录用 `WECOM_CONTACT_SECRET`;人事助手、打卡、消息和 OAuth 默认用 `WECOM_AGENT_SECRET`;不得在运行时代码里轮询多个 Secret 试错。 @@ -62,7 +62,7 @@ - WHMSLIVE 公网验证中,`SMOKE002/测试员工`(脱敏 `oa_smoke***`)因企微侧缺手机号、邮箱和部门按 `missing_required_field` 跳过,证明缺字段不会写入占位数据;普通员工访问同步 API 已验证为 403。 - 已有 OA 员工只更新安全主数据字段,不覆盖银行账户、身份证、工资、合同附件或历史审批。 - WAPI 后 WHMS/WOUA 读取策略修正为通讯录 `user/list_id` + 可选成员详情 + 人事助手 `get_staff_info`,通讯录用 `WECOM_CONTACT_SECRET`,人事助手用 `WECOM_AGENT_SECRET`,不再靠 fallback 试错。 -- WHMS 不代表考勤/审批镜像、审批回调、OA 应急接管、恢复回写或 RuleOps 已完成;下一步先建 `WOUA-FIX / WECOM-RUNTIME-READY-V1` 或确定 WHMS 批量同步窗口,之后再判断是否另建 `wecom_attendance_approval_mirror_v1`。 +- WHMS/WOUA-FIX 不代表考勤/审批镜像、审批回调、OA 应急接管、恢复回写或 RuleOps 已完成;下一步先建 `WECOM-RUNTIME-READY-V1`,完成运行入口、OAuth/绑定、脱敏消息、打卡读取和审批/回调配置 POC,之后再判断是否另建 `wecom_attendance_approval_mirror_v1`。 --- diff --git a/docs/企微优先开发路线图.md b/docs/企微优先开发路线图.md index 4e2ac2c7..e387690a 100644 --- a/docs/企微优先开发路线图.md +++ b/docs/企微优先开发路线图.md @@ -2,8 +2,8 @@ **文档状态**:当前开发路线图事实源。 **分类**:开发治理 / 路线图。 -**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA 之后。 -**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA`。 +**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX 之后。 +**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX`。 **最近审计**:2026-05-18。 **不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。WHMSLIVE 后只代表企微 HR 主数据镜像已部署并受控验证;WAPI 后进一步固定企微 API/Secret 口径并转为运行优先;WRRA 后明确 `PASS_WITH_FINDINGS` 只代表只读对账成功且存在差异,不代表企微考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成运行时实现。 @@ -29,6 +29,7 @@ | `WOUA` | 已执行并由 WAPI 新口径复跑 | 初始报告因旧读取路径阻断;WAPI 后远端只读复跑为 `PASS_WITH_FINDINGS`:OA active 12、企微 active 5、匹配 5、OA active 缺企微 7、已归档仍有绑定 1、缺必填字段 5。该结论明确当前 OA/企微用户不一致,也不批准真实员工批量 apply。 | | `WAPI` | 已固化运行优先门禁 | 新增本地企微 API 能力目录和 Secret 矩阵:通讯录使用 `WECOM_CONTACT_SECRET`,人事助手/打卡/消息/OAuth 使用 `WECOM_AGENT_SECRET`;WOUA/WHMS 读取改为 `user/list_id` + 可选成员详情 + 花名册读取;后续先利用入口、通知、通讯录 userid、人事助手读取和打卡读取。 | | `WRRA` | 已固化路线修正 | 收紧运行优先退出门禁:`PASS_WITH_FINDINGS` 不能直接放行深度镜像;下一步默认先做 `WOUA-FIX / WECOM-RUNTIME-READY-V1`,收口用户一致性差异或明确 WHMS 同步窗口后再判断是否进入考勤/审批镜像准备。 | +| `WOUA-FIX` | 已受控执行并公网复检 | 按用户确认将 7 个 OA active 缺企微测试账号软归档,修正 `T0000` seed 复活问题,并转交 `SMOKE201` 归档后遗留的测试主管/待办副作用;复检 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0。剩余 5 个企微缺字段先接受不补,1 个归档绑定保留审计,不写企微资料、不执行真实员工 WHMS 批量 apply。 | ## 3. 阶段路线 @@ -36,7 +37,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 为 `PASS_NO_DRIFT`,或 `PASS_WITH_FINDINGS`/可解释阻断已完成差异分类和处理决策;后续按决策进入 `WOUA-FIX`、WHMS 批量同步窗口或考勤/审批镜像准备。 | +| 3 | `runtime_first_wecom_enablement` | 先把已可用企微能力用于系统运行:入口、免登/绑定、脱敏消息、deeplink、通讯录 userid 清单、人事助手读取、打卡读取和只读对账。 | WAPI API 目录和 Secret 矩阵完成;WOUA 按新读取策略复跑。 | WOUA-FIX 后 OA active 缺企微已清零;剩余企微缺字段和归档绑定已有处理决策。下一步优先做运行入口、OAuth/绑定、脱敏消息、deeplink、打卡读取和审批/回调配置 POC,不直接跳深度镜像。 | | 4 | `wecom_attendance_approval_mirror_v1` | 企微打卡、请假、补卡、外出、加班、假期余额和审批历史镜像到 OA。 | 运行优先门禁完成且 WOUA 差异已收口或有明确处理决策;审批模板、测试审批单号 `sp_no`、真实回调配置、打卡规则和只读 POC 均明确。 | OA 可查询企微 HR/考勤镜像、附件摘要、审批历史、考勤解释和审计;不把 OA 主办高敏审批迁到企微。 | | 5 | `oa_hr_emergency_mode_v1` | 企业微信不可用时,由管理员人工开启 OA HR/考勤应急接管。 | 企微镜像数据可支撑对账;OA 应急入口、权限和审计方案明确。 | 管理员可填写原因、范围、预计结束时间和责任人后开启/关闭;应急单据带 `EMERGENCY` 标记。 | | 6 | `wecom_recovery_writeback_v1` | 企微恢复后,OA 应急数据先 dry-run,再回写企微。 | 应急接管模式完成;企微写回接口、冲突规则和回滚策略已单独批准。 | 系统生成成功、失败、跳过和冲突清单;无冲突项可自动回写,冲突进入人工确认。 | @@ -148,11 +149,10 @@ WCPC 已允许进入 `wecom_hr_master_sync_v1`。WHMS 已承接该阶段,完 ## 10. WRRA 路线修正与下一阶段 -WRRA 将 WAPI/WOUA 后的路线门禁修正为“运行优先 + 差异收口先行”。`PASS_WITH_FINDINGS` 只证明只读对账按正确 API/Secret 口径执行成功并发现差异,不代表 OA/企微用户已一致,也不批准真实员工批量 `apply`。 +WRRA 将 WAPI/WOUA 后的路线门禁修正为“运行优先 + 差异收口先行”。WOUA-FIX 已按用户确认完成第一轮写入收口:7 个 OA active 缺企微测试账号软归档,复检 `oaActiveMissingInWecom=0`;5 个企微缺字段先接受不补;`HRWDTEST-20260517214548` 归档绑定保留为审计项。该结果让运行优先能力可以继续推进,但仍不等于考勤/审批深度镜像准入。 -下一阶段默认推荐新建 `WOUA-FIX / WECOM-RUNTIME-READY-V1` 合同: +下一阶段推荐 `WECOM-RUNTIME-READY-V1`: -- 只读归因 WOUA 差异:7 个 OA active 缺企微、5 个缺企微必填字段、1 个已归档仍有绑定。 -- 区分真实员工、测试账号、待补企微字段、待清理绑定和是否允许 WHMS 批量 dry-run/apply。 -- 并行或随后利用运行优先能力:工作台入口、OAuth/免登绑定、脱敏消息、OA deeplink、通讯录 userid 清单、人事助手花名册读取、打卡记录和打卡规则读取。 +- 工作台入口、OAuth/免登绑定、脱敏应用消息、OA deeplink、通讯录 userid 清单、人事助手花名册读取、打卡记录和打卡规则读取先投入运行。 +- 对剩余企微缺字段保持 accepted finding,不补企微资料;如后续要补字段或 WHMS 真实员工批量 apply,必须另建写入合同。 - `wecom_attendance_approval_mirror_v1` 继续暂缓,直到审批模板 ID、测试 `sp_no`、真实回调配置、回调验签/解密/幂等和打卡细项 POC 明确。 diff --git a/docs/开发交付与变更管理规约.md b/docs/开发交付与变更管理规约.md index 61761c8e..ac3bc023 100644 --- a/docs/开发交付与变更管理规约.md +++ b/docs/开发交付与变更管理规约.md @@ -22,7 +22,7 @@ WDRG 后,企微优先 HR/考勤改造增加一层路线图门禁;WAPI/WRRA `需求事实源 -> 设计门禁 -> 路线图门禁 -> 企微能力 POC -> 单功能 Issue CSV -> 方案批准 -> 开发实现 -> review -> QA 回归 -> 部署 / 公网验证` -路线图只控制顺序、边界、准入和退出条件;具体接口、schema、页面、权限、审计和测试仍必须在单功能 Issue CSV 中声明。阶段未退出前不得跳阶段:WOUA `PASS_WITH_FINDINGS` 不能直接放行深度镜像,必须先完成 WOUA 差异分类和处理决策,才能进入 `wecom_attendance_approval_mirror_v1`。 +路线图只控制顺序、边界、准入和退出条件;具体接口、schema、页面、权限、审计和测试仍必须在单功能 Issue CSV 中声明。阶段未退出前不得跳阶段:WOUA-FIX 已完成测试账号差异收口,但 `PASS_WITH_FINDINGS` 的剩余缺字段/归档绑定不直接放行深度镜像;必须先完成 `WECOM-RUNTIME-READY-V1` 的运行入口、OAuth/绑定、脱敏消息、打卡读取和审批/回调配置 POC,才能进入 `wecom_attendance_approval_mirror_v1`。 ## 2. Issue 生命周期 diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md index 811e8b40..1ab0b02a 100644 --- a/docs/文档事实源状态矩阵.md +++ b/docs/文档事实源状态矩阵.md @@ -71,7 +71,8 @@ | `WHMSLIVE` | `issues/2026-05-18_19-28-25-whms-deploy-live-verify-v1.csv` | `EVIDENCE` | WHMS 已部署并完成受控公网验证:migration 已应用,`SMOKE002` dry-run/apply 管道通过但因缺手机号、邮箱和部门按 `missing_required_field` 跳过,普通员工权限负例 403,最终 `wecom_hr_master_sync_v1=force_off`,未执行生产真实员工批量 apply | | `WOUA` | `issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv` | `EVIDENCE` | OA/企微用户一致性只读审计已由 WAPI 新口径复跑:结论 `PASS_WITH_FINDINGS`,OA active 12、企微 active 5、匹配 5、OA active 缺企微 7、已归档仍有绑定 1、缺必填字段 5;未修改 OA/企微用户,未执行真实员工批量 apply | | `WAPI` | `issues/2026-05-18_21-46-41-wecom-api-capability-catalog-runtime-first.csv` | `CURRENT` | 企微 API 能力目录与运行优先纠偏已收口:固定官方 API 目录、Secret 矩阵、WHMS/WOUA 读取策略,成员详情降级为可选富化,公网 WOUA 复跑 `PASS_WITH_FINDINGS`;不执行真实员工批量 apply | -| `WRRA` | `issues/2026-05-18_23-19-38-wecom-route-readiness-adjustment.csv` | `CURRENT` | 企微路线门禁修正与下一阶段规划已收口:`PASS_WITH_FINDINGS` 不能直接放行深度镜像;下一步默认先做 `WOUA-FIX / WECOM-RUNTIME-READY-V1` 或明确 WHMS 批量同步窗口 | +| `WRRA` | `issues/2026-05-18_23-19-38-wecom-route-readiness-adjustment.csv` | `EVIDENCE` | 企微路线门禁修正与下一阶段规划已收口:`PASS_WITH_FINDINGS` 不能直接放行深度镜像;其差异收口已由 WOUA-FIX 承接 | +| `WOUA-FIX` | `issues/2026-05-18_23-45-45-wecom-oa-user-consistency-fix-runtime-ready.csv` | `CURRENT` | OA/企微用户一致性差异已受控收口:7 个缺企微测试账号软归档,`T0000` seed 复活已修正,`SMOKE201` 测试主管/待办副作用已转交;WOUA 复检 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;企微缺字段先接受不补 | 旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。 @@ -79,12 +80,12 @@ | 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` | `rg` | none | 2026-05-18 | `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` | `audit-docs` | none | 2026-05-18 | `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` | `rg` | none | 2026-05-18 | `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` | `audit-docs` | none | 2026-05-18 | `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` | Issue CSV | `docs/requirements-specification.md` | 2026-05-18 | `rg` | high | WRRA 后定义 HR 主数据镜像读取口径、运行优先、WOUA 差异收口和下一阶段边界;ERP 未来不接入 OA | | `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA` | `rg` | none | 2026-05-18 | `rg` | high | 记录已拍板产品边界、企微/OA 设计门禁、开发路线图、WCPC 准入结论、WHMS 部署验证、WAPI 运行优先和 WRRA 差异收口口径 | | `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` | Issue CSV | none | 2026-05-18 | `rg` | high | WRRA 后记录企微 HR/考勤路线阶段顺序、API/Secret 口径、运行优先阶段、用户一致性差异收口和禁止跳阶段 | +| `ROADMAP-WECOM` | `docs/企微优先开发路线图.md` | 开发治理 | 企微优先开发路线图事实源 | `CURRENT` | product/dev | `WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX` | Issue CSV | none | 2026-05-18 | `rg` | high | WOUA-FIX 后记录企微 HR/考勤路线阶段顺序、API/Secret 口径、运行优先阶段、用户一致性差异收口和禁止跳阶段 | | `WECOM-API-CATALOG` | `docs/企微API能力目录.md` | 集成事实源 | 企微 API 能力目录和 Secret 矩阵事实源 | `CURRENT` | product/backend/qa | `WAPI,WRRA` | 官方企业微信开发者中心链接/Issue CSV | none | 2026-05-18 | `rg` | high | 企微开发先读;只保存摘要、链接和项目决策,不保存 Secret、token、完整 userid、手机号、邮箱或原始 payload;WRRA 后深度镜像等待 WOUA 差异收口或 WHMS 同步窗口决策 | | `DELIVERY` | `docs/开发交付与变更管理规约.md` | 开发治理 | 开发交付事实源 | `CURRENT` | dev/qa | `GOV8,WDRG,WRRA` | `rg` | none | 2026-05-18 | `rg` | high | Issue、review、commit、交付规则;WRRA 后采用路线图门禁 + 运行优先 + WOUA 差异收口 + 单功能 Issue CSV | | `QA-GATE` | `docs/测试规约与回归门禁.md` | 测试验收 | 测试和回归门禁事实源 | `CURRENT` | qa | `QAF8,GOV8` | 回归脚本/Playwright | none | 2026-05-13 | `rg` | high | 测试计划和 DONE 门禁 | @@ -100,7 +101,7 @@ | `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` | artifacts/qa-flow | none | 2026-05-18 | `rg` | medium | 只证明发生过;WAPI 记录 OA/企微用户一致性只读审计复跑差异,不直接派工 | +| `TRIAL-REPORT` | `docs/trialrun/人工试运行报告.md` | 证据材料 | 试运行证据入口 | `EVIDENCE` | qa | `R7EP,OPS8,QAF8,WOUA,WAPI,WOUAFIX` | artifacts/qa-flow | none | 2026-05-18 | `rg` | medium | 只证明发生过;记录 WOUA-FIX 受控写入和复检结果,不直接放行深度镜像 | | `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 历史恢复 | @@ -189,4 +190,4 @@ - `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 配置复跑均已完成,当前 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 试错。WAPI 已复跑 WOUA,远端报告 `/app/artifacts/qa-flow/wecom-oa-user-consistency-20260518T142544Z/summary.json` 结论为 `PASS_WITH_FINDINGS`:OA active 12、企微 active 5、匹配 5、OA active 缺企微 7、已归档仍有绑定 1、缺必填字段 5;该结果明确当前 OA 和企微用户不一致,也不批准真实员工批量 apply。WRRA 已收紧运行优先门禁:`PASS_WITH_FINDINGS` 不能直接放行深度镜像,下一步默认先建 `WOUA-FIX / WECOM-RUNTIME-READY-V1` 或明确 WHMS 批量同步窗口。HR/考勤员工日常审批优先复用企业微信,OA 目标是按域镜像、人工接管、恢复回写和 RuleOps;采购、费用、合同、项目、报销、付款、AP、代账交接、审计和高敏附件继续 OA 主办。考勤/审批镜像、审批回调、OA 应急接管、恢复回写和 RuleOps 尚未实现;审批模板 ID、审批单号 `sp_no`、真实回调配置和 HR 写接口仍需后续独立合同处理。后续仍须按 [企微优先开发路线图](./企微优先开发路线图.md) 先完成 WOUA 差异分类和处理决策,再另拆 `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 均已完成,当前 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` 归档绑定保留审计。HR/考勤员工日常审批优先复用企业微信,OA 目标是按域镜像、人工接管、恢复回写和 RuleOps;采购、费用、合同、项目、报销、付款、AP、代账交接、审计和高敏附件继续 OA 主办。考勤/审批镜像、审批回调、OA 应急接管、恢复回写和 RuleOps 尚未实现;审批模板 ID、审批单号 `sp_no`、真实回调配置和 HR 写接口仍需后续独立合同处理。下一阶段默认是 `WECOM-RUNTIME-READY-V1`:先推进工作台入口、OAuth/绑定、脱敏消息、OA deeplink、通讯录 userid 清单、人事助手读取、打卡读取和审批/回调配置 POC;不得直接跳到 `wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1`。HRWD 已完成 OA 驱动企业微信通讯录同步,并通过 `HRWDTEST-20260517214548` 真实公网 create/update/disable 验证;测试成员按审计要求保留离职/禁用状态。 diff --git a/docs/文档索引.md b/docs/文档索引.md index 2e042d8e..15fc5eaa 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。 +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。 - R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。 - ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。 @@ -63,8 +63,9 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M - WCPC 是 WCP1 后的配置收口合同:已使用既有在职低风险测试号 `SMOKE002/测试员工`(报告只记录 `oa_smoke***`)完成远端只读复跑,报告 `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json`,结论 `GO_TO_HR_MASTER_SYNC`。WHMS 已承接该准入结论;审批模板 ID、审批单号 `sp_no`、真实回调配置和 HR 写接口仍需后续独立合同处理。 - WHMS/WHMSLIVE 是 WCPC 后的企微 HR 主数据镜像合同和部署验证合同:已实现并部署 `wecom_hr_master_sync_v1`、企微/人事助手读取服务、dry-run/apply、同步批次/逐员工记录、People 管理入口和 QA-only runner;公网对 `SMOKE002`(脱敏 `oa_smoke***`)完成 dry-run/apply 管道验证,缺手机号、邮箱和部门按 `missing_required_field` 跳过;权限负例 403;最终 `force_off`,未执行生产真实员工批量 apply。WAPI 后其读取口径被修正为通讯录 `user/list_id` + 人事助手 `get_staff_info`,不再用运行时 Secret fallback。 - WOUA 是 WHMSLIVE 后的 OA/企微用户一致性只读审计合同:已新增 QA-only runner `server/src/scripts/audit-wecom-oa-user-consistency.js`,本地静态/单测门禁通过并以 `SYNC_ENV=0` 部署执行;初始公网报告因旧读取路径为 `SKIP / BLOCKED_BY_EXTERNAL_CONFIG`。WAPI 后按新 API/Secret 口径复跑成功,远端脱敏报告 `/app/artifacts/qa-flow/wecom-oa-user-consistency-20260518T142544Z/summary.json` 为 `PASS_WITH_FINDINGS`,计数为 `oaEmployees=13`、`oaActiveEmployees=12`、`oaActiveWithBinding=5`、`wecomProfiles=5`、`matchedActive=5`、`oaActiveMissingInWecom=7`、`inactiveWithBinding=1`、`missingRequiredFields=5`。本轮未修改 OA/企微用户,未执行真实员工批量 apply,不代表 OA/企微用户已经一致。 -- WAPI 是 WOUA 后的企微 API 能力目录与运行优先纠偏合同:新增 [企微 API 能力目录](./企微API能力目录.md),固定通讯录用 `WECOM_CONTACT_SECRET`、人事助手/打卡/消息/OAuth 用 `WECOM_AGENT_SECRET`、`WECOM_CHECKIN_SECRET` 仅作显式打卡 override、`WECOM_HR_ASSISTANT_SECRET` 仅历史兼容;后续企微能力不清楚时先查官方 API 和本地目录,不在运行时代码里轮询多个 Secret 试错。WAPI 已把通讯录成员详情降级为可选富化并完成 WOUA 公网复跑;下一步判断 WOUA-FIX、WHMS 批量同步窗口或考勤/审批镜像准备。 -- WRRA 是 WAPI 后的企微路线门禁修正合同:只做 docs-only 路线修正,不改业务代码、不部署、不写 OA/企微数据;明确 `PASS_WITH_FINDINGS` 不能直接放行深度镜像,下一步默认先建 `WOUA-FIX / WECOM-RUNTIME-READY-V1` 或明确 WHMS 批量同步窗口,再判断是否进入 `wecom_attendance_approval_mirror_v1` 准备。 +- WAPI 是 WOUA 后的企微 API 能力目录与运行优先纠偏合同:新增 [企微 API 能力目录](./企微API能力目录.md),固定通讯录用 `WECOM_CONTACT_SECRET`、人事助手/打卡/消息/OAuth 用 `WECOM_AGENT_SECRET`、`WECOM_CHECKIN_SECRET` 仅作显式打卡 override、`WECOM_HR_ASSISTANT_SECRET` 仅历史兼容;后续企微能力不清楚时先查官方 API 和本地目录,不在运行时代码里轮询多个 Secret 试错。WAPI 已把通讯录成员详情降级为可选富化并完成 WOUA 公网复跑,后续由 WRRA/WOUA-FIX 承接路线与差异收口。 +- WRRA 是 WAPI 后的企微路线门禁修正合同:只做 docs-only 路线修正,不改业务代码、不部署、不写 OA/企微数据;明确 `PASS_WITH_FINDINGS` 不能直接放行深度镜像,并已由 WOUA-FIX 承接测试账号差异收口;后续默认进入 `WECOM-RUNTIME-READY-V1`。 +- WOUA-FIX 是 WRRA 后的 OA/企微用户一致性差异收口合同:按用户确认软归档 7 个缺企微测试/Smoke 账号,修正 `T0000` seed 复活,转交 `SMOKE201` 归档后的测试主管和待办副作用;复检 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0。企微缺字段先接受不补,归档绑定保留审计,不写企微资料、不执行真实员工 WHMS 批量 apply。 ## 当前活跃治理流 @@ -117,6 +118,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M | `WOUA` | `issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv` | 已收口:OA/企微用户一致性只读对账已由 WAPI 新口径复跑 | OA active 员工 vs 企业微信通讯录 + 人事助手只读审计、脱敏差异报告、本地单测、受控部署和公网执行 | WAPI 复跑结论 `PASS_WITH_FINDINGS`:OA active 12、企微 active 5、匹配 5、OA active 缺企微 7、已归档仍有绑定 1、缺必填字段 5;未改 OA/企微用户,未执行真实员工批量 apply | | `WAPI` | `issues/2026-05-18_21-46-41-wecom-api-capability-catalog-runtime-first.csv` | 已收口:企微 API 能力目录与运行优先纠偏 | 本地 API 能力目录、Secret 矩阵、WHMS/WOUA 读取策略纠偏、文档/skill 回写和公网 WOUA 只读复跑 | 不执行真实员工批量 apply;不打开 `wecom_hr_master_sync_v1`;不修改 OA/企微用户数据;公网只读复跑 `PASS_WITH_FINDINGS` | | `WRRA` | `issues/2026-05-18_23-19-38-wecom-route-readiness-adjustment.csv` | 已收口:企微路线门禁修正与下一阶段规划 | 收紧运行优先退出门禁、修正 PRD/规约/手册旧顺序、补充 `WOUA-FIX / WECOM-RUNTIME-READY-V1` 下一阶段草案 | docs-only;不改业务代码、不部署、不写 OA/企微数据;`PASS_WITH_FINDINGS` 不放行深度镜像 | +| `WOUA-FIX` | `issues/2026-05-18_23-45-45-wecom-oa-user-consistency-fix-runtime-ready.csv` | 已部署并受控写入:OA/企微用户一致性差异收口 | 7 个 OA active 缺企微测试账号已软归档,`T0000` seed 不再复活,`SMOKE201` 测试主管/待办副作用已转交;WOUA 复检 active 5/5 匹配 | 不写企微资料;不补企微缺字段;不执行真实员工 WHMS 批量 apply;不放行深度考勤/审批镜像 | 旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。 @@ -139,7 +141,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M | [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源 | | [文档事实源状态矩阵](./文档事实源状态矩阵.md) | 文档分类、删除恢复和冲突规则 | | [产品设计规则](./产品设计规则.md) | PC、移动、企业微信 WebView 设计事实源;DG8 后飞书工作台/飞书审批为唯一主 UI/布局参考;DG9 后按页面分层和样板选型 | -| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先和 WOUA 差异收口事实源 | +| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WOUA-FIX 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先和 WOUA 差异收口事实源 | | [企微 API 能力目录](./企微API能力目录.md) | WAPI/WRRA 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界和运行优先决策事实源 | | [产品口径](./产品口径.md) | 已拍板产品边界和业务规则 | | [需求 - 统一审批中心规约](./需求-统一审批中心规约.md) | OA 主办审批、企微主办 HR/考勤员工日常审批镜像、催办、超时提醒、审批效率 | diff --git a/docs/海音达OA-MVP需求规格说明书.md b/docs/海音达OA-MVP需求规格说明书.md index f4785342..2b9bcd49 100644 --- a/docs/海音达OA-MVP需求规格说明书.md +++ b/docs/海音达OA-MVP需求规格说明书.md @@ -77,7 +77,7 @@ WFDG 将 DBWFOC 后的产品方向固化为开发前设计门禁。本节只代 | 报销、采购、合同、项目、付款、AP、代账 | OA | 主办提交、审批、附件、导出、付款批次、审计和代账资料 | 只发送脱敏消息和 OA deeplink | 采购单附件、合同正文、报价、供应商资料、发票、银行回单和代账资料不进入企微原生审批或企微附件 | | RuleOps | OA + 企业微信 API | AI 草拟、差异报告、dry-run、人工确认、审计和回滚建议 | 接收经确认的审批模板、打卡规则、排班、假期余额或人事字段配置写入 | AI 不得直接改正式规则;每次写入保存前后快照、确认人、API `errcode` 和失败原因 | -`WECOM-CAPABILITY-POC-V1` / WCPC 已按 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY`、`NOT_SUPPORTED_BY_API` 输出人事助手、通讯录、打卡、审批、假期余额、回调和权限配置能力矩阵,并达到 HR 主数据镜像准入。WHMS/WHMSLIVE 已完成 HR 主数据镜像实现和受控公网验证;WAPI 已固定 API/Secret 口径并复跑 WOUA;WRRA 后明确 `PASS_WITH_FINDINGS` 不能直接放行深度镜像。生产写接口必须单独批准。正式开发顺序为 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)-> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。 +`WECOM-CAPABILITY-POC-V1` / WCPC 已按 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY`、`NOT_SUPPORTED_BY_API` 输出人事助手、通讯录、打卡、审批、假期余额、回调和权限配置能力矩阵,并达到 HR 主数据镜像准入。WHMS/WHMSLIVE 已完成 HR 主数据镜像实现和受控公网验证;WAPI 已固定 API/Secret 口径并复跑 WOUA;WRRA 后明确 `PASS_WITH_FINDINGS` 不能直接放行深度镜像;WOUA-FIX 已将 7 个缺企微测试账号软归档并复检 `oaActiveMissingInWecom=0`,企微缺字段先接受不补。生产写接口必须单独批准。正式开发顺序为 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)-> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。 ### 1.7 WDRG 开发路线图门禁 @@ -128,7 +128,7 @@ WHMS 将企业微信通讯录/人事助手作为 HR 主数据源的读取能力 - 企微不在职状态记录 `needs_offboarding_confirmation`,不自动禁用 OA 账号;离职仍走 OA checklist。 - 已有 OA 员工只更新姓名、部门、职位、直属主管、手机号、邮箱和企微绑定,不覆盖银行账户、身份证、工资、合同附件或历史审批。 -当前状态为已部署并完成受控公网验证:`WHMSLIVE` 已将 WHMS 部署到 `https://oa.hyinda.com`,远端应用 migration `20260518184319_wecom_hr_master_sync_v1`;验证窗口临时 `force_on` 后对 `SMOKE002/测试员工`(脱敏 `oa_smoke***`)执行 dry-run 和显式 userids apply,企微读取链路通过,因企微侧缺手机号、邮箱和部门按 `missing_required_field` 跳过,未写 OA 主数据;普通员工访问同步 API 返回 403;最终 `wecom_hr_master_sync_v1=force_off`。尚未对真实员工执行生产批量 apply。WAPI 后已完成 WOUA 新口径复跑,结论 `PASS_WITH_FINDINGS`,说明 OA/企微用户仍不一致;下一步先做 WOUA 差异收口或明确 WHMS 批量同步窗口,`wecom_attendance_approval_mirror_v1` 暂缓到审批模板、测试审批单号、真实回调配置和打卡细项 POC 明确后再另建合同。 +当前状态为已部署并完成受控公网验证:`WHMSLIVE` 已将 WHMS 部署到 `https://oa.hyinda.com`,远端应用 migration `20260518184319_wecom_hr_master_sync_v1`;验证窗口临时 `force_on` 后对 `SMOKE002/测试员工`(脱敏 `oa_smoke***`)执行 dry-run 和显式 userids apply,企微读取链路通过,因企微侧缺手机号、邮箱和部门按 `missing_required_field` 跳过,未写 OA 主数据;普通员工访问同步 API 返回 403;最终 `wecom_hr_master_sync_v1=force_off`。尚未对真实员工执行生产批量 apply。WOUA-FIX 后复检为 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余 5 个企微缺字段按用户决策先不补,1 个归档测试绑定保留审计。`wecom_attendance_approval_mirror_v1` 暂缓到审批模板、测试审批单号、真实回调配置和打卡细项 POC 明确后再另建合同。 --- diff --git a/docs/管理员手册.md b/docs/管理员手册.md index 9846cba5..ed00fe56 100644 --- a/docs/管理员手册.md +++ b/docs/管理员手册.md @@ -1,6 +1,6 @@ # hyb-oa 管理员手册(10 人以下团队版) -**当前状态(2026-05-18 / WRRA)**: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 均已收口;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 已按新口径复跑为 `PASS_WITH_FINDINGS`,说明 OA/企微用户仍不一致;WRRA 后明确下一步先建 `WOUA-FIX / WECOM-RUNTIME-READY-V1` 或确定 WHMS 批量同步窗口,`wecom_attendance_approval_mirror_v1` 暂缓到审批模板、测试 `sp_no` 和真实回调配置明确后。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。 +**当前状态(2026-05-18 / WOUA-FIX)**: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 均已收口;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 个归档绑定保留审计;`wecom_attendance_approval_mirror_v1` 仍暂缓到审批模板、测试 `sp_no` 和真实回调配置明确后。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。 ## 1. 适用范围 - 适用于 `small_team_internal` 工作区模式。 @@ -197,7 +197,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` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1` 分合同实施;WOUA 差异未分类并形成处理决策前,不得进入深度考勤/审批镜像。 +5. 正式开发仍按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1` 分合同实施;WOUA-FIX 已收口测试账号差异和处理决策,但审批模板 ID、测试 `sp_no`、真实回调配置、验签/解密/幂等和补偿拉取未明确前,不得进入深度考勤/审批镜像。 6. 任何生产写接口、企微规则写入、应急模式开启或恢复回写都必须有单独批准、dry-run 或可审计记录。 WCPC 配置复跑清单: diff --git a/docs/需求-企业微信基础设施规约.md b/docs/需求-企业微信基础设施规约.md index 749f2e34..bace7dd1 100644 --- a/docs/需求-企业微信基础设施规约.md +++ b/docs/需求-企业微信基础设施规约.md @@ -225,7 +225,7 @@ REQAUD 复核后,ATTV1 已完成统一入口实现,企业微信打卡边界 - WDRG 后,企业微信相关运行时迁移采用“路线图门禁 + 单功能 Issue CSV”,路线图事实源为 [企微优先开发路线图](./企微优先开发路线图.md)。 - WRRA 后阶段顺序调整为 `WECOM-CAPABILITY-POC-V1` -> `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `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 已按 WAPI 新口径复跑为 `PASS_WITH_FINDINGS`,该结论不能直接放行深度镜像,深度考勤/审批镜像需在用户一致性差异处理、审批模板、测试 `sp_no` 和真实回调配置明确后再拆。 +- 运行优先阶段先利用入口、免登/绑定、脱敏消息、deeplink、通讯录 userid 清单、人事助手花名册读取和打卡读取;WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 `oaActiveMissingInWecom=0`;剩余企微缺字段先接受不补,归档测试绑定保留审计。该结论仍不能直接放行深度镜像,深度考勤/审批镜像需在审批模板、测试 `sp_no` 和真实回调配置明确后再拆。 - 线上缺陷、正式使用阻断、真实反馈扫尾和 QA-only 脚本维护可另开合同并行;采购、合同、报销、付款、AP、代账和高敏附件继续 OA 主办,不混入企微 HR/考勤路线。 - 本路线图只代表开发节奏和门禁,不代表任何企微 HR/考勤镜像、OA 应急接管、恢复回写或 RuleOps 运行时能力已完成。 diff --git a/docs/需求-统一审批中心规约.md b/docs/需求-统一审批中心规约.md index b0de5d0a..1be394e7 100644 --- a/docs/需求-统一审批中心规约.md +++ b/docs/需求-统一审批中心规约.md @@ -354,7 +354,7 @@ WF8-3、WF8-4、WF8-5 实现前必须基于本设计给技术方案;实现后 - 采购单附件、合同正文、报价、供应商资料、发票、银行回单、代账资料和其他高敏附件不进入企业微信原生审批或企业微信附件;企业微信只做提醒、deeplink 和免登录回流。 - 按域镜像是默认规则:企微主办 HR/考勤域镜像到 OA;OA 主办采购、合同、项目、财务和报销域不反向全量镜像到企微。 - WFDG 后,审批相关页面和接口必须区分 `OA 主办`、`企微镜像` 和 `OA 应急接管`;企微镜像不得被误用为 OA 日常新建入口,OA 应急接管单据必须带 `EMERGENCY` 并进入恢复回写队列。 -- WAPI/WRRA 后,审批相关运行时迁移必须先读 [企微优先开发路线图](./企微优先开发路线图.md) 和 [企微 API 能力目录](./企微API能力目录.md),按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` 的顺序拆单功能 Issue CSV;WOUA 差异未分类并形成处理决策前,不得直接开发企微审批镜像或恢复回写。 +- WAPI/WRRA/WOUA-FIX 后,审批相关运行时迁移必须先读 [企微优先开发路线图](./企微优先开发路线图.md) 和 [企微 API 能力目录](./企微API能力目录.md),按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` 的顺序拆单功能 Issue CSV;WOUA-FIX 只收口了用户一致性测试账号差异和处理决策,审批模板 ID、测试 `sp_no`、真实回调配置、验签/解密/幂等和补偿拉取未明确前,不得直接开发企微审批镜像或恢复回写。 - 后续企微审批同步的 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 通知链路硬门槛。 diff --git a/docs/需求-请假考勤修正规约.md b/docs/需求-请假考勤修正规约.md index 138cefa5..6adb909e 100644 --- a/docs/需求-请假考勤修正规约.md +++ b/docs/需求-请假考勤修正规约.md @@ -220,9 +220,9 @@ WFOC 后,正常审批规则优先以企业微信审批模板和打卡/假期 ## 10. 后续开发拆分建议 - 已完成:OA 侧补卡/外出/加班申请对象、统一审批接入、月度考勤解释页、移动入口和企业微信 deeplink 回 OA;WFOC 后这些能力默认作为历史查询和应急接管基础。 -- WAPI/WRRA 后:后续开发先读 [企微优先开发路线图](./企微优先开发路线图.md) 和 [企微 API 能力目录](./企微API能力目录.md),阶段未退出前不得跳阶段;`PASS_WITH_FINDINGS` 不能直接放行深度镜像,必须先完成 WOUA 差异分类和处理决策。 -- 后续 A0:`WOUA-FIX / WECOM-RUNTIME-READY-V1`,只读归因 OA active 缺企微、缺必填字段、归档仍绑定等差异,决定真实员工、测试账号、待补字段、待清理绑定和 WHMS 批量 dry-run/apply 窗口。 -- 后续 A1:`wecom_attendance_approval_mirror_v1`,同步企微请假、补卡、外出、加班审批、附件、历史和状态回调;该命名承接旧口径 `wecom_native_approval_mirror_v1`。正式开发前必须补齐 WOUA 差异处理决策、审批模板 ID、测试 `sp_no`、真实回调配置、回调验签/解密/幂等和必要打卡细项 POC。 +- WAPI/WRRA/WOUA-FIX 后:后续开发先读 [企微优先开发路线图](./企微优先开发路线图.md) 和 [企微 API 能力目录](./企微API能力目录.md),阶段未退出前不得跳阶段;WOUA-FIX 已完成测试账号差异收口,但 `PASS_WITH_FINDINGS` 剩余缺字段/归档绑定不能直接放行深度镜像。 +- 后续 A0:`WECOM-RUNTIME-READY-V1`,先完成工作台入口、OAuth/绑定、脱敏消息、OA deeplink、通讯录 userid 清单、人事助手读取、打卡读取和审批/回调配置 POC。 +- 后续 A1:`wecom_attendance_approval_mirror_v1`,同步企微请假、补卡、外出、加班审批、附件、历史和状态回调;该命名承接旧口径 `wecom_native_approval_mirror_v1`。正式开发前必须补齐审批模板 ID、测试 `sp_no`、真实回调配置、回调验签/解密/幂等和必要打卡细项 POC。 - 后续 A2:企微打卡规则、排班、记录、日报、月报按域镜像,覆盖 Secret/API 权限、短窗口同步、错误码和脱敏证据。 - 后续 A3:`oa_hr_emergency_mode_v1`,管理员人工接管、应急提交、应急审批和恢复队列。 - 后续 A4:`wecom_recovery_writeback_v1`,企微恢复后 dry-run、自动回写、失败重试和冲突清单。 diff --git a/issues/2026-05-18_23-45-45-wecom-oa-user-consistency-fix-runtime-ready.csv b/issues/2026-05-18_23-45-45-wecom-oa-user-consistency-fix-runtime-ready.csv new file mode 100644 index 00000000..a5c0a3e2 --- /dev/null +++ b/issues/2026-05-18_23-45-45-wecom-oa-user-consistency-fix-runtime-ready.csv @@ -0,0 +1,9 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"WOUAFIX-0","合同建档与边界冻结","建立 WOUA-FIX / WECOM-RUNTIME-READY-V1 执行合同,承接用户确认:OA active 缺企微人员按测试数据处理,企微缺必填字段先接受不补,本轮允许受控写入但不修改企微资料。","1) CSV 校验通过;2) 范围限定为 OA 测试员工软清理、归档仍绑定处理、WOUA 只读复检和事实源回写;3) 不修改企微通讯录或人事助手资料;4) 不打开 wecom_hr_master_sync_v1 真实员工批量 apply;5) 不推进 wecom_attendance_approval_mirror_v1。","受影响流程:OA active 员工 -> 企业微信通讯录/人事助手 -> 差异分类 -> 受控清理 -> 只读复检;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-18_23-45-45-wecom-oa-user-consistency-fix-runtime-ready.csv。","python3|manual","DONE","DONE","DONE","issues/2026-05-18_23-45-45-wecom-oa-user-consistency-fix-runtime-ready.csv","[]","用户已明确批准写入;边界保持为 OA 测试账号软归档、企微缺字段接受不补、归档绑定保留审计;不写企微、不执行真实员工 WHMS 批量 apply、不进入深度镜像。CSV 校验已通过。" +"WOUAFIX-1","核实现有模型和安全写入路径","读取员工、账号、企业微信绑定、审计日志和既有归档/离职处理代码,确定测试员工应采用软归档/停用而非物理删除。","1) 明确 employees active/status/archive 字段和用户停用字段;2) 明确 wecom_identity_bindings 处理方式;3) 明确不会影响真实员工和已匹配企微人员;4) 输出脚本实现所需的匹配条件。","受影响流程:管理员清理测试员工 -> OA 员工状态和登录权限 -> 企业微信绑定审计;验证步骤:rg -n 'wecom_identity_bindings|archived|terminated|is_active|status' server/prisma/schema.prisma server/src。","rg|manual","DONE","DONE","DONE","server/prisma/schema.prisma|server/src","[""WOUAFIX-0""]","确认采用 employees.status='archived'、employment_status='inactive'、archived_at 和 token_version+1 软归档;wecom_identity_bindings 默认保留;只触达 WOUA 报告中的 oaActiveMissingInWecom 或显式 allowlist。" +"WOUAFIX-2","新增受控修复 runner","新增 QA-only CLI,对 WOUA 报告中的 oaActiveMissingInWecom 测试员工执行 dry-run/apply 软清理,并对 inactiveWithBinding 生成处理项;同时修正 trialrun seed/runtime smoke,避免已归档的 T0000 fallback 管理员在存在真实 active admin 时被下次部署重新激活;支持归档测试主管后的 active 下属和 pending 待办转交到 fallback active manager。","1) 默认 dry-run 不写数据库;2) apply 必须 --apply 且 WOUA_FIX_APPLY=1;3) 只处理当前只读审计报告中 oaActiveMissingInWecom 的员工号或显式 allowlist;4) 不输出手机号、邮箱、完整 userid、secret、token 或原始 payload;5) 企微缺字段仅记录 accepted_missing_required_fields;6) 部署 smoke 可使用任意 active admin,不强制复活已归档 fallback admin;7) manager side-effect repair 只转交员工号和待办指派,不写企微。","受影响流程:QA 执行清理脚本 -> 软归档测试 OA 员工 -> 禁止测试账号继续登录 -> 生成脱敏修复报告 -> 下次部署不重复激活已归档 fallback 测试管理员 -> 归档测试主管的下属和 pending 待办转交到 active manager;验证步骤:cd server && node --check src/scripts/resolve-wecom-oa-user-consistency.js;cd server && node --check src/scripts/seed-trialrun.js;cd server && node --check src/scripts/smoke-trialrun-runtime.js;cd server && node src/scripts/resolve-wecom-oa-user-consistency.js --help。","node|pg","DONE","DONE","DONE","server/src/scripts/resolve-wecom-oa-user-consistency.js|server/src/scripts/seed-trialrun.js|server/src/scripts/smoke-trialrun-runtime.js|server/package.json","[""WOUAFIX-1""]","新增 npm script resolve:wecom-oa-users;help、node --check 均通过;远端 seed 日志确认已归档 T0000 保持归档并使用 HYD000006 active admin fallback;manager repair 将 SMOKE101 和 2 个 pending 测试待办转交 HYD000007。" +"WOUAFIX-3","单测覆盖修复边界","新增 focused vitest 覆盖 dry-run、apply 双确认、缺企微测试员工软清理、企微缺字段接受、inactive 绑定处理、allowlist 防误伤和脱敏输出。","1) 未设置 WOUA_FIX_APPLY=1 时 apply 抛错且不写;2) 只处理允许的差异分类;3) missingRequiredFields 不触发更新;4) 输出不含手机号、邮箱或完整 userid;5) 单测可独立运行。","受影响流程:WOUA 修复 runner -> 写入保护 -> 审计报告;验证步骤:cd server && npx vitest run tests/unit/scripts/resolve-wecom-oa-user-consistency.test.js --reporter=verbose。","vitest","DONE","DONE","DONE","server/tests/unit/scripts/resolve-wecom-oa-user-consistency.test.js","[""WOUAFIX-2""]","focused vitest 已通过:1 个测试文件、8 个用例全部通过;覆盖 dry-run 回滚、apply guard、allowlist、缺字段接受、inactive binding 默认保留和显式清理。" +"WOUAFIX-4","本地静态门禁","执行 WOUA-FIX 本地门禁,确认脚本、既有 WOUA runner、trialrun seed/runtime smoke、Prisma schema 和 diff 格式正常。","1) node --check 新修复脚本通过;2) node --check 既有 WOUA 审计脚本通过;3) node --check seed-trialrun 和 smoke-trialrun-runtime 通过;4) prisma validate 通过;5) git diff --check 通过;6) focused vitest 通过。","受影响流程:本地实现 -> 可部署 QA-only 修复工具;验证步骤:cd server && node --check src/scripts/resolve-wecom-oa-user-consistency.js;cd server && node --check src/scripts/audit-wecom-oa-user-consistency.js;cd server && node --check src/scripts/seed-trialrun.js;cd server && node --check src/scripts/smoke-trialrun-runtime.js;cd server && npm run prisma:validate;git diff --check。","node|npm|git","DONE","DONE","DONE","server/src/scripts/resolve-wecom-oa-user-consistency.js|server/src/scripts/seed-trialrun.js|server/src/scripts/smoke-trialrun-runtime.js|server/tests/unit/scripts/resolve-wecom-oa-user-consistency.test.js|server/package.json","[""WOUAFIX-3""]","node --check、npm run prisma:validate、focused vitest、skill 校验和文档审计均通过;audit-docs 仅保留既有 unregistered docs warning;最终 git diff --check 在提交前重跑。" +"WOUAFIX-5","公网 dry-run/apply 与只读复检","以 SYNC_ENV=0 受控部署 QA-only 变更,在远端先 dry-run 核对受影响员工号,再执行受控 apply,随后复跑 WOUA 只读审计。","1) dry-run 报告只列脱敏差异和员工号,不含隐私字段;2) apply 只处理用户确认的测试数据,不写企微;3) 复跑 WOUA 结论应为 PASS_NO_DRIFT 或 PASS_WITH_FINDINGS 且不再包含未决测试 OA active 缺企微;4) 企微缺字段保留为 accepted finding。","受影响流程:远端 OA 测试员工清理 -> 企业微信/OA 用户一致性复检 -> 运行优先门禁判断;验证步骤:SYNC_ENV=0 bash deploy/deploy.sh;ssh 远端容器执行 npm run resolve:wecom-oa-users -- --report --dry-run;WOUA_FIX_APPLY=1 npm run resolve:wecom-oa-users -- --report --apply;npm run audit:wecom-oa-users -- --live --format=json。","bash|ssh|node","DONE","DONE","DONE","external|artifacts/qa-flow/wecom-oa-user-consistency-fix-*","[""WOUAFIX-4""]","已以 SYNC_ENV=0 部署至 https://oa.hyinda.com;dry-run 命中 EMP001、SMOKE001、SMOKE201、SMOKE301、SMOKE302、SMOKE401、T0000;apply 报告 /app/artifacts/qa-flow/wecom-oa-user-consistency-fix-20260518T160944Z/summary.json archivedApplied=7 wecomWrites=0;manager repair apply 报告 /app/artifacts/qa-flow/wecom-oa-user-consistency-fix-20260518T161656Z/summary.json;最终 WOUA /app/artifacts/qa-flow/wecom-oa-user-consistency-20260518T161716Z/summary.json:OA active 5、WeCom active 5、matchedActive 5、oaActiveMissingInWecom 0、missingRequiredFields 5、inactiveWithBinding 1。" +"WOUAFIX-6","事实源回写与路线判断","更新人工试运行报告、文档索引、事实源矩阵和路线图,记录 WOUA-FIX 结果、企微缺字段暂不补的决策,以及下一步是否进入运行优先能力实现。","1) 文档记录清理结果、复检报告路径和 accepted_missing_required_fields;2) 明确不代表真实员工批量 WHMS apply 已批准;3) 明确考勤/审批深度镜像仍需模板 ID、sp_no 和真实回调配置;4) 文档审计通过。","受影响流程:事实源读取 -> 企微路线判断 -> 下一阶段派工;验证步骤:node scripts/qa/audit-docs.mjs --check-registry;rg -n 'WOUA-FIX|accepted_missing_required_fields|PASS_WITH_FINDINGS|WECOM-RUNTIME-READY-V1' docs issues。","node|rg","DONE","DONE","DONE","docs/trialrun/人工试运行报告.md|docs/trialrun/真机公网测试清单.md|docs/管理员手册.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/企微优先开发路线图.md|docs/产品口径.md|docs/开发交付与变更管理规约.md|docs/需求-请假考勤修正规约.md","[""WOUAFIX-5""]","已回写路线图、PRD、产品口径、开发交付规约、请假考勤/审批/企微规约、管理员手册、试运行报告、真机清单、文档索引、事实源矩阵和 skill;明确 WOUA-FIX 只清零 OA active 缺企微测试账号,不补企微字段,不放行深度镜像;下一步推荐 WECOM-RUNTIME-READY-V1。" +"WOUAFIX-7","状态回填与中文提交","完成 Issue 状态回填、本地验证、公网复检证据核对和中文提交。","1) WOUAFIX-0..6 按实际执行结果回填;2) 所有已声明 Test_Method 有结果;3) git diff 不含 secret、token、手机号、邮箱、完整 userid 或原始 payload;4) 中文 commit 引用 WOUAFIX。","受影响流程:Issue CSV -> 验证证据 -> 提交审计 -> 下一阶段执行入口;验证步骤:git status --short;git log -1 --oneline。","git|manual","DONE","DONE","DONE","issues/2026-05-18_23-45-45-wecom-oa-user-consistency-fix-runtime-ready.csv|docs|server","[""WOUAFIX-6""]","状态已回填;提交前本地门禁通过;最终提交后在交付回复中记录 commit hash、部署状态、远端报告路径、剩余 accepted findings 和下一步 WECOM-RUNTIME-READY-V1。" diff --git a/server/package.json b/server/package.json index dc7594ff..8efe76ca 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", + "resolve:wecom-oa-users": "node src/scripts/resolve-wecom-oa-user-consistency.js", "test:watch": "vitest", "test:coverage": "vitest run --coverage" }, diff --git a/server/src/scripts/resolve-wecom-oa-user-consistency.js b/server/src/scripts/resolve-wecom-oa-user-consistency.js new file mode 100644 index 00000000..aff300cc --- /dev/null +++ b/server/src/scripts/resolve-wecom-oa-user-consistency.js @@ -0,0 +1,711 @@ +#!/usr/bin/env node +require("dotenv/config"); + +const fs = require("node:fs"); +const path = require("node:path"); +const { Pool } = require("pg"); + +const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow"); +const MAX_REPORT_ITEMS = 200; + +function timestamp() { + return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} + +function printHelp() { + process.stdout.write(`Usage: node src/scripts/resolve-wecom-oa-user-consistency.js --report [options] + +受控处理 WOUA 只读报告中的 OA/企微用户一致性差异。 +默认只 dry-run;真实写 OA 必须同时指定 --apply 且设置 WOUA_FIX_APPLY=1。 + +Options: + --report= WOUA summary.json 路径,必填。 + --dry-run 只生成计划,不写数据库,默认。 + --apply 执行受控 OA 写入,需 WOUA_FIX_APPLY=1。 + --allow-employee-no= 限定允许处理的员工号;可重复。 + --clear-inactive-bindings 显式清理 inactiveWithBinding 中的绑定;默认不清理。 + --repair-manager-effects 将归档测试主管的 active 下属和 pending 待办转给 fallback active manager。 + --manager-fallback-employee-no= + 指定 fallback manager;未指定时选择 active 且有企微绑定的 manager。 + --format=text|json 输出格式,默认 text。 + --report-dir= 输出目录,默认 artifacts/qa-flow/wecom-oa-user-consistency-fix-。 + --help 显示帮助。 + +隐私边界: + 输出只包含员工号、状态、角色、分类、计数和处理结果。 + 不输出手机号、邮箱、密码哈希、token、secret、完整企微 userid 或企微原始 payload。 +`); +} + +function parseArgs(argv) { + const options = { + reportPath: process.env.WOUA_FIX_REPORT || "", + mode: "dry_run", + format: "text", + reportDir: process.env.WOUA_FIX_REPORT_DIR + || path.join(DEFAULT_REPORT_ROOT, `wecom-oa-user-consistency-fix-${timestamp()}`), + allowEmployeeNos: [], + clearInactiveBindings: false, + repairManagerEffects: false, + managerFallbackEmployeeNo: "", + 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 === "--report") { + options.reportPath = argv[index + 1] || ""; + index += 1; + } else if (arg.startsWith("--report=")) { + options.reportPath = arg.slice("--report=".length); + } else if (arg === "--dry-run") { + options.mode = "dry_run"; + } else if (arg === "--apply") { + options.mode = "apply"; + } 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 === "--allow-employee-no") { + options.allowEmployeeNos.push(...splitList(argv[index + 1] || "")); + index += 1; + } else if (arg.startsWith("--allow-employee-no=")) { + options.allowEmployeeNos.push(...splitList(arg.slice("--allow-employee-no=".length))); + } else if (arg === "--clear-inactive-bindings") { + options.clearInactiveBindings = true; + } else if (arg === "--repair-manager-effects") { + options.repairManagerEffects = true; + } else if (arg === "--manager-fallback-employee-no") { + options.managerFallbackEmployeeNo = normalizeEmployeeNo(argv[index + 1] || ""); + index += 1; + } else if (arg.startsWith("--manager-fallback-employee-no=")) { + options.managerFallbackEmployeeNo = normalizeEmployeeNo(arg.slice("--manager-fallback-employee-no=".length)); + } else { + throw new Error(`Unsupported argument: ${arg}`); + } + } + + if (!["text", "json"].includes(options.format)) { + throw new Error(`Unsupported --format: ${options.format}`); + } + options.allowEmployeeNos = normalizeEmployeeNoList(options.allowEmployeeNos); + return options; +} + +function splitList(value) { + return String(value ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizeEmployeeNo(value) { + return String(value ?? "").trim().slice(0, 128); +} + +function normalizeEmployeeNoList(values) { + const seen = new Set(); + const items = []; + for (const value of values ?? []) { + const employeeNo = normalizeEmployeeNo(value); + if (!employeeNo || seen.has(employeeNo)) continue; + seen.add(employeeNo); + items.push(employeeNo); + } + return items; +} + +function sample(items) { + return items.slice(0, MAX_REPORT_ITEMS); +} + +function sanitizeRoles(roles) { + return Array.isArray(roles) ? roles.filter((role) => typeof role === "string").sort() : []; +} + +function sanitizeErrorMessage(value) { + return String(value ?? "") + .replace(/access_token=[^&\s]+/gi, "access_token=***") + .replace(/[A-Za-z0-9_-]{24,}/g, "***") + .slice(0, 300); +} + +function readJsonFile(filePath) { + if (!filePath) { + throw new Error("Missing --report"); + } + const absolute = path.resolve(filePath); + return JSON.parse(fs.readFileSync(absolute, "utf8")); +} + +function employeeNoFromFinding(item) { + return normalizeEmployeeNo(item?.employeeNo ?? item?.employee?.employeeNo); +} + +function buildEmployeeNoActions(items, action, reason) { + return normalizeEmployeeNoList((items ?? []).map(employeeNoFromFinding)).map((employeeNo) => ({ + employeeNo, + action, + reason, + })); +} + +function buildAcceptedMissingRequiredFields(items) { + return sample(items ?? []).map((item) => ({ + employeeNo: employeeNoFromFinding(item), + fields: Array.isArray(item?.fields) ? [...item.fields].sort() : [], + action: "accepted_no_write", + reason: "user_confirmed_do_not_fill_wecom_required_fields_now", + })).filter((item) => item.employeeNo); +} + +function buildFixPlan(report, options = {}) { + const findings = report?.findings ?? {}; + const allowSet = new Set(normalizeEmployeeNoList(options.allowEmployeeNos)); + const shouldInclude = (item) => !allowSet.size || allowSet.has(item.employeeNo); + const archiveCandidates = buildEmployeeNoActions( + findings.oaActiveMissingInWecom, + "archive_oa_test_employee", + "user_confirmed_oa_active_missing_in_wecom_are_test_data", + ).filter(shouldInclude); + const excludedByAllowlist = allowSet.size + ? buildEmployeeNoActions( + findings.oaActiveMissingInWecom, + "excluded_by_allowlist", + "allowlist_did_not_include_employee_no", + ).filter((item) => !allowSet.has(item.employeeNo)) + : []; + + const inactiveBindingAction = options.clearInactiveBindings + ? "clear_inactive_wecom_binding" + : "accepted_no_write"; + + return { + sourceStatus: report?.status ?? "UNKNOWN", + sourceConclusion: report?.conclusion ?? null, + sourceGeneratedAt: report?.generatedAt ?? null, + decisions: { + oaActiveMissingInWecom: "archive_oa_test_employees", + missingRequiredFields: "accepted_no_write", + inactiveWithBinding: inactiveBindingAction, + managerSideEffects: options.repairManagerEffects ? "repair_to_active_manager" : "report_only", + wecomWriteBehavior: "no_wecom_writes", + }, + archiveOaTestEmployees: archiveCandidates, + excludedByAllowlist, + acceptedMissingRequiredFields: buildAcceptedMissingRequiredFields(findings.missingRequiredFields), + inactiveWithBinding: buildEmployeeNoActions( + findings.inactiveWithBinding, + inactiveBindingAction, + options.clearInactiveBindings + ? "explicit_clear_inactive_bindings" + : "binding_retained_for_audit_by_default", + ), + }; +} + +function assertApplyAllowed(options) { + if (options.mode !== "apply") return; + if (process.env.WOUA_FIX_APPLY !== "1") { + throw new Error("Refusing apply without WOUA_FIX_APPLY=1"); + } +} + +function buildDryRunResult(plan, options = {}) { + const inactiveClearCount = options.clearInactiveBindings ? plan.inactiveWithBinding.length : 0; + return { + mode: "dry_run", + generatedAt: new Date().toISOString(), + status: "DRY_RUN", + privacy: "employee_no/status/roles/counts only; no phone/email/password/token/secret/full userid/raw payload", + source: { + status: plan.sourceStatus, + conclusion: plan.sourceConclusion, + generatedAt: plan.sourceGeneratedAt, + }, + decisions: plan.decisions, + totals: { + archiveOaTestEmployees: plan.archiveOaTestEmployees.length, + excludedByAllowlist: plan.excludedByAllowlist.length, + acceptedMissingRequiredFields: plan.acceptedMissingRequiredFields.length, + inactiveWithBinding: plan.inactiveWithBinding.length, + inactiveBindingsToClear: inactiveClearCount, + managerSideEffectRepairs: options.repairManagerEffects ? plan.archiveOaTestEmployees.length : 0, + wecomWrites: 0, + }, + actions: { + archiveOaTestEmployees: sample(plan.archiveOaTestEmployees), + excludedByAllowlist: sample(plan.excludedByAllowlist), + acceptedMissingRequiredFields: sample(plan.acceptedMissingRequiredFields), + inactiveWithBinding: sample(plan.inactiveWithBinding), + }, + }; +} + +async function queryEmployeesForNos(client, employeeNos) { + if (!employeeNos.length) return []; + const result = await client.query( + ` + SELECT + e.id::text, + e.employee_no, + e.status::text, + e.employment_status::text, + (e.archived_at IS NOT NULL) AS archived, + e.token_version, + COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles, + (w.employee_id IS NOT NULL) AS has_wecom_binding, + ( + SELECT COUNT(*)::int + FROM workflow_tasks wt + WHERE wt.assignee_id = e.id + AND wt.status = 'pending' + ) AS pending_workflow_tasks, + ( + SELECT COUNT(*)::int + FROM employees dr + WHERE dr.direct_manager_id = e.id + AND dr.status = 'active' + AND dr.archived_at IS NULL + ) AS active_direct_reports, + ( + SELECT COUNT(*)::int + FROM auth_sessions s + WHERE s.employee_id = e.id + AND s.revoked_at IS NULL + ) AS active_auth_sessions + FROM employees e + LEFT JOIN employee_roles er ON er.employee_id = e.id + LEFT JOIN wecom_identity_bindings w ON w.employee_id = e.id + WHERE e.employee_no = ANY($1::text[]) + GROUP BY e.id, e.employee_no, e.status, e.employment_status, e.archived_at, e.token_version, w.employee_id + ORDER BY e.employee_no ASC + `, + [employeeNos], + ); + return result.rows.map((row) => ({ + id: row.id, + employeeNo: row.employee_no, + status: row.status, + employmentStatus: row.employment_status, + archived: Boolean(row.archived), + tokenVersion: Number(row.token_version ?? 0), + roles: sanitizeRoles(row.roles), + hasWecomBinding: Boolean(row.has_wecom_binding), + pendingWorkflowTasks: Number(row.pending_workflow_tasks ?? 0), + activeDirectReports: Number(row.active_direct_reports ?? 0), + activeAuthSessions: Number(row.active_auth_sessions ?? 0), + })); +} + +function mapRowsByEmployeeNo(rows) { + const map = new Map(); + for (const row of rows) { + map.set(row.employeeNo, row); + } + return map; +} + +function summarizeArchiveItem(planned, row, result) { + return { + employeeNo: planned.employeeNo, + action: result.action, + result: result.result, + reason: result.reason ?? planned.reason, + before: row + ? { + status: row.status, + employmentStatus: row.employmentStatus, + archived: row.archived, + roles: row.roles, + hasWecomBinding: row.hasWecomBinding, + pendingWorkflowTasks: row.pendingWorkflowTasks, + activeDirectReports: row.activeDirectReports, + activeAuthSessions: row.activeAuthSessions, + } + : null, + }; +} + +async function archiveEmployee(client, row, now, dryRun) { + if (!row) { + return { action: "archive_oa_test_employee", result: "skipped", reason: "employee_not_found" }; + } + if (row.status !== "active" || row.archived) { + return { action: "archive_oa_test_employee", result: "noop", reason: "already_inactive_or_archived" }; + } + if (dryRun) { + return { action: "archive_oa_test_employee", result: "planned", reason: "dry_run" }; + } + + await client.query( + ` + UPDATE employees + SET + status = 'archived', + employment_status = 'inactive', + archived_at = COALESCE(archived_at, $2::timestamptz), + token_version = token_version + 1, + updated_at = $2::timestamptz, + updated_by = NULL + WHERE id = $1::uuid + AND status = 'active' + AND archived_at IS NULL + `, + [row.id, now.toISOString()], + ); + await client.query( + ` + INSERT INTO employee_lifecycle_events (id, employee_id, event_type, effective_date, payload_json, created_at, created_by) + VALUES (gen_random_uuid(), $1::uuid, 'offboard', $2::date, $3::jsonb, $4::timestamptz, NULL) + `, + [ + row.id, + now.toISOString().slice(0, 10), + JSON.stringify({ + source: "WOUA-FIX", + reason: "oa_active_missing_in_wecom_test_data_cleanup", + }), + now.toISOString(), + ], + ); + return { action: "archive_oa_test_employee", result: "applied", reason: "archived_and_token_invalidated" }; +} + +async function clearInactiveBinding(client, row, now, dryRun) { + if (!row) { + return { action: "clear_inactive_wecom_binding", result: "skipped", reason: "employee_not_found" }; + } + if (row.status === "active" && !row.archived) { + return { action: "clear_inactive_wecom_binding", result: "skipped", reason: "employee_is_active" }; + } + if (!row.hasWecomBinding) { + return { action: "clear_inactive_wecom_binding", result: "noop", reason: "binding_not_found" }; + } + if (dryRun) { + return { action: "clear_inactive_wecom_binding", result: "planned", reason: "dry_run" }; + } + await client.query( + "DELETE FROM wecom_identity_bindings WHERE employee_id = $1::uuid", + [row.id], + ); + await client.query( + ` + INSERT INTO employee_lifecycle_events (id, employee_id, event_type, effective_date, payload_json, created_at, created_by) + VALUES (gen_random_uuid(), $1::uuid, 'offboard', $2::date, $3::jsonb, $4::timestamptz, NULL) + `, + [ + row.id, + now.toISOString().slice(0, 10), + JSON.stringify({ + source: "WOUA-FIX", + reason: "inactive_wecom_binding_cleared_explicitly", + }), + now.toISOString(), + ], + ); + return { action: "clear_inactive_wecom_binding", result: "applied", reason: "binding_deleted" }; +} + +async function resolveManagerFallback(client, options, excludedEmployeeIds) { + if (options.managerFallbackEmployeeNo) { + const result = await client.query( + ` + SELECT e.id::text, e.employee_no + FROM employees e + JOIN employee_roles er ON er.employee_id = e.id AND er.role_code = 'manager' + WHERE e.employee_no = $1 + AND e.status = 'active' + AND e.archived_at IS NULL + LIMIT 1 + `, + [options.managerFallbackEmployeeNo], + ); + return result.rows[0] + ? { id: result.rows[0].id, employeeNo: result.rows[0].employee_no, source: "explicit" } + : null; + } + + const result = await client.query( + ` + SELECT e.id::text, e.employee_no + FROM employees e + JOIN employee_roles er ON er.employee_id = e.id AND er.role_code = 'manager' + JOIN wecom_identity_bindings w ON w.employee_id = e.id + WHERE e.status = 'active' + AND e.archived_at IS NULL + AND NOT (e.id = ANY($1::uuid[])) + ORDER BY e.employee_no ASC + LIMIT 1 + `, + [excludedEmployeeIds], + ); + return result.rows[0] + ? { id: result.rows[0].id, employeeNo: result.rows[0].employee_no, source: "auto_active_bound_manager" } + : null; +} + +async function repairArchivedManagerEffects(client, archivedRows, options, now, dryRun) { + const managerRows = archivedRows.filter( + (row) => row?.id && (row.activeDirectReports > 0 || row.pendingWorkflowTasks > 0), + ); + if (!managerRows.length) return []; + + const fallback = await resolveManagerFallback(client, options, archivedRows.map((row) => row.id)); + if (!fallback) { + return managerRows.map((row) => ({ + employeeNo: row.employeeNo, + action: "repair_manager_side_effects", + result: "skipped", + reason: "active_manager_fallback_not_found", + fallbackEmployeeNo: null, + directReports: row.activeDirectReports, + pendingWorkflowTasks: row.pendingWorkflowTasks, + })); + } + + const repairs = []; + for (const row of managerRows) { + const directReports = await client.query( + ` + SELECT employee_no + FROM employees + WHERE direct_manager_id = $1::uuid + AND status = 'active' + AND archived_at IS NULL + ORDER BY employee_no ASC + `, + [row.id], + ); + const taskCountResult = await client.query( + ` + SELECT COUNT(*)::int AS task_count + FROM workflow_tasks + WHERE assignee_id = $1::uuid + AND status = 'pending' + `, + [row.id], + ); + const pendingWorkflowTasks = Number(taskCountResult.rows[0]?.task_count ?? 0); + const directReportNos = directReports.rows.map((item) => item.employee_no); + + if (!dryRun) { + await client.query( + ` + UPDATE employees + SET direct_manager_id = $2::uuid, updated_at = $3::timestamptz, updated_by = NULL + WHERE direct_manager_id = $1::uuid + AND status = 'active' + AND archived_at IS NULL + `, + [row.id, fallback.id, now.toISOString()], + ); + await client.query( + ` + UPDATE workflow_tasks + SET assignee_id = $2::uuid + WHERE assignee_id = $1::uuid + AND status = 'pending' + `, + [row.id, fallback.id], + ); + } + + repairs.push({ + employeeNo: row.employeeNo, + action: "repair_manager_side_effects", + result: dryRun ? "planned" : "applied", + fallbackEmployeeNo: fallback.employeeNo, + fallbackSource: fallback.source, + directReports: directReportNos.length, + directReportEmployeeNos: sample(directReportNos), + pendingWorkflowTasks, + }); + } + return repairs; +} + +async function applyPlanWithClient(client, plan, options = {}) { + const dryRun = options.mode !== "apply"; + const now = options.now ?? new Date(); + const archiveNos = plan.archiveOaTestEmployees.map((item) => item.employeeNo); + const inactiveNos = plan.inactiveWithBinding.map((item) => item.employeeNo); + const employeeNos = normalizeEmployeeNoList([...archiveNos, ...inactiveNos]); + + await client.query("BEGIN"); + try { + const rows = await queryEmployeesForNos(client, employeeNos); + const byEmployeeNo = mapRowsByEmployeeNo(rows); + const archived = []; + const inactiveBindings = []; + + for (const planned of plan.archiveOaTestEmployees) { + const row = byEmployeeNo.get(planned.employeeNo) ?? null; + const result = await archiveEmployee(client, row, now, dryRun); + archived.push(summarizeArchiveItem(planned, row, result)); + } + + const archivedRows = plan.archiveOaTestEmployees + .map((planned) => byEmployeeNo.get(planned.employeeNo)) + .filter(Boolean); + const managerRepairs = options.repairManagerEffects + ? await repairArchivedManagerEffects(client, archivedRows, options, now, dryRun) + : []; + + for (const planned of plan.inactiveWithBinding) { + const row = byEmployeeNo.get(planned.employeeNo) ?? null; + if (!options.clearInactiveBindings) { + inactiveBindings.push(summarizeArchiveItem(planned, row, { + action: "accepted_no_write", + result: "accepted", + reason: "binding_retained_for_audit_by_default", + })); + continue; + } + const result = await clearInactiveBinding(client, row, now, dryRun); + inactiveBindings.push(summarizeArchiveItem(planned, row, result)); + } + + if (dryRun) { + await client.query("ROLLBACK"); + } else { + await client.query("COMMIT"); + } + + return buildDatabaseResult({ plan, archived, inactiveBindings, managerRepairs, options, now }); + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch { + // Preserve original error. + } + throw error; + } +} + +function countByResult(items, result) { + return items.filter((item) => item.result === result).length; +} + +function buildDatabaseResult({ plan, archived, inactiveBindings, managerRepairs = [], options, now }) { + const mode = options.mode === "apply" ? "apply" : "dry_run"; + return { + mode, + generatedAt: now.toISOString(), + status: mode === "apply" ? "APPLIED" : "DRY_RUN", + privacy: "employee_no/status/roles/counts only; no phone/email/password/token/secret/full userid/raw payload", + source: { + status: plan.sourceStatus, + conclusion: plan.sourceConclusion, + generatedAt: plan.sourceGeneratedAt, + }, + decisions: plan.decisions, + totals: { + archiveOaTestEmployees: archived.length, + archivedApplied: countByResult(archived, "applied"), + archivedPlanned: countByResult(archived, "planned"), + archivedNoop: countByResult(archived, "noop"), + archivedSkipped: countByResult(archived, "skipped"), + excludedByAllowlist: plan.excludedByAllowlist.length, + acceptedMissingRequiredFields: plan.acceptedMissingRequiredFields.length, + inactiveWithBinding: inactiveBindings.length, + inactiveBindingApplied: countByResult(inactiveBindings, "applied"), + inactiveBindingAccepted: countByResult(inactiveBindings, "accepted"), + inactiveBindingPlanned: countByResult(inactiveBindings, "planned"), + inactiveBindingSkipped: countByResult(inactiveBindings, "skipped"), + managerSideEffectRepairs: managerRepairs.length, + managerSideEffectRepairApplied: countByResult(managerRepairs, "applied"), + managerSideEffectRepairPlanned: countByResult(managerRepairs, "planned"), + managerSideEffectRepairSkipped: countByResult(managerRepairs, "skipped"), + wecomWrites: 0, + }, + actions: { + archiveOaTestEmployees: sample(archived), + excludedByAllowlist: sample(plan.excludedByAllowlist), + acceptedMissingRequiredFields: sample(plan.acceptedMissingRequiredFields), + inactiveWithBinding: sample(inactiveBindings), + managerSideEffectRepairs: sample(managerRepairs), + }, + }; +} + +async function executeResolve(options) { + const report = readJsonFile(options.reportPath); + const plan = buildFixPlan(report, options); + assertApplyAllowed(options); + + if (!process.env.DATABASE_URL) { + if (options.mode === "dry_run") { + return buildDryRunResult(plan, options); + } + throw new Error("Missing DATABASE_URL"); + } + + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + const client = await pool.connect(); + try { + return await applyPlanWithClient(client, plan, options); + } finally { + client.release(); + await pool.end(); + } +} + +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-oa-user-consistency-fix] status=${report.status} mode=${report.mode}\n`); + process.stdout.write(`[wecom-oa-user-consistency-fix] report=${reportPath}\n`); + process.stdout.write(`[wecom-oa-user-consistency-fix] privacy=${report.privacy}\n`); + process.stdout.write(`[wecom-oa-user-consistency-fix] decisions=${JSON.stringify(report.decisions)}\n`); + process.stdout.write(`[wecom-oa-user-consistency-fix] totals=${JSON.stringify(report.totals)}\n`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + const report = await executeResolve(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-oa-user-consistency-fix][FAIL] ${sanitizeErrorMessage(error?.message || error)}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + applyPlanWithClient, + assertApplyAllowed, + buildDryRunResult, + buildFixPlan, + executeResolve, + normalizeEmployeeNoList, + parseArgs, +}; diff --git a/server/src/scripts/seed-trialrun.js b/server/src/scripts/seed-trialrun.js index b18afe86..3b371903 100644 --- a/server/src/scripts/seed-trialrun.js +++ b/server/src/scripts/seed-trialrun.js @@ -208,10 +208,23 @@ async function ensureAdminEmployee(prisma, params) { const existing = await prisma.employee.findFirst({ where: { OR: [{ email: params.email }, { phone: params.phone }] }, - select: { id: true, email: true, phone: true, status: true }, + select: { id: true, employee_no: true, email: true, phone: true, status: true, archived_at: true }, }); if (existing) { + if ((existing.status === "archived" || existing.archived_at) && process.env.TRIALRUN_ADMIN_REACTIVATE_ARCHIVED !== "1") { + const fallbackAdmin = await findActiveAdminEmployee(prisma, existing.id); + if (fallbackAdmin) { + return { + employeeId: fallbackAdmin.id, + created: false, + password: null, + skippedArchived: true, + fallbackEmployeeNo: fallbackAdmin.employee_no, + }; + } + } + await prisma.employee.update({ where: { id: existing.id }, data: { @@ -278,6 +291,23 @@ async function ensureAdminEmployee(prisma, params) { return { employeeId: employee.id, created: true, password }; } +async function findActiveAdminEmployee(prisma, excludedEmployeeId) { + const where = { + status: "active", + archived_at: null, + roles: { some: { role_code: "admin" } }, + }; + if (excludedEmployeeId) { + where.id = { not: excludedEmployeeId }; + } + + return prisma.employee.findFirst({ + where, + orderBy: { employee_no: "asc" }, + select: { id: true, employee_no: true }, + }); +} + async function main() { const adminEmail = requiredEnv("TRIALRUN_ADMIN_EMAIL"); const adminPhone = requiredEnv("TRIALRUN_ADMIN_PHONE"); @@ -326,6 +356,10 @@ async function main() { console.log(`- created: ${result.created}`); // eslint-disable-next-line no-console console.log(`- force_on flags: ${forceOnFlags.join(", ")}`); + if (result.skippedArchived) { + // eslint-disable-next-line no-console + console.log(`- archived trialrun admin: kept archived; active admin fallback: ${result.fallbackEmployeeNo}`); + } if (result.password) { // eslint-disable-next-line no-console console.log(`- one-time password: ${result.password}`); diff --git a/server/src/scripts/smoke-trialrun-runtime.js b/server/src/scripts/smoke-trialrun-runtime.js index dd708c8e..fd9703af 100644 --- a/server/src/scripts/smoke-trialrun-runtime.js +++ b/server/src/scripts/smoke-trialrun-runtime.js @@ -163,21 +163,43 @@ async function main() { assertEnvValues(runtimeMode); const adminEmail = (process.env.TRIALRUN_ADMIN_EMAIL || 'admin@hyinda.com').trim(); - const adminEmployee = await prisma.employee.findFirst({ - where: { email: adminEmail }, + let adminEmployee = await prisma.employee.findFirst({ + where: { email: adminEmail, status: 'active', archived_at: null }, select: { id: true, + employee_no: true, name: true, email: true, status: true, + archived_at: true, department_id: true, roles: { select: { role_code: true } }, }, }); + if (!adminEmployee) { + adminEmployee = await prisma.employee.findFirst({ + where: { + status: 'active', + archived_at: null, + roles: { some: { role_code: 'admin' } }, + }, + orderBy: { employee_no: 'asc' }, + select: { + id: true, + employee_no: true, + name: true, + email: true, + status: true, + archived_at: true, + department_id: true, + roles: { select: { role_code: true } }, + }, + }); + } - assertCondition(Boolean(adminEmployee), `未找到 trialrun admin 员工:${adminEmail}`); - assertCondition(adminEmployee.status === 'active', `trialrun admin 状态异常:${adminEmployee.status}`); - console.log(`[PASS] 已找到 active admin 员工:${adminEmployee.email}`); + assertCondition(Boolean(adminEmployee), `未找到 active admin 员工:${adminEmail}`); + assertCondition(adminEmployee.status === 'active' && !adminEmployee.archived_at, `active admin 状态异常:${adminEmployee.status}`); + console.log(`[PASS] 已找到 active admin 员工:${adminEmployee.employee_no}`); const workspace = await getWorkspaceSetting(prisma); assertWorkspaceSetting(workspace); diff --git a/server/tests/unit/scripts/resolve-wecom-oa-user-consistency.test.js b/server/tests/unit/scripts/resolve-wecom-oa-user-consistency.test.js new file mode 100644 index 00000000..8a3285e0 --- /dev/null +++ b/server/tests/unit/scripts/resolve-wecom-oa-user-consistency.test.js @@ -0,0 +1,201 @@ +const { + applyPlanWithClient, + assertApplyAllowed, + buildDryRunResult, + buildFixPlan, + normalizeEmployeeNoList, + parseArgs, +} = require("../../../src/scripts/resolve-wecom-oa-user-consistency"); + +function auditReport(overrides = {}) { + return { + status: "PASS_WITH_FINDINGS", + generatedAt: "2026-05-18T14:25:44.000Z", + findings: { + oaActiveMissingInWecom: [ + { employeeNo: "TEST001", status: "active", roles: ["employee"] }, + { employeeNo: "TEST002", status: "active", roles: ["employee"] }, + ], + missingRequiredFields: [ + { + employee: { employeeNo: "REAL001", status: "active", roles: ["employee"] }, + wecom: { externalUserIdPrefix: "wecom_re***" }, + fields: ["email", "phone", "department"], + }, + ], + inactiveWithBinding: [ + { employeeNo: "HRWDTEST-20260517214548", status: "archived", archived: true, roles: ["employee"] }, + ], + matchedActive: [], + wecomActiveMissingInOa: [], + oaActiveMissingBinding: [], + statusMismatch: [], + matchConflict: [], + }, + ...overrides, + }; +} + +function employeeRow(overrides = {}) { + return { + id: overrides.id ?? "11111111-1111-4111-8111-111111111111", + employee_no: overrides.employeeNo ?? "TEST001", + status: overrides.status ?? "active", + employment_status: overrides.employmentStatus ?? "active", + archived: overrides.archived ?? false, + token_version: overrides.tokenVersion ?? 1, + roles: overrides.roles ?? ["employee"], + has_wecom_binding: overrides.hasWecomBinding ?? false, + }; +} + +function buildClient(rows) { + const calls = []; + return { + calls, + async query(sql, params) { + const text = String(sql); + calls.push({ sql: text, params }); + if (text.includes("FROM employees e")) { + return { rows }; + } + return { rows: [], rowCount: 1 }; + }, + }; +} + +function sqlCalls(client, pattern) { + return client.calls.filter((call) => call.sql.includes(pattern)); +} + +describe("resolve-wecom-oa-user-consistency", () => { + afterEach(() => { + delete process.env.WOUA_FIX_APPLY; + }); + + it("builds a default plan that archives missing OA test employees and accepts missing WeCom fields", () => { + const plan = buildFixPlan(auditReport()); + const dryRun = buildDryRunResult(plan); + const serialized = JSON.stringify(dryRun); + + expect(plan.archiveOaTestEmployees.map((item) => item.employeeNo)).toEqual(["TEST001", "TEST002"]); + expect(plan.acceptedMissingRequiredFields).toEqual([ + expect.objectContaining({ + employeeNo: "REAL001", + action: "accepted_no_write", + fields: ["department", "email", "phone"], + }), + ]); + expect(plan.inactiveWithBinding[0]).toEqual(expect.objectContaining({ + employeeNo: "HRWDTEST-20260517214548", + action: "accepted_no_write", + })); + expect(dryRun.totals.wecomWrites).toBe(0); + expect(serialized).not.toContain("13800000001"); + expect(serialized).not.toContain("employee@example.com"); + expect(serialized).not.toContain("wecom_user_001_very_long"); + }); + + it("supports allowlist narrowing to avoid touching unlisted employees", () => { + const plan = buildFixPlan(auditReport(), { allowEmployeeNos: ["TEST002"] }); + + expect(plan.archiveOaTestEmployees.map((item) => item.employeeNo)).toEqual(["TEST002"]); + expect(plan.excludedByAllowlist.map((item) => item.employeeNo)).toEqual(["TEST001"]); + }); + + it("requires explicit environment approval for apply mode", () => { + expect(() => assertApplyAllowed({ mode: "apply" })).toThrow(/WOUA_FIX_APPLY=1/); + process.env.WOUA_FIX_APPLY = "1"; + expect(() => assertApplyAllowed({ mode: "apply" })).not.toThrow(); + }); + + it("parses mode and repeated employee allowlist arguments", () => { + const options = parseArgs([ + "--report=summary.json", + "--apply", + "--allow-employee-no=TEST001,TEST002", + "--allow-employee-no", + "TEST001", + "--repair-manager-effects", + "--manager-fallback-employee-no=HYD000007", + "--format=json", + ]); + + expect(options.mode).toBe("apply"); + expect(options.reportPath).toBe("summary.json"); + expect(options.format).toBe("json"); + expect(options.allowEmployeeNos).toEqual(["TEST001", "TEST002"]); + expect(options.repairManagerEffects).toBe(true); + expect(options.managerFallbackEmployeeNo).toBe("HYD000007"); + }); + + it("deduplicates normalized employee numbers", () => { + expect(normalizeEmployeeNoList([" TEST001 ", "TEST001", "", "TEST002"])).toEqual(["TEST001", "TEST002"]); + }); + + it("dry-runs inside a rolled-back transaction and does not update employees", async () => { + const plan = buildFixPlan(auditReport(), { allowEmployeeNos: ["TEST001"] }); + const client = buildClient([employeeRow({ employeeNo: "TEST001" })]); + const result = await applyPlanWithClient(client, plan, { + mode: "dry_run", + now: new Date("2026-05-18T00:00:00.000Z"), + }); + + expect(result.status).toBe("DRY_RUN"); + expect(result.totals.archivedPlanned).toBe(1); + expect(sqlCalls(client, "UPDATE employees")).toHaveLength(0); + expect(sqlCalls(client, "ROLLBACK")).toHaveLength(1); + expect(sqlCalls(client, "COMMIT")).toHaveLength(0); + }); + + it("archives active test employees in apply mode and increments token_version", async () => { + const plan = buildFixPlan(auditReport(), { allowEmployeeNos: ["TEST001"] }); + const client = buildClient([employeeRow({ employeeNo: "TEST001" })]); + const result = await applyPlanWithClient(client, plan, { + mode: "apply", + now: new Date("2026-05-18T00:00:00.000Z"), + }); + + expect(result.status).toBe("APPLIED"); + expect(result.totals.archivedApplied).toBe(1); + expect(sqlCalls(client, "UPDATE employees")).toHaveLength(1); + expect(sqlCalls(client, "token_version = token_version + 1")).toHaveLength(1); + expect(sqlCalls(client, "INSERT INTO employee_lifecycle_events")).toHaveLength(1); + expect(sqlCalls(client, "COMMIT")).toHaveLength(1); + expect(sqlCalls(client, "ROLLBACK")).toHaveLength(0); + }); + + it("keeps inactive WeCom bindings by default and clears them only with explicit flag", async () => { + const plan = buildFixPlan(auditReport(), { clearInactiveBindings: true }); + const dryClient = buildClient([ + employeeRow({ employeeNo: "TEST001" }), + employeeRow({ + id: "22222222-2222-4222-8222-222222222222", + employeeNo: "HRWDTEST-20260517214548", + status: "archived", + employmentStatus: "inactive", + archived: true, + hasWecomBinding: true, + }), + ]); + const defaultPlan = buildFixPlan(auditReport()); + const defaultClient = buildClient([ + employeeRow({ employeeNo: "TEST001" }), + ]); + + const defaultResult = await applyPlanWithClient(defaultClient, defaultPlan, { + mode: "apply", + now: new Date("2026-05-18T00:00:00.000Z"), + }); + const explicitResult = await applyPlanWithClient(dryClient, plan, { + mode: "apply", + clearInactiveBindings: true, + now: new Date("2026-05-18T00:00:00.000Z"), + }); + + expect(defaultResult.totals.inactiveBindingAccepted).toBe(1); + expect(sqlCalls(defaultClient, "DELETE FROM wecom_identity_bindings")).toHaveLength(0); + expect(explicitResult.totals.inactiveBindingApplied).toBe(1); + expect(sqlCalls(dryClient, "DELETE FROM wecom_identity_bindings")).toHaveLength(1); + }); +});