feat(reimbursement-small-team-form-validation): 完善付款批次核对与回单预览
这个提交包含在:
@@ -83,6 +83,11 @@ const REASON_SCHEMA = z.object({
|
||||
reason: z.string().trim().min(2).max(500),
|
||||
}).strict();
|
||||
|
||||
const RECEIPT_FILE_QUERY_SCHEMA = z.object({
|
||||
mode: z.enum(["download", "preview"]).default("download"),
|
||||
reason: z.string().max(500).optional(),
|
||||
}).strict();
|
||||
|
||||
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
|
||||
const PAYMENT_BATCH_DAILY_SEQUENCE_MAX = 999;
|
||||
|
||||
@@ -740,8 +745,45 @@ function paymentOrderExportBlockers(batch) {
|
||||
return blockers;
|
||||
}
|
||||
|
||||
async function loadReimbursementExpenseItems(tx, batch) {
|
||||
if (batch.entity_type !== "reimbursement") return new Map();
|
||||
const entityIds = Array.from(new Set((batch.items ?? []).map((item) => item.entity_id)));
|
||||
if (!entityIds.length) return new Map();
|
||||
const reimbursements = await tx.reimbursementForm.findMany({
|
||||
where: { id: { in: entityIds } },
|
||||
select: {
|
||||
id: true,
|
||||
items: {
|
||||
orderBy: [{ expense_date: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
expense_date: true,
|
||||
description: true,
|
||||
amount: true,
|
||||
expense_type: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return new Map(reimbursements.map((reimbursement) => {
|
||||
const expenseItems = (reimbursement.items ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
expenseTypeName: item.expense_type?.name ?? "未分类费用",
|
||||
expenseDate: item.expense_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
description: item.description,
|
||||
amount: toMoneyString(item.amount),
|
||||
}));
|
||||
return [reimbursement.id, {
|
||||
expenseItems,
|
||||
expenseItemsTotal: toMoneyString(expenseItems.reduce((sum, item) => sum + toMoneyNumber(item.amount), 0)),
|
||||
}];
|
||||
}));
|
||||
}
|
||||
|
||||
function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = {}) {
|
||||
const includePayee = options.includePayee === true;
|
||||
const includeExpenseItems = options.includeExpenseItems === true;
|
||||
const expenseItemsByEntityId = options.expenseItemsByEntityId ?? new Map();
|
||||
const batchRisk = summarizeBatchRisk(batch);
|
||||
const riskMap = new Map();
|
||||
for (const item of openRiskFlags) {
|
||||
@@ -791,7 +833,11 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
|
||||
const payee = includePayee
|
||||
? readSnapshotPayee(item.snapshot_json) ?? legacyPayees.get(item.entity_id) ?? {}
|
||||
: {};
|
||||
return {
|
||||
const expenseDetail = expenseItemsByEntityId.get(item.entity_id) ?? {
|
||||
expenseItems: [],
|
||||
expenseItemsTotal: "0.00",
|
||||
};
|
||||
const mappedItem = {
|
||||
id: item.id,
|
||||
entityId: item.entity_id,
|
||||
entityCode: item.entity_code,
|
||||
@@ -810,6 +856,12 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
openRiskFlags: riskMap.get(item.entity_id) ?? [],
|
||||
};
|
||||
if (includeExpenseItems) {
|
||||
mappedItem.expenseItems = expenseDetail.expenseItems;
|
||||
mappedItem.expenseItemsTotal = expenseDetail.expenseItemsTotal;
|
||||
mappedItem.expenseItemsAmountMatches = expenseDetail.expenseItemsTotal === toMoneyString(item.amount);
|
||||
}
|
||||
return mappedItem;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -853,8 +905,12 @@ function buildPaymentBatchProcessingProgress(batch, actorNames) {
|
||||
};
|
||||
}
|
||||
|
||||
async function mapBatchForResponse(tx, batch, openRiskFlags, config) {
|
||||
const legacyPayees = await loadLegacyPayees(tx, batch, config);
|
||||
async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {}) {
|
||||
const includeExpenseItems = options.includeExpenseItems === true && batch.entity_type === "reimbursement";
|
||||
const [legacyPayees, expenseItemsByEntityId] = await Promise.all([
|
||||
loadLegacyPayees(tx, batch, config),
|
||||
includeExpenseItems ? loadReimbursementExpenseItems(tx, batch) : Promise.resolve(new Map()),
|
||||
]);
|
||||
const actorIds = Array.from(new Set([
|
||||
batch.created_by,
|
||||
batch.reviewed_by,
|
||||
@@ -869,7 +925,11 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config) {
|
||||
: [];
|
||||
const actorNames = new Map(actors.map((actor) => [actor.id, actor.name]));
|
||||
return {
|
||||
...mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true }),
|
||||
...mapBatch(batch, openRiskFlags, legacyPayees, {
|
||||
includePayee: true,
|
||||
includeExpenseItems,
|
||||
expenseItemsByEntityId,
|
||||
}),
|
||||
processingProgress: buildPaymentBatchProcessingProgress(batch, actorNames),
|
||||
};
|
||||
}
|
||||
@@ -1620,7 +1680,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
|
||||
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, batch, openRiskFlags, fastify.config),
|
||||
item: await mapBatchForResponse(fastify.prisma, batch, openRiskFlags, fastify.config, {
|
||||
includeExpenseItems: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1907,6 +1969,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const batchId = parseId(request.params?.id);
|
||||
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
|
||||
const queryParsed = RECEIPT_FILE_QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!queryParsed.success) throw buildValidationError(queryParsed.error.flatten());
|
||||
const previewMode = queryParsed.data.mode === "preview";
|
||||
const batch = await loadBatchWithItems(fastify.prisma, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
|
||||
const maxBytes = resolveReceiptAttachmentMaxBytes(fastify.config);
|
||||
@@ -1935,8 +2000,10 @@ async function paymentBatchesRoutes(fastify) {
|
||||
if (attachment.archived_at) {
|
||||
throw buildValidationError({ receiptAttachment: "voided" }, 409);
|
||||
}
|
||||
const downloadReason = sanitizeDownloadReason(request.query?.reason);
|
||||
if (requiresDownloadReason(attachment) && !downloadReason) {
|
||||
const downloadReason = previewMode
|
||||
? "付款批次内在线预览"
|
||||
: sanitizeDownloadReason(queryParsed.data.reason);
|
||||
if (!previewMode && requiresDownloadReason(attachment) && !downloadReason) {
|
||||
throw buildValidationError({ reason: "required" });
|
||||
}
|
||||
|
||||
@@ -2028,10 +2095,23 @@ async function paymentBatchesRoutes(fastify) {
|
||||
throw buildReceiptAttachmentBlockedError(error.reasonCode);
|
||||
}
|
||||
|
||||
const previewMimeTypes = new Set(["application/pdf", "image/jpeg", "image/png", "image/webp"]);
|
||||
if (previewMode && !previewMimeTypes.has(validated.mimeType)) {
|
||||
await auditBlockedReceiptDownload({
|
||||
fastify,
|
||||
request,
|
||||
batchId,
|
||||
attachment,
|
||||
reasonCode: "preview_type_not_allowed",
|
||||
downloadReason,
|
||||
});
|
||||
throw buildReceiptAttachmentBlockedError("preview_type_not_allowed");
|
||||
}
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
|
||||
action: previewMode ? "PAYMENT_BATCH_RECEIPT_PREVIEWED" : "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
|
||||
targetType: "payment_batch",
|
||||
targetId: batchId,
|
||||
extra: {
|
||||
@@ -2044,10 +2124,10 @@ async function paymentBatchesRoutes(fastify) {
|
||||
}),
|
||||
});
|
||||
|
||||
reply.type("application/octet-stream");
|
||||
reply.type(previewMode ? validated.mimeType : "application/octet-stream");
|
||||
reply.header(
|
||||
"content-disposition",
|
||||
buildAttachmentContentDisposition(validated.originalName),
|
||||
buildAttachmentContentDisposition(validated.originalName, previewMode ? { disposition: "inline" } : undefined),
|
||||
);
|
||||
reply.header("x-content-type-options", "nosniff");
|
||||
reply.header("cache-control", "private, no-store, max-age=0");
|
||||
|
||||
@@ -549,9 +549,14 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
|
||||
result: source.result,
|
||||
comment: source.comment,
|
||||
detail: source.detail,
|
||||
targetHref: batchTargetHref,
|
||||
targetHref: null,
|
||||
});
|
||||
}
|
||||
if (batchTargetHref) {
|
||||
const reviewOwnsBatchLink = ["current", "exception"].includes(batchReviewStage.state) &&
|
||||
["pending", "terminated"].includes(paymentStage.state);
|
||||
(reviewOwnsBatchLink ? batchReviewStage : paymentStage).targetHref = batchTargetHref;
|
||||
}
|
||||
}
|
||||
|
||||
if (!batch && reimbursement.status === "paid") {
|
||||
|
||||
@@ -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: [] }),
|
||||
|
||||
在新工单中引用
屏蔽一个用户