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