#!/usr/bin/env node import { createRequire } from "node:module"; import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import process from "node:process"; const require = createRequire(import.meta.url); const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", ".."); const MAX_MANAGER_DEPTH = 10; const APPROVAL_ROLES = ["manager", "finance_mgr", "finance", "ceo", "hr", "administration"]; function loadPgPool() { const candidates = [ "pg", path.join(process.cwd(), "node_modules", "pg"), path.join(rootDir, "server", "node_modules", "pg"), "/app/server/node_modules/pg", ]; for (const candidate of candidates) { try { return require(candidate).Pool; } catch { // Try the next known runtime layout. } } throw new Error("Unable to load pg. Run from server container or install server dependencies."); } function parseArgs(argv) { const options = { employeeNo: process.env.OAFLOW1_TARGET_EMPLOYEE_NO || "HYD000006", reportDir: process.env.OAFLOW1_REPORT_DIR || path.resolve( `artifacts/qa-flow/owner-application-approval-route-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`, ), json: false, help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--help" || arg === "-h") { options.help = true; } else if (arg === "--json") { options.json = true; } else if (arg === "--employee-no") { options.employeeNo = String(argv[index + 1] || "").trim(); index += 1; } else if (arg.startsWith("--employee-no=")) { options.employeeNo = arg.slice("--employee-no=".length).trim(); } else if (arg === "--report-dir") { options.reportDir = String(argv[index + 1] || "").trim(); index += 1; } else if (arg.startsWith("--report-dir=")) { options.reportDir = arg.slice("--report-dir=".length).trim(); } else { throw new Error(`Unsupported argument: ${arg}`); } } if (!options.employeeNo) { throw new Error("Missing --employee-no"); } if (!options.reportDir) { throw new Error("Missing --report-dir"); } return options; } function printHelp() { process.stdout.write(`Usage: node scripts/qa/check-owner-application-approval-route.mjs [options] 只读诊断负责人本人提交报销时的审批链解析风险。 Options: --employee-no= 目标员工号,默认 HYD000006 或 OAFLOW1_TARGET_EMPLOYEE_NO。 --report-dir= 报告目录,默认 artifacts/qa-flow/owner-application-approval-route-*。 --json 同时向 stdout 输出 summary.json 内容。 --help 显示帮助。 边界: 只读数据库事务;不提交报销;不创建待办;不修改员工、审批规则或生产配置。 报告只输出员工号、状态、角色、规则名和脱敏 finding。 `); } function isActive(employee) { return employee?.status === "active" && !employee?.archived; } function isRootLike(employee) { const roles = new Set(employee?.roles || []); return roles.has("ceo") || (roles.has("admin") && !roles.has("manager")); } function safeEmployee(employee) { if (!employee) return null; return { employeeNo: employee.employee_no, status: employee.status, archived: Boolean(employee.archived), roles: employee.roles || [], directManagerEmployeeNo: employee.manager_employee_no || null, }; } function safeRule(rule) { return { name: rule.name, minAmount: String(rule.min_amount), maxAmount: rule.max_amount == null ? null : String(rule.max_amount), roles: [rule.level1_role, rule.level2_role, rule.level3_role].filter(Boolean), ownerSelfFallbackRole: rule.owner_self_fallback_role || null, priority: Number(rule.priority), }; } function buildRoleHolders(employees) { const holders = {}; for (const role of APPROVAL_ROLES) holders[role] = []; for (const employee of employees) { if (!isActive(employee)) continue; for (const role of employee.roles || []) { if (!holders[role]) holders[role] = []; holders[role].push(employee); } } return holders; } function resolveDepartmentManager(owner, employeesById) { let currentId = owner.id; const visited = new Set(); for (let depth = 1; depth <= MAX_MANAGER_DEPTH; depth += 1) { if (visited.has(currentId)) { return { status: "UNRESOLVED", reason: "cycle_detected", depthChecked: depth - 1 }; } visited.add(currentId); const current = employeesById.get(currentId); if (!current) { return { status: "UNRESOLVED", reason: "chain_broken", depthChecked: depth }; } const managerId = current.direct_manager_id; if (!managerId) { return { status: "UNRESOLVED", reason: "no_manager_found", depthChecked: depth }; } if (managerId === currentId) { return { status: "UNRESOLVED", reason: "self_loop", depthChecked: depth }; } const manager = employeesById.get(managerId); if (!manager) { return { status: "UNRESOLVED", reason: "chain_broken", depthChecked: depth }; } if (manager.id === owner.id) { return { status: "BLOCKED", reason: "self_approval_candidate", depthChecked: depth, assignee: safeEmployee(manager), }; } if (!isActive(manager)) { return { status: "BLOCKED", reason: "manager_inactive_or_archived", depthChecked: depth, assignee: safeEmployee(manager), }; } return { status: "ACTIONABLE", reason: null, depthChecked: depth, assignee: safeEmployee(manager), }; } return { status: "UNRESOLVED", reason: "max_depth_exceeded", depthChecked: MAX_MANAGER_DEPTH }; } function resolveRoleStep(roleCode, owner, roleHolders) { const holders = roleHolders[roleCode] || []; const nonSelfHolders = holders.filter((employee) => employee.id !== owner.id); const selfOnly = holders.length > 0 && nonSelfHolders.length === 0; if (nonSelfHolders.length > 0) { return { status: "ACTIONABLE_ROLE", roleCode, activeNonSelfHolderCount: nonSelfHolders.length, sampleHolderEmployeeNos: nonSelfHolders.slice(0, 5).map((employee) => employee.employee_no), }; } return { status: "BLOCKED", roleCode, reason: selfOnly ? "role_holders_self_only" : "role_without_active_nonself_holder", activeNonSelfHolderCount: 0, }; } function evaluateRule(rule, owner, employeesById, roleHolders) { const roles = [rule.level1_role, rule.level2_role, rule.level3_role].filter(Boolean); const steps = []; const seenRoleCodes = new Set(); for (let index = 0; index < roles.length; index += 1) { const roleCode = roles[index]; if (roleCode === "manager") { const resolved = resolveDepartmentManager(owner, employeesById); const step = { index, roleCode, assigneeRule: "department_manager", ...resolved, }; steps.push(step); if (resolved.status !== "ACTIONABLE") { if (isRootLike(owner) && rule.owner_self_fallback_role) { const fallback = resolveRoleStep(rule.owner_self_fallback_role, owner, roleHolders); const fallbackStep = { index, roleCode: rule.owner_self_fallback_role, assigneeRule: "owner_self_fallback_role", fallbackForRole: roleCode, managerReason: resolved.reason, ...fallback, }; steps.push(fallbackStep); if (fallback.status === "ACTIONABLE_ROLE") { seenRoleCodes.add(rule.owner_self_fallback_role); return { rule: safeRule(rule), submitSimulation: "WOULD_CREATE_OWNER_SELF_FALLBACK_ROLE_TASK", firstActionable: fallbackStep, steps, }; } return { rule: safeRule(rule), submitSimulation: "BLOCKED", blocker: { stepIndex: index, roleCode: rule.owner_self_fallback_role, reason: fallback.reason, fallbackForRole: roleCode, }, steps, }; } return { rule: safeRule(rule), submitSimulation: "BLOCKED", blocker: { stepIndex: index, roleCode, reason: resolved.reason, }, steps, }; } return { rule: safeRule(rule), submitSimulation: "WOULD_CREATE_TASK", firstActionable: step, steps, }; } if (isRootLike(owner) && roleCode === "ceo") { steps.push({ index, roleCode, assigneeRule: "role", status: "SKIPPED", reason: "owner_self_ceo_role_removed", }); continue; } if (seenRoleCodes.has(roleCode)) { steps.push({ index, roleCode, assigneeRule: "role", status: "SKIPPED", reason: "duplicate_role_removed", }); continue; } const resolved = resolveRoleStep(roleCode, owner, roleHolders); const step = { index, roleCode, assigneeRule: "role", ...resolved, }; steps.push(step); if (resolved.status !== "ACTIONABLE_ROLE") { return { rule: safeRule(rule), submitSimulation: "BLOCKED", blocker: { stepIndex: index, roleCode, reason: resolved.reason, }, steps, }; } seenRoleCodes.add(roleCode); return { rule: safeRule(rule), submitSimulation: "WOULD_CREATE_ROLE_TASK", firstActionable: step, steps, }; } return { rule: safeRule(rule), submitSimulation: "BLOCKED", blocker: { reason: "empty_approval_chain" }, steps, }; } function collectFallbackCandidates(owner, roleHolders) { return APPROVAL_ROLES .filter((roleCode) => roleCode !== "administration") .map((roleCode) => { const holders = (roleHolders[roleCode] || []).filter((employee) => employee.id !== owner.id); return { roleCode, activeNonSelfHolderCount: holders.length, sampleHolderEmployeeNos: holders.slice(0, 5).map((employee) => employee.employee_no), }; }) .filter((item) => item.activeNonSelfHolderCount > 0); } function collectFindings(owner, evaluations, fallbackCandidates) { const findings = []; if (isRootLike(owner) && !owner.direct_manager_id) { findings.push({ code: "owner_root_like_no_direct_manager", severity: "info", employeeNo: owner.employee_no, reason: "负责人/root-like 员工没有直属主管,这本身可接受,但不能直接用于 manager 节点审批。", }); } const managerBlockedRules = evaluations.filter( (item) => item.blocker?.roleCode === "manager" && item.blocker?.reason === "no_manager_found", ); if (isRootLike(owner) && managerBlockedRules.length > 0) { findings.push({ code: "owner_self_application_missing_fallback", severity: "blocker_for_owner_self_application", employeeNo: owner.employee_no, affectedRuleCount: managerBlockedRules.length, reason: "负责人本人申请命中 manager/直属主管节点,但本人没有直属主管,且当前审批规则没有非本人兜底配置。", }); } if (managerBlockedRules.length > 0 && fallbackCandidates.length > 0) { findings.push({ code: "fallback_candidate_roles_available_but_not_configured", severity: "recommendation", employeeNo: owner.employee_no, roles: fallbackCandidates.map((item) => ({ roleCode: item.roleCode, activeNonSelfHolderCount: item.activeNonSelfHolderCount, })), reason: "系统存在可考虑的非本人复核角色,但当前审批规则还不能表达负责人本人申请兜底。", }); } return findings; } async function queryAll(client) { const employeesResult = await client.query(` SELECT e.id::text, e.employee_no, e.status::text, (e.archived_at IS NOT NULL) AS archived, e.direct_manager_id::text, dm.employee_no AS manager_employee_no, COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles FROM employees e LEFT JOIN employees dm ON dm.id = e.direct_manager_id LEFT JOIN employee_roles er ON er.employee_id = e.id GROUP BY e.id, e.employee_no, e.status, e.archived_at, e.direct_manager_id, dm.employee_no ORDER BY e.employee_no ASC `); const rulesResult = await client.query(` SELECT id::text, name, min_amount::text, max_amount::text, level1_role::text, level2_role::text, level3_role::text, owner_self_fallback_role::text, priority, enabled FROM approval_rules WHERE enabled = true ORDER BY priority ASC, created_at ASC `); return { employees: employeesResult.rows, approvalRules: rulesResult.rows }; } async function run(options) { if (!process.env.DATABASE_URL) { throw new Error("Missing DATABASE_URL"); } const Pool = loadPgPool(); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const client = await pool.connect(); try { await client.query("BEGIN READ ONLY"); const queried = await queryAll(client); await client.query("COMMIT"); const employeesById = new Map(queried.employees.map((employee) => [employee.id, employee])); const owner = queried.employees.find((employee) => employee.employee_no === options.employeeNo); if (!owner) { return { mode: "owner-application-approval-route", status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG", checkedAt: new Date().toISOString(), targetEmployeeNo: options.employeeNo, privacy: "employee_no/status/roles/rule names only; no phone/email/password/token/secret/attachments/raw payload", findings: [{ code: "target_employee_not_found", severity: "blocker", employeeNo: options.employeeNo, }], }; } const roleHolders = buildRoleHolders(queried.employees); const evaluations = queried.approvalRules.map((rule) => evaluateRule(rule, owner, employeesById, roleHolders)); const fallbackCandidates = collectFallbackCandidates(owner, roleHolders); const findings = collectFindings(owner, evaluations, fallbackCandidates); const hasOwnerBlocker = findings.some((item) => item.severity === "blocker_for_owner_self_application" || item.severity === "blocker"); return { mode: "owner-application-approval-route", status: hasOwnerBlocker ? "PASS_WITH_FINDINGS" : "PASS_OWNER_APPLICATION_ROUTE_READY", checkedAt: new Date().toISOString(), target: safeEmployee(owner), targetClassification: { rootLike: isRootLike(owner), active: isActive(owner), }, privacy: "employee_no/status/roles/rule names only; no phone/email/password/token/secret/attachments/raw payload", summary: { activeEmployeeCount: queried.employees.filter(isActive).length, activeApprovalRuleCount: queried.approvalRules.length, findingCount: findings.length, ownerSelfApplicationBlocked: hasOwnerBlocker, }, fallbackCandidates, approvalRuleEvaluations: evaluations, findings, boundaries: { readOnlyTransaction: true, noBusinessWrites: true, noReimbursementSubmit: true, noWorkflowTaskCreation: true, noConfigChanges: true, noDeployment: true, }, }; } catch (error) { try { await client.query("ROLLBACK"); } catch { // Preserve original error. } throw error; } finally { client.release(); await pool.end(); } } function printText(summary) { process.stdout.write(`[owner-approval-route] status=${summary.status}\n`); process.stdout.write(`[owner-approval-route] target=${summary.target?.employeeNo || summary.targetEmployeeNo}\n`); process.stdout.write(`[owner-approval-route] findings=${summary.summary?.findingCount ?? summary.findings?.length ?? 0}\n`); for (const finding of summary.findings || []) { process.stdout.write(`- ${finding.code}: ${finding.severity}\n`); } } async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); return; } const summary = await run(options); mkdirSync(options.reportDir, { recursive: true }); const summaryPath = path.join(options.reportDir, "summary.json"); writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); if (options.json) { process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); } else { printText(summary); } process.stdout.write(`[owner-approval-route] summary=${path.relative(process.cwd(), summaryPath)}\n`); } main().catch((error) => { process.stderr.write(`[owner-approval-route][FAIL] ${error?.message || error}\n`); process.exitCode = 1; });