WOUA 增加 OA 与企微用户只读对账
这个提交包含在:
@@ -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 预检推进
|
||||
|
||||
|
||||
+8
-7
@@ -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 验证;测试成员按审计要求保留离职/禁用状态。
|
||||
|
||||
+4
-2
@@ -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、附件细则 |
|
||||
|
||||
@@ -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 [email protected] 在 /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。"
|
||||
|
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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=<n> 企微通讯录读取上限,默认 200。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-oa-user-consistency-<timestamp>。
|
||||
--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,
|
||||
};
|
||||
@@ -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 ?? "[email protected]",
|
||||
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 ?? "[email protected]",
|
||||
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("[email protected]");
|
||||
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: "[email protected]",
|
||||
wecomExternalUserId: "",
|
||||
}),
|
||||
employee({
|
||||
id: "employee-by-phone",
|
||||
employeeNo: "E003",
|
||||
phone: "13900000002",
|
||||
email: "[email protected]",
|
||||
wecomExternalUserId: "",
|
||||
}),
|
||||
],
|
||||
wecomProfiles: [
|
||||
profile({
|
||||
externalUserId: "wecom_user_conflict_very_long",
|
||||
phone: "13900000002",
|
||||
email: "[email protected]",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户