From 2e778d3c9f6460a6658923b4b49b288c38ee132b Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 24 May 2026 21:12:18 +0800 Subject: [PATCH] =?UTF-8?q?OAFLOW1=20=E6=94=AF=E6=8C=81=E8=B4=9F=E8=B4=A3?= =?UTF-8?q?=E4=BA=BA=E6=9C=AC=E4=BA=BA=E6=8A=A5=E9=94=80=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E5=AE=A1=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/hyb-oa-delivery/SKILL.md | 1 + client/src/api/approvalRules.ts | 4 +- client/src/pages/ApprovalRulesPage.tsx | 52 +- client/vite.config.ts | 32 - docs/trialrun/人工试运行报告.md | 18 + docs/文档事实源状态矩阵.md | 13 +- docs/文档索引.md | 2 +- docs/管理员手册.md | 28 +- docs/需求-统一审批中心规约.md | 20 + ...owner-application-fallback-approval-v1.csv | 11 + ...check-owner-application-approval-route.mjs | 546 ++++++++++++++++++ .../migration.sql | 7 + server/prisma/schema.prisma | 1 + server/src/routes/approvalRules.js | 33 +- server/src/routes/configRead.js | 2 + server/src/routes/reimbursements.js | 10 +- server/src/scripts/audit-org-master-data.js | 10 +- server/src/scripts/seed-config.js | 3 + server/src/services/feeControl.js | 38 +- server/src/utils/workflow.js | 196 +++++++ server/tests/unit/routes/portal.test.js | 45 +- .../reimbursements.expense-standards.test.js | 136 ++++- server/tests/unit/utils/workflow.test.js | 202 +++++++ 23 files changed, 1324 insertions(+), 86 deletions(-) create mode 100644 issues/2026-05-24_19-03-07-oa-owner-application-fallback-approval-v1.csv create mode 100644 scripts/qa/check-owner-application-approval-route.mjs create mode 100644 server/prisma/migrations/20260524193000_owner_self_fallback_role/migration.sql diff --git a/.agents/skills/hyb-oa-delivery/SKILL.md b/.agents/skills/hyb-oa-delivery/SKILL.md index 0aa7d082..7d2c8658 100644 --- a/.agents/skills/hyb-oa-delivery/SKILL.md +++ b/.agents/skills/hyb-oa-delivery/SKILL.md @@ -30,6 +30,7 @@ Read only what is relevant: - OSMOKE1 is the current OA-only smoke account governance lane after ASSET1D/OAOPS1 blockers: do not request more Enterprise WeChat accounts just to test OA-primary reimbursement/assets/finance/AP/contracts/permissions. Enterprise WeChat smoke accounts remain for HR/attendance evidence; admin/finance/hr/ceo OA regression accounts are OA-only smoke accounts and may be active in OA without WeCom binding. Use `npm run repair:oa-only-smoke` from `server/` for dry-run first; apply requires `--apply` plus `OA_ONLY_SMOKE_REPAIR_APPLY=1`, must not reset passwords, print secrets, affect real employees, or write WeCom. `audit-wecom-oa-user-consistency` classifies env-listed, explicit allowlisted, or `SMOKE*`/`smoke-*`/test-named OA-only smoke accounts as `oaOnlySmokeExempted` so they are not archived as missing in WeCom; `run-oa-real-use-daily.sh` includes OA-only smoke readiness before asset/finance UI automation. - OALAUNCH1 is the current final-enable gate after OAOPS1/ASSET1D/OSMOKE1: do not treat ASSET1D completion or `PASS_OA_REAL_USE_DAILY_READY` as final launch approval. Use `scripts/qa/run-oa-final-launch-readiness.sh` to aggregate evidence only; it must not create business data, execute payments, write WeCom, reset/restore the production DB, print secrets, or mark final launch ready without real business closeout, 7-day stabilization, permission matrix acceptance, backup/restore drill evidence, data-governance/training acceptance, and Day 7 Go/No-Go. Valid conclusions are `PASS_OA_LAUNCH_READY`, `PASS_WITH_FINDINGS`, `PAUSED_MANUAL_CONFIRMATION`, and `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`. Permission or backup/restore fixes require read-only reviewer review before launch acceptance. - OPOLICY1 is the current policy-system alignment lane after OMAT1/OALAUNCH1 review: external company policies under `/home/shunli/Projects/faculty-daily-assistant/公司/制度体系建设_20260524` are adjusted to a small-team V1口径 first, then hyb-oa facts are aligned. Treat policy parameters, data-retention boundaries, seal-use ledger, approval-exception SOP, policy signoff, and P2 freeze rules as OALAUNCH1 data-governance gates. Do not assume budget hard control, complex procurement/tendering, e-signature, full e-archive, ERP/bank deep integration, or AI rule writes are pre-launch requirements. For V1, contract registry summary/archive/offline archive fields and existing attachment audit can carry seal-use traceability; announcements/manual evidence can carry policy notices/signoff until a later Issue proves dedicated schema is required. Never copy sensitive policy text, thresholds, confidential contract bodies, credentials, or full local paths into repo docs. +- OAFLOW1 is the current OA reimbursement approval hardening lane: `wangshunli/HYD000006` as `ceo + admin` must not be assigned a fake direct manager, must not self-approve, and `admin` must not be treated as business approval authority. Reimbursement `approval_rules` now support `ownerSelfFallbackRole`; OAFLOW1 V1 only allows `finance_mgr` or null, and existing enabled rules default to `finance_mgr`. For CEO owner self-applications with no direct manager, build and persist a new workflow snapshot by replacing the first `manager` node with `finance_mgr`, removing the owner's own `ceo` node and duplicate fallback role. Precheck, submit, and approval advancement must use the same snapshot; public deployment and low-risk owner reimbursement verification require separate approval and must not execute real payment. - OMAT1 is the current maturity-gap review goal after OALAUNCH1: use `issues/2026-05-23_17-20-00-oa-maturity-gap-review-v1.csv` as the reviewable ledger for every problem or suggestion. It includes domestic benchmark sources, a domain capability comparison matrix, manual decision Q&A checklist, review decision packet, status audit, and MAGI round reviews in the manual trial-run report, the small-team rule that maturity means controlled operation before feature breadth, explicit adopt/defer/reject decision回填, and a need for a concise evidence view. Follow the MAGI cycle wording as audit structure only: review/evaluate first, record or solve bounded evidence gaps second, then prioritize direction. Do not implement OMAT1 suggestions until the user chooses adopt/defer/reject and a proper Issue CSV exists for the adopted work. - WEGOV1 changes the current WeCom route to "Enterprise WeChat owns mature HR/attendance flows; OA stores self-owned history, audit, entry, messages, and emergency fallback." Treat four domains explicitly: WeCom-owned domain is check-in, leave, missed punch, outing, overtime, leave balance, and HR assistant; OA-owned domain remains reimbursement, procurement, contracts, projects, payments, invoices, bank receipts, sensitive attachments, and operating audit; OA archive domain stores WeCom business history, full external keys, and necessary snapshots; OA emergency domain is manually enabled only when WeCom is unavailable. Do not assume a full isolated WeCom sandbox; use the controlled test domain `SMOKE002 -> SMOKE101`, `OA-POC` templates, low-risk approvals, synthetic tests, public live runners, and limited real-device confirmation. WARCH1 now supplies the minimum leave/missed-punch archive implementation, WARCH2 supplies the formal read-only history query surface, and WROAD2 supplies controlled ops daily checks. Do not start deep mirror until WROAD2-CLOSEOUT, WROAD2-BIZ1, WAPPR1, WRULE1, WAUTO1/WEXT1LD, WFULLSMOKE1, WTCLOSE2, WEXT1-FORMAL, WREAL1 question gates or explicit approval, and the mirror stage's own contract are complete. - For reimbursement, invoice, OCR, duplicate invoice, or attachment work, read `docs/报销发票附件规则.md` and `references/reimbursement.md`. diff --git a/client/src/api/approvalRules.ts b/client/src/api/approvalRules.ts index cbf7d176..f73b12c2 100644 --- a/client/src/api/approvalRules.ts +++ b/client/src/api/approvalRules.ts @@ -9,6 +9,7 @@ export type ApprovalRoleCode = | "administration" | "hr" | "admin"; +export type ApprovalOwnerSelfFallbackRoleCode = "finance_mgr"; export type ApprovalRule = { id: string; @@ -18,6 +19,7 @@ export type ApprovalRule = { level1Role: ApprovalRoleCode; level2Role: ApprovalRoleCode | null; level3Role: ApprovalRoleCode | null; + ownerSelfFallbackRole: ApprovalOwnerSelfFallbackRoleCode | null; enabled: boolean; priority: number; createdAt: string | null; @@ -46,6 +48,7 @@ export type ApprovalRuleCreatePayload = { level1Role: ApprovalRoleCode; level2Role: ApprovalRoleCode | null; level3Role: ApprovalRoleCode | null; + ownerSelfFallbackRole: ApprovalOwnerSelfFallbackRoleCode | null; enabled?: boolean; priority?: number; }; @@ -65,4 +68,3 @@ export async function patchApprovalRule(approvalRuleId: string, payload: Approva body: JSON.stringify(payload), }); } - diff --git a/client/src/pages/ApprovalRulesPage.tsx b/client/src/pages/ApprovalRulesPage.tsx index 0d2b5377..0014a59d 100644 --- a/client/src/pages/ApprovalRulesPage.tsx +++ b/client/src/pages/ApprovalRulesPage.tsx @@ -7,6 +7,7 @@ import { createApprovalRule, fetchApprovalRules, patchApprovalRule, + type ApprovalOwnerSelfFallbackRoleCode, type ApprovalRoleCode, type ApprovalRule, } from "../api/approvalRules"; @@ -27,6 +28,9 @@ const ROLE_OPTIONS: Array<{ code: ApprovalRoleCode; label: string }> = [ { code: "finance_mgr", label: "finance_mgr" }, { code: "admin", label: "admin" }, ]; +const OWNER_SELF_FALLBACK_ROLE_OPTIONS = ROLE_OPTIONS.filter( + (role) => role.code === "finance_mgr", +); function formatAmount(value: string | null) { if (!value) return "-"; @@ -44,6 +48,7 @@ export function ApprovalRulesPage() { const [level1Role, setLevel1Role] = useState("manager"); const [level2Role, setLevel2Role] = useState(""); const [level3Role, setLevel3Role] = useState(""); + const [ownerSelfFallbackRole, setOwnerSelfFallbackRole] = useState("finance_mgr"); const [enabled, setEnabled] = useState(true); const [editOpen, setEditOpen] = useState(false); @@ -65,10 +70,11 @@ export function ApprovalRulesPage() { level1Role, level2Role: level2Role ? (level2Role as ApprovalRoleCode) : null, level3Role: level3Role ? (level3Role as ApprovalRoleCode) : null, + ownerSelfFallbackRole: ownerSelfFallbackRole ? (ownerSelfFallbackRole as ApprovalOwnerSelfFallbackRoleCode) : null, enabled, priority: priority.trim() ? Number(priority) : undefined, }; - }, [enabled, level1Role, level2Role, level3Role, maxAmount, minAmount, name, priority]); + }, [enabled, level1Role, level2Role, level3Role, maxAmount, minAmount, name, ownerSelfFallbackRole, priority]); const createMutation = useMutation({ mutationFn: () => createApprovalRule(createPayload), @@ -81,6 +87,7 @@ export function ApprovalRulesPage() { setLevel1Role("manager"); setLevel2Role(""); setLevel3Role(""); + setOwnerSelfFallbackRole("finance_mgr"); setEnabled(true); setError(""); toast.success("规则已创建"); @@ -141,6 +148,7 @@ export function ApprovalRulesPage() { setLevel1Role(editRule.level1Role); setLevel2Role(editRule.level2Role ?? ""); setLevel3Role(editRule.level3Role ?? ""); + setOwnerSelfFallbackRole(editRule.ownerSelfFallbackRole ?? ""); }, [editOpen, editRule]); return ( @@ -262,6 +270,25 @@ export function ApprovalRulesPage() { +
+ + +

推荐 finance_mgr;不使用 admin。

+
+
- {[rule.level1Role, rule.level2Role, rule.level3Role].filter(Boolean).join(" -> ") || "-"} +
{[rule.level1Role, rule.level2Role, rule.level3Role].filter(Boolean).join(" -> ") || "-"}
+
+ 本人兜底:{rule.ownerSelfFallbackRole ?? "无"} +
{rule.priority} @@ -483,6 +513,24 @@ export function ApprovalRulesPage() {
+
+ + +
+
id.includes(`/node_modules/${pkg}/`))) { @@ -71,14 +47,6 @@ function resolveManualChunk(id: string) { return "app-domain-shared"; } - for (const [chunkName, pageNames] of Object.entries(PAGE_CHUNK_PATTERNS)) { - for (const pageName of pageNames) { - if (id.includes(`/src/router/lazy/${pageName}.ts`)) { - return chunkName; - } - } - } - return undefined; } diff --git a/docs/trialrun/人工试运行报告.md b/docs/trialrun/人工试运行报告.md index 457ce758..4864a011 100644 --- a/docs/trialrun/人工试运行报告.md +++ b/docs/trialrun/人工试运行报告.md @@ -727,3 +727,21 @@ Q7-Q9:A ```bash git show 5da05eb:docs/trialrun/人工试运行报告.md ``` + +## OAFLOW1 负责人本人报销兜底审批 + +触发背景:`wangshunli/HYD000006` 本人提交报销时,当前报销金额规则首节点为 `manager`,但该负责人不应配置假直属主管,导致提交前诊断报告 `owner_self_application_missing_fallback`。 + +当前实现口径: + +| 项目 | 结论 | +| --- | --- | +| 合同 | `issues/2026-05-24_19-03-07-oa-owner-application-fallback-approval-v1.csv` | +| 数据模型 | `approval_rules.owner_self_fallback_role` / API 字段 `ownerSelfFallbackRole` | +| 默认值 | 现有启用报销金额规则迁移回填 `finance_mgr` | +| 配置范围 | OAFLOW1 首版只允许 `finance_mgr` 或置空,不开放 `admin/ceo/hr/administration` | +| 提交流程 | 新单提交前生成审批快照;负责人本人无直属主管时使用 `finance_mgr` 替换 `manager`,移除本人 `ceo` 节点和重复角色 | +| 边界 | 不允许自审,不允许 `admin` 作为业务审批兜底,不执行真实付款 | +| 公网验证 | 仍需单独批准部署后再由负责人本人提交 1 条低风险报销验证;本节不代表已执行公网写入 | + +预期链路:小额 `finance_mgr -> finance`,中额 `finance_mgr`,大额 `finance_mgr`。若 `finance_mgr` 没有 active 非本人持有人,或申请人本人也持有兜底角色,系统继续阻断。 diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md index d40993c5..419b8b2c 100644 --- a/docs/文档事实源状态矩阵.md +++ b/docs/文档事实源状态矩阵.md @@ -114,6 +114,7 @@ | `OALAUNCH1` | `issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv` | `CURRENT` | 当前最终启用前收口与 Go/No-Go 门禁合同:在 OAOPS1/ASSET1D/OSMOKE1 之上增加真实业务闭环、7 天稳定化、权限矩阵、备份恢复演练、数据治理培训、运营支持和 Day 7 人工决策;新增 `scripts/qa/run-oa-final-launch-readiness.sh` 默认只读汇总证据;未达到 `PASS_OA_LAUNCH_READY` 前不得宣布最终正式启用完成 | | `OMAT1` | `issues/2026-05-23_17-20-00-oa-maturity-gap-review-v1.csv` | `CURRENT` | 当前成熟度缺口审查台账:按 MAGI 审视/执行/提升轮次调查 hyb-oa 是否成熟,并对标国内主流 OA/协同/低代码/财务/资产系统;已补充国内对标来源、小团队取舍原则、用户决策回填和成熟度证据视图;所有问题和建议先记录为待用户采纳、暂缓或否决,不直接改业务代码 | | `OPOLICY1` | `issues/2026-05-24_01-20-52-oa-policy-system-alignment-v1.csv` | `CURRENT` | 当前制度与 OA 对齐合同:先调整外部制度包为小团队 V1,再回写 OA 事实源和启用门禁;制度参数、资料留存、用印台账、审批异常 SOP、制度签收和 P2 冻结进入 OALAUNCH1 数据治理门禁;现有合同台账、资产台账和公告能力可承载 V1,不立即做 schema 或业务代码改造 | +| `OAFLOW1` | `issues/2026-05-24_19-03-07-oa-owner-application-fallback-approval-v1.csv` | `CURRENT` | 当前负责人本人申请兜底审批合同:`wangshunli/HYD000006` 本人报销不配置假直属主管、不允许自审、不把 `admin` 当业务审批权;报销审批规则新增 `ownerSelfFallbackRole`,首版只允许 `finance_mgr` 或置空,现有启用规则默认 `finance_mgr`,提交时生成新单审批快照并移除本人 `ceo` 节点和重复兜底角色;公网低风险验证需另行批准 | | `WREAL1-PREP` | `issues/2026-05-20_14-35-42-wecom-real-trial-prep-v1.csv` | `CURRENT` | 真实小范围试用准备合同:只固化候选员工、非自审审批人、回滚联系人、允许流程、暂停条件和验收标准;当前不启用真实员工,真实试用必须等请假/补卡/外出/加班四类测试号证据、WAPPR1 审批规则门禁和 WFULLSMOKE1 全域主路径可解释通过后再问 | 旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。 @@ -122,8 +123,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,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1,OSMOKE1,OALAUNCH1,OMAT1,OPOLICY1` | `rg` | none | 2026-05-24 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么;OAOPS1/ASSET1/OSMOKE1 后优先读取 OALAUNCH1 最终启用门禁、OMAT1 成熟度缺口、OPOLICY1 制度与 OA 对齐、小团队 V1、每日巡检、备份恢复和 P2 冻结口径 | -| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1,OSMOKE1,OALAUNCH1,OMAT1,OPOLICY1` | `audit-docs` | none | 2026-05-24 | `audit-docs --check-registry` | medium | 判断文档权威性;登记当前 OALAUNCH1 最终启用 Go/No-Go、OMAT1 成熟度缺口、OPOLICY1 制度与 OA 对齐、OAOPS1、ASSET1、OSMOKE1、每日巡检、阻断即修和冻结项事实 | +| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1,OSMOKE1,OALAUNCH1,OMAT1,OPOLICY1,OAFLOW1` | `rg` | none | 2026-05-24 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么;OAOPS1/ASSET1/OSMOKE1 后优先读取 OALAUNCH1 最终启用门禁、OMAT1 成熟度缺口、OPOLICY1 制度与 OA 对齐、OAFLOW1 负责人本人报销兜底、小团队 V1、每日巡检、备份恢复和 P2 冻结口径 | +| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1,OSMOKE1,OALAUNCH1,OMAT1,OPOLICY1,OAFLOW1` | `audit-docs` | none | 2026-05-24 | `audit-docs --check-registry` | medium | 判断文档权威性;登记当前 OALAUNCH1 最终启用 Go/No-Go、OMAT1 成熟度缺口、OPOLICY1 制度与 OA 对齐、OAFLOW1 负责人本人报销兜底、OAOPS1、ASSET1、OSMOKE1、每日巡检、阻断即修和冻结项事实 | | `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1,WAPC2,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1` | Issue CSV | `docs/requirements-specification.md` | 2026-05-20 | `rg` | high | WARCH3/WCHECK1/WEXT1/WOPS1 后定义 HR/考勤企微主办、OA 自建留存、受控测试域、只读历史查询、自动巡检、打卡摘要、外出/加班 dry-run、WEXT1-FORMAL、WREAL1 前置和深度镜像边界;ERP 未来不接入 OA | | `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1,WAPC2FLIVE,WCBK1,WEGOV1` | `rg` | none | 2026-05-19 | `rg` | high | 记录已拍板产品边界、企微/OA 设计门禁、开发路线图、WCBK1 真实回调事件证据、WEGOV1 混合主办边界和 OA 自建留存策略 | | `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 应急接管 | @@ -133,18 +134,18 @@ | `QA-GATE` | `docs/测试规约与回归门禁.md` | 测试验收 | 测试和回归门禁事实源 | `CURRENT` | qa | `QAF8,GOV8,WPROC1,WPROC2,WAPC2FLIVE,WCBK1,WEGOV1` | 回归脚本/Playwright | none | 2026-05-19 | `rg` | high | 测试计划、DONE 门禁、企微三层门禁、自动化优先、非自审、无完整 sandbox 假设、WREAD1 真机可读性和 WARCH1 留存验收门禁 | | `AGENT-PRACTICE` | `docs/Agent开发交付实践.md` | Agent 治理 | Agent 协作事实源 | `CURRENT` | dev | `GOV8,DG8` | Skill 校验 | none | 2026-05-13 | `rg` | medium | Agent 协作开发实践和设计主参考层级 | | `AGENTS` | `AGENTS.md` | 执行硬约束 | 项目硬规则 | `CURRENT` | lead | `GOV8` | 人工核对 | none | 2026-05-13 | `rg` | high | 不可违反约束 | -| `SKILL-HYB` | `.agents/skills/hyb-oa-delivery/SKILL.md` | Skill | 项目交付 Skill | `CURRENT` | dev | `GOV8,DG8,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WROUTE2,WTCLOSE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1,OSMOKE1,OALAUNCH1,OMAT1,OPOLICY1` | quick_validate | none | 2026-05-24 | `quick_validate.py` | medium | 每轮交付操作入口;企微/OA 后续先读 API 能力目录、Secret 矩阵、路线图、ASSET1 资产边界、OSMOKE1 OA-only smoke 账号治理、OALAUNCH1 最终启用门禁和 OPOLICY1 小团队 V1 制度对齐;不得绕过真实业务闭环、权限矩阵、备份恢复、制度签收、Day 7 Go/No-Go 或后续独立合同直接宣布最终启用完成 | -| `REQ-WF` | `docs/需求-统一审批中心规约.md` | 需求事实源 | 审批模块规约 | `CURRENT` | product/backend | `AP6,WF8,WFOC,DBWFOC,WFDG,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WRULE1,WTCLOSE2` | workflow 回归 | none | 2026-05-22 | `rg` | high | OA 主办审批、企微主办 HR/考勤审批、OA 自建留存、真实回调事件证据、WACP3R 补偿拉取 findings、WREAD1 请假/补卡真机可读性结论、WARCH1 最小历史留存、WAPPR1 审批规则规范化和 API 不支持读取规则边界、WTCLOSE1 小团队一层审批 + 管理员兜底目标 A、WRULE1 理论审批人与实际审批链校验、WTCLOSE2 测试号最终收口、WAUTO1/WEXT1LD/WFULLSMOKE1 前置和 OA 应急接管边界 | +| `SKILL-HYB` | `.agents/skills/hyb-oa-delivery/SKILL.md` | Skill | 项目交付 Skill | `CURRENT` | dev | `GOV8,DG8,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WROUTE2,WTCLOSE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1,OSMOKE1,OALAUNCH1,OMAT1,OPOLICY1,OAFLOW1` | quick_validate | none | 2026-05-24 | `quick_validate.py` | medium | 每轮交付操作入口;企微/OA 后续先读 API 能力目录、Secret 矩阵、路线图、ASSET1 资产边界、OSMOKE1 OA-only smoke 账号治理、OALAUNCH1 最终启用门禁、OPOLICY1 小团队 V1 制度对齐和 OAFLOW1 负责人本人报销兜底;不得绕过真实业务闭环、权限矩阵、备份恢复、制度签收、Day 7 Go/No-Go 或后续独立合同直接宣布最终启用完成 | +| `REQ-WF` | `docs/需求-统一审批中心规约.md` | 需求事实源 | 审批模块规约 | `CURRENT` | product/backend | `AP6,WF8,WFOC,DBWFOC,WFDG,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WRULE1,WTCLOSE2,OAFLOW1` | workflow 回归 | none | 2026-05-24 | `rg` | high | OA 主办审批、企微主办 HR/考勤审批、OA 自建留存、真实回调事件证据、WACP3R 补偿拉取 findings、WREAD1 请假/补卡真机可读性结论、WARCH1 最小历史留存、WAPPR1 审批规则规范化和 API 不支持读取规则边界、WTCLOSE1 小团队一层审批 + 管理员兜底目标 A、WRULE1 理论审批人与实际审批链校验、WTCLOSE2 测试号最终收口、OAFLOW1 负责人本人报销兜底、WAUTO1/WEXT1LD/WFULLSMOKE1 前置和 OA 应急接管边界 | | `REQ-OCR` | `docs/需求-报销发票OCR规约.md` | 需求事实源 | OCR 模块规约 | `CURRENT` | product/backend | `RI6,OCR8,BIZ8` | OCR 回归 | none | 2026-05-13 | `rg` | high | OCR 和发票边界 | | `RULE-REIMB` | `docs/报销发票附件规则.md` | 需求事实源 | 报销发票附件规则 | `CURRENT` | product/finance | `RI6,BIZ8` | 报销回归 | none | 2026-05-13 | `rg` | high | 报销、发票、附件细则 | | `REQ-WECOM` | `docs/需求-企业微信基础设施规约.md` | 需求事实源 | 企业微信边界规约 | `CURRENT` | product/backend | `R7E,ORG8,WF8,ATTV1,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WTCLOSE2` | live smoke/真机反馈/Issue CSV | none | 2026-05-22 | `rg` | high | 企微优先承担 HR/考勤员工日常审批和人事/打卡能力;WARCH2 已提供请假/补卡正式只读历史查询,WARCH3 固定受控上线自动巡检,WCHECK1 固定打卡摘要留存,WEXT1 固定外出/加班 dry-run,WROAD2B 固定三天人工业务冒烟,WAPPR1/WTCLOSE1 固定审批规则规范化和小团队目标 A,WFULLSMOKE1 固定全域主路径测试边界,WTCLOSE2 固定最终测试号自动收口,WEXT1-FORMAL 需收口后另建合同;采购、合同、项目、报销和财务继续 OA 主办 | | `REQ-ASSET` | `docs/需求-资产管理规约.md` | 需求事实源 | 统一资产台账规约 | `CURRENT` | product/backend | `ASSET1,OPOLICY1` | Issue CSV / 本地门禁 | none | 2026-05-24 | `rg` | high | 固定资产和电子资产统一台账;编号族 `FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP`;`EA-ACC` 暂缓;Git 管源码,OA 只登记仓库元数据;OPOLICY1 后固定资料留存、备份资产、账号凭据禁入和 P2 冻结边界 | | `RUNBOOK-TRIAL` | `docs/试运行与自动化验证手册.md` | 操作手册 | 试运行验证手册 | `CURRENT` | qa/ops | `R7E,QAOPS8` | live readiness | none | 2026-05-13 | `rg` | medium | 本地、公网、企业微信和真实 OCR 验证 | -| `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E,WFOC,DBWFOC,WFDG,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WTCLOSE2,WREAL1P,ASSET1,OALAUNCH1,OPOLICY1` | 人工验收 | none | 2026-05-24 | `rg` | medium | 管理员上线、企微优先边界、Secret 矩阵、巡检、HR/考勤门禁、ASSET1、OALAUNCH1 和 OPOLICY1;明确制度参数、资料留存、用印台账、审批异常 SOP、制度签收、备份恢复、权限矩阵和敏感资料不进公网 OA 的管理口径 | +| `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E,WFOC,DBWFOC,WFDG,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WTCLOSE2,WREAL1P,ASSET1,OALAUNCH1,OPOLICY1,OAFLOW1` | 人工验收 | none | 2026-05-24 | `rg` | medium | 管理员上线、企微优先边界、Secret 矩阵、巡检、HR/考勤门禁、ASSET1、OALAUNCH1、OPOLICY1 和 OAFLOW1;明确制度参数、资料留存、用印台账、审批异常 SOP、制度签收、负责人本人报销兜底、备份恢复、权限矩阵和敏感资料不进公网 OA 的管理口径 | | `MANUAL-EMPLOYEE` | `docs/员工快速上手.md` | 操作手册 | 员工手册 | `CURRENT` | product | `R7E,WFOC,DBWFOC,WFDG,ASSET1,OALAUNCH1,OPOLICY1` | 人工验收 | none | 2026-05-24 | `rg` | low | 员工侧使用说明,HR/考勤优先企业微信,采购/合同/财务仍在 OA;补充统一资产台账、电子资产、涉密资产可见边界、制度签收、资料留存和 OALAUNCH1 收口期问题反馈口径 | | `R7-DRAFT` | `docs/trialrun/R7扩大试运行草案.md` | 草案 | R7 准备材料 | `DRAFT` | product/qa | `R7E` | R7EP 报告 | none | 2026-05-13 | `rg` | medium | 不代表当前 R7E 执行状态 | | `OPS-RUNBOOK` | `docs/trialrun/OPS7运维交接Runbook.md` | 运维草案 | OPS 准备材料 | `DRAFT` | ops | `OPS8,QAOPS8` | 只读检查 | none | 2026-05-13 | `rg` | medium | 不代表恢复演练已执行 | -| `TRIAL-REPORT` | `docs/trialrun/人工试运行报告.md` | 证据材料 | 试运行证据入口 | `EVIDENCE` | qa | `R7EP,OPS8,QAF8,WOUA,WAPI,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WRULE1,WTCLOSE2,WREAL1P,ASSET1,OALAUNCH1,OMAT1` | artifacts/qa-flow | none | 2026-05-23 | `rg` | medium | 只证明发生过;记录 WAPC2FLIVE live 样件、WCBK1 真实回调事件、企微路线证据、WTCLOSE2 测试号最终收口、WREAL1 准备、ASSET1 统一资产台账、OALAUNCH1 最终启用门禁、OMAT1 成熟度验收摘要、域级能力对标矩阵、人工决策问答清单、审查决策包、状态审计和 MAGI 轮次审视;不直接放行深度镜像或最终正式启用 | +| `TRIAL-REPORT` | `docs/trialrun/人工试运行报告.md` | 证据材料 | 试运行证据入口 | `EVIDENCE` | qa | `R7EP,OPS8,QAF8,WOUA,WAPI,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WRULE1,WTCLOSE2,WREAL1P,ASSET1,OALAUNCH1,OMAT1,OAFLOW1` | artifacts/qa-flow | none | 2026-05-24 | `rg` | medium | 只证明发生过;记录 WAPC2FLIVE live 样件、WCBK1 真实回调事件、企微路线证据、WTCLOSE2 测试号最终收口、WREAL1 准备、ASSET1 统一资产台账、OALAUNCH1 最终启用门禁、OMAT1 成熟度验收摘要、OAFLOW1 负责人本人报销兜底审批、域级能力对标矩阵、人工决策问答清单、审查决策包、状态审计和 MAGI 轮次审视;不直接放行深度镜像或最终正式启用 | | `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 历史恢复 | diff --git a/docs/文档索引.md b/docs/文档索引.md index dff2e2be..9f64ca5f 100644 --- a/docs/文档索引.md +++ b/docs/文档索引.md @@ -239,7 +239,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M | 文档 | 用途 | | --- | --- | | [历史功能状态矩阵](./历史功能状态矩阵.md) | 轻量历史功能索引;旧大表从 git 历史恢复 | -| [人工试运行报告](./trialrun/人工试运行报告.md) | R6 后当前状态、关键证据索引、OMAT1 成熟度验收摘要、域级能力对标矩阵、人工决策问答清单、审查决策包、状态审计和 MAGI 轮次审视 | +| [人工试运行报告](./trialrun/人工试运行报告.md) | R6 后当前状态、关键证据索引、OMAT1 成熟度验收摘要、域级能力对标矩阵、人工决策问答清单、审查决策包、状态审计、MAGI 轮次审视和 OAFLOW1 负责人本人报销兜底审批口径 | | [R7E 后功能边界审计](./trialrun/R7E后功能边界审计.md) | R7E 后全端功能边界、P2 候选和后续 Issue 草案 | | [试运行覆盖矩阵](./trialrun/试运行覆盖矩阵.csv) | 轻量覆盖摘要 | | [项目级流程覆盖矩阵](./trialrun/项目级流程覆盖矩阵.csv) | `check-flow-coverage` 使用的最小流程矩阵 | diff --git a/docs/管理员手册.md b/docs/管理员手册.md index 44dfbf2e..6d2f2636 100644 --- a/docs/管理员手册.md +++ b/docs/管理员手册.md @@ -12,6 +12,7 @@ **OSMOKE1 OA-only smoke 账号治理**:企业微信账号数量有限时,不再为了 OA 报销、资产、财务、AP、合同或权限回归额外申请企业微信账号。企微测试账号只服务 HR/考勤链路;公网 OA 自动化使用 OA-only smoke 账号,允许不绑定企微,但必须在 OA 内保持 active、角色正确且可登录。企微/OA 一致性审计会把私有 env 中列出、显式员工号 allowlist 命中,或符合 `SMOKE*`/`smoke-*`/`oa-smoke-*`/测试命名的 OA-only smoke 账号归为 `oaOnlySmokeExempted`,不再当作“缺企微账号”归档;真实员工不得用该命名绕过一致性治理。恢复账号必须先 dry-run,真实修复必须 `--apply` 且设置 `OA_ONLY_SMOKE_REPAIR_APPLY=1`,不得重置密码、不得打印凭据、不得影响真实员工。 **OALAUNCH1 最终启用门禁**:ASSET1D 已完成资产台账公网部署、最小 UI 试录和 3 条脱敏 `EA-DOC` 受控导入;OAOPS1 自动巡检已具备公网只读证据,但正式启用前仍必须补齐真实业务闭环、权限矩阵、备份恢复演练、数据治理培训和 Day 7 Go/No-Go。新增 `scripts/qa/run-oa-final-launch-readiness.sh` 只聚合证据并输出 `PASS_OA_LAUNCH_READY`、`PASS_WITH_FINDINGS`、`PAUSED_MANUAL_CONFIRMATION` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`;脚本不创建业务数据、不执行付款、不写企业微信、不做数据库恢复。未达到 `PASS_OA_LAUNCH_READY` 前,只能视为小团队稳定化收口中,不能宣布最终正式启用完成。 **OPOLICY1 制度与 OA 对齐口径**:公司制度包已裁剪为“小团队 V1”执行口径,原则是先做到能执行、能留痕、能追责。制度参数、资料留存、用印台账、审批异常 SOP、制度签收和 P2 冻结边界进入 OALAUNCH1 数据治理门禁;预算硬控、复杂招采、电子签章、完整电子档案系统、ERP/银行深集成和 AI 自动改规则不作为正式启用前置条件。 +**OAFLOW1 负责人本人报销兜底审批**:`wangshunli/HYD000006` 作为 `ceo + admin` 提交本人报销时,不配置假直属主管,不允许自审,也不把 `admin` 当业务审批权。OA 报销审批规则新增 `负责人本人兜底角色(ownerSelfFallbackRole)`,当前推荐和默认回填为 `finance_mgr`。负责人本人无直属主管时,系统把第一层 `manager` 替换为 `finance_mgr`,并移除本人 `ceo` 节点和重复角色;若兜底角色没有 active 非本人持有人或兜底角色也由申请人本人持有,提交仍会阻断。 ## 1. 适用范围 - 适用于 `small_team_internal` 工作区模式。 @@ -31,7 +32,7 @@ - 正式审批组织口径:`majianyong` 配置为员工可见直属主管和 `manager`;`wangshunli` 配置为后台公司负责人 `ceo + admin`,不配置为普通 `manager`。 - 普通员工和专用测试员工的直属主管配置为 `majianyong`;`majianyong` 的直属领导配置为 `wangshunli`。 - `admin` 只代表系统管理权限,不代表业务审批权;需要业务审批时必须配置 `manager`、`ceo`、`finance`、`hr` 等正式业务角色。 - - 缺直属主管、主管停用、主管链断裂或审批节点解析到申请人本人时,应先修正组织或审批规则,不要用管理员账号代批。 + - 缺直属主管、主管停用、主管链断裂或审批节点解析到申请人本人时,应先修正组织或审批规则,不要用管理员账号代批。负责人本人报销只允许通过审批规则里的 `负责人本人兜底角色` 进入非本人复核,推荐 `finance_mgr`。 5. 检查功能开关: - 建议开启:`portal_home_v1`、企业微信入口相关开关、报销/OCR、审批待办、请假、项目基础能力和 `payment_batch_v1`。 - 按需开启:`announcements_v1`、`presence_board_v1`、`weekly_report_v1`、`employee_lifecycle_v1`、`hr_wecom_directory_sync_v1`、`todo_board_v1`、`global_search_v1`。 @@ -153,13 +154,34 @@ WHERE t.status = 'pending' AND r.roles !~ '(^|,)(manager|ceo|finance|finance_mgr|hr)(,|$)' ORDER BY t.created_at; -SELECT id, name, level1_role, level2_role, level3_role +SELECT id, name, level1_role, level2_role, level3_role, owner_self_fallback_role FROM approval_rules WHERE enabled = true - AND 'admin' IN (level1_role::text, COALESCE(level2_role::text, ''), COALESCE(level3_role::text, '')) + AND 'admin' IN ( + level1_role::text, + COALESCE(level2_role::text, ''), + COALESCE(level3_role::text, ''), + COALESCE(owner_self_fallback_role::text, '') + ) ORDER BY priority; ``` +负责人本人报销兜底检查: + +```sql +SELECT name, level1_role, level2_role, level3_role, owner_self_fallback_role +FROM approval_rules +WHERE enabled = true +ORDER BY priority; +``` + +管理要求: + +1. 启用的报销金额规则建议均配置 `owner_self_fallback_role = 'finance_mgr'`。 +2. OAFLOW1 首版 `owner_self_fallback_role` 只允许 `finance_mgr` 或置空;不得配置为 `admin`、`ceo`、`hr`、`administration` 或其他非报销审批角色。 +3. `finance_mgr` 必须至少有 1 名 active 非本人持有人;否则负责人本人报销提交会阻断。 +4. 提交后系统保存审批快照;之后修改规则只影响新单,不回写历史流程。 + 财务和系统管理员混授: ```sql diff --git a/docs/需求-统一审批中心规约.md b/docs/需求-统一审批中心规约.md index aec27c91..c52e4ec7 100644 --- a/docs/需求-统一审批中心规约.md +++ b/docs/需求-统一审批中心规约.md @@ -105,6 +105,26 @@ OA 中既有请假、补卡、外出、加班能力转为历史只读、镜像 正式员工必须配置直属主管。缺直属主管、主管停用、主管链断裂或审批节点解析到申请人本人时,应阻断提交或阻断审批动作,并提示管理员修正组织或审批规则。不得用 `admin` 权限解释业务审批权,也不得用无条件升级 CEO 掩盖主数据错误。 +### 2.4 OAFLOW1 负责人本人报销兜底审批 + +OAFLOW1 后,OA 主办的报销审批规则增加 `ownerSelfFallbackRole`。当公司负责人本人(当前业务口径为 `ceo` 角色)提交报销且直属主管节点无法解析时,系统使用审批规则上的非本人兜底角色替换第一层 `manager` 节点;当前推荐和默认回填为 `finance_mgr`。该规则只作用于新提交报销单,提交时保存 `workflow_steps_snapshot`,后续审批推进只读快照,不因之后修改审批规则而漂移。 + +执行边界: + +- 不把 `wangshunli/HYD000006` 配成假直属主管,不用组织主数据造假绕过流程。 +- 不允许自审;若兜底角色也是申请人本人持有的角色,或没有 active 非本人持有人,提交必须阻断。 +- 不支持 `admin` 作为负责人本人报销兜底角色;`admin` 仍只代表系统管理权限,不代表业务审批权。 +- 当前首版只覆盖 OA 报销金额规则,不扩展到采购、合同、复杂 BPM 或自由流程。 +- 现有启用报销金额规则迁移时默认回填 `ownerSelfFallbackRole=finance_mgr`;OAFLOW1 首版只允许 `finance_mgr` 或置空,置空后负责人本人无主管申请将继续阻断。 + +预期链路示例: + +| 金额规则 | 原链路 | 负责人本人申请快照 | +| --- | --- | --- | +| 小额 | `manager -> finance` | `finance_mgr -> finance` | +| 中额 | `manager -> finance_mgr` | `finance_mgr` | +| 大额 | `manager -> ceo -> finance_mgr` | `finance_mgr` | + --- ## 3. 入口与任务视图 diff --git a/issues/2026-05-24_19-03-07-oa-owner-application-fallback-approval-v1.csv b/issues/2026-05-24_19-03-07-oa-owner-application-fallback-approval-v1.csv new file mode 100644 index 00000000..4971b5be --- /dev/null +++ b/issues/2026-05-24_19-03-07-oa-owner-application-fallback-approval-v1.csv @@ -0,0 +1,11 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +OAFLOW1-0,建立负责人本人申请兜底审批合同,承接 wangshunli/HYD000006 提交报销时因无直属主管触发 no_actionable_approver 的真实试运行反馈,建立 OAFLOW1 执行合同。边界:不为了过流程伪造组织直属上级;不允许自审;不改当前公网数据;后续实现前必须再次批准方案。,Issue CSV 通过校验;明确短期 OAOPS1 真实报销闭环优先由普通员工提交,负责人本人申请兜底规则作为独立实现项;不把 admin 权限等同于业务审批权。,受影响流程:负责人本人提交报销 -> 审批人解析 -> 阻断或兜底审批;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-24_19-03-07-oa-owner-application-fallback-approval-v1.csv。,python3|manual,DONE,DONE,DONE,issues/2026-05-24_19-03-07-oa-owner-application-fallback-approval-v1.csv,[],本行仅建合同,不改代码、不改数据库、不部署;当前线上阻断符合现有规约,但暴露负责人本人申请缺少非本人兜底规则。 +OAFLOW1-1,OAOPS1 当前闭环绕行策略,在 OAFLOW1 实现前,OALAUNCH1/OAOPS1 首条真实报销闭环不使用 wangshunli 本人发起,而由普通员工或专用真实试用员工提交;wangshunli 作为 CEO/admin 观察或在高额节点处理。,不调整 wangshunli 的直属主管为假上级;普通员工提交报销能按直属主管进入审批;OALAUNCH1 真实业务证据记录该绕行原因和后续 OAFLOW1 计划。,受影响流程:真实报销 PDF OCR -> 提交 -> 审批 -> 付款批次 -> 回单 -> 已付款 -> AP 导出;验证步骤:人工在公网 OA 使用普通员工完成 1 条低风险真实报销闭环,再运行 scripts/qa/run-oa-final-launch-readiness.sh 汇总。,manual|bash,TODO,TODO,TODO,external|issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv|issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv,"[""OAFLOW1-0""]",真实票据、银行回单、合同正文、敏感报价和未脱敏票面不得进入仓库、聊天或自动化报告;这只是启用验收路径选择,不是业务规则永久绕行。 +OAFLOW1-2,只读诊断负责人本人申请审批链,新增或复用 QA-only 只读诊断,报告 CEO/admin/root-like 员工本人提交报销时当前审批规则会解析到谁、是否缺直属主管、是否会自审、是否存在可用非本人兜底角色或人员。,诊断默认只读;报告只输出员工号、角色摘要、规则名、节点类型、脱敏 finding,不输出手机号、邮箱、密码、token、Secret、附件、票据原文或完整 payload;当前 HYD000006 应报告 owner_self_application_missing_fallback。,受影响流程:管理员发现提交阻断 -> 运行只读诊断 -> 决定配置或开发;验证步骤:node --check scripts/qa/check-owner-application-approval-route.mjs;公网只读运行后 summary.json 不含敏感值。,node|bash,DONE,DONE,DONE,scripts/qa/check-owner-application-approval-route.mjs|artifacts/qa-flow/owner-application-approval-route-*,"[""OAFLOW1-0""]",已新增 scripts/qa/check-owner-application-approval-route.mjs;公网容器只读运行报告 artifacts/qa-flow/owner-application-approval-route-20260524-auto/summary.json 为 PASS_WITH_FINDINGS,确认 HYD000006 在 3 条报销金额规则第 0 步 manager 均因 no_manager_found 阻断,并发现 manager/finance_mgr/finance 等非本人候选角色存在但尚未配置为负责人本人申请兜底;隐私 grep 未发现敏感值;未写业务数据、未创建待办、未改配置。 +OAFLOW1-3,设计审批规则数据模型扩展,设计并实现可配置的负责人本人申请兜底规则,优先支持按业务类型和金额规则配置非本人兜底角色或指定人员。推荐字段方向:当 department_manager 无法解析或解析到申请人本人时,可按审批规则配置 fallback role/employee;fallback 必须非本人、active、未归档且具备业务审批角色。,Prisma 迁移可回滚;审批规则不再只表达 level1/2/3 role,还能表达负责人本人或无主管时的非本人兜底;现有普通员工审批规则不回归;无兜底时仍阻断。,受影响流程:审批规则配置 -> 报销提交 -> 工作流任务创建;验证步骤:cd server && npm run prisma:validate;迁移 SQL review;负责人本人、普通员工、缺兜底三类用例通过。,prisma|node|manual,DONE,DONE,DONE,server/prisma/schema.prisma|server/prisma/migrations/*|server/src/routes/approvalRules.js|server/src/utils/workflow.js,"[""OAFLOW1-2""]","已实现 approval_rules.owner_self_fallback_role / API ownerSelfFallbackRole;OAFLOW1 V1 只允许 finance_mgr 或 null,现有启用规则迁移回填 finance_mgr;显式 null 保持无兜底。回滚路径:停止服务并确认无新单依赖该列后,回退代码并执行 ALTER TABLE ""approval_rules"" DROP COLUMN ""owner_self_fallback_role""。" +OAFLOW1-4,实现报销提交兜底解析,在报销提交和后续审批推进中,当审批节点为直属主管但申请人为 root-like/负责人且无直属主管,或主管节点解析到申请人本人时,使用配置的非本人兜底角色或人员。不得自动升级到 admin,不得让申请人自审,不得生成无可处理人的 role task。,wangshunli/HYD000006 低风险报销在配置兜底后可提交到非本人审批人;未配置或兜底不可用时仍返回 no_actionable_approver;普通员工仍走直属主管;审批通过、驳回、退回、撤回和通知不回归。,受影响流程:报销草稿 -> 提交 -> 首个待办 -> 审批推进 -> 通知;验证步骤:cd server && node --check src/routes/reimbursements.js src/routes/workflow.js src/utils/workflow.js;运行 workflow/reimbursement 相关回归。,node|bash,DONE,DONE,DONE,server/src/routes/reimbursements.js|server/src/routes/workflow.js|server/src/utils/workflow.js|server/tests,"[""OAFLOW1-3""]",已实现负责人本人报销审批快照:ceo owner 无直属主管时用 ownerSelfFallbackRole=finance_mgr 替换 manager,移除本人 ceo 节点和重复角色;提交前校验 active 非本人 finance_mgr 持有人;不自动升级 admin,不允许自审;审批推进继续只读 workflow_steps_snapshot。 +OAFLOW1-5,管理端配置与操作手册,在审批规则管理或系统设置中展示负责人本人申请兜底配置,并在管理员手册说明小团队推荐口径:普通员工走直属主管,主管本人走负责人,负责人本人走指定非本人复核人。,管理员能在 UI 中看懂并配置兜底角色或人员;页面不要求新建复杂 BPM;手册写明不要用假直属主管绕行,不要用 admin 代替业务审批。,"受影响流程:管理员配置审批规则 -> 员工提交 -> 审批链生效;验证步骤:cd client && npm run typecheck && npm run build;rg -n ""负责人本人|兜底审批|非本人复核|假直属主管"" docs client/src。",typescript|rg|manual,DONE,DONE,DONE,client/src|docs/管理员手册.md|docs/需求-统一审批中心规约.md,"[""OAFLOW1-3"",""OAFLOW1-4""]",已在审批规则页面新增负责人本人兜底角色配置,V1 仅 finance_mgr 或无;已回写管理员手册、统一审批规约、人工试运行报告、文档索引、事实源矩阵和项目 skill;明确不要用假直属主管绕行,不要用 admin 代替业务审批。 +OAFLOW1-6,自动化回归覆盖,补充自动化用例覆盖负责人本人申请、主管本人申请、普通员工申请、无兜底阻断、兜底人停用、自审防护和角色待办可处理性。,至少覆盖:1) 普通员工报销不变;2) HYD000006 无兜底时阻断;3) 配置非本人兜底后可提交;4) fallback 指向本人或停用人时阻断;5) 任务不能被申请人本人审批。,受影响流程:报销提交 -> 待办分配 -> 审批动作;验证步骤:cd server && npm run prisma:validate && ./scripts/regression-workflow-actions.sh && ./scripts/regression-all.sh;必要时增加定向测试命令。,bash|node,DONE,DONE,TODO,server/tests|server/scripts/regression-workflow-actions.sh|server/scripts/regression-all.sh,"[""OAFLOW1-4""]",已补充 workflow 单测覆盖普通员工不变、负责人 finance_mgr 兜底、无兜底阻断、无 active 非本人持有人阻断、owner 同持兜底角色阻断、unsupported role 阻断;新增无端口 Fastify app.inject 提交路径测试,验证 CEO owner 报销会保存 finance_mgr 兜底 workflow_steps_snapshot 并创建 finance_mgr 待办,且兜底无人时不创建流程实例。定向 vitest 21 项通过;后端完整 vitest 141 个文件、707 项测试通过。HTTP workflow regression 因沙箱禁止本地 server 监听且提升权限被 auto_review 拒绝(避免加载真实 .env 后定时任务写库)未执行,保留 Regression_Status=TODO。 +OAFLOW1-7,只读 reviewer 审查,由于本项涉及审批规则、权限模型和可能的数据迁移,实施后启动只读 reviewer subagent 审查,不允许其改代码。,reviewer 明确检查:自审是否被阻断,admin 是否未被当作业务审批权,兜底角色是否可能无 active 持有人,迁移是否可回滚,普通员工流程是否回归。,受影响流程:实现完成 -> 独立评审 -> 修复或记录 blocker;验证步骤:reviewer 输出无 blocker 或 blocker 已处理,Issue 状态再进入 QA。,subagent|manual,DONE,DONE,DONE,N/A,"[""OAFLOW1-3"",""OAFLOW1-4"",""OAFLOW1-6""]",只读 reviewer 已完成首轮审查并提出 4 项 finding;已修复高/中/低风险配置问题:fallback 限定 finance_mgr/null、前端不再暴露 ceo/hr/admin/administration、创建接口尊重显式 null,并补充回滚路径。第二轮复审请求超时后已关闭;主线程复核和自动门禁未发现新增 blocker,不进入公网部署。 +OAFLOW1-8,公网部署与负责人本人低风险验证,在本地门禁、reviewer 和 QA 通过后,经人工批准部署到公网;由 wangshunli 提交 1 条低风险报销验证非本人兜底审批链,再继续 OALAUNCH1 真实启用门禁。,部署后 /ready 通过;负责人本人低风险报销能进入非本人审批人待办;审批历史显示非本人处理;不会自审;不会执行真实付款;报告脱敏留存。,受影响流程:部署 -> /ready -> 负责人本人提交低风险报销 -> 非本人审批 -> OALAUNCH1 汇总;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;公网 /ready;人工提交低风险报销;运行 OAOPS1/OALAUNCH1 只读汇总。,bash|manual,TODO,TODO,TODO,deploy/trialrun/deploy.sh|artifacts/qa-flow/oa-real-use-daily-*|artifacts/qa-flow/oa-final-launch-readiness-*,"[""OAFLOW1-7""]",部署和公网业务验证必须再次人工批准;不执行真实付款,不上传敏感合同正文、银行流水原件、凭据或未脱敏票面到报告;回滚/清理:代码按 trialrun runbook 回滚,低风险验证报销单若未进入付款按撤回或驳回留痕处理,已生成的审计日志保留不删除。 +OAFLOW1-9,消除前端 Vite circular chunk warning,处理 OAFLOW1 本地构建中发现的 Vite/Rollup circular chunk warning,避免手工页面分包强拆互相依赖的页面和共享模块。边界:只调整构建分包策略,不改业务页面行为、不部署。,"client build 不再输出 Circular chunk warning;前端 typecheck 和 build 通过;Issue CSV 校验通过。","受影响流程:前端构建 -> 部署产物生成 -> OAFLOW1 本地门禁;验证步骤:cd client && npm run build;确认输出无 Circular chunk;python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-24_19-03-07-oa-owner-application-fallback-approval-v1.csv。",typescript|vite|python,DONE,DONE,DONE,client/vite.config.ts,"[""OAFLOW1-6""]",已删除页面组手工分包,只保留 vendor/app-core/app-domain-shared 等稳定共享分包;client build 通过且未再输出 Circular chunk warning。 diff --git a/scripts/qa/check-owner-application-approval-route.mjs b/scripts/qa/check-owner-application-approval-route.mjs new file mode 100644 index 00000000..a9f073ae --- /dev/null +++ b/scripts/qa/check-owner-application-approval-route.mjs @@ -0,0 +1,546 @@ +#!/usr/bin/env node + +import { createRequire } from "node:module"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const require = createRequire(import.meta.url); +const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", ".."); +const MAX_MANAGER_DEPTH = 10; +const APPROVAL_ROLES = ["manager", "finance_mgr", "finance", "ceo", "hr", "administration"]; + +function loadPgPool() { + const candidates = [ + "pg", + path.join(process.cwd(), "node_modules", "pg"), + path.join(rootDir, "server", "node_modules", "pg"), + "/app/server/node_modules/pg", + ]; + + for (const candidate of candidates) { + try { + return require(candidate).Pool; + } catch { + // Try the next known runtime layout. + } + } + + throw new Error("Unable to load pg. Run from server container or install server dependencies."); +} + +function parseArgs(argv) { + const options = { + employeeNo: process.env.OAFLOW1_TARGET_EMPLOYEE_NO || "HYD000006", + reportDir: process.env.OAFLOW1_REPORT_DIR || path.resolve( + `artifacts/qa-flow/owner-application-approval-route-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`, + ), + json: false, + 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 === "--json") { + options.json = true; + } else if (arg === "--employee-no") { + options.employeeNo = String(argv[index + 1] || "").trim(); + index += 1; + } else if (arg.startsWith("--employee-no=")) { + options.employeeNo = arg.slice("--employee-no=".length).trim(); + } else if (arg === "--report-dir") { + options.reportDir = String(argv[index + 1] || "").trim(); + index += 1; + } else if (arg.startsWith("--report-dir=")) { + options.reportDir = arg.slice("--report-dir=".length).trim(); + } else { + throw new Error(`Unsupported argument: ${arg}`); + } + } + + if (!options.employeeNo) { + throw new Error("Missing --employee-no"); + } + if (!options.reportDir) { + throw new Error("Missing --report-dir"); + } + + return options; +} + +function printHelp() { + process.stdout.write(`Usage: node scripts/qa/check-owner-application-approval-route.mjs [options] + +只读诊断负责人本人提交报销时的审批链解析风险。 + +Options: + --employee-no= 目标员工号,默认 HYD000006 或 OAFLOW1_TARGET_EMPLOYEE_NO。 + --report-dir= 报告目录,默认 artifacts/qa-flow/owner-application-approval-route-*。 + --json 同时向 stdout 输出 summary.json 内容。 + --help 显示帮助。 + +边界: + 只读数据库事务;不提交报销;不创建待办;不修改员工、审批规则或生产配置。 + 报告只输出员工号、状态、角色、规则名和脱敏 finding。 +`); +} + +function isActive(employee) { + return employee?.status === "active" && !employee?.archived; +} + +function isRootLike(employee) { + const roles = new Set(employee?.roles || []); + return roles.has("ceo") || (roles.has("admin") && !roles.has("manager")); +} + +function safeEmployee(employee) { + if (!employee) return null; + return { + employeeNo: employee.employee_no, + status: employee.status, + archived: Boolean(employee.archived), + roles: employee.roles || [], + directManagerEmployeeNo: employee.manager_employee_no || null, + }; +} + +function safeRule(rule) { + return { + name: rule.name, + minAmount: String(rule.min_amount), + maxAmount: rule.max_amount == null ? null : String(rule.max_amount), + roles: [rule.level1_role, rule.level2_role, rule.level3_role].filter(Boolean), + ownerSelfFallbackRole: rule.owner_self_fallback_role || null, + priority: Number(rule.priority), + }; +} + +function buildRoleHolders(employees) { + const holders = {}; + for (const role of APPROVAL_ROLES) holders[role] = []; + + for (const employee of employees) { + if (!isActive(employee)) continue; + for (const role of employee.roles || []) { + if (!holders[role]) holders[role] = []; + holders[role].push(employee); + } + } + + return holders; +} + +function resolveDepartmentManager(owner, employeesById) { + let currentId = owner.id; + const visited = new Set(); + + for (let depth = 1; depth <= MAX_MANAGER_DEPTH; depth += 1) { + if (visited.has(currentId)) { + return { status: "UNRESOLVED", reason: "cycle_detected", depthChecked: depth - 1 }; + } + visited.add(currentId); + + const current = employeesById.get(currentId); + if (!current) { + return { status: "UNRESOLVED", reason: "chain_broken", depthChecked: depth }; + } + + const managerId = current.direct_manager_id; + if (!managerId) { + return { status: "UNRESOLVED", reason: "no_manager_found", depthChecked: depth }; + } + + if (managerId === currentId) { + return { status: "UNRESOLVED", reason: "self_loop", depthChecked: depth }; + } + + const manager = employeesById.get(managerId); + if (!manager) { + return { status: "UNRESOLVED", reason: "chain_broken", depthChecked: depth }; + } + + if (manager.id === owner.id) { + return { + status: "BLOCKED", + reason: "self_approval_candidate", + depthChecked: depth, + assignee: safeEmployee(manager), + }; + } + + if (!isActive(manager)) { + return { + status: "BLOCKED", + reason: "manager_inactive_or_archived", + depthChecked: depth, + assignee: safeEmployee(manager), + }; + } + + return { + status: "ACTIONABLE", + reason: null, + depthChecked: depth, + assignee: safeEmployee(manager), + }; + } + + return { status: "UNRESOLVED", reason: "max_depth_exceeded", depthChecked: MAX_MANAGER_DEPTH }; +} + +function resolveRoleStep(roleCode, owner, roleHolders) { + const holders = roleHolders[roleCode] || []; + const nonSelfHolders = holders.filter((employee) => employee.id !== owner.id); + const selfOnly = holders.length > 0 && nonSelfHolders.length === 0; + + if (nonSelfHolders.length > 0) { + return { + status: "ACTIONABLE_ROLE", + roleCode, + activeNonSelfHolderCount: nonSelfHolders.length, + sampleHolderEmployeeNos: nonSelfHolders.slice(0, 5).map((employee) => employee.employee_no), + }; + } + + return { + status: "BLOCKED", + roleCode, + reason: selfOnly ? "role_holders_self_only" : "role_without_active_nonself_holder", + activeNonSelfHolderCount: 0, + }; +} + +function evaluateRule(rule, owner, employeesById, roleHolders) { + const roles = [rule.level1_role, rule.level2_role, rule.level3_role].filter(Boolean); + const steps = []; + const seenRoleCodes = new Set(); + + for (let index = 0; index < roles.length; index += 1) { + const roleCode = roles[index]; + if (roleCode === "manager") { + const resolved = resolveDepartmentManager(owner, employeesById); + const step = { + index, + roleCode, + assigneeRule: "department_manager", + ...resolved, + }; + steps.push(step); + if (resolved.status !== "ACTIONABLE") { + if (isRootLike(owner) && rule.owner_self_fallback_role) { + const fallback = resolveRoleStep(rule.owner_self_fallback_role, owner, roleHolders); + const fallbackStep = { + index, + roleCode: rule.owner_self_fallback_role, + assigneeRule: "owner_self_fallback_role", + fallbackForRole: roleCode, + managerReason: resolved.reason, + ...fallback, + }; + steps.push(fallbackStep); + if (fallback.status === "ACTIONABLE_ROLE") { + seenRoleCodes.add(rule.owner_self_fallback_role); + return { + rule: safeRule(rule), + submitSimulation: "WOULD_CREATE_OWNER_SELF_FALLBACK_ROLE_TASK", + firstActionable: fallbackStep, + steps, + }; + } + return { + rule: safeRule(rule), + submitSimulation: "BLOCKED", + blocker: { + stepIndex: index, + roleCode: rule.owner_self_fallback_role, + reason: fallback.reason, + fallbackForRole: roleCode, + }, + steps, + }; + } + return { + rule: safeRule(rule), + submitSimulation: "BLOCKED", + blocker: { + stepIndex: index, + roleCode, + reason: resolved.reason, + }, + steps, + }; + } + return { + rule: safeRule(rule), + submitSimulation: "WOULD_CREATE_TASK", + firstActionable: step, + steps, + }; + } + + if (isRootLike(owner) && roleCode === "ceo") { + steps.push({ + index, + roleCode, + assigneeRule: "role", + status: "SKIPPED", + reason: "owner_self_ceo_role_removed", + }); + continue; + } + if (seenRoleCodes.has(roleCode)) { + steps.push({ + index, + roleCode, + assigneeRule: "role", + status: "SKIPPED", + reason: "duplicate_role_removed", + }); + continue; + } + + const resolved = resolveRoleStep(roleCode, owner, roleHolders); + const step = { + index, + roleCode, + assigneeRule: "role", + ...resolved, + }; + steps.push(step); + if (resolved.status !== "ACTIONABLE_ROLE") { + return { + rule: safeRule(rule), + submitSimulation: "BLOCKED", + blocker: { + stepIndex: index, + roleCode, + reason: resolved.reason, + }, + steps, + }; + } + seenRoleCodes.add(roleCode); + return { + rule: safeRule(rule), + submitSimulation: "WOULD_CREATE_ROLE_TASK", + firstActionable: step, + steps, + }; + } + + return { + rule: safeRule(rule), + submitSimulation: "BLOCKED", + blocker: { reason: "empty_approval_chain" }, + steps, + }; +} + +function collectFallbackCandidates(owner, roleHolders) { + return APPROVAL_ROLES + .filter((roleCode) => roleCode !== "administration") + .map((roleCode) => { + const holders = (roleHolders[roleCode] || []).filter((employee) => employee.id !== owner.id); + return { + roleCode, + activeNonSelfHolderCount: holders.length, + sampleHolderEmployeeNos: holders.slice(0, 5).map((employee) => employee.employee_no), + }; + }) + .filter((item) => item.activeNonSelfHolderCount > 0); +} + +function collectFindings(owner, evaluations, fallbackCandidates) { + const findings = []; + + if (isRootLike(owner) && !owner.direct_manager_id) { + findings.push({ + code: "owner_root_like_no_direct_manager", + severity: "info", + employeeNo: owner.employee_no, + reason: "负责人/root-like 员工没有直属主管,这本身可接受,但不能直接用于 manager 节点审批。", + }); + } + + const managerBlockedRules = evaluations.filter( + (item) => item.blocker?.roleCode === "manager" && item.blocker?.reason === "no_manager_found", + ); + + if (isRootLike(owner) && managerBlockedRules.length > 0) { + findings.push({ + code: "owner_self_application_missing_fallback", + severity: "blocker_for_owner_self_application", + employeeNo: owner.employee_no, + affectedRuleCount: managerBlockedRules.length, + reason: "负责人本人申请命中 manager/直属主管节点,但本人没有直属主管,且当前审批规则没有非本人兜底配置。", + }); + } + + if (managerBlockedRules.length > 0 && fallbackCandidates.length > 0) { + findings.push({ + code: "fallback_candidate_roles_available_but_not_configured", + severity: "recommendation", + employeeNo: owner.employee_no, + roles: fallbackCandidates.map((item) => ({ + roleCode: item.roleCode, + activeNonSelfHolderCount: item.activeNonSelfHolderCount, + })), + reason: "系统存在可考虑的非本人复核角色,但当前审批规则还不能表达负责人本人申请兜底。", + }); + } + + return findings; +} + +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, + COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles + 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 + GROUP BY e.id, e.employee_no, e.status, e.archived_at, e.direct_manager_id, dm.employee_no + ORDER BY e.employee_no ASC + `); + + const rulesResult = await client.query(` + SELECT + id::text, + name, + min_amount::text, + max_amount::text, + level1_role::text, + level2_role::text, + level3_role::text, + owner_self_fallback_role::text, + priority, + enabled + FROM approval_rules + WHERE enabled = true + ORDER BY priority ASC, created_at ASC + `); + + return { employees: employeesResult.rows, approvalRules: rulesResult.rows }; +} + +async function run(options) { + if (!process.env.DATABASE_URL) { + throw new Error("Missing DATABASE_URL"); + } + + const Pool = loadPgPool(); + 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 employeesById = new Map(queried.employees.map((employee) => [employee.id, employee])); + const owner = queried.employees.find((employee) => employee.employee_no === options.employeeNo); + + if (!owner) { + return { + mode: "owner-application-approval-route", + status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG", + checkedAt: new Date().toISOString(), + targetEmployeeNo: options.employeeNo, + privacy: "employee_no/status/roles/rule names only; no phone/email/password/token/secret/attachments/raw payload", + findings: [{ + code: "target_employee_not_found", + severity: "blocker", + employeeNo: options.employeeNo, + }], + }; + } + + const roleHolders = buildRoleHolders(queried.employees); + const evaluations = queried.approvalRules.map((rule) => evaluateRule(rule, owner, employeesById, roleHolders)); + const fallbackCandidates = collectFallbackCandidates(owner, roleHolders); + const findings = collectFindings(owner, evaluations, fallbackCandidates); + const hasOwnerBlocker = findings.some((item) => item.severity === "blocker_for_owner_self_application" || item.severity === "blocker"); + + return { + mode: "owner-application-approval-route", + status: hasOwnerBlocker ? "PASS_WITH_FINDINGS" : "PASS_OWNER_APPLICATION_ROUTE_READY", + checkedAt: new Date().toISOString(), + target: safeEmployee(owner), + targetClassification: { + rootLike: isRootLike(owner), + active: isActive(owner), + }, + privacy: "employee_no/status/roles/rule names only; no phone/email/password/token/secret/attachments/raw payload", + summary: { + activeEmployeeCount: queried.employees.filter(isActive).length, + activeApprovalRuleCount: queried.approvalRules.length, + findingCount: findings.length, + ownerSelfApplicationBlocked: hasOwnerBlocker, + }, + fallbackCandidates, + approvalRuleEvaluations: evaluations, + findings, + boundaries: { + readOnlyTransaction: true, + noBusinessWrites: true, + noReimbursementSubmit: true, + noWorkflowTaskCreation: true, + noConfigChanges: true, + noDeployment: true, + }, + }; + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch { + // Preserve original error. + } + throw error; + } finally { + client.release(); + await pool.end(); + } +} + +function printText(summary) { + process.stdout.write(`[owner-approval-route] status=${summary.status}\n`); + process.stdout.write(`[owner-approval-route] target=${summary.target?.employeeNo || summary.targetEmployeeNo}\n`); + process.stdout.write(`[owner-approval-route] findings=${summary.summary?.findingCount ?? summary.findings?.length ?? 0}\n`); + for (const finding of summary.findings || []) { + process.stdout.write(`- ${finding.code}: ${finding.severity}\n`); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const summary = await run(options); + mkdirSync(options.reportDir, { recursive: true }); + const summaryPath = path.join(options.reportDir, "summary.json"); + writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + + if (options.json) { + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + } else { + printText(summary); + } + process.stdout.write(`[owner-approval-route] summary=${path.relative(process.cwd(), summaryPath)}\n`); +} + +main().catch((error) => { + process.stderr.write(`[owner-approval-route][FAIL] ${error?.message || error}\n`); + process.exitCode = 1; +}); diff --git a/server/prisma/migrations/20260524193000_owner_self_fallback_role/migration.sql b/server/prisma/migrations/20260524193000_owner_self_fallback_role/migration.sql new file mode 100644 index 00000000..496cd2a8 --- /dev/null +++ b/server/prisma/migrations/20260524193000_owner_self_fallback_role/migration.sql @@ -0,0 +1,7 @@ +ALTER TABLE "approval_rules" + ADD COLUMN "owner_self_fallback_role" "RoleCode"; + +UPDATE "approval_rules" +SET "owner_self_fallback_role" = 'finance_mgr' +WHERE "enabled" = true + AND "owner_self_fallback_role" IS NULL; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 26d8b2c8..4a9bc806 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -3510,6 +3510,7 @@ model ApprovalRule { level1_role RoleCode level2_role RoleCode? level3_role RoleCode? + owner_self_fallback_role RoleCode? enabled Boolean @default(true) priority Int diff --git a/server/src/routes/approvalRules.js b/server/src/routes/approvalRules.js index fb8ed98d..03705e51 100644 --- a/server/src/routes/approvalRules.js +++ b/server/src/routes/approvalRules.js @@ -7,6 +7,7 @@ const { ROLE_PERMISSIONS } = require("../constants/permissions"); const ROLE_CODES = Object.keys(ROLE_PERMISSIONS); const MONEY_STRING_SCHEMA = z.string().regex(/^\d+(\.\d{1,2})?$/); +const OWNER_SELF_FALLBACK_ROLE_CODES = ["finance_mgr"]; const CREATE_BODY_SCHEMA = z.object({ name: z.string().trim().min(1).max(100), @@ -15,6 +16,7 @@ const CREATE_BODY_SCHEMA = z.object({ level1Role: z.enum(ROLE_CODES), level2Role: z.enum(ROLE_CODES).nullable().optional(), level3Role: z.enum(ROLE_CODES).nullable().optional(), + ownerSelfFallbackRole: z.enum(OWNER_SELF_FALLBACK_ROLE_CODES).nullable().optional(), enabled: z.boolean().optional(), priority: z.number().int().min(0).max(10000).optional(), }); @@ -75,6 +77,21 @@ function ensureAmountRangeValid(minAmount, maxAmount) { } } +function ensureOwnerSelfFallbackRoleValid(roleCode) { + if (roleCode == null) { + return; + } + + if (!OWNER_SELF_FALLBACK_ROLE_CODES.includes(roleCode)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { ownerSelfFallbackRole: "unsupported_business_approval_role" }, + }); + } +} + function mapApprovalRule(item) { return { id: item.id, @@ -87,6 +104,7 @@ function mapApprovalRule(item) { level1Role: item.level1_role, level2Role: item.level2_role ?? null, level3Role: item.level3_role ?? null, + ownerSelfFallbackRole: item.owner_self_fallback_role ?? null, enabled: item.enabled, priority: item.priority, createdAt: item.created_at?.toISOString?.() ?? null, @@ -104,6 +122,7 @@ async function approvalRulesRoutes(fastify) { const body = parseBody(CREATE_BODY_SCHEMA, request.body); const maxAmount = Object.prototype.hasOwnProperty.call(body, "maxAmount") ? body.maxAmount : null; ensureAmountRangeValid(body.minAmount, maxAmount); + ensureOwnerSelfFallbackRoleValid(body.ownerSelfFallbackRole); const result = await fastify.prisma.$transaction(async (tx) => { let priority = body.priority; @@ -120,6 +139,9 @@ async function approvalRulesRoutes(fastify) { level1_role: body.level1Role, level2_role: body.level2Role ?? null, level3_role: body.level3Role ?? null, + owner_self_fallback_role: Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole") + ? body.ownerSelfFallbackRole ?? null + : "finance_mgr", enabled: body.enabled ?? true, priority, created_by: request.employee.id, @@ -133,6 +155,7 @@ async function approvalRulesRoutes(fastify) { level1_role: true, level2_role: true, level3_role: true, + owner_self_fallback_role: true, enabled: true, priority: true, created_at: true, @@ -156,6 +179,7 @@ async function approvalRulesRoutes(fastify) { level1Role: created.level1_role, level2Role: created.level2_role ?? null, level3Role: created.level3_role ?? null, + ownerSelfFallbackRole: created.owner_self_fallback_role ?? null, enabled: created.enabled, priority: created.priority, }, @@ -186,6 +210,9 @@ async function approvalRulesRoutes(fastify) { details: { body: "empty_patch" }, }); } + if (Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole")) { + ensureOwnerSelfFallbackRoleValid(body.ownerSelfFallbackRole); + } const result = await fastify.prisma.$transaction(async (tx) => { const locked = await tx.$queryRaw` @@ -212,6 +239,7 @@ async function approvalRulesRoutes(fastify) { level1_role: true, level2_role: true, level3_role: true, + owner_self_fallback_role: true, enabled: true, priority: true, created_at: true, @@ -251,6 +279,9 @@ async function approvalRulesRoutes(fastify) { ...(Object.prototype.hasOwnProperty.call(body, "level3Role") ? { level3_role: body.level3Role ?? null } : {}), + ...(Object.prototype.hasOwnProperty.call(body, "ownerSelfFallbackRole") + ? { owner_self_fallback_role: body.ownerSelfFallbackRole ?? null } + : {}), ...(body.enabled != null ? { enabled: body.enabled } : {}), ...(body.priority != null ? { priority: body.priority } : {}), updated_by: request.employee.id, @@ -263,6 +294,7 @@ async function approvalRulesRoutes(fastify) { level1_role: true, level2_role: true, level3_role: true, + owner_self_fallback_role: true, enabled: true, priority: true, created_at: true, @@ -291,4 +323,3 @@ async function approvalRulesRoutes(fastify) { } module.exports = approvalRulesRoutes; - diff --git a/server/src/routes/configRead.js b/server/src/routes/configRead.js index 9be5cd83..1066077c 100644 --- a/server/src/routes/configRead.js +++ b/server/src/routes/configRead.js @@ -62,6 +62,7 @@ function mapApprovalRule(item) { level1Role: item.level1_role, level2Role: item.level2_role ?? null, level3Role: item.level3_role ?? null, + ownerSelfFallbackRole: item.owner_self_fallback_role ?? null, enabled: item.enabled, priority: item.priority, createdAt: item.created_at?.toISOString?.() ?? null, @@ -115,6 +116,7 @@ async function configReadRoutes(fastify) { level1_role: true, level2_role: true, level3_role: true, + owner_self_fallback_role: true, enabled: true, priority: true, created_at: true, diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js index c8d7f069..43ddc3cd 100644 --- a/server/src/routes/reimbursements.js +++ b/server/src/routes/reimbursements.js @@ -32,7 +32,7 @@ const { const { issueDocumentNumber } = require("../services/documentSequence"); const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage"); const { - buildWorkflowStepsFromApprovalRule, + buildApprovalWorkflowSnapshot, getWorkflowStepsSnapshot, resolveActionableStep, toMoneyString, @@ -1024,6 +1024,7 @@ async function loadApprovalRuleForAmount(tx, totalAmount) { level1_role: true, level2_role: true, level3_role: true, + owner_self_fallback_role: true, priority: true, }, }); @@ -3231,7 +3232,12 @@ async function reimbursementRoutes(fastify) { } const approvalRule = await loadApprovalRuleForAmount(tx, reimbursement.total_amount); - const workflowSteps = buildWorkflowStepsFromApprovalRule(approvalRule); + const workflowSteps = await buildApprovalWorkflowSnapshot({ + tx, + approvalRule, + ownerId: reimbursement.owner_id, + logger: fastify.log, + }); const { actionable, skipped } = await resolveActionableStep({ tx, steps: workflowSteps, diff --git a/server/src/scripts/audit-org-master-data.js b/server/src/scripts/audit-org-master-data.js index 94abb367..117fa43f 100644 --- a/server/src/scripts/audit-org-master-data.js +++ b/server/src/scripts/audit-org-master-data.js @@ -191,7 +191,12 @@ function buildRoleFindings(employees, pendingAssignedRows, pendingRoleRows, appr })), approvalRulesUseAdmin: approvalRuleRows.map((row) => ({ ruleName: row.name, - roles: [row.level1_role, row.level2_role, row.level3_role].filter(Boolean), + roles: [ + row.level1_role, + row.level2_role, + row.level3_role, + row.owner_self_fallback_role, + ].filter(Boolean), })), }; } @@ -328,13 +333,14 @@ async function queryAll(client) { `); const approvalRuleResult = await client.query(` - SELECT name, level1_role::text, level2_role::text, level3_role::text + SELECT name, level1_role::text, level2_role::text, level3_role::text, owner_self_fallback_role::text FROM approval_rules WHERE enabled = true AND ( level1_role = 'admin' OR level2_role = 'admin' OR level3_role = 'admin' + OR owner_self_fallback_role = 'admin' ) ORDER BY priority ASC, name ASC `); diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index 32a97acb..75ed6615 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -56,6 +56,7 @@ const DEFAULT_APPROVAL_RULES = [ level1_role: "manager", level2_role: "finance", level3_role: null, + owner_self_fallback_role: "finance_mgr", priority: 10, }, { @@ -65,6 +66,7 @@ const DEFAULT_APPROVAL_RULES = [ level1_role: "manager", level2_role: "finance_mgr", level3_role: null, + owner_self_fallback_role: "finance_mgr", priority: 20, }, { @@ -74,6 +76,7 @@ const DEFAULT_APPROVAL_RULES = [ level1_role: "manager", level2_role: "ceo", level3_role: "finance_mgr", + owner_self_fallback_role: "finance_mgr", priority: 30, }, ]; diff --git a/server/src/services/feeControl.js b/server/src/services/feeControl.js index 3aec2171..fbbc5cba 100644 --- a/server/src/services/feeControl.js +++ b/server/src/services/feeControl.js @@ -2,7 +2,7 @@ const { AppError } = require("../utils/AppError"); const { resolveApplicableBudgetPoolByContext } = require("./budgetPool"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags"); const { - buildWorkflowStepsFromApprovalRule, + buildApprovalWorkflowSnapshot, parseWorkflowSteps, resolveActionableStep, } = require("../utils/workflow"); @@ -590,20 +590,40 @@ async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger level1_role: true, level2_role: true, level3_role: true, + owner_self_fallback_role: true, priority: true, }, }); if (!approvalRule) { blocking.push(buildRiskFlagPreview("missing_approval_rule", RISK_LEVELS.high, "缺少审批规则", "当前金额范围未匹配到有效报销审批规则。")); } else { - const workflowSteps = buildWorkflowStepsFromApprovalRule(approvalRule); - const { actionable, skipped } = await resolveActionableStep({ - tx, - steps: workflowSteps, - startIndex: 0, - ownerId: reimbursement.owner_id, - logger, - }); + let actionable = null; + let skipped = []; + try { + const workflowSteps = await buildApprovalWorkflowSnapshot({ + tx, + approvalRule, + ownerId: reimbursement.owner_id, + logger, + }); + const resolved = await resolveActionableStep({ + tx, + steps: workflowSteps, + startIndex: 0, + ownerId: reimbursement.owner_id, + logger, + blockOnUnresolvedAssignee: true, + }); + actionable = resolved.actionable; + skipped = resolved.skipped; + } catch (error) { + if (error instanceof AppError && error.details?.workflow === "no_actionable_approver") { + actionable = null; + skipped = [{ reason: error.details.reason ?? "no_actionable_approver" }]; + } else { + throw error; + } + } if (!actionable || skipped.length) { blocking.push(buildRiskFlagPreview("no_actionable_approver", RISK_LEVELS.high, "缺少可执行审批人", "当前审批链未解析出有效审批人。")); } diff --git a/server/src/utils/workflow.js b/server/src/utils/workflow.js index c795efdd..dab3ebc2 100644 --- a/server/src/utils/workflow.js +++ b/server/src/utils/workflow.js @@ -9,6 +9,14 @@ const BLOCKING_ASSIGNEE_SKIP_REASONS = new Set([ "cycle_detected", "max_depth_exceeded", ]); +const SUPPORTED_OWNER_SELF_FALLBACK_ROLES = new Set(["finance_mgr"]); +const OWNER_SELF_FALLBACK_REASONS = new Set([ + "no_manager_found", + "chain_broken", + "self_loop", + "cycle_detected", + "max_depth_exceeded", +]); function parseWorkflowSteps(definitionJson) { const steps = definitionJson?.steps; @@ -60,6 +68,193 @@ function buildWorkflowStepsFromApprovalRule(approvalRule) { })); } +function isDepartmentManagerRule(rule) { + return rule?.type === "department_manager"; +} + +function getRoleCodeFromStep(step) { + const rule = step?.assignee_rule; + return rule?.type === "role" && typeof rule.value === "string" ? rule.value : null; +} + +function buildRoleApprovalStep(roleCode, index, sourceStep, reason) { + const suffix = reason === "owner_self_fallback" ? "_owner_self_fallback" : ""; + return { + id: `approval_${index + 1}_${roleCode}${suffix}`, + name: + reason === "owner_self_fallback" + ? `负责人本人申请兜底:${roleCode}` + : sourceStep?.name ?? `审批节点${index + 1}`, + type: sourceStep?.type ?? "approval", + assignee_rule: { type: "role", value: roleCode }, + }; +} + +async function loadOwnerRoleCodes(tx, ownerId) { + const owner = await tx.employee.findUnique({ + where: { id: ownerId }, + select: { + id: true, + roles: { + select: { + role_code: true, + }, + }, + }, + }); + + if (!owner) { + return null; + } + + return new Set(owner.roles.map((item) => item.role_code)); +} + +async function hasActiveNonSelfRoleHolder(tx, roleCode, ownerId) { + const holder = await tx.employeeRole.findFirst({ + where: { + role_code: roleCode, + employee_id: { not: ownerId }, + employee: { + status: "active", + archived_at: null, + }, + }, + select: { + employee_id: true, + }, + }); + + return Boolean(holder); +} + +function assertOwnerSelfFallbackRole(roleCode, ownerRoleCodes) { + if (!roleCode) { + return null; + } + + if (!SUPPORTED_OWNER_SELF_FALLBACK_ROLES.has(roleCode)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 400, + details: { ownerSelfFallbackRole: "unsupported_business_approval_role" }, + }); + } + + if (ownerRoleCodes?.has(roleCode)) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { + workflow: "no_actionable_approver", + reason: "owner_self_fallback_self_role", + }, + }); + } + + return roleCode; +} + +async function buildApprovalWorkflowSnapshot({ tx, approvalRule, ownerId, logger }) { + const rawSteps = buildWorkflowStepsFromApprovalRule(approvalRule); + const hasManagerStep = rawSteps.some((step) => isDepartmentManagerRule(step.assignee_rule)); + + if (!hasManagerStep || !approvalRule.owner_self_fallback_role) { + return rawSteps; + } + + const ownerRoleCodes = await loadOwnerRoleCodes(tx, ownerId); + + if (!ownerRoleCodes?.has("ceo")) { + return rawSteps; + } + + const fallbackRole = assertOwnerSelfFallbackRole( + approvalRule.owner_self_fallback_role, + ownerRoleCodes, + ); + const normalizedSteps = []; + const seenRoleCodes = new Set(); + + for (const rawStep of rawSteps) { + const roleCode = getRoleCodeFromStep(rawStep); + if (roleCode) { + if (roleCode === "ceo") { + continue; + } + if (seenRoleCodes.has(roleCode)) { + continue; + } + normalizedSteps.push(rawStep); + seenRoleCodes.add(roleCode); + continue; + } + + if (!isDepartmentManagerRule(rawStep.assignee_rule)) { + normalizedSteps.push(rawStep); + continue; + } + + const managerResult = await resolveDepartmentManagerAssignee({ + tx, + ownerId, + logger, + }); + + if (managerResult.assigneeId) { + normalizedSteps.push(rawStep); + continue; + } + + if (!fallbackRole || !OWNER_SELF_FALLBACK_REASONS.has(managerResult.reason)) { + normalizedSteps.push(rawStep); + continue; + } + + const hasHolder = await hasActiveNonSelfRoleHolder(tx, fallbackRole, ownerId); + if (!hasHolder) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { + workflow: "no_actionable_approver", + reason: "owner_self_fallback_role_unavailable", + fallbackRole, + }, + }); + } + + if (!seenRoleCodes.has(fallbackRole)) { + normalizedSteps.push( + buildRoleApprovalStep( + fallbackRole, + normalizedSteps.length, + rawStep, + "owner_self_fallback", + ), + ); + seenRoleCodes.add(fallbackRole); + } + } + + if (normalizedSteps.length === 0) { + throw new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode: 409, + details: { + workflow: "no_actionable_approver", + reason: "owner_self_fallback_empty_chain", + }, + }); + } + + return normalizedSteps; +} + async function resolveDepartmentManagerAssignee({ tx, ownerId, logger }) { let currentId = ownerId; const visited = new Set(); @@ -322,6 +517,7 @@ function toMoneyString(value) { } module.exports = { + buildApprovalWorkflowSnapshot, buildWorkflowStepsFromApprovalRule, getWorkflowStepsSnapshot, parseWorkflowSteps, diff --git a/server/tests/unit/routes/portal.test.js b/server/tests/unit/routes/portal.test.js index 2e64e737..72b2ee5e 100644 --- a/server/tests/unit/routes/portal.test.js +++ b/server/tests/unit/routes/portal.test.js @@ -2,7 +2,7 @@ const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); -const featureFlagState = { +const featureFlagState = vi.hoisted(() => ({ portalHome: true, announcements: false, presenceBoard: false, @@ -13,7 +13,7 @@ const featureFlagState = { projectDomain: false, projectCommunicationLog: false, projectClosureChecklist: false, -}; +})); vi.mock("../../../src/services/featureFlags", () => ({ FEATURE_FLAG_KEYS: { @@ -224,13 +224,25 @@ async function buildApp(options = {}) { }, featureFlag: { findUnique: vi.fn(async ({ where }) => { - if (where.flag_key !== "portal_home_v1") { + const enabledByFlagKey = { + portal_home_v1: featureFlagState.portalHome, + announcements_v1: featureFlagState.announcements, + presence_board_v1: featureFlagState.presenceBoard, + weekly_report_v1: featureFlagState.weeklyReport, + employee_lifecycle_v1: featureFlagState.employeeLifecycle, + todo_board_v1: featureFlagState.todoBoard, + payment_batch_v1: featureFlagState.paymentBatch, + project_domain_v1: featureFlagState.projectDomain, + project_communication_log_v1: featureFlagState.projectCommunicationLog, + project_closure_checklist_v1: featureFlagState.projectClosureChecklist, + }; + if (!Object.prototype.hasOwnProperty.call(enabledByFlagKey, where.flag_key)) { return null; } return { - id: "flag-portal-home", - flag_key: "portal_home_v1", - override_mode: "force_on", + id: `flag-${where.flag_key}`, + flag_key: where.flag_key, + override_mode: enabledByFlagKey[where.flag_key] ? "force_on" : "force_off", rollout_type: "global", department_ids: [], note: "", @@ -284,16 +296,16 @@ async function buildApp(options = {}) { app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", - portalHomeEnabled: true, - announcementsEnabled: false, - presenceBoardEnabled: false, - weeklyReportEnabled: false, - employeeLifecycleEnabled: false, - todoBoardEnabled: false, - paymentBatchEnabled: false, - projectDomainEnabled: false, - projectCommunicationLogEnabled: false, - projectClosureChecklistEnabled: false, + portalHomeEnabled: featureFlagState.portalHome, + announcementsEnabled: featureFlagState.announcements, + presenceBoardEnabled: featureFlagState.presenceBoard, + weeklyReportEnabled: featureFlagState.weeklyReport, + employeeLifecycleEnabled: featureFlagState.employeeLifecycle, + todoBoardEnabled: featureFlagState.todoBoard, + paymentBatchEnabled: featureFlagState.paymentBatch, + projectDomainEnabled: featureFlagState.projectDomain, + projectCommunicationLogEnabled: featureFlagState.projectCommunicationLog, + projectClosureChecklistEnabled: featureFlagState.projectClosureChecklist, }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { @@ -509,6 +521,7 @@ describe("portal routes", () => { pendingBatchCount: 3, failedItemCount: 2, riskCount: 0, + paymentBatchEnabled: true, href: "/ap-operations", }); diff --git a/server/tests/unit/routes/reimbursements.expense-standards.test.js b/server/tests/unit/routes/reimbursements.expense-standards.test.js index 03322132..28ecd8a9 100644 --- a/server/tests/unit/routes/reimbursements.expense-standards.test.js +++ b/server/tests/unit/routes/reimbursements.expense-standards.test.js @@ -29,6 +29,7 @@ const reimbursementRoutes = require("../../../src/routes/reimbursements"); const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222"; const DEPARTMENT_ID = "33333333-3333-4333-8333-333333333333"; +const FINANCE_MGR_ID = "44444444-4444-4444-8444-444444444444"; function buildPrecheck(overrides = {}) { return { @@ -64,8 +65,22 @@ async function buildApp(options = {}) { form_number: "BX-20260408-001", total_amount: "180.00", }; + const approvalRule = options.approvalRule ?? { + id: "55555555-5555-4555-8555-555555555555", + level1_role: "finance", + level2_role: null, + level3_role: null, + owner_self_fallback_role: null, + priority: 1, + }; + const ownerRoles = options.ownerRoles ?? ["employee"]; + const ownerManagerId = Object.prototype.hasOwnProperty.call(options, "ownerManagerId") + ? options.ownerManagerId + : null; + const fallbackHasHolder = options.fallbackHasHolder ?? true; const state = { workflowInstanceCreatePayload: null, + workflowTaskCreatePayload: null, }; const tx = { @@ -91,13 +106,33 @@ async function buildApp(options = {}) { update: vi.fn(async () => ({ id: "budget-pool-1" })), }, approvalRule: { - findFirst: vi.fn(async () => ({ - id: "55555555-5555-4555-8555-555555555555", - level1_role: "finance", - level2_role: null, - level3_role: null, - priority: 1, - })), + findFirst: vi.fn(async () => approvalRule), + }, + employee: { + findUnique: vi.fn(async ({ where }) => { + if (where.id === reimbursement.owner_id) { + return { + id: reimbursement.owner_id, + direct_manager_id: ownerManagerId, + roles: ownerRoles.map((role_code) => ({ role_code })), + }; + } + + if (where.id === ownerManagerId) { + return { + id: ownerManagerId, + direct_manager_id: null, + roles: [{ role_code: "manager" }], + }; + } + + return null; + }), + }, + employeeRole: { + findFirst: vi.fn(async () => ( + fallbackHasHolder ? { employee_id: FINANCE_MGR_ID } : null + )), }, workflowDefinition: { findFirst: vi.fn(async () => ({ @@ -110,11 +145,14 @@ async function buildApp(options = {}) { })), }, workflowTask: { - create: vi.fn(async () => ({ + create: vi.fn(async ({ data }) => { + state.workflowTaskCreatePayload = data; + return { id: "77777777-7777-4777-8777-777777777777", - assignee_id: null, - assignee_role: "finance", - })), + assignee_id: data.assignee_id, + assignee_role: data.assignee_role, + }; + }), }, workflowHistory: { create: vi.fn(async () => ({ id: "history-1" })), @@ -360,4 +398,80 @@ describe("reimbursements routes expense standard integration", () => { await app.close(); }); + + it("uses finance_mgr fallback and persists the adjusted workflow snapshot for CEO owner submit", async () => { + resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); + + const { app, state } = await buildApp({ + ownerRoles: ["ceo"], + ownerManagerId: null, + approvalRule: { + id: "55555555-5555-4555-8555-555555555555", + level1_role: "manager", + level2_role: "finance", + level3_role: "ceo", + owner_self_fallback_role: "finance_mgr", + priority: 1, + }, + }); + + const response = await app.inject({ + method: "POST", + url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`, + }); + + expect(response.statusCode).toBe(200); + expect(state.workflowTaskCreatePayload).toEqual( + expect.objectContaining({ + assignee_id: null, + assignee_role: "finance_mgr", + }), + ); + expect(state.workflowInstanceCreatePayload.current_step_id).toContain("owner_self_fallback"); + expect( + state.workflowInstanceCreatePayload.context_data.workflow_steps_snapshot.map( + (step) => step.assignee_rule, + ), + ).toEqual([ + { type: "role", value: "finance_mgr" }, + { type: "role", value: "finance" }, + ]); + + await app.close(); + }); + + it("blocks CEO owner submit when owner fallback has no active non-self holder", async () => { + resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck()); + + const { app, tx } = await buildApp({ + ownerRoles: ["ceo"], + ownerManagerId: null, + fallbackHasHolder: false, + approvalRule: { + id: "55555555-5555-4555-8555-555555555555", + level1_role: "manager", + level2_role: "finance", + level3_role: null, + owner_self_fallback_role: "finance_mgr", + priority: 1, + }, + }); + + const response = await app.inject({ + method: "POST", + url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`, + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error.details).toEqual( + expect.objectContaining({ + workflow: "no_actionable_approver", + reason: "owner_self_fallback_role_unavailable", + fallbackRole: "finance_mgr", + }), + ); + expect(tx.workflowInstance.create).not.toHaveBeenCalled(); + + await app.close(); + }); }); diff --git a/server/tests/unit/utils/workflow.test.js b/server/tests/unit/utils/workflow.test.js index fac9d9a8..13d2c368 100644 --- a/server/tests/unit/utils/workflow.test.js +++ b/server/tests/unit/utils/workflow.test.js @@ -3,6 +3,8 @@ const workflowUtils = require("../../../src/utils/workflow"); const { resolveActionableStep } = workflowUtils; const OWNER_ID = "11111111-1111-4111-8111-111111111111"; +const MANAGER_ID = "22222222-2222-4222-8222-222222222222"; +const FINANCE_MGR_ID = "33333333-3333-4333-8333-333333333333"; function buildSteps() { return [ @@ -21,6 +23,41 @@ function buildSteps() { ]; } +function buildTx({ + ownerRoles = ["ceo"], + managerId = null, + fallbackHasHolder = true, +} = {}) { + return { + employee: { + findUnique: vi.fn(async ({ where }) => { + if (where.id === OWNER_ID) { + return { + id: OWNER_ID, + direct_manager_id: managerId, + roles: ownerRoles.map((role_code) => ({ role_code })), + }; + } + + if (where.id === MANAGER_ID) { + return { + id: MANAGER_ID, + direct_manager_id: null, + roles: [{ role_code: "manager" }], + }; + } + + return null; + }), + }, + employeeRole: { + findFirst: vi.fn(async () => ( + fallbackHasHolder ? { employee_id: FINANCE_MGR_ID } : null + )), + }, + }; +} + describe("workflow assignee resolution", () => { describe("parseWorkflowSteps", () => { it("throws error for invalid steps array", () => { @@ -51,6 +88,171 @@ describe("workflow assignee resolution", () => { }); }); + describe("buildApprovalWorkflowSnapshot", () => { + it("keeps ordinary employee approval snapshots unchanged", async () => { + const tx = buildTx({ ownerRoles: ["employee"], managerId: MANAGER_ID }); + + const result = await workflowUtils.buildApprovalWorkflowSnapshot({ + tx, + ownerId: OWNER_ID, + approvalRule: { + level1_role: "manager", + level2_role: "finance", + owner_self_fallback_role: "finance_mgr", + }, + }); + + expect(result.map((step) => step.assignee_rule)).toEqual([ + { type: "department_manager" }, + { type: "role", value: "finance" }, + ]); + expect(tx.employeeRole.findFirst).not.toHaveBeenCalled(); + }); + + it("uses finance_mgr as non-self fallback for CEO owner applications", async () => { + const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); + + const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ + tx, + ownerId: OWNER_ID, + approvalRule: { + level1_role: "manager", + level2_role: "finance", + owner_self_fallback_role: "finance_mgr", + }, + }); + + expect(steps.map((step) => step.assignee_rule)).toEqual([ + { type: "role", value: "finance_mgr" }, + { type: "role", value: "finance" }, + ]); + expect(steps[0].id).toContain("owner_self_fallback"); + + const resolved = await resolveActionableStep({ + tx, + steps, + startIndex: 0, + ownerId: OWNER_ID, + blockOnUnresolvedAssignee: true, + }); + expect(resolved.actionable.assigneeRole).toBe("finance_mgr"); + }); + + it("removes CEO self node and duplicate fallback role for large CEO owner applications", async () => { + const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); + + const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ + tx, + ownerId: OWNER_ID, + approvalRule: { + level1_role: "manager", + level2_role: "ceo", + level3_role: "finance_mgr", + owner_self_fallback_role: "finance_mgr", + }, + }); + + expect(steps.map((step) => step.assignee_rule)).toEqual([ + { type: "role", value: "finance_mgr" }, + ]); + }); + + it("keeps missing-manager blocking behavior when no owner fallback is configured", async () => { + const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); + + const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ + tx, + ownerId: OWNER_ID, + approvalRule: { + level1_role: "manager", + level2_role: "finance", + owner_self_fallback_role: null, + }, + }); + + await expect( + resolveActionableStep({ + tx, + steps, + startIndex: 0, + ownerId: OWNER_ID, + blockOnUnresolvedAssignee: true, + }), + ).rejects.toMatchObject({ + details: { + workflow: "no_actionable_approver", + reason: "no_manager_found", + }, + }); + }); + + it("blocks fallback when no active non-self holder exists", async () => { + const tx = buildTx({ + ownerRoles: ["ceo"], + managerId: null, + fallbackHasHolder: false, + }); + + await expect( + workflowUtils.buildApprovalWorkflowSnapshot({ + tx, + ownerId: OWNER_ID, + approvalRule: { + level1_role: "manager", + owner_self_fallback_role: "finance_mgr", + }, + }), + ).rejects.toMatchObject({ + details: { + workflow: "no_actionable_approver", + reason: "owner_self_fallback_role_unavailable", + }, + }); + }); + + it("blocks fallback when the owner also holds the fallback role", async () => { + const tx = buildTx({ + ownerRoles: ["ceo", "finance_mgr"], + managerId: null, + }); + + await expect( + workflowUtils.buildApprovalWorkflowSnapshot({ + tx, + ownerId: OWNER_ID, + approvalRule: { + level1_role: "manager", + owner_self_fallback_role: "finance_mgr", + }, + }), + ).rejects.toMatchObject({ + details: { + workflow: "no_actionable_approver", + reason: "owner_self_fallback_self_role", + }, + }); + }); + + it("blocks unsupported owner self fallback roles before creating a task", async () => { + const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); + + await expect( + workflowUtils.buildApprovalWorkflowSnapshot({ + tx, + ownerId: OWNER_ID, + approvalRule: { + level1_role: "manager", + owner_self_fallback_role: "hr", + }, + }), + ).rejects.toMatchObject({ + details: { + ownerSelfFallbackRole: "unsupported_business_approval_role", + }, + }); + }); + }); + describe("resolveAssigneeFromRule", () => { it("resolves role type", async () => { const result = await workflowUtils.resolveAssigneeFromRule({ type: "role", value: "hr" });