P2D-CORR/P2-ORG 收口只读主数据巡检
这个提交包含在:
@@ -17,6 +17,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
||||
- ORG8 组织账号与权限主数据已完成本地代表性角色验收和阶段收口;后续组织主数据治理只作为 P2 只读巡检候选,不自动进入写修复。
|
||||
- 2026-05-16 已建立并细化 QAOPS9 上线硬化连续合同和 P2D 功能决策合同;QAOPS9 固定隔离本地业务流与公网 flag-off 证据分离,P2D 固定 `GO_TO_CONTRACT`、`HOLD_FOR_PREREQ`、`FREEZE` 三类结论;本次只更新 Issue CSV、既有事实源和 QA 脚本过滤参数,未改业务代码、API/schema、feature flag 或前端功能,未部署,公网运行状态不变。
|
||||
- 2026-05-16 QAOPS9 已完成隔离核心业务流稳定化、恢复演练 dry-run checklist、公网 live smoke、真实 OCR 和公网移动矩阵复核;`pending OCR` 因本地未注入 `DATABASE_URL` 继续按规则记非阻断 `SKIP`。P2D 已完成分流:`P2-ORG` 只读巡检可进入独立合同,其余 P2 候选先保持前置条件等待。
|
||||
- 2026-05-16 P2D 旧口径已修正:live smoke 凭据、员工 OCR 凭据和授权 PDF 样票已具备,不再作为 P2-FIN/P2-OCR 的 HOLD 原因。P2-ORG 已完成只读巡检:当前连接数据源 high=0、medium=0、low=9,建议 `NO_WRITE_FIX_NEEDED`;低风险项为 CEO/root 无直属主管观察 1 和 active 员工无企业微信绑定 8。
|
||||
|
||||
## 关键证据索引
|
||||
|
||||
@@ -41,6 +42,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
||||
| FBA | R7E 后全端功能边界审计已收口;只作为功能边界和 P2 候选证据 | `issues/2026-05-15_23-45-41-r7e-post-function-boundary-audit.csv`、`docs/trialrun/R7E后功能边界审计.md` |
|
||||
| QAOPS9 | 已收口:隔离核心业务流、恢复演练 dry-run、公网 live smoke、真实 OCR 和公网移动矩阵均已形成证据;`pending OCR` 缺 `DATABASE_URL` 为非阻断 SKIP | `issues/2026-05-16_00-19-20-qaops9-release-hardening.csv`、`artifacts/qa-flow/business-flow-20260516-012215/summary.txt`、`artifacts/qa-flow/live-smoke-readiness-20260516-013727/summary.json` |
|
||||
| P2D | 已完成决策分流:`P2-ORG` 只读巡检可进入独立合同;付款批次、图片 OCR、UX8、审批通知和轻业务增强均需前置条件;冻结项继续冻结 | `issues/2026-05-16_00-19-21-p2-decision-followup.csv` |
|
||||
| P2-ORG | 已收口:组织账号主数据只读巡检完成,高/中风险为 0,低风险观察 9,不需要写修复合同 | `issues/2026-05-16_02-04-27-p2-org-readonly-master-data-audit.csv`、`artifacts/qa-flow/p2-org-readonly-audit-20260516-020427/report.json` |
|
||||
|
||||
## 当前门禁
|
||||
|
||||
@@ -55,6 +57,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
||||
| QAOPS9 核心业务流 | 通过 | 隔离临时库业务流 `reimbursement/approval/leave/procurement/governance` 通过;付款批次 spec 已排除,报告 `artifacts/qa-flow/business-flow-20260516-012215/summary.txt` |
|
||||
| QAOPS9 live readiness | 通过,含非阻断 pending OCR SKIP | `/ready`、WeCom config、public bundle 通过;公网 live smoke 3 passed,真实 OCR 1 passed;pending OCR 因本地未注入 `DATABASE_URL` 记录 SKIP,报告 `artifacts/qa-flow/live-smoke-readiness-20260516-013727/` |
|
||||
| QAOPS9 公网移动矩阵 | 通过 | `E2E_BASE_URL=https://oa.hyinda.com npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list`,6 passed |
|
||||
| P2-ORG 只读巡检 | 通过,含低风险观察项 | `server/src/scripts/audit-org-master-data.js` 在 READ ONLY 事务中执行;当前连接数据源 high=0、medium=0、low=9,未输出手机号、邮箱、token 或企业微信真实标识,报告 `artifacts/qa-flow/p2-org-readonly-audit-20260516-020427/` |
|
||||
|
||||
## R7EP / OPS8 / QAF8 / UX8 / P2T 预检推进
|
||||
|
||||
|
||||
+4
-3
@@ -38,6 +38,7 @@
|
||||
| `FBA` | `issues/2026-05-15_23-45-41-r7e-post-function-boundary-audit.csv` | `EVIDENCE` | R7E 后全端功能边界审计和后续 Issue 草案;不启动 P2 功能开发 |
|
||||
| `QAOPS9` | `issues/2026-05-16_00-19-20-qaops9-release-hardening.csv` | `EVIDENCE` | R7E 后上线硬化连续合同已收口;核心业务流、live smoke、真实 OCR、移动矩阵和恢复演练 dry-run 均有证据,未部署新 commit |
|
||||
| `P2D` | `issues/2026-05-16_00-19-21-p2-decision-followup.csv` | `EVIDENCE` | P2 功能决策分流已完成:`P2-ORG` 只读巡检可进独立合同,其余 P2 候选保持 `HOLD_FOR_PREREQ`,冻结项继续冻结 |
|
||||
| `P2-ORG` | `issues/2026-05-16_02-04-27-p2-org-readonly-master-data-audit.csv` | `EVIDENCE` | 组织账号主数据只读巡检已完成;当前连接数据源高/中风险为 0,仅有低风险观察项,不需要写修复合同 |
|
||||
|
||||
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
|
||||
|
||||
@@ -45,8 +46,8 @@
|
||||
|
||||
| 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` | `rg` | none | 2026-05-16 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 |
|
||||
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D` | `audit-docs` | none | 2026-05-16 | `audit-docs --check-registry` | medium | 判断文档权威性 |
|
||||
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG` | `rg` | none | 2026-05-16 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 |
|
||||
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG` | `audit-docs` | none | 2026-05-16 | `audit-docs --check-registry` | medium | 判断文档权威性 |
|
||||
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8` | Issue CSV | `docs/requirements-specification.md` | 2026-05-13 | `rg` | high | 定义做什么、不做什么和验收口径 |
|
||||
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T` | `rg` | none | 2026-05-13 | `rg` | high | 记录已拍板产品边界 |
|
||||
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9` | Playwright/视觉审查/DG8/DG9 | none | 2026-05-13 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型 |
|
||||
@@ -151,4 +152,4 @@
|
||||
|
||||
- `ME6-3` 代表性企业微信真机门禁已关闭。
|
||||
- MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。
|
||||
- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口和 QAOPS9 上线硬化连续合同均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2 功能开发必须另建或延续对应连续合同。
|
||||
- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口和 QAOPS9 上线硬化连续合同均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同;P2 功能开发必须另建或延续对应连续合同。
|
||||
|
||||
+3
-1
@@ -20,7 +20,7 @@
|
||||
|
||||
## 最近事实源链
|
||||
|
||||
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D。
|
||||
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG。
|
||||
|
||||
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
|
||||
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
|
||||
@@ -32,6 +32,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
||||
- FBA 是 R7E 后全端功能边界审计,只提供证据和 Issue 草案,不启动 P2 功能开发。
|
||||
- QAOPS9 是 R7E 后上线硬化连续合同,优先处理公网业务流测试数据、live smoke 账号链路、恢复演练 dry-run 和 SLO/Go-No-Go 自动化增强;隔离本地业务流可使用临时库和测试开关,公网证据必须保持 flag-off 或 SKIP 可解释;不部署、不执行生产恢复、不重置数据库、不打印远端 env。
|
||||
- P2D 是 P2 功能决策合同,只决定组织主数据、付款批次、图片 OCR、UX8、审批通知和轻业务增强是否进入独立开发合同;统一使用 `GO_TO_CONTRACT`、`HOLD_FOR_PREREQ`、`FREEZE` 三类结论,不新增 API/schema、feature flag 或前端功能。
|
||||
- P2-ORG 是 P2D 后第一个执行合同,已完成组织账号主数据只读巡检;当前连接数据源下高/中风险为 0,低风险观察项为 CEO/root 无直属主管 1 和 active 员工无企业微信绑定 8;本轮不需要写修复合同。
|
||||
|
||||
## 当前活跃治理流
|
||||
|
||||
@@ -50,6 +51,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
||||
| `FBA` | `issues/2026-05-15_23-45-41-r7e-post-function-boundary-audit.csv` | 已收口:R7E 后全端功能边界审计和后续 Issue 草案 | PC、移动/企业微信、后端 API/作业、管理/运维边界审计 | 不改业务代码、不改 API/schema、不部署、不启动 P2 |
|
||||
| `QAOPS9` | `issues/2026-05-16_00-19-20-qaops9-release-hardening.csv` | 已收口:上线硬化证据完成 | 公网业务流测试数据稳定化、live smoke 账号链路、恢复演练 dry-run、SLO/Go-No-Go 自动化增强;隔离业务流与公网 flag-off 证据分离 | 不改业务需求、不部署、不执行生产恢复、不重置数据库、不打印远端 env |
|
||||
| `P2D` | `issues/2026-05-16_00-19-21-p2-decision-followup.csv` | 已收口:P2 决策分流完成 | `P2-ORG` 只读巡检进入独立合同;付款批次、图片 OCR、UX8、审批通知、轻业务增强均 `HOLD_FOR_PREREQ`;冻结 ERP/AI/完整差旅/复杂 BPM/审批设计器/重预算/企业微信原生审批 | 不新增 API/schema/feature flag/UI;不启用付款批次或图片 OCR |
|
||||
| `P2-ORG` | `issues/2026-05-16_02-04-27-p2-org-readonly-master-data-audit.csv` | 已收口:组织主数据只读巡检完成 | 主管链、停用审批人、角色持有人、admin-only、财务/admin 混授、企业微信绑定漂移的脱敏只读审计 | 不写生产账号数据、不执行 P2-ORG-FIX、不改 API/schema/UI、不部署 |
|
||||
|
||||
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
"P2D-0","P2 决策合同建档","建立 R7E 后 P2 决策合同,只做功能是否进入开发的决策、边界、先决条件、回滚口径和测试口径,不写业务代码。","1) CSV 校验通过;2) 文档索引和事实源矩阵登记 P2D;3) 不新增 API、schema、feature flag 或前端功能;4) 不启用 payment_batch_v1,不开放图片 OCR;5) 决策项统一使用 GO_TO_CONTRACT、HOLD_FOR_PREREQ、FREEZE。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-16_00-19-21-p2-decision-followup.csv;node scripts/qa/audit-docs.mjs --check-registry;git diff --check;人工核对 git diff 仅包含 issues/docs/scripts/qa。","python3|node|git|manual","DONE","DONE","DONE","issues/2026-05-16_00-19-21-p2-decision-followup.csv|docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/trialrun/人工试运行报告.md","[]","决策模板:Decision=<GO_TO_CONTRACT|HOLD_FOR_PREREQ|FREEZE>; Rationale=<证据和原因>; Prereq=<前置条件>; NextContract=<P2-FIN|P2-OCR|P2-ORG|P2-WF|P2-BIZ|UX8|N/A>; Rollback=<回滚或撤销口径>; TestScope=<验收口径>。"
|
||||
"P2D-1","组织主数据只读巡检决策","决定 P2-ORG 是否进入只读巡检合同,范围为直属主管缺失、主管或审批人停用、admin-only 误用、财务和管理员角色混授、企业微信绑定异常。","1) 只读巡检范围和查询边界清楚;2) 如需写修复必须另拆审批窗口;3) 不直接写生产账号数据;4) 巡检结果能映射到权限回归风险;5) 输出统一 Decision 模板。","受影响流程:员工提交审批后按主管链流转,审批人和财务账号保持 active,企业微信绑定可用于入口和 deeplink;验证步骤:只读 SQL 或脚本生成账号、角色、主管、审批人和企业微信绑定矩阵,生产写操作不在本决策内;结论写为 GO_TO_CONTRACT、HOLD_FOR_PREREQ 或 FREEZE。","manual|readonly-sql","DONE","DONE","DONE","docs/mcp-tools.md|docs/需求-企业微信基础设施规约.md|issues/2026-05-13_18-23-51-org8-master-data-permission.csv","[""P2D-0""]","Decision=GO_TO_CONTRACT; Rationale=ORG8 已完成本地代表性角色验收,但 R7E 后仍需要持续只读巡检主管链、停用审批人、admin-only、财务/admin 混授和 WeCom 绑定漂移; Prereq=只读数据源、查询脚本或只读 SQL、输出不含隐私明文; NextContract=P2-ORG; Rollback=只读合同无数据回滚,若后续写修复必须另批窗口并可撤销角色/主管变更; TestScope=账号/角色/主管/绑定矩阵、权限直连回归、生产写操作不纳入。"
|
||||
"P2D-2","付款批次启用决策","决定 P2-FIN 是否启用 payment_batch_v1,先锁角色边界、测试账号、公网验收窗口、导出审计和回滚方式。","1) 是否启用付款批次有明确结论;2) 角色矩阵覆盖财务成功、普通员工和 admin-only 拒绝;3) feature flag 和回滚路径明确;4) 不接真实支付或 ERP 实时凭证;5) 输出统一 Decision 模板。","受影响流程:已审批报销进入财务复核,财务创建付款批次、复核、导出和审计,普通员工和 admin-only 不能越权;验证步骤:决策阶段只核对现有 flag_force_off 证据、PRD/财务规约和测试账号前置,不执行付款批次;结论写为 GO_TO_CONTRACT、HOLD_FOR_PREREQ 或 FREEZE。","manual|rg","DONE","DONE","DONE","docs/海音达OA-MVP需求规格说明书.md|docs/报销发票附件规则.md|docs/trialrun/R7E后功能边界审计.md|issues/*.csv","[""P2D-0""]","Decision=HOLD_FOR_PREREQ; Rationale=R7E 明确 payment_batch_v1 关闭,移动端仅展示未启用空态,QAOPS9 live smoke 凭据和公网验收窗口仍缺失; Prereq=财务/复核角色测试账号、feature flag 启停方案、公网验收窗口、导出/审计字段、回滚到 flag off 的验证; NextContract=P2-FIN; Rollback=flag off、隐藏入口、保留已审计数据但不接真实支付; TestScope=财务成功、员工/admin-only 拒绝、导出权限、审计记录、flag off 空态。"
|
||||
"P2D-3","图片 OCR 与样票治理决策","决定 P2-OCR 是否进入图片 OCR 样票治理,先锁授权样票、脱敏、provider、字段归一化、PDF-only 兼容、feature flag 和失败回滚。","1) 图片 OCR 是否进入开发有明确结论;2) 样票授权和脱敏路径明确;3) mock 与真实 provider 验收分离;4) 无授权图片样票时只能 SKIP 不能 PASS;5) 输出统一 Decision 模板。","受影响流程:员工上传发票附件,PDF-only 流程不回归,图片样票进入 OCR 后能解释成功、失败、重复和人工复核;验证步骤:决策阶段只核对 OCR 规约、授权样票清单、腾讯云官方能力参考和 provider 前置,不调用真实图片 OCR;结论写为 GO_TO_CONTRACT、HOLD_FOR_PREREQ 或 FREEZE。","manual|rg","DONE","DONE","DONE","docs/需求-报销发票OCR规约.md|docs/报销发票附件规则.md|docs/trialrun/R7E后功能边界审计.md","[""P2D-0""]","Decision=HOLD_FOR_PREREQ; Rationale=腾讯云官方能力证明图片/PDF 技术可行,但当前 live OCR 基线仍为 PDF-only,QAOPS9 缺授权 PDF/图片样票和真实 OCR 凭据; Prereq=授权图片样票、脱敏规则、provider 回归账号、字段归一化、feature flag、PDF-only 回归; NextContract=P2-OCR; Rollback=关闭图片入口和 flag,保留 PDF-only;失败回人工复核; TestScope=PDF 不回归、图片 mock/真实 provider 分离、无授权样票记 SKIP 不记 PASS。"
|
||||
"P2D-2","付款批次启用决策","决定 P2-FIN 是否启用 payment_batch_v1,先锁角色边界、测试账号、公网验收窗口、导出审计和回滚方式。","1) 是否启用付款批次有明确结论;2) 角色矩阵覆盖财务成功、普通员工和 admin-only 拒绝;3) feature flag 和回滚路径明确;4) 不接真实支付或 ERP 实时凭证;5) 输出统一 Decision 模板。","受影响流程:已审批报销进入财务复核,财务创建付款批次、复核、导出和审计,普通员工和 admin-only 不能越权;验证步骤:决策阶段只核对现有 flag_force_off 证据、PRD/财务规约和测试账号前置,不执行付款批次;结论写为 GO_TO_CONTRACT、HOLD_FOR_PREREQ 或 FREEZE。","manual|rg","DONE","DONE","DONE","docs/海音达OA-MVP需求规格说明书.md|docs/报销发票附件规则.md|docs/trialrun/R7E后功能边界审计.md|issues/*.csv","[""P2D-0""]","Decision=HOLD_FOR_PREREQ; Rationale=R7E 明确 payment_batch_v1 关闭,移动端仅展示未启用空态;QAOPS9 live smoke 凭据已具备且不再作为阻断,当前 HOLD 原因为付款批次启用窗口、正式角色验收、flag 回滚和导出/审计方案未获批准; Prereq=财务/复核角色测试账号、feature flag 启停方案、公网验收窗口、导出/审计字段、回滚到 flag off 的验证; NextContract=P2-FIN; Rollback=flag off、隐藏入口、保留已审计数据但不接真实支付; TestScope=财务成功、员工/admin-only 拒绝、导出权限、审计记录、flag off 空态。"
|
||||
"P2D-3","图片 OCR 与样票治理决策","决定 P2-OCR 是否进入图片 OCR 样票治理,先锁授权样票、脱敏、provider、字段归一化、PDF-only 兼容、feature flag 和失败回滚。","1) 图片 OCR 是否进入开发有明确结论;2) 样票授权和脱敏路径明确;3) mock 与真实 provider 验收分离;4) 无授权图片样票时只能 SKIP 不能 PASS;5) 输出统一 Decision 模板。","受影响流程:员工上传发票附件,PDF-only 流程不回归,图片样票进入 OCR 后能解释成功、失败、重复和人工复核;验证步骤:决策阶段只核对 OCR 规约、授权样票清单、腾讯云官方能力参考和 provider 前置,不调用真实图片 OCR;结论写为 GO_TO_CONTRACT、HOLD_FOR_PREREQ 或 FREEZE。","manual|rg","DONE","DONE","DONE","docs/需求-报销发票OCR规约.md|docs/报销发票附件规则.md|docs/trialrun/R7E后功能边界审计.md","[""P2D-0""]","Decision=HOLD_FOR_PREREQ; Rationale=腾讯云官方能力证明图片/PDF 技术可行;QAOPS9 员工 OCR 凭据和授权 PDF 样票已具备且仅证明 PDF live OCR 基线,当前 HOLD 原因为图片 OCR 尚缺授权图片样票、脱敏规则确认、provider 图片回归和 feature flag 方案; Prereq=授权图片样票、脱敏规则、provider 回归账号、字段归一化、feature flag、PDF-only 回归; NextContract=P2-OCR; Rollback=关闭图片入口和 flag,保留 PDF-only;失败回人工复核; TestScope=PDF 不回归、图片 mock/真实 provider 分离、无授权样票记 SKIP 不记 PASS。"
|
||||
"P2D-4","UX8 反馈池承接决策","决定 UX8-1..5 是否继续保持真实反馈池或拆出具体体验修复,范围限移动和企业微信体验,不新增后端字段或业务域。","1) UX8-1..5 的保留、关闭或拆分理由清楚;2) 只有真实反馈或可复现问题才进入实现;3) 移动视口矩阵和真机差异验收口径明确;4) 不混入移动采购创建新能力;5) 输出统一 Decision 模板。","受影响流程:iPhone 12 间距、详情扫读、财务摘要、移动采购创建评估和列表卡片反馈;验证步骤:核对 UX8 CSV、代表设备反馈和 320/360/375/390/393/414/430 视口矩阵,未复现项保持观察;结论写为 GO_TO_CONTRACT、HOLD_FOR_PREREQ 或 FREEZE。","manual|playwright|rg","DONE","DONE","DONE","issues/2026-05-13_13-17-38-ux8-trialrun-feedback-polish.csv|docs/产品设计规则.md|output/playwright","[""P2D-0""]","Decision=HOLD_FOR_PREREQ; Rationale=UX8-1..5 是真实反馈池,当前无新的可复现阻断;移动采购创建属于新能力而非普通 UX 修复; Prereq=真实截图、设备型号、角色、页面、可复现步骤或明确频次; NextContract=UX8; Rollback=前端体验改动可按组件/样式回退,不改 API/schema; TestScope=320/360/375/390/393/414/430 视口、企业微信代表设备、对应角色截图。"
|
||||
"P2D-5","审批通知扩展与邮件通道决策","决定 P2-WF 是否推进转审扩展、通知模板、偏好设置或邮件通道;邮件必须先确认 SMTP、发件域、收件范围、退信处理和脱敏策略。","1) 审批通知扩展项有进入或冻结结论;2) 邮件通道前置条件明确;3) 企业微信个人应用消息仍是正式通知通道;4) 不进入复杂 BPM 或审批设计器;5) 输出统一 Decision 模板。","受影响流程:审批待办、催办、超时提醒、通知 deeplink、企业微信个人应用消息和可能的邮件兜底;验证步骤:决策阶段核对审批规约、企业微信规约和 WF8 证据,缺 SMTP 等正式配置时邮件记录 SKIP;结论写为 GO_TO_CONTRACT、HOLD_FOR_PREREQ 或 FREEZE。","manual|rg","DONE","DONE","DONE","docs/需求-统一审批中心规约.md|docs/需求-企业微信基础设施规约.md|issues/2026-05-13_18-23-52-wf8-approval-notification.csv","[""P2D-0""]","Decision=HOLD_FOR_PREREQ; Rationale=企业微信个人应用消息和 deeplink 已作为正式通道收口,转审/模板/偏好缺真实新反馈,邮件缺 SMTP、发件域、退信和脱敏方案; Prereq=明确通知痛点、模板清单、收件范围、SMTP/域名/退信配置;复杂 BPM 和审批设计器不进入; NextContract=P2-WF; Rollback=关闭新增模板/偏好/邮件 flag,保留现有企业微信和站内通知; TestScope=workflow 回归、通知 deeplink、WeCom 消息;邮件缺正式配置时 SKIP。"
|
||||
"P2D-6","轻业务增强决策","决定 P2-BIZ 是否评估供应商准入、轻量费用提示、项目费用或采购摘要、合同归档细化、资产盘点体验等轻增强。","1) 每个轻增强有业务频次、角色、验收和冻结结论;2) 不与付款批次、图片 OCR 或 UX 修复混同;3) 不启动 ERP、AI、完整差旅或重预算;4) 进入开发项另拆合同;5) 输出统一 Decision 模板。","受影响流程:采购申请、费用提示、项目关联、合同附件归档和资产盘点;验证步骤:决策阶段核对 PRD、对应模块规约、FBA 审计和真实反馈,不改数据模型;结论写为 GO_TO_CONTRACT、HOLD_FOR_PREREQ 或 FREEZE。","manual|rg","DONE","DONE","DONE","docs/海音达OA-MVP需求规格说明书.md|docs/需求-采购费用审批规约.md|docs/需求-合同附件归档规约.md|docs/需求-项目轻管理规约.md|docs/trialrun/R7E后功能边界审计.md","[""P2D-0""]","Decision=HOLD_FOR_PREREQ; Rationale=供应商准入、费用提示、项目采购摘要、合同归档和资产体验均属轻增强候选,但当前没有高频真实反馈或上线硬化依赖; Prereq=业务频次、角色 owner、验收样例和是否影响 schema/API; NextContract=P2-BIZ; Rollback=按独立模块 flag/路由/接口回退,禁止牵连付款批次或 OCR; TestScope=对应模块流程、权限边界、导出/审计影响;ERP、AI、完整差旅和重预算不纳入。"
|
||||
"P2D-7","冻结项确认与 P2 分流收口","确认 ERP 实时集成、AI 自动处理、完整差旅、复杂 BPM、审批设计器、重预算控制和企业微信原生审批继续冻结,并输出 P2 分流结论。","1) 冻结项清单与原因明确;2) 可进入开发的 P2 项有优先级和独立合同要求;3) 未进入项不从历史 TODO 自动复活;4) QAOPS9 优先级不被 P2 功能挤占;5) 汇总所有 Decision 模板。","受影响流程:全端功能边界和后续开发派工;验证步骤:validate_issues_csv.py 本 CSV;rg -n 'P2D|GO_TO_CONTRACT|HOLD_FOR_PREREQ|FREEZE|ERP|AI|差旅|复杂 BPM|审批设计器|重预算|企业微信原生审批|付款批次|图片 OCR|组织主数据|UX8' docs issues;人工核对分流结论。","python3|rg|manual","DONE","DONE","DONE","issues/2026-05-16_00-19-21-p2-decision-followup.csv|docs/文档索引.md|docs/文档事实源状态矩阵.md","[""P2D-1"",""P2D-2"",""P2D-3"",""P2D-4"",""P2D-5"",""P2D-6""]","Decision=GO_TO_CONTRACT for P2-ORG read-only only; HOLD_FOR_PREREQ for P2-FIN、P2-OCR、UX8、P2-WF、P2-BIZ; FREEZE for ERP 实时集成、AI 自动处理、完整差旅、复杂 BPM、审批设计器、重预算控制、企业微信原生审批和缺正式配置的邮件通道; Rationale=QAOPS9 硬化优先且 live smoke/OCR 前置未齐; Prereq=每个 HOLD 项满足账号、样票、反馈或配置后另建合同; NextContract=P2-ORG first, then approved P2-*; Rollback=未获 GO 的项不实现不启用; TestScope=validate CSV、rg 分流关键词、人工核对冻结边界。"
|
||||
"P2D-7","冻结项确认与 P2 分流收口","确认 ERP 实时集成、AI 自动处理、完整差旅、复杂 BPM、审批设计器、重预算控制和企业微信原生审批继续冻结,并输出 P2 分流结论。","1) 冻结项清单与原因明确;2) 可进入开发的 P2 项有优先级和独立合同要求;3) 未进入项不从历史 TODO 自动复活;4) QAOPS9 优先级不被 P2 功能挤占;5) 汇总所有 Decision 模板。","受影响流程:全端功能边界和后续开发派工;验证步骤:validate_issues_csv.py 本 CSV;rg -n 'P2D|GO_TO_CONTRACT|HOLD_FOR_PREREQ|FREEZE|ERP|AI|差旅|复杂 BPM|审批设计器|重预算|企业微信原生审批|付款批次|图片 OCR|组织主数据|UX8' docs issues;人工核对分流结论。","python3|rg|manual","DONE","DONE","DONE","issues/2026-05-16_00-19-21-p2-decision-followup.csv|docs/文档索引.md|docs/文档事实源状态矩阵.md","[""P2D-1"",""P2D-2"",""P2D-3"",""P2D-4"",""P2D-5"",""P2D-6""]","Decision=GO_TO_CONTRACT for P2-ORG read-only only; HOLD_FOR_PREREQ for P2-FIN、P2-OCR、UX8、P2-WF、P2-BIZ; FREEZE for ERP 实时集成、AI 自动处理、完整差旅、复杂 BPM、审批设计器、重预算控制、企业微信原生审批和缺正式配置的邮件通道; Rationale=QAOPS9 硬化证据已完成,live smoke、员工 OCR 凭据和授权 PDF 样票已具备;P2-FIN 仍需启用窗口/角色/flag/审计决策,P2-OCR 仍需授权图片样票和图片 provider 回归,其他 HOLD 项仍需真实反馈、配置或业务频次; Prereq=每个 HOLD 项满足账号、样票、反馈或配置后另建合同; NextContract=P2-ORG first, then approved P2-*; Rollback=未获 GO 的项不实现不启用; TestScope=validate CSV、rg 分流关键词、人工核对冻结边界。"
|
||||
|
||||
|
@@ -0,0 +1,11 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
"P2ORG-0","P2-ORG 只读巡检合同建档","建立 R7E 后组织账号主数据只读巡检合同,承接 P2D 中唯一 GO_TO_CONTRACT 项;本合同只做脱敏只读审计、影响分级和修复分流,不写生产账号数据。","1) CSV 校验通过;2) P2D 旧口径已修正;3) 文档索引和事实源矩阵登记 P2-ORG;4) 不新增业务 API、schema、feature flag 或前端功能;5) 不打印远端 env、手机号、邮箱、密码哈希、token 或企业微信真实标识。","受影响流程:员工提交审批后按主管链流转,审批人和财务账号保持 active,企业微信绑定可用于入口和 deeplink;验证步骤:validate_issues_csv.py 本 CSV;validate_issues_csv.py P2D CSV;rg 核对 P2D-CORR/P2-ORG/P2-FIN/P2-OCR 口径;git diff --check。","python3|rg|git|manual","DONE","DONE","DONE","issues/2026-05-16_02-04-27-p2-org-readonly-master-data-audit.csv|issues/2026-05-16_00-19-21-p2-decision-followup.csv|docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/trialrun/人工试运行报告.md","[]","完成:P2D-CORR 已修正 P2-FIN/P2-OCR/P2D-7 旧口径;本 CSV 校验通过;回滚路径为删除本 CSV 登记和相关索引回填;本行不改运行环境。"
|
||||
"P2ORG-1","只读审计脚本与输出口径","新增或确认只读审计脚本,统一输出组织账号、角色、主管链、审批待办和企业微信绑定的脱敏摘要。","1) 脚本只执行 SELECT 或 READ ONLY 事务;2) 输出只含员工号、状态、角色摘要、问题类型和计数;3) 不输出手机号、邮箱、密码哈希、token、外部企业微信 ID;4) 支持 text/json;5) 不作为应用运行时依赖。","受影响流程:组织主数据审计和后续修复分流;验证步骤:cd server && node --check src/scripts/audit-org-master-data.js;cd server && node src/scripts/audit-org-master-data.js --help;cd server && node src/scripts/audit-org-master-data.js --format=json。","node|manual","DONE","DONE","DONE","server/src/scripts/audit-org-master-data.js|server/package.json|artifacts/qa-flow","[""P2ORG-0""]","完成:新增 npm script audit:org-master-data 和 src/scripts/audit-org-master-data.js;脚本使用 PostgreSQL BEGIN READ ONLY,只输出 employee_no/status/roles/counts;node --check、--help、json/text 执行通过;报告 artifacts/qa-flow/p2-org-readonly-audit-20260516-020427/。"
|
||||
"P2ORG-2","主管链只读审计","审计启用员工直属主管缺失、主管停用、主管自指和主管链循环,识别会阻断审批提交或造成自审的主数据风险。","1) 缺直属主管员工有脱敏摘要;2) 主管停用或归档能识别;3) 自指和循环链可识别;4) CEO/root 观察项与员工阻断项分开;5) 不写修复。","受影响流程:报销、请假、采购提交后默认进入直属主管审批;验证步骤:运行 P2ORG-1 脚本并核对 missingDirectManager、disabledManager、selfManagerLoop、managerChainCycle 分组;人工判断是否需要 P2-ORG-FIX。","node|manual","DONE","DONE","DONE","server/src/scripts/audit-org-master-data.js|artifacts/qa-flow","[""P2ORG-1""]","完成:当前 DATABASE_URL 巡检 missingDirectManager=0、disabledManager=0、selfManagerLoop=0、managerChainCycle=0;rootNoManager=1 为 CEO/root 观察项;未写 employee.direct_manager_id。"
|
||||
"P2ORG-3","停用审批人和角色持有人审计","审计 pending 待办是否分配给停用/归档审批人,以及审批角色是否没有 active 持有人。","1) 停用审批人 pending 待办可计数;2) assignee_role 无 active 持有人可计数;3) 审批历史不被修改;4) 不重派待办;5) 结果映射到流程影响。","受影响流程:审批人处理 PC/mobile/企业微信 deeplink 待办;验证步骤:运行 P2ORG-1 脚本并核对 pendingInactiveAssignees、pendingRoleWithoutActiveHolder、configuredRoleWithoutActiveHolder 分组。","node|manual","DONE","DONE","DONE","server/src/scripts/audit-org-master-data.js|artifacts/qa-flow","[""P2ORG-1""]","完成:pendingInactiveAssignees=0、pendingRoleWithoutActiveHolder=0、configuredRoleWithoutActiveHolder=0;角色 active holder counts 为 manager=1、ceo=1、finance=2、finance_mgr=1、hr=1、administration=1;未改 workflow_tasks。"
|
||||
"P2ORG-4","admin-only 与财务管理员混授审计","审计仅具备系统管理权限的账号是否承担业务审批,以及财务角色和系统管理员角色是否混授。","1) admin-only pending 待办为 0 或有问题摘要;2) 启用审批规则不得使用 admin 作为业务审批角色;3) 财务/admin 混授账号有摘要;4) 不导出敏感字段;5) 给出风险等级。","受影响流程:业务审批、财务复核、系统管理最小权限;验证步骤:运行 P2ORG-1 脚本并核对 adminOnlyPendingTasks、approvalRulesUseAdmin、financeAdminMixedRoles 分组。","node|manual","DONE","DONE","DONE","server/src/scripts/audit-org-master-data.js|artifacts/qa-flow","[""P2ORG-1""]","完成:adminOnlyPendingTasks=0、approvalRulesUseAdmin=0、financeAdminMixedRoles=0;admin 仍只代表系统管理权限,不自动获得业务审批权。"
|
||||
"P2ORG-5","企业微信绑定异常审计","审计企业微信绑定与 OA 员工主数据是否漂移,区分未绑定观察项、停用账号仍绑定和重复/异常绑定。","1) active 员工未绑定可作为观察项计数;2) 停用/归档账号仍绑定可识别;3) 不输出 external_user_id 或 open_user_id;4) 企业微信只作为入口、身份和通知;5) 不修改绑定。","受影响流程:企业微信入口免登、个人应用消息、deeplink 回 OA;验证步骤:运行 P2ORG-1 脚本并核对 activeWithoutWecomBinding、inactiveWithWecomBinding、bindingSummary 分组。","node|manual","DONE","DONE","DONE","server/src/scripts/audit-org-master-data.js|artifacts/qa-flow","[""P2ORG-1""]","完成:activeEmployees=8、activeWithBinding=0、activeWithoutWecomBinding=8、inactiveWithWecomBinding=0;当前连接数据源未绑定企业微信记录为低风险观察项,不阻断 PC/OA 审批链;未输出 external_user_id/open_user_id,未修改绑定。"
|
||||
"P2ORG-6","流程影响分级与修复分流","将只读巡检结果按阻断、高、中、低分级,并决定是否需要新建 P2-ORG-FIX 写修复合同。","1) 每类发现有数量、影响流程和建议 owner;2) 写修复必须另批窗口;3) 无问题时明确无需修复合同;4) 付款批次和图片 OCR 不因本行自动启动;5) 结果回填 CSV Notes。","受影响流程:审批提交、审批待办、财务复核、企业微信入口和后续 P2 功能准备;验证步骤:人工核对 P2ORG-1 JSON 报告 summary 和 findings;如 high/medium 问题非 0,记录 P2-ORG-FIX 候选;否则记录 no-write-fix-needed。","manual|node","DONE","DONE","DONE","issues/2026-05-16_02-04-27-p2-org-readonly-master-data-audit.csv|artifacts/qa-flow","[""P2ORG-2"",""P2ORG-3"",""P2ORG-4"",""P2ORG-5""]","完成:report.json summary 为 high=0、medium=0、low=9、recommendation=NO_WRITE_FIX_NEEDED;低风险项为 CEO/root 无直属主管观察 1 和当前连接数据源 active 员工无企业微信绑定 8;本轮不需要 P2-ORG-FIX,不启动付款批次或图片 OCR。"
|
||||
"P2ORG-7","权限和审批门禁复核","复核只读巡检脚本没有改变现有权限、审批和 Prisma schema 门禁。","1) Prisma schema validate 通过;2) 审计脚本语法通过;3) 必要时补 workflow/权限回归;4) 无业务代码改动;5) 不部署。","受影响流程:组织权限、审批分配、财务权限和企业微信入口;验证步骤:cd server && npm run prisma:validate;cd server && node --check src/scripts/audit-org-master-data.js;必要时执行 server/scripts/regression-workflow-actions.sh 或权限直连回归。","npm|node|manual","DONE","DONE","DONE","server/prisma/schema.prisma|server/src/scripts/audit-org-master-data.js|server/package.json","[""P2ORG-1""]","完成:cd server && npm run prisma:validate 通过;node --check 脚本通过;本轮只新增 QA 只读脚本和 package script,未改 server/src 业务代码、client/src、Prisma schema 或权限模型,因此未跑完整 workflow 业务回归。"
|
||||
"P2ORG-8","索引和运行报告回填","将 P2-ORG 合同、只读证据路径和后续分流结论回写既有索引、事实源矩阵和人工试运行报告。","1) 不新建综合规划文档;2) docs/文档索引.md 登记 P2-ORG;3) docs/文档事实源状态矩阵.md 登记 P2-ORG;4) docs/trialrun/人工试运行报告.md 补充证据摘要;5) 不改 PRD 口径。","受影响流程:后续开发读取当前事实源和审计证据;验证步骤:rg -n 'P2-ORG|组织主数据|只读巡检|P2-ORG-FIX|P2D-CORR' docs issues;node scripts/qa/audit-docs.mjs --check-registry。","rg|node|manual","DONE","DONE","DONE","docs/文档索引.md|docs/文档事实源状态矩阵.md|docs/trialrun/人工试运行报告.md","[""P2ORG-6""]","完成:已回写文档索引、事实源矩阵和人工试运行报告;只登记入口、证据和状态,不新建规划文档,不改 PRD 口径。"
|
||||
"P2ORG-9","P2-ORG 阶段收口","完成 P2D-CORR 和 P2-ORG 只读巡检状态回填、校验和中文提交。","1) P2D CSV 校验通过;2) P2-ORG CSV 校验通过;3) audit-docs errors=0;4) git diff --check 通过;5) 中文 commit 引用 P2D-CORR/P2-ORG;6) 明确未部署。","受影响流程:P2 后续工作派工和组织账号治理闭环;验证步骤:validate_issues_csv.py P2D CSV;validate_issues_csv.py 本 CSV;node scripts/qa/audit-docs.mjs --check-registry;rg 核对关键词;git diff --check;git status --short。","python3|node|rg|git|manual","DONE","DONE","DONE","issues/2026-05-16_00-19-21-p2-decision-followup.csv|issues/2026-05-16_02-04-27-p2-org-readonly-master-data-audit.csv|docs|server/src/scripts/audit-org-master-data.js|server/package.json","[""P2ORG-8""]","完成:P2D CSV 与本 CSV 校验通过;audit-docs errors=0 warnings=1,warning 为既有 unregistered docs files=24;rg 关键词核对和 git diff --check 通过;未部署、未写生产数据、未启用付款批次或图片 OCR。"
|
||||
|
@@ -27,6 +27,7 @@
|
||||
"job:verification-once": "node src/scripts/run-verification-once.js",
|
||||
"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",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const { Pool } = require("pg");
|
||||
|
||||
const BUSINESS_ROLES = new Set(["employee", "manager", "finance", "finance_mgr", "hr", "administration"]);
|
||||
const APPROVAL_ROLES = ["manager", "ceo", "finance", "finance_mgr", "hr", "administration"];
|
||||
const ADMIN_BUSINESS_ROLES = new Set(["manager", "ceo", "finance", "finance_mgr", "hr", "administration"]);
|
||||
const REPORT_LIMIT = 50;
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/audit-org-master-data.js [options]
|
||||
|
||||
只读巡检组织账号主数据、主管链、审批待办、角色边界和企业微信绑定漂移。
|
||||
脚本在 PostgreSQL READ ONLY 事务中执行,不修改业务数据。
|
||||
|
||||
Options:
|
||||
--format=text|json 输出格式,默认 text
|
||||
--help 显示帮助
|
||||
|
||||
输出脱敏规则:
|
||||
只输出员工号、状态、角色摘要、问题类型和计数。
|
||||
不输出手机号、邮箱、密码哈希、token、企业微信 external_user_id/open_user_id。
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { format: "text", help: false };
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--format=")) {
|
||||
options.format = arg.slice("--format=".length).trim() || "text";
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
|
||||
if (!["text", "json"].includes(options.format)) {
|
||||
throw new Error(`Unsupported --format: ${options.format}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function isActive(employee) {
|
||||
return employee.status === "active" && !employee.archived;
|
||||
}
|
||||
|
||||
function hasAnyRole(employee, roles) {
|
||||
return (employee.roles ?? []).some((role) => roles.has(role));
|
||||
}
|
||||
|
||||
function isAdminOnly(employee) {
|
||||
const roles = employee.roles ?? [];
|
||||
return roles.includes("admin") && !roles.some((role) => ADMIN_BUSINESS_ROLES.has(role));
|
||||
}
|
||||
|
||||
function sanitizeEmployee(employee) {
|
||||
return {
|
||||
employeeNo: employee.employee_no,
|
||||
status: employee.status,
|
||||
archived: Boolean(employee.archived),
|
||||
roles: employee.roles ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function sample(items) {
|
||||
return items.slice(0, REPORT_LIMIT);
|
||||
}
|
||||
|
||||
function buildManagerChainFindings(employees) {
|
||||
const byId = new Map(employees.map((employee) => [employee.id, employee]));
|
||||
const missingDirectManager = [];
|
||||
const rootNoManager = [];
|
||||
const disabledManager = [];
|
||||
const selfManagerLoop = [];
|
||||
const managerChainCycle = [];
|
||||
|
||||
for (const employee of employees) {
|
||||
if (!isActive(employee)) continue;
|
||||
|
||||
const roles = employee.roles ?? [];
|
||||
const isRootLike = roles.includes("ceo");
|
||||
const isBusinessSubject = hasAnyRole(employee, BUSINESS_ROLES);
|
||||
|
||||
if (!employee.direct_manager_id) {
|
||||
if (isRootLike) {
|
||||
rootNoManager.push(sanitizeEmployee(employee));
|
||||
} else if (isBusinessSubject) {
|
||||
missingDirectManager.push(sanitizeEmployee(employee));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (employee.direct_manager_id === employee.id) {
|
||||
selfManagerLoop.push(sanitizeEmployee(employee));
|
||||
}
|
||||
|
||||
const manager = byId.get(employee.direct_manager_id);
|
||||
if (manager && !isActive(manager)) {
|
||||
disabledManager.push({
|
||||
...sanitizeEmployee(employee),
|
||||
managerEmployeeNo: manager.employee_no,
|
||||
managerStatus: manager.status,
|
||||
managerArchived: Boolean(manager.archived),
|
||||
});
|
||||
}
|
||||
|
||||
const visited = new Set([employee.id]);
|
||||
const chain = [employee.employee_no];
|
||||
let current = manager;
|
||||
let depth = 0;
|
||||
|
||||
while (current && current.direct_manager_id && depth < 50) {
|
||||
chain.push(current.employee_no);
|
||||
if (visited.has(current.id)) {
|
||||
managerChainCycle.push({
|
||||
employeeNo: employee.employee_no,
|
||||
chain: sample(chain),
|
||||
});
|
||||
break;
|
||||
}
|
||||
visited.add(current.id);
|
||||
current = byId.get(current.direct_manager_id);
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
missingDirectManager,
|
||||
rootNoManager,
|
||||
disabledManager,
|
||||
selfManagerLoop,
|
||||
managerChainCycle,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoleFindings(employees, pendingAssignedRows, pendingRoleRows, approvalRuleRows) {
|
||||
const activeEmployees = employees.filter(isActive);
|
||||
const activeRoleCounts = {};
|
||||
|
||||
for (const role of APPROVAL_ROLES) {
|
||||
activeRoleCounts[role] = activeEmployees.filter((employee) => (employee.roles ?? []).includes(role)).length;
|
||||
}
|
||||
|
||||
const configuredRoleWithoutActiveHolder = APPROVAL_ROLES
|
||||
.filter((role) => activeRoleCounts[role] === 0)
|
||||
.map((role) => ({ roleCode: role, activeHolders: 0 }));
|
||||
|
||||
const activeWithoutRoles = activeEmployees
|
||||
.filter((employee) => !employee.roles?.length)
|
||||
.map(sanitizeEmployee);
|
||||
|
||||
const adminOnlyEmployeeIds = new Set(activeEmployees.filter(isAdminOnly).map((employee) => employee.id));
|
||||
const employeeById = new Map(employees.map((employee) => [employee.id, employee]));
|
||||
|
||||
const adminOnlyPendingTasks = pendingAssignedRows
|
||||
.filter((row) => adminOnlyEmployeeIds.has(row.assignee_id))
|
||||
.map((row) => {
|
||||
const employee = employeeById.get(row.assignee_id);
|
||||
return {
|
||||
employeeNo: employee?.employee_no ?? "unknown",
|
||||
entityType: row.entity_type ?? "unknown",
|
||||
taskCount: Number(row.task_count),
|
||||
roles: employee?.roles ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
const financeAdminMixedRoles = activeEmployees
|
||||
.filter((employee) => {
|
||||
const roles = employee.roles ?? [];
|
||||
return roles.includes("admin") && (roles.includes("finance") || roles.includes("finance_mgr"));
|
||||
})
|
||||
.map(sanitizeEmployee);
|
||||
|
||||
return {
|
||||
activeRoleCounts,
|
||||
configuredRoleWithoutActiveHolder,
|
||||
activeWithoutRoles,
|
||||
adminOnlyPendingTasks,
|
||||
financeAdminMixedRoles,
|
||||
pendingRoleWithoutActiveHolder: pendingRoleRows.map((row) => ({
|
||||
roleCode: row.role_code,
|
||||
entityType: row.entity_type ?? "unknown",
|
||||
taskCount: Number(row.task_count),
|
||||
})),
|
||||
approvalRulesUseAdmin: approvalRuleRows.map((row) => ({
|
||||
ruleName: row.name,
|
||||
roles: [row.level1_role, row.level2_role, row.level3_role].filter(Boolean),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWecomFindings(employees) {
|
||||
const activeEmployees = employees.filter(isActive);
|
||||
const inactiveEmployees = employees.filter((employee) => !isActive(employee));
|
||||
const activeWithoutWecomBinding = activeEmployees
|
||||
.filter((employee) => !employee.has_wecom_binding)
|
||||
.map(sanitizeEmployee);
|
||||
const inactiveWithWecomBinding = inactiveEmployees
|
||||
.filter((employee) => employee.has_wecom_binding)
|
||||
.map(sanitizeEmployee);
|
||||
|
||||
return {
|
||||
bindingSummary: {
|
||||
activeEmployees: activeEmployees.length,
|
||||
activeWithBinding: activeEmployees.length - activeWithoutWecomBinding.length,
|
||||
activeWithoutBinding: activeWithoutWecomBinding.length,
|
||||
inactiveWithBinding: inactiveWithWecomBinding.length,
|
||||
},
|
||||
activeWithoutWecomBinding,
|
||||
inactiveWithWecomBinding,
|
||||
};
|
||||
}
|
||||
|
||||
function classifySeverity(findings) {
|
||||
const high =
|
||||
findings.manager.selfManagerLoop.length +
|
||||
findings.manager.managerChainCycle.length +
|
||||
findings.workflow.pendingInactiveAssignees.length +
|
||||
findings.roles.pendingRoleWithoutActiveHolder.length +
|
||||
findings.roles.adminOnlyPendingTasks.length +
|
||||
findings.roles.approvalRulesUseAdmin.length;
|
||||
|
||||
const medium =
|
||||
findings.manager.missingDirectManager.length +
|
||||
findings.manager.disabledManager.length +
|
||||
findings.roles.configuredRoleWithoutActiveHolder.length +
|
||||
findings.roles.activeWithoutRoles.length +
|
||||
findings.roles.financeAdminMixedRoles.length +
|
||||
findings.wecom.inactiveWithWecomBinding.length;
|
||||
|
||||
const low =
|
||||
findings.manager.rootNoManager.length +
|
||||
findings.wecom.activeWithoutWecomBinding.length;
|
||||
|
||||
return {
|
||||
high,
|
||||
medium,
|
||||
low,
|
||||
recommendation: high > 0 || medium > 0 ? "NEED_P2_ORG_FIX_REVIEW" : "NO_WRITE_FIX_NEEDED",
|
||||
};
|
||||
}
|
||||
|
||||
async function queryAll(client) {
|
||||
const employeesResult = await client.query(`
|
||||
SELECT
|
||||
e.id::text,
|
||||
e.employee_no,
|
||||
e.status::text,
|
||||
(e.archived_at IS NOT NULL) AS archived,
|
||||
e.direct_manager_id::text,
|
||||
dm.employee_no AS manager_employee_no,
|
||||
dm.status::text AS manager_status,
|
||||
(dm.archived_at IS NOT NULL) AS manager_archived,
|
||||
COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles,
|
||||
(w.employee_id IS NOT NULL) AS has_wecom_binding
|
||||
FROM employees e
|
||||
LEFT JOIN employees dm ON dm.id = e.direct_manager_id
|
||||
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.status,
|
||||
e.archived_at,
|
||||
e.direct_manager_id,
|
||||
dm.employee_no,
|
||||
dm.status,
|
||||
dm.archived_at,
|
||||
w.employee_id
|
||||
ORDER BY e.employee_no ASC
|
||||
`);
|
||||
|
||||
const pendingInactiveResult = await client.query(`
|
||||
SELECT
|
||||
e.employee_no,
|
||||
e.status::text AS employee_status,
|
||||
(e.archived_at IS NOT NULL) AS archived,
|
||||
wi.entity_type,
|
||||
count(*)::int AS task_count
|
||||
FROM workflow_tasks t
|
||||
JOIN employees e ON e.id = t.assignee_id
|
||||
LEFT JOIN workflow_instances wi ON wi.id = t.instance_id
|
||||
WHERE t.status = 'pending'
|
||||
AND t.assignee_id IS NOT NULL
|
||||
AND (e.status <> 'active' OR e.archived_at IS NOT NULL)
|
||||
GROUP BY e.employee_no, e.status, e.archived_at, wi.entity_type
|
||||
ORDER BY task_count DESC, e.employee_no ASC
|
||||
`);
|
||||
|
||||
const pendingAssignedResult = await client.query(`
|
||||
SELECT
|
||||
t.assignee_id::text,
|
||||
wi.entity_type,
|
||||
count(*)::int AS task_count
|
||||
FROM workflow_tasks t
|
||||
LEFT JOIN workflow_instances wi ON wi.id = t.instance_id
|
||||
WHERE t.status = 'pending'
|
||||
AND t.assignee_id IS NOT NULL
|
||||
GROUP BY t.assignee_id, wi.entity_type
|
||||
`);
|
||||
|
||||
const pendingRoleResult = await client.query(`
|
||||
SELECT
|
||||
t.assignee_role::text AS role_code,
|
||||
wi.entity_type,
|
||||
count(*)::int AS task_count
|
||||
FROM workflow_tasks t
|
||||
LEFT JOIN workflow_instances wi ON wi.id = t.instance_id
|
||||
WHERE t.status = 'pending'
|
||||
AND t.assignee_role IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM employee_roles er
|
||||
JOIN employees e ON e.id = er.employee_id
|
||||
WHERE er.role_code = t.assignee_role
|
||||
AND e.status = 'active'
|
||||
AND e.archived_at IS NULL
|
||||
)
|
||||
GROUP BY t.assignee_role, wi.entity_type
|
||||
ORDER BY task_count DESC, role_code ASC
|
||||
`);
|
||||
|
||||
const approvalRuleResult = await client.query(`
|
||||
SELECT name, level1_role::text, level2_role::text, level3_role::text
|
||||
FROM approval_rules
|
||||
WHERE enabled = true
|
||||
AND (
|
||||
level1_role = 'admin'
|
||||
OR level2_role = 'admin'
|
||||
OR level3_role = 'admin'
|
||||
)
|
||||
ORDER BY priority ASC, name ASC
|
||||
`);
|
||||
|
||||
return {
|
||||
employees: employeesResult.rows,
|
||||
pendingInactiveAssignees: pendingInactiveResult.rows.map((row) => ({
|
||||
employeeNo: row.employee_no,
|
||||
employeeStatus: row.employee_status,
|
||||
archived: Boolean(row.archived),
|
||||
entityType: row.entity_type ?? "unknown",
|
||||
taskCount: Number(row.task_count),
|
||||
})),
|
||||
pendingAssignedRows: pendingAssignedResult.rows,
|
||||
pendingRoleRows: pendingRoleResult.rows,
|
||||
approvalRuleRows: approvalRuleResult.rows,
|
||||
};
|
||||
}
|
||||
|
||||
async function auditOrgMasterData() {
|
||||
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 queried = await queryAll(client);
|
||||
await client.query("COMMIT");
|
||||
|
||||
const manager = buildManagerChainFindings(queried.employees);
|
||||
const roles = buildRoleFindings(
|
||||
queried.employees,
|
||||
queried.pendingAssignedRows,
|
||||
queried.pendingRoleRows,
|
||||
queried.approvalRuleRows,
|
||||
);
|
||||
const wecom = buildWecomFindings(queried.employees);
|
||||
const workflow = {
|
||||
pendingInactiveAssignees: queried.pendingInactiveAssignees,
|
||||
};
|
||||
const findings = { manager, workflow, roles, wecom };
|
||||
|
||||
return {
|
||||
mode: "read_only",
|
||||
generatedAt: new Date().toISOString(),
|
||||
privacy: "employee_no/status/roles only; no phone/email/password/token/wecom external ids",
|
||||
totals: {
|
||||
employees: queried.employees.length,
|
||||
activeEmployees: queried.employees.filter(isActive).length,
|
||||
},
|
||||
severity: classifySeverity(findings),
|
||||
findings: {
|
||||
manager: {
|
||||
missingDirectManager: {
|
||||
count: manager.missingDirectManager.length,
|
||||
items: sample(manager.missingDirectManager),
|
||||
},
|
||||
rootNoManager: {
|
||||
count: manager.rootNoManager.length,
|
||||
items: sample(manager.rootNoManager),
|
||||
},
|
||||
disabledManager: {
|
||||
count: manager.disabledManager.length,
|
||||
items: sample(manager.disabledManager),
|
||||
},
|
||||
selfManagerLoop: {
|
||||
count: manager.selfManagerLoop.length,
|
||||
items: sample(manager.selfManagerLoop),
|
||||
},
|
||||
managerChainCycle: {
|
||||
count: manager.managerChainCycle.length,
|
||||
items: sample(manager.managerChainCycle),
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
pendingInactiveAssignees: {
|
||||
count: workflow.pendingInactiveAssignees.length,
|
||||
items: sample(workflow.pendingInactiveAssignees),
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
activeRoleCounts: roles.activeRoleCounts,
|
||||
configuredRoleWithoutActiveHolder: {
|
||||
count: roles.configuredRoleWithoutActiveHolder.length,
|
||||
items: roles.configuredRoleWithoutActiveHolder,
|
||||
},
|
||||
activeWithoutRoles: {
|
||||
count: roles.activeWithoutRoles.length,
|
||||
items: sample(roles.activeWithoutRoles),
|
||||
},
|
||||
pendingRoleWithoutActiveHolder: {
|
||||
count: roles.pendingRoleWithoutActiveHolder.length,
|
||||
items: sample(roles.pendingRoleWithoutActiveHolder),
|
||||
},
|
||||
adminOnlyPendingTasks: {
|
||||
count: roles.adminOnlyPendingTasks.length,
|
||||
items: sample(roles.adminOnlyPendingTasks),
|
||||
},
|
||||
approvalRulesUseAdmin: {
|
||||
count: roles.approvalRulesUseAdmin.length,
|
||||
items: sample(roles.approvalRulesUseAdmin),
|
||||
},
|
||||
financeAdminMixedRoles: {
|
||||
count: roles.financeAdminMixedRoles.length,
|
||||
items: sample(roles.financeAdminMixedRoles),
|
||||
},
|
||||
},
|
||||
wecom: {
|
||||
bindingSummary: wecom.bindingSummary,
|
||||
activeWithoutWecomBinding: {
|
||||
count: wecom.activeWithoutWecomBinding.length,
|
||||
items: sample(wecom.activeWithoutWecomBinding),
|
||||
},
|
||||
inactiveWithWecomBinding: {
|
||||
count: wecom.inactiveWithWecomBinding.length,
|
||||
items: sample(wecom.inactiveWithWecomBinding),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.query("ROLLBACK");
|
||||
} catch {
|
||||
// Ignore rollback failures; preserve the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function writeFindingLine(label, finding) {
|
||||
process.stdout.write(`- ${label}: ${finding.count}\n`);
|
||||
for (const item of finding.items ?? []) {
|
||||
process.stdout.write(` ${JSON.stringify(item)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function printTextReport(report) {
|
||||
process.stdout.write("[org-master-data-audit] mode=read_only\n");
|
||||
process.stdout.write(`[org-master-data-audit] generatedAt=${report.generatedAt}\n`);
|
||||
process.stdout.write(`[org-master-data-audit] privacy=${report.privacy}\n`);
|
||||
process.stdout.write(`[org-master-data-audit] employees=${report.totals.employees} active=${report.totals.activeEmployees}\n`);
|
||||
process.stdout.write(
|
||||
`[org-master-data-audit] severity high=${report.severity.high} medium=${report.severity.medium} low=${report.severity.low} recommendation=${report.severity.recommendation}\n`,
|
||||
);
|
||||
|
||||
process.stdout.write("\nManager chain:\n");
|
||||
writeFindingLine("missingDirectManager", report.findings.manager.missingDirectManager);
|
||||
writeFindingLine("rootNoManager", report.findings.manager.rootNoManager);
|
||||
writeFindingLine("disabledManager", report.findings.manager.disabledManager);
|
||||
writeFindingLine("selfManagerLoop", report.findings.manager.selfManagerLoop);
|
||||
writeFindingLine("managerChainCycle", report.findings.manager.managerChainCycle);
|
||||
|
||||
process.stdout.write("\nWorkflow:\n");
|
||||
writeFindingLine("pendingInactiveAssignees", report.findings.workflow.pendingInactiveAssignees);
|
||||
|
||||
process.stdout.write("\nRoles:\n");
|
||||
process.stdout.write(`- activeRoleCounts: ${JSON.stringify(report.findings.roles.activeRoleCounts)}\n`);
|
||||
writeFindingLine("configuredRoleWithoutActiveHolder", report.findings.roles.configuredRoleWithoutActiveHolder);
|
||||
writeFindingLine("activeWithoutRoles", report.findings.roles.activeWithoutRoles);
|
||||
writeFindingLine("pendingRoleWithoutActiveHolder", report.findings.roles.pendingRoleWithoutActiveHolder);
|
||||
writeFindingLine("adminOnlyPendingTasks", report.findings.roles.adminOnlyPendingTasks);
|
||||
writeFindingLine("approvalRulesUseAdmin", report.findings.roles.approvalRulesUseAdmin);
|
||||
writeFindingLine("financeAdminMixedRoles", report.findings.roles.financeAdminMixedRoles);
|
||||
|
||||
process.stdout.write("\nWeCom binding:\n");
|
||||
process.stdout.write(`- bindingSummary: ${JSON.stringify(report.findings.wecom.bindingSummary)}\n`);
|
||||
writeFindingLine("activeWithoutWecomBinding", report.findings.wecom.activeWithoutWecomBinding);
|
||||
writeFindingLine("inactiveWithWecomBinding", report.findings.wecom.inactiveWithWecomBinding);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const report = await auditOrgMasterData();
|
||||
if (options.format === "json") {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
} else {
|
||||
printTextReport(report);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[org-master-data-audit][FAIL] ${error?.message ?? error}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户