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

合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。
这个提交包含在:
2026-03-29 09:21:17 +08:00
父节点 e8d16c47f2
当前提交 6dee2daf3e
共修改 19 个文件,包含 1394 行新增和 482 行删除
+134
查看文件
@@ -1,5 +1,6 @@
const { AppError } = require("../utils/AppError");
const { resolveApplicableBudgetPoolByContext } = require("./budgetPool");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("./featureFlags");
const {
buildWorkflowStepsFromApprovalRule,
parseWorkflowSteps,
@@ -7,6 +8,12 @@ const {
} = 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",
@@ -21,6 +28,15 @@ function buildNotFoundError() {
});
}
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)) {
@@ -47,6 +63,20 @@ function buildBasePrecheck(entityType, entityId) {
};
}
function buildDisabledPrecheck(entityType, entityId) {
return {
entityType,
entityId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["功能未开启,沿用当前提交流程"],
};
}
function buildIssue(code, title, detail) {
return {
code,
@@ -726,16 +756,120 @@ async function buildTravelRequestPrecheck(tx, { travelRequestId, config, logger
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) {
return buildDisabledPrecheck(entityType, entityId);
}
return buildEntityPrecheck(prisma, {
entityType,
entityId,
config,
logger,
});
}
module.exports = {
BUDGET_RISK_CODES,
PRECHECK_DECISIONS,
buildDisabledPrecheck,
RISK_LEVELS,
getPrecheckPermissionCode,
toMoneyNumber,
toMoneyString,
listRiskFlags,
ensureRiskFlag,
buildPersistedRiskFlag,
createRiskFlagsFromPrecheck,
buildEntityPrecheck,
buildReimbursementPrecheck,
buildPurchaseRequestPrecheck,
buildTravelRequestPrecheck,
loadPrecheckEntityContext,
resolveEntityPrecheck,
};