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

这个提交包含在:
2026-05-24 21:12:18 +08:00
父节点 d6ae1cff89
当前提交 2e778d3c9f
共修改 23 个文件,包含 1324 行新增和 86 行删除
+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,
},
];
+29 -9
查看文件
@@ -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({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: reimbursement.owner_id,
logger,
});
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,