P25L-1~P25L-5 实现预算控制与AP最小可用运营
合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。
这个提交包含在:
@@ -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,
|
||||
|
||||
在新工单中引用
屏蔽一个用户