OAFLOW1 支持负责人本人报销兜底审批

这个提交包含在:
2026-05-24 21:12:18 +08:00
父节点 d6ae1cff89
当前提交 2e778d3c9f
共修改 23 个文件,包含 1324 行新增和 86 行删除
+1
查看文件
@@ -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`.
+3 -1
查看文件
@@ -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),
});
}
+50 -2
查看文件
@@ -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<ApprovalRoleCode>("manager");
const [level2Role, setLevel2Role] = useState<ApprovalRoleCode | "">("");
const [level3Role, setLevel3Role] = useState<ApprovalRoleCode | "">("");
const [ownerSelfFallbackRole, setOwnerSelfFallbackRole] = useState<ApprovalOwnerSelfFallbackRoleCode | "">("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() {
</select>
</div>
<div className="space-y-1">
<Label htmlFor="ownerSelfFallbackRole">负责人本人兜底角色</Label>
<select
id="ownerSelfFallbackRole"
data-testid="approval-rules-create-owner-self-fallback"
className="select-std"
value={ownerSelfFallbackRole}
onChange={(event) => setOwnerSelfFallbackRole(event.target.value as ApprovalOwnerSelfFallbackRoleCode)}
>
<option value="">无</option>
{OWNER_SELF_FALLBACK_ROLE_OPTIONS.map((role) => (
<option key={role.code} value={role.code}>
{role.label}
</option>
))}
</select>
<p className="text-xs text-zinc-500">推荐 finance_mgr;不使用 admin。</p>
</div>
<div className="flex items-center gap-2 pt-6">
<input
id="ruleEnabled"
@@ -328,7 +355,10 @@ export function ApprovalRulesPage() {
{formatAmount(rule.minAmount)} ~ {rule.maxAmount ? formatAmount(rule.maxAmount) : "∞"}
</TableCell>
<TableCell className="text-sm text-zinc-700">
{[rule.level1Role, rule.level2Role, rule.level3Role].filter(Boolean).join(" -> ") || "-"}
<div>{[rule.level1Role, rule.level2Role, rule.level3Role].filter(Boolean).join(" -> ") || "-"}</div>
<div className="mt-1 text-xs text-zinc-500">
本人兜底:{rule.ownerSelfFallbackRole ?? "无"}
</div>
</TableCell>
<TableCell>{rule.priority}</TableCell>
<TableCell className="text-sm">
@@ -483,6 +513,24 @@ export function ApprovalRulesPage() {
</select>
</div>
<div className="space-y-1">
<Label htmlFor="editRuleOwnerSelfFallback">负责人本人兜底角色</Label>
<select
id="editRuleOwnerSelfFallback"
data-testid="approval-rule-edit-owner-self-fallback"
className="select-std"
value={ownerSelfFallbackRole}
onChange={(event) => setOwnerSelfFallbackRole(event.target.value as ApprovalOwnerSelfFallbackRoleCode)}
>
<option value="">无</option>
{OWNER_SELF_FALLBACK_ROLE_OPTIONS.map((role) => (
<option key={role.code} value={role.code}>
{role.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2 pt-6">
<input
id="editRuleEnabled"
-32
查看文件
@@ -2,30 +2,6 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
const PAGE_CHUNK_PATTERNS = {
"page-travel": ["TravelRequestsPage"],
"page-assets": ["AssetRegistryPage", "InventoryChecksPage"],
"page-procurement": ["PurchaseRequestsPage"],
"page-workflow": ["WorkflowTasksPage", "WorkflowTaskDetailPage"],
"page-system": [
"AuditLogsPage",
"ApprovalRulesPage",
"DelegationsPage",
"DepartmentsPage",
"ErpIntegrationPage",
"FeatureFlagsPage",
"InvoicePoolPage",
"LeaveRequestPage",
"MasterDataPage",
"NotificationChannelsPage",
"NotificationsPage",
"ReimbursementDetailPage",
"ReimbursementsPage",
"UserManagementPage",
],
"page-mobile": ["MobileCapturePage", "MobileAssetScanPage", "MobileApprovalsPage"],
} as const;
function resolveManualChunk(id: string) {
if (id.includes("node_modules")) {
if (["react", "react-dom", "react-router-dom"].some((pkg) => 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;
}
+18
查看文件
@@ -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 非本人持有人,或申请人本人也持有兜底角色,系统继续阻断。
+7 -6
查看文件
@@ -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 历史恢复 |
+1 -1
查看文件
@@ -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` 使用的最小流程矩阵 |
+25 -3
查看文件
@@ -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
+20
查看文件
@@ -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. 入口与任务视图
@@ -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。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 [] 本行仅建合同,不改代码、不改数据库、不部署;当前线上阻断符合现有规约,但暴露负责人本人申请缺少非本人兜底规则。
3 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"] 真实票据、银行回单、合同正文、敏感报价和未脱敏票面不得进入仓库、聊天或自动化报告;这只是启用验收路径选择,不是业务规则永久绕行。
4 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 未发现敏感值;未写业务数据、未创建待办、未改配置。
5 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"。
6 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。
7 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 代替业务审批。
8 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。
9 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,不进入公网部署。
10 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 回滚,低风险验证报销单若未进入付款按撤回或驳回留痕处理,已生成的审计日志保留不删除。
11 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。
@@ -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=<no> 目标员工号,默认 HYD000006 或 OAFLOW1_TARGET_EMPLOYEE_NO。
--report-dir=<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;
});
@@ -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;
+1
查看文件
@@ -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
+32 -1
查看文件
@@ -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;
+2
查看文件
@@ -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,
+8 -2
查看文件
@@ -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,
+8 -2
查看文件
@@ -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
`);
+3
查看文件
@@ -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,
},
];
+23 -3
查看文件
@@ -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({
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, "缺少可执行审批人", "当前审批链未解析出有效审批人。"));
}
+196
查看文件
@@ -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,
+29 -16
查看文件
@@ -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",
});
@@ -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();
});
});
+202
查看文件
@@ -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" });