收紧付款复核资料与阶段编辑(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-02 15:15:01 +08:00
父节点 9afebdb44d
当前提交 185d64dcd6
共修改 14 个文件,包含 777 行新增和 121 行删除
+146 -38
查看文件
@@ -17,6 +17,7 @@ const { sendNotificationEmailSafe } = require("../services/email");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const {
buildAttachmentContentDisposition,
@@ -61,7 +62,6 @@ const CREATE_SCHEMA = z.object({
const PATCH_ITEM_SCHEMA = z.object({
paymentVoucherNoOverride: z.string().trim().max(50).nullable().optional(),
paymentDateOverride: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
paymentChannelOverride: PAYMENT_CHANNEL_SCHEMA.nullable().optional(),
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
const REVIEW_SCHEMA = z.object({
@@ -492,7 +492,22 @@ async function lockPaymentEntities(tx, entityType, entityIds) {
`);
}
async function loadEntitySnapshots(tx, entityType, entityIds) {
function buildPayeeSnapshot(config, owner) {
const encryptedAccount = owner?.bank_account_encrypted;
const bankAccount = encryptedAccount
? decryptStringOrThrow(config, encryptedAccount)
: null;
if (encryptedAccount && !bankAccount) {
throw new Error("Payee bank account could not be decrypted");
}
return {
name: owner?.name ?? null,
bankName: owner?.bank_name ?? null,
bankAccountMasked: maskBankAccount(bankAccount),
};
}
async function loadEntitySnapshots(tx, entityType, entityIds, config) {
if (entityType === "reimbursement") {
const items = await tx.reimbursementForm.findMany({
where: { id: { in: entityIds } },
@@ -507,6 +522,13 @@ async function loadEntitySnapshots(tx, entityType, entityIds) {
payment_date: true,
payment_channel: true,
paid_at: true,
owner: {
select: {
name: true,
bank_name: true,
bank_account_encrypted: true,
},
},
},
});
return items.map((item) => ({
@@ -525,6 +547,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds) {
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: item.payment_channel ?? null,
paidAt: item.paid_at?.toISOString?.() ?? null,
payee: buildPayeeSnapshot(config, item.owner),
},
}));
}
@@ -577,7 +600,77 @@ function ensureBatchEntityStatus(entityType, snapshot) {
}
}
function mapBatch(batch, openRiskFlags = []) {
function normalizeSnapshotText(value) {
if (typeof value !== "string") return null;
const normalized = value.trim();
const compactDigits = normalized.replace(/[\s-]/g, "");
if (
!normalized ||
normalized.length > 100 ||
normalized.startsWith("v1:") ||
/^\d{9,32}$/.test(compactDigits)
) return null;
return normalized;
}
function normalizeMaskedBankAccount(value) {
if (typeof value !== "string") return null;
const normalized = value.trim();
if (!normalized || normalized.length > 64) return null;
if (/^\*{1,8}$/.test(normalized)) return normalized;
return /^[0-9A-Za-z]{4}\*+[0-9A-Za-z]{4}$/.test(normalized) ? normalized : null;
}
function readSnapshotPayee(snapshotJson) {
const payee = snapshotJson?.payee;
if (!payee || typeof payee !== "object" || Array.isArray(payee)) {
return null;
}
const bankAccountMasked = normalizeMaskedBankAccount(payee.bankAccountMasked);
const name = normalizeSnapshotText(payee.name);
const bankName = normalizeSnapshotText(payee.bankName);
if (
(payee.name != null && !name) ||
(payee.bankName != null && !bankName) ||
(payee.bankAccountMasked != null && !bankAccountMasked)
) {
return null;
}
return {
name,
bankName,
bankAccountMasked,
};
}
async function loadLegacyPayees(tx, batch, config) {
if (batch.entity_type !== "reimbursement") {
return new Map();
}
const entityIds = (batch.items ?? [])
.filter((item) => !readSnapshotPayee(item.snapshot_json))
.map((item) => item.entity_id);
if (!entityIds.length) {
return new Map();
}
const reimbursements = await tx.reimbursementForm.findMany({
where: { id: { in: Array.from(new Set(entityIds)) } },
select: {
id: true,
owner: {
select: {
name: true,
bank_name: true,
bank_account_encrypted: true,
},
},
},
});
return new Map(reimbursements.map((item) => [item.id, buildPayeeSnapshot(config, item.owner)]));
}
function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = {}) {
const includePayee = options.includePayee === true;
const batchRisk = summarizeBatchRisk(batch);
const riskMap = new Map();
for (const item of openRiskFlags) {
@@ -615,26 +708,38 @@ function mapBatch(batch, openRiskFlags = []) {
cancelledAt: batch.cancelled_at?.toISOString?.() ?? null,
cancelledBy: batch.cancelled_by ?? null,
cancelledReason: batch.cancelled_reason ?? null,
items: (batch.items ?? []).map((item) => ({
id: item.id,
entityId: item.entity_id,
entityCode: item.entity_code,
entityTitle: item.entity_title,
amount: toMoneyString(item.amount),
currencyCode: item.currency_code,
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: item.payment_channel_override ?? null,
status: item.status,
failureCode: item.failure_code ?? null,
failureMessage: item.failure_message ?? null,
snapshotJson: item.snapshot_json,
createdAt: item.created_at?.toISOString?.() ?? null,
openRiskFlags: riskMap.get(item.entity_id) ?? [],
})),
items: (batch.items ?? []).map((item) => {
const payee = includePayee
? readSnapshotPayee(item.snapshot_json) ?? legacyPayees.get(item.entity_id) ?? {}
: {};
return {
id: item.id,
entityId: item.entity_id,
entityCode: item.entity_code,
entityTitle: item.entity_title,
amount: toMoneyString(item.amount),
currencyCode: item.currency_code,
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: item.payment_channel_override ?? null,
payeeName: payee.name ?? null,
bankName: payee.bankName ?? null,
bankAccountMasked: payee.bankAccountMasked ?? null,
status: item.status,
failureCode: item.failure_code ?? null,
failureMessage: item.failure_message ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
openRiskFlags: riskMap.get(item.entity_id) ?? [],
};
}),
};
}
async function mapBatchForResponse(tx, batch, openRiskFlags, config) {
const legacyPayees = await loadLegacyPayees(tx, batch, config);
return mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true });
}
async function listOpenRiskFlagsForBatch(tx, batch) {
const entityIds = batch.items.map((item) => item.entity_id);
if (!entityIds.length) {
@@ -1217,7 +1322,7 @@ async function paymentBatchesRoutes(fastify) {
const { batch: created, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
await lockPaymentEntities(tx, body.entityType, entityIds);
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds);
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds, fastify.config);
if (snapshots.length !== entityIds.length) {
throw buildValidationError({ entityIds: "contains_not_found" }, 404);
}
@@ -1312,7 +1417,8 @@ async function paymentBatchesRoutes(fastify) {
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
return reply.code(201).send({ requestId: request.id, data: { item: mapBatch(created, []) } });
const mappedCreated = await mapBatchForResponse(fastify.prisma, created, [], fastify.config);
return reply.code(201).send({ requestId: request.id, data: { item: mappedCreated } });
});
fastify.get("/payment-batches/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -1322,7 +1428,9 @@ async function paymentBatchesRoutes(fastify) {
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
return sendData(reply, request, { item: mapBatch(batch, openRiskFlags) });
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, batch, openRiskFlags, fastify.config),
});
});
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -1371,7 +1479,7 @@ async function paymentBatchesRoutes(fastify) {
const batchId = parseId(request.params?.id);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
if (batch.status === "cancelled") {
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
}
@@ -1397,7 +1505,7 @@ async function paymentBatchesRoutes(fastify) {
createdItems = await fastify.prisma.$transaction(async (tx) => {
const lockedBatch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, lockedBatch);
if (lockedBatch.status === "cancelled") {
if (!["reviewed", "failed"].includes(lockedBatch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
}
@@ -1653,7 +1761,7 @@ async function paymentBatchesRoutes(fastify) {
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (batch.status === "cancelled") {
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
}
const attachment = await tx.paymentBatchReceiptAttachment.findFirst({
@@ -1705,8 +1813,8 @@ async function paymentBatchesRoutes(fastify) {
const updated = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (!["draft", "reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "only_open_batch_editable" }, 409);
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "payment_metadata_not_editable" }, 409);
}
const item = batch.items.find((entry) => entry.id === itemId);
if (!item) {
@@ -1719,7 +1827,6 @@ async function paymentBatchesRoutes(fastify) {
const before = {
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: item.payment_channel_override ?? null,
};
const next = await tx.paymentBatchItem.update({
where: { id: itemId },
@@ -1730,17 +1837,13 @@ async function paymentBatchesRoutes(fastify) {
...(body.paymentDateOverride !== undefined
? { payment_date_override: body.paymentDateOverride ? parseDateOnlyString(body.paymentDateOverride) : null }
: {}),
...(body.paymentChannelOverride !== undefined
? { payment_channel_override: body.paymentChannelOverride ?? null }
: {}),
},
});
const after = {
paymentVoucherNoOverride: next.payment_voucher_no_override ?? null,
paymentDateOverride: next.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: next.payment_channel_override ?? null,
};
if (batch.status !== "draft" && JSON.stringify(before) !== JSON.stringify(after)) {
if (JSON.stringify(before) !== JSON.stringify(after)) {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
@@ -1758,7 +1861,6 @@ async function paymentBatchesRoutes(fastify) {
id: updated.id,
paymentVoucherNoOverride: updated.payment_voucher_no_override ?? null,
paymentDateOverride: updated.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannelOverride: updated.payment_channel_override ?? null,
},
});
});
@@ -1859,7 +1961,9 @@ async function paymentBatchesRoutes(fastify) {
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
return sendData(reply, request, { item: mapBatch(result, []) });
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, result, [], fastify.config),
});
});
fastify.post("/payment-batches/:id/execute", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -2027,7 +2131,9 @@ async function paymentBatchesRoutes(fastify) {
}
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result);
return sendData(reply, request, { item: mapBatch(result, openRiskFlags) });
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, result, openRiskFlags, fastify.config),
});
});
fastify.post("/payment-batches/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -2078,7 +2184,9 @@ async function paymentBatchesRoutes(fastify) {
});
return loadBatchWithItems(tx, batchId);
});
return sendData(reply, request, { item: mapBatch(cancelled, []) });
return sendData(reply, request, {
item: await mapBatchForResponse(fastify.prisma, cancelled, [], fastify.config),
});
});
}
@@ -5,6 +5,7 @@ const { Client } = require("pg");
const { buildApp } = require("../app");
const { createPrismaClient } = require("../prismaClient");
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
const { encryptStringOrThrow } = require("../services/crypto");
const { currentDateString } = require("../utils/date");
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
@@ -93,9 +94,9 @@ function validatePaymentConcurrencyEnvironment(env = process.env) {
fail("database_confirmation_mismatch");
}
requireValue(env, "JWT_SECRET");
requireValue(env, "FIELD_ENCRYPTION_KEY");
const fieldEncryptionKey = requireValue(env, "FIELD_ENCRYPTION_KEY");
return { databaseName, databaseUrl };
return { databaseName, databaseUrl, fieldEncryptionKey };
}
function responseJson(response) {
@@ -200,7 +201,7 @@ async function runBlockedRace({
}
}
function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }) {
function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId, fieldEncryptionKey }) {
return {
id,
employee_no: `QA-${runKey}-${kind}`,
@@ -211,7 +212,10 @@ function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }
legal_entity_id: legalEntityId,
title: "并发验收",
hire_date: new Date("2026-01-01T00:00:00.000Z"),
bank_account_encrypted: `synthetic-${runKey}`,
bank_account_encrypted: encryptStringOrThrow(
{ fieldEncryptionKey },
`6222${runKey.slice(0, 8)}5678`,
),
bank_name: "合成银行",
password_hash: "unused-by-token-based-concurrency-check",
token_version: 0,
@@ -219,7 +223,7 @@ function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }
};
}
async function seedSyntheticFixture(prisma) {
async function seedSyntheticFixture(prisma, fieldEncryptionKey) {
const runKey = crypto.randomBytes(6).toString("hex");
const department = await prisma.department.create({
data: { name: `合成部门-${runKey}` },
@@ -244,6 +248,7 @@ async function seedSyntheticFixture(prisma) {
kind,
departmentId: department.id,
legalEntityId: legalEntity.id,
fieldEncryptionKey,
}),
});
}
@@ -772,7 +777,7 @@ async function runPaymentPostgresConcurrencyCheck(env = process.env) {
const { prisma, pool } = createPrismaClient();
let app = null;
try {
const fixture = await seedSyntheticFixture(prisma);
const fixture = await seedSyntheticFixture(prisma, context.fieldEncryptionKey);
app = await buildApp({ startJobs: async () => undefined });
await app.ready();
const tokens = {