P25L-1~P25L-5 实现预算控制与AP最小可用运营

合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。
这个提交包含在:
2026-03-29 09:21:17 +08:00
父节点 e8d16c47f2
当前提交 6dee2daf3e
共修改 19 个文件,包含 1394 行新增和 482 行删除
+26 -50
查看文件
@@ -21,8 +21,8 @@ const { ensureCutoverAllowed } = require("../services/cutover");
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
const {
buildPersistedRiskFlag,
buildReimbursementPrecheck,
createRiskFlagsFromPrecheck,
resolveEntityPrecheck,
} = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence");
const {
@@ -435,20 +435,6 @@ function buildBudgetHint(budgetPool, reimbursementAmount, controlMode) {
};
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildPrecheckBlockedError(precheck) {
return new AppError({
code: "VALID_001",
@@ -1511,30 +1497,13 @@ async function reimbursementRoutes(fastify) {
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, department_id: true },
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const gate = await evaluateFeatureFlag({
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("reimbursement", reimbursementId));
}
const precheck = await buildReimbursementPrecheck(fastify.prisma, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
entityType: "reimbursement",
entityId: reimbursementId,
actorDepartmentId: request.employee.department_id ?? null,
});
return sendData(reply, request, precheck);
},
@@ -1610,20 +1579,14 @@ async function reimbursementRoutes(fastify) {
});
}
const budgetPrecheckGate = await evaluateFeatureFlag({
const authoritativePrecheck = await resolveEntityPrecheck({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
logger: fastify.log,
entityType: "reimbursement",
entityId: reimbursementId,
actorDepartmentId: request.employee.department_id ?? null,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildReimbursementPrecheck(tx, {
reimbursementId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("reimbursement", reimbursementId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
@@ -1641,7 +1604,20 @@ async function reimbursementRoutes(fastify) {
const currentAmount = Number(reimbursement.total_amount ?? 0);
const nextUsed = usedAmount + currentAmount;
const usagePercent = allocatedAmount > 0 ? (nextUsed / allocatedAmount) * 100 : 0;
const controlMode = normalizeBudgetControlMode(fastify.config);
const budgetPrecheckGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: reimbursement.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
const fallbackControlMode = normalizeBudgetControlMode(fastify.config);
const controlMode = budgetPrecheckGate.enabled
? authoritativePrecheck.matchedBudgetControlRule?.controlMode
?? authoritativePrecheck.budgetHint?.controlMode
?? fallbackControlMode
: fallbackControlMode;
const shouldBlockByLegacyMode = !budgetPrecheckGate.enabled && controlMode === "block" && nextUsed > allocatedAmount;
budgetCheck = {
budgetId: budgetPool.id,
@@ -1670,7 +1646,7 @@ async function reimbursementRoutes(fastify) {
});
}
if (controlMode === "block" && nextUsed > allocatedAmount) {
if (shouldBlockByLegacyMode) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
@@ -1683,7 +1659,7 @@ async function reimbursementRoutes(fastify) {
data: {
budget_pool_id: budgetPool.id,
reimbursement_id: reimbursementId,
kind: controlMode === "block" && nextUsed > allocatedAmount ? "blocked" : "submit",
kind: shouldBlockByLegacyMode ? "blocked" : "submit",
amount: reimbursement.total_amount,
note: `submit:${reimbursement.form_number ?? reimbursementId}`,
created_by: request.employee.id,