文件
hyb-oa/server/src/services/feeControl.js
T

1072 行
32 KiB
JavaScript

const { AppError } = require("../utils/AppError");
const { resolveApplicableBudgetPoolByContext } = require("./budgetPool");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
const {
buildApprovalWorkflowSnapshot,
parseWorkflowSteps,
resolveActionableStep,
} = require("../utils/workflow");
const PRECHECK_DECISIONS = Object.freeze(["pass", "warn", "manual_review", "block"]);
const PRECHECK_PERMISSION_CODES = Object.freeze({
reimbursement: "expense.submit",
purchase_request: "procurement.submit",
travel_request: "travel.submit",
});
const BUDGET_RISK_CODES = Object.freeze(["budget_exceeded", "budget_threshold_exceeded"]);
const RISK_LEVELS = Object.freeze({
low: "low",
medium: "medium",
high: "high",
});
function buildNotFoundError() {
return new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function toMoneyNumber(value) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) {
return 0;
}
return Number(numeric.toFixed(2));
}
function toMoneyString(value) {
return toMoneyNumber(value).toFixed(2);
}
function buildBasePrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["可直接提交"],
};
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildIssue(code, title, detail) {
return {
code,
title,
detail,
};
}
function buildRiskFlagPreview(code, level, title, detail) {
return {
code,
level,
title,
detail,
willOpenOnSubmit: true,
};
}
function buildPersistedRiskFlag(record) {
return {
id: record.id,
entityType: record.entity_type,
entityId: record.entity_id,
riskCode: record.risk_code,
level: record.level,
title: record.title,
detail: record.detail ?? null,
source: record.source,
status: record.status,
payloadJson: record.payload_json ?? null,
createdAt: record.created_at?.toISOString?.() ?? null,
createdBy: record.created_by ?? null,
resolvedAt: record.resolved_at?.toISOString?.() ?? null,
resolvedBy: record.resolved_by ?? null,
};
}
function normalizeFallbackControlMode(config) {
const mode = String(config?.budgetControlMode ?? "warn").trim().toLowerCase();
if (mode === "block" || mode === "manual_review") {
return mode;
}
return "warn";
}
function computeRuleScopeRank(rule) {
if (rule.cost_center_id && rule.legal_entity_id) {
return 0;
}
if (rule.department_id) {
return 1;
}
return 2;
}
function matchesBudgetControlRule(rule, context) {
if (!rule?.enabled || rule?.archived_at) {
return false;
}
if (rule.entity_type !== "all" && rule.entity_type !== context.entityType) {
return false;
}
if (rule.department_id && rule.department_id !== context.departmentId) {
return false;
}
if (rule.legal_entity_id && rule.legal_entity_id !== context.legalEntityId) {
return false;
}
if (rule.cost_center_id && rule.cost_center_id !== context.costCenterId) {
return false;
}
if (context.entityType !== "reimbursement" && rule.expense_type_id) {
return false;
}
if (context.entityType === "reimbursement" && rule.expense_type_id) {
return context.expenseTypeIds.includes(rule.expense_type_id);
}
return true;
}
async function resolveBudgetControlRule(tx, context) {
const rules = await tx.budgetControlRule.findMany({
where: {
enabled: true,
archived_at: null,
entity_type: { in: ["all", context.entityType] },
},
orderBy: [{ priority: "asc" }, { created_at: "asc" }],
});
const matched = rules
.filter((rule) => matchesBudgetControlRule(rule, context))
.sort((left, right) => {
const scopeDiff = computeRuleScopeRank(left) - computeRuleScopeRank(right);
if (scopeDiff !== 0) {
return scopeDiff;
}
if (left.priority !== right.priority) {
return left.priority - right.priority;
}
return new Date(left.created_at).getTime() - new Date(right.created_at).getTime();
})[0] ?? null;
if (!matched) {
return null;
}
return {
id: matched.id,
name: matched.name,
controlMode: matched.control_mode,
record: matched,
};
}
function buildBudgetHint(budgetPool, currentAmount, controlMode) {
const allocatedAmount = toMoneyNumber(budgetPool?.allocated_amount);
const usedAmount = toMoneyNumber(budgetPool?.used_amount);
const amount = toMoneyNumber(currentAmount);
const nextUsedAmount = toMoneyNumber(usedAmount + amount);
const nextUsagePercent = allocatedAmount > 0 ? Number(((nextUsedAmount / allocatedAmount) * 100).toFixed(2)) : 0;
return {
budgetId: budgetPool.id,
budgetCode: budgetPool.code,
budgetName: budgetPool.name,
controlMode,
warningThresholdPercent: Number(budgetPool.warning_threshold_percent ?? 80),
allocatedAmount: toMoneyString(allocatedAmount),
usedAmount: toMoneyString(usedAmount),
remainingAmount: toMoneyString(Math.max(0, allocatedAmount - usedAmount)),
currentAmount: toMoneyString(amount),
nextUsedAmount: toMoneyString(nextUsedAmount),
nextUsagePercent,
exceedsThreshold: nextUsagePercent >= Number(budgetPool.warning_threshold_percent ?? 80),
exceedsBudget: nextUsedAmount > allocatedAmount,
};
}
function applyDecisionByIssues(response, issues, matchedRule, config) {
if (!issues.length) {
response.nextActions = ["可直接提交"];
return response;
}
const controlMode = matchedRule?.controlMode ?? normalizeFallbackControlMode(config);
if (response.budgetHint) {
response.budgetHint.controlMode = controlMode;
}
response.matchedBudgetControlRule = matchedRule
? {
id: matchedRule.id,
name: matchedRule.name,
controlMode: matchedRule.controlMode,
}
: null;
if (controlMode === "block") {
response.decision = "block";
response.blockingReasons.push(...issues.map((item) => buildIssue(item.code, item.title, item.detail)));
response.nextActions = ["修正阻断项后再提交"];
return response;
}
if (controlMode === "manual_review") {
response.decision = "manual_review";
response.riskFlags.push(
...issues.map((item) => buildRiskFlagPreview(item.code, item.level, item.title, item.detail)),
);
response.nextActions = ["允许提交,但会生成待复核风险项"];
return response;
}
response.decision = "warn";
response.warnings.push(...issues.map((item) => buildIssue(item.code, item.title, item.detail)));
response.nextActions = ["确认提示后可继续提交"];
return response;
}
function appendBlockingReasons(response, issues) {
if (!issues.length) {
return response;
}
response.decision = "block";
response.blockingReasons.push(...issues.map((item) => buildIssue(item.code, item.title, item.detail)));
response.nextActions = ["修正阻断项后再提交"];
return response;
}
function buildStandardIssueDetail(check) {
return `标准:${check.standardName},上限 ¥${check.maxAmount}/${check.perUnit},当前 ¥${check.amount},超出 ¥${check.exceededAmount}`;
}
function buildStandardIssue(check) {
return buildIssue(
`expense_standard_${check.controlMode}_${check.itemId}`,
`${check.expenseTypeName} 超出费用标准`,
buildStandardIssueDetail(check),
);
}
async function checkExpenseStandards(tx, { reimbursementId }) {
const reimbursement = await tx.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
department_id: true,
items: {
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
expense_type_id: true,
amount: true,
expense_type: {
select: {
id: true,
name: true,
},
},
},
},
},
});
if (!reimbursement) {
throw buildNotFoundError();
}
const expenseTypeIds = Array.from(
new Set(reimbursement.items.map((item) => item.expense_type_id).filter(Boolean)),
);
if (!expenseTypeIds.length) {
return [];
}
const standards = await tx.expenseStandard.findMany({
where: {
enabled: true,
expense_type_id: { in: expenseTypeIds },
OR: [
{ scope_type: "global" },
...(reimbursement.department_id
? [{ scope_type: "department", scope_key: reimbursement.department_id }]
: []),
],
},
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
expense_type_id: true,
name: true,
max_amount: true,
per_unit: true,
control_mode: true,
scope_type: true,
scope_key: true,
},
});
const standardMap = new Map();
for (const standard of standards) {
const current = standardMap.get(standard.expense_type_id);
if (!current) {
standardMap.set(standard.expense_type_id, standard);
continue;
}
const currentPriority = current.scope_type === "department" ? 2 : 1;
const nextPriority = standard.scope_type === "department" ? 2 : 1;
if (nextPriority > currentPriority) {
standardMap.set(standard.expense_type_id, standard);
}
}
return reimbursement.items
.map((item) => {
const standard = standardMap.get(item.expense_type_id);
if (!standard) {
return null;
}
const amount = toMoneyNumber(item.amount);
const maxAmount = toMoneyNumber(standard.max_amount);
const exceededAmount = Math.max(0, toMoneyNumber(amount - maxAmount));
return {
itemId: item.id,
expenseTypeId: item.expense_type_id,
expenseTypeName: item.expense_type?.name ?? item.expense_type_id,
amount: toMoneyString(amount),
standardId: standard.id,
standardName: standard.name,
maxAmount: toMoneyString(maxAmount),
perUnit: standard.per_unit,
controlMode: standard.control_mode,
scopeType: standard.scope_type,
scopeKey: standard.scope_key ?? null,
exceeded: amount > maxAmount,
exceededAmount: toMoneyString(exceededAmount),
};
})
.filter(Boolean);
}
function mergeReimbursementPrecheck(precheck, standardChecks) {
const response = {
...precheck,
warnings: [...precheck.warnings],
blockingReasons: [...precheck.blockingReasons],
riskFlags: [...precheck.riskFlags],
nextActions: [...precheck.nextActions],
standardChecks,
};
const warnChecks = standardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
const blockChecks = standardChecks.filter((item) => item.exceeded && item.controlMode === "block");
if (warnChecks.length) {
response.warnings.push(...warnChecks.map((item) => buildStandardIssue(item)));
}
if (blockChecks.length) {
response.blockingReasons.push(...blockChecks.map((item) => buildStandardIssue(item)));
}
if (response.blockingReasons.length) {
response.decision = "block";
response.nextActions = ["修正阻断项后再提交"];
return response;
}
if (response.riskFlags.length) {
response.decision = "manual_review";
response.nextActions = ["允许提交,但会生成待复核风险项"];
return response;
}
if (response.warnings.length) {
response.decision = "warn";
response.nextActions = ["确认提示后可继续提交"];
return response;
}
response.decision = "pass";
response.nextActions = ["可直接提交"];
return response;
}
async function createRiskFlagsFromPrecheck(tx, { entityType, entityId, actorId, precheck, source = "precheck" }) {
if (precheck.decision !== "manual_review" || !precheck.riskFlags.length) {
return [];
}
const created = [];
for (const item of precheck.riskFlags) {
const existing = await tx.entityRiskFlag.findFirst({
where: {
entity_type: entityType,
entity_id: entityId,
risk_code: item.code,
source,
status: "open",
},
});
if (existing) {
created.push(existing);
continue;
}
const record = await tx.entityRiskFlag.create({
data: {
entity_type: entityType,
entity_id: entityId,
risk_code: item.code,
level: item.level,
title: item.title,
detail: item.detail,
source,
status: "open",
payload_json: {
openedByDecision: precheck.decision,
matchedBudgetControlRule: precheck.matchedBudgetControlRule,
budgetHint: precheck.budgetHint,
standardChecks: precheck.standardChecks ?? [],
},
created_by: actorId ?? null,
},
});
created.push(record);
}
return created;
}
async function ensureRiskFlag(tx, input) {
const existing = await tx.entityRiskFlag.findFirst({
where: {
entity_type: input.entityType,
entity_id: input.entityId,
risk_code: input.riskCode,
source: input.source,
status: "open",
},
});
if (existing) {
return existing;
}
return tx.entityRiskFlag.create({
data: {
entity_type: input.entityType,
entity_id: input.entityId,
risk_code: input.riskCode,
level: input.level,
title: input.title,
detail: input.detail ?? null,
source: input.source,
status: "open",
payload_json: input.payloadJson ?? null,
created_by: input.actorId ?? null,
},
});
}
async function listRiskFlags(tx, filters = {}) {
const rows = await tx.entityRiskFlag.findMany({
where: {
...(filters.entityType ? { entity_type: filters.entityType } : {}),
...(filters.entityId ? { entity_id: filters.entityId } : {}),
...(filters.status ? { status: filters.status } : {}),
...(filters.source ? { source: filters.source } : {}),
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
return rows.map(buildPersistedRiskFlag);
}
async function buildReimbursementPrecheck(tx, { reimbursementId, config, logger }) {
const reimbursement = await tx.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: {
id: true,
department_id: true,
reimbursement_date: true,
total_amount: true,
status: true,
owner_id: true,
items: {
select: {
id: true,
expense_type_id: true,
},
},
},
});
if (!reimbursement) {
throw buildNotFoundError();
}
const response = buildBasePrecheck("reimbursement", reimbursementId);
const blocking = [];
if (!["draft", "rejected"].includes(reimbursement.status)) {
blocking.push(buildRiskFlagPreview("invalid_submit_status", RISK_LEVELS.high, "当前状态不可提交", "只有草稿或已驳回报销单可提交审批。"));
}
const activeInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
entity_id: reimbursementId,
current_status: "pending",
},
select: { id: true },
});
if (activeInstance) {
blocking.push(buildRiskFlagPreview("already_in_review", RISK_LEVELS.high, "报销单已在审批中", "当前报销单已有进行中的审批实例。"));
}
const boundInvoiceCount = await tx.invoicePool.count({
where: {
linked_reimbursement_id: reimbursementId,
status: "bound",
},
});
if (boundInvoiceCount < 1) {
blocking.push(buildRiskFlagPreview("bound_invoice_required", RISK_LEVELS.high, "缺少已绑定票据", "报销提交前至少需要绑定一张票据。"));
}
const amountString = toMoneyString(reimbursement.total_amount);
const approvalRule = await tx.approvalRule.findFirst({
where: {
enabled: true,
min_amount: { lte: amountString },
OR: [{ max_amount: null }, { max_amount: { gt: amountString } }],
},
orderBy: { priority: "asc" },
select: {
id: true,
name: true,
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 {
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, "缺少可执行审批人", "当前审批链未解析出有效审批人。"));
}
}
appendBlockingReasons(response, blocking);
if (response.decision === "block") {
return response;
}
const matchedRule = await resolveBudgetControlRule(tx, {
entityType: "reimbursement",
departmentId: reimbursement.department_id,
legalEntityId: null,
costCenterId: null,
expenseTypeIds: Array.from(new Set(reimbursement.items.map((item) => item.expense_type_id).filter(Boolean))),
});
const budgetPool = await resolveApplicableBudgetPoolByContext(tx, {
departmentId: reimbursement.department_id,
effectiveDate: reimbursement.reimbursement_date,
});
const budgetIssues = [];
if (!budgetPool) {
budgetIssues.push({
code: "budget_pool_missing",
level: RISK_LEVELS.medium,
title: "未找到可用预算池",
detail: "当前报销日期和部门范围未匹配到有效预算池。",
});
} else {
response.budgetHint = buildBudgetHint(
budgetPool,
reimbursement.total_amount,
matchedRule?.controlMode ?? normalizeFallbackControlMode(config),
);
if (response.budgetHint.exceedsBudget) {
budgetIssues.push({
code: "budget_exceeded",
level: RISK_LEVELS.high,
title: "预算将被超用",
detail: "当前报销金额会导致预算池超出已分配额度。",
});
} else if (response.budgetHint.exceedsThreshold) {
budgetIssues.push({
code: "budget_threshold_exceeded",
level: RISK_LEVELS.medium,
title: "预算达到预警阈值",
detail: "本次提交后预算使用率将达到或超过预警阈值。",
});
}
}
return applyDecisionByIssues(response, budgetIssues, matchedRule, config);
}
async function buildPurchaseRequestPrecheck(tx, { purchaseRequestId, config, logger }) {
const purchaseRequest = await tx.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: {
id: true,
applicant_id: true,
department_id: true,
legal_entity_id: true,
cost_center_id: true,
supplier_id: true,
document_category_id: true,
budget_pool_id: true,
total_amount: true,
status: true,
items: { select: { id: true } },
},
});
if (!purchaseRequest) {
throw buildNotFoundError();
}
const response = buildBasePrecheck("purchase_request", purchaseRequestId);
const blocking = [];
if (purchaseRequest.status !== "draft") {
blocking.push(buildRiskFlagPreview("only_draft_submittable", RISK_LEVELS.high, "当前状态不可提交", "采购申请只有草稿状态允许提交。"));
}
if (!purchaseRequest.legal_entity_id) {
blocking.push(buildRiskFlagPreview("legal_entity_required", RISK_LEVELS.high, "缺少法体", "提交前必须选择法体。"));
}
if (!purchaseRequest.cost_center_id) {
blocking.push(buildRiskFlagPreview("cost_center_required", RISK_LEVELS.high, "缺少成本中心", "提交前必须选择成本中心。"));
}
if (!purchaseRequest.supplier_id) {
blocking.push(buildRiskFlagPreview("supplier_required", RISK_LEVELS.high, "缺少供应商", "提交前必须选择供应商。"));
}
if (!purchaseRequest.document_category_id) {
blocking.push(buildRiskFlagPreview("document_category_required", RISK_LEVELS.high, "缺少单据类型", "提交前必须选择采购单据类型。"));
}
if (!purchaseRequest.items.length) {
blocking.push(buildRiskFlagPreview("items_required", RISK_LEVELS.high, "缺少采购明细", "提交前至少需要一条采购明细。"));
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: {
entity_type: "purchase_request",
is_active: true,
archived_at: null,
},
orderBy: { version: "desc" },
select: { id: true, definition_json: true },
});
if (!workflowDefinition) {
blocking.push(buildRiskFlagPreview("workflow_definition_missing", RISK_LEVELS.high, "缺少流程定义", "采购申请缺少有效流程定义。"));
} else {
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
const { actionable, skipped } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: purchaseRequest.applicant_id,
logger,
});
if (!actionable || skipped.length) {
blocking.push(buildRiskFlagPreview("no_actionable_approver", RISK_LEVELS.high, "缺少可执行审批人", "当前采购流程未解析出有效审批人。"));
}
}
appendBlockingReasons(response, blocking);
if (response.decision === "block") {
return response;
}
const matchedRule = await resolveBudgetControlRule(tx, {
entityType: "purchase_request",
departmentId: purchaseRequest.department_id,
legalEntityId: purchaseRequest.legal_entity_id,
costCenterId: purchaseRequest.cost_center_id,
expenseTypeIds: [],
});
let resolvedBudgetPool = null;
if (purchaseRequest.legal_entity_id && purchaseRequest.cost_center_id) {
resolvedBudgetPool = await resolveApplicableBudgetPoolByContext(tx, {
departmentId: purchaseRequest.department_id,
legalEntityId: purchaseRequest.legal_entity_id,
costCenterId: purchaseRequest.cost_center_id,
effectiveDate: new Date(),
});
}
const budgetIssues = [];
const selectedBudgetPoolId = purchaseRequest.budget_pool_id ?? resolvedBudgetPool?.id ?? null;
if (purchaseRequest.budget_pool_id && resolvedBudgetPool && purchaseRequest.budget_pool_id !== resolvedBudgetPool.id) {
budgetIssues.push({
code: "budget_context_mismatch",
level: RISK_LEVELS.high,
title: "预算池与业务上下文不匹配",
detail: "手工选择的预算池与当前法体/成本中心范围不一致。",
});
}
const budgetPool = selectedBudgetPoolId
? await tx.budgetPool.findUnique({ where: { id: selectedBudgetPoolId } })
: null;
if (!budgetPool) {
budgetIssues.push({
code: "budget_pool_missing",
level: RISK_LEVELS.medium,
title: "未找到可用预算池",
detail: "当前采购申请未匹配到可用预算池。",
});
} else {
response.budgetHint = buildBudgetHint(
budgetPool,
purchaseRequest.total_amount,
matchedRule?.controlMode ?? normalizeFallbackControlMode(config),
);
if (response.budgetHint.exceedsBudget) {
budgetIssues.push({
code: "budget_exceeded",
level: RISK_LEVELS.high,
title: "预算将被超用",
detail: "当前采购金额会导致预算池超出已分配额度。",
});
} else if (response.budgetHint.exceedsThreshold) {
budgetIssues.push({
code: "budget_threshold_exceeded",
level: RISK_LEVELS.medium,
title: "预算达到预警阈值",
detail: "本次提交后预算使用率将达到或超过预警阈值。",
});
}
}
return applyDecisionByIssues(response, budgetIssues, matchedRule, config);
}
async function buildTravelRequestPrecheck(tx, { travelRequestId, config, logger }) {
const travelRequest = await tx.travelRequest.findUnique({
where: { id: travelRequestId },
select: {
id: true,
applicant_employee_id: true,
department_id: true,
legal_entity_id: true,
cost_center_id: true,
start_date: true,
estimated_amount: true,
status: true,
segments: { select: { id: true } },
},
});
if (!travelRequest) {
throw buildNotFoundError();
}
const response = buildBasePrecheck("travel_request", travelRequestId);
const blocking = [];
if (travelRequest.status !== "draft") {
blocking.push(buildRiskFlagPreview("only_draft_submittable", RISK_LEVELS.high, "当前状态不可提交", "差旅申请只有草稿状态允许提交。"));
}
if (!travelRequest.legal_entity_id) {
blocking.push(buildRiskFlagPreview("legal_entity_required", RISK_LEVELS.high, "缺少法体", "提交前必须选择法体。"));
}
if (!travelRequest.cost_center_id) {
blocking.push(buildRiskFlagPreview("cost_center_required", RISK_LEVELS.high, "缺少成本中心", "提交前必须选择成本中心。"));
}
if (!travelRequest.segments.length) {
blocking.push(buildRiskFlagPreview("segments_required", RISK_LEVELS.high, "缺少行程段", "提交前至少需要一个行程段。"));
}
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: {
entity_type: "travel_request",
is_active: true,
archived_at: null,
},
orderBy: { version: "desc" },
select: { id: true, definition_json: true },
});
if (!workflowDefinition) {
blocking.push(buildRiskFlagPreview("workflow_definition_missing", RISK_LEVELS.high, "缺少流程定义", "差旅申请缺少有效流程定义。"));
} else {
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
const { actionable, skipped } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: travelRequest.applicant_employee_id,
logger,
});
if (!actionable || skipped.length) {
blocking.push(buildRiskFlagPreview("no_actionable_approver", RISK_LEVELS.high, "缺少可执行审批人", "当前差旅流程未解析出有效审批人。"));
}
}
appendBlockingReasons(response, blocking);
if (response.decision === "block") {
return response;
}
const matchedRule = await resolveBudgetControlRule(tx, {
entityType: "travel_request",
departmentId: travelRequest.department_id,
legalEntityId: travelRequest.legal_entity_id,
costCenterId: travelRequest.cost_center_id,
expenseTypeIds: [],
});
const budgetPool = travelRequest.legal_entity_id && travelRequest.cost_center_id
? await resolveApplicableBudgetPoolByContext(tx, {
departmentId: travelRequest.department_id,
legalEntityId: travelRequest.legal_entity_id,
costCenterId: travelRequest.cost_center_id,
effectiveDate: travelRequest.start_date,
})
: null;
const budgetIssues = [];
if (!budgetPool) {
budgetIssues.push({
code: "budget_pool_missing",
level: RISK_LEVELS.medium,
title: "未找到可用预算池",
detail: "当前差旅申请未匹配到可用预算池。",
});
} else {
response.budgetHint = buildBudgetHint(
budgetPool,
travelRequest.estimated_amount,
matchedRule?.controlMode ?? normalizeFallbackControlMode(config),
);
if (response.budgetHint.exceedsBudget) {
budgetIssues.push({
code: "budget_exceeded",
level: RISK_LEVELS.high,
title: "预算将被超用",
detail: "当前差旅申请金额会导致预算池超出已分配额度。",
});
} else if (response.budgetHint.exceedsThreshold) {
budgetIssues.push({
code: "budget_threshold_exceeded",
level: RISK_LEVELS.medium,
title: "预算达到预警阈值",
detail: "本次提交后预算使用率将达到或超过预警阈值。",
});
}
}
return applyDecisionByIssues(response, budgetIssues, matchedRule, config);
}
function getPrecheckPermissionCode(entityType) {
return PRECHECK_PERMISSION_CODES[entityType] ?? null;
}
async function loadPrecheckEntityContext(tx, { entityType, entityId }) {
if (entityType === "reimbursement") {
const item = await tx.reimbursementForm.findUnique({
where: { id: entityId },
select: { id: true, department_id: true },
});
if (!item) {
throw buildNotFoundError();
}
return { id: item.id, departmentId: item.department_id ?? null };
}
if (entityType === "purchase_request") {
const item = await tx.purchaseRequest.findUnique({
where: { id: entityId },
select: { id: true, department_id: true },
});
if (!item) {
throw buildNotFoundError();
}
return { id: item.id, departmentId: item.department_id ?? null };
}
if (entityType === "travel_request") {
const item = await tx.travelRequest.findUnique({
where: { id: entityId },
select: { id: true, department_id: true },
});
if (!item) {
throw buildNotFoundError();
}
return { id: item.id, departmentId: item.department_id ?? null };
}
throw buildValidationError({ entityType: "unsupported" });
}
async function buildEntityPrecheck(tx, { entityType, entityId, config, logger }) {
if (entityType === "reimbursement") {
return buildReimbursementPrecheck(tx, {
reimbursementId: entityId,
config,
logger,
});
}
if (entityType === "purchase_request") {
return buildPurchaseRequestPrecheck(tx, {
purchaseRequestId: entityId,
config,
logger,
});
}
if (entityType === "travel_request") {
return buildTravelRequestPrecheck(tx, {
travelRequestId: entityId,
config,
logger,
});
}
throw buildValidationError({ entityType: "unsupported" });
}
async function resolveEntityPrecheck({
prisma,
config,
logger,
entityType,
entityId,
actorDepartmentId = null,
}) {
const entity = await loadPrecheckEntityContext(prisma, { entityType, entityId });
const gate = await evaluateFeatureFlag({
prisma,
config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: entity.departmentId ?? actorDepartmentId ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
const disabledPrecheck = buildDisabledPrecheck(entityType, entityId);
if (entityType !== "reimbursement") {
return disabledPrecheck;
}
const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId });
return mergeReimbursementPrecheck(disabledPrecheck, standardChecks);
}
const precheck = await buildEntityPrecheck(prisma, {
entityType,
entityId,
config,
logger,
});
if (entityType !== "reimbursement") {
return precheck;
}
const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId });
return mergeReimbursementPrecheck(precheck, standardChecks);
}
module.exports = {
BUDGET_RISK_CODES,
PRECHECK_DECISIONS,
buildDisabledPrecheck,
RISK_LEVELS,
getPrecheckPermissionCode,
toMoneyNumber,
toMoneyString,
listRiskFlags,
ensureRiskFlag,
buildPersistedRiskFlag,
createRiskFlagsFromPrecheck,
buildEntityPrecheck,
buildReimbursementPrecheck,
buildPurchaseRequestPrecheck,
buildTravelRequestPrecheck,
checkExpenseStandards,
loadPrecheckEntityContext,
mergeReimbursementPrecheck,
resolveEntityPrecheck,
};