CASHV 增加付款批次银行回单附件

这个提交包含在:
2026-05-17 09:35:37 +08:00
父节点 94d2cc8bf9
当前提交 ab80d481cb
共修改 11 个文件,包含 891 行新增和 16 行删除
+299 -5
查看文件
@@ -1,9 +1,13 @@
const crypto = require("node:crypto");
const path = require("node:path");
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { parseDateOnlyString } = require("../utils/date");
const { sha256Hex } = require("../utils/crypto");
const { compactDateString, parseDateOnlyString } = require("../utils/date");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { createNotificationsWithRecords } = require("../services/notifications");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
@@ -11,6 +15,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 { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
@@ -42,6 +47,12 @@ const REVIEW_SCHEMA = z.object({
}).strict();
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
const RECEIPT_ATTACHMENT_MIME_ALLOWLIST = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
]);
function buildValidationError(details, statusCode = 400) {
return new AppError({
@@ -76,6 +87,49 @@ function normalizeNullableString(value) {
return trimmed || null;
}
function sanitizeAttachmentFilename(fileName) {
if (!fileName) return "file";
return fileName.replace(/[\r\n"]/g, "_");
}
function mapReceiptAttachment(item) {
return {
id: item.id,
originalName: item.original_name,
mimeType: item.mime_type,
fileSize: item.file_size,
fileSha256: item.file_sha256,
uploadedBy: item.uploader
? {
id: item.uploader.id,
employeeNo: item.uploader.employee_no,
name: item.uploader.name,
}
: null,
createdAt: item.created_at?.toISOString?.() ?? null,
};
}
function resolveItemPaymentChannel(batch, item) {
return item.payment_channel_override ?? batch.default_payment_channel ?? null;
}
async function ensureBankReceiptForExecutableBatch(tx, batch) {
const requiresBankReceipt = (batch.items ?? []).some((item) =>
["pending", "failed"].includes(item.status) && resolveItemPaymentChannel(batch, item) === "bank",
);
if (!requiresBankReceipt) {
return;
}
const receiptCount = await tx.paymentBatchReceiptAttachment.count({
where: { payment_batch_id: batch.id },
});
if (receiptCount <= 0) {
throw buildValidationError({ receiptAttachments: "bank_receipt_required" }, 409);
}
}
async function ensureFeatureEnabled(fastify) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
@@ -625,6 +679,55 @@ function buildNotFoundError() {
return new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
async function readReceiptUploadFiles(fastify, request) {
const files = [];
try {
for await (const part of request.parts()) {
if (part.type !== "file") {
continue;
}
const originalName = part.filename ?? "receipt";
const mimeType = part.mimetype;
if (!RECEIPT_ATTACHMENT_MIME_ALLOWLIST.has(mimeType)) {
throw buildValidationError({ file: "unsupported_mime_type" });
}
const chunks = [];
let fileSize = 0;
for await (const chunk of part.file) {
fileSize += chunk.length;
if (fileSize > fastify.config.maxUploadSizeBytes) {
throw buildValidationError({ file: "file_too_large" });
}
chunks.push(chunk);
}
if (fileSize === 0) {
throw buildValidationError({ file: "empty_file" });
}
files.push({
buffer: Buffer.concat(chunks),
originalName,
mimeType,
fileSize,
});
}
} catch (error) {
if (error?.code === "FST_REQ_FILE_TOO_LARGE") {
throw buildValidationError({ file: "file_too_large" });
}
throw error;
}
if (!files.length) {
throw buildValidationError({ file: "missing" });
}
return files;
}
async function paymentBatchesRoutes(fastify) {
fastify.get("/payment-batches", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
@@ -723,6 +826,196 @@ async function paymentBatchesRoutes(fastify) {
return sendData(reply, request, { item: mapBatch(batch, openRiskFlags) });
});
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReadPermission(fastify, request);
const batchId = parseId(request.params?.id);
await loadBatchWithItems(fastify.prisma, batchId);
const items = await fastify.prisma.paymentBatchReceiptAttachment.findMany({
where: { payment_batch_id: batchId },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
});
return sendData(reply, request, { items: items.map(mapReceiptAttachment) });
});
fastify.post("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
if (["executed", "cancelled"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
}
const files = await readReceiptUploadFiles(fastify, request);
const datePrefix = compactDateString(fastify.config.appTimezone);
const createdItems = [];
for (const file of files) {
const fileSha256 = sha256Hex(file.buffer);
const extension = path.extname(file.originalName ?? "").toLowerCase();
const objectKey = path.posix.join("payment-batch-receipts", datePrefix, `${batchId}-${crypto.randomUUID()}${extension}`);
const storagePath = await writeBuffer({
config: fastify.config,
key: objectKey,
buffer: file.buffer,
contentType: file.mimeType,
});
const created = await fastify.prisma.paymentBatchReceiptAttachment.create({
data: {
payment_batch_id: batchId,
storage_path: storagePath,
original_name: file.originalName,
mime_type: file.mimeType,
file_size: file.fileSize,
file_sha256: fileSha256,
uploaded_by: request.employee.id,
},
select: {
id: true,
original_name: true,
mime_type: true,
file_size: true,
file_sha256: true,
created_at: true,
uploader: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
});
createdItems.push(created);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_RECEIPT_UPLOADED",
targetType: "payment_batch",
targetId: batchId,
extra: {
attachmentIds: createdItems.map((item) => item.id),
attachmentCount: createdItems.length,
},
}),
});
return sendData(reply, request, { items: createdItems.map(mapReceiptAttachment) });
});
fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/file", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchReadPermission(fastify, request);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const attachment = await fastify.prisma.paymentBatchReceiptAttachment.findFirst({
where: {
id: attachmentId,
payment_batch_id: batchId,
},
select: {
id: true,
storage_path: true,
original_name: true,
mime_type: true,
},
});
if (!attachment) {
throw buildNotFoundError();
}
let stream = null;
try {
const opened = await openReadStream({
config: fastify.config,
storagePath: attachment.storage_path,
});
stream = opened.stream;
} catch (error) {
if (error instanceof StorageNotFoundError) {
throw buildNotFoundError();
}
throw error;
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
targetType: "payment_batch",
targetId: batchId,
extra: { attachmentId: attachment.id },
}),
});
reply.type(attachment.mime_type || "application/octet-stream");
reply.header(
"content-disposition",
`inline; filename=\"${sanitizeAttachmentFilename(attachment.original_name)}\"`,
);
return reply.send(stream);
});
fastify.delete("/payment-batches/:id/receipt-attachments/:attachmentId", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await loadBatchWithItems(tx, batchId);
if (["executed", "cancelled"].includes(batch.status)) {
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
}
const deleted = await tx.paymentBatchReceiptAttachment.deleteMany({
where: {
id: attachmentId,
payment_batch_id: batchId,
},
});
if (deleted.count === 0) {
throw buildNotFoundError();
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "PAYMENT_BATCH_RECEIPT_DELETED",
targetType: "payment_batch",
targetId: batchId,
extra: { attachmentId },
}),
});
return { attachmentId };
});
return sendData(reply, request, { attachmentId: result.attachmentId, outcome: "deleted" });
});
fastify.patch("/payment-batches/:id/items/:itemId", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
@@ -731,15 +1024,15 @@ async function paymentBatchesRoutes(fastify) {
const body = parseBody(PATCH_ITEM_SCHEMA, request.body);
const batch = await loadBatchWithItems(fastify.prisma, batchId);
if (batch.status !== "draft") {
throw buildValidationError({ status: "only_draft_editable" }, 409);
if (!["draft", "reviewed", "failed"].includes(batch.status)) {
throw buildValidationError({ status: "only_open_batch_editable" }, 409);
}
const item = batch.items.find((entry) => entry.id === itemId);
if (!item) {
throw buildNotFoundError();
}
if (item.status !== "pending") {
throw buildValidationError({ item: "only_pending_editable" }, 409);
if (!["pending", "failed"].includes(item.status)) {
throw buildValidationError({ item: "only_open_item_editable" }, 409);
}
const updated = await fastify.prisma.paymentBatchItem.update({
@@ -872,6 +1165,7 @@ async function paymentBatchesRoutes(fastify) {
review: batchRisk.highRisk ? "high_risk_second_review_required" : "batch_review_required",
}, 409);
}
await ensureBankReceiptForExecutableBatch(tx, batch);
let successCount = 0;
let failedCount = 0;