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

这个提交包含在:
2026-08-05 01:02:51 +08:00
父节点 9db8bbc338
当前提交 d4400116c5
共修改 24 个文件,包含 979 行新增和 245 行删除
+90 -10
查看文件
@@ -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");
+6 -1
查看文件
@@ -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") {