- {item.sellerName || "未识别销售方"} · {item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
+ {[resolveInvoicePartyText(item), item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "", `金额 ¥${item.totalAmount ?? "-"}`].filter(Boolean).join(" · ")}
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDisplayDateTime(item.createdAt)}
diff --git a/client/src/pages/ReimbursementMaterialsPage.tsx b/client/src/pages/ReimbursementMaterialsPage.tsx
new file mode 100644
index 00000000..025b43b0
--- /dev/null
+++ b/client/src/pages/ReimbursementMaterialsPage.tsx
@@ -0,0 +1,96 @@
+import { useQuery } from "@tanstack/react-query";
+import { ArrowLeft, RefreshCw } from "lucide-react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { fetchReimbursementMaterialPreviewBundle, type ReimbursementMaterialPreviewBundle } from "../api/reimbursements";
+import { CardSkeleton } from "../components/common/CardSkeleton";
+import { Button } from "../components/ui/button";
+import { resolveErrorMessage } from "../lib/resolveErrorMessage";
+
+type MaterialPageState = {
+ bundle?: ReimbursementMaterialPreviewBundle;
+ from?: string;
+};
+
+function pageLabel(page: ReimbursementMaterialPreviewBundle["pages"][number]) {
+ const type = page.materialType === "invoice" ? "发票" : "补充附件";
+ return `${type} ${page.materialIndex}${page.pageNumber > 1 ? ` · 第 ${page.pageNumber} 页` : ""}`;
+}
+
+export function ReimbursementMaterialsPage() {
+ const { reimbursementId = "" } = useParams();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const state = location.state as MaterialPageState | null;
+ const isMobile = location.pathname.startsWith("/mobile/");
+ const fallback = isMobile
+ ? `/mobile/reimbursements/${reimbursementId}`
+ : `/reimbursements/${reimbursementId}`;
+ const bundleQuery = useQuery({
+ enabled: Boolean(reimbursementId) && !state?.bundle,
+ queryKey: ["reimbursements", reimbursementId, "material-preview-bundle"],
+ queryFn: () => fetchReimbursementMaterialPreviewBundle(reimbursementId),
+ staleTime: 0,
+ retry: false,
+ });
+ const bundle = state?.bundle ?? bundleQuery.data;
+
+ const handleBack = () => {
+ const from = typeof state?.from === "string" && state.from.startsWith("/")
+ ? state.from
+ : fallback;
+ navigate(from, { replace: true });
+ };
+
+ if (!reimbursementId) {
+ return
缺少报销单参数。
;
+ }
+
+ return (
+
+
+
+
+
全部报销材料
+
按发票、补充附件和文件页序排列
+
+ {bundle ? 共 {bundle.pages.length} 页 : null}
+
+
+ {bundleQuery.isLoading && !bundle ?
: null}
+ {bundleQuery.isError && !bundle ? (
+
+
{resolveErrorMessage(bundleQuery.error)}
+
+
+ ) : null}
+
+ {bundle ? (
+
+ {bundle.pages.map((page, index) => (
+
+
+ {pageLabel(page)}
+
+
+

+
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+export default ReimbursementMaterialsPage;
diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx
index 121300c1..623eb4f2 100644
--- a/client/src/router/index.tsx
+++ b/client/src/router/index.tsx
@@ -62,6 +62,7 @@ import {
ReimbursementDetailPage,
ReimbursementDashboardPage,
ReimbursementPrintPage,
+ ReimbursementMaterialsPage,
ReimbursementTemplatesPage,
ReimbursementsPage,
TravelRequestsPage,
@@ -232,6 +233,15 @@ const routeElements = (
}
/>
+
+ {withRouteFallback()}
+
+ }
+ />
+
+
+ {withRouteFallback()}
+
+ }
+ />
+
import("./PurchaseRequestsPage"))
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
export const ReimbursementPrintPage = lazy(() => import("./ReimbursementPrintPage"));
+export const ReimbursementMaterialsPage = lazy(() => import("./ReimbursementMaterialsPage"));
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemplatesPage"));
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
diff --git a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts
index 49d8f6e3..083c4fda 100644
--- a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts
+++ b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts
@@ -73,10 +73,13 @@ function buildInvoice() {
return {
id: pendingInvoiceId,
status: "bound",
- invoiceCode: "044002600111",
+ invoiceCode: null as string | null,
invoiceNumber: "12345678",
invoiceDate: "2026-05-08",
- sellerName: "北京悦海堂餐饮管理有限公司",
+ sellerName: "北京悦海堂餐饮管理有限公司" as string | null,
+ buyerName: null as string | null,
+ documentCategory: "full_vat_invoice" as string | null,
+ documentSubtype: "VatElectronicInvoiceFull" as string | null,
totalAmount: "1683.00",
originalName: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
@@ -90,7 +93,10 @@ function buildUnlinkedInvoice() {
...buildInvoice(),
id: unlinkedInvoiceId,
invoiceNumber: "12345679",
- sellerName: "未关联材料供应方",
+ sellerName: null,
+ buyerName: "测试购买方",
+ documentCategory: "electronic_train_ticket",
+ documentSubtype: "ElectronicTrainTicketFull",
originalName: "未关联发票.pdf",
linkedItemId: null,
};
@@ -351,6 +357,30 @@ async function mockApis(page: Page, state: ReturnType ({
+ url: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/materials/preview/77777777-7777-4777-8777-777777777777/${index}?previewToken=invoice-${index}`,
+ materialType: "invoice",
+ materialIndex: index + 1,
+ pageNumber: 1,
+ })),
+ ...state.attachments.map((_, index) => ({
+ url: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/materials/preview/77777777-7777-4777-8777-777777777777/${state.invoices.length + index}?previewToken=attachment-${index}`,
+ materialType: "attachment",
+ materialIndex: index + 1,
+ pageNumber: 1,
+ })),
+ ];
+ await fulfillJson(route, { expiresAt: "2026-07-31T10:02:00.000Z", pages });
+ return;
+ }
+
+ if (pathName.includes(`/api/reimbursements/${reimbursementId}/materials/preview/`) && request.method() === "GET") {
+ await route.fulfill({ status: 200, contentType: "image/png", body: Buffer.from("synthetic-preview-image") });
+ return;
+ }
+
if (
pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`
&& request.method() === "POST"
@@ -611,17 +641,15 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect(page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible();
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
+ await expect(page.getByTestId("reimbursement-materials")).toContainText("数电发票 · 12345678");
+ await expect(page.getByTestId("reimbursement-materials")).toContainText("电子火车票 · 12345679");
+ await expect(page.getByTestId("reimbursement-materials")).not.toContainText("无代码");
+ await expect(page.getByTestId("reimbursement-materials")).not.toContainText("未识别销售方");
const itemRow = page.getByTestId("reimbursement-item-row");
- await expect(itemRow.getByRole("button", { name: /查看关联发票/ })).toHaveCount(1);
- await expect(itemRow).not.toContainText("未关联材料供应方");
+ await expect(itemRow.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
+ await expect(itemRow).not.toContainText("电子火车票");
await expect(page.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
await expect(page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
- await itemRow.getByRole("button", { name: /查看关联发票/ }).click();
- await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1);
- const invoiceOpenUrl = await page.evaluate(
- () => String((window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0] ?? ""),
- );
- expect(invoiceOpenUrl).toContain(`/api/invoice-pool/${pendingInvoiceId}/file?token=`);
await page.locator("#reimbursement-attachment-upload").setInputFiles({
name: "付款截图.png",
@@ -631,8 +659,13 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.getByRole("button", { name: "上传附件" }).click();
await expect(page.getByText("付款截图.png")).toBeVisible();
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
- await page.getByTestId("reimbursement-attachments").getByRole("button", { name: /查看补充附件/ }).click();
- await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(2);
+ await page.getByTestId("reimbursement-materials-open-all").click();
+ await page.waitForURL(new RegExp(`/reimbursements/${reimbursementId}/materials$`));
+ await expect(page.getByTestId("reimbursement-material-viewer")).toContainText("共 3 页");
+ await expect(page.getByRole("img", { name: /预览/ })).toHaveCount(3);
+ await page.getByRole("button", { name: "返回报销单" }).click();
+ await page.waitForURL(new RegExp(`/reimbursements/${reimbursementId}$`));
+ expect(await page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(0);
const bodyText = await page.locator("body").innerText();
expect(bodyText).not.toContain("绑定整单");
@@ -733,16 +766,14 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await mobile.page.keyboard.press("Escape");
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
+ await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("数电发票 · 12345678");
+ await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("电子火车票 · 12345679");
+ await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("无代码");
+ await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("未识别销售方");
const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first();
- await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(1);
- await expect(mobileItem).not.toContainText("未关联材料供应方");
+ await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
+ await expect(mobileItem).not.toContainText("电子火车票");
await expect(mobile.page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
- const linkedInvoiceTarget = await mobileItem.getByRole("button", { name: /查看关联发票/ }).boundingBox();
- expect(linkedInvoiceTarget?.height ?? 0).toBeGreaterThanOrEqual(44);
- await mobile.page.getByRole("button", { name: /查看发票原件:044002600111-12345678/ }).click();
- await expect.poll(() => mobile.page.evaluate(() => (
- window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
- ).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(1);
await mobile.page.getByRole("button", { name: "添加附件" }).click();
await mobile.page.getByTestId("mobile-reimbursement-materials").locator("input[type='file']").setInputFiles({
@@ -753,26 +784,25 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await mobile.page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: "上传附件" }).click();
await expect(mobile.page.getByText("付款截图.png")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
- await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: /查看补充附件/ }).click();
+ const openAllButton = mobile.page.getByTestId("mobile-reimbursement-materials-open-all");
+ const openAllTarget = await openAllButton.boundingBox();
+ expect(openAllTarget?.height ?? 0).toBeGreaterThanOrEqual(44);
+ await openAllButton.click();
await expect.poll(() => mobile.page.evaluate(() => (
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
- ).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(2);
+ ).__r111BridgeCalls.filter((call) => call.name === "imagePreview").length)).toBe(1);
const nativePreviewCalls = await mobile.page.evaluate(() => (
window as typeof window & { __r111BridgeCalls: Array<{ name: string; params: Record }> }
- ).__r111BridgeCalls.filter((call) => call.name === "previewFile"));
+ ).__r111BridgeCalls.filter((call) => call.name === "imagePreview"));
expect(nativePreviewCalls).toEqual([
expect.objectContaining({
params: expect.objectContaining({
- url: expect.stringContaining(`/api/invoice-pool/${pendingInvoiceId}/file?token=`),
- name: "北京悦海堂发票.pdf",
- size: 4096,
- }),
- }),
- expect.objectContaining({
- params: expect.objectContaining({
- url: expect.stringContaining(`/attachments/${attachmentId}/file?previewToken=`),
- name: "付款截图.png",
- size: 2048,
+ current: expect.stringContaining(`/materials/preview/`),
+ urls: expect.arrayContaining([
+ expect.stringContaining("previewToken=invoice-0"),
+ expect.stringContaining("previewToken=invoice-1"),
+ expect.stringContaining("previewToken=attachment-0"),
+ ]),
}),
}),
]);
diff --git a/deploy/trialrun/Dockerfile b/deploy/trialrun/Dockerfile
index 62521f8d..8e017450 100644
--- a/deploy/trialrun/Dockerfile
+++ b/deploy/trialrun/Dockerfile
@@ -7,7 +7,7 @@ ENV VITE_BUILD_SHA=$VITE_BUILD_SHA
WORKDIR /app
-RUN apk add --no-cache bash curl
+RUN apk add --no-cache bash curl poppler-utils
FROM base AS client_deps
COPY client/package.json client/package-lock.json ./client/
diff --git a/server/src/routes/invoicePool/read.js b/server/src/routes/invoicePool/read.js
index 9d6efd68..109b3e49 100644
--- a/server/src/routes/invoicePool/read.js
+++ b/server/src/routes/invoicePool/read.js
@@ -8,6 +8,7 @@ const { buildAttachmentContentDisposition } = require("../../utils/contentDispos
const { openReadStream, StorageNotFoundError } = require("../../services/storage");
const { toMoneyString } = require("../../utils/workflow");
const { resolveInvoiceAvailability } = require("../../services/invoiceGovernance");
+const { mapInvoiceDocumentMeta } = require("../../services/invoiceDocumentMeta");
const {
mapInvoiceSummary,
normalizeInvoiceResponse,
@@ -201,11 +202,13 @@ function registerInvoicePoolReadRoutes(fastify) {
invoice_number: true,
invoice_date: true,
seller_name: true,
+ buyer_name: true,
total_amount: true,
original_name: true,
ocr_attempts: true,
ocr_last_error: true,
ocr_completed_at: true,
+ ocr_result: { select: { result_json: true } },
updated_at: true,
linked_reimbursement: {
select: {
@@ -221,6 +224,7 @@ function registerInvoicePoolReadRoutes(fastify) {
items: readableRows.map((invoice) => {
const statusReason = resolveInvoiceStatusReason(invoice);
return {
+ ...mapInvoiceDocumentMeta(invoice),
id: invoice.id,
status: invoice.status,
reason: statusReason.reason,
@@ -231,6 +235,7 @@ function registerInvoicePoolReadRoutes(fastify) {
invoiceNumber: invoice.invoice_number ?? null,
invoiceDate: invoice.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
sellerName: invoice.seller_name ?? null,
+ buyerName: invoice.buyer_name ?? null,
totalAmount: toMoneyString(invoice.total_amount),
originalName: invoice.original_name ?? null,
linkedReimbursementId: invoice.linked_reimbursement_id ?? null,
@@ -319,19 +324,23 @@ function registerInvoicePoolReadRoutes(fastify) {
invoice_number: true,
invoice_date: true,
seller_name: true,
+ buyer_name: true,
total_amount: true,
original_name: true,
created_at: true,
+ ocr_result: { select: { result_json: true } },
},
});
return sendData(reply, request, {
items: items.map((item) => ({
+ ...mapInvoiceDocumentMeta(item),
id: item.id,
invoiceCode: item.invoice_code ?? null,
invoiceNumber: item.invoice_number ?? null,
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,
createdAt: item.created_at?.toISOString?.() ?? null,
diff --git a/server/src/routes/invoicePool/shared/mappers.js b/server/src/routes/invoicePool/shared/mappers.js
index 86e04548..0964fe25 100644
--- a/server/src/routes/invoicePool/shared/mappers.js
+++ b/server/src/routes/invoicePool/shared/mappers.js
@@ -1,5 +1,6 @@
const { toMoneyString } = require("../../../utils/workflow");
const { resolveInvoiceAvailability } = require("../../../services/invoiceGovernance");
+const { mapInvoiceDocumentMeta } = require("../../../services/invoiceDocumentMeta");
function mapVerificationRecord(item) {
if (!item) {
@@ -30,6 +31,7 @@ function mapInvoiceSummary(item) {
const availability = resolveInvoiceAvailability(item);
return {
+ ...mapInvoiceDocumentMeta(item),
id: item.id,
status: item.status,
availabilityReason: availability.reason,
@@ -78,6 +80,7 @@ function normalizeInvoiceResponse(invoice) {
const availability = resolveInvoiceAvailability(invoice);
return {
+ ...mapInvoiceDocumentMeta(invoice),
id: invoice.id,
status: invoice.status,
availabilityReason: availability.reason,
diff --git a/server/src/routes/invoicePool/shared/selects.js b/server/src/routes/invoicePool/shared/selects.js
index 2100b4ff..629522db 100644
--- a/server/src/routes/invoicePool/shared/selects.js
+++ b/server/src/routes/invoicePool/shared/selects.js
@@ -49,6 +49,7 @@ function buildInvoiceSummarySelect() {
created_at: true,
updated_at: true,
verification_records: buildLatestVerificationSelect(),
+ ocr_result: { select: { result_json: true } },
owner_employee: {
select: { id: true, employee_no: true, name: true },
},
@@ -95,6 +96,7 @@ function buildInvoiceDetailSelect(options = {}) {
created_at: true,
updated_at: true,
verification_records: buildLatestVerificationSelect(),
+ ocr_result: { select: { result_json: true } },
};
if (options.includeLinkedReimbursement) {
diff --git a/server/src/routes/reimbursements.js b/server/src/routes/reimbursements.js
index d56b0999..7ec3773b 100644
--- a/server/src/routes/reimbursements.js
+++ b/server/src/routes/reimbursements.js
@@ -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",
{
diff --git a/server/src/services/invoiceDocumentMeta.js b/server/src/services/invoiceDocumentMeta.js
new file mode 100644
index 00000000..29689202
--- /dev/null
+++ b/server/src/services/invoiceDocumentMeta.js
@@ -0,0 +1,22 @@
+const SAFE_DOCUMENT_CATEGORIES = new Set([
+ "vat_invoice",
+ "full_vat_invoice",
+ "electronic_train_ticket",
+]);
+
+function normalizeSafeMetaString(value, maxLength = 64) {
+ if (typeof value !== "string") return null;
+ const normalized = value.trim();
+ return normalized ? normalized.slice(0, maxLength) : null;
+}
+
+function mapInvoiceDocumentMeta(invoice) {
+ const result = invoice?.ocr_result?.result_json;
+ const category = normalizeSafeMetaString(result?.documentCategory);
+ return {
+ documentCategory: SAFE_DOCUMENT_CATEGORIES.has(category) ? category : null,
+ documentSubtype: normalizeSafeMetaString(result?.documentSubtype),
+ };
+}
+
+module.exports = { mapInvoiceDocumentMeta };
diff --git a/server/src/services/reimbursementMaterialPreview.js b/server/src/services/reimbursementMaterialPreview.js
new file mode 100644
index 00000000..b517a96b
--- /dev/null
+++ b/server/src/services/reimbursementMaterialPreview.js
@@ -0,0 +1,210 @@
+const crypto = require("node:crypto");
+const fs = require("node:fs/promises");
+const os = require("node:os");
+const path = require("node:path");
+const { promisify } = require("node:util");
+const { execFile } = require("node:child_process");
+const sharp = require("sharp");
+
+const execFileAsync = promisify(execFile);
+
+const MATERIAL_PREVIEW_LIMITS = Object.freeze({
+ maxMaterials: 20,
+ maxSourceBytes: 50 * 1024 * 1024,
+ maxPages: 40,
+ maxPageWidth: 1800,
+ maxPagePixels: 40_000_000,
+ maxOutputBytes: 40 * 1024 * 1024,
+ maxCacheBytes: 80 * 1024 * 1024,
+ rasterDpi: 144,
+ rasterTimeoutMs: 20_000,
+});
+
+class MaterialPreviewError extends Error {
+ constructor(code) {
+ super(code);
+ this.name = "MaterialPreviewError";
+ this.code = code;
+ }
+}
+
+function pageNumberFromName(fileName) {
+ const matched = fileName.match(/-(\d+)\.png$/i);
+ return matched ? Number(matched[1]) : Number.MAX_SAFE_INTEGER;
+}
+
+async function normalizePreviewImage(buffer, limits = MATERIAL_PREVIEW_LIMITS) {
+ try {
+ return await sharp(buffer, { failOn: "error", limitInputPixels: limits.maxPagePixels })
+ .rotate()
+ .resize({ width: limits.maxPageWidth, withoutEnlargement: true })
+ .png({ compressionLevel: 8, adaptiveFiltering: true })
+ .toBuffer();
+ } catch {
+ throw new MaterialPreviewError("material_preview_image_invalid");
+ }
+}
+
+async function rasterizePdfWithPoppler(buffer, limits = MATERIAL_PREVIEW_LIMITS) {
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-material-preview-"));
+ const sourcePath = path.join(tempDir, "source.pdf");
+ const outputPrefix = path.join(tempDir, "page");
+
+ try {
+ await fs.writeFile(sourcePath, buffer, { flag: "wx" });
+ try {
+ await execFileAsync(
+ "pdftoppm",
+ [
+ "-png",
+ "-r",
+ String(limits.rasterDpi),
+ "-f",
+ "1",
+ "-l",
+ String(limits.maxPages + 1),
+ "-scale-to-x",
+ String(limits.maxPageWidth),
+ "-scale-to-y",
+ "-1",
+ sourcePath,
+ outputPrefix,
+ ],
+ {
+ timeout: limits.rasterTimeoutMs,
+ maxBuffer: 1024 * 1024,
+ windowsHide: true,
+ },
+ );
+ } catch (error) {
+ if (error?.code === "ENOENT") {
+ throw new MaterialPreviewError("material_preview_rasterizer_unavailable");
+ }
+ if (error?.killed || error?.signal) {
+ throw new MaterialPreviewError("material_preview_raster_timeout");
+ }
+ throw new MaterialPreviewError("material_preview_pdf_invalid");
+ }
+
+ const fileNames = (await fs.readdir(tempDir))
+ .filter((fileName) => /^page-\d+\.png$/i.test(fileName))
+ .sort((left, right) => pageNumberFromName(left) - pageNumberFromName(right));
+ if (!fileNames.length) {
+ throw new MaterialPreviewError("material_preview_pdf_empty");
+ }
+
+ const pages = [];
+ for (const fileName of fileNames) {
+ pages.push(await normalizePreviewImage(await fs.readFile(path.join(tempDir, fileName)), limits));
+ }
+ return pages;
+ } finally {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ }
+}
+
+function createReimbursementMaterialPreviewStore(options = {}) {
+ const limits = { ...MATERIAL_PREVIEW_LIMITS, ...(options.limits ?? {}) };
+ const rasterizePdf = options.rasterizePdf ?? ((buffer) => rasterizePdfWithPoppler(buffer, limits));
+ const normalizeImage = options.normalizeImage ?? ((buffer) => normalizePreviewImage(buffer, limits));
+ const now = options.now ?? (() => Date.now());
+ const randomUUID = options.randomUUID ?? (() => crypto.randomUUID());
+ const bundles = new Map();
+ let cachedBytes = 0;
+
+ function deleteBundle(bundleId) {
+ const bundle = bundles.get(bundleId);
+ if (!bundle) return;
+ cachedBytes -= bundle.outputBytes;
+ bundles.delete(bundleId);
+ }
+
+ function pruneExpired() {
+ const current = now();
+ for (const [bundleId, bundle] of bundles) {
+ if (bundle.expiresAt <= current) deleteBundle(bundleId);
+ }
+ }
+
+ async function createBundle({ actorId, reimbursementId, materials, expiresAt, readSource }) {
+ pruneExpired();
+ if (!Array.isArray(materials) || materials.length === 0) {
+ throw new MaterialPreviewError("material_preview_empty");
+ }
+ if (materials.length > limits.maxMaterials) {
+ throw new MaterialPreviewError("material_preview_material_limit");
+ }
+ const sourceBytes = materials.reduce((sum, material) => sum + Number(material.fileSize ?? 0), 0);
+ if (!Number.isSafeInteger(sourceBytes) || sourceBytes <= 0 || sourceBytes > limits.maxSourceBytes) {
+ throw new MaterialPreviewError("material_preview_source_size_limit");
+ }
+
+ const pages = [];
+ let outputBytes = 0;
+ let actualSourceBytes = 0;
+ for (const material of materials) {
+ const source = await readSource(material);
+ if (!Buffer.isBuffer(source) || source.length === 0) {
+ throw new MaterialPreviewError("material_preview_source_missing");
+ }
+ actualSourceBytes += source.length;
+ if (actualSourceBytes > limits.maxSourceBytes) {
+ throw new MaterialPreviewError("material_preview_source_size_limit");
+ }
+ const renderedPages = material.mimeType === "application/pdf"
+ ? await rasterizePdf(source)
+ : [await normalizeImage(source)];
+ for (let index = 0; index < renderedPages.length; index += 1) {
+ if (pages.length >= limits.maxPages) {
+ throw new MaterialPreviewError("material_preview_page_limit");
+ }
+ const image = renderedPages[index];
+ outputBytes += image.length;
+ if (outputBytes > limits.maxOutputBytes) {
+ throw new MaterialPreviewError("material_preview_output_size_limit");
+ }
+ pages.push({
+ buffer: image,
+ materialId: material.id,
+ materialType: material.materialType,
+ materialIndex: material.materialIndex,
+ pageNumber: index + 1,
+ });
+ }
+ }
+
+ if (cachedBytes + outputBytes > limits.maxCacheBytes) {
+ pruneExpired();
+ if (cachedBytes + outputBytes > limits.maxCacheBytes) {
+ throw new MaterialPreviewError("material_preview_cache_limit");
+ }
+ }
+
+ const bundleId = randomUUID();
+ const bundle = { actorId, reimbursementId, expiresAt, outputBytes, pages };
+ bundles.set(bundleId, bundle);
+ cachedBytes += outputBytes;
+ const timer = setTimeout(() => deleteBundle(bundleId), Math.max(1, expiresAt - now()));
+ timer.unref?.();
+ return { bundleId, bundle };
+ }
+
+ function getPage({ bundleId, pageIndex }) {
+ pruneExpired();
+ const bundle = bundles.get(bundleId);
+ if (!bundle || !Number.isInteger(pageIndex) || pageIndex < 0 || pageIndex >= bundle.pages.length) {
+ return null;
+ }
+ return { bundle, page: bundle.pages[pageIndex] };
+ }
+
+ return { createBundle, getPage, deleteBundle };
+}
+
+module.exports = {
+ MATERIAL_PREVIEW_LIMITS,
+ MaterialPreviewError,
+ createReimbursementMaterialPreviewStore,
+ normalizePreviewImage,
+ rasterizePdfWithPoppler,
+};
diff --git a/server/tests/unit/routes/reimbursements.attachments.test.js b/server/tests/unit/routes/reimbursements.attachments.test.js
index 207179f0..ef4573f0 100644
--- a/server/tests/unit/routes/reimbursements.attachments.test.js
+++ b/server/tests/unit/routes/reimbursements.attachments.test.js
@@ -44,19 +44,22 @@ function buildMultipartRequest({ files, fields = {} }) {
};
}
-async function buildApp({ reimbursementStatus = "draft" } = {}) {
+async function buildApp({ reimbursementStatus = "draft", materialPreviewStore = null } = {}) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r111-reimbursement-attachments-"));
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const createdAttachments = [];
const prisma = {
reimbursementForm: {
- findUnique: vi.fn().mockResolvedValue({
+ findUnique: vi.fn().mockImplementation(async ({ select }) => ({
id: reimbursementId,
owner_id: ACTOR_ID,
submitter_id: ACTOR_ID,
status: reimbursementStatus,
- }),
+ ...(select?.invoice_pool_items
+ ? { invoice_pool_items: [], attachments: createdAttachments }
+ : {}),
+ })),
},
reimbursementAttachment: {
create: vi.fn().mockImplementation(async ({ data, select }) => {
@@ -141,6 +144,9 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
const app = Fastify();
app.decorate("prisma", prisma);
+ if (materialPreviewStore) {
+ app.decorate("reimbursementMaterialPreviewStore", materialPreviewStore);
+ }
app.decorate("config", {
appTimezone: "Asia/Shanghai",
storageDir: tempDir,
@@ -612,10 +618,18 @@ describe("reimbursement attachment routes", () => {
headers: { authorization: "Bearer test", "content-type": "application/json" },
payload: {},
});
+ const missingBundleReason = await app.inject({
+ method: "POST",
+ url: `/api/reimbursements/${reimbursementId}/materials/preview-bundle`,
+ headers: { authorization: "Bearer test", "content-type": "application/json" },
+ payload: {},
+ });
expect(unauthenticated.statusCode).toBe(401);
expect(missingReason.statusCode).toBe(400);
expect(missingReason.json().error.details).toEqual({ reason: "required" });
+ expect(missingBundleReason.statusCode).toBe(400);
+ expect(missingBundleReason.json().error.details).toEqual({ reason: "required" });
expect(prisma.auditLog.create).not.toHaveBeenCalled();
} finally {
await app.close();
@@ -682,4 +696,109 @@ describe("reimbursement attachment routes", () => {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
+
+ it("issues an ordered image bundle and audits each token-bound page access", async () => {
+ const bundleId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
+ const previewBuffer = Buffer.from("synthetic-png-page");
+ const materialPreviewStore = {
+ createBundle: vi.fn().mockImplementation(async ({ actorId, reimbursementId, materials, expiresAt }) => ({
+ bundleId,
+ bundle: {
+ actorId,
+ reimbursementId,
+ expiresAt,
+ pages: [{
+ buffer: previewBuffer,
+ materialId: materials[0].id,
+ materialType: materials[0].materialType,
+ materialIndex: materials[0].materialIndex,
+ pageNumber: 1,
+ }],
+ },
+ })),
+ getPage: vi.fn(),
+ };
+ const { app, prisma, tempDir, reimbursementId } = await buildApp({ materialPreviewStore });
+
+ try {
+ const requestPayload = buildMultipartRequest({
+ files: [{ filename: "payment.png", contentType: "image/png", content: "png-content" }],
+ });
+ await app.inject({
+ method: "POST",
+ url: `/api/reimbursements/${reimbursementId}/attachments`,
+ headers: { authorization: "Bearer test", ...requestPayload.headers },
+ payload: requestPayload.payload,
+ });
+
+ const issued = await app.inject({
+ method: "POST",
+ url: `/api/reimbursements/${reimbursementId}/materials/preview-bundle`,
+ headers: { authorization: "Bearer test", "content-type": "application/json" },
+ payload: {},
+ });
+ expect(issued.statusCode).toBe(200);
+ expect(issued.json().data.pages).toEqual([
+ expect.objectContaining({ materialType: "attachment", materialIndex: 1, pageNumber: 1 }),
+ ]);
+ const pageUrl = new URL(issued.json().data.pages[0].url);
+ const verified = verifyToken(pageUrl.searchParams.get("previewToken"), JWT_SECRET);
+ expect(verified).toEqual(expect.objectContaining({
+ ok: true,
+ payload: expect.objectContaining({
+ purpose: "reimbursement_material_preview",
+ reimbursementId,
+ bundleId,
+ pageIndex: 0,
+ actorId: ACTOR_ID,
+ }),
+ }));
+
+ const issuedToken = pageUrl.searchParams.get("previewToken");
+ const tamperedToken = `${issuedToken.slice(0, -1)}${issuedToken.endsWith("a") ? "b" : "a"}`;
+ const tamperedResponse = await app.inject({
+ method: "GET",
+ url: `${pageUrl.pathname}?previewToken=${encodeURIComponent(tamperedToken)}`,
+ });
+ expect(tamperedResponse.statusCode).toBe(403);
+ expect(materialPreviewStore.getPage).not.toHaveBeenCalled();
+
+ const missingBundleResponse = await app.inject({
+ method: "GET",
+ url: `${pageUrl.pathname}${pageUrl.search}`,
+ });
+ expect(missingBundleResponse.statusCode).toBe(403);
+
+ materialPreviewStore.getPage.mockReturnValue({
+ bundle: {
+ actorId: ACTOR_ID,
+ reimbursementId,
+ expiresAt: verified.payload.exp,
+ },
+ page: {
+ buffer: previewBuffer,
+ materialId: "bbbbbbbb-bbbb-4bbb-8bbb-000000000001",
+ materialType: "attachment",
+ materialIndex: 1,
+ pageNumber: 1,
+ },
+ });
+ const pageResponse = await app.inject({ method: "GET", url: `${pageUrl.pathname}${pageUrl.search}` });
+ expect(pageResponse.statusCode).toBe(200);
+ expect(pageResponse.rawPayload).toEqual(previewBuffer);
+ expect(pageResponse.headers["content-type"]).toContain("image/png");
+ expect(pageResponse.headers["cache-control"]).toBe("private, no-store");
+ expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
+ data: expect.objectContaining({ action: "REIMBURSEMENT_MATERIAL_PREVIEWED", actor_id: ACTOR_ID }),
+ }));
+
+ prisma.auditLog.create.mockRejectedValueOnce(new Error("synthetic audit outage"));
+ const auditFailureResponse = await app.inject({ method: "GET", url: `${pageUrl.pathname}${pageUrl.search}` });
+ expect(auditFailureResponse.statusCode).toBe(500);
+ expect(auditFailureResponse.body).not.toContain(previewBuffer.toString());
+ } finally {
+ await app.close();
+ await fs.rm(tempDir, { recursive: true, force: true });
+ }
+ });
});
diff --git a/server/tests/unit/services/invoiceDocumentMeta.test.js b/server/tests/unit/services/invoiceDocumentMeta.test.js
new file mode 100644
index 00000000..dd6a17aa
--- /dev/null
+++ b/server/tests/unit/services/invoiceDocumentMeta.test.js
@@ -0,0 +1,25 @@
+const { mapInvoiceDocumentMeta } = require("../../../src/services/invoiceDocumentMeta");
+
+describe("invoice document metadata", () => {
+ it("exposes only supported document categories and a bounded subtype", () => {
+ expect(mapInvoiceDocumentMeta({
+ ocr_result: {
+ result_json: {
+ documentCategory: " electronic_train_ticket ",
+ documentSubtype: `ElectronicTrainTicketFull${"x".repeat(80)}`,
+ raw: { sensitive: true },
+ },
+ },
+ })).toEqual({
+ documentCategory: "electronic_train_ticket",
+ documentSubtype: `ElectronicTrainTicketFull${"x".repeat(39)}`,
+ });
+ });
+
+ it("fails closed for unknown, blank, or malformed OCR metadata", () => {
+ expect(mapInvoiceDocumentMeta({
+ ocr_result: { result_json: { documentCategory: "unknown_invoice", documentSubtype: " " } },
+ })).toEqual({ documentCategory: null, documentSubtype: null });
+ expect(mapInvoiceDocumentMeta(null)).toEqual({ documentCategory: null, documentSubtype: null });
+ });
+});
diff --git a/server/tests/unit/services/reimbursementMaterialPreview.test.js b/server/tests/unit/services/reimbursementMaterialPreview.test.js
new file mode 100644
index 00000000..11cbe991
--- /dev/null
+++ b/server/tests/unit/services/reimbursementMaterialPreview.test.js
@@ -0,0 +1,65 @@
+const {
+ MaterialPreviewError,
+ createReimbursementMaterialPreviewStore,
+} = require("../../../src/services/reimbursementMaterialPreview");
+
+describe("reimbursement material preview store", () => {
+ it("keeps material and PDF page order in a bounded short-lived bundle", async () => {
+ let currentTime = 1_000;
+ const store = createReimbursementMaterialPreviewStore({
+ now: () => currentTime,
+ randomUUID: () => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ rasterizePdf: vi.fn().mockResolvedValue([Buffer.from("pdf-page-1"), Buffer.from("pdf-page-2")]),
+ normalizeImage: vi.fn().mockResolvedValue(Buffer.from("image-page")),
+ });
+
+ const created = await store.createBundle({
+ actorId: "actor-1",
+ reimbursementId: "reimbursement-1",
+ expiresAt: 2_000,
+ materials: [
+ { id: "invoice-1", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 10 },
+ { id: "attachment-1", materialType: "attachment", materialIndex: 1, mimeType: "image/png", fileSize: 10 },
+ ],
+ readSource: async (material) => Buffer.from(material.id),
+ });
+
+ expect(created.bundle.pages.map((page) => [page.materialType, page.materialIndex, page.pageNumber])).toEqual([
+ ["invoice", 1, 1],
+ ["invoice", 1, 2],
+ ["attachment", 1, 1],
+ ]);
+ expect(store.getPage({ bundleId: created.bundleId, pageIndex: 1 })?.page.buffer.toString()).toBe("pdf-page-2");
+
+ currentTime = 2_001;
+ expect(store.getPage({ bundleId: created.bundleId, pageIndex: 0 })).toBeNull();
+ });
+
+ it("rejects source and page limits without keeping a partial bundle", async () => {
+ const store = createReimbursementMaterialPreviewStore({
+ limits: { maxMaterials: 1, maxSourceBytes: 10, maxPages: 1 },
+ rasterizePdf: vi.fn().mockResolvedValue([Buffer.from("one"), Buffer.from("two")]),
+ });
+
+ await expect(store.createBundle({
+ actorId: "actor-1",
+ reimbursementId: "reimbursement-1",
+ expiresAt: Date.now() + 1_000,
+ materials: [
+ { id: "one", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 6 },
+ { id: "two", materialType: "attachment", materialIndex: 1, mimeType: "image/png", fileSize: 6 },
+ ],
+ readSource: async () => Buffer.from("source"),
+ })).rejects.toMatchObject({ code: "material_preview_material_limit" });
+
+ await expect(store.createBundle({
+ actorId: "actor-1",
+ reimbursementId: "reimbursement-1",
+ expiresAt: Date.now() + 1_000,
+ materials: [
+ { id: "one", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 6 },
+ ],
+ readSource: async () => Buffer.from("source"),
+ })).rejects.toBeInstanceOf(MaterialPreviewError);
+ });
+});