R56 A1-A6:费用标准管控

这个提交包含在:
2026-04-08 12:47:40 +08:00
父节点 40c3b7632a
当前提交 845558ab17
共修改 25 个文件,包含 2993 行新增和 10 行删除
+178 -2
查看文件
@@ -56,6 +56,7 @@ function buildBasePrecheck(entityType, entityId) {
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
@@ -70,6 +71,7 @@ function buildDisabledPrecheck(entityType, entityId) {
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
@@ -272,6 +274,165 @@ function appendBlockingReasons(response, issues) {
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 [];
@@ -308,6 +469,7 @@ async function createRiskFlagsFromPrecheck(tx, { entityType, entityId, actorId,
openedByDecision: precheck.decision,
matchedBudgetControlRule: precheck.matchedBudgetControlRule,
budgetHint: precheck.budgetHint,
standardChecks: precheck.standardChecks ?? [],
},
created_by: actorId ?? null,
},
@@ -843,15 +1005,27 @@ async function resolveEntityPrecheck({
});
if (!gate.enabled) {
return buildDisabledPrecheck(entityType, entityId);
const disabledPrecheck = buildDisabledPrecheck(entityType, entityId);
if (entityType !== "reimbursement") {
return disabledPrecheck;
}
const standardChecks = await checkExpenseStandards(prisma, { reimbursementId: entityId });
return mergeReimbursementPrecheck(disabledPrecheck, standardChecks);
}
return buildEntityPrecheck(prisma, {
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 = {
@@ -870,6 +1044,8 @@ module.exports = {
buildReimbursementPrecheck,
buildPurchaseRequestPrecheck,
buildTravelRequestPrecheck,
checkExpenseStandards,
loadPrecheckEntityContext,
mergeReimbursementPrecheck,
resolveEntityPrecheck,
};