实现报销材料连续预览(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -22,6 +22,7 @@ const {
|
||||
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
|
||||
const { mapInvoiceDocumentMeta } = require("../services/invoiceDocumentMeta");
|
||||
const { dispatchNotificationBatch, flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { ensureCutoverAllowed } = require("../services/cutover");
|
||||
@@ -32,7 +33,11 @@ const {
|
||||
resolveEntityPrecheck,
|
||||
} = require("../services/feeControl");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const { openReadStream, readFileBuffer, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
MaterialPreviewError,
|
||||
createReimbursementMaterialPreviewStore,
|
||||
} = require("../services/reimbursementMaterialPreview");
|
||||
const {
|
||||
ATTACHMENT_TYPE,
|
||||
DOWNLOAD_POLICY,
|
||||
@@ -80,6 +85,8 @@ const REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA = z
|
||||
const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
|
||||
.object({ reason: z.string().trim().max(200).optional() })
|
||||
.strict();
|
||||
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
|
||||
const REIMBURSEMENT_MATERIAL_PREVIEW_PAGE_SCHEMA = z.coerce.number().int().min(0).max(39);
|
||||
|
||||
function resolveSignedFileOrigin(config, request) {
|
||||
const configured = String(config.appOrigin ?? "")
|
||||
@@ -94,6 +101,21 @@ function resolvePreviewUrlTtlMs(config) {
|
||||
return Math.min(configuredSeconds, 120) * 1000;
|
||||
}
|
||||
|
||||
function throwMaterialPreviewAppError(error) {
|
||||
if (error instanceof StorageNotFoundError) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!(error instanceof MaterialPreviewError)) {
|
||||
throw error;
|
||||
}
|
||||
const limitError = /_limit$/.test(error.code);
|
||||
throw new AppError({
|
||||
code: limitError ? "VALID_001" : "MATERIAL_PREVIEW_001",
|
||||
message: limitError ? "报销材料较多,暂时无法连续预览" : "部分材料暂时无法生成连续预览",
|
||||
statusCode: limitError ? 409 : 422,
|
||||
});
|
||||
}
|
||||
|
||||
const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
|
||||
.object({
|
||||
ownerEmployeeId: z.string().uuid().optional(),
|
||||
@@ -1361,6 +1383,9 @@ async function finalizeReimbursementSubmitSideEffects({
|
||||
}
|
||||
|
||||
async function reimbursementRoutes(fastify) {
|
||||
const materialPreviewStore = fastify.reimbursementMaterialPreviewStore
|
||||
?? createReimbursementMaterialPreviewStore();
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements",
|
||||
{
|
||||
@@ -2121,11 +2146,13 @@ async function reimbursementRoutes(fastify) {
|
||||
invoice_number: true,
|
||||
invoice_date: true,
|
||||
seller_name: true,
|
||||
buyer_name: true,
|
||||
total_amount: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
linked_item_id: true,
|
||||
ocr_result: { select: { result_json: true } },
|
||||
},
|
||||
},
|
||||
workflow_instances: {
|
||||
@@ -2291,12 +2318,14 @@ async function reimbursementRoutes(fastify) {
|
||||
attachments: reimbursement.attachments.map(mapReimbursementAttachment),
|
||||
budgetHint,
|
||||
invoices: reimbursement.invoice_pool_items.map((item) => ({
|
||||
...mapInvoiceDocumentMeta(item),
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
invoiceCode: item.invoice_code,
|
||||
invoiceNumber: item.invoice_number,
|
||||
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
sellerName: item.seller_name ?? null,
|
||||
buyerName: item.buyer_name ?? null,
|
||||
totalAmount: toMoneyString(item.total_amount),
|
||||
originalName: item.original_name ?? null,
|
||||
mimeType: item.mime_type ?? null,
|
||||
@@ -2951,6 +2980,212 @@ async function reimbursementRoutes(fastify) {
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/materials/preview-bundle",
|
||||
{ preHandler: fastify.authenticate },
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedBody = REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsedBody.success) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400 });
|
||||
}
|
||||
|
||||
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
|
||||
where: { id: reimbursementId },
|
||||
select: {
|
||||
id: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
invoice_pool_items: {
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
storage_path: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
storage_path: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
download_policy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!reimbursement) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
ensureOwnOrCanViewAll(fastify, request, reimbursement);
|
||||
|
||||
const reason = sanitizeDownloadReason(parsedBody.data.reason);
|
||||
if (reimbursement.attachments.some((attachment) => requiresDownloadReason(attachment)) && !reason) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { reason: "required" },
|
||||
});
|
||||
}
|
||||
|
||||
const materials = [
|
||||
...reimbursement.invoice_pool_items.map((invoice, index) => ({
|
||||
id: invoice.id,
|
||||
materialType: "invoice",
|
||||
materialIndex: index + 1,
|
||||
storagePath: invoice.storage_path,
|
||||
mimeType: invoice.mime_type,
|
||||
fileSize: invoice.file_size,
|
||||
})),
|
||||
...reimbursement.attachments.map((attachment, index) => ({
|
||||
id: attachment.id,
|
||||
materialType: "attachment",
|
||||
materialIndex: index + 1,
|
||||
storagePath: attachment.storage_path,
|
||||
mimeType: attachment.mime_type,
|
||||
fileSize: attachment.file_size,
|
||||
})),
|
||||
];
|
||||
if (materials.some((material) => !ATTACHMENT_MIME_ALLOWLIST.has(material.mimeType))) {
|
||||
throw new AppError({
|
||||
code: "MATERIAL_PREVIEW_001",
|
||||
message: "部分材料格式暂不支持连续预览",
|
||||
statusCode: 422,
|
||||
});
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + resolvePreviewUrlTtlMs(fastify.config);
|
||||
let created;
|
||||
try {
|
||||
created = await materialPreviewStore.createBundle({
|
||||
actorId: request.employee.id,
|
||||
reimbursementId,
|
||||
materials,
|
||||
expiresAt,
|
||||
readSource: (material) => readFileBuffer({
|
||||
config: fastify.config,
|
||||
storagePath: material.storagePath,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
throwMaterialPreviewAppError(error);
|
||||
}
|
||||
|
||||
const origin = resolveSignedFileOrigin(fastify.config, request);
|
||||
const pages = created.bundle.pages.map((page, pageIndex) => {
|
||||
const previewToken = signPayload({
|
||||
purpose: "reimbursement_material_preview",
|
||||
reimbursementId,
|
||||
bundleId: created.bundleId,
|
||||
pageIndex,
|
||||
actorId: request.employee.id,
|
||||
exp: expiresAt,
|
||||
}, fastify.config.jwtSecret);
|
||||
return {
|
||||
url: `${origin}/api/reimbursements/${reimbursementId}/materials/preview/${created.bundleId}/${pageIndex}?previewToken=${encodeURIComponent(previewToken)}`,
|
||||
materialType: page.materialType,
|
||||
materialIndex: page.materialIndex,
|
||||
pageNumber: page.pageNumber,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "REIMBURSEMENT_MATERIAL_PREVIEW_BUNDLE_ISSUED",
|
||||
targetType: "reimbursement",
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
invoiceCount: reimbursement.invoice_pool_items.length,
|
||||
attachmentCount: reimbursement.attachments.length,
|
||||
pageCount: pages.length,
|
||||
reason,
|
||||
expiresAt,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
materialPreviewStore.deleteBundle?.(created.bundleId);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(reply, request, {
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
pages,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/reimbursements/:id/materials/preview/:bundleId/:pageIndex",
|
||||
{},
|
||||
async (request, reply) => {
|
||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||
const parsedBundleId = UUID_SCHEMA.safeParse(request.params?.bundleId);
|
||||
const parsedPageIndex = REIMBURSEMENT_MATERIAL_PREVIEW_PAGE_SCHEMA.safeParse(request.params?.pageIndex);
|
||||
const previewToken = typeof request.query?.previewToken === "string" ? request.query.previewToken : "";
|
||||
const verified = previewToken ? verifyToken(previewToken, fastify.config.jwtSecret) : { ok: false };
|
||||
const payload = verified.ok ? verified.payload : null;
|
||||
const parsedActorId = UUID_SCHEMA.safeParse(payload?.actorId);
|
||||
if (
|
||||
!parsedBundleId.success ||
|
||||
!parsedPageIndex.success ||
|
||||
!verified.ok ||
|
||||
payload?.purpose !== "reimbursement_material_preview" ||
|
||||
payload?.reimbursementId !== reimbursementId ||
|
||||
payload?.bundleId !== parsedBundleId.data ||
|
||||
payload?.pageIndex !== parsedPageIndex.data ||
|
||||
typeof payload?.exp !== "number" ||
|
||||
payload.exp <= Date.now() ||
|
||||
!parsedActorId.success
|
||||
) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
|
||||
const resolved = materialPreviewStore.getPage({
|
||||
bundleId: parsedBundleId.data,
|
||||
pageIndex: parsedPageIndex.data,
|
||||
});
|
||||
if (
|
||||
!resolved ||
|
||||
resolved.bundle.reimbursementId !== reimbursementId ||
|
||||
resolved.bundle.actorId !== parsedActorId.data ||
|
||||
resolved.bundle.expiresAt !== payload.exp
|
||||
) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: parsedActorId.data,
|
||||
action: "REIMBURSEMENT_MATERIAL_PREVIEWED",
|
||||
targetType: "reimbursement",
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
materialId: resolved.page.materialId,
|
||||
materialType: resolved.page.materialType,
|
||||
materialIndex: resolved.page.materialIndex,
|
||||
pageNumber: resolved.page.pageNumber,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
reply.type("image/png");
|
||||
reply.header("cache-control", "private, no-store");
|
||||
reply.header("referrer-policy", "no-referrer");
|
||||
reply.header("content-disposition", "inline");
|
||||
return reply.send(resolved.page.buffer);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/reimbursements/:id/items",
|
||||
{
|
||||
|
||||
在新工单中引用
屏蔽一个用户