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

合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。
这个提交包含在:
2026-03-29 09:21:17 +08:00
父节点 e8d16c47f2
当前提交 6dee2daf3e
共修改 19 个文件,包含 1394 行新增和 482 行删除
@@ -0,0 +1,4 @@
ALTER TABLE "payment_batches"
ADD COLUMN "secondary_review_comment" TEXT,
ADD COLUMN "secondary_reviewed_at" TIMESTAMP(3),
ADD COLUMN "secondary_reviewed_by" UUID;
+3
查看文件
@@ -1558,12 +1558,15 @@ model PaymentBatch {
default_payment_channel String? @db.VarChar(20)
voucher_prefix String? @db.VarChar(32)
review_comment String?
secondary_review_comment String?
execution_summary_json Json?
created_at DateTime @default(now())
created_by String? @db.Uuid
reviewed_at DateTime?
reviewed_by String? @db.Uuid
secondary_reviewed_at DateTime?
secondary_reviewed_by String? @db.Uuid
executed_at DateTime?
executed_by String? @db.Uuid
cancelled_at DateTime?
+2
查看文件
@@ -40,6 +40,7 @@ const exportsRoutes = require("./routes/exports");
const dashboardRoutes = require("./routes/dashboard");
const budgetsRoutes = require("./routes/budgets");
const budgetControlRulesRoutes = require("./routes/budgetControlRules");
const feeControlRoutes = require("./routes/feeControl");
const riskFlagsRoutes = require("./routes/riskFlags");
const paymentBatchesRoutes = require("./routes/paymentBatches");
const apOperationsRoutes = require("./routes/apOperations");
@@ -324,6 +325,7 @@ async function buildApp() {
await app.register(dashboardRoutes, { prefix: "/api" });
await app.register(budgetsRoutes, { prefix: "/api" });
await app.register(budgetControlRulesRoutes, { prefix: "/api" });
await app.register(feeControlRoutes, { prefix: "/api" });
await app.register(riskFlagsRoutes, { prefix: "/api" });
await app.register(paymentBatchesRoutes, { prefix: "/api" });
await app.register(apOperationsRoutes, { prefix: "/api" });
+95 -33
查看文件
@@ -1,7 +1,7 @@
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { buildPersistedRiskFlag, toMoneyString } = require("../services/feeControl");
const { BUDGET_RISK_CODES, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
function ensureReadPermission(fastify, request) {
if (
@@ -30,12 +30,46 @@ async function ensureFeatureEnabled(fastify) {
}
function mapBatchSummary(item) {
const totalAmount = toMoneyString((item.items ?? []).reduce((sum, entry) => sum + toMoneyNumber(entry.amount), 0));
return {
id: item.id,
batchNo: item.batch_no,
entityType: item.entity_type,
name: item.name,
status: item.status,
totalAmount,
highRisk: toMoneyNumber(totalAmount) >= 50000,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
function mapFailedPaymentItem(item) {
return {
id: item.id,
paymentBatchId: item.payment_batch_id,
entityId: item.entity_id,
entityCode: item.entity_code,
entityTitle: item.entity_title,
amount: toMoneyString(item.amount),
currencyCode: item.currency_code,
failureCode: item.failure_code ?? null,
failureMessage: item.failure_message ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
function mapReconciliationAnomaly(item) {
return {
id: item.id,
travelReconciliationBatchId: item.travel_reconciliation_batch_id,
travelOrderId: item.travel_order_id ?? null,
travelRequestId: item.travel_request_id ?? null,
matchStatus: item.match_status,
resolutionStatus: item.resolution_status,
anomalyCode: item.anomaly_code ?? null,
orderAmount: item.order_amount != null ? toMoneyString(item.order_amount) : null,
transactionAmount: item.transaction_amount != null ? toMoneyString(item.transaction_amount) : null,
currency: item.currency,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
@@ -45,22 +79,62 @@ async function apOperationsRoutes(fastify) {
await ensureFeatureEnabled(fastify);
ensureReadPermission(fastify, request);
const [batches, failedItems, openRiskFlags, anomalies] = await Promise.all([
const [
batches,
pendingPaymentCount,
failedItems,
failedPaymentItemCount,
openRiskFlags,
nonBudgetRiskFlagCount,
budgetOverspendCount,
anomalies,
partialMatchCount,
] = await Promise.all([
fastify.prisma.paymentBatch.findMany({
where: { status: { in: ["draft", "reviewed", "failed"] } },
include: {
items: {
select: {
amount: true,
},
},
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 20,
}),
fastify.prisma.paymentBatchItem.count({
where: {
payment_batch: {
status: { in: ["draft", "reviewed", "failed"] },
},
status: { in: ["pending", "failed"] },
},
}),
fastify.prisma.paymentBatchItem.findMany({
where: { status: "failed" },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 50,
}),
fastify.prisma.paymentBatchItem.count({
where: { status: "failed" },
}),
fastify.prisma.entityRiskFlag.findMany({
where: { status: "open" },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: 50,
}),
fastify.prisma.entityRiskFlag.count({
where: {
status: "open",
risk_code: { notIn: BUDGET_RISK_CODES },
},
}),
fastify.prisma.entityRiskFlag.count({
where: {
status: "open",
risk_code: { in: BUDGET_RISK_CODES },
},
}),
fastify.prisma.travelReconciliationItem.findMany({
where: {
resolution_status: "pending",
@@ -82,42 +156,30 @@ async function apOperationsRoutes(fastify) {
created_at: true,
},
}),
fastify.prisma.travelReconciliationItem.count({
where: {
resolution_status: "pending",
match_status: { not: "matched" },
},
}),
]);
const riskFlags = openRiskFlags.map(buildPersistedRiskFlag);
const budgetOverspendRisks = riskFlags.filter((item) => BUDGET_RISK_CODES.includes(item.riskCode));
const exceptionRiskFlags = riskFlags.filter((item) => !BUDGET_RISK_CODES.includes(item.riskCode));
return sendData(reply, request, {
summary: {
openPaymentBatchCount: batches.length,
failedPaymentBatchItemCount: failedItems.length,
openRiskFlagCount: openRiskFlags.length,
reconciliationAnomalyCount: anomalies.length,
pendingPaymentCount,
partialMatchCount,
exceptionCount: failedPaymentItemCount + nonBudgetRiskFlagCount,
budgetOverspendCount,
},
openPaymentBatches: batches.map(mapBatchSummary),
failedPaymentBatchItems: failedItems.map((item) => ({
id: item.id,
paymentBatchId: item.payment_batch_id,
entityId: item.entity_id,
entityCode: item.entity_code,
entityTitle: item.entity_title,
amount: toMoneyString(item.amount),
currencyCode: item.currency_code,
failureCode: item.failure_code ?? null,
failureMessage: item.failure_message ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
})),
openRiskFlags: openRiskFlags.map(buildPersistedRiskFlag),
reconciliationAnomalies: anomalies.map((item) => ({
id: item.id,
travelReconciliationBatchId: item.travel_reconciliation_batch_id,
travelOrderId: item.travel_order_id ?? null,
travelRequestId: item.travel_request_id ?? null,
matchStatus: item.match_status,
resolutionStatus: item.resolution_status,
anomalyCode: item.anomaly_code ?? null,
orderAmount: item.order_amount != null ? toMoneyString(item.order_amount) : null,
transactionAmount: item.transaction_amount != null ? toMoneyString(item.transaction_amount) : null,
currency: item.currency,
createdAt: item.created_at?.toISOString?.() ?? null,
})),
pendingPaymentBatches: batches.map(mapBatchSummary),
failedPaymentBatchItems: failedItems.map(mapFailedPaymentItem),
exceptionRiskFlags,
budgetOverspendRisks,
partialMatchItems: anomalies.map(mapReconciliationAnomaly),
});
});
}
+53
查看文件
@@ -0,0 +1,53 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const {
getPrecheckPermissionCode,
resolveEntityPrecheck,
} = require("../services/feeControl");
const PRECHECK_BODY_SCHEMA = z.object({
entityType: z.enum(["reimbursement", "purchase_request", "travel_request"]),
entityId: z.string().uuid(),
}).strict();
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseBody(body) {
const parsed = PRECHECK_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
async function feeControlRoutes(fastify) {
fastify.post("/fee-control/precheck", { preHandler: fastify.authenticate }, async (request, reply) => {
const body = parseBody(request.body);
const permissionCode = getPrecheckPermissionCode(body.entityType);
if (!permissionCode || !fastify.hasPermission(request.employee, permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
entityType: body.entityType,
entityId: body.entityId,
actorDepartmentId: request.employee.department_id ?? null,
});
return sendData(reply, request, precheck);
});
}
module.exports = feeControlRoutes;
+75 -13
查看文件
@@ -5,7 +5,7 @@ const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { parseDateOnlyString } = require("../utils/date");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyString } = require("../services/feeControl");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
@@ -36,6 +36,8 @@ const REVIEW_SCHEMA = z.object({
resolveRiskFlagIds: z.array(UUID_SCHEMA).optional(),
}).strict();
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
@@ -112,6 +114,32 @@ function buildBatchNo() {
return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}`;
}
function summarizeBatchRisk(batch) {
const totalAmountValue = (batch.items ?? []).reduce((sum, item) => sum + toMoneyNumber(item.amount), 0);
const totalAmount = toMoneyString(totalAmountValue);
const highRisk = totalAmountValue >= HIGH_RISK_BATCH_THRESHOLD_AMOUNT;
const completedReviewCount = batch.secondary_reviewed_at ? 2 : batch.reviewed_at ? 1 : 0;
const requiredReviewCount = highRisk ? 2 : 1;
const nextStep = completedReviewCount >= requiredReviewCount
? "ready_for_execute"
: completedReviewCount === 0
? "first_review"
: "second_review";
return {
totalAmount,
highRisk,
reviewRequirement: {
thresholdAmount: toMoneyString(HIGH_RISK_BATCH_THRESHOLD_AMOUNT),
requiredReviewCount,
completedReviewCount,
nextStep,
makerCheckerRequired: true,
reviewerPermission: "risk.review.manage",
},
};
}
async function loadBatchWithItems(tx, batchId) {
const batch = await tx.paymentBatch.findUnique({
where: { id: batchId },
@@ -204,6 +232,7 @@ function ensureBatchEntityStatus(entityType, snapshot) {
}
function mapBatch(batch, openRiskFlags = []) {
const batchRisk = summarizeBatchRisk(batch);
const riskMap = new Map();
for (const item of openRiskFlags) {
const entityId = item.entityId;
@@ -222,12 +251,18 @@ function mapBatch(batch, openRiskFlags = []) {
defaultPaymentDate: batch.default_payment_date?.toISOString?.()?.slice(0, 10) ?? null,
defaultPaymentChannel: batch.default_payment_channel ?? null,
voucherPrefix: batch.voucher_prefix ?? null,
totalAmount: batchRisk.totalAmount,
highRisk: batchRisk.highRisk,
reviewRequirement: batchRisk.reviewRequirement,
reviewComment: batch.review_comment ?? null,
secondaryReviewComment: batch.secondary_review_comment ?? null,
executionSummaryJson: batch.execution_summary_json ?? null,
createdAt: batch.created_at?.toISOString?.() ?? null,
createdBy: batch.created_by ?? null,
reviewedAt: batch.reviewed_at?.toISOString?.() ?? null,
reviewedBy: batch.reviewed_by ?? null,
secondaryReviewedAt: batch.secondary_reviewed_at?.toISOString?.() ?? null,
secondaryReviewedBy: batch.secondary_reviewed_by ?? null,
executedAt: batch.executed_at?.toISOString?.() ?? null,
executedBy: batch.executed_by ?? null,
cancelledAt: batch.cancelled_at?.toISOString?.() ?? null,
@@ -488,6 +523,8 @@ async function paymentBatchesRoutes(fastify) {
extra: {
entityType: body.entityType,
entityCount: snapshots.length,
totalAmount: toMoneyString(snapshots.reduce((sum, item) => sum + toMoneyNumber(item.amount), 0)),
highRisk: snapshots.reduce((sum, item) => sum + toMoneyNumber(item.amount), 0) >= HIGH_RISK_BATCH_THRESHOLD_AMOUNT,
},
}),
});
@@ -558,14 +595,21 @@ async function paymentBatchesRoutes(fastify) {
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await loadBatchWithItems(tx, batchId);
if (batch.status !== "draft") {
throw buildValidationError({ status: "only_draft_reviewable" }, 409);
const batchRisk = summarizeBatchRisk(batch);
const isFirstReview = batch.status === "draft" && !batch.reviewed_at;
const isSecondReview = batchRisk.highRisk && batch.status === "reviewed" && !batch.secondary_reviewed_at;
if (!isFirstReview && !isSecondReview) {
throw buildValidationError({ review: "batch_not_reviewable" }, 409);
}
if (batch.items.some((item) => item.status !== "pending")) {
throw buildValidationError({ items: "all_items_must_be_pending" }, 409);
}
if (batch.created_by && batch.created_by === request.employee.id) {
throw buildValidationError({ review: "maker_checker_conflict" }, 409);
}
if (batch.items.some((item) => item.status !== "pending")) {
throw buildValidationError({ items: "all_items_must_be_pending" }, 409);
if (isSecondReview && batch.reviewed_by && batch.reviewed_by === request.employee.id) {
throw buildValidationError({ review: "second_reviewer_conflict" }, 409);
}
const openRiskFlags = await tx.entityRiskFlag.findMany({
@@ -592,26 +636,36 @@ async function paymentBatchesRoutes(fastify) {
});
}
const reviewedAt = new Date();
const reviewed = await tx.paymentBatch.update({
where: { id: batchId },
data: {
status: "reviewed",
review_comment: normalizeNullableString(body.comment),
reviewed_at: new Date(),
reviewed_by: request.employee.id,
updated_by: request.employee.id,
},
data: isFirstReview
? {
status: "reviewed",
review_comment: normalizeNullableString(body.comment),
reviewed_at: reviewedAt,
reviewed_by: request.employee.id,
updated_by: request.employee.id,
}
: {
secondary_review_comment: normalizeNullableString(body.comment),
secondary_reviewed_at: reviewedAt,
secondary_reviewed_by: request.employee.id,
updated_by: request.employee.id,
},
include: { items: true },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_REVIEWED",
action: isFirstReview ? "PAYMENT_BATCH_REVIEWED" : "PAYMENT_BATCH_SECOND_REVIEWED",
targetType: "payment_batch",
targetId: batchId,
extra: {
highRisk: batchRisk.highRisk,
resolvedRiskFlagCount: openRiskFlags.length,
reviewStage: isFirstReview ? "first_review" : "second_review",
},
}),
});
@@ -629,9 +683,15 @@ async function paymentBatchesRoutes(fastify) {
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await loadBatchWithItems(tx, batchId);
const batchRisk = summarizeBatchRisk(batch);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "only_reviewed_or_failed_executable" }, 409);
}
if (batchRisk.reviewRequirement.completedReviewCount < batchRisk.reviewRequirement.requiredReviewCount) {
throw buildValidationError({
review: batchRisk.highRisk ? "high_risk_second_review_required" : "batch_review_required",
}, 409);
}
let successCount = 0;
let failedCount = 0;
@@ -709,6 +769,8 @@ async function paymentBatchesRoutes(fastify) {
targetType: "payment_batch",
targetId: batchId,
extra: {
highRisk: batchRisk.highRisk,
totalAmount: batchRisk.totalAmount,
successCount,
failedCount,
},
+10 -46
查看文件
@@ -15,8 +15,8 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildPersistedRiskFlag,
buildPurchaseRequestPrecheck,
createRiskFlagsFromPrecheck,
resolveEntityPrecheck,
} = require("../services/feeControl");
const {
PROCUREMENT_ERP_TARGETS,
@@ -343,20 +343,6 @@ function normalizeBudgetControlMode(config) {
return String(config.budgetControlMode ?? "warn").toLowerCase() === "block" ? "block" : "warn";
}
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",
@@ -1464,29 +1450,13 @@ async function purchaseRequestRoutes(fastify) {
},
async (request, reply) => {
const purchaseRequestId = parsePurchaseRequestId(request.params?.id);
const purchaseRequest = await fastify.prisma.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
select: { id: true, department_id: true },
});
if (!purchaseRequest) {
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: purchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("purchase_request", purchaseRequestId));
}
const precheck = await buildPurchaseRequestPrecheck(fastify.prisma, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
entityType: "purchase_request",
entityId: purchaseRequestId,
actorDepartmentId: request.employee.department_id ?? null,
});
return sendData(reply, request, precheck);
},
@@ -1517,20 +1487,14 @@ async function purchaseRequestRoutes(fastify) {
});
}
const budgetPrecheckGate = await evaluateFeatureFlag({
const authoritativePrecheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: unlockedPurchaseRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
logger: fastify.log,
entityType: "purchase_request",
entityId: purchaseRequestId,
actorDepartmentId: request.employee.department_id ?? null,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildPurchaseRequestPrecheck(fastify.prisma, {
purchaseRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("purchase_request", purchaseRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
+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,
+10 -46
查看文件
@@ -20,8 +20,8 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF
const { ensureCutoverAllowed } = require("../services/cutover");
const {
buildPersistedRiskFlag,
buildTravelRequestPrecheck,
createRiskFlagsFromPrecheck,
resolveEntityPrecheck,
} = require("../services/feeControl");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
@@ -300,20 +300,6 @@ function ensureDateRangeValid(startDate, endDate) {
}
}
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",
@@ -922,29 +908,13 @@ async function createTravelRequestsRoutes(fastify) {
},
async (request, reply) => {
const travelRequestId = parseTravelRequestId(request.params?.id);
const travelRequest = await fastify.prisma.travelRequest.findUnique({
where: { id: travelRequestId },
select: { id: true, department_id: true },
});
if (!travelRequest) {
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: travelRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
return sendData(reply, request, buildDisabledPrecheck("travel_request", travelRequestId));
}
const precheck = await buildTravelRequestPrecheck(fastify.prisma, {
travelRequestId,
config: fastify.config,
logger: fastify.log,
entityType: "travel_request",
entityId: travelRequestId,
actorDepartmentId: request.employee.department_id ?? null,
});
return sendData(reply, request, precheck);
},
@@ -978,20 +948,14 @@ async function createTravelRequestsRoutes(fastify) {
throw buildValidationError({ segments: "required" }, 409);
}
const budgetPrecheckGate = await evaluateFeatureFlag({
const authoritativePrecheck = await resolveEntityPrecheck({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.budgetPrecheck,
departmentId: travelRequest.department_id ?? request.employee.department_id ?? null,
strictDepartment: false,
logger: fastify.log,
entityType: "travel_request",
entityId: travelRequestId,
actorDepartmentId: request.employee.department_id ?? null,
});
const authoritativePrecheck = budgetPrecheckGate.enabled
? await buildTravelRequestPrecheck(tx, {
travelRequestId,
config: fastify.config,
logger: fastify.log,
})
: buildDisabledPrecheck("travel_request", travelRequestId);
if (authoritativePrecheck.decision === "block") {
throw buildPrecheckBlockedError(authoritativePrecheck);
}
+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,
};