547 行
17 KiB
JavaScript
547 行
17 KiB
JavaScript
#!/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;
|
|
});
|