统一付款详情与批次短编号(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-02 09:02:40 +08:00
父节点 171fc09524
当前提交 5d274af8d4
共修改 11 个文件,包含 467 行新增和 82 行删除
+7
查看文件
@@ -0,0 +1,7 @@
const ADVISORY_LOCK_NAMESPACES = Object.freeze({
paymentBatchDailySequence: 1001,
});
module.exports = {
ADVISORY_LOCK_NAMESPACES,
};
+46 -10
查看文件
@@ -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",
@@ -4,6 +4,8 @@ const { Client } = require("pg");
const { buildApp } = require("../app");
const { createPrismaClient } = require("../prismaClient");
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
const { currentDateString } = require("../utils/date");
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
@@ -567,6 +569,56 @@ async function runDoubleCreateRace({ app, prisma, context, fixture, tokens }) {
assertCondition(notificationCount === 0, "double_create_notification_created");
}
async function runDailySequenceRace({ app, prisma, context, fixture, tokens }) {
const [firstReimbursement, secondReimbursement] = await Promise.all([
createApprovedReimbursement(prisma, fixture, 6),
createApprovedReimbursement(prisma, fixture, 7),
]);
const businessDate = currentDateString(process.env.APP_TIMEZONE ?? "Asia/Shanghai");
const compactDate = businessDate.replaceAll("-", "");
const prefix = `PB-${compactDate}-`;
const existing = await prisma.paymentBatch.findMany({
where: { batch_no: { startsWith: prefix } },
select: { batch_no: true },
});
const sequencePattern = new RegExp(`^PB-${compactDate}-([0-9]{3})$`);
const previousMax = existing.reduce((maximum, batch) => {
const match = sequencePattern.exec(batch.batch_no);
return Math.max(maximum, match ? Number.parseInt(match[1], 10) : 0);
}, 0);
const responses = await runBlockedRace({
databaseUrl: context.databaseUrl,
lockSql: "SELECT pg_advisory_xact_lock($1::int, $2::int)",
lockParams: [
ADVISORY_LOCK_NAMESPACES.paymentBatchDailySequence,
Number.parseInt(compactDate, 10),
],
requestFactories: [firstReimbursement, secondReimbursement].map((reimbursement) => () =>
injectJson(app, {
method: "POST",
url: "/api/payment-batches",
token: tokens.finance,
payload: {
entityType: "reimbursement",
entityIds: [reimbursement.id],
defaultPaymentChannel: "cash",
},
})),
timeoutCode: "daily_sequence_race_timeout",
});
assertStatusPair(responses, [201, 201], "daily_sequence_create_status_invalid");
const batchNos = responses.map((response) => response.body?.data?.item?.batchNo).sort();
assertCondition(batchNos.every((batchNo) => sequencePattern.test(batchNo)), "daily_sequence_format_invalid");
const sequences = batchNos.map((batchNo) => Number.parseInt(sequencePattern.exec(batchNo)[1], 10));
assertCondition(
JSON.stringify(sequences) === JSON.stringify([previousMax + 1, previousMax + 2]),
"daily_sequence_not_unique_incrementing",
);
const createdCount = await prisma.paymentBatch.count({ where: { batch_no: { in: batchNos } } });
assertCondition(createdCount === 2, "daily_sequence_batch_count_invalid");
}
async function runMissingMakerReviewCheck({ app, prisma, fixture, tokens }) {
const reimbursement = await createApprovedReimbursement(prisma, fixture, 5);
const created = await injectJson(app, {
@@ -737,6 +789,9 @@ async function runPaymentPostgresConcurrencyCheck(env = process.env) {
console.log("[PAYMENT-CONCURRENCY] double-create: start");
await runDoubleCreateRace({ app, prisma, context, fixture, tokens });
console.log("[PAYMENT-CONCURRENCY] double-create: pass");
console.log("[PAYMENT-CONCURRENCY] daily-sequence: start");
await runDailySequenceRace({ app, prisma, context, fixture, tokens });
console.log("[PAYMENT-CONCURRENCY] daily-sequence: pass");
console.log("[PAYMENT-CONCURRENCY] direct-pay-batch: start");
await runDirectPayBatchRace({ app, prisma, context, fixture, tokens });
console.log("[PAYMENT-CONCURRENCY] direct-pay-batch: pass");