实现报销材料连续预览(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-01 00:17:54 +08:00
父节点 b6485a278e
当前提交 8a9279efce
共修改 26 个文件,包含 1211 行新增和 178 行删除
@@ -576,96 +576,103 @@ reply.header(
); );
``` ```
## Scenario: Reimbursement Material Presentation And Item Linking ## Scenario: Reimbursement Material Image Bundle And Category-Aware Summary
### 1. Scope / Trigger ### 1. Scope / Trigger
- Trigger: changing PC or mobile reimbursement-detail presentation for invoice - Trigger: changing PC/mobile reimbursement material presentation, invoice OCR
originals, supporting attachments or their relationship to expense items. category labels, PDF rasterization or the continuous-preview grant.
- Purpose: let finance reconcile each item with its invoice without exposing a - Purpose: keep expense items concise while allowing an approver to enter once
separate technical “open file” button for every material. and continuously inspect every invoice and supporting attachment.
### 2. Signatures ### 2. Signatures
- Existing detail payload: `detail.items[]`, `detail.invoices[]` with - Detail payload keeps `invoice.linkedItemId` and adds the safe fields
`invoice.linkedItemId: string | null`, and `detail.attachments[]`. `documentCategory`, `documentSubtype` and `buyerName`; it never exposes the
- Existing preview actions: `openInvoiceMutation` and raw OCR provider response.
`handleOpenAttachment`; WeCom continues through `previewFile` and PC through - Bundle issue: `POST /api/reimbursements/:id/materials/preview-bundle`, optional
its existing browser-safe path. `{ reason }`; response `{ expiresAt, pages: [{ url, materialType,
- Stable UI selectors: `reimbursement-materials` and materialIndex, pageNumber }] }`.
`mobile-reimbursement-materials`; existing invoice/attachment child test IDs - Page fetch: `GET /api/reimbursements/:id/materials/preview/:bundleId/:pageIndex?previewToken=...`.
remain available for focused regressions. - Runtime dependency: production Alpine image contains `poppler-utils`;
`reimbursementMaterialPreview` invokes `pdftoppm` with `execFile`, never a shell.
### 3. Contracts ### 3. Contracts
- Present invoice originals and supporting attachments under one user-facing - Expense-item UI does not render or preview linked invoices. Preserve
`报销材料` section with separate type labels and counts. Do not merge their `linkedItemId` for finance/audit semantics; presentation must not delete or
database types, API fields, authorization, audit or lifecycle rules. rewrite the relationship.
- Within each expense item, render only invoices whose - The read-only detail shows one `报销材料` count and one `查看全部材料` action.
`linkedItemId === item.id`. The linked invoice summary is itself a semantic Editable drafts may retain per-record management rows; read-only users see
preview button and shows an invoice identifier, seller and amount. them only when continuous preview fails and the original-file fallback opens.
- Keep every invoice, including unlinked or stale-linked invoices, in the - Material order is invoices by `created_at,id`, then attachments by
material section. Never infer a link from amount, order or list position. `created_at,id`, then PDF page order. Never infer order from amount, file name
- The material information row is the preview target; do not append a repeated or `linkedItemId`.
visible `查看原件` button to each file. Draft-only add/remove/upload/delete - PDFs are rasterized separately; images are normalized with `sharp`. Do not
actions remain separate buttons so preview cannot mutate data. merge PDFs, strip their signatures, use PDF.js in the WeCom page, or persist
- Do not introduce a file-browser route, carousel, bulk download or aggregate preview images as business attachments.
backend endpoint for the small-team detail page unless later real use proves - The in-memory bundle is bounded to 20 materials, 50 MiB source bytes, 40
that the inline material list is insufficient. pages, 1800px page width, 40 megapixels per page, 40 MiB bundle output and
- Clickable rows have a descriptive `aria-label`, visible focus, disabled state 80 MiB process cache.
during open, a chevron affordance and a minimum 44px mobile touch target. Poppler gets a fixed DPI, timeout and temporary directory; render one page
beyond the limit so an oversized PDF fails instead of being silently cut.
- Tokens bind purpose, reimbursement, actor, bundle, page index and expiry.
Issue requires current object access plus any attachment download reason;
every page access writes audit before bytes and returns `private, no-store`.
- Category labels are `vat_invoice -> 增值税发票`,
`full_vat_invoice -> 数电发票`, and
`electronic_train_ticket -> 电子火车票`. Known no-code categories never show
`无代码`; railway tickets may show `buyerName` but never claim a missing seller.
### 4. Validation & Error Matrix ### 4. Validation & Error Matrix
- Matching `linkedItemId` -> show the invoice inside that item and in the - No materials -> do not issue a bundle; show the existing empty state.
material section. - Unsupported/corrupt input, missing Poppler or raster timeout -> stable
- `linkedItemId=null` or target item absent -> show only in the material continuous-preview error and expose authorized original-file fallback.
section; do not attach it to the first or amount-matching item. - Material/source/page/output/cache limit -> bounded validation error; never
- No invoices and no attachments -> one `暂无报销材料` empty state. return a partial bundle or silently omit pages.
- Preview pending -> disable repeated opens; existing preview errors and - Missing required reason -> `400`; invalid/expired/tampered/object-mismatched
download-reason behavior remain authoritative. token, absent cache bundle or process restart -> `403` with no bytes.
- Read-only approver -> preview rows visible, mutation actions absent. - Bundle-issue audit failure deletes the bundle; page-access audit failure ->
`5xx` with no image body.
- Unknown or malformed OCR category -> neutral `发票` label; never guess a
category or rerun production OCR merely to fix display text.
### 5. Good/Base/Bad Cases ### 5. Good/Base/Bad Cases
- Good: a hotel item shows its linked hotel invoice inline, while an unlinked - Good: three invoice PDFs and three PNG attachments produce one ordered image
payment screenshot remains under `报销材料` and both originals are previewable. URL array; WeCom swipes natively and PC loads the same bounded pages.
- Base: one item, one linked invoice and no attachment renders one inline - Base: one single-page PDF becomes one PNG preview while its original remains
invoice summary plus one typed material row. unchanged and separately auditable.
- Bad: flatten invoices away from items, guess links by equal amount, merge all - Bad: concatenate PDFs, expose OCR raw data, put invoice buttons back into
file records into one backend type, or add a `查看原件` button beside every every expense item, or accept the first 40 pages of a 41-page file silently.
row.
### 6. Tests Required ### 6. Tests Required
- Focused PC and WeCom Playwright fixtures contain at least one linked and one - Service tests assert material/page order, expiry, source/page/output/cache
unlinked invoice plus one supporting attachment. limits and absence of partial bundles.
- Assert the linked invoice appears only in the matching item; the unlinked - Route tests assert authorization, reason gate, token binding, cache miss,
invoice appears in materials but not in any item. issue/access audit, audit-failure fail-closed behavior and PNG response.
- Assert material counts, absence of exact visible `查看原件` buttons, PC open - A target-image probe must generate synthetic PDF bytes and prove the built
URL behavior, WeCom `previewFile` name/size/URL, 44px touch target and mobile Alpine container returns PNG through the production rasterizer.
portrait/landscape overflow. - PC/WeCom Playwright asserts no invoice entry inside expense items, correct
- Preserve editable add/remove/upload/delete regression coverage, then run category text, no `无代码/未识别销售方`, ordered `previewImage.urls`, fallback,
frontend typecheck/build and Trellis governance gates. editable actions and responsive layout. Run full server tests, Prisma
validation, frontend typecheck/build and Trellis governance gates.
### 7. Wrong vs Correct ### 7. Wrong vs Correct
#### Wrong #### Wrong
```tsx ```tsx
detail.invoices.map((invoice) => <Button>查看原件</Button>); const bundlePdf = mergePdfFiles(detail.invoices); // changes originals and adds a second document pipeline
``` ```
#### Correct #### Correct
```tsx ```tsx
detail.invoices const bundle = await fetchReimbursementMaterialPreviewBundle(reimbursementId);
.filter((invoice) => invoice.linkedItemId === item.id) await previewImagesInWecom(bundle.pages.map((page) => page.url));
.map((invoice) => (
<button aria-label={`查看关联发票:${formatInvoiceDisplayTitle(invoice)}`}>
{formatInvoiceDisplayTitle(invoice)}
</button>
));
``` ```
## Scenario: Payment Batch Receipt Attention Count ## Scenario: Payment Batch Receipt Attention Count
+25
查看文件
@@ -62,8 +62,13 @@ or administration workflow has already migrated.
- Reimbursement attachment grant: - Reimbursement attachment grant:
`POST /api/reimbursements/:id/attachments/:attachmentId/preview-url`, optional `POST /api/reimbursements/:id/attachments/:attachmentId/preview-url`, optional
body `{ reason }`; response uses the same file metadata shape. body `{ reason }`; response uses the same file metadata shape.
- Reimbursement material bundle:
`POST /api/reimbursements/:id/materials/preview-bundle`, optional body
`{ reason }`; response contains ordered short-lived PNG URLs.
- Native call: `previewFile({ url, name, size })`; `name` includes an extension - Native call: `previewFile({ url, name, size })`; `name` includes an extension
and `size` is the persisted positive byte count. and `size` is the persisted positive byte count.
- Multi-image native call: `previewImage({ current, urls })`; `current` is one
member of the non-empty ordered `urls` array.
### 3. Contracts ### 3. Contracts
@@ -81,6 +86,14 @@ or administration workflow has already migrated.
- PC keeps its browser/authenticated download behavior. Only a detected WeCom - PC keeps its browser/authenticated download behavior. Only a detected WeCom
WebView dynamically loads `@wecom/jssdk` and calls `previewFile`; do not send WebView dynamically loads `@wecom/jssdk` and calls `previewFile`; do not send
the actual file through a WeCom message or native approval payload. the actual file through a WeCom message or native approval payload.
- `previewImage` is the only native continuous-preview path for mixed original
PDF/image reimbursement materials: OA first rasterizes each PDF separately
into short-lived images. `previewFile` accepts one file only; never pass an
invented array to it or merge originals merely to simulate navigation.
- JS-SDK is allowed only on authenticated business pages that need a native
capability and is dynamically imported behind WeCom detection. The hosted PC
login/password/email-code page remains script-free as required by the
`Hosted PC WeCom Web Login` scenario below.
### 4. Validation & Error Matrix ### 4. Validation & Error Matrix
@@ -93,6 +106,9 @@ or administration workflow has already migrated.
- Missing required download reason -> `400 { reason: "required" }`. - Missing required download reason -> `400 { reason: "required" }`.
- Expired, tampered, wrong-purpose or object-mismatched file grant -> `403`. - Expired, tampered, wrong-purpose or object-mismatched file grant -> `403`.
- Access audit failure -> `5xx` with no file body. - Access audit failure -> `5xx` with no file body.
- Empty or inconsistent `previewImage` URL arrays -> frontend stops before the
bridge call. Bundle expiry/cache loss -> safe fallback to individually
authorized originals; do not broaden token scope or extend it silently.
### 5. Good/Base/Bad Cases ### 5. Good/Base/Bad Cases
@@ -100,6 +116,8 @@ or administration workflow has already migrated.
`previewFile`, sees the original in WeCom and receives no edit capability. `previewFile`, sees the original in WeCom and receives no edit capability.
- Base: a PC user opens the same invoice through the browser and downloads an - Base: a PC user opens the same invoice through the browser and downloads an
attachment through the existing authenticated Blob path. attachment through the existing authenticated Blob path.
- Base: a WeCom approver requests one authorized reimbursement image bundle,
then `previewImage` opens the ordered PNG URLs with native swiping.
- Bad: expose a long-lived generic `?token=`, trust a caller-supplied size/name, - Bad: expose a long-lived generic `?token=`, trust a caller-supplied size/name,
put a provider ticket in the response, or use async `window.open(blob:)` in put a provider ticket in the response, or use async `window.open(blob:)` in
WeCom and assume desktop popup behavior applies. WeCom and assume desktop popup behavior applies.
@@ -114,6 +132,9 @@ or administration workflow has already migrated.
- Playwright injects a synthetic `WeixinJSBridge`, asserts `previewFile` receives - Playwright injects a synthetic `WeixinJSBridge`, asserts `previewFile` receives
the exact URL/name/size for invoice and attachment, and asserts no the exact URL/name/size for invoice and attachment, and asserts no
`window.open` call in WeCom. PC assertions keep browser URL and Blob behavior. `window.open` call in WeCom. PC assertions keep browser URL and Blob behavior.
- Continuous-preview Playwright asserts one `previewImage` call, the exact
ordered URL array and no JS-SDK load in a normal PC browser. Backend tests
separately prove the page URLs remain object/actor/bundle/index bound.
- Production deployment and real-device preview remain separate approvals. - Production deployment and real-device preview remain separate approvals.
### 7. Wrong vs Correct ### 7. Wrong vs Correct
@@ -131,6 +152,10 @@ window.open(URL.createObjectURL(blob)); // WeCom may block after await
authenticated grant issue -> <=120s object-bound URL authenticated grant issue -> <=120s object-bound URL
-> ww.previewFile({ url, nameWithExtension, exactSize }) -> ww.previewFile({ url, nameWithExtension, exactSize })
-> audit before file bytes -> audit before file bytes
authenticated material-bundle issue -> ordered <=120s PNG URLs
-> ww.previewImage({ current: urls[0], urls })
-> audit each page before image bytes
``` ```
## Scenario: WeCom Member Data Is A Read-Only Source For OA ## Scenario: WeCom Member Data Is A Read-Only Source For OA
@@ -205,9 +205,10 @@
## 12. 报销明细与材料的 KISS 对齐 ## 12. 报销明细与材料的 KISS 对齐
- 交互采用“展示统一、语义保留”:用户界面把发票原件和补充附件统一归入“报销材料”,但 API、数据库类型、授权、审计和生命周期保持不变;付款后材料不在本轮提前接入。 - 交互采用“一个入口、一次通览、语义保留”:费用明细只展示费用事实,不再渲染关联发票;`linkedItemId`、票据/附件类型、对象权限、审计和生命周期均不改变。付款回单仍属于付款阶段材料,不提前混入本次提交材料。
- 财务核对以费用明细为主。两端页面按 `invoice.linkedItemId === item.id` 将发票摘要渲染在对应明细内,摘要至少包含发票标识、销售方和金额;摘要整行是明确的键盘/触控预览入口,并复用现有 `openInvoiceMutation`。 - 报销材料只读态首屏显示“发票 N · 补充附件 M”和一个明确的“查看全部材料”按钮,不为每份文件重复暴露技术操作。草稿/驳回编辑态仍保留发票添加/移除和附件上传/删除所需列表,数据变更动作与只读通览分离。
- 未关联或关联目标已不存在的发票只出现在“报销材料”区,不猜测关联关系。材料区显示“发票 N · 补充附件 M”,并继续列出全部材料,方便主管快速通览以及处理未关联材料。 - 官方 `previewFile` 只有单文件参数;混合 PDF/图片不能直接连续切换。服务端新增报销级图片预览 grant:授权后读取当前绑定的发票 PDF 与补充附件,PDF 通过受控 Poppler `pdftoppm` 子进程逐页栅格化,JPG/PNG/WebP 通过现有 `sharp` 规范化为浏览器/企微兼容图片,生成短时图片预览包。企微调用一次 `previewImage({ current, urls })` 原生左右滑动,PC/普通浏览器在同一材料浏览页按顺序连续显示。
- 材料列表以带类型标签的文件信息行作为预览入口,不再给每份材料追加“查看原件”按钮。草稿编辑态的添加发票、移除发票、上传附件和删除附件仍作为独立显式动作,避免把预览与数据变更混在同一点击目标。 - 预览顺序固定为发票创建顺序、补充附件创建顺序、PDF 内页顺序;不根据 `linkedItemId`、金额或文件名猜测排序。图片预览包不进入业务表、不形成长期附件,只在单进程有界内存缓存中保存至 grant 过期,token 绑定 purpose、reimbursement、actor、随机 bundle key、图片索引和过期时间。
- 不新增材料浏览页、底部抽屉、上一份/下一份状态、批量下载、缩略图瀑布流或新的后端聚合接口。PC 与移动端保持相同信息结构,但沿用各自已有桌面表格和移动卡片组件。 - grant 签发前复用报销对象级读权限及附件治理/理由门禁;生成时限制材料数量、源文件总字节、PDF 总页数、单页像素和输出字节,Poppler 使用无 shell 的固定参数、超时和临时目录。每个图片 GET 验签、核对缓存 bundle 与 actor 后先写审计再发送。缓存缺失、进程重启、材料不支持/损坏、超限或栅格化失败时返回稳定错误,客户端展开现有逐份原件预览作为降级,不静默漏页。
- 可点击信息行必须有可见焦点、自然中文 `aria-label`,移动端触控目标不小于 44px;加载中禁用重复打开。企微仍走 `previewFile`,PC 仍走当前安全预览路径。 - 后端从已脱敏持久化 OCR `result_json` 只映射 `documentCategory` / `documentSubtype` 到报销详情,不下发原始腾讯响应。前端共享格式化函数按 `vat_invoice`、`full_vat_invoice`、`electronic_train_ticket` 选择票种名称和适用字段;未知票种使用中性“发票 + 号码/文件名”,不显示“无代码”或对不适用字段宣称“未识别”。
- “查看全部材料”和降级行均使用语义化 button、可见焦点、加载反馈和至少 44px 触控目标;保留可预测返回。PC 与移动端共享业务文案、票种格式化逻辑和材料浏览页;不引入 PDF.js,企微优先原生 `previewImage`,PC/普通浏览器在短时 URL 过期前主动加载有界图片包。
@@ -158,7 +158,18 @@
- [x] 发布脚本的来源、环境合同、init、migration、`/ready`、external/runtime smoke 和公网资源扫描通过;公网构建标识为 `0e44aa72f3f5`,活动资源包含 PC/移动统一材料区标识。 - [x] 发布脚本的来源、环境合同、init、migration、`/ready`、external/runtime smoke 和公网资源扫描通过;公网构建标识为 `0e44aa72f3f5`,活动资源包含 PC/移动统一材料区标识。
- [x] 复用现有 unit 参数和运行时间刷新 automatic backup 三项与 WARCH3;4 个 service 构建身份一致,4 个 timer 均 enabled/active。 - [x] 复用现有 unit 参数和运行时间刷新 automatic backup 三项与 WARCH3;4 个 service 构建身份一致,4 个 timer 均 enabled/active。
- [x] 手工触发 automatic backup daily 和源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次或付款写入。 - [x] 手工触发 automatic backup daily 和源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次或付款写入。
- [ ] 请用户在企微刷新后验证:费用明细内可直接点关联发票,页面只有一个“报销材料”区,发票与补充附件整行可点且不再重复显示“查看原件”按钮。 - [x] 用户验收后修正方向:不需要在费用明细内查看关联发票;现有逐份材料行仍然繁琐,需要一次进入连续查看全部材料。该反馈覆盖 6.9 的内联发票展示合同,已部署事实保留但不再作为目标体验。
## 6.11 票种摘要与整单材料预览
- [x] 只读核查生产真实报销:绑定票据 3 张,均为 `bound`、腾讯 OCR 已实际执行且无存储错误;号码/日期/金额完整。票种为 2 张电子火车票和 1 张数电发票,传统代码均不适用,火车票销售方字段不适用;未输出号码、金额、主体、文件名、UUID 或原始 OCR 结果。
- [x] 核查企微 SDK 合同:`previewFile` 仅接受单个 `url/name/size`;只有图片接口 `previewImage` 接受 `urls[]`。当前真实材料为 3 份 PDF 发票和 3 份 PNG 补充附件,总源文件小于 1 MiB,适合逐份 PDF 栅格化后组成有序图片包验证连续预览。
- [x] 用户批准采用服务端 PDF 栅格化 + 企微 `previewImage`,其余按统一材料入口、票种摘要和原件降级方案实施;不采用合并 PDF 或 PDF.js。
- [x] 删除 PC/移动费用明细中的关联发票 UI 和对应旧断言,保留后端 `linkedItemId`。
- [x] 报销详情 API 增加安全票种分类映射;共享票据格式化按普通增值税、数电发票和电子火车票显示适用字段,不重跑或改写当前真实 OCR 数据。
- [x] 在运行镜像加入固定 Poppler 工具,结合既有 `sharp` 实现有界、短时、对象授权的报销级图片预览 grant;企微一次 `previewImage` 原生滑动,PC/普通浏览器连续浏览,失败展开逐份原件预览降级。
- [x] 更新后端路由/授权/审计/超限/缓存失效测试与 PC/企微 Playwright;运行 Prisma validate、server 聚焦测试与全量基线、client typecheck/build、Trellis/governance/process/docs/sensitive/diff 门禁。
- [x] 稳定合同验收后再反向更新 `reimbursement-invoice-ocr.md` 与 `wecom-boundary.md`,覆盖当前“明细内发票、无聚合接口”的旧规范;提交、推送、生产部署和真实材料查看分别遵守既有批准门禁。
## 7. 完成条件 ## 7. 完成条件
@@ -225,3 +236,10 @@
- 本次发布第一次本地命令因包含临时目录递归清理,被安全护栏在进程启动前拒绝,未生成快照、未连接或改变生产。第二次移除清理动作后,从干净 clone 发布成功;发布后按提交标识和受限临时目录模式删除本轮唯一快照,未保留构建日志。 - 本次发布第一次本地命令因包含临时目录递归清理,被安全护栏在进程启动前拒绝,未生成快照、未连接或改变生产。第二次移除清理动作后,从干净 clone 发布成功;发布后按提交标识和受限临时目录模式删除本轮唯一快照,未保留构建日志。
- 发布后运维对齐:只读检查先发现 automatic backup 三项与 WARCH3 均仍为旧构建身份;复用现有 unit 参数和运行时间刷新后,4 个 service 匹配 `0e44aa7`、4 个 timer enabled/active。新 automatic backup daily 与源端 watchdog 均为 `success/0`。 - 发布后运维对齐:只读检查先发现 automatic backup 三项与 WARCH3 均仍为旧构建身份;复用现有 unit 参数和运行时间刷新后,4 个 service 匹配 `0e44aa7`、4 个 timer enabled/active。新 automatic backup daily 与源端 watchdog 均为 `success/0`。
- 本轮未同步生产 `.env`,未修改权限、审批状态机、企微认证或业务数据,未执行真实审批、财务审核、付款批次、银行回单或付款动作;真人企微 KISS 页面验收仍待用户刷新确认。 - 本轮未同步生产 `.env`,未修改权限、审批状态机、企微认证或业务数据,未执行真实审批、财务审核、付款批次、银行回单或付款动作;真人企微 KISS 页面验收仍待用户刷新确认。
- 整单材料实现:PC/移动费用明细不再渲染关联发票;只读态以一个“查看全部材料”入口生成有序图片包,企微调用一次 `previewImage`,PC 使用共享材料页连续显示;编辑态和失败降级仍保留逐份原件管理/查看能力。
- 票种显示回归:合成数电发票与电子火车票分别显示正确票种;数电发票保留销售方,火车票使用购买方且不再出现“无代码 / 未识别销售方”;未重跑或改写生产 OCR 数据。
- 后端验证:聚焦 5 个测试文件、23 项通过;服务端全量 171 个文件、1109 项通过,Prisma validate 与 touched-file `node --check` 通过。新增断言覆盖顺序、页/源/输出/缓存上限、token 篡改、缓存缺失、下载理由、访问审计失败和安全票种映射。
- 前端验证:生产 build(含 typecheck)通过;R111 PC/企微 2/2 通过,覆盖费用明细无发票入口、票种文案、统一计数、材料页与一次有序 `previewImage.urls` 调用。
- 目标镜像验证:`server_build` 成功安装 `poppler-utils`;Playwright 生成的无业务合成单页 PDF 经容器内生产 `reimbursementMaterialPreview` 转换为 1 页 PNG,探针结果为 `raster-probe-pass`。未合并、改写或使用真实票据。
- 治理复核:task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。首次敏感扫描命令误指向不存在的旧路径并立即失败,改用当前 `scripts/qa/check-sensitive-content.mjs` 后 30 个变更文件、0 finding;该失败未执行生产或业务写入。
- 本步骤只完成本地实现与验证;未推送、未部署、未修改生产配置或真实业务数据,不能据此通知真人线上测试。
@@ -43,7 +43,9 @@
- 真实审批继续暴露材料查看阻断:PC 能打开普通附件,但 PC 与移动报销详情均没有发票原件入口,企微 WebView 中异步 `window.open(blob:)` 只下载附件而不能预览。企业微信自建应用内必须优先使用官方 `ww.previewFile` / `wx.previewFile` 原生能力查看 PDF;普通 PC 浏览器保留浏览器预览,两端保持“查看原件”的业务能力一致。 - 真实审批继续暴露材料查看阻断:PC 能打开普通附件,但 PC 与移动报销详情均没有发票原件入口,企微 WebView 中异步 `window.open(blob:)` 只下载附件而不能预览。企业微信自建应用内必须优先使用官方 `ww.previewFile` / `wx.previewFile` 原生能力查看 PDF;普通 PC 浏览器保留浏览器预览,两端保持“查看原件”的业务能力一致。
- 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。 - 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。
- 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。 - 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
- 生产可见性验收通过后发现报销详情仍把每张发票、每个补充附件都暴露为独立“查看原件”按钮,且费用明细与其 `linkedItemId` 发票分区展示,不利于财务逐条核对。PC 与移动端应统一采用“业务关联优先、材料入口收敛”的展示:每条明细直接显示并可预览其关联发票;全部发票和补充附件仍在单一“报销材料”区按类型汇总,文件信息行本身作为预览入口,不新增独立材料浏览路由、轮播器或后端模型。 - 生产可见性验收进一步确认:费用明细内再次展示并预览关联发票仍然重复,审批人希望一次进入后连续查看本单全部材料。PC 与移动端应统一采用“明细只讲费用、材料一次通览”:费用明细不再渲染发票摘要或原件入口;发票和补充附件仍保留各自后台类型、关联、权限和审计,但只在单一“报销材料”区汇总,并提供一个“查看全部材料”动作。
- 企业微信官方 `previewFile({ url, name, size })` 一次只接收一个文件,不具备 `previewImage({ urls })` 的多文件数组和左右切换合同。为满足企微内一次进入后滑动查看全部 PDF/图片,本任务将当前报销材料按稳定顺序栅格化/规范化为短时图片预览包,再调用一次 `previewImage({ current, urls })`;普通 PC 使用同一预览包连续浏览。栅格化失败时必须保留逐份原件预览的降级路径,不能让单个异常文件阻断全部材料核对。
- 生产只读核查确认当前真实报销的 3 张票均已由腾讯 OCR 成功处理:号码、日期和金额齐全,无 OCR 错误;2 张为电子火车票,1 张为数电发票。三者按票种均无传统发票代码,电子火车票也没有销售方字段。页面显示“无代码 / 未识别销售方”属于票种展示错误,不得通过重新调用 OCR、伪造代码或把其他主体字段冒充销售方来补值。报销详情 API 应暴露已持久化 OCR 结果中的安全票种分类,前端按票种显示“电子火车票”或“数电发票”、号码、日期、金额及适用主体字段。
## Scope ## Scope
@@ -79,8 +81,10 @@
- [x] 移动报销列表仅对具备 `expense.view_all` 的用户显示“我的 / 全部”切换,默认“我的”;切换“全部”后能通过现有列表进入他人报销详情,普通员工始终只能查看本人列表。 - [x] 移动报销列表仅对具备 `expense.view_all` 的用户显示“我的 / 全部”切换,默认“我的”;切换“全部”后能通过现有列表进入他人报销详情,普通员工始终只能查看本人列表。
- [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。 - [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。
- [x] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,用户已确认能在企微查看当前真实报销材料。 - [x] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,用户已确认能在企微查看当前真实报销材料。
- [x] PC 与移动端每条费用明细展示通过 `linkedItemId` 关联的发票摘要,点击该摘要可沿用现有安全预览能力打开原件;未关联发票不错误归入任何明细。 - [x] PC 与移动端费用明细不再展示或预览关联发票;`linkedItemId` 数据关系继续保留,仅不在费用明细 UI 重复呈现。
- [x] PC 与移动端将原“发票 / 附件材料”收敛为单一“报销材料”区,显示发票与补充附件计数;材料信息行可点击预览,不再为每份材料重复渲染“查看原件”按钮,同时保留草稿编辑态的添加、移除、上传和删除能力。 - [x] PC 与移动端单一“报销材料”区显示发票与补充附件计数,并提供一个“查看全部材料”动作;企微调用一次原生 `previewImage` 打开有序图片数组,PC 使用同一图片预览包连续浏览。
- [x] 图片预览包只包含当前有权查看的报销所绑定发票与补充附件,按“发票 → 补充附件、各自稳定创建顺序、PDF 内页序”生成;短时授权、无持久业务副本、访问审计、数量/页数/源文件字节/输出像素和输出字节上限及失败降级均有验证。
- [x] 票据摘要按 OCR `documentCategory` 展示正确票种与适用字段;数电发票和电子火车票不再出现误导性的“无代码”,电子火车票不再出现“未识别销售方”。当前真实票据不重跑 OCR、不改原始识别数据。
## Out of Scope ## Out of Scope
@@ -104,4 +108,4 @@
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。 - 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。 - 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
- 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。 - 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。
- 用合成多明细、已关联发票、未关联发票和补充附件验证 PC/移动一致性:关联发票出现在正确明细内;统一材料区计数正确且所有材料仍可预览;页面不再出现逐文件“查看原件”按钮;编辑态管理动作不回归。 - 用合成普通增值税发票、数电发票、电子火车票、PDF 与 JPG/PNG/WebP 补充附件验证 PC/移动一致性:费用明细无发票预览;票种标题不虚构缺失字段;统一材料区计数正确;一次图片预览包包含全部可读材料并保持顺序;异常/加密 PDF、超限和栅格化失败可安全降级;编辑态管理动作不回归。
+10
查看文件
@@ -28,6 +28,8 @@ export type InvoicePoolListItem = {
ocrAttempts: number; ocrAttempts: number;
ocrLastError: string | null; ocrLastError: string | null;
ocrCompletedAt: string | null; ocrCompletedAt: string | null;
documentCategory: string | null;
documentSubtype: string | null;
invoiceCode: string | null; invoiceCode: string | null;
invoiceNumber: string | null; invoiceNumber: string | null;
invoiceDate: string | null; invoiceDate: string | null;
@@ -94,6 +96,8 @@ export type InvoicePoolDetail = {
ocrLastRequestedAt: string | null; ocrLastRequestedAt: string | null;
ocrLastError: string | null; ocrLastError: string | null;
ocrCompletedAt: string | null; ocrCompletedAt: string | null;
documentCategory: string | null;
documentSubtype: string | null;
invoiceCode: string | null; invoiceCode: string | null;
invoiceNumber: string | null; invoiceNumber: string | null;
invoiceDate: string | null; invoiceDate: string | null;
@@ -136,6 +140,9 @@ export type BindableInvoiceSummary = {
invoiceNumber: string | null; invoiceNumber: string | null;
invoiceDate: string | null; invoiceDate: string | null;
sellerName: string | null; sellerName: string | null;
buyerName: string | null;
documentCategory: string | null;
documentSubtype: string | null;
totalAmount: string | null; totalAmount: string | null;
originalName: string | null; originalName: string | null;
createdAt: string | null; createdAt: string | null;
@@ -156,6 +163,9 @@ export type InvoiceStatusItem = {
invoiceNumber: string | null; invoiceNumber: string | null;
invoiceDate: string | null; invoiceDate: string | null;
sellerName: string | null; sellerName: string | null;
buyerName: string | null;
documentCategory: string | null;
documentSubtype: string | null;
totalAmount: string | null; totalAmount: string | null;
originalName: string | null; originalName: string | null;
linkedReimbursementId: string | null; linkedReimbursementId: string | null;
+26
查看文件
@@ -173,6 +173,9 @@ export type ReimbursementDetail = {
invoiceNumber: string | null; invoiceNumber: string | null;
invoiceDate: string | null; invoiceDate: string | null;
sellerName: string | null; sellerName: string | null;
buyerName: string | null;
documentCategory: string | null;
documentSubtype: string | null;
totalAmount: string | null; totalAmount: string | null;
originalName: string | null; originalName: string | null;
mimeType: string | null; mimeType: string | null;
@@ -544,6 +547,29 @@ export async function fetchReimbursementAttachmentPreviewUrl(
}); });
} }
export type ReimbursementMaterialPreviewBundle = {
expiresAt: string;
pages: Array<{
url: string;
materialType: "invoice" | "attachment";
materialIndex: number;
pageNumber: number;
}>;
};
export async function fetchReimbursementMaterialPreviewBundle(
reimbursementId: string,
reason?: string,
) {
return apiRequestData<ReimbursementMaterialPreviewBundle>(
`/api/reimbursements/${reimbursementId}/materials/preview-bundle`,
{
method: "POST",
body: JSON.stringify(reason ? { reason } : {}),
},
);
}
export async function linkReimbursementProject(reimbursementId: string, projectId: string | null) { export async function linkReimbursementProject(reimbursementId: string, projectId: string | null) {
return apiRequestData<{ projectId: string | null; project: { id: string; projectNo: string; name: string } | null }>(`/api/reimbursements/${reimbursementId}/link-project`, { return apiRequestData<{ projectId: string | null; project: { id: string; projectNo: string; name: string } | null }>(`/api/reimbursements/${reimbursementId}/link-project`, {
+30 -3
查看文件
@@ -7,6 +7,8 @@ type InvoiceDisplayLike = {
invoiceNumber?: string | null; invoiceNumber?: string | null;
invoiceDate?: string | null; invoiceDate?: string | null;
sellerName?: string | null; sellerName?: string | null;
buyerName?: string | null;
documentCategory?: string | null;
originalName?: string | null; originalName?: string | null;
unavailableReason?: string | null; unavailableReason?: string | null;
unavailableMessage?: string | null; unavailableMessage?: string | null;
@@ -18,6 +20,26 @@ type InvoiceDisplayLike = {
ocrLastError?: string | null; ocrLastError?: string | null;
}; };
const INVOICE_CATEGORY_LABELS: Record<string, string> = {
vat_invoice: "增值税发票",
full_vat_invoice: "数电发票",
electronic_train_ticket: "电子火车票",
};
export function resolveInvoiceTypeLabel(invoice: InvoiceDisplayLike | null | undefined) {
return invoice?.documentCategory && INVOICE_CATEGORY_LABELS[invoice.documentCategory]
? INVOICE_CATEGORY_LABELS[invoice.documentCategory]
: "发票";
}
export function resolveInvoicePartyText(invoice: InvoiceDisplayLike | null | undefined) {
if (!invoice) return "";
if (invoice.documentCategory === "electronic_train_ticket") {
return invoice.buyerName ? `购买方:${invoice.buyerName}` : "";
}
return invoice.sellerName ? `销售方:${invoice.sellerName}` : "";
}
const INVOICE_STATUS_LABELS: Record<InvoicePoolStatus, string> = { const INVOICE_STATUS_LABELS: Record<InvoicePoolStatus, string> = {
pending_ocr: "识别中", pending_ocr: "识别中",
ocr_failed: "识别失败", ocr_failed: "识别失败",
@@ -37,11 +59,16 @@ export function formatInvoiceDisplayTitle(invoice: InvoiceDisplayLike | null | u
return "未识别发票"; return "未识别发票";
} }
if (invoice.invoiceCode || invoice.invoiceNumber) { const typeLabel = resolveInvoiceTypeLabel(invoice);
return `${invoice.invoiceCode || "无代码"}-${invoice.invoiceNumber || "无号码"}`; if (invoice.invoiceCode && invoice.invoiceNumber) {
return `${typeLabel} · ${invoice.invoiceCode}-${invoice.invoiceNumber}`;
} }
return invoice.sellerName || invoice.originalName || "未识别发票"; if (invoice.invoiceNumber || invoice.invoiceCode) {
return `${typeLabel} · ${invoice.invoiceNumber || invoice.invoiceCode}`;
}
return invoice.sellerName || invoice.originalName || typeLabel;
} }
function looksLikeMachineMessage(message: string) { function looksLikeMachineMessage(message: string) {
+14 -1
查看文件
@@ -24,7 +24,7 @@ async function configuredWecomSdk() {
} }
ww.register({ ww.register({
corpId: config.corpId, corpId: config.corpId,
jsApiList: ["previewFile"], jsApiList: ["previewFile", "previewImage"],
async getConfigSignature(url) { async getConfigSignature(url) {
const signed = await fetchWecomJsSdkSignature(url || currentPageUrl()); const signed = await fetchWecomJsSdkSignature(url || currentPageUrl());
return { return {
@@ -58,6 +58,19 @@ export async function previewFileInWecom(file: NativePreviewFile) {
return true; return true;
} }
export async function previewImagesInWecom(urls: string[], current = urls[0]) {
if (!isWecomWebView()) {
return false;
}
const normalized = urls.map((url) => url.trim()).filter(Boolean);
if (!normalized.length || !current || !normalized.includes(current)) {
throw new Error("图片预览信息不完整");
}
const ww = await configuredWecomSdk();
await ww.previewImage({ current, urls: normalized });
return true;
}
export function openFileInBrowser(url: string) { export function openFileInBrowser(url: string) {
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
} }
@@ -23,6 +23,7 @@ import {
downloadReimbursementAttachment, downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl, fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail, fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
MAX_REIMBURSEMENT_ATTACHMENT_FILES, MAX_REIMBURSEMENT_ATTACHMENT_FILES,
precheckReimbursement, precheckReimbursement,
submitReimbursement, submitReimbursement,
@@ -56,6 +57,7 @@ import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFi
import { buildPrecheckDialogMessage } from "../lib/precheck"; import { buildPrecheckDialogMessage } from "../lib/precheck";
import { import {
formatInvoiceDisplayTitle, formatInvoiceDisplayTitle,
resolveInvoicePartyText,
resolveInvoiceNextStepText, resolveInvoiceNextStepText,
resolveInvoiceStatusLabel, resolveInvoiceStatusLabel,
resolveInvoiceUserMessage, resolveInvoiceUserMessage,
@@ -63,13 +65,13 @@ import {
import { resolveErrorMessage } from "../lib/resolveErrorMessage"; import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { formatDisplayDateTime } from "../lib/displayFormat"; import { formatDisplayDateTime } from "../lib/displayFormat";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { TEST_IDS } from "../lib/testIds"; import { TEST_IDS } from "../lib/testIds";
import { withUploadTimeout } from "../lib/uploadTimeout"; import { withUploadTimeout } from "../lib/uploadTimeout";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker"; import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { isWecomWebView } from "../lib/runtimeContext"; import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview"; import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
type ItemFormState = { type ItemFormState = {
mode: "create" | "edit"; mode: "create" | "edit";
@@ -194,6 +196,7 @@ export function MobileReimbursementDetailPage() {
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]); const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]); const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [invoiceActionError, setInvoiceActionError] = useState(""); const [invoiceActionError, setInvoiceActionError] = useState("");
const [materialPreviewError, setMaterialPreviewError] = useState("");
const [invoiceActionMessage, setInvoiceActionMessage] = useState(""); const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]); const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 }); const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
@@ -285,6 +288,9 @@ export function MobileReimbursementDetailPage() {
invoiceNumber: item.invoiceNumber, invoiceNumber: item.invoiceNumber,
invoiceDate: item.invoiceDate, invoiceDate: item.invoiceDate,
sellerName: null, sellerName: null,
buyerName: null,
documentCategory: item.documentCategory,
documentSubtype: item.documentSubtype,
totalAmount: item.totalAmount, totalAmount: item.totalAmount,
originalName: item.originalName, originalName: item.originalName,
createdAt: item.createdAt, createdAt: item.createdAt,
@@ -731,6 +737,36 @@ export function MobileReimbursementDetailPage() {
openAttachmentMutation.mutate({ attachment, reason }); openAttachmentMutation.mutate({ attachment, reason });
} }
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: async (bundle) => {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewImagesInWecom(bundle.pages.map((page) => page.url));
return;
} catch {
toast.error("企业微信滑动预览未打开,已切换到页面预览");
}
}
navigate(`/mobile/reimbursements/${reimbursementId}/materials`, {
state: { bundle, from: `/mobile/reimbursements/${reimbursementId}` },
});
},
onError: (error) => {
const message = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件";
setMaterialPreviewError(message);
toast.error("连续预览生成失败,可逐份打开原件");
},
});
async function handleOpenAllMaterials() {
const governedAttachment = (detail?.attachments ?? []).find(attachmentRequiresDownloadReason);
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
if (reason === null) return;
materialPreviewMutation.mutate(reason);
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (!detail) { if (!detail) {
return; return;
@@ -905,6 +941,24 @@ export function MobileReimbursementDetailPage() {
) : null} ) : null}
</div> </div>
{detail.invoices.length || (detail.attachments ?? []).length ? (
<Button
className="mt-3 min-h-12 w-full"
data-testid="mobile-reimbursement-materials-open-all"
type="button"
disabled={materialPreviewMutation.isPending}
onClick={() => void handleOpenAllMaterials()}
>
{materialPreviewMutation.isPending ? "生成预览中..." : "查看全部材料"}
</Button>
) : null}
{materialPreviewError ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{materialPreviewError},可在下方逐份打开原件。
</p>
) : null}
{itemError ? ( {itemError ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{itemError}</div> <div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{itemError}</div>
) : null} ) : null}
@@ -984,28 +1038,6 @@ export function MobileReimbursementDetailPage() {
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatCurrency(item.amount)}</div> <div className="shrink-0 text-base font-semibold text-zinc-900">{formatCurrency(item.amount)}</div>
</div> </div>
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null} {item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
{detail.invoices
.filter((invoice) => invoice.linkedItemId === item.id)
.map((invoice) => (
<button
key={invoice.id}
type="button"
className="mt-3 flex min-h-11 w-full items-center gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-left transition-colors hover:bg-zinc-100 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openInvoiceMutation.isPending}
aria-label={`查看关联发票:${formatInvoiceDisplayTitle(invoice)}`}
onClick={() => openInvoiceMutation.mutate(invoice.id)}
>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-zinc-800">
发票 · {formatInvoiceDisplayTitle(invoice)}
</div>
<div className="mt-0.5 truncate text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatCurrency(invoice.totalAmount)}
</div>
</div>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
))}
{canEdit ? ( {canEdit ? (
<div className="mobile-two-action-grid mt-3"> <div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}> <Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
@@ -1116,6 +1148,7 @@ export function MobileReimbursementDetailPage() {
) : null} ) : null}
{detail.invoices.length || (detail.attachments ?? []).length ? ( {detail.invoices.length || (detail.attachments ?? []).length ? (
canOpenInvoiceDialog || canManageAttachments || Boolean(materialPreviewError) ? (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2"> <div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
{detail.invoices.map((invoice) => { {detail.invoices.map((invoice) => {
@@ -1133,7 +1166,7 @@ export function MobileReimbursementDetailPage() {
<span className="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span> <span className="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span>
<span className="mt-2 block truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span> <span className="mt-2 block truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
<span className="mt-1 block text-xs text-zinc-500"> <span className="mt-1 block text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} · {formatCurrency(invoice.totalAmount)} {[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDate(invoice.invoiceDate) : "", formatCurrency(invoice.totalAmount)].filter(Boolean).join(" · ")}
</span> </span>
<span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span> <span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span>
{invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null} {invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null}
@@ -1198,6 +1231,7 @@ export function MobileReimbursementDetailPage() {
))} ))}
</div> </div>
</div> </div>
) : null
) : ( ) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500"> <div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无报销材料 暂无报销材料
@@ -1475,7 +1509,7 @@ export function MobileReimbursementDetailPage() {
) : null} ) : null}
</div> </div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} · {formatCurrency(invoice.totalAmount)} {[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDate(invoice.invoiceDate) : "", formatCurrency(invoice.totalAmount)].filter(Boolean).join(" · ")}
</div> </div>
</div> </div>
</div> </div>
+68 -37
查看文件
@@ -15,6 +15,7 @@ import {
downloadReimbursementAttachment, downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl, fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail, fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
MAX_REIMBURSEMENT_ATTACHMENT_FILES, MAX_REIMBURSEMENT_ATTACHMENT_FILES,
precheckReimbursement, precheckReimbursement,
submitReimbursement, submitReimbursement,
@@ -34,7 +35,7 @@ import {
} from "../api/invoicePool"; } from "../api/invoicePool";
import { toast } from "sonner"; import { toast } from "sonner";
import { isWecomWebView } from "../lib/runtimeContext"; import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview"; import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions"; import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton"; import { CardSkeleton } from "../components/common/CardSkeleton";
@@ -61,13 +62,14 @@ import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { import {
formatInvoiceDisplayTitle, formatInvoiceDisplayTitle,
resolveInvoicePartyText,
resolveInvoiceNextStepText, resolveInvoiceNextStepText,
resolveInvoiceStatusLabel, resolveInvoiceStatusLabel,
resolveInvoiceUserMessage, resolveInvoiceUserMessage,
} from "../lib/invoiceStatus"; } from "../lib/invoiceStatus";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker"; import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat"; import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
import { TEST_IDS } from "../lib/testIds"; import { TEST_IDS } from "../lib/testIds";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles"; import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
@@ -152,6 +154,7 @@ export function ReimbursementDetailPage() {
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]); const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null); const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
const [invoiceActionError, setInvoiceActionError] = useState(""); const [invoiceActionError, setInvoiceActionError] = useState("");
const [materialPreviewError, setMaterialPreviewError] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]); const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 }); const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({ const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
@@ -727,6 +730,36 @@ export function ReimbursementDetailPage() {
openAttachmentMutation.mutate({ attachment, reason }); openAttachmentMutation.mutate({ attachment, reason });
} }
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: async (bundle) => {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewImagesInWecom(bundle.pages.map((page) => page.url));
return;
} catch {
toast.error("企业微信滑动预览未打开,已切换到页面预览");
}
}
navigate(`/reimbursements/${reimbursementId}/materials`, {
state: { bundle, from: `/reimbursements/${reimbursementId}` },
});
},
onError: (error) => {
const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件");
setMaterialPreviewError(message);
toast.error("连续预览生成失败,可逐份打开原件");
},
});
async function handleOpenAllMaterials() {
const governedAttachment = (detailQuery.data?.attachments ?? []).find(attachmentRequiresDownloadReason);
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
if (reason === null) return;
materialPreviewMutation.mutate(reason);
}
if (!reimbursementId) { if (!reimbursementId) {
return ( return (
<div className="card-std p-5"> <div className="card-std p-5">
@@ -1339,28 +1372,6 @@ export function ReimbursementDetailPage() {
{check.standardName}:当前金额超出费用标准。 {check.standardName}:当前金额超出费用标准。
</div> </div>
))} ))}
{detail.invoices
.filter((invoice) => invoice.linkedItemId === item.id)
.map((invoice) => (
<button
key={invoice.id}
type="button"
className="mt-2 flex min-h-11 w-full items-center gap-2 rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-left transition-colors hover:bg-zinc-100 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openInvoiceMutation.isPending}
aria-label={`查看关联发票:${formatInvoiceDisplayTitle(invoice)}`}
onClick={() => openInvoiceMutation.mutate(invoice.id)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-zinc-800">
发票 · {formatInvoiceDisplayTitle(invoice)}
</span>
<span className="mt-0.5 block truncate text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · ¥{invoice.totalAmount ?? "-"}
</span>
</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
))}
</TableCell> </TableCell>
<TableCell> <TableCell>
{canEditItems ? ( {canEditItems ? (
@@ -1426,18 +1437,36 @@ export function ReimbursementDetailPage() {
<p className="text-sm text-zinc-600"> <p className="text-sm text-zinc-600">
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length} 发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
</p> </p>
{canOpenInvoiceBindDialog ? ( <div className="flex flex-wrap gap-2">
<Button {detail.invoices.length || (detail.attachments ?? []).length ? (
data-testid={TEST_IDS.reimbursement.invoiceAdd} <Button
type="button" data-testid="reimbursement-materials-open-all"
variant="secondary" type="button"
onClick={() => handleInvoiceDialogOpenChange(true)} disabled={materialPreviewMutation.isPending}
> onClick={() => void handleOpenAllMaterials()}
添加发票 >
</Button> {materialPreviewMutation.isPending ? "生成预览中..." : "查看全部材料"}
) : null} </Button>
) : null}
{canOpenInvoiceBindDialog ? (
<Button
data-testid={TEST_IDS.reimbursement.invoiceAdd}
type="button"
variant="secondary"
onClick={() => handleInvoiceDialogOpenChange(true)}
>
添加发票
</Button>
) : null}
</div>
</div> </div>
{materialPreviewError ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{materialPreviewError},可在下方逐份打开原件。
</p>
) : null}
{invoiceActionError ? ( {invoiceActionError ? (
<p <p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600" className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
@@ -1447,6 +1476,7 @@ export function ReimbursementDetailPage() {
</p> </p>
) : null} ) : null}
{canOpenInvoiceBindDialog || materialPreviewError ? (
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}> <div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
{detail.invoices.map((invoice) => { {detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice); const invoiceHint = resolveInvoiceNextStepText(invoice);
@@ -1463,7 +1493,7 @@ export function ReimbursementDetailPage() {
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate font-medium text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span> <span className="block truncate font-medium text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
<span className="mt-1 block text-xs text-zinc-500"> <span className="mt-1 block text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {invoice.invoiceDate ? formatDisplayDate(invoice.invoiceDate) : "未识别日期"} · ¥{invoice.totalAmount ?? "-"} {[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDisplayDate(invoice.invoiceDate) : "", `¥${invoice.totalAmount ?? "-"}`].filter(Boolean).join(" · ")}
</span> </span>
<span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span> <span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span>
{invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null} {invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null}
@@ -1486,6 +1516,7 @@ export function ReimbursementDetailPage() {
); );
})} })}
</div> </div>
) : null}
<div className="space-y-4" data-testid="reimbursement-attachments"> <div className="space-y-4" data-testid="reimbursement-attachments">
{canEditItems ? ( {canEditItems ? (
@@ -1531,7 +1562,7 @@ export function ReimbursementDetailPage() {
</div> </div>
) : null} ) : null}
{(detail.attachments ?? []).length ? ( {(detail.attachments ?? []).length && (canEditItems || Boolean(materialPreviewError)) ? (
<div className="space-y-2"> <div className="space-y-2">
{(detail.attachments ?? []).map((attachment) => ( {(detail.attachments ?? []).map((attachment) => (
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center"> <div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center">
@@ -1820,7 +1851,7 @@ export function ReimbursementDetailPage() {
<div className="space-y-1"> <div className="space-y-1">
<div className="font-medium text-zinc-900">{formatInvoiceDisplayTitle(item)}</div> <div className="font-medium text-zinc-900">{formatInvoiceDisplayTitle(item)}</div>
<div className="text-zinc-600"> <div className="text-zinc-600">
{item.sellerName || "未识别销售方"} · {item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"} {[resolveInvoicePartyText(item), item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "", `金额 ¥${item.totalAmount ?? "-"}`].filter(Boolean).join(" · ")}
</div> </div>
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDisplayDateTime(item.createdAt)} 原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDisplayDateTime(item.createdAt)}
@@ -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 <div className="card-std p-6 text-sm text-red-600">缺少报销单参数。</div>;
}
return (
<div className={isMobile ? "space-y-3 pb-6" : "mx-auto max-w-5xl space-y-4 pb-8"} data-testid="reimbursement-material-viewer">
<header className="sticky top-0 z-20 flex min-h-14 items-center gap-3 border-b border-zinc-200 bg-white/95 px-3 py-2 backdrop-blur md:rounded-xl md:border">
<Button aria-label="返回报销单" className="h-11 w-11 shrink-0 p-0" type="button" variant="secondary" onClick={handleBack}>
<ArrowLeft aria-hidden="true" className="size-5" />
</Button>
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold text-zinc-900">全部报销材料</h1>
<p className="text-xs text-zinc-500">按发票、补充附件和文件页序排列</p>
</div>
{bundle ? <span className="shrink-0 text-sm text-zinc-600">共 {bundle.pages.length} 页</span> : null}
</header>
{bundleQuery.isLoading && !bundle ? <CardSkeleton lines={8} /> : null}
{bundleQuery.isError && !bundle ? (
<div className="card-std space-y-3 p-5">
<p className="text-sm text-red-600">{resolveErrorMessage(bundleQuery.error)}</p>
<Button type="button" variant="secondary" disabled={bundleQuery.isFetching} onClick={() => void bundleQuery.refetch()}>
<RefreshCw aria-hidden="true" className="mr-2 size-4" />
{bundleQuery.isFetching ? "重新生成中..." : "重新生成预览"}
</Button>
</div>
) : null}
{bundle ? (
<div className="space-y-4 px-3 md:px-0">
{bundle.pages.map((page, index) => (
<figure key={`${page.materialType}-${page.materialIndex}-${page.pageNumber}-${index}`} className="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-sm">
<figcaption className="border-b border-zinc-100 px-4 py-3 text-sm font-medium text-zinc-700">
{pageLabel(page)}
</figcaption>
<div className="flex min-h-40 items-center justify-center bg-zinc-100 p-2 md:p-4">
<img
alt={`${pageLabel(page)}预览`}
className="h-auto max-w-full rounded-md bg-white"
decoding="async"
loading="eager"
src={page.url}
/>
</div>
</figure>
))}
</div>
) : null}
</div>
);
}
export default ReimbursementMaterialsPage;
+19
查看文件
@@ -62,6 +62,7 @@ import {
ReimbursementDetailPage, ReimbursementDetailPage,
ReimbursementDashboardPage, ReimbursementDashboardPage,
ReimbursementPrintPage, ReimbursementPrintPage,
ReimbursementMaterialsPage,
ReimbursementTemplatesPage, ReimbursementTemplatesPage,
ReimbursementsPage, ReimbursementsPage,
TravelRequestsPage, TravelRequestsPage,
@@ -232,6 +233,15 @@ const routeElements = (
} }
/> />
<Route
path="reimbursements/:reimbursementId/materials"
element={
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
{withRouteFallback(<ReimbursementMaterialsPage />)}
</RequirePermission>
}
/>
<Route <Route
path="leave-requests" path="leave-requests"
element={ element={
@@ -388,6 +398,15 @@ const routeElements = (
} }
/> />
<Route
path="mobile/reimbursements/:reimbursementId/materials"
element={
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
{withRouteFallback(<ReimbursementMaterialsPage />)}
</RequirePermission>
}
/>
<Route <Route
path="mobile/leave" path="mobile/leave"
element={ element={
@@ -0,0 +1 @@
export { ReimbursementMaterialsPage as default } from "../../pages/ReimbursementMaterialsPage";
+1
查看文件
@@ -51,6 +51,7 @@ export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"))
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage")); export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage")); export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
export const ReimbursementPrintPage = lazy(() => import("./ReimbursementPrintPage")); export const ReimbursementPrintPage = lazy(() => import("./ReimbursementPrintPage"));
export const ReimbursementMaterialsPage = lazy(() => import("./ReimbursementMaterialsPage"));
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage")); export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemplatesPage")); export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemplatesPage"));
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage")); export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
@@ -73,10 +73,13 @@ function buildInvoice() {
return { return {
id: pendingInvoiceId, id: pendingInvoiceId,
status: "bound", status: "bound",
invoiceCode: "044002600111", invoiceCode: null as string | null,
invoiceNumber: "12345678", invoiceNumber: "12345678",
invoiceDate: "2026-05-08", 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", totalAmount: "1683.00",
originalName: "北京悦海堂发票.pdf", originalName: "北京悦海堂发票.pdf",
mimeType: "application/pdf", mimeType: "application/pdf",
@@ -90,7 +93,10 @@ function buildUnlinkedInvoice() {
...buildInvoice(), ...buildInvoice(),
id: unlinkedInvoiceId, id: unlinkedInvoiceId,
invoiceNumber: "12345679", invoiceNumber: "12345679",
sellerName: "未关联材料供应方", sellerName: null,
buyerName: "测试购买方",
documentCategory: "electronic_train_ticket",
documentSubtype: "ElectronicTrainTicketFull",
originalName: "未关联发票.pdf", originalName: "未关联发票.pdf",
linkedItemId: null, linkedItemId: null,
}; };
@@ -351,6 +357,30 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
return; return;
} }
if (pathName === `/api/reimbursements/${reimbursementId}/materials/preview-bundle` && request.method() === "POST") {
const pages = [
...state.invoices.map((_, index) => ({
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 ( if (
pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url` pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`
&& request.method() === "POST" && request.method() === "POST"
@@ -611,17 +641,15 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect(page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible(); await expect(page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible(); await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible();
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 0"); 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"); const itemRow = page.getByTestId("reimbursement-item-row");
await expect(itemRow.getByRole("button", { name: /查看关联发票/ })).toHaveCount(1); await expect(itemRow.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
await expect(itemRow).not.toContainText("未关联材料供应方"); await expect(itemRow).not.toContainText("电子火车票");
await expect(page.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2); await expect(page.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
await expect(page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0); 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({ await page.locator("#reimbursement-attachment-upload").setInputFiles({
name: "付款截图.png", name: "付款截图.png",
@@ -631,8 +659,13 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await page.getByRole("button", { name: "上传附件" }).click(); await page.getByRole("button", { name: "上传附件" }).click();
await expect(page.getByText("付款截图.png")).toBeVisible(); await expect(page.getByText("付款截图.png")).toBeVisible();
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1"); await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
await page.getByTestId("reimbursement-attachments").getByRole("button", { name: /查看补充附件/ }).click(); await page.getByTestId("reimbursement-materials-open-all").click();
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(2); 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(); const bodyText = await page.locator("body").innerText();
expect(bodyText).not.toContain("绑定整单"); expect(bodyText).not.toContain("绑定整单");
@@ -733,16 +766,14 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await mobile.page.keyboard.press("Escape"); await mobile.page.keyboard.press("Escape");
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible(); 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("发票 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(); const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first();
await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(1); await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
await expect(mobileItem).not.toContainText("未关联材料供应方"); await expect(mobileItem).not.toContainText("电子火车票");
await expect(mobile.page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0); 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.getByRole("button", { name: "添加附件" }).click();
await mobile.page.getByTestId("mobile-reimbursement-materials").locator("input[type='file']").setInputFiles({ 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 mobile.page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: "上传附件" }).click();
await expect(mobile.page.getByText("付款截图.png")).toBeVisible(); await expect(mobile.page.getByText("付款截图.png")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1"); 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(() => ( await expect.poll(() => mobile.page.evaluate(() => (
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> } 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(() => ( const nativePreviewCalls = await mobile.page.evaluate(() => (
window as typeof window & { __r111BridgeCalls: Array<{ name: string; params: Record<string, unknown> }> } window as typeof window & { __r111BridgeCalls: Array<{ name: string; params: Record<string, unknown> }> }
).__r111BridgeCalls.filter((call) => call.name === "previewFile")); ).__r111BridgeCalls.filter((call) => call.name === "imagePreview"));
expect(nativePreviewCalls).toEqual([ expect(nativePreviewCalls).toEqual([
expect.objectContaining({ expect.objectContaining({
params: expect.objectContaining({ params: expect.objectContaining({
url: expect.stringContaining(`/api/invoice-pool/${pendingInvoiceId}/file?token=`), current: expect.stringContaining(`/materials/preview/`),
name: "北京悦海堂发票.pdf", urls: expect.arrayContaining([
size: 4096, expect.stringContaining("previewToken=invoice-0"),
}), expect.stringContaining("previewToken=invoice-1"),
}), expect.stringContaining("previewToken=attachment-0"),
expect.objectContaining({ ]),
params: expect.objectContaining({
url: expect.stringContaining(`/attachments/${attachmentId}/file?previewToken=`),
name: "付款截图.png",
size: 2048,
}), }),
}), }),
]); ]);
+1 -1
查看文件
@@ -7,7 +7,7 @@ ENV VITE_BUILD_SHA=$VITE_BUILD_SHA
WORKDIR /app WORKDIR /app
RUN apk add --no-cache bash curl RUN apk add --no-cache bash curl poppler-utils
FROM base AS client_deps FROM base AS client_deps
COPY client/package.json client/package-lock.json ./client/ COPY client/package.json client/package-lock.json ./client/
+9
查看文件
@@ -8,6 +8,7 @@ const { buildAttachmentContentDisposition } = require("../../utils/contentDispos
const { openReadStream, StorageNotFoundError } = require("../../services/storage"); const { openReadStream, StorageNotFoundError } = require("../../services/storage");
const { toMoneyString } = require("../../utils/workflow"); const { toMoneyString } = require("../../utils/workflow");
const { resolveInvoiceAvailability } = require("../../services/invoiceGovernance"); const { resolveInvoiceAvailability } = require("../../services/invoiceGovernance");
const { mapInvoiceDocumentMeta } = require("../../services/invoiceDocumentMeta");
const { const {
mapInvoiceSummary, mapInvoiceSummary,
normalizeInvoiceResponse, normalizeInvoiceResponse,
@@ -201,11 +202,13 @@ function registerInvoicePoolReadRoutes(fastify) {
invoice_number: true, invoice_number: true,
invoice_date: true, invoice_date: true,
seller_name: true, seller_name: true,
buyer_name: true,
total_amount: true, total_amount: true,
original_name: true, original_name: true,
ocr_attempts: true, ocr_attempts: true,
ocr_last_error: true, ocr_last_error: true,
ocr_completed_at: true, ocr_completed_at: true,
ocr_result: { select: { result_json: true } },
updated_at: true, updated_at: true,
linked_reimbursement: { linked_reimbursement: {
select: { select: {
@@ -221,6 +224,7 @@ function registerInvoicePoolReadRoutes(fastify) {
items: readableRows.map((invoice) => { items: readableRows.map((invoice) => {
const statusReason = resolveInvoiceStatusReason(invoice); const statusReason = resolveInvoiceStatusReason(invoice);
return { return {
...mapInvoiceDocumentMeta(invoice),
id: invoice.id, id: invoice.id,
status: invoice.status, status: invoice.status,
reason: statusReason.reason, reason: statusReason.reason,
@@ -231,6 +235,7 @@ function registerInvoicePoolReadRoutes(fastify) {
invoiceNumber: invoice.invoice_number ?? null, invoiceNumber: invoice.invoice_number ?? null,
invoiceDate: invoice.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, invoiceDate: invoice.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
sellerName: invoice.seller_name ?? null, sellerName: invoice.seller_name ?? null,
buyerName: invoice.buyer_name ?? null,
totalAmount: toMoneyString(invoice.total_amount), totalAmount: toMoneyString(invoice.total_amount),
originalName: invoice.original_name ?? null, originalName: invoice.original_name ?? null,
linkedReimbursementId: invoice.linked_reimbursement_id ?? null, linkedReimbursementId: invoice.linked_reimbursement_id ?? null,
@@ -319,19 +324,23 @@ function registerInvoicePoolReadRoutes(fastify) {
invoice_number: true, invoice_number: true,
invoice_date: true, invoice_date: true,
seller_name: true, seller_name: true,
buyer_name: true,
total_amount: true, total_amount: true,
original_name: true, original_name: true,
created_at: true, created_at: true,
ocr_result: { select: { result_json: true } },
}, },
}); });
return sendData(reply, request, { return sendData(reply, request, {
items: items.map((item) => ({ items: items.map((item) => ({
...mapInvoiceDocumentMeta(item),
id: item.id, id: item.id,
invoiceCode: item.invoice_code ?? null, invoiceCode: item.invoice_code ?? null,
invoiceNumber: item.invoice_number ?? null, invoiceNumber: item.invoice_number ?? null,
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
sellerName: item.seller_name ?? null, sellerName: item.seller_name ?? null,
buyerName: item.buyer_name ?? null,
totalAmount: toMoneyString(item.total_amount), totalAmount: toMoneyString(item.total_amount),
originalName: item.original_name ?? null, originalName: item.original_name ?? null,
createdAt: item.created_at?.toISOString?.() ?? null, createdAt: item.created_at?.toISOString?.() ?? null,
@@ -1,5 +1,6 @@
const { toMoneyString } = require("../../../utils/workflow"); const { toMoneyString } = require("../../../utils/workflow");
const { resolveInvoiceAvailability } = require("../../../services/invoiceGovernance"); const { resolveInvoiceAvailability } = require("../../../services/invoiceGovernance");
const { mapInvoiceDocumentMeta } = require("../../../services/invoiceDocumentMeta");
function mapVerificationRecord(item) { function mapVerificationRecord(item) {
if (!item) { if (!item) {
@@ -30,6 +31,7 @@ function mapInvoiceSummary(item) {
const availability = resolveInvoiceAvailability(item); const availability = resolveInvoiceAvailability(item);
return { return {
...mapInvoiceDocumentMeta(item),
id: item.id, id: item.id,
status: item.status, status: item.status,
availabilityReason: availability.reason, availabilityReason: availability.reason,
@@ -78,6 +80,7 @@ function normalizeInvoiceResponse(invoice) {
const availability = resolveInvoiceAvailability(invoice); const availability = resolveInvoiceAvailability(invoice);
return { return {
...mapInvoiceDocumentMeta(invoice),
id: invoice.id, id: invoice.id,
status: invoice.status, status: invoice.status,
availabilityReason: availability.reason, availabilityReason: availability.reason,
@@ -49,6 +49,7 @@ function buildInvoiceSummarySelect() {
created_at: true, created_at: true,
updated_at: true, updated_at: true,
verification_records: buildLatestVerificationSelect(), verification_records: buildLatestVerificationSelect(),
ocr_result: { select: { result_json: true } },
owner_employee: { owner_employee: {
select: { id: true, employee_no: true, name: true }, select: { id: true, employee_no: true, name: true },
}, },
@@ -95,6 +96,7 @@ function buildInvoiceDetailSelect(options = {}) {
created_at: true, created_at: true,
updated_at: true, updated_at: true,
verification_records: buildLatestVerificationSelect(), verification_records: buildLatestVerificationSelect(),
ocr_result: { select: { result_json: true } },
}; };
if (options.includeLinkedReimbursement) { if (options.includeLinkedReimbursement) {
+236 -1
查看文件
@@ -22,6 +22,7 @@ const {
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { sendNotificationEmailSafe } = require("../services/email"); const { sendNotificationEmailSafe } = require("../services/email");
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal"); const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
const { mapInvoiceDocumentMeta } = require("../services/invoiceDocumentMeta");
const { dispatchNotificationBatch, flushNotificationDispatchTasks } = require("../services/notificationDispatcher"); const { dispatchNotificationBatch, flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags"); const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { ensureCutoverAllowed } = require("../services/cutover"); const { ensureCutoverAllowed } = require("../services/cutover");
@@ -32,7 +33,11 @@ const {
resolveEntityPrecheck, resolveEntityPrecheck,
} = require("../services/feeControl"); } = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence"); 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 { const {
ATTACHMENT_TYPE, ATTACHMENT_TYPE,
DOWNLOAD_POLICY, DOWNLOAD_POLICY,
@@ -80,6 +85,8 @@ const REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA = z
const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
.object({ reason: z.string().trim().max(200).optional() }) .object({ reason: z.string().trim().max(200).optional() })
.strict(); .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) { function resolveSignedFileOrigin(config, request) {
const configured = String(config.appOrigin ?? "") const configured = String(config.appOrigin ?? "")
@@ -94,6 +101,21 @@ function resolvePreviewUrlTtlMs(config) {
return Math.min(configuredSeconds, 120) * 1000; 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 const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
.object({ .object({
ownerEmployeeId: z.string().uuid().optional(), ownerEmployeeId: z.string().uuid().optional(),
@@ -1361,6 +1383,9 @@ async function finalizeReimbursementSubmitSideEffects({
} }
async function reimbursementRoutes(fastify) { async function reimbursementRoutes(fastify) {
const materialPreviewStore = fastify.reimbursementMaterialPreviewStore
?? createReimbursementMaterialPreviewStore();
fastify.post( fastify.post(
"/reimbursements", "/reimbursements",
{ {
@@ -2121,11 +2146,13 @@ async function reimbursementRoutes(fastify) {
invoice_number: true, invoice_number: true,
invoice_date: true, invoice_date: true,
seller_name: true, seller_name: true,
buyer_name: true,
total_amount: true, total_amount: true,
original_name: true, original_name: true,
mime_type: true, mime_type: true,
file_size: true, file_size: true,
linked_item_id: true, linked_item_id: true,
ocr_result: { select: { result_json: true } },
}, },
}, },
workflow_instances: { workflow_instances: {
@@ -2291,12 +2318,14 @@ async function reimbursementRoutes(fastify) {
attachments: reimbursement.attachments.map(mapReimbursementAttachment), attachments: reimbursement.attachments.map(mapReimbursementAttachment),
budgetHint, budgetHint,
invoices: reimbursement.invoice_pool_items.map((item) => ({ invoices: reimbursement.invoice_pool_items.map((item) => ({
...mapInvoiceDocumentMeta(item),
id: item.id, id: item.id,
status: item.status, status: item.status,
invoiceCode: item.invoice_code, invoiceCode: item.invoice_code,
invoiceNumber: item.invoice_number, invoiceNumber: item.invoice_number,
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null, invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
sellerName: item.seller_name ?? null, sellerName: item.seller_name ?? null,
buyerName: item.buyer_name ?? null,
totalAmount: toMoneyString(item.total_amount), totalAmount: toMoneyString(item.total_amount),
originalName: item.original_name ?? null, originalName: item.original_name ?? null,
mimeType: item.mime_type ?? 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( fastify.post(
"/reimbursements/:id/items", "/reimbursements/:id/items",
{ {
@@ -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 };
@@ -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,
};
@@ -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 tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r111-reimbursement-attachments-"));
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const createdAttachments = []; const createdAttachments = [];
const prisma = { const prisma = {
reimbursementForm: { reimbursementForm: {
findUnique: vi.fn().mockResolvedValue({ findUnique: vi.fn().mockImplementation(async ({ select }) => ({
id: reimbursementId, id: reimbursementId,
owner_id: ACTOR_ID, owner_id: ACTOR_ID,
submitter_id: ACTOR_ID, submitter_id: ACTOR_ID,
status: reimbursementStatus, status: reimbursementStatus,
}), ...(select?.invoice_pool_items
? { invoice_pool_items: [], attachments: createdAttachments }
: {}),
})),
}, },
reimbursementAttachment: { reimbursementAttachment: {
create: vi.fn().mockImplementation(async ({ data, select }) => { create: vi.fn().mockImplementation(async ({ data, select }) => {
@@ -141,6 +144,9 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
const app = Fastify(); const app = Fastify();
app.decorate("prisma", prisma); app.decorate("prisma", prisma);
if (materialPreviewStore) {
app.decorate("reimbursementMaterialPreviewStore", materialPreviewStore);
}
app.decorate("config", { app.decorate("config", {
appTimezone: "Asia/Shanghai", appTimezone: "Asia/Shanghai",
storageDir: tempDir, storageDir: tempDir,
@@ -612,10 +618,18 @@ describe("reimbursement attachment routes", () => {
headers: { authorization: "Bearer test", "content-type": "application/json" }, headers: { authorization: "Bearer test", "content-type": "application/json" },
payload: {}, 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(unauthenticated.statusCode).toBe(401);
expect(missingReason.statusCode).toBe(400); expect(missingReason.statusCode).toBe(400);
expect(missingReason.json().error.details).toEqual({ reason: "required" }); 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(); expect(prisma.auditLog.create).not.toHaveBeenCalled();
} finally { } finally {
await app.close(); await app.close();
@@ -682,4 +696,109 @@ describe("reimbursement attachment routes", () => {
await fs.rm(tempDir, { recursive: true, force: true }); 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 });
}
});
}); });
@@ -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 });
});
});
@@ -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);
});
});