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

这个提交包含在:
2026-05-17 09:35:37 +08:00
父节点 94d2cc8bf9
当前提交 ab80d481cb
共修改 11 个文件,包含 891 行新增和 16 行删除
@@ -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: {