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

这个提交包含在:
2026-05-24 21:12:18 +08:00
父节点 d6ae1cff89
当前提交 2e778d3c9f
共修改 23 个文件,包含 1324 行新增和 86 行删除
@@ -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;
});