From 6273201a1347f3f0cd5b0dc5195332360ffe0195 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 18 May 2026 21:08:51 +0800 Subject: [PATCH] =?UTF-8?q?WOUA=20=E5=A2=9E=E5=8A=A0=20OA=20=E4=B8=8E?= =?UTF-8?q?=E4=BC=81=E5=BE=AE=E7=94=A8=E6=88=B7=E5=8F=AA=E8=AF=BB=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/trialrun/人工试运行报告.md | 3 + docs/文档事实源状态矩阵.md | 15 +- docs/文档索引.md | 6 +- ...-45-58-wecom-oa-user-consistency-audit.csv | 7 + server/package.json | 1 + .../audit-wecom-oa-user-consistency.js | 552 ++++++++++++++++++ .../audit-wecom-oa-user-consistency.test.js | 151 +++++ 7 files changed, 726 insertions(+), 9 deletions(-) create mode 100644 issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv create mode 100644 server/src/scripts/audit-wecom-oa-user-consistency.js create mode 100644 server/tests/unit/scripts/audit-wecom-oa-user-consistency.test.js diff --git a/docs/trialrun/人工试运行报告.md b/docs/trialrun/人工试运行报告.md index 10a15721..67fcedd1 100644 --- a/docs/trialrun/人工试运行报告.md +++ b/docs/trialrun/人工试运行报告.md @@ -47,6 +47,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 - 2026-05-18 WCP1 已完成企业微信能力 POC:新增 QA-only 只读 runner `scripts/qa/check-wecom-capability-poc.mjs`,本地不开启 live read 时可解释为 `PAUSE_FOR_API_POC`,本地开启 live read 但无真实 env 时可解释为 `PAUSE_FOR_CONFIG`;部署服务器容器内 live 只读探测报告为远端 `/tmp/wecom-capability-poc-wcp1/summary.json`,历史结论为 `PAUSE_FOR_CONFIG`。该报告继续作为初始配置缺口证据保留,当前 HR 主数据镜像准入以 WCPC 复跑结论为准。 - 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、本地门禁、受控部署和公网执行。公网 OA 侧只读成功,脱敏报告记录 `oaEmployees=13`、`oaActiveEmployees=12`、`oaActiveWithBinding=5`;企微侧全量读取被外部配置/权限阻断,结论为 `SKIP / BLOCKED_BY_EXTERNAL_CONFIG`,错误码 `wecom_errcode_60011`。本轮未修改 OA 或企微用户,未执行真实员工批量 apply,不能据此判定“OA 和企微用户已一致”;进入考勤/审批镜像前,应先处理企微读权限或另建 WOUA-FIX/WHMS 批量同步窗口。 ## 关键证据索引 @@ -93,6 +94,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | AUTO-REMAINING-CLOSEOUT | 已收口:剩余自动化运营门禁通过,结论 `GO_TO_OPERATE`;公网 `payment_batch_v1=force_on`、`erp_sync=force_off`,付款批次常开闭环、AP 导出、审计、公网移动和企业微信渲染矩阵均通过;企微打卡同步外部配置仍记 `SKIP` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv`、`artifacts/qa-flow/launch-auto-ops-20260517-193317/summary.json`、`artifacts/qa-flow/launch-auto-ops-20260517-193317/live-result.json` | | 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 | 已收口:OA/企微用户一致性只读审计 runner 已部署执行;公网报告为 `SKIP / BLOCKED_BY_EXTERNAL_CONFIG`,企微读取阻断 `wecom_errcode_60011`,OA 侧脱敏计数为 13/12/5;未修改 OA/企微用户,未执行真实员工批量 apply | `issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv`、`artifacts/qa-flow/wecom-oa-user-consistency-20260518-2055/summary.json` | ## 当前门禁 @@ -136,6 +138,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | WCP1 企业微信能力 POC | 完成只读 POC,历史结论 `PAUSE_FOR_CONFIG` | Issue `issues/2026-05-18_13-30-07-wecom-capability-poc-v1.csv`;新增 `scripts/qa/check-wecom-capability-poc.mjs`。本地脚本门禁通过,远端容器内 live 只读报告为 `/tmp/wecom-capability-poc-wcp1/summary.json`:`CAN_REUSE_NOW=9`、`NEEDS_CONFIG=8`、`NEEDS_API_POC=1`、`KEEP_OA_PRIMARY=1`。阻断为 `contact.member_read=wecom_errcode_48009`、`hr.staff_read=wecom_errcode_60021`、打卡同步未开启、缺审批模板/实例、假期权限 `wecom_errcode_301062` 和回调配置;该历史阻断已由 WCPC 复跑收口。 | | WCPC 企业微信能力 POC 配置收口 | 通过,结论 `GO_TO_HR_MASTER_SYNC` | Issue `issues/2026-05-18_15-01-23-wecom-capability-poc-config-closeout-v1.csv`;未补配置前远端基线复跑报告 `/tmp/wecom-capability-poc-wcpc-baseline` 为 `PASS / PAUSE_FOR_CONFIG`;随后使用既有在职测试号 `SMOKE002/测试员工`(脱敏 `oa_smoke***`)完成只读复跑,报告 `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json`,`nextStageAllowed=true`,`blockingCapabilities=[]`,`CAN_REUSE_NOW=15`、`NEEDS_CONFIG=3`、`NEEDS_API_POC=1`、`KEEP_OA_PRIMARY=1`。该准入已由 WHMS 承接;审批模板/实例、回调配置和 HR 写接口仍留后续合同。 | | WHMS 企微 HR 主数据镜像 | 已部署并完成受控公网验证 | Issue `issues/2026-05-18_18-43-19-wecom-hr-master-sync-v1.csv`、`issues/2026-05-18_19-28-25-whms-deploy-live-verify-v1.csv`;已部署 `wecom_hr_master_sync_v1`、同步批次/逐员工记录、企微通讯录/人事助手读取服务、管理员 API、People 管理卡片和 `scripts/qa/check-wecom-hr-master-sync.mjs`。公网 dry-run/apply 对 `SMOKE002` 返回 `missing_required_field` skip,未写 OA 主数据;权限负例 403;最终 `force_off`,未执行生产真实员工批量 apply。 | +| WOUA OA/企微用户一致性只读对账 | 外部配置阻断,结论 `BLOCKED_BY_EXTERNAL_CONFIG` | Issue `issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv`;新增 QA-only runner `server/src/scripts/audit-wecom-oa-user-consistency.js` 和脱敏匹配单测。公网只读报告 `artifacts/qa-flow/wecom-oa-user-consistency-20260518-2055/summary.json`:OA 侧只读成功,`oaEmployees=13`、`oaActiveEmployees=12`、`oaActiveWithBinding=5`;企微侧全量读取返回 `wecom_errcode_60011`,按 `SKIP / BLOCKED_BY_EXTERNAL_CONFIG` 收口。未打开 `wecom_hr_master_sync_v1`,未调用 WHMS dry-run/apply API,未修改 OA/企微用户。 | ## R7EP / OPS8 / QAF8 / UX8 / P2T 预检推进 diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md index 08a95f45..cb3cd442 100644 --- a/docs/文档事实源状态矩阵.md +++ b/docs/文档事实源状态矩阵.md @@ -69,6 +69,7 @@ | `WCPC` | `issues/2026-05-18_15-01-23-wecom-capability-poc-config-closeout-v1.csv` | `EVIDENCE` | WCP1 配置收口已用既有在职测试号复跑通过,结论 `GO_TO_HR_MASTER_SYNC`,已由 WHMS 承接 | | `WHMS` | `issues/2026-05-18_18-43-19-wecom-hr-master-sync-v1.csv` | `CURRENT` | 企微 HR 主数据镜像已实现:`wecom_hr_master_sync_v1`、同步批次/逐员工记录、dry-run/apply、管理员 API、People 管理入口和 QA-only runner;默认 `force_off`,不代表考勤/审批镜像已完成 | | `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/企微用户一致性只读审计已部署执行:OA 侧只读计数 13/12/5,企微全量读取因 `wecom_errcode_60011` 阻断,结论 `SKIP / BLOCKED_BY_EXTERNAL_CONFIG`,未修改 OA/企微用户,未执行真实员工批量 apply | 旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。 @@ -76,12 +77,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` | `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` | `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` | `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` | `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` | Issue CSV | `docs/requirements-specification.md` | 2026-05-18 | `rg` | high | WHMS 后定义 HR 主数据镜像已部署验证和下一阶段边界;ERP 未来不接入 OA | | `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE` | `rg` | none | 2026-05-18 | `rg` | high | 记录已拍板产品边界、企微/OA 设计门禁、开发路线图、WCPC 准入结论和 WHMS 部署验证状态 | | `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` | Issue CSV | none | 2026-05-18 | `rg` | high | WHMS 后记录企微 HR/考勤路线阶段顺序、HR 主数据镜像部署验证和下一阶段准入边界 | +| `ROADMAP-WECOM` | `docs/企微优先开发路线图.md` | 开发治理 | 企微优先开发路线图事实源 | `CURRENT` | product/dev | `WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA` | Issue CSV | none | 2026-05-18 | `rg` | high | WOUA 后记录企微 HR/考勤路线阶段顺序、HR 主数据镜像部署验证和用户一致性只读审计阻断边界 | | `DELIVERY` | `docs/开发交付与变更管理规约.md` | 开发治理 | 开发交付事实源 | `CURRENT` | dev/qa | `GOV8,WDRG` | `rg` | none | 2026-05-18 | `rg` | high | Issue、review、commit、交付规则;WDRG 后采用路线图门禁 + 单功能 Issue CSV | | `QA-GATE` | `docs/测试规约与回归门禁.md` | 测试验收 | 测试和回归门禁事实源 | `CURRENT` | qa | `QAF8,GOV8` | 回归脚本/Playwright | none | 2026-05-13 | `rg` | high | 测试计划和 DONE 门禁 | | `AGENT-PRACTICE` | `docs/Agent开发交付实践.md` | Agent 治理 | Agent 协作事实源 | `CURRENT` | dev | `GOV8,DG8` | Skill 校验 | none | 2026-05-13 | `rg` | medium | Agent 协作开发实践和设计主参考层级 | @@ -90,13 +91,13 @@ | `REQ-WF` | `docs/需求-统一审批中心规约.md` | 需求事实源 | 审批模块规约 | `CURRENT` | product/backend | `AP6,WF8,WFOC,DBWFOC,WFDG` | workflow 回归 | none | 2026-05-18 | `rg` | high | OA 主办审批、企微镜像和 OA 应急接管边界 | | `REQ-OCR` | `docs/需求-报销发票OCR规约.md` | 需求事实源 | OCR 模块规约 | `CURRENT` | product/backend | `RI6,OCR8,BIZ8` | OCR 回归 | none | 2026-05-13 | `rg` | high | OCR 和发票边界 | | `RULE-REIMB` | `docs/报销发票附件规则.md` | 需求事实源 | 报销发票附件规则 | `CURRENT` | product/finance | `RI6,BIZ8` | 报销回归 | none | 2026-05-13 | `rg` | high | 报销、发票、附件细则 | -| `REQ-WECOM` | `docs/需求-企业微信基础设施规约.md` | 需求事实源 | 企业微信边界规约 | `CURRENT` | product/backend | `R7E,ORG8,WF8,ATTV1,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WCP1,WCPC,WHMS,WHMSLIVE` | live smoke/真机反馈/Issue CSV | none | 2026-05-18 | `rg` | high | 企微优先承担 HR/考勤员工日常审批和人事/打卡能力;WHMS 记录 HR 主数据镜像部署验证;采购、合同、项目、报销和财务继续 OA 主办 | +| `REQ-WECOM` | `docs/需求-企业微信基础设施规约.md` | 需求事实源 | 企业微信边界规约 | `CURRENT` | product/backend | `R7E,ORG8,WF8,ATTV1,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA` | live smoke/真机反馈/Issue CSV | none | 2026-05-18 | `rg` | high | 企微优先承担 HR/考勤员工日常审批和人事/打卡能力;WOUA 记录 OA/企微用户一致性只读审计被企微读取权限阻断;采购、合同、项目、报销和财务继续 OA 主办 | | `RUNBOOK-TRIAL` | `docs/试运行与自动化验证手册.md` | 操作手册 | 试运行验证手册 | `CURRENT` | qa/ops | `R7E,QAOPS8` | live readiness | none | 2026-05-13 | `rg` | medium | 本地、公网、企业微信和真实 OCR 验证 | | `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E,WFOC,DBWFOC,WFDG,WCPC,WHMS,WHMSLIVE` | 人工验收 | none | 2026-05-18 | `rg` | medium | 管理员上线、企微优先边界、OA 主办数据边界、HR 主数据镜像、OA 应急接管、恢复回写和 POC 前置 | | `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` | artifacts/qa-flow | none | 2026-05-13 | `rg` | medium | 只证明发生过,不直接派工 | +| `TRIAL-REPORT` | `docs/trialrun/人工试运行报告.md` | 证据材料 | 试运行证据入口 | `EVIDENCE` | qa | `R7EP,OPS8,QAF8,WOUA` | artifacts/qa-flow | none | 2026-05-18 | `rg` | medium | 只证明发生过;WOUA 记录 OA/企微用户一致性只读审计阻断,不直接派工 | | `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 历史恢复 | @@ -109,7 +110,7 @@ | `docs/文档事实源状态矩阵.md` | 当前事实源 | 文档分类和恢复规则 | | `docs/海音达OA-MVP需求规格说明书.md` | 当前事实源 | PRD 主事实源 | | `docs/产品设计规则.md` | 当前事实源 | PC、移动、企业微信设计规则;DG8 主 UI/布局参考层级 | -| `docs/企微优先开发路线图.md` | 当前事实源 | WDRG/WCP1/WCPC/WHMS/WHMSLIVE 后企微优先 HR/考勤改造阶段顺序、准入/退出门禁、禁止跳阶段和当前 HR 主数据镜像部署验证状态 | +| `docs/企微优先开发路线图.md` | 当前事实源 | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA 后企微优先 HR/考勤改造阶段顺序、准入/退出门禁、禁止跳阶段和当前用户一致性只读审计阻断状态 | | `docs/产品口径.md` | 当前事实源 | 产品边界和业务规则 | | `docs/需求-统一审批中心规约.md` | 当前事实源 | 审批、催办、超时、效率摘要 | | `docs/报销发票附件规则.md` | 当前事实源 | 报销、发票、附件细则 | @@ -184,4 +185,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。HR/考勤员工日常审批优先复用企业微信,OA 目标是按域镜像、人工接管、恢复回写和 RuleOps;采购、费用、合同、项目、报销、付款、AP、代账交接、审计和高敏附件继续 OA 主办。考勤/审批镜像、审批回调、OA 应急接管、恢复回写和 RuleOps 尚未实现;审批模板 ID、审批单号 `sp_no`、真实回调配置和 HR 写接口仍需后续独立合同处理。后续仍须按 [企微优先开发路线图](./企微优先开发路线图.md) 在 WHMSLIVE 之后另拆 `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 配置复跑均已完成,当前 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。WOUA 已执行 OA/企微用户一致性只读审计,OA 侧只读计数为 13/12/5,企微全量读取因 `wecom_errcode_60011` 阻断,结论 `SKIP / BLOCKED_BY_EXTERNAL_CONFIG`;该结果不代表 OA 和企微用户已一致,也不批准真实员工批量 apply。HR/考勤员工日常审批优先复用企业微信,OA 目标是按域镜像、人工接管、恢复回写和 RuleOps;采购、费用、合同、项目、报销、付款、AP、代账交接、审计和高敏附件继续 OA 主办。考勤/审批镜像、审批回调、OA 应急接管、恢复回写和 RuleOps 尚未实现;审批模板 ID、审批单号 `sp_no`、真实回调配置、HR 写接口和企微用户一致性读权限仍需后续独立合同处理。后续仍须按 [企微优先开发路线图](./企微优先开发路线图.md) 在 WHMSLIVE/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 验证;测试成员按审计要求保留离职/禁用状态。 diff --git a/docs/文档索引.md b/docs/文档索引.md index 01c2df1b..d1c8f99e 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。 +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。 - R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。 - ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。 @@ -62,6 +62,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M - WCP1 是 WDRG 后的企业微信能力 POC 合同:新增 QA-only 只读 runner `scripts/qa/check-wecom-capability-poc.mjs`,对人事助手、通讯录、打卡、审批、假期余额、回调和权限配置输出脱敏能力矩阵。远端 live 只读历史结论为 `PAUSE_FOR_CONFIG`,作为初始配置缺口证据保留;当前 HR 主数据镜像准入以 WCPC 复跑结论为准。 - 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。下一阶段可另建 `wecom_attendance_approval_mirror_v1`。 +- WOUA 是 WHMSLIVE 后的 OA/企微用户一致性只读审计合同:已新增 QA-only runner `server/src/scripts/audit-wecom-oa-user-consistency.js`,本地静态/单测门禁通过并以 `SYNC_ENV=0` 部署执行;公网报告 `artifacts/qa-flow/wecom-oa-user-consistency-20260518-2055/summary.json` 为 `SKIP / BLOCKED_BY_EXTERNAL_CONFIG`,企微读取阻断 `wecom_errcode_60011`,OA 侧脱敏计数为 `oaEmployees=13`、`oaActiveEmployees=12`、`oaActiveWithBinding=5`。本轮未修改 OA/企微用户,未执行真实员工批量 apply,不代表当前已完成一致性核验;进入考勤/审批镜像前需先处理企微读权限或另建 WOUA-FIX/WHMS 批量同步窗口。 ## 当前活跃治理流 @@ -111,6 +112,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M | `WCPC` | `issues/2026-05-18_15-01-23-wecom-capability-poc-config-closeout-v1.csv` | 已收口:既有测试号 POC 复跑通过 | 使用 `SMOKE002/测试员工`(脱敏 `oa_smoke***`)完成远端只读复跑,结论 `GO_TO_HR_MASTER_SYNC`,已由 WHMS 承接 | 不开发业务功能、不部署、不持久改服务器 env;考勤/审批镜像仍需后续补审批模板、审批单和回调配置 | | `WHMS` | `issues/2026-05-18_18-43-19-wecom-hr-master-sync-v1.csv` | 已实现:企微 HR 主数据镜像 | 新增 `wecom_hr_master_sync_v1`、Prisma 同步批次/逐员工记录、企微通讯录/人事助手读取、dry-run/apply、管理员 API、People 管理卡片和 QA-only runner | 默认 `force_off`;不做考勤/审批镜像、审批回调、应急接管、恢复回写或 RuleOps | | `WHMSLIVE` | `issues/2026-05-18_19-28-25-whms-deploy-live-verify-v1.csv` | 已部署验证:企微 HR 主数据镜像公网受控验证 | 部署 WHMS、应用 migration、公网 dry-run/apply 管道验证、普通员工权限负例、最终 `wecom_hr_master_sync_v1=force_off` | `SMOKE002` 缺手机号、邮箱和部门按 `missing_required_field` 跳过,未写 OA 主数据;未执行生产真实员工批量 apply | +| `WOUA` | `issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv` | 已收口:OA/企微用户一致性只读对账外部配置阻断 | OA active 员工 vs 企业微信通讯录 + 人事助手只读审计、脱敏差异报告、本地单测、受控部署和公网执行 | 公网结论 `SKIP / BLOCKED_BY_EXTERNAL_CONFIG`,企微读取 `wecom_errcode_60011`;未改 OA/企微用户,未执行真实员工批量 apply;不据此进入考勤/审批镜像 | 旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。 @@ -133,7 +135,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 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段和当前 HR 主数据镜像部署验证事实源 | +| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段和当前用户一致性只读审计阻断事实源 | | [产品口径](./产品口径.md) | 已拍板产品边界和业务规则 | | [需求 - 统一审批中心规约](./需求-统一审批中心规约.md) | OA 主办审批、企微主办 HR/考勤员工日常审批镜像、催办、超时提醒、审批效率 | | [报销发票附件规则](./报销发票附件规则.md) | 报销、发票、OCR、附件细则 | diff --git a/issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv b/issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv new file mode 100644 index 00000000..a5fc6eb4 --- /dev/null +++ b/issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"WOUA-0","合同建档与边界冻结","建立 OA 与企微用户一致性只读对账合同,锁定本轮只生成脱敏差异报告,不执行真实员工批量 apply,不打开 wecom_hr_master_sync_v1,不修改 OA 或企微用户数据。","1) CSV 校验通过;2) 范围限定为 QA-only 只读 runner、脱敏匹配单测、公网只读审计、事实源回写;3) 不新增公开 API、Prisma schema、前端 UI 或 feature flag;4) 不调用会持久化同步批次的 WHMS dry-run API。","受影响流程:OA active 员工 -> 企业微信通讯录/人事助手 -> 用户一致性审计 -> 后续修复合同决策;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv。","python3|manual","DONE","DONE","DONE","issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv","[]","回滚路径:删除本 CSV 和本轮 QA-only 代码/文档回写;本轮不涉及运行时数据变更。" +"WOUA-1","新增只读对账 runner","新增 QA-only CLI,读取 OA 员工和企业微信通讯录/人事助手资料,按 WHMS 匹配优先级生成脱敏一致性报告。","1) OA 侧使用 PostgreSQL BEGIN READ ONLY;2) 企微侧只读调用通讯录和人事助手;3) 报告包含 matchedActive、oaActiveMissingInWecom、wecomActiveMissingInOa、oaActiveMissingBinding、inactiveWithBinding、statusMismatch、missingRequiredFields、matchConflict;4) 不输出手机号、邮箱、完整 userid、token、secret 或原始 payload。","受影响流程:管理员/QA 执行只读审计 -> 生成脱敏差异报告;验证步骤:cd server && node --check src/scripts/audit-wecom-oa-user-consistency.js;cd server && node src/scripts/audit-wecom-oa-user-consistency.js --help。","node|pg|wecom","DONE","DONE","DONE","server/src/scripts/audit-wecom-oa-user-consistency.js|server/package.json","[""WOUA-0""]","本 runner 不作为应用运行时依赖;缺企微配置时必须输出 SKIP/BLOCKED_BY_EXTERNAL_CONFIG,不伪造成 PASS。" +"WOUA-2","脱敏与匹配单测","新增 focused 单测覆盖全匹配、OA active 缺企微、企微 active 缺 OA、企微缺必填字段、inactive 仍绑定和重复匹配冲突。","1) 测试覆盖匹配优先级 external_user_id -> employee_no/userid -> phone -> email;2) 输出不含手机号、邮箱或完整 userid;3) status 可判定 PASS_NO_DRIFT 或 PASS_WITH_FINDINGS;4) 单测可独立运行。","受影响流程:对账逻辑 -> 报告分类 -> 后续修复分流;验证步骤:cd server && npx vitest run tests/unit/scripts/audit-wecom-oa-user-consistency.test.js --reporter=verbose。","vitest","DONE","DONE","DONE","server/tests/unit/scripts/audit-wecom-oa-user-consistency.test.js","[""WOUA-1""]","只测纯逻辑和脱敏,不访问真实数据库或企业微信。" +"WOUA-3","公网只读对账执行","本地门禁通过后,以 SYNC_ENV=0 受控部署 QA 脚本,在远端 server 容器内执行只读对账并保存报告。","1) 部署不覆盖远端 env;2) 公网执行命令只读,不打开 feature flag,不写 OA/企微用户数据;3) 报告结论为 PASS_WITH_FINDINGS、PASS_NO_DRIFT 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;4) 报告路径可审计。","受影响流程:公网 OA 员工和企微可见成员 -> 只读差异审计 -> 后续修复决策;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;ssh deploy@118.89.77.218 在 /opt/hyb-oa-trialrun 容器内执行 npm run audit:wecom-oa-users -- --live --format=json。","bash|ssh|node","DONE","DONE","DONE","external|artifacts/qa-flow/wecom-oa-user-consistency-*","[""WOUA-2""]","公网审计实际结论为 SKIP/BLOCKED_BY_EXTERNAL_CONFIG,企微读取返回 wecom_errcode_60011;OA 侧只读计数 oaEmployees=13、oaActiveEmployees=12、oaActiveWithBinding=5;未打印 Secret/token/完整 userid/手机号/邮箱,未写 OA/企微数据。" +"WOUA-4","报告和事实源回写","更新人工试运行报告、文档索引和事实源矩阵,记录本轮只是只读对账,不代表真实员工已批量同步。","1) 文档记录 WOUA 当前结论、报告路径和下一步建议;2) 明确不执行真实员工 apply;3) 明确如差异较大先进入 WOUA-FIX 或 WHMS 批量同步窗口决策;4) 文档审计和关键词核对通过。","受影响流程:事实源读取 -> 判断 OA/企微用户一致性 -> 后续派工;验证步骤:node scripts/qa/audit-docs.mjs --check-registry;rg -n 'WOUA|用户一致性|PASS_WITH_FINDINGS|wecom-oa-user-consistency|批量同步' docs issues。","node|rg","DONE","DONE","DONE","docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md","[""WOUA-3""]","事实源记录 WOUA 为只读审计且被外部配置阻断,不承诺自动修复;下一步需处理企微读权限或另建 WOUA-FIX/WHMS 批量同步窗口。" +"WOUA-5","状态回填与中文提交","完成 WOUA 状态回填、本地门禁、公网报告核对和中文提交。","1) WOUA-0..4 均按实际结果回填;2) validate_issues_csv、node --check、focused vitest、prisma validate、git diff --check 通过;3) 中文 commit 引用 WOUA;4) 最终说明是否部署和用户是否可查看报告。","受影响流程:Issue CSV -> 验证证据 -> 中文提交 -> 后续实施入口;验证步骤:git status --short;git log -1 --oneline。","git|manual","DONE","DONE","DONE","issues/2026-05-18_20-45-58-wecom-oa-user-consistency-audit.csv|docs|server/src/scripts/audit-wecom-oa-user-consistency.js|server/tests/unit/scripts/audit-wecom-oa-user-consistency.test.js|server/package.json","[""WOUA-4""]","提交前确认不包含 Secret、token、手机号、邮箱、完整 userid 或企微原始 payload;本地门禁和公网只读审计证据已核对,中文提交引用 WOUA。" diff --git a/server/package.json b/server/package.json index 20de0396..dc7594ff 100644 --- a/server/package.json +++ b/server/package.json @@ -28,6 +28,7 @@ "job:workflow-overdue-reminders": "node src/scripts/run-workflow-overdue-reminders.js", "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", "test:watch": "vitest", "test:coverage": "vitest run --coverage" }, diff --git a/server/src/scripts/audit-wecom-oa-user-consistency.js b/server/src/scripts/audit-wecom-oa-user-consistency.js new file mode 100644 index 00000000..67341a93 --- /dev/null +++ b/server/src/scripts/audit-wecom-oa-user-consistency.js @@ -0,0 +1,552 @@ +#!/usr/bin/env node +require("dotenv/config"); + +const fs = require("node:fs"); +const path = require("node:path"); +const { Pool } = require("pg"); + +const { getConfig } = require("../config"); +const { + WecomHrMasterSyncError, + fetchWecomHrProfiles, + maskExternalUserId, +} = require("../services/integrations/wecomHrMaster"); + +const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow"); +const DEFAULT_LIMIT = 200; +const MAX_REPORT_ITEMS = 200; + +function printHelp() { + process.stdout.write(`Usage: node src/scripts/audit-wecom-oa-user-consistency.js [options] + +只读对账 OA active 员工与企业微信通讯录 + 人事助手资料。 +脚本不会调用 WHMS dry-run/apply API,不会写 OA/企微用户数据。 + +Options: + --live 启用企微 live 只读读取;未指定时输出 LIVE_DISABLED SKIP。 + --format=text|json 输出格式,默认 text。 + --limit= 企微通讯录读取上限,默认 200。 + --report-dir= 报告目录,默认 artifacts/qa-flow/wecom-oa-user-consistency-。 + --help 显示帮助。 + +隐私边界: + 输出只包含员工号、状态、角色摘要、脱敏企微 userid 前缀、计数和问题类型。 + 不输出手机号、邮箱、密码哈希、token、secret、完整企微 userid 或原始 payload。 +`); +} + +function timestamp() { + return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} + +function parseArgs(argv) { + const options = { + live: process.env.WOUA_LIVE_READ === "1", + format: "text", + limit: Number(process.env.WOUA_LIMIT || DEFAULT_LIMIT), + reportDir: process.env.WOUA_REPORT_DIR || path.join(DEFAULT_REPORT_ROOT, `wecom-oa-user-consistency-${timestamp()}`), + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") { + options.help = true; + } else if (arg === "--live") { + options.live = true; + } else if (arg === "--no-live") { + options.live = false; + } else if (arg === "--format") { + options.format = argv[index + 1] || options.format; + index += 1; + } else if (arg.startsWith("--format=")) { + options.format = arg.slice("--format=".length); + } else if (arg === "--limit") { + options.limit = Number(argv[index + 1] || options.limit); + index += 1; + } else if (arg.startsWith("--limit=")) { + options.limit = Number(arg.slice("--limit=".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 { + throw new Error(`Unsupported argument: ${arg}`); + } + } + + if (!["text", "json"].includes(options.format)) { + throw new Error(`Unsupported --format: ${options.format}`); + } + options.limit = Number.isFinite(options.limit) + ? Math.max(1, Math.min(200, Math.floor(options.limit))) + : DEFAULT_LIMIT; + return options; +} + +function normalizeKey(value) { + const raw = String(value ?? "").trim(); + return raw ? raw.toLowerCase() : ""; +} + +function safeMessage(value) { + return String(value ?? "") + .replace(/access_token=[^&\s]+/gi, "access_token=***") + .replace(/[A-Za-z0-9_-]{24,}/g, "***") + .slice(0, 180); +} + +function isOaActive(employee) { + return employee.status === "active" && !employee.archived; +} + +function isWecomActive(profile) { + return profile.sourceStatus === "active"; +} + +function sanitizeRoles(roles) { + return Array.isArray(roles) ? roles.filter((role) => typeof role === "string").sort() : []; +} + +function sanitizeEmployee(employee) { + if (!employee) return null; + return { + employeeNo: employee.employeeNo, + status: employee.status, + archived: Boolean(employee.archived), + roles: sanitizeRoles(employee.roles), + }; +} + +function sanitizeWecomProfile(profile) { + if (!profile) return null; + return { + externalUserIdPrefix: maskExternalUserId(profile.externalUserId), + sourceStatus: profile.sourceStatus, + }; +} + +function sanitizeMatched(employee, profile, matchedBy) { + return { + employee: sanitizeEmployee(employee), + wecom: sanitizeWecomProfile(profile), + matchedBy, + }; +} + +function addToIndex(index, key, employee) { + if (!key) return; + const bucket = index.get(key) || []; + bucket.push(employee); + index.set(key, bucket); +} + +function buildOaIndexes(employees) { + const byBinding = new Map(); + const byEmployeeNo = new Map(); + const byPhone = new Map(); + const byEmail = new Map(); + + for (const employee of employees) { + addToIndex(byBinding, normalizeKey(employee.wecomExternalUserId), employee); + addToIndex(byEmployeeNo, normalizeKey(employee.employeeNo), employee); + addToIndex(byPhone, normalizeKey(employee.phone), employee); + addToIndex(byEmail, normalizeKey(employee.email), employee); + } + + return { byBinding, byEmployeeNo, byPhone, byEmail }; +} + +function firstIndexMatch(index, key) { + const bucket = key ? index.get(key) || [] : []; + if (bucket.length === 1) return { employee: bucket[0], duplicate: false }; + if (bucket.length > 1) return { employee: null, duplicate: true, employees: bucket }; + return { employee: null, duplicate: false }; +} + +function collectMatchCandidates(profile, indexes) { + const externalIdKey = normalizeKey(profile.externalUserId); + const checks = [ + ["binding_external_user_id", indexes.byBinding, externalIdKey], + ["employee_no_or_userid", indexes.byEmployeeNo, externalIdKey], + ["phone", indexes.byPhone, normalizeKey(profile.phone)], + ["email", indexes.byEmail, normalizeKey(profile.email)], + ]; + + const candidates = []; + const duplicateKeys = []; + for (const [method, index, key] of checks) { + if (!key) continue; + const match = firstIndexMatch(index, key); + if (match.duplicate) { + duplicateKeys.push({ method, count: match.employees.length }); + } else if (match.employee) { + candidates.push({ method, employee: match.employee }); + } + } + + const unique = new Map(); + for (const candidate of candidates) { + if (!unique.has(candidate.employee.id)) { + unique.set(candidate.employee.id, { employee: candidate.employee, methods: [candidate.method] }); + } else { + unique.get(candidate.employee.id).methods.push(candidate.method); + } + } + + return { unique: Array.from(unique.values()), duplicateKeys }; +} + +function buildConflictItem(profile, reason, details = {}) { + return { + reason, + wecom: sanitizeWecomProfile(profile), + ...details, + }; +} + +function sample(items) { + return items.slice(0, MAX_REPORT_ITEMS); +} + +function summarizeFindings(findings) { + const summary = {}; + for (const [key, items] of Object.entries(findings)) { + summary[key] = Array.isArray(items) ? items.length : 0; + } + return summary; +} + +function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new Date().toISOString(), limit = DEFAULT_LIMIT }) { + const findings = { + matchedActive: [], + oaActiveMissingInWecom: [], + wecomActiveMissingInOa: [], + oaActiveMissingBinding: [], + inactiveWithBinding: [], + statusMismatch: [], + missingRequiredFields: [], + matchConflict: [], + }; + const matchedOrConflictedEmployeeIds = new Set(); + const seenWecomIds = new Set(); + const indexes = buildOaIndexes(oaEmployees); + + for (const employee of oaEmployees) { + if (!isOaActive(employee) && employee.wecomExternalUserId) { + findings.inactiveWithBinding.push(sanitizeEmployee(employee)); + } + } + + for (const profile of wecomProfiles) { + const externalKey = normalizeKey(profile.externalUserId); + if (externalKey && seenWecomIds.has(externalKey)) { + findings.matchConflict.push(buildConflictItem(profile, "duplicate_wecom_external_user_id")); + continue; + } + if (externalKey) seenWecomIds.add(externalKey); + + const candidates = collectMatchCandidates(profile, indexes); + if (candidates.duplicateKeys.length) { + findings.matchConflict.push(buildConflictItem(profile, "duplicate_oa_match_key", { duplicateKeys: candidates.duplicateKeys })); + for (const candidate of candidates.unique) matchedOrConflictedEmployeeIds.add(candidate.employee.id); + continue; + } + if (candidates.unique.length > 1) { + findings.matchConflict.push(buildConflictItem(profile, "multiple_oa_candidates", { + candidates: candidates.unique.map((candidate) => ({ + employee: sanitizeEmployee(candidate.employee), + methods: candidate.methods, + })), + })); + for (const candidate of candidates.unique) matchedOrConflictedEmployeeIds.add(candidate.employee.id); + continue; + } + + const match = candidates.unique[0] || null; + if (!match) { + if (isWecomActive(profile)) { + findings.wecomActiveMissingInOa.push({ wecom: sanitizeWecomProfile(profile) }); + } + if (profile.missingRequiredFields?.length) { + findings.missingRequiredFields.push({ + employee: null, + wecom: sanitizeWecomProfile(profile), + fields: [...profile.missingRequiredFields].sort(), + }); + } + continue; + } + + const employee = match.employee; + matchedOrConflictedEmployeeIds.add(employee.id); + const matchedBy = match.methods[0]; + const oaActive = isOaActive(employee); + const wecomActive = isWecomActive(profile); + + if (oaActive && wecomActive) { + findings.matchedActive.push(sanitizeMatched(employee, profile, matchedBy)); + } + if (oaActive && !employee.wecomExternalUserId) { + findings.oaActiveMissingBinding.push(sanitizeMatched(employee, profile, matchedBy)); + } + if (oaActive !== wecomActive) { + findings.statusMismatch.push({ + ...sanitizeMatched(employee, profile, matchedBy), + oaActive, + wecomActive, + }); + } + if (profile.missingRequiredFields?.length) { + findings.missingRequiredFields.push({ + ...sanitizeMatched(employee, profile, matchedBy), + fields: [...profile.missingRequiredFields].sort(), + }); + } + } + + for (const employee of oaEmployees) { + if (isOaActive(employee) && !matchedOrConflictedEmployeeIds.has(employee.id)) { + findings.oaActiveMissingInWecom.push(sanitizeEmployee(employee)); + } + } + + const findingCounts = summarizeFindings(findings); + const findingTotal = Object.entries(findingCounts) + .filter(([key]) => key !== "matchedActive") + .reduce((total, [, count]) => total + count, 0); + + return { + mode: "read_only", + generatedAt, + status: findingTotal > 0 ? "PASS_WITH_FINDINGS" : "PASS_NO_DRIFT", + privacy: "employee_no/status/roles/masked wecom userid only; no phone/email/password/token/secret/full userid/raw payload", + scope: { + oa: "active employees and wecom identity bindings", + wecom: "contact members plus hr assistant staff info", + writeBehavior: "no OA writes, no WeCom writes, no WHMS dry-run/apply API call", + }, + inputs: { + limit, + mayBeTruncated: wecomProfiles.length >= limit, + }, + totals: { + oaEmployees: oaEmployees.length, + oaActiveEmployees: oaEmployees.filter(isOaActive).length, + oaActiveWithBinding: oaEmployees.filter((employee) => isOaActive(employee) && employee.wecomExternalUserId).length, + wecomProfiles: wecomProfiles.length, + wecomActiveProfiles: wecomProfiles.filter(isWecomActive).length, + }, + findingCounts, + findings: Object.fromEntries(Object.entries(findings).map(([key, value]) => [key, sample(value)])), + }; +} + +async function queryOaEmployees(client) { + const result = await client.query(` + SELECT + e.id::text, + e.employee_no, + e.name, + e.phone, + e.email, + e.status::text, + (e.archived_at IS NOT NULL) AS archived, + COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles, + w.external_user_id AS wecom_external_user_id + 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 + GROUP BY + e.id, + e.employee_no, + e.name, + e.phone, + e.email, + e.status, + e.archived_at, + w.external_user_id + ORDER BY e.employee_no ASC + `); + + return result.rows.map((row) => ({ + id: row.id, + employeeNo: row.employee_no, + name: row.name, + phone: row.phone, + email: row.email, + status: row.status, + archived: Boolean(row.archived), + roles: row.roles || [], + wecomExternalUserId: row.wecom_external_user_id || "", + })); +} + +async function queryOaEmployeesReadOnly() { + if (!process.env.DATABASE_URL) { + throw new Error("Missing DATABASE_URL"); + } + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + const client = await pool.connect(); + try { + await client.query("BEGIN READ ONLY"); + const employees = await queryOaEmployees(client); + await client.query("COMMIT"); + return employees; + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch { + // Keep the original error. + } + throw error; + } finally { + client.release(); + await pool.end(); + } +} + +function buildSkipReport({ generatedAt, reason, details, oaEmployees = [], limit }) { + const findings = { + matchedActive: [], + oaActiveMissingInWecom: [], + wecomActiveMissingInOa: [], + oaActiveMissingBinding: [], + inactiveWithBinding: [], + statusMismatch: [], + missingRequiredFields: [], + matchConflict: [], + }; + return { + mode: "read_only", + generatedAt, + status: "SKIP", + conclusion: reason, + privacy: "employee_no/status/roles only; no phone/email/password/token/secret/full userid/raw payload", + scope: { + oa: "active employees and wecom identity bindings", + wecom: "contact members plus hr assistant staff info", + writeBehavior: "no OA writes, no WeCom writes, no WHMS dry-run/apply API call", + }, + inputs: { limit }, + totals: { + oaEmployees: oaEmployees.length, + oaActiveEmployees: oaEmployees.filter(isOaActive).length, + oaActiveWithBinding: oaEmployees.filter((employee) => isOaActive(employee) && employee.wecomExternalUserId).length, + }, + findingCounts: summarizeFindings(findings), + findings, + details, + }; +} + +async function executeAudit(options) { + const generatedAt = new Date().toISOString(); + const oaEmployees = await queryOaEmployeesReadOnly(); + + if (!options.live) { + return buildSkipReport({ + generatedAt, + reason: "LIVE_DISABLED", + details: { runWith: "--live" }, + oaEmployees, + limit: options.limit, + }); + } + + try { + const config = getConfig(); + const { profiles, tokenSource } = await fetchWecomHrProfiles({ + config, + userids: [], + limit: options.limit, + }); + const report = buildConsistencyReport({ + oaEmployees, + wecomProfiles: profiles, + generatedAt, + limit: options.limit, + }); + return { + ...report, + tokenSource: { + contact: tokenSource?.contact || null, + hr: tokenSource?.hr || null, + }, + }; + } catch (error) { + const details = { + code: error?.code || (error instanceof WecomHrMasterSyncError ? "wecom_hr_master_sync_error" : "wecom_read_failed"), + message: safeMessage(error?.message || error), + }; + return buildSkipReport({ + generatedAt, + reason: "BLOCKED_BY_EXTERNAL_CONFIG", + details, + oaEmployees, + limit: options.limit, + }); + } +} + +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] status=${report.status}${report.conclusion ? ` conclusion=${report.conclusion}` : ""}\n`); + process.stdout.write(`[wecom-oa-user-consistency] report=${reportPath}\n`); + process.stdout.write(`[wecom-oa-user-consistency] privacy=${report.privacy}\n`); + if (report.totals) { + process.stdout.write(`[wecom-oa-user-consistency] totals=${JSON.stringify(report.totals)}\n`); + } + if (report.findingCounts) { + process.stdout.write(`[wecom-oa-user-consistency] findingCounts=${JSON.stringify(report.findingCounts)}\n`); + } + if (report.details) { + process.stdout.write(`[wecom-oa-user-consistency] details=${JSON.stringify(report.details)}\n`); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + const report = await executeAudit(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][FAIL] ${safeMessage(error?.message || error)}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + buildConsistencyReport, + collectMatchCandidates, + executeAudit, + isOaActive, + isWecomActive, + normalizeKey, + parseArgs, + sanitizeEmployee, + sanitizeWecomProfile, + summarizeFindings, +}; diff --git a/server/tests/unit/scripts/audit-wecom-oa-user-consistency.test.js b/server/tests/unit/scripts/audit-wecom-oa-user-consistency.test.js new file mode 100644 index 00000000..70e1de5d --- /dev/null +++ b/server/tests/unit/scripts/audit-wecom-oa-user-consistency.test.js @@ -0,0 +1,151 @@ +const { + buildConsistencyReport, +} = require("../../../src/scripts/audit-wecom-oa-user-consistency"); + +function employee(overrides = {}) { + return { + id: overrides.id ?? "employee-1", + employeeNo: overrides.employeeNo ?? "E001", + name: overrides.name ?? "员工一", + phone: overrides.phone ?? "13800000001", + email: overrides.email ?? "employee1@example.com", + status: overrides.status ?? "active", + archived: overrides.archived ?? false, + roles: overrides.roles ?? ["employee"], + wecomExternalUserId: overrides.wecomExternalUserId ?? "wecom_user_001_very_long", + }; +} + +function profile(overrides = {}) { + return { + externalUserId: overrides.externalUserId ?? "wecom_user_001_very_long", + name: overrides.name ?? "员工一", + phone: overrides.phone ?? "13800000001", + email: overrides.email ?? "employee1@example.com", + title: overrides.title ?? "工程师", + departmentExternalId: overrides.departmentExternalId ?? "1", + departmentName: overrides.departmentName ?? "研发部", + sourceStatus: overrides.sourceStatus ?? "active", + missingRequiredFields: overrides.missingRequiredFields ?? [], + }; +} + +function count(report, key) { + return report.findingCounts[key]; +} + +describe("audit-wecom-oa-user-consistency", () => { + it("reports no drift when OA active employee matches WeCom active profile by binding", () => { + const report = buildConsistencyReport({ + oaEmployees: [employee()], + wecomProfiles: [profile()], + generatedAt: "2026-05-18T00:00:00.000Z", + limit: 200, + }); + + expect(report.status).toBe("PASS_NO_DRIFT"); + expect(count(report, "matchedActive")).toBe(1); + expect(count(report, "oaActiveMissingInWecom")).toBe(0); + expect(count(report, "wecomActiveMissingInOa")).toBe(0); + expect(count(report, "matchConflict")).toBe(0); + }); + + it("classifies OA active employees missing from WeCom", () => { + const report = buildConsistencyReport({ + oaEmployees: [employee({ employeeNo: "E002", wecomExternalUserId: "" })], + wecomProfiles: [], + }); + + expect(report.status).toBe("PASS_WITH_FINDINGS"); + expect(count(report, "oaActiveMissingInWecom")).toBe(1); + expect(report.findings.oaActiveMissingInWecom[0]).toMatchObject({ + employeeNo: "E002", + status: "active", + }); + }); + + it("classifies WeCom active profiles missing from OA", () => { + const report = buildConsistencyReport({ + oaEmployees: [], + wecomProfiles: [profile({ externalUserId: "wecom_user_new_very_long" })], + }); + + expect(report.status).toBe("PASS_WITH_FINDINGS"); + expect(count(report, "wecomActiveMissingInOa")).toBe(1); + expect(report.findings.wecomActiveMissingInOa[0].wecom.externalUserIdPrefix).toBe("wecom_us***"); + }); + + it("classifies missing required WeCom fields without exposing phone or email", () => { + const report = buildConsistencyReport({ + oaEmployees: [employee()], + wecomProfiles: [ + profile({ + phone: "", + email: "", + departmentExternalId: "", + departmentName: "", + missingRequiredFields: ["phone", "email", "department"], + }), + ], + }); + const serialized = JSON.stringify(report); + + expect(report.status).toBe("PASS_WITH_FINDINGS"); + expect(count(report, "missingRequiredFields")).toBe(1); + expect(report.findings.missingRequiredFields[0].fields).toEqual(["department", "email", "phone"]); + expect(serialized).not.toContain("13800000001"); + expect(serialized).not.toContain("employee1@example.com"); + expect(serialized).not.toContain("wecom_user_001_very_long"); + }); + + it("classifies inactive OA employees that still keep a WeCom binding", () => { + const report = buildConsistencyReport({ + oaEmployees: [ + employee({ + employeeNo: "HRWDTEST-20260517214548", + status: "archived", + archived: true, + wecomExternalUserId: "HRWDTEST-20260517214548", + }), + ], + wecomProfiles: [], + }); + + expect(report.status).toBe("PASS_WITH_FINDINGS"); + expect(count(report, "inactiveWithBinding")).toBe(1); + expect(report.findings.inactiveWithBinding[0].employeeNo).toBe("HRWDTEST-20260517214548"); + }); + + it("detects match conflicts across priority keys", () => { + const report = buildConsistencyReport({ + oaEmployees: [ + employee({ + id: "employee-by-no", + employeeNo: "wecom_user_conflict_very_long", + phone: "13800000001", + email: "by-no@example.com", + wecomExternalUserId: "", + }), + employee({ + id: "employee-by-phone", + employeeNo: "E003", + phone: "13900000002", + email: "by-phone@example.com", + wecomExternalUserId: "", + }), + ], + wecomProfiles: [ + profile({ + externalUserId: "wecom_user_conflict_very_long", + phone: "13900000002", + email: "new@example.com", + }), + ], + }); + + expect(report.status).toBe("PASS_WITH_FINDINGS"); + expect(count(report, "matchConflict")).toBe(1); + expect(report.findings.matchConflict[0]).toMatchObject({ reason: "multiple_oa_candidates" }); + expect(report.findings.matchConflict[0].candidates).toHaveLength(2); + }); +});