feat(reimbursement-small-team-form-validation): 完善付款批次核对与回单预览

这个提交包含在:
2026-08-05 01:02:51 +08:00
父节点 9db8bbc338
当前提交 d4400116c5
共修改 24 个文件,包含 979 行新增和 245 行删除
@@ -56,6 +56,10 @@ const BANK_INSTITUTION_ID = "13131313-1313-4131-8131-131313131313";
const PAYMENT_ACCOUNT_ID = "14141414-1414-4141-8141-141414141414";
const TRAVEL_ORDER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const SYNTHETIC_RECEIPT_PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n", "ascii");
const SYNTHETIC_RECEIPT_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
const SYNTHETIC_BANK_ACCOUNT = `6222${"0".repeat(8)}5678`;
@@ -168,6 +172,15 @@ function buildReimbursement(overrides = {}) {
reimbursement_date: new Date("2026-04-28T00:00:00.000Z"),
description: "研发报销",
total_amount: "1683.00",
items: [
{
id: "15151515-1515-4151-8151-151515151515",
expense_date: new Date("2026-04-27T00:00:00.000Z"),
description: "研发设备采购",
amount: "1683.00",
expense_type: { name: "办公设备" },
},
],
payment_voucher_no: null,
payment_date: null,
payment_channel: null,
@@ -652,6 +665,7 @@ describe("payment batch reimbursement routes", () => {
expect(response.statusCode).toBe(200);
expect(response.json().data.item.status).toBe("executed");
expect(response.json().data.item.items[0]).not.toHaveProperty("expenseItems");
expect(state.batch.status).toBe("executed");
expect(state.batch.items[0].status).toBe("executed");
expect(state.reimbursement.status).toBe("paid");
@@ -983,6 +997,7 @@ describe("payment batch reimbursement routes", () => {
bankAccountMasked: null,
});
expect(listResponse.json().data.items[0].items[0]).not.toHaveProperty("snapshotJson");
expect(listResponse.json().data.items[0].items[0]).not.toHaveProperty("expenseItems");
expect(listResponse.json().data.items[0]).not.toHaveProperty("processingProgress");
expect(tx.reimbursementForm.findMany).toHaveBeenCalledTimes(1);
@@ -997,6 +1012,14 @@ describe("payment batch reimbursement routes", () => {
payeeName: "测试员工",
bankName: "中国建设银行",
bankAccountMasked: "6222********5678",
expenseItemsTotal: "1683.00",
expenseItemsAmountMatches: true,
expenseItems: [expect.objectContaining({
expenseTypeName: "办公设备",
expenseDate: "2026-04-27",
description: "研发设备采购",
amount: "1683.00",
})],
});
expect(detailResponse.json().data.item.paymentOrderExportBlockers).toEqual(expect.arrayContaining([
{ code: "legacy_payment_account_snapshot_missing" },
@@ -1166,6 +1189,7 @@ describe("payment batch reimbursement routes", () => {
});
expect(response.statusCode).toBe(201);
expect(response.json().data.item.items[0]).not.toHaveProperty("expenseItems");
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
tx,
expect.objectContaining({
@@ -1911,6 +1935,28 @@ describe("payment batch reimbursement routes", () => {
expect(missingReasonResponse.statusCode).toBe(400);
expect(missingReasonResponse.json().error.details).toEqual({ reason: "required" });
const previewResponse = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file?mode=preview`,
headers: { authorization: "Bearer test" },
});
expect(previewResponse.statusCode).toBe(200);
expect(Buffer.from(previewResponse.rawPayload)).toEqual(SYNTHETIC_RECEIPT_PDF);
expect(previewResponse.headers["content-type"]).toContain("application/pdf");
expect(previewResponse.headers["content-disposition"]).toContain("inline;");
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PAYMENT_BATCH_RECEIPT_PREVIEWED",
target_id: BATCH_ID,
extra: expect.objectContaining({
attachmentId,
reason: "付款批次内在线预览",
}),
}),
}),
);
const downloadResponse = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file?reason=付款复核`,
@@ -1977,6 +2023,52 @@ describe("payment batch reimbursement routes", () => {
},
);
it("previews a validated image receipt inline with the fixed audit purpose", async () => {
const { app, prisma, tempDir } = await buildApp({ receiptAttachments: [] });
try {
const requestPayload = buildMultipartRequest({
files: [{
filename: "bank-receipt.png",
contentType: "image/png",
content: SYNTHETIC_RECEIPT_PNG,
}],
});
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);
const attachmentId = uploadResponse.json().data.items[0].id;
const previewResponse = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file?mode=preview`,
headers: { authorization: "Bearer test" },
});
expect(previewResponse.statusCode).toBe(200);
expect(previewResponse.headers["content-type"]).toContain("image/png");
expect(previewResponse.headers["content-disposition"]).toContain("inline;");
expect(Buffer.from(previewResponse.rawPayload).subarray(0, 8)).toEqual(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "PAYMENT_BATCH_RECEIPT_PREVIEWED",
extra: expect.objectContaining({ reason: "付款批次内在线预览" }),
}),
}),
);
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it.each(["draft", "executed", "cancelled"])(
"rejects receipt upload and void in %s without attachment side effects",
async (status) => {
@@ -688,6 +688,58 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
await app.close();
});
it("attaches the payment-batch link to exactly one status-owning stage", async () => {
const cases = [
{ status: "draft", reviewedAt: null, targetIndex: 3 },
{ status: "reviewed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 },
{ status: "executed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 },
{ status: "failed", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 },
{ status: "cancelled", reviewedAt: null, targetIndex: 3 },
{ status: "cancelled", reviewedAt: new Date("2026-04-11T08:00:00.000Z"), targetIndex: 4 },
];
for (const item of cases) {
const { app } = await buildApp({
permissionCodes: ["expense.view_own", "payment.batch.review"],
paymentBatchItem: {
id: "batch-item-1",
payment_batch: {
id: "batch-1",
status: item.status,
entity_type: "reimbursement",
default_payment_channel: "bank",
payment_order_export_count: 0,
created_at: new Date("2026-04-11T07:00:00.000Z"),
created_by: "maker-1",
reviewed_at: item.reviewedAt,
reviewed_by: item.reviewedAt ? "reviewer-1" : null,
executed_at: ["executed", "failed"].includes(item.status) ? new Date("2026-04-12T08:00:00.000Z") : null,
executed_by: ["executed", "failed"].includes(item.status) ? "executor-1" : null,
cancelled_at: item.status === "cancelled" ? new Date("2026-04-11T09:00:00.000Z") : null,
cancelled_by: item.status === "cancelled" ? "reviewer-1" : null,
cancelled_reason: item.status === "cancelled" ? "批次资料需调整" : null,
items: [{ status: item.status === "executed" ? "executed" : "pending", payment_channel_override: null }],
_count: { receipt_attachments: item.status === "executed" ? 1 : 0 },
},
},
});
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${REIMBURSEMENT_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
const stages = response.json().data.processingProgress.stages;
const linkedStageIndexes = stages
.map((stage, index) => stage.targetHref ? index : -1)
.filter((index) => index >= 0);
expect(linkedStageIndexes).toEqual([item.targetIndex]);
expect(stages[item.targetIndex].targetHref).toBe("/payment-batches?id=batch-1");
await app.close();
}
});
it("marks finance batch creation as the current responsibility after workflow approval", async () => {
const { app } = await buildApp({
reimbursement: buildReimbursementRecord({ status: "approved", workflow_instances: [] }),