实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-01 09:25:44 +08:00
父节点 3d9ec4c5a7
当前提交 da35591bd5
共修改 29 个文件,包含 907 行新增和 527 行删除
@@ -581,9 +581,9 @@ reply.header(
### 1. Scope / Trigger
- Trigger: changing PC/mobile reimbursement material presentation, invoice OCR
category labels, PDF rasterization or the continuous-preview grant.
- Purpose: keep expense items concise while allowing an approver to enter once
and continuously inspect every invoice and supporting attachment.
category labels or the whole-reimbursement merged-preview grant.
- Purpose: keep expense items concise while allowing an approver to open one
derived PDF and continuously inspect every invoice and supporting attachment.
### 2. Signatures
@@ -591,34 +591,37 @@ reply.header(
`documentCategory`, `documentSubtype` and `buyerName`; it never exposes the
raw OCR provider response.
- Bundle issue: `POST /api/reimbursements/:id/materials/preview-bundle`, optional
`{ reason }`; response `{ expiresAt, pages: [{ url, materialType,
materialIndex, pageNumber }] }`.
- Page fetch: `GET /api/reimbursements/:id/materials/preview/:bundleId/:pageIndex?previewToken=...`.
- Runtime dependency: production Alpine image contains `poppler-utils`;
`reimbursementMaterialPreview` invokes `pdftoppm` with `execFile`, never a shell.
`{ reason }`; response `{ expiresAt, signedUrl, originalName, mimeType,
fileSize, pageCount }`.
- Bundle fetch: `GET /api/reimbursements/:id/materials/preview/:bundleId?previewToken=...`.
- Runtime: `pdf-lib` and `sharp` execute inside Node `worker_threads`; this does
not enable the Compose worker profile and does not add a frontend PDF viewer.
### 3. Contracts
- Expense-item UI does not render or preview linked invoices. Preserve
`linkedItemId` for finance/audit semantics; presentation must not delete or
rewrite the relationship.
- The read-only detail shows one `报销材料` count and one `查看全部材料` action.
- The read-only detail shows one `报销材料` count and one `查看材料合并预览` action.
Editable drafts may retain per-record management rows; read-only users see
them only when continuous preview fails and the original-file fallback opens.
- Material order is invoices by `created_at,id`, then attachments by
`created_at,id`, then PDF page order. Never infer order from amount, file name
or `linkedItemId`.
- PDFs are rasterized separately; images are normalized with `sharp`. Do not
merge PDFs, strip their signatures, use PDF.js in the WeCom page, or persist
preview images as business attachments.
- PDF originals contribute every original page; JPG/PNG/WebP materials are
normalized with `sharp` and embedded one per page. The merged PDF is a
derived approval view, never an original or digital-signature fact source.
- A stable collection hash covers the explicitly ordered material type, ID,
file hash, MIME and byte count. The cache key also includes reimbursement ID
and merger version. Fix CreationDate, ModificationDate, Creator, Producer and
save options so identical input produces byte-identical output.
- The in-memory bundle is bounded to 20 materials, 50 MiB source bytes, 40
pages, 1800px page width, 40 megapixels per page, 40 MiB bundle output and
80 MiB process cache.
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`.
pages, 40 megapixels per image, 50 MiB output and 100 MiB process cache.
Worker execution has a fixed timeout; concurrent requests for one collection
share one in-flight generation.
- Tokens bind purpose, reimbursement, actor, bundle and expiry. Issue requires
current object access plus any attachment download reason; bundle GET audits
before bytes and returns exact cached bytes with `private, no-store`.
- Category labels are `vat_invoice -> 增值税发票`,
`full_vat_invoice -> 数电发票`, and
`electronic_train_ticket -> 电子火车票`. Known no-code categories never show
@@ -627,36 +630,35 @@ reply.header(
### 4. Validation & Error Matrix
- No materials -> do not issue a bundle; show the existing empty state.
- Unsupported/corrupt input, missing Poppler or raster timeout -> stable
continuous-preview error and expose authorized original-file fallback.
- Encrypted, unsupported or corrupt PDF/image, worker error or timeout -> the
entire merge fails and exposes authorized original-file fallback.
- Material/source/page/output/cache limit -> bounded validation error; never
return a partial bundle or silently omit pages.
- Missing required reason -> `400`; invalid/expired/tampered/object-mismatched
token, absent cache bundle or process restart -> `403` with no bytes.
- Bundle-issue audit failure deletes the bundle; page-access audit failure ->
`5xx` with no image body.
- Bundle-issue audit failure deletes the bundle; bundle-access audit failure ->
`5xx` with no PDF body.
- Cache loss/process restart after issue -> `403`; never regenerate different
bytes behind an already-issued `fileSize`.
- 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
- Good: three invoice PDFs and three PNG attachments produce one ordered image
URL array; WeCom swipes natively and PC loads the same bounded pages.
- Base: one single-page PDF becomes one PNG preview while its original remains
unchanged and separately auditable.
- Bad: concatenate PDFs, expose OCR raw data, put invoice buttons back into
every expense item, or accept the first 40 pages of a 41-page file silently.
- Good: three invoice PDFs and three images produce one ordered six-or-more-page
PDF; WeCom and PC use their native PDF readers while originals remain intact.
- Base: one single-page PDF is copied into a one-page derived preview.
- Bad: use `ignoreEncryption`, skip a failed material, add PDF.js to the client,
expose OCR raw data, or accept the first 40 pages of a 41-page file silently.
### 6. Tests Required
- Service tests assert material/page order, expiry, source/page/output/cache
limits and absence of partial bundles.
- Service tests assert deterministic bytes, all-page copy, image embedding,
20–30-page worker behavior, concurrent deduplication, expiry and limits.
- Route tests assert authorization, reason gate, token binding, cache miss,
issue/access audit, audit-failure fail-closed behavior and PNG response.
- A target-image probe must generate synthetic PDF bytes and prove the built
Alpine container returns PNG through the production rasterizer.
issue/access audit, audit-failure fail-closed behavior and exact PDF size.
- PC/WeCom Playwright asserts no invoice entry inside expense items, correct
category text, no `无代码/未识别销售方`, ordered `previewImage.urls`, fallback,
category text, no `无代码/未识别销售方`, one exact `previewFile` call, fallback,
editable actions and responsive layout. Run full server tests, Prisma
validation, frontend typecheck/build and Trellis governance gates.
@@ -665,14 +667,74 @@ reply.header(
#### Wrong
```tsx
const bundlePdf = mergePdfFiles(detail.invoices); // changes originals and adds a second document pipeline
const pages = await rasterizeEveryPdfOnThePhone(); // slow, blank-prone and duplicates PDF rendering
```
#### Correct
```tsx
const bundle = await fetchReimbursementMaterialPreviewBundle(reimbursementId);
await previewImagesInWecom(bundle.pages.map((page) => page.url));
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
```
## Scenario: Lightweight Travel Summary On Reimbursements
### 1. Scope / Trigger
- Trigger: a reimbursement contains an expense item whose expense-type category
is `travel`, or links an approved travel request.
### 2. Signatures
- DB nullable columns: `travel_purpose varchar(200)`, `travel_location
varchar(200)`, `travel_start_date date`, `travel_end_date date`.
- `PATCH /api/reimbursements/:id` accepts nullable camel-case counterparts.
- Detail response includes `travelSummary: { isTravel, purpose, location,
startDate, endDate }`.
### 3. Contracts
- Only `draft`/`rejected` applicant-editable reimbursements may change the four
fields. Reviewing records are immutable business facts.
- Linked travel requests seed purpose, ordered segment cities and dates. Blank
reimbursements persist user input directly.
- Historical read-only travel records may display description as purpose and
min/max travel-item dates; location remains `未填写` and is never guessed.
- Submit requires all four fields only for travel reimbursements; ordinary
reimbursements remain unaffected.
### 4. Validation & Error Matrix
- String over 200 chars or malformed date -> `400 VALID_001`.
- Start after end -> `400` with `travelEndDate: invalid_date_range`.
- Travel submit with missing fields -> `400` with
`travelSummary: incomplete` and stable `missingFields`.
- Edit while not draft/rejected -> existing `409 invalid_edit_status`.
### 5. Good/Base/Bad Cases
- Good: travel item plus complete purpose/location/date range submits normally.
- Base: office-supply reimbursement has no travel summary and is not blocked.
- Bad: infer a destination from invoice OCR, attachment name or free text.
### 6. Tests Required
- Prisma validation and all migrations against an empty PostgreSQL database.
- Route tests for detail fallback, complete/incomplete travel submit, date order
and non-travel compatibility; frontend typecheck/build and PC/mobile UI tests.
### 7. Wrong vs Correct
#### Wrong
```js
travelLocation = invoice.ocrResult.sellerName;
```
#### Correct
```js
travelLocation = reimbursement.travel_location ?? null;
```
## Scenario: Payment Batch Receipt Attention Count
+15 -16
查看文件
@@ -64,11 +64,10 @@ or administration workflow has already migrated.
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.
`{ reason }`; response contains one short-lived merged PDF URL plus exact
`name`, `size` and `pageCount`.
- Native call: `previewFile({ url, name, size })`; `name` includes an extension
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
@@ -86,10 +85,10 @@ or administration workflow has already migrated.
- PC keeps its browser/authenticated download behavior. Only a detected WeCom
WebView dynamically loads `@wecom/jssdk` and calls `previewFile`; do not send
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.
- Whole-reimbursement continuous preview derives one deterministic PDF: PDF
pages are copied and images are embedded as pages, then WeCom receives one
`previewFile` call. The derivative is explicitly not an original or digital-
signature fact source; individual originals remain separately authorized.
- 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
@@ -106,7 +105,7 @@ or administration workflow has already migrated.
- Missing required download reason -> `400 { reason: "required" }`.
- Expired, tampered, wrong-purpose or object-mismatched file grant -> `403`.
- Access audit failure -> `5xx` with no file body.
- Empty or inconsistent `previewImage` URL arrays -> frontend stops before the
- Missing/invalid merged PDF URL, name or size -> 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.
@@ -116,8 +115,8 @@ or administration workflow has already migrated.
`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
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.
- Base: a WeCom approver requests one authorized reimbursement PDF bundle,
then `previewFile` opens it for continuous native scrolling.
- 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
WeCom and assume desktop popup behavior applies.
@@ -132,9 +131,9 @@ or administration workflow has already migrated.
- Playwright injects a synthetic `WeixinJSBridge`, asserts `previewFile` receives
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.
- 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.
- Continuous-preview Playwright asserts one `previewFile` call with exact URL,
filename and byte size. Backend tests separately prove deterministic output,
exact response size and object/actor/bundle binding.
- Production deployment and real-device preview remain separate approvals.
### 7. Wrong vs Correct
@@ -153,9 +152,9 @@ authenticated grant issue -> <=120s object-bound URL
-> ww.previewFile({ url, nameWithExtension, exactSize })
-> audit before file bytes
authenticated material-bundle issue -> ordered <=120s PNG URLs
-> ww.previewImage({ current: urls[0], urls })
-> audit each page before image bytes
authenticated material-bundle issue -> one <=120s deterministic PDF URL
-> ww.previewFile({ url, nameWithExtension, exactSize })
-> audit before exact cached PDF bytes
```
## Scenario: WeCom Member Data Is A Read-Only Source For OA
@@ -206,9 +206,50 @@
## 12. 报销明细与材料的 KISS 对齐
- 交互采用“一个入口、一次通览、语义保留”:费用明细只展示费用事实,不再渲染关联发票;`linkedItemId`、票据/附件类型、对象权限、审计和生命周期均不改变。付款回单仍属于付款阶段材料,不提前混入本次提交材料。
- 报销材料只读态首屏显示“发票 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、图片索引和过期时间。
- grant 签发前复用报销对象级读权限及附件治理/理由门禁;生成时限制材料数量、源文件总字节、PDF 总页数、单页像素和输出字节,Poppler 使用无 shell 的固定参数、超时和临时目录。每个图片 GET 验签、核对缓存 bundle 与 actor 后先写审计再发送。缓存缺失、进程重启、材料不支持/损坏、超限或栅格化失败时返回稳定错误,客户端展开现有逐份原件预览作为降级,不静默漏页。
- 报销材料只读态首屏显示“发票 N · 补充附件 M”和一个明确的“查看材料合并预览”按钮,不为每份文件重复暴露技术操作。草稿/驳回编辑态仍保留发票添加/移除和附件上传/删除所需列表,数据变更动作与只读通览分离。
- 服务端新增报销级合并 PDF grant:授权后读取当前绑定的发票 PDF 与补充附件,PDF 原页全部复制,JPG/PNG/WebP 经 Sharp 规范化后各嵌入一页。企微调用一次 `previewFile({ url, name, size })`,PC/普通浏览器打开同一短时 PDF URL;不引入 PDF.js,不在客户端渲染 PDF。
- 预览顺序固定为发票 `created_at, id`、补充附件 `created_at, id`、PDF 内页顺序;不根据 `linkedItemId`、金额或文件名猜测排序。有序材料集合哈希覆盖材料类型、ID、文件哈希/大小/MIME,缓存键由报销单 ID、集合哈希和合并器版本组成。
- 合并在 Node `worker_threads` 中执行,不启用 Compose worker profile。worker 限制材料数、源文件总字节、PDF 总页数、图片像素、输出字节和执行时间;`pdf-lib` 不使用 `ignoreEncryption`,加密、损坏或不可复制 PDF 导致整包失败。任一材料失败都不能跳过,客户端展开现有逐份原件路径。
- 合并 PDF 固定 CreationDate、ModificationDate、Creator、Producer 和保存选项;相同有序输入必须生成完全相同字节。合并 Buffer 生成一次后缓存并原样返回,签发 `size` 取该 Buffer 长度。缓存缺失时原签发 URL 返回拒绝,不现场生成另一份字节;用户重新操作会重新签发。
- 合并件不进入业务表、不形成原件,只在有界短时缓存中保存;页面明确“报销材料合并预览,仅供审批核对;原始文件另行保留”。grant 绑定 purpose、reimbursement、actor、bundle 与过期时间,实际 GET 验签并写整包预览审计。
- 后端从已脱敏持久化 OCR `result_json` 只映射 `documentCategory` / `documentSubtype` 到报销详情,不下发原始腾讯响应。前端共享格式化函数按 `vat_invoice`、`full_vat_invoice`、`electronic_train_ticket` 选择票种名称和适用字段;未知票种使用中性“发票 + 号码/文件名”,不显示“无代码”或对不适用字段宣称“未识别”。
- “查看全部材料”和降级行均使用语义化 button、可见焦点、加载反馈和至少 44px 触控目标;保留可预测返回。PC 与移动端共享业务文案、票种格式化逻辑和材料浏览页;不引入 PDF.js,企微优先原生 `previewImage`,PC/普通浏览器在短时 URL 过期前主动加载有界图片包。
- “查看材料合并预览”和降级行均使用语义化 button、可见焦点、至少 44px 触控目标;超过 300ms 时按钮禁用并显示“正在准备材料预览…”。材料区提示“打开后上下滚动,连续查看全部材料”;不再提示左右滑动。
## 13. 生产验收修正:材料可读性与轻量差旅概要
### 13.1 按钮层级
- PC 现有入口已在“报销材料” `SectionPanel`,保持不变。
- 移动端将入口从 `mobile-reimbursement-items` 移到现有“报销材料” card 标题/计数之后;明细区只保留费用明细及其编辑动作。
### 13.2 合并 PDF 与确定性合同
- 生产脱敏计时基线保留为旧图片包证据;新主路径不再使用 Poppler 或图片数组。
- `createBundle` 先按显式排序取得材料并计算集合哈希,再把源字节交给受限 `worker_threads`。worker 复制全部 PDF 页、把图片嵌为页面,并返回单个 Buffer、页数和精确大小。
- 固定 PDF 元数据和序列化选项;单测对相同输入分别启动两次合并,断言 Buffer 全等。签发响应的 `fileSize` 必须等于随后 GET 的 `content-length` 和响应字节数。
- 合并缓存按报销单 ID、集合哈希、合并器版本复用;actor grant 仍独立。缓存淘汰或进程重启后旧 URL 失败,不以重新生成结果冒充旧签发字节。
- 移除旧图片 bundle 页路由与 `previewImage` 主路径;若 Poppler 无其他用途,从镜像移除 `poppler-utils`。保留单份发票/附件原件预览作为失败降级与电子签名事实源。
### 13.3 轻量差旅概要数据流
```text
草稿/驳回报销的差旅明细
-> 编辑 travelPurpose / travelLocation / travelStartDate / travelEndDate
-> PATCH 入口集中验证长度与日期顺序
-> reimbursement_forms 可空列持久化
-> 详情 API 返回轻量 travelSummary
-> PC/移动首部同一信息层级显示
-> 存在 travel category 明细时,submit precheck 拒绝缺失字段
```
- Prisma 在 `ReimbursementForm` 增加四个 nullable 列:事由、地点、开始日期、结束日期。迁移只加可空列,不回填、不改历史单、不修改审批状态;回滚时代码先回滚,列可保留至受控后续清理。
- 差旅单判定使用“任一明细的 `expenseType.category === travel`”或存在关联差旅申请,不新建另一套报销类型字典。
- 关联差旅申请时,概要可从已批准申请的 purpose、segments 城市、start/end date 读取;普通差旅报销使用自身四字段。历史数据只读降级为“description 作事由、明细最小/最大日期作起止日”,地点缺失如实显示,禁止从文件名、OCR 金额或自由文本猜地点。
- 四字段只允许申请人/受权代办在 `draft/rejected` 更改。审批中单不原地改业务事实;当前真实单如需完整地点,必须由主管退回后申请人补齐并重新提交。
- 详情页把“差旅概要”置于金额/报销人概况之后、费用明细之前;只有差旅单显示,不让普通费用报销承担空字段。
### 13.4 验证与回退
- 后端覆盖迁移、PATCH/GET/submit 字段合同、日期倒置、非差旅报销不误拦截、关联差旅申请摘要与旧单降级。
- 前端覆盖 PC/移动概要顺序、草稿编辑、审批中只读、按钮归位、加载反馈、JPEG 类型、企微有序 URL 和 375px/横屏。
- 若 JPEG/并发修复未通过目标镜像与合成火车票验收,回滚应用代码到现有 PNG 串行路径,保留原件降级;不删生产数据或回滚整库。
@@ -181,6 +181,20 @@
- [x] 手工触发 automatic backup daily 与源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次、银行回单或付款写入。
- [x] 构建耗时主要来自首次加入 Poppler 系统包后的基础层失效:系统依赖安装及 client/server/init 重新构建;migration 无待执行项,不是本次耗时主因。
## 6.13 生产真机验收缺口修正
- [x] 将移动端“查看全部材料”从报销明细区移到报销材料区;PC 保持现有正确位置。
- [x] 用 `pdf-lib` 实现确定性合并 PDF:显式排序、全部 PDF 页、图片各一页、固定元数据/保存选项、集合哈希缓存;不使用 `ignoreEncryption`。
- [x] 用 Node `worker_threads` 受限执行合并并设置材料数、源字节、页数、图片像素、输出大小和超时;不启用 Compose worker profile。
- [x] 将材料 bundle 签发改为单一 PDF URL + 准确 `name/size`,企微一次 `previewFile`、PC 浏览器原生预览;缓存缺失、加密/损坏/超限时整包失败并展开逐份原件。
- [x] 删除旧图片页 GET、共享图片浏览页和 `previewImage` 主路径;确认无其他用途后移除 Poppler 镜像依赖。
- [x] 新增报销差旅事由/地点/开始/结束四个 nullable 列和加法迁移,更新 PATCH/GET/submit 合同,不回填或改写审批中真实单。
- [x] PC/移动差旅单在首部概况与明细之间显示轻量差旅概要;草稿/驳回可编辑,审批中只读,历史单只用说明/明细日期降级且不猜地点。
- [x] 后端聚焦测试覆盖字节确定性、显式顺序、页数、准确 size、缓存缺失、加密/损坏回退、全部上限、差旅字段验证与非差旅不误拦截;前端 Playwright 覆盖按钮位置、加载文案、企微 `previewFile`、PC 打开和概要。
- [x] 运行 Prisma validate、新建空库 migration、server 聚焦/全量、client typecheck/build、无业务 25 页合并探针、Trellis/governance/process/docs/sensitive/diff 门禁。
- [x] 只读 review 迁移、提交门禁、短时合并件权限与审计。
- [ ] 提交、推送、部署和真机验收分别遵守批准门禁。
## 7. 完成条件
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
@@ -255,4 +269,10 @@
- 本步骤只完成本地实现与验证;未推送、未部署、未修改生产配置或真实业务数据,不能据此通知真人线上测试。
- 整单材料生产发布:`8a9279efceee` 已部署,`SYNC_ENV=0`,62 个 migration 无待执行项;`/ready`、external/runtime smoke、公网扫描与未登录 401 边界通过,目标容器内 `pdftoppm` 可用。
- 本次长构建主要是 Poppler 首次进入生产基础层后引起系统包安装和 client/server/init 缓存失效;首轮路径权限、无进展 clone、启动前安全护栏拒绝和本地连接中断均已如实记录,未美化为一次成功。
- 合并 PDF 本地实现:材料按发票/附件各自 `created_at,id` 显式排序,集合哈希纳入缓存键;`pdf-lib` 复制全部 PDF 页,Sharp 仅处理图片,固定 PDF 元数据与保存选项,相同输入两次生成字节完全一致。
- Worker/边界验证:Node `worker_threads` 成功合并 25 页无业务 PDF 且页数完整;加密 PDF 和损坏 PDF 均整包失败,未使用 `ignoreEncryption`,未启用 Compose worker profile;同集合并发请求只生成一次。
- 签发与审计验证:接口只返回一个短时 PDF URL、名称、精确字节数和页数;GET 复核 `content-length` 与响应字节一致,token 篡改、缓存缺失和访问审计失败均在发送字节前拒绝。
- 前端回归:R111 PC/企微双端 2/2 通过;移动入口只在报销材料区,PC 只打开一个 PDF URL,企微只调用一次 `previewFile` 且 URL/name/size 精确;PC/移动均显示合成差旅概要。
- 数据合同验证:Prisma schema 通过;63 个 migration 在临时空 PostgreSQL 16 数据库完整 apply;新迁移只增加四个 nullable 差旅概要列,未回填或改写真实审批中数据。
- 最终质量:server 全量 171 个文件、1114 项通过;client typecheck/生产 build 通过;task、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过。文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。
- 发布后运维对齐:automatic backup 三项与 WARCH3 共 4 个 service 均匹配 `8a9279e`,4 个 timer 均 enabled/active;新 automatic backup daily 与源端 watchdog 均为 `success/0`。
@@ -44,8 +44,12 @@
- 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。
- 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
- 生产可见性验收进一步确认:费用明细内再次展示并预览关联发票仍然重复,审批人希望一次进入后连续查看本单全部材料。PC 与移动端应统一采用“明细只讲费用、材料一次通览”:费用明细不再渲染发票摘要或原件入口;发票和补充附件仍保留各自后台类型、关联、权限和审计,但只在单一“报销材料”区汇总,并提供一个“查看全部材料”动作。
- 企业微信官方 `previewFile({ url, name, size })` 一次只接收一个文件,不具备 `previewImage({ urls })` 的多文件数组和左右切换合同。为满足企微内一次进入后滑动查看全部 PDF/图片,本任务将当前报销材料按稳定顺序栅格化/规范化为短时图片预览包,再调用一次 `previewImage({ current, urls })`;普通 PC 使用同一预览包连续浏览。栅格化失败时必须保留逐份原件预览的降级路径,不能让单个异常文件阻断全部材料核对。
- 企业微信官方 `previewFile({ url, name, size })` 一次只接收一个文件。为满足企微与 PC 一次进入、连续查看全部 PDF/图片,本任务将整单材料按稳定顺序派生为一个短时合并 PDF:PDF 原页全部复制,图片各占一页;企微调用一次 `previewFile`,PC 交给浏览器内置 PDF 阅读器。合并件仅供审批核对,不替代原件;任一材料加密、损坏、不支持、超限或合并失败时整包失败并回退逐份原件,绝不静默跳过材料或 PDF 页。
- 生产只读核查确认当前真实报销的 3 张票均已由腾讯 OCR 成功处理:号码、日期和金额齐全,无 OCR 错误;2 张为电子火车票,1 张为数电发票。三者按票种均无传统发票代码,电子火车票也没有销售方字段。页面显示“无代码 / 未识别销售方”属于票种展示错误,不得通过重新调用 OCR、伪造代码或把其他主体字段冒充销售方来补值。报销详情 API 应暴露已持久化 OCR 结果中的安全票种分类,前端按票种显示“电子火车票”或“数电发票”、号码、日期、金额及适用主体字段。
- 生产验收发现移动端“查看全部材料”被误放在“报销明细”区,必须归位到“报销材料”区;PC 已在正确区域,不重构其层级。
- 生产只读计时确认当前图片包串行处理约 1.8 秒,且火车票栅格图在企微中视觉上近似空白。新主路径不得继续现场栅格化 PDF;合并由 Node `worker_threads` 执行并设置材料数、源文件字节、总页数、输出大小和超时上限,避免阻塞单一 server 的事件循环。不启用 Compose worker profile,不引入 PDF.js。
- 合并产物必须可重复生成:材料查询使用显式排序键,缓存键包含报销单 ID、有序材料集合哈希与合并器版本;PDF 元数据和保存选项固定。同样输入独立生成两次必须字节完全相同。签发返回的 `fileSize` 与实际 GET 返回的缓存字节严格一致;缓存缺失或进程重启不能临时拼出另一份字节冒充原签发产物,只能拒绝并让客户端重新签发或降级原件。
- 当前真实报销为“空白报销”创建且未关联差旅申请,现有数据模型没有独立的出差事由、地点和起止日期,因此详情页无法显示完整差旅概要。轻量差旅报销必须直接持久化这四项可选业务字段;存在任一差旅费用明细时,草稿/驳回态提交前必须补齐。既有审批中单据不自动伪造或回填地点;可以只读显示报销说明和明细日期的明确降级值,需完整补齐时由主管退回后申请人修改。
## Scope
@@ -82,9 +86,13 @@
- [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。
- [x] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,用户已确认能在企微查看当前真实报销材料。
- [x] PC 与移动端费用明细不再展示或预览关联发票;`linkedItemId` 数据关系继续保留,仅不在费用明细 UI 重复呈现。
- [x] PC 与移动端单一“报销材料”区显示发票与补充附件计数,并提供一个“查看全部材料”动作;企微调用一次原生 `previewImage` 打开有序图片数组,PC 使用同一图片预览包连续浏览。
- [x] 图片预览包只包含当前有权查看的报销所绑定发票与补充附件,按“发票 → 补充附件、各自稳定创建顺序、PDF 内页序”生成;短时授权、无持久业务副本、访问审计、数量/页数/源文件字节/输出像素和输出字节上限及失败降级均有验证。
- [x] PC 与移动端单一“报销材料”区显示发票与补充附件计数,并提供一个“查看材料合并预览”动作;企微调用一次原生 `previewFile`,PC 用浏览器内置阅读器打开同一份合并 PDF。
- [x] 合并预览只包含当前有权查看的报销所绑定发票与补充附件,按“发票 → 补充附件、各自创建时间再按 ID、PDF 内原页序”生成;多页 PDF 不漏页,图片各占一页,短时授权、审计、全部上限和整包失败降级均有验证。
- [x] 票据摘要按 OCR `documentCategory` 展示正确票种与适用字段;数电发票和电子火车票不再出现误导性的“无代码”,电子火车票不再出现“未识别销售方”。当前真实票据不重跑 OCR、不改原始识别数据。
- [x] 移动端材料合并预览动作仅出现在“报销材料”区,不占用“报销明细”的主操作位。
- [x] 合并在 `worker_threads` 中受限执行;同样输入生成两次字节完全相同,签发 `fileSize` 等于 GET 实际字节,缓存丢失不返回漂移产物。
- [x] 用无业务数据的多页 PDF 与 JPG/PNG/WebP 合成样本验证页数、顺序、25 页边界和企微一次 `previewFile`;加密、损坏或不可复制 PDF 必须整包失败并回退逐份原件。
- [x] 存在差旅费用明细时,PC/移动详情首部均显示“出差事由、出差地点、起止日期”的轻量概要;草稿/驳回态可编辑且提交校验完整,审批中历史单不伪造缺失字段。
## Out of Scope
@@ -108,4 +116,4 @@
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
- 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。
- 用合成普通增值税发票、数电发票、电子火车票、PDF 与 JPG/PNG/WebP 补充附件验证 PC/移动一致性:费用明细无发票预览;票种标题不虚构缺失字段;统一材料区计数正确;一次图片预览包包含全部可读材料并保持顺序;异常/加密 PDF、超限和栅格化失败可安全降级;编辑态管理动作不回归。
- 用合成普通增值税发票、数电发票、电子火车票、多页 PDF 与 JPG/PNG/WebP 补充附件验证 PC/移动一致性:费用明细无发票预览;票种标题不虚构缺失字段;统一材料区计数正确;合并 PDF 包含全部材料并保持顺序;异常/加密 PDF、超限和合并失败可安全降级;编辑态管理动作不回归。
+33 -6
查看文件
@@ -137,6 +137,13 @@ export type ReimbursementDetail = {
reimbursementDate: string | null;
totalAmount: string | null;
description: string | null;
travelSummary: {
isTravel: boolean;
purpose: string | null;
location: string | null;
startDate: string | null;
endDate: string | null;
};
payment: {
paymentVoucherNo: string | null;
paymentDate: string | null;
@@ -276,6 +283,17 @@ export type ReimbursementCreatePayload = {
description?: string;
templateId?: string;
linkedTravelRequestId?: string;
travelPurpose?: string;
travelLocation?: string;
travelStartDate?: string;
travelEndDate?: string;
};
export type ReimbursementTravelSummaryPayload = {
travelPurpose: string | null;
travelLocation: string | null;
travelStartDate: string | null;
travelEndDate: string | null;
};
export type ReimbursementCreateResponse = {
@@ -459,6 +477,16 @@ export async function deleteReimbursement(reimbursementId: string) {
});
}
export async function updateReimbursementTravelSummary(
reimbursementId: string,
payload: ReimbursementTravelSummaryPayload,
) {
return apiRequestData<{ travelSummary: Omit<ReimbursementDetail["travelSummary"], "isTravel"> }>(
`/api/reimbursements/${reimbursementId}`,
{ method: "PATCH", body: JSON.stringify(payload) },
);
}
export async function createReimbursementItem(
reimbursementId: string,
payload: ReimbursementItemPayload,
@@ -549,12 +577,11 @@ export async function fetchReimbursementAttachmentPreviewUrl(
export type ReimbursementMaterialPreviewBundle = {
expiresAt: string;
pages: Array<{
url: string;
materialType: "invoice" | "attachment";
materialIndex: number;
pageNumber: number;
}>;
signedUrl: string;
originalName: string;
mimeType: "application/pdf";
fileSize: number;
pageCount: number;
};
export async function fetchReimbursementMaterialPreviewBundle(
@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateReimbursementTravelSummary, type ReimbursementDetail, type ReimbursementTravelSummaryPayload } from "../../api/reimbursements";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { isTravelReimbursement } from "../../lib/reimbursementTravel";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
type Props = { detail: ReimbursementDetail; reimbursementId: string; editable: boolean; mobile?: boolean };
const emptyForm: ReimbursementTravelSummaryPayload = { travelPurpose: null, travelLocation: null, travelStartDate: null, travelEndDate: null };
export function ReimbursementTravelSummary({ detail, reimbursementId, editable, mobile = false }: Props) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(emptyForm);
const isTravel = isTravelReimbursement(detail);
useEffect(() => {
setForm({
travelPurpose: detail.travelSummary?.purpose ?? null,
travelLocation: detail.travelSummary?.location ?? null,
travelStartDate: detail.travelSummary?.startDate ?? null,
travelEndDate: detail.travelSummary?.endDate ?? null,
});
}, [detail.travelSummary]);
const mutation = useMutation({
mutationFn: () => updateReimbursementTravelSummary(reimbursementId, form),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
setEditing(false);
toast.success("差旅概要已保存");
},
onError: (error) => toast.error(resolveErrorMessage(error) || "差旅概要保存失败"),
});
if (!isTravel) return null;
const summary = detail.travelSummary ?? { purpose: null, location: null, startDate: null, endDate: null };
return (
<section className={mobile ? "mobile-card" : "rounded-xl border border-zinc-200 bg-white p-5"} data-testid={mobile ? "mobile-reimbursement-travel-summary" : "reimbursement-travel-summary"}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">差旅概要</h2>
{editable && !editing ? <Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>编辑</Button> : null}
</div>
{editing ? (
<div className="mt-3 space-y-3">
<label className="block space-y-1 text-sm text-zinc-700"><span>出差事由</span><Input value={form.travelPurpose ?? ""} onChange={(event) => setForm((current) => ({ ...current, travelPurpose: event.target.value || null }))} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>出差地点</span><Input value={form.travelLocation ?? ""} onChange={(event) => setForm((current) => ({ ...current, travelLocation: event.target.value || null }))} /></label>
<div className="grid grid-cols-2 gap-3">
<label className="block space-y-1 text-sm text-zinc-700"><span>开始日期</span><Input type="date" value={form.travelStartDate ?? ""} onChange={(event) => setForm((current) => ({ ...current, travelStartDate: event.target.value || null }))} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>结束日期</span><Input type="date" value={form.travelEndDate ?? ""} onChange={(event) => setForm((current) => ({ ...current, travelEndDate: event.target.value || null }))} /></label>
</div>
<div className="grid grid-cols-2 gap-3">
<Button type="button" disabled={mutation.isPending} onClick={() => mutation.mutate()}>{mutation.isPending ? "保存中…" : "保存"}</Button>
<Button type="button" variant="secondary" disabled={mutation.isPending} onClick={() => setEditing(false)}>取消</Button>
</div>
</div>
) : (
<dl className="mt-3 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div><dt className="text-xs text-zinc-500">出差事由</dt><dd className="mt-1 text-zinc-900">{summary.purpose || "未填写"}</dd></div>
<div><dt className="text-xs text-zinc-500">出差地点</dt><dd className="mt-1 text-zinc-900">{summary.location || "未填写"}</dd></div>
<div><dt className="text-xs text-zinc-500">开始日期</dt><dd className="mt-1 text-zinc-900">{summary.startDate || "未填写"}</dd></div>
<div><dt className="text-xs text-zinc-500">结束日期</dt><dd className="mt-1 text-zinc-900">{summary.endDate || "未填写"}</dd></div>
</dl>
)}
</section>
);
}
+11
查看文件
@@ -0,0 +1,11 @@
import type { ReimbursementDetail } from "../api/reimbursements";
export function isTravelReimbursement(detail: ReimbursementDetail) {
return Boolean(detail.travelSummary?.isTravel || detail.linkedTravelRequestId || detail.items.some((item) => item.expenseType?.category === "travel"));
}
export function hasCompleteTravelSummary(detail: ReimbursementDetail) {
if (!isTravelReimbursement(detail)) return true;
const summary = detail.travelSummary;
return Boolean(summary?.purpose && summary.location && summary.startDate && summary.endDate);
}
+1 -14
查看文件
@@ -24,7 +24,7 @@ async function configuredWecomSdk() {
}
ww.register({
corpId: config.corpId,
jsApiList: ["previewFile", "previewImage"],
jsApiList: ["previewFile"],
async getConfigSignature(url) {
const signed = await fetchWecomJsSdkSignature(url || currentPageUrl());
return {
@@ -58,19 +58,6 @@ export async function previewFileInWecom(file: NativePreviewFile) {
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) {
window.open(url, "_blank", "noopener,noreferrer");
}
@@ -42,6 +42,7 @@ import { useAttachmentDownloadReasonDialog } from "../components/common/useAttac
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
@@ -64,6 +65,7 @@ import {
} from "../lib/invoiceStatus";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
import { formatDisplayDateTime } from "../lib/displayFormat";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { TEST_IDS } from "../lib/testIds";
@@ -71,7 +73,7 @@ import { withUploadTimeout } from "../lib/uploadTimeout";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { cn } from "../lib/utils";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
type ItemFormState = {
mode: "create" | "edit";
@@ -743,15 +745,13 @@ export function MobileReimbursementDetailPage() {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewImagesInWecom(bundle.pages.map((page) => page.url));
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
return;
} catch {
toast.error("企业微信滑动预览未打开,已切换到页面预览");
toast.error("企业微信材料预览未打开,已切换到浏览器预览");
}
}
navigate(`/mobile/reimbursements/${reimbursementId}/materials`, {
state: { bundle, from: `/mobile/reimbursements/${reimbursementId}` },
});
openFileInBrowser(bundle.signedUrl);
},
onError: (error) => {
const message = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件";
@@ -777,6 +777,12 @@ export function MobileReimbursementDetailPage() {
toast.error(message);
return;
}
if (!hasCompleteTravelSummary(detail)) {
const message = "请先补全出差事由、地点和起止日期";
setActionError(message);
toast.error(message);
return;
}
if (!detail.invoices.length) {
const message = "请先添加至少 1 张发票";
setActionError(message);
@@ -931,6 +937,13 @@ export function MobileReimbursementDetailPage() {
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
</section>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEdit && isParticipant)}
mobile
/>
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
@@ -941,24 +954,6 @@ export function MobileReimbursementDetailPage() {
) : null}
</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 ? (
<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}
@@ -1074,6 +1069,27 @@ export function MobileReimbursementDetailPage() {
</div>
</div>
{detail.invoices.length || (detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2">
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
<Button
className="min-h-12 w-full"
data-testid="mobile-reimbursement-materials-open-all"
type="button"
disabled={materialPreviewMutation.isPending}
onClick={() => void handleOpenAllMaterials()}
>
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
</Button>
</div>
) : 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}
{canOpenInvoiceDialog || canManageAttachments ? (
<div className="mobile-two-action-grid mt-3">
{canOpenInvoiceDialog ? (
+20 -7
查看文件
@@ -35,7 +35,8 @@ import {
} from "../api/invoicePool";
import { toast } from "sonner";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
@@ -45,6 +46,7 @@ import { AttachmentGovernanceBadges } from "../components/common/AttachmentGover
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import {
@@ -736,15 +738,13 @@ export function ReimbursementDetailPage() {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewImagesInWecom(bundle.pages.map((page) => page.url));
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
return;
} catch {
toast.error("企业微信滑动预览未打开,已切换到页面预览");
toast.error("企业微信材料预览未打开,已切换到浏览器预览");
}
}
navigate(`/reimbursements/${reimbursementId}/materials`, {
state: { bundle, from: `/reimbursements/${reimbursementId}` },
});
openFileInBrowser(bundle.signedUrl);
},
onError: (error) => {
const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件");
@@ -891,6 +891,12 @@ export function ReimbursementDetailPage() {
try {
setActionError("");
if (!hasCompleteTravelSummary(detail)) {
const message = "请先补全出差事由、地点和起止日期";
setActionError(message);
toast.error(message);
return;
}
if (detail.invoices.length < 1) {
setActionError(missingInvoiceSubmitMessage);
toast.error(missingInvoiceSubmitMessage);
@@ -1285,6 +1291,12 @@ export function ReimbursementDetailPage() {
) : null}
</SectionPanel>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEditItems && isReimbursementParticipant)}
/>
<SectionPanel title="报销明细">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
@@ -1436,6 +1448,7 @@ export function ReimbursementDetailPage() {
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-zinc-600">
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
<span className="mt-1 block text-xs text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</span>
</p>
<div className="flex flex-wrap gap-2">
{detail.invoices.length || (detail.attachments ?? []).length ? (
@@ -1445,7 +1458,7 @@ export function ReimbursementDetailPage() {
disabled={materialPreviewMutation.isPending}
onClick={() => void handleOpenAllMaterials()}
>
{materialPreviewMutation.isPending ? "生成预览中..." : "查看全部材料"}
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
</Button>
) : null}
{canOpenInvoiceBindDialog ? (
@@ -1,96 +0,0 @@
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,7 +62,6 @@ import {
ReimbursementDetailPage,
ReimbursementDashboardPage,
ReimbursementPrintPage,
ReimbursementMaterialsPage,
ReimbursementTemplatesPage,
ReimbursementsPage,
TravelRequestsPage,
@@ -233,15 +232,6 @@ const routeElements = (
}
/>
<Route
path="reimbursements/:reimbursementId/materials"
element={
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
{withRouteFallback(<ReimbursementMaterialsPage />)}
</RequirePermission>
}
/>
<Route
path="leave-requests"
element={
@@ -398,15 +388,6 @@ const routeElements = (
}
/>
<Route
path="mobile/reimbursements/:reimbursementId/materials"
element={
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
{withRouteFallback(<ReimbursementMaterialsPage />)}
</RequirePermission>
}
/>
<Route
path="mobile/leave"
element={
@@ -1 +0,0 @@
export { ReimbursementMaterialsPage as default } from "../../pages/ReimbursementMaterialsPage";
-1
查看文件
@@ -51,7 +51,6 @@ export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"))
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
export const ReimbursementPrintPage = lazy(() => import("./ReimbursementPrintPage"));
export const ReimbursementMaterialsPage = lazy(() => import("./ReimbursementMaterialsPage"));
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemplatesPage"));
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
@@ -180,6 +180,13 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
reimbursementDate: "2026-05-08",
totalAmount: "1683.00",
description: "客户招待报销",
travelSummary: {
isTravel: true,
purpose: "合成差旅概要",
location: "测试地点",
startDate: "2026-05-07",
endDate: "2026-05-08",
},
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
@@ -358,26 +365,19 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
}
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 });
await fulfillJson(route, {
expiresAt: "2026-07-31T10:02:00.000Z",
signedUrl: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/materials/preview/77777777-7777-4777-8777-777777777777?previewToken=merged-pdf`,
originalName: "reimbursement-materials-preview.pdf",
mimeType: "application/pdf",
fileSize: 8192,
pageCount: state.invoices.length + state.attachments.length,
});
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") });
await route.fulfill({ status: 200, contentType: "application/pdf", body: Buffer.from("%PDF synthetic-preview") });
return;
}
@@ -658,14 +658,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
});
await page.getByRole("button", { name: "上传附件" }).click();
await expect(page.getByText("付款截图.png")).toBeVisible();
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("合成差旅概要");
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("测试地点");
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
await page.getByTestId("reimbursement-materials-open-all").click();
await page.waitForURL(new RegExp(`/reimbursements/${reimbursementId}/materials$`));
await expect(page.getByTestId("reimbursement-material-viewer")).toContainText("共 3 页");
await expect(page.getByRole("img", { name: /预览/ })).toHaveCount(3);
await page.getByRole("button", { name: "返回报销单" }).click();
await page.waitForURL(new RegExp(`/reimbursements/${reimbursementId}$`));
expect(await page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(0);
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1);
expect(await page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0])).toContain("previewToken=merged-pdf");
const bodyText = await page.locator("body").innerText();
expect(bodyText).not.toContain("绑定整单");
@@ -783,6 +781,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
});
await mobile.page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: "上传附件" }).click();
await expect(mobile.page.getByText("付款截图.png")).toBeVisible();
await expect(mobile.page.getByTestId("mobile-reimbursement-travel-summary")).toContainText("合成差旅概要");
await expect(mobile.page.getByTestId("mobile-reimbursement-travel-summary")).toContainText("测试地点");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
const openAllButton = mobile.page.getByTestId("mobile-reimbursement-materials-open-all");
const openAllTarget = await openAllButton.boundingBox();
@@ -790,19 +790,16 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await openAllButton.click();
await expect.poll(() => mobile.page.evaluate(() => (
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
).__r111BridgeCalls.filter((call) => call.name === "imagePreview").length)).toBe(1);
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(1);
const nativePreviewCalls = await mobile.page.evaluate(() => (
window as typeof window & { __r111BridgeCalls: Array<{ name: string; params: Record<string, unknown> }> }
).__r111BridgeCalls.filter((call) => call.name === "imagePreview"));
).__r111BridgeCalls.filter((call) => call.name === "previewFile"));
expect(nativePreviewCalls).toEqual([
expect.objectContaining({
params: expect.objectContaining({
current: expect.stringContaining(`/materials/preview/`),
urls: expect.arrayContaining([
expect.stringContaining("previewToken=invoice-0"),
expect.stringContaining("previewToken=invoice-1"),
expect.stringContaining("previewToken=attachment-0"),
]),
url: expect.stringContaining("previewToken=merged-pdf"),
name: "reimbursement-materials-preview.pdf",
size: 8192,
}),
}),
]);
+1 -1
查看文件
@@ -7,7 +7,7 @@ ENV VITE_BUILD_SHA=$VITE_BUILD_SHA
WORKDIR /app
RUN apk add --no-cache bash curl poppler-utils
RUN apk add --no-cache bash curl
FROM base AS client_deps
COPY client/package.json client/package-lock.json ./client/
+31
查看文件
@@ -34,6 +34,7 @@
"node-cron": "^4.2.1",
"nodemailer": "^9.0.3",
"openid-client": "^6.8.2",
"pdf-lib": "1.17.1",
"pg": "^8.18.0",
"pg-boss": "^10.3.0",
"prisma": "^7.8.0",
@@ -3412,6 +3413,24 @@
"@opentelemetry/api": "^1.1.0"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -7562,6 +7581,18 @@
"node": ">= 14.16"
}
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
+1
查看文件
@@ -77,6 +77,7 @@
"node-cron": "^4.2.1",
"nodemailer": "^9.0.3",
"openid-client": "^6.8.2",
"pdf-lib": "1.17.1",
"pg": "^8.18.0",
"pg-boss": "^10.3.0",
"prisma": "^7.8.0",
@@ -0,0 +1,5 @@
ALTER TABLE "reimbursement_forms"
ADD COLUMN "travel_purpose" VARCHAR(200),
ADD COLUMN "travel_location" VARCHAR(200),
ADD COLUMN "travel_start_date" DATE,
ADD COLUMN "travel_end_date" DATE;
+4
查看文件
@@ -1407,6 +1407,10 @@ model ReimbursementForm {
paid_by String? @db.Uuid
project_id String? @db.Uuid
linked_travel_request_id String? @db.Uuid
travel_purpose String? @db.VarChar(200)
travel_location String? @db.VarChar(200)
travel_start_date DateTime? @db.Date
travel_end_date DateTime? @db.Date
created_at DateTime @default(now())
created_by String? @db.Uuid
+138 -36
查看文件
@@ -75,6 +75,14 @@ const REIMBURSEMENT_LIST_QUERY_SCHEMA = z.object({
const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
description: z.string().trim().max(500).nullable().optional(),
travelPurpose: z.string().trim().max(200).nullable().optional(),
travelLocation: z.string().trim().max(200).nullable().optional(),
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
}).superRefine((value, ctx) => {
if (value.travelStartDate && value.travelEndDate && value.travelStartDate > value.travelEndDate) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid_date_range", path: ["travelEndDate"] });
}
});
const REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA = z
@@ -86,7 +94,6 @@ const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
.object({ reason: z.string().trim().max(200).optional() })
.strict();
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
const REIMBURSEMENT_MATERIAL_PREVIEW_PAGE_SCHEMA = z.coerce.number().int().min(0).max(39);
function resolveSignedFileOrigin(config, request) {
const configured = String(config.appOrigin ?? "")
@@ -123,9 +130,16 @@ const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
description: z.string().max(500).optional(),
templateId: z.string().uuid().optional(),
linkedTravelRequestId: z.string().uuid().optional(),
travelPurpose: z.string().trim().max(200).optional(),
travelLocation: z.string().trim().max(200).optional(),
travelStartDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
travelEndDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.travelStartDate && value.travelEndDate && value.travelStartDate > value.travelEndDate) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "invalid_date_range", path: ["travelEndDate"] });
}
if (value.templateId && value.linkedTravelRequestId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -1540,6 +1554,18 @@ async function reimbursementRoutes(fastify) {
timeZone: fastify.config.appTimezone,
});
const linkedTravelCities = linkedTravelRequestRecord
? [...new Set(linkedTravelRequestRecord.segments.flatMap((segment) => [segment.from_city, segment.to_city]).filter(Boolean))]
: [];
const travelPurpose = body.travelPurpose?.trim() || linkedTravelRequestRecord?.purpose || null;
const travelLocation = body.travelLocation?.trim() || linkedTravelCities.join("、") || null;
const travelStartDate = body.travelStartDate
? parseDateOnlyString(body.travelStartDate)
: linkedTravelRequestRecord?.start_date ?? null;
const travelEndDate = body.travelEndDate
? parseDateOnlyString(body.travelEndDate)
: linkedTravelRequestRecord?.end_date ?? null;
const created = await tx.reimbursementForm.create({
data: {
form_number: issued.documentNumber,
@@ -1549,6 +1575,10 @@ async function reimbursementRoutes(fastify) {
reimbursement_date: parseDateOnlyString(reimbursementDate),
description: resolvedDescription,
linked_travel_request_id: linkedTravelRequestRecord?.id ?? null,
travel_purpose: travelPurpose,
travel_location: travelLocation,
travel_start_date: travelStartDate,
travel_end_date: travelEndDate,
created_by: request.employee.id,
updated_by: request.employee.id,
},
@@ -1562,6 +1592,10 @@ async function reimbursementRoutes(fastify) {
owner_id: true,
submitter_id: true,
linked_travel_request_id: true,
travel_purpose: true,
travel_location: true,
travel_start_date: true,
travel_end_date: true,
travel_request: {
select: {
id: true,
@@ -1658,6 +1692,12 @@ async function reimbursementRoutes(fastify) {
totalAmount: toMoneyString(reimbursement.total_amount),
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
travelSummary: {
purpose: reimbursement.travel_purpose ?? null,
location: reimbursement.travel_location ?? null,
startDate: toDateOnlyString(reimbursement.travel_start_date),
endDate: toDateOnlyString(reimbursement.travel_end_date),
},
items: Array.isArray(reimbursement.items) ? reimbursement.items.map(mapCreatedReimbursementItem) : [],
});
},
@@ -2061,6 +2101,10 @@ async function reimbursementRoutes(fastify) {
paid_at: true,
project_id: true,
linked_travel_request_id: true,
travel_purpose: true,
travel_location: true,
travel_start_date: true,
travel_end_date: true,
paid_by_employee: {
select: {
id: true,
@@ -2261,6 +2305,12 @@ async function reimbursementRoutes(fastify) {
},
orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
const travelItems = reimbursement.items.filter((item) => item.expense_type?.category === "travel");
const travelItemDates = travelItems
.map((item) => item.expense_date)
.filter(Boolean)
.sort((left, right) => left.getTime() - right.getTime());
const isTravelReimbursement = Boolean(reimbursement.linked_travel_request_id || travelItems.length);
return sendData(reply, request, {
id: reimbursement.id,
@@ -2297,6 +2347,13 @@ async function reimbursementRoutes(fastify) {
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
travelSummary: {
isTravel: isTravelReimbursement,
purpose: reimbursement.travel_purpose ?? (isTravelReimbursement ? reimbursement.description : null),
location: reimbursement.travel_location ?? null,
startDate: toDateOnlyString(reimbursement.travel_start_date ?? travelItemDates[0] ?? null),
endDate: toDateOnlyString(reimbursement.travel_end_date ?? travelItemDates.at(-1) ?? null),
},
owner: reimbursement.owner,
submitter: reimbursement.submitter,
items: reimbursement.items.map((item) => ({
@@ -2357,7 +2414,7 @@ async function reimbursementRoutes(fastify) {
const updated = await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
SELECT id, owner_id, submitter_id, department_id, status, travel_start_date, travel_end_date
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
@@ -2392,6 +2449,20 @@ async function reimbursementRoutes(fastify) {
const nextDescription =
body.description === undefined ? undefined : body.description?.trim() || null;
const nextTravelStartDate = body.travelStartDate === undefined
? reimbursement.travel_start_date
: body.travelStartDate ? parseDateOnlyString(body.travelStartDate) : null;
const nextTravelEndDate = body.travelEndDate === undefined
? reimbursement.travel_end_date
: body.travelEndDate ? parseDateOnlyString(body.travelEndDate) : null;
if (nextTravelStartDate && nextTravelEndDate && nextTravelStartDate > nextTravelEndDate) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { travelEndDate: "invalid_date_range" },
});
}
return tx.reimbursementForm.update({
where: { id: reimbursementId },
@@ -2400,6 +2471,10 @@ async function reimbursementRoutes(fastify) {
? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) }
: {}),
...(body.description !== undefined ? { description: nextDescription } : {}),
...(body.travelPurpose !== undefined ? { travel_purpose: body.travelPurpose?.trim() || null } : {}),
...(body.travelLocation !== undefined ? { travel_location: body.travelLocation?.trim() || null } : {}),
...(body.travelStartDate !== undefined ? { travel_start_date: nextTravelStartDate } : {}),
...(body.travelEndDate !== undefined ? { travel_end_date: nextTravelEndDate } : {}),
updated_by: request.employee.id,
},
select: {
@@ -2408,6 +2483,10 @@ async function reimbursementRoutes(fastify) {
reimbursement_date: true,
description: true,
total_amount: true,
travel_purpose: true,
travel_location: true,
travel_start_date: true,
travel_end_date: true,
},
});
});
@@ -2418,6 +2497,12 @@ async function reimbursementRoutes(fastify) {
reimbursementDate: updated.reimbursement_date.toISOString().slice(0, 10),
description: updated.description,
totalAmount: toMoneyString(updated.total_amount),
travelSummary: {
purpose: updated.travel_purpose,
location: updated.travel_location,
startDate: toDateOnlyString(updated.travel_start_date),
endDate: toDateOnlyString(updated.travel_end_date),
},
});
},
);
@@ -3003,6 +3088,7 @@ async function reimbursementRoutes(fastify) {
storage_path: true,
mime_type: true,
file_size: true,
file_sha256: true,
},
},
attachments: {
@@ -3012,6 +3098,7 @@ async function reimbursementRoutes(fastify) {
storage_path: true,
mime_type: true,
file_size: true,
file_sha256: true,
governance_status: true,
sensitivity_level: true,
attachment_type: true,
@@ -3043,6 +3130,7 @@ async function reimbursementRoutes(fastify) {
storagePath: invoice.storage_path,
mimeType: invoice.mime_type,
fileSize: invoice.file_size,
fileSha256: invoice.file_sha256,
})),
...reimbursement.attachments.map((attachment, index) => ({
id: attachment.id,
@@ -3051,6 +3139,7 @@ async function reimbursementRoutes(fastify) {
storagePath: attachment.storage_path,
mimeType: attachment.mime_type,
fileSize: attachment.file_size,
fileSha256: attachment.file_sha256,
})),
];
if (materials.some((material) => !ATTACHMENT_MIME_ALLOWLIST.has(material.mimeType))) {
@@ -3079,22 +3168,14 @@ async function reimbursementRoutes(fastify) {
}
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,
};
});
const previewToken = signPayload({
purpose: "reimbursement_material_preview",
reimbursementId,
bundleId: created.bundleId,
actorId: request.employee.id,
exp: expiresAt,
}, fastify.config.jwtSecret);
const signedUrl = `${origin}/api/reimbursements/${reimbursementId}/materials/preview/${created.bundleId}?previewToken=${encodeURIComponent(previewToken)}`;
try {
await fastify.prisma.auditLog.create({
@@ -3106,7 +3187,7 @@ async function reimbursementRoutes(fastify) {
extra: {
invoiceCount: reimbursement.invoice_pool_items.length,
attachmentCount: reimbursement.attachments.length,
pageCount: pages.length,
pageCount: created.bundle.pageCount,
reason,
expiresAt,
},
@@ -3119,30 +3200,31 @@ async function reimbursementRoutes(fastify) {
return sendData(reply, request, {
expiresAt: new Date(expiresAt).toISOString(),
pages,
signedUrl,
originalName: "reimbursement-materials-preview.pdf",
mimeType: "application/pdf",
fileSize: created.fileSize,
pageCount: created.bundle.pageCount,
});
},
);
fastify.get(
"/reimbursements/:id/materials/preview/:bundleId/:pageIndex",
"/reimbursements/:id/materials/preview/:bundleId",
{},
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
@@ -3150,10 +3232,7 @@ async function reimbursementRoutes(fastify) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const resolved = materialPreviewStore.getPage({
bundleId: parsedBundleId.data,
pageIndex: parsedPageIndex.data,
});
const resolved = materialPreviewStore.getBundle({ bundleId: parsedBundleId.data });
if (
!resolved ||
resolved.bundle.reimbursementId !== reimbursementId ||
@@ -3170,19 +3249,17 @@ async function reimbursementRoutes(fastify) {
targetType: "reimbursement",
targetId: reimbursementId,
extra: {
materialId: resolved.page.materialId,
materialType: resolved.page.materialType,
materialIndex: resolved.page.materialIndex,
pageNumber: resolved.page.pageNumber,
pageCount: resolved.bundle.pageCount,
},
}),
});
reply.type("image/png");
reply.type("application/pdf");
reply.header("cache-control", "private, no-store");
reply.header("referrer-policy", "no-referrer");
reply.header("content-disposition", "inline");
return reply.send(resolved.page.buffer);
reply.header("content-disposition", 'inline; filename="reimbursement-materials-preview.pdf"');
reply.header("content-length", String(resolved.artifact.buffer.length));
return reply.send(resolved.artifact.buffer);
},
);
@@ -3526,7 +3603,8 @@ async function reimbursementRoutes(fastify) {
const submitResult = await fastify.prisma.$transaction(async (tx) => {
let budgetCheck = null;
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, department_id, reimbursement_date, status, form_number, total_amount
SELECT id, owner_id, submitter_id, department_id, reimbursement_date, status, form_number, total_amount,
linked_travel_request_id, travel_purpose, travel_location, travel_start_date, travel_end_date
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
@@ -3554,6 +3632,30 @@ async function reimbursementRoutes(fastify) {
ensureNonNegativeAmount(reimbursement.total_amount);
const travelItem = await tx.reimbursementItem.findFirst({
where: {
reimbursement_id: reimbursementId,
expense_type: { category: "travel" },
},
select: { id: true },
});
if (travelItem || reimbursement.linked_travel_request_id) {
const missingTravelFields = [
["travelPurpose", reimbursement.travel_purpose],
["travelLocation", reimbursement.travel_location],
["travelStartDate", reimbursement.travel_start_date],
["travelEndDate", reimbursement.travel_end_date],
].filter(([, value]) => !value).map(([field]) => field);
if (missingTravelFields.length) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { travelSummary: "incomplete", missingFields: missingTravelFields },
});
}
}
const activeInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "reimbursement",
@@ -0,0 +1,100 @@
const crypto = require("node:crypto");
const { PDFDocument } = require("pdf-lib");
const sharp = require("sharp");
const FIXED_PDF_DATE = new Date("2000-01-01T00:00:00.000Z");
const PDF_CREATOR = "hyb-oa";
const PDF_PRODUCER = "hyb-oa reimbursement material merger";
function materialCollectionHash(materials, mergerVersion) {
const hash = crypto.createHash("sha256");
hash.update(String(mergerVersion));
for (const material of materials) {
hash.update("\0");
hash.update([
material.materialType,
material.id,
material.fileSha256 ?? "",
material.mimeType,
String(material.fileSize),
].join("\u001f"));
}
return hash.digest("hex");
}
async function normalizeImageForPdf(source, limits) {
const image = sharp(source, { failOn: "error", limitInputPixels: limits.maxImagePixels }).rotate();
const metadata = await image.metadata();
if (!metadata.width || !metadata.height || metadata.width * metadata.height > limits.maxImagePixels) {
throw new Error("material_preview_image_invalid");
}
const normalized = await image
.flatten({ background: "#ffffff" })
.jpeg({ quality: 90, chromaSubsampling: "4:4:4", mozjpeg: false })
.toBuffer();
return { buffer: normalized, width: metadata.width, height: metadata.height };
}
function fitPage(width, height) {
const maxSide = 842;
const scale = Math.min(1, maxSide / Math.max(width, height));
return { width: Math.max(1, width * scale), height: Math.max(1, height * scale) };
}
async function mergeMaterialsToPdf(materials, limits) {
const output = await PDFDocument.create();
output.setCreationDate(FIXED_PDF_DATE);
output.setModificationDate(FIXED_PDF_DATE);
output.setCreator(PDF_CREATOR);
output.setProducer(PDF_PRODUCER);
output.setTitle("Reimbursement Material Preview");
output.setSubject("Derived preview; originals retained separately");
let pageCount = 0;
for (const material of materials) {
const source = Buffer.from(material.source);
if (material.mimeType === "application/pdf") {
let input;
try {
input = await PDFDocument.load(source, { updateMetadata: false });
} catch {
throw new Error("material_preview_pdf_invalid");
}
const indices = input.getPageIndices();
if (!indices.length || pageCount + indices.length > limits.maxPages) {
throw new Error(indices.length ? "material_preview_page_limit" : "material_preview_pdf_empty");
}
const pages = await output.copyPages(input, indices);
pages.forEach((page) => output.addPage(page));
pageCount += pages.length;
continue;
}
if (pageCount + 1 > limits.maxPages) throw new Error("material_preview_page_limit");
let normalized;
try {
normalized = await normalizeImageForPdf(source, limits);
} catch (error) {
if (error?.message === "material_preview_image_invalid") throw error;
throw new Error("material_preview_image_invalid");
}
const embedded = await output.embedJpg(normalized.buffer);
const size = fitPage(normalized.width, normalized.height);
const page = output.addPage([size.width, size.height]);
page.drawImage(embedded, { x: 0, y: 0, width: size.width, height: size.height });
pageCount += 1;
}
if (!pageCount) throw new Error("material_preview_empty");
const bytes = await output.save({
useObjectStreams: true,
addDefaultPage: false,
objectsPerTick: 50,
updateFieldAppearances: false,
});
const buffer = Buffer.from(bytes);
if (buffer.length > limits.maxOutputBytes) throw new Error("material_preview_output_size_limit");
return { buffer, pageCount };
}
module.exports = { materialCollectionHash, mergeMaterialsToPdf };
@@ -1,23 +1,17 @@
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 { Worker } = require("node:worker_threads");
const { materialCollectionHash } = require("./reimbursementMaterialPdf");
const MATERIAL_PREVIEW_MERGER_VERSION = "pdf-v1";
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,
maxImagePixels: 40_000_000,
maxOutputBytes: 50 * 1024 * 1024,
maxCacheBytes: 100 * 1024 * 1024,
mergeTimeoutMs: 20_000,
});
class MaterialPreviewError extends Error {
@@ -28,183 +22,137 @@ class MaterialPreviewError extends Error {
}
}
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");
function runMergeWorker(materials, limits) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.join(__dirname, "reimbursementMaterialPreviewWorker.js"), {
workerData: { materials, limits },
});
const timeout = setTimeout(() => {
worker.terminate().catch(() => {});
reject(new MaterialPreviewError("material_preview_merge_timeout"));
}, limits.mergeTimeoutMs);
timeout.unref?.();
worker.once("message", (message) => {
clearTimeout(timeout);
if (!message?.ok) {
reject(new MaterialPreviewError(message?.code || "material_preview_merge_failed"));
return;
}
if (error?.killed || error?.signal) {
throw new MaterialPreviewError("material_preview_raster_timeout");
resolve({ buffer: Buffer.from(message.bytes), pageCount: message.pageCount });
});
worker.once("error", () => {
clearTimeout(timeout);
reject(new MaterialPreviewError("material_preview_merge_failed"));
});
worker.once("exit", (code) => {
if (code !== 0) {
clearTimeout(timeout);
reject(new MaterialPreviewError("material_preview_merge_failed"));
}
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 mergePdf = options.mergePdf ?? ((materials) => runMergeWorker(materials, limits));
const now = options.now ?? (() => Date.now());
const randomUUID = options.randomUUID ?? (() => crypto.randomUUID());
const bundles = new Map();
const grants = new Map();
const artifacts = new Map();
const pendingArtifacts = new Map();
let cachedBytes = 0;
function deleteBundle(bundleId) {
const bundle = bundles.get(bundleId);
if (!bundle) return;
cachedBytes -= bundle.outputBytes;
bundles.delete(bundleId);
const grant = grants.get(bundleId);
if (!grant) return;
grants.delete(bundleId);
const stillUsed = [...grants.values()].some((candidate) => candidate.cacheKey === grant.cacheKey);
if (!stillUsed) {
const artifact = artifacts.get(grant.cacheKey);
if (artifact) cachedBytes -= artifact.buffer.length;
artifacts.delete(grant.cacheKey);
}
}
function pruneExpired() {
const current = now();
for (const [bundleId, bundle] of bundles) {
if (bundle.expiresAt <= current) deleteBundle(bundleId);
for (const [bundleId, grant] of grants) {
if (grant.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) {
if (!Array.isArray(materials) || !materials.length) throw new MaterialPreviewError("material_preview_empty");
if (materials.length > limits.maxMaterials) throw new MaterialPreviewError("material_preview_material_limit");
const declaredBytes = materials.reduce((sum, material) => sum + Number(material.fileSize ?? 0), 0);
if (!Number.isSafeInteger(declaredBytes) || declaredBytes <= 0 || declaredBytes > 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");
const collectionHash = materialCollectionHash(materials, MATERIAL_PREVIEW_MERGER_VERSION);
const cacheKey = `${reimbursementId}:${collectionHash}:${MATERIAL_PREVIEW_MERGER_VERSION}`;
let artifact = artifacts.get(cacheKey);
if (!artifact) {
let pending = pendingArtifacts.get(cacheKey);
if (!pending) pending = (async () => {
const loaded = [];
let actualBytes = 0;
for (const material of materials) {
const source = await readSource(material);
if (!Buffer.isBuffer(source) || !source.length) throw new MaterialPreviewError("material_preview_source_missing");
actualBytes += source.length;
if (actualBytes > limits.maxSourceBytes) throw new MaterialPreviewError("material_preview_source_size_limit");
loaded.push({ ...material, source });
}
actualSourceBytes += source.length;
if (actualSourceBytes > limits.maxSourceBytes) {
throw new MaterialPreviewError("material_preview_source_size_limit");
let generated;
try {
generated = await mergePdf(loaded);
} catch (error) {
if (error instanceof MaterialPreviewError) throw error;
throw new MaterialPreviewError(error?.message || "material_preview_merge_failed");
}
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");
if (!Buffer.isBuffer(generated?.buffer) || !generated.buffer.length) {
throw new MaterialPreviewError("material_preview_merge_failed");
}
if (generated.buffer.length > limits.maxOutputBytes) throw new MaterialPreviewError("material_preview_output_size_limit");
if (cachedBytes + generated.buffer.length > limits.maxCacheBytes) {
pruneExpired();
if (cachedBytes + generated.buffer.length > limits.maxCacheBytes) {
throw new MaterialPreviewError("material_preview_cache_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");
}
artifacts.set(cacheKey, generated);
cachedBytes += generated.buffer.length;
return generated;
})().finally(() => pendingArtifacts.delete(cacheKey));
pendingArtifacts.set(cacheKey, pending);
artifact = await pending;
}
const bundleId = randomUUID();
const bundle = { actorId, reimbursementId, expiresAt, outputBytes, pages };
bundles.set(bundleId, bundle);
cachedBytes += outputBytes;
const bundle = { actorId, reimbursementId, expiresAt, cacheKey, collectionHash, pageCount: artifact.pageCount };
grants.set(bundleId, bundle);
const timer = setTimeout(() => deleteBundle(bundleId), Math.max(1, expiresAt - now()));
timer.unref?.();
return { bundleId, bundle };
return { bundleId, bundle, fileSize: artifact.buffer.length };
}
function getPage({ bundleId, pageIndex }) {
function getBundle({ bundleId }) {
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] };
const bundle = grants.get(bundleId);
const artifact = bundle ? artifacts.get(bundle.cacheKey) : null;
return bundle && artifact ? { bundle, artifact } : null;
}
return { createBundle, getPage, deleteBundle };
return { createBundle, getBundle, deleteBundle };
}
module.exports = {
MATERIAL_PREVIEW_LIMITS,
MATERIAL_PREVIEW_MERGER_VERSION,
MaterialPreviewError,
createReimbursementMaterialPreviewStore,
normalizePreviewImage,
rasterizePdfWithPoppler,
runMergeWorker,
};
@@ -0,0 +1,11 @@
const { parentPort, workerData } = require("node:worker_threads");
const { mergeMaterialsToPdf } = require("./reimbursementMaterialPdf");
mergeMaterialsToPdf(workerData.materials, workerData.limits)
.then(({ buffer, pageCount }) => {
const bytes = Uint8Array.from(buffer);
parentPort.postMessage({ ok: true, bytes, pageCount }, [bytes.buffer]);
})
.catch((error) => {
parentPort.postMessage({ ok: false, code: error?.message || "material_preview_merge_failed" });
});
@@ -697,9 +697,9 @@ describe("reimbursement attachment routes", () => {
}
});
it("issues an ordered image bundle and audits each token-bound page access", async () => {
it("issues one token-bound PDF bundle with exact size and audits access", async () => {
const bundleId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
const previewBuffer = Buffer.from("synthetic-png-page");
const previewBuffer = Buffer.from("%PDF synthetic merged preview");
const materialPreviewStore = {
createBundle: vi.fn().mockImplementation(async ({ actorId, reimbursementId, materials, expiresAt }) => ({
bundleId,
@@ -707,16 +707,11 @@ describe("reimbursement attachment routes", () => {
actorId,
reimbursementId,
expiresAt,
pages: [{
buffer: previewBuffer,
materialId: materials[0].id,
materialType: materials[0].materialType,
materialIndex: materials[0].materialIndex,
pageNumber: 1,
}],
pageCount: 1,
},
fileSize: previewBuffer.length,
})),
getPage: vi.fn(),
getBundle: vi.fn(),
};
const { app, prisma, tempDir, reimbursementId } = await buildApp({ materialPreviewStore });
@@ -738,62 +733,58 @@ describe("reimbursement attachment routes", () => {
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(issued.json().data).toEqual(expect.objectContaining({
mimeType: "application/pdf",
fileSize: previewBuffer.length,
pageCount: 1,
}));
const previewUrl = new URL(issued.json().data.signedUrl);
const verified = verifyToken(previewUrl.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 issuedToken = previewUrl.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)}`,
url: `${previewUrl.pathname}?previewToken=${encodeURIComponent(tamperedToken)}`,
});
expect(tamperedResponse.statusCode).toBe(403);
expect(materialPreviewStore.getPage).not.toHaveBeenCalled();
expect(materialPreviewStore.getBundle).not.toHaveBeenCalled();
const missingBundleResponse = await app.inject({
method: "GET",
url: `${pageUrl.pathname}${pageUrl.search}`,
url: `${previewUrl.pathname}${previewUrl.search}`,
});
expect(missingBundleResponse.statusCode).toBe(403);
materialPreviewStore.getPage.mockReturnValue({
materialPreviewStore.getBundle.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,
},
artifact: { buffer: previewBuffer },
});
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");
const previewResponse = await app.inject({ method: "GET", url: `${previewUrl.pathname}${previewUrl.search}` });
expect(previewResponse.statusCode).toBe(200);
expect(previewResponse.rawPayload).toEqual(previewBuffer);
expect(previewResponse.headers["content-type"]).toContain("application/pdf");
expect(Number(previewResponse.headers["content-length"])).toBe(previewBuffer.length);
expect(previewResponse.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}` });
const auditFailureResponse = await app.inject({ method: "GET", url: `${previewUrl.pathname}${previewUrl.search}` });
expect(auditFailureResponse.statusCode).toBe(500);
expect(auditFailureResponse.body).not.toContain(previewBuffer.toString());
} finally {
@@ -238,6 +238,13 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
},
createdAt: "2026-04-10T09:00:00.000Z",
submittedAt: "2026-04-10T10:00:00.000Z",
travelSummary: {
isTravel: true,
purpose: "客户拜访报销",
location: null,
startDate: "2026-04-09",
endDate: "2026-04-09",
},
invoices: [
{
id: "invoice-1",
@@ -99,6 +99,9 @@ async function buildApp(options = {}) {
invoicePool: {
count: vi.fn(async () => 1),
},
reimbursementItem: {
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
},
budgetLedgerEntry: {
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
},
@@ -230,6 +233,18 @@ describe("reimbursements routes expense standard integration", () => {
resolveRecipientsForAssigneeMock.mockResolvedValue([]);
});
it("blocks travel reimbursement submit until the structured summary is complete", async () => {
const { app, tx } = await buildApp({ travelItem: true });
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual(expect.objectContaining({
travelSummary: "incomplete",
missingFields: ["travelPurpose", "travelLocation", "travelStartDate", "travelEndDate"],
}));
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
await app.close();
});
it("returns standardChecks from reimbursement precheck", async () => {
resolveEntityPrecheckMock.mockResolvedValue(
buildPrecheck({
@@ -1,65 +1,100 @@
const {
MaterialPreviewError,
createReimbursementMaterialPreviewStore,
} = require("../../../src/services/reimbursementMaterialPreview");
const { PDFDocument } = require("pdf-lib");
const sharp = require("sharp");
const { mergeMaterialsToPdf } = require("../../../src/services/reimbursementMaterialPdf");
const { MaterialPreviewError, createReimbursementMaterialPreviewStore, runMergeWorker } = require("../../../src/services/reimbursementMaterialPreview");
describe("reimbursement material preview store", () => {
it("keeps material and PDF page order in a bounded short-lived bundle", async () => {
const limits = { maxPages: 40, maxImagePixels: 40_000_000, maxOutputBytes: 10 * 1024 * 1024 };
const ENCRYPTED_PDF = Buffer.from("JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovUHJvZHVjZXIgPGQ1MTZlOTU0YzA+Cj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9QYWdlcwovQ291bnQgMQovS2lkcyBbIDQgMCBSIF0KPj4KZW5kb2JqCjMgMCBvYmoKPDwKL1R5cGUgL0NhdGFsb2cKL1BhZ2VzIDIgMCBSCj4+CmVuZG9iago0IDAgb2JqCjw8Ci9UeXBlIC9QYWdlCi9SZXNvdXJjZXMgPDwKPj4KL01lZGlhQm94IFsgMC4wIDAuMCAyMDAgMjAwIF0KL1BhcmVudCAyIDAgUgo+PgplbmRvYmoKNSAwIG9iago8PAovViAyCi9SIDMKL0xlbmd0aCAxMjgKL1AgNDI5NDk2NzI5MgovRmlsdGVyIC9TdGFuZGFyZAovTyA8YTgxN2MwMzIxY2ViOTJjMDQ3MDkyMDc2MTA0NjJkZjViZWIwMzJjYzBhYTQ4YTY3MGY5YWVmNTM3NTk2MzdkNj4KL1UgPDIyOWI4ZWJjYTUzMTFkZWYzZjhiNzM4MzQ1MzExZTdmMjhiZjRlNWU0ZTc1OGE0MTY0MDA0ZTU2ZmZmYTAxMDg+Cj4+CmVuZG9iagp4cmVmCjAgNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTUgMDAwMDAgbiAKMDAwMDAwMDA1OSAwMDAwMCBuIAowMDAwMDAwMTE4IDAwMDAwIG4gCjAwMDAwMDAxNjcgMDAwMDAgbiAKMDAwMDAwMDI2MSAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDYKL1Jvb3QgMyAwIFIKL0luZm8gMSAwIFIKL0lEIFsgPDM1Mzk2MzMyMzA2MjYyNjE2NTYzMzgzMjY1MzE2MjM1NjMzNjMzNjM2MTYyNjU2NjM1NjE2NjYxNjU2NjMxMzE+IDwzNTM5NjMzMjMwNjI2MjYxNjU2MzM4MzI2NTMxNjIzNTYzMzYzMzYzNjE2MjY1NjYzNTYxNjY2MTY1NjYzMTMxPiBdCi9FbmNyeXB0IDUgMCBSCj4+CnN0YXJ0eHJlZgo0NzYKJSVFT0YK", "base64");
async function samplePdf(pageCount = 2) {
const pdf = await PDFDocument.create();
for (let index = 0; index < pageCount; index += 1) pdf.addPage([300 + index, 200 + index]);
return Buffer.from(await pdf.save({ useObjectStreams: true }));
}
describe("reimbursement material PDF preview", () => {
it("copies every PDF page, embeds images, and produces deterministic bytes", async () => {
const pdf = await samplePdf(2);
const png = await sharp({ create: { width: 80, height: 40, channels: 3, background: "white" } }).png().toBuffer();
const materials = [
{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", source: pdf },
{ id: "attachment-1", materialType: "attachment", mimeType: "image/png", source: png },
];
const first = await mergeMaterialsToPdf(materials, limits);
const second = await mergeMaterialsToPdf(materials, limits);
expect(first.pageCount).toBe(3);
expect(first.buffer.equals(second.buffer)).toBe(true);
expect((await PDFDocument.load(first.buffer)).getPageCount()).toBe(3);
});
it("keeps one cached byte sequence per ordered collection and expires its grant", 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({
const merged = Buffer.from("deterministic-pdf");
const mergePdf = vi.fn().mockResolvedValue({ buffer: merged, pageCount: 3 });
const store = createReimbursementMaterialPreviewStore({ now: () => currentTime, randomUUID: () => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", mergePdf });
const args = {
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");
materials: [{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", fileSize: 10, fileSha256: "a" }],
readSource: async () => Buffer.from("source"),
};
const created = await store.createBundle(args);
expect(created.fileSize).toBe(merged.length);
expect(store.getBundle({ bundleId: created.bundleId })?.artifact.buffer).toBe(merged);
expect(mergePdf).toHaveBeenCalledTimes(1);
currentTime = 2_001;
expect(store.getPage({ bundleId: created.bundleId, pageIndex: 0 })).toBeNull();
expect(store.getBundle({ bundleId: created.bundleId })).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")]),
});
it("deduplicates concurrent generation for the same ordered collection", async () => {
const mergePdf = vi.fn(async () => ({ buffer: Buffer.from("one-pdf"), pageCount: 1 }));
let sequence = 0;
const store = createReimbursementMaterialPreviewStore({ mergePdf, randomUUID: () => `bundle-${sequence += 1}` });
const args = {
actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 10_000,
materials: [{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", fileSize: 6, fileSha256: "same" }],
readSource: async () => Buffer.from("source"),
};
await Promise.all([store.createBundle(args), store.createBundle(args)]);
expect(mergePdf).toHaveBeenCalledTimes(1);
});
it("rejects material/source limits and never keeps a partial bundle", async () => {
const store = createReimbursementMaterialPreviewStore({ limits: { maxMaterials: 1, maxSourceBytes: 5 }, mergePdf: vi.fn() });
await expect(store.createBundle({
actorId: "actor-1",
reimbursementId: "reimbursement-1",
expiresAt: Date.now() + 1_000,
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 },
{ id: "one", materialType: "invoice", mimeType: "application/pdf", fileSize: 3 },
{ id: "two", materialType: "attachment", mimeType: "image/png", fileSize: 3 },
],
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"),
actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 1_000,
materials: [{ id: "one", materialType: "invoice", mimeType: "application/pdf", fileSize: 3 }],
readSource: async () => Buffer.from("source-too-large"),
})).rejects.toBeInstanceOf(MaterialPreviewError);
});
it("fails the whole merge for a damaged PDF", async () => {
await expect(mergeMaterialsToPdf([
{ id: "bad", materialType: "invoice", mimeType: "application/pdf", source: Buffer.from("not-a-pdf") },
], limits)).rejects.toThrow("material_preview_pdf_invalid");
});
it("fails the whole merge for an encrypted PDF without ignoreEncryption", async () => {
await expect(mergeMaterialsToPdf([
{ id: "encrypted", materialType: "invoice", mimeType: "application/pdf", source: ENCRYPTED_PDF },
], limits)).rejects.toThrow("material_preview_pdf_invalid");
});
it("merges a 25-page synthetic document in worker_threads without losing pages", async () => {
const pdf = await samplePdf(25);
const result = await runMergeWorker([
{ id: "multi-page", materialType: "invoice", mimeType: "application/pdf", source: pdf },
], { ...limits, mergeTimeoutMs: 10_000 });
expect(result.pageCount).toBe(25);
expect((await PDFDocument.load(result.buffer)).getPageCount()).toBe(25);
});
});