统一付款详情与批次短编号(08-01-reimbursement-notification-progress-clarity)
这个提交包含在:
@@ -11,6 +11,7 @@ const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { createNotificationsWithRecords } = require("../services/notifications");
|
||||
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
|
||||
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
|
||||
@@ -73,6 +74,7 @@ const REASON_SCHEMA = z.object({
|
||||
}).strict();
|
||||
|
||||
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
|
||||
const PAYMENT_BATCH_DAILY_SEQUENCE_MAX = 999;
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({
|
||||
@@ -367,16 +369,48 @@ async function dispatchPaymentResponsibilityNotification(fastify, request, task)
|
||||
}
|
||||
}
|
||||
|
||||
function buildBatchNo() {
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const time = now.toISOString().slice(11, 19).replace(/:/g, "");
|
||||
const suffix = crypto.randomBytes(3).toString("hex").toUpperCase();
|
||||
return `PB-${date}-${time}-${String(now.getMilliseconds()).padStart(3, "0")}-${suffix}`;
|
||||
function parsePaymentBatchDailySequence(batchNo, compactDate) {
|
||||
const match = new RegExp(`^PB-${compactDate}-([0-9]{3})$`).exec(batchNo ?? "");
|
||||
return match ? Number.parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
function buildDefaultBatchName(timeZone) {
|
||||
return `${currentDateString(timeZone ?? "Asia/Shanghai")} 付款批次`;
|
||||
async function allocatePaymentBatchNumber(tx, timeZone) {
|
||||
const businessDate = currentDateString(timeZone ?? "Asia/Shanghai");
|
||||
const compactDate = businessDate.replaceAll("-", "");
|
||||
const dateLockKey = Number.parseInt(compactDate, 10);
|
||||
const batchNoPrefix = `PB-${compactDate}-`;
|
||||
const batchNoPattern = `^PB-${compactDate}-[0-9]{3}$`;
|
||||
|
||||
await tx.$executeRaw`
|
||||
SELECT pg_advisory_xact_lock(
|
||||
${ADVISORY_LOCK_NAMESPACES.paymentBatchDailySequence}::int,
|
||||
${dateLockKey}::int
|
||||
)
|
||||
`;
|
||||
|
||||
const rows = await tx.$queryRaw`
|
||||
SELECT "batch_no"
|
||||
FROM "payment_batches"
|
||||
WHERE "batch_no" LIKE ${`${batchNoPrefix}%`}
|
||||
AND "batch_no" ~ ${batchNoPattern}
|
||||
ORDER BY "batch_no" DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const nextSequence = parsePaymentBatchDailySequence(rows?.[0]?.batch_no, compactDate) + 1;
|
||||
if (nextSequence > PAYMENT_BATCH_DAILY_SEQUENCE_MAX) {
|
||||
throw buildValidationError({ batchNo: "daily_sequence_exhausted" }, 409);
|
||||
}
|
||||
|
||||
const sequence = String(nextSequence).padStart(3, "0");
|
||||
return {
|
||||
batchNo: `${batchNoPrefix}${sequence}`,
|
||||
businessDate,
|
||||
sequence,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultBatchName(businessDate, sequence) {
|
||||
return `${businessDate} 付款批次 ${sequence}`;
|
||||
}
|
||||
|
||||
function summarizeBatchRisk(batch) {
|
||||
@@ -1215,11 +1249,13 @@ async function paymentBatchesRoutes(fastify) {
|
||||
throw buildValidationError({ review: "no_eligible_reviewer" }, 409);
|
||||
}
|
||||
|
||||
const allocatedNumber = await allocatePaymentBatchNumber(tx, fastify.config.appTimezone);
|
||||
|
||||
const batch = await tx.paymentBatch.create({
|
||||
data: {
|
||||
batch_no: buildBatchNo(),
|
||||
batch_no: allocatedNumber.batchNo,
|
||||
entity_type: body.entityType,
|
||||
name: body.name ?? buildDefaultBatchName(fastify.config.appTimezone),
|
||||
name: body.name ?? buildDefaultBatchName(allocatedNumber.businessDate, allocatedNumber.sequence),
|
||||
status: "draft",
|
||||
default_payment_date: body.defaultPaymentDate ? parseDateOnlyString(body.defaultPaymentDate) : null,
|
||||
default_payment_channel: body.defaultPaymentChannel ?? "bank",
|
||||
|
||||
在新工单中引用
屏蔽一个用户