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

合同 CSV 已回填 reviewer/QA 结果;运行时回归受当前环境缺少 node/npm 阻塞。
这个提交包含在:
2026-03-29 09:21:17 +08:00
父节点 e8d16c47f2
当前提交 6dee2daf3e
共修改 19 个文件,包含 1394 行新增和 482 行删除
+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,
},