CASHV 增加付款批次银行回单附件
这个提交包含在:
@@ -0,0 +1,33 @@
|
||||
-- CASHV: 付款批次银行回单附件。用于记录网银/银行侧付款完成后的回单证据,不接真实支付接口。
|
||||
CREATE TABLE "payment_batch_receipt_attachments" (
|
||||
"id" UUID NOT NULL,
|
||||
"payment_batch_id" UUID NOT NULL,
|
||||
"storage_path" TEXT NOT NULL,
|
||||
"original_name" TEXT NOT NULL,
|
||||
"mime_type" TEXT NOT NULL,
|
||||
"file_size" INTEGER NOT NULL,
|
||||
"file_sha256" TEXT NOT NULL,
|
||||
"uploaded_by" UUID NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "payment_batch_receipt_attachments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "idx_payment_batch_receipts_batch_created"
|
||||
ON "payment_batch_receipt_attachments"("payment_batch_id", "created_at");
|
||||
|
||||
CREATE INDEX "idx_payment_batch_receipts_uploader"
|
||||
ON "payment_batch_receipt_attachments"("uploaded_by");
|
||||
|
||||
CREATE INDEX "idx_payment_batch_receipts_file_sha256"
|
||||
ON "payment_batch_receipt_attachments"("file_sha256");
|
||||
|
||||
ALTER TABLE "payment_batch_receipt_attachments"
|
||||
ADD CONSTRAINT "payment_batch_receipt_attachments_payment_batch_id_fkey"
|
||||
FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "payment_batch_receipt_attachments"
|
||||
ADD CONSTRAINT "payment_batch_receipt_attachments_uploaded_by_fkey"
|
||||
FOREIGN KEY ("uploaded_by") REFERENCES "employees"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -601,6 +601,7 @@ model Employee {
|
||||
owned_reimbursements ReimbursementForm[] @relation("ReimbursementOwner")
|
||||
paid_reimbursements ReimbursementForm[] @relation("ReimbursementPaidBy")
|
||||
reimbursement_attachments_uploaded ReimbursementAttachment[] @relation("ReimbursementAttachmentUploader")
|
||||
payment_batch_receipts_uploaded PaymentBatchReceiptAttachment[] @relation("PaymentBatchReceiptAttachmentUploader")
|
||||
leave_requests LeaveRequest[]
|
||||
leave_ledgers LeaveLedger[]
|
||||
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
|
||||
@@ -1741,6 +1742,7 @@ model PaymentBatch {
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
items PaymentBatchItem[]
|
||||
receipt_attachments PaymentBatchReceiptAttachment[]
|
||||
|
||||
@@index([entity_type, status, created_at], map: "idx_payment_batches_entity_status_created")
|
||||
@@map("payment_batches")
|
||||
@@ -1770,6 +1772,26 @@ model PaymentBatchItem {
|
||||
@@map("payment_batch_items")
|
||||
}
|
||||
|
||||
model PaymentBatchReceiptAttachment {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
storage_path String
|
||||
original_name String
|
||||
mime_type String
|
||||
file_size Int
|
||||
file_sha256 String
|
||||
uploaded_by String @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Cascade)
|
||||
uploader Employee @relation("PaymentBatchReceiptAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([payment_batch_id, created_at], map: "idx_payment_batch_receipts_batch_created")
|
||||
@@index([uploaded_by], map: "idx_payment_batch_receipts_uploader")
|
||||
@@index([file_sha256], map: "idx_payment_batch_receipts_file_sha256")
|
||||
@@map("payment_batch_receipt_attachments")
|
||||
}
|
||||
|
||||
model PortalAnnouncement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
title String
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const Fastify = require("fastify");
|
||||
const multipart = require("@fastify/multipart");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const featureFlags = require("../../../src/services/featureFlags");
|
||||
@@ -41,6 +46,31 @@ const BATCH_ID = "55555555-5555-4555-8555-555555555555";
|
||||
const ITEM_ID = "66666666-6666-4666-8666-666666666666";
|
||||
const OWNER_ID = "77777777-7777-4777-8777-777777777777";
|
||||
const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888";
|
||||
const RECEIPT_ID = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
function buildMultipartRequest({ files }) {
|
||||
const boundary = "----codex-payment-batch-receipt-boundary";
|
||||
const segments = [];
|
||||
|
||||
for (const file of files) {
|
||||
segments.push(
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`,
|
||||
),
|
||||
Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content, "utf8"),
|
||||
Buffer.from("\r\n"),
|
||||
);
|
||||
}
|
||||
|
||||
segments.push(Buffer.from(`--${boundary}--\r\n`));
|
||||
|
||||
return {
|
||||
payload: Buffer.concat(segments),
|
||||
headers: {
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPaymentBatch(overrides = {}) {
|
||||
return {
|
||||
@@ -118,9 +148,28 @@ function buildReimbursement(overrides = {}) {
|
||||
}
|
||||
|
||||
function buildPrisma(options = {}) {
|
||||
const defaultReceiptAttachments = [
|
||||
{
|
||||
id: RECEIPT_ID,
|
||||
payment_batch_id: BATCH_ID,
|
||||
storage_path: "payment-batch-receipts/20260429/receipt.pdf",
|
||||
original_name: "receipt.pdf",
|
||||
mime_type: "application/pdf",
|
||||
file_size: 12,
|
||||
file_sha256: "sha256-receipt",
|
||||
uploaded_by: ACTOR_ID,
|
||||
created_at: new Date("2026-04-29T09:00:00.000Z"),
|
||||
uploader: {
|
||||
id: ACTOR_ID,
|
||||
employee_no: "FIN001",
|
||||
name: "出纳",
|
||||
},
|
||||
},
|
||||
];
|
||||
const state = {
|
||||
batch: buildPaymentBatch(options.batch),
|
||||
reimbursement: buildReimbursement(options.reimbursement),
|
||||
receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments,
|
||||
invoice: {
|
||||
linked_reimbursement_id: REIMBURSEMENT_ID,
|
||||
status: "bound",
|
||||
@@ -167,6 +216,43 @@ function buildPrisma(options = {}) {
|
||||
return { ...item };
|
||||
}),
|
||||
},
|
||||
paymentBatchReceiptAttachment: {
|
||||
count: vi.fn(async ({ where }) =>
|
||||
state.receiptAttachments.filter((item) => item.payment_batch_id === where.payment_batch_id).length,
|
||||
),
|
||||
findMany: vi.fn(async ({ where }) =>
|
||||
state.receiptAttachments
|
||||
.filter((item) => item.payment_batch_id === where.payment_batch_id)
|
||||
.map((item) => ({ ...item })),
|
||||
),
|
||||
create: vi.fn(async ({ data }) => {
|
||||
const created = {
|
||||
id: `aaaaaaaa-aaaa-4aaa-8aaa-${String(state.receiptAttachments.length + 1).padStart(12, "0")}`,
|
||||
...data,
|
||||
created_at: new Date("2026-04-29T10:00:00.000Z"),
|
||||
uploader: {
|
||||
id: ACTOR_ID,
|
||||
employee_no: "FIN001",
|
||||
name: "出纳",
|
||||
},
|
||||
};
|
||||
state.receiptAttachments.push(created);
|
||||
return { ...created };
|
||||
}),
|
||||
findFirst: vi.fn(async ({ where }) => {
|
||||
const item = state.receiptAttachments.find(
|
||||
(candidate) => candidate.id === where.id && candidate.payment_batch_id === where.payment_batch_id,
|
||||
);
|
||||
return item ? { ...item } : null;
|
||||
}),
|
||||
deleteMany: vi.fn(async ({ where }) => {
|
||||
const before = state.receiptAttachments.length;
|
||||
state.receiptAttachments = state.receiptAttachments.filter(
|
||||
(item) => !(item.id === where.id && item.payment_batch_id === where.payment_batch_id),
|
||||
);
|
||||
return { count: before - state.receiptAttachments.length };
|
||||
}),
|
||||
},
|
||||
reimbursementForm: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
if (where.id !== state.reimbursement.id) {
|
||||
@@ -221,6 +307,7 @@ function buildPrisma(options = {}) {
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const { prisma, tx, state } = buildPrisma(options);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-cashv-payment-receipts-"));
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: ACTOR_ID,
|
||||
@@ -263,6 +350,9 @@ async function buildApp(options = {}) {
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
storageDir: tempDir,
|
||||
maxUploadSizeBytes: 5 * 1024 * 1024,
|
||||
paymentBatchEnabled: true,
|
||||
erpSyncEnabled: true,
|
||||
integrationOutboxMaxAttempts: 5,
|
||||
@@ -302,9 +392,13 @@ async function buildApp(options = {}) {
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(multipart, {
|
||||
attachFieldsToBody: false,
|
||||
limits: { files: 5, fileSize: 5 * 1024 * 1024 },
|
||||
});
|
||||
await app.register(paymentBatchesRoutes, { prefix: "/api" });
|
||||
|
||||
return { app, prisma, tx, state };
|
||||
return { app, prisma, tx, state, tempDir };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -417,6 +511,159 @@ describe("payment batch reimbursement routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects bank-channel execution when no bank receipt attachment exists", async () => {
|
||||
const { app, state } = await buildApp({ receiptAttachments: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/execute`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.receiptAttachments).toBe("bank_receipt_required");
|
||||
expect(state.batch.status).toBe("reviewed");
|
||||
expect(state.reimbursement.status).toBe("approved");
|
||||
expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("allows payment metadata updates after review before execution", async () => {
|
||||
const { app, state } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: {
|
||||
paymentVoucherNoOverride: "BANK-RECEIPT-001",
|
||||
paymentDateOverride: "2026-04-30",
|
||||
paymentChannelOverride: "bank",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toMatchObject({
|
||||
paymentVoucherNoOverride: "BANK-RECEIPT-001",
|
||||
paymentDateOverride: "2026-04-30",
|
||||
paymentChannelOverride: "bank",
|
||||
});
|
||||
expect(state.batch.items[0]).toMatchObject({
|
||||
payment_voucher_no_override: "BANK-RECEIPT-001",
|
||||
payment_channel_override: "bank",
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("uploads, downloads and deletes bank receipt attachments", async () => {
|
||||
const { app, prisma, state, tempDir } = await buildApp({ receiptAttachments: [] });
|
||||
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
files: [
|
||||
{
|
||||
filename: "bank-receipt.pdf",
|
||||
contentType: "application/pdf",
|
||||
content: "%PDF bank receipt",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const uploadResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
|
||||
headers: {
|
||||
authorization: "Bearer test",
|
||||
...requestPayload.headers,
|
||||
},
|
||||
payload: requestPayload.payload,
|
||||
});
|
||||
|
||||
expect(uploadResponse.statusCode).toBe(200);
|
||||
expect(uploadResponse.json().data.items).toHaveLength(1);
|
||||
expect(uploadResponse.json().data.items[0]).toMatchObject({
|
||||
originalName: "bank-receipt.pdf",
|
||||
mimeType: "application/pdf",
|
||||
});
|
||||
const attachmentId = uploadResponse.json().data.items[0].id;
|
||||
expect(prisma.paymentBatchReceiptAttachment.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PAYMENT_BATCH_RECEIPT_UPLOADED",
|
||||
target_id: BATCH_ID,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const storedPath = state.receiptAttachments[0].storage_path;
|
||||
const stored = await fs.readFile(path.join(tempDir, storedPath));
|
||||
expect(stored.toString("utf8")).toBe("%PDF bank receipt");
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json().data.items).toHaveLength(1);
|
||||
|
||||
const downloadResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
expect(downloadResponse.statusCode).toBe(200);
|
||||
expect(downloadResponse.body).toBe("%PDF bank receipt");
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
expect(deleteResponse.statusCode).toBe(200);
|
||||
expect(state.receiptAttachments).toHaveLength(0);
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsupported bank receipt attachment MIME types", async () => {
|
||||
const { app, tempDir } = await buildApp({ receiptAttachments: [] });
|
||||
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
files: [
|
||||
{
|
||||
filename: "receipt.txt",
|
||||
contentType: "text/plain",
|
||||
content: "not a receipt attachment type",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
|
||||
headers: {
|
||||
authorization: "Bearer test",
|
||||
...requestPayload.headers,
|
||||
},
|
||||
payload: requestPayload.payload,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details.file).toBe("unsupported_mime_type");
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("marks batch item failed when reimbursement is not payable and does not queue follow-ups", async () => {
|
||||
const { app, state } = await buildApp({
|
||||
reimbursement: {
|
||||
|
||||
在新工单中引用
屏蔽一个用户