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