OAFLOW1 支持负责人本人报销兜底审批
这个提交包含在:
@@ -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;
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户