R56 A1-A6:费用标准管控
这个提交包含在:
@@ -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,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户