统一付款详情与批次短编号(08-01-reimbursement-notification-progress-clarity)
这个提交包含在:
@@ -0,0 +1,7 @@
|
||||
const ADVISORY_LOCK_NAMESPACES = Object.freeze({
|
||||
paymentBatchDailySequence: 1001,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
ADVISORY_LOCK_NAMESPACES,
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -429,7 +429,17 @@ function buildPrisma(options = {}) {
|
||||
employee: {
|
||||
findMany: vi.fn(async () => state.employees.map((employee) => ({ ...employee }))),
|
||||
},
|
||||
$queryRaw: vi.fn(async () => [{ id: BATCH_ID }]),
|
||||
$executeRaw: vi.fn(async () => 0),
|
||||
$queryRaw: vi.fn(async (query, ...values) => {
|
||||
const text = Array.isArray(query) ? query.join(" ") : String(query?.sql ?? query ?? "");
|
||||
if (text.includes('ORDER BY "batch_no" DESC')) {
|
||||
const pattern = values[1];
|
||||
return typeof pattern === "string" && new RegExp(pattern).test(state.batch.batch_no)
|
||||
? [{ batch_no: state.batch.batch_no }]
|
||||
: [];
|
||||
}
|
||||
return [{ id: BATCH_ID }];
|
||||
}),
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
@@ -507,7 +517,7 @@ async function buildApp(options = {}) {
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
appTimezone: options.appTimezone ?? "Asia/Shanghai",
|
||||
storageDir: tempDir,
|
||||
maxUploadSizeBytes: 5 * 1024 * 1024,
|
||||
paymentBatchEnabled: true,
|
||||
@@ -1050,7 +1060,8 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(tx.paymentBatch.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
name: `${currentDateString("Asia/Shanghai")} 付款批次`,
|
||||
batch_no: `PB-${currentDateString("Asia/Shanghai").replaceAll("-", "")}-001`,
|
||||
name: `${currentDateString("Asia/Shanghai")} 付款批次 001`,
|
||||
default_payment_date: null,
|
||||
default_payment_channel: "bank",
|
||||
voucher_prefix: null,
|
||||
@@ -1157,7 +1168,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("adds a non-identifying random suffix to concurrent-style batch numbers", async () => {
|
||||
it("allocates an increasing company-local daily sequence and preserves an explicit name", async () => {
|
||||
const { app, tx } = await buildApp();
|
||||
const payload = {
|
||||
entityType: "reimbursement",
|
||||
@@ -1182,9 +1193,87 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(second.statusCode).toBe(201);
|
||||
const batchNos = tx.paymentBatch.create.mock.calls.map(([argument]) => argument.data.batch_no);
|
||||
expect(batchNos).toHaveLength(2);
|
||||
expect(batchNos[0]).toMatch(/^PB-\d{8}-\d{6}-\d{3}-[0-9A-F]{6}$/);
|
||||
expect(batchNos[1]).toMatch(/^PB-\d{8}-\d{6}-\d{3}-[0-9A-F]{6}$/);
|
||||
expect(batchNos[0]).not.toBe(batchNos[1]);
|
||||
const compactDate = currentDateString("Asia/Shanghai").replaceAll("-", "");
|
||||
expect(batchNos).toEqual([
|
||||
`PB-${compactDate}-001`,
|
||||
`PB-${compactDate}-002`,
|
||||
]);
|
||||
expect(tx.paymentBatch.create.mock.calls[0][0].data.name).toBe("付款批次编号验证");
|
||||
expect(tx.paymentBatch.create.mock.calls[1][0].data.name).toBe("付款批次编号验证");
|
||||
const advisoryLockCalls = tx.$executeRaw.mock.calls.filter(([query]) =>
|
||||
Array.isArray(query) && query.join(" ").includes("pg_advisory_xact_lock"),
|
||||
);
|
||||
expect(advisoryLockCalls).toHaveLength(2);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("uses the configured company timezone and starts its local day at 001", async () => {
|
||||
const appTimezone = "Pacific/Honolulu";
|
||||
const businessDate = currentDateString(appTimezone);
|
||||
const { app, tx } = await buildApp({ appTimezone });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/payment-batches",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(tx.paymentBatch.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
batch_no: `PB-${businessDate.replaceAll("-", "")}-001`,
|
||||
name: `${businessDate} 付款批次 001`,
|
||||
}),
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("ignores a legacy long batch number from the same company-local day", async () => {
|
||||
const businessDate = currentDateString("Asia/Shanghai");
|
||||
const compactDate = businessDate.replaceAll("-", "");
|
||||
const { app, tx } = await buildApp({
|
||||
batch: { batch_no: `PB-${compactDate}-063245-812-A1B2C3` },
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/payment-batches",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(tx.paymentBatch.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
batch_no: `PB-${compactDate}-001`,
|
||||
name: `${businessDate} 付款批次 001`,
|
||||
}),
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("fails closed when the company-local daily sequence is exhausted", async () => {
|
||||
const businessDate = currentDateString("Asia/Shanghai");
|
||||
const { app, tx } = await buildApp({
|
||||
batch: { batch_no: `PB-${businessDate.replaceAll("-", "")}-999` },
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/payment-batches",
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.batchNo).toBe("daily_sequence_exhausted");
|
||||
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
|
||||
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户