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