实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -581,9 +581,9 @@ reply.header(
|
|||||||
### 1. Scope / Trigger
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
- Trigger: changing PC/mobile reimbursement material presentation, invoice OCR
|
- Trigger: changing PC/mobile reimbursement material presentation, invoice OCR
|
||||||
category labels, PDF rasterization or the continuous-preview grant.
|
category labels or the whole-reimbursement merged-preview grant.
|
||||||
- Purpose: keep expense items concise while allowing an approver to enter once
|
- Purpose: keep expense items concise while allowing an approver to open one
|
||||||
and continuously inspect every invoice and supporting attachment.
|
derived PDF and continuously inspect every invoice and supporting attachment.
|
||||||
|
|
||||||
### 2. Signatures
|
### 2. Signatures
|
||||||
|
|
||||||
@@ -591,34 +591,37 @@ reply.header(
|
|||||||
`documentCategory`, `documentSubtype` and `buyerName`; it never exposes the
|
`documentCategory`, `documentSubtype` and `buyerName`; it never exposes the
|
||||||
raw OCR provider response.
|
raw OCR provider response.
|
||||||
- Bundle issue: `POST /api/reimbursements/:id/materials/preview-bundle`, optional
|
- Bundle issue: `POST /api/reimbursements/:id/materials/preview-bundle`, optional
|
||||||
`{ reason }`; response `{ expiresAt, pages: [{ url, materialType,
|
`{ reason }`; response `{ expiresAt, signedUrl, originalName, mimeType,
|
||||||
materialIndex, pageNumber }] }`.
|
fileSize, pageCount }`.
|
||||||
- Page fetch: `GET /api/reimbursements/:id/materials/preview/:bundleId/:pageIndex?previewToken=...`.
|
- Bundle fetch: `GET /api/reimbursements/:id/materials/preview/:bundleId?previewToken=...`.
|
||||||
- Runtime dependency: production Alpine image contains `poppler-utils`;
|
- Runtime: `pdf-lib` and `sharp` execute inside Node `worker_threads`; this does
|
||||||
`reimbursementMaterialPreview` invokes `pdftoppm` with `execFile`, never a shell.
|
not enable the Compose worker profile and does not add a frontend PDF viewer.
|
||||||
|
|
||||||
### 3. Contracts
|
### 3. Contracts
|
||||||
|
|
||||||
- Expense-item UI does not render or preview linked invoices. Preserve
|
- Expense-item UI does not render or preview linked invoices. Preserve
|
||||||
`linkedItemId` for finance/audit semantics; presentation must not delete or
|
`linkedItemId` for finance/audit semantics; presentation must not delete or
|
||||||
rewrite the relationship.
|
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
|
Editable drafts may retain per-record management rows; read-only users see
|
||||||
them only when continuous preview fails and the original-file fallback opens.
|
them only when continuous preview fails and the original-file fallback opens.
|
||||||
- Material order is invoices by `created_at,id`, then attachments by
|
- 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
|
`created_at,id`, then PDF page order. Never infer order from amount, file name
|
||||||
or `linkedItemId`.
|
or `linkedItemId`.
|
||||||
- PDFs are rasterized separately; images are normalized with `sharp`. Do not
|
- PDF originals contribute every original page; JPG/PNG/WebP materials are
|
||||||
merge PDFs, strip their signatures, use PDF.js in the WeCom page, or persist
|
normalized with `sharp` and embedded one per page. The merged PDF is a
|
||||||
preview images as business attachments.
|
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
|
- 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
|
pages, 40 megapixels per image, 50 MiB output and 100 MiB process cache.
|
||||||
80 MiB process cache.
|
Worker execution has a fixed timeout; concurrent requests for one collection
|
||||||
Poppler gets a fixed DPI, timeout and temporary directory; render one page
|
share one in-flight generation.
|
||||||
beyond the limit so an oversized PDF fails instead of being silently cut.
|
- Tokens bind purpose, reimbursement, actor, bundle and expiry. Issue requires
|
||||||
- Tokens bind purpose, reimbursement, actor, bundle, page index and expiry.
|
current object access plus any attachment download reason; bundle GET audits
|
||||||
Issue requires current object access plus any attachment download reason;
|
before bytes and returns exact cached bytes with `private, no-store`.
|
||||||
every page access writes audit before bytes and returns `private, no-store`.
|
|
||||||
- Category labels are `vat_invoice -> 增值税发票`,
|
- Category labels are `vat_invoice -> 增值税发票`,
|
||||||
`full_vat_invoice -> 数电发票`, and
|
`full_vat_invoice -> 数电发票`, and
|
||||||
`electronic_train_ticket -> 电子火车票`. Known no-code categories never show
|
`electronic_train_ticket -> 电子火车票`. Known no-code categories never show
|
||||||
@@ -627,36 +630,35 @@ reply.header(
|
|||||||
### 4. Validation & Error Matrix
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
- No materials -> do not issue a bundle; show the existing empty state.
|
- No materials -> do not issue a bundle; show the existing empty state.
|
||||||
- Unsupported/corrupt input, missing Poppler or raster timeout -> stable
|
- Encrypted, unsupported or corrupt PDF/image, worker error or timeout -> the
|
||||||
continuous-preview error and expose authorized original-file fallback.
|
entire merge fails and exposes authorized original-file fallback.
|
||||||
- Material/source/page/output/cache limit -> bounded validation error; never
|
- Material/source/page/output/cache limit -> bounded validation error; never
|
||||||
return a partial bundle or silently omit pages.
|
return a partial bundle or silently omit pages.
|
||||||
- Missing required reason -> `400`; invalid/expired/tampered/object-mismatched
|
- Missing required reason -> `400`; invalid/expired/tampered/object-mismatched
|
||||||
token, absent cache bundle or process restart -> `403` with no bytes.
|
token, absent cache bundle or process restart -> `403` with no bytes.
|
||||||
- Bundle-issue audit failure deletes the bundle; page-access audit failure ->
|
- Bundle-issue audit failure deletes the bundle; bundle-access audit failure ->
|
||||||
`5xx` with no image body.
|
`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
|
- Unknown or malformed OCR category -> neutral `发票` label; never guess a
|
||||||
category or rerun production OCR merely to fix display text.
|
category or rerun production OCR merely to fix display text.
|
||||||
|
|
||||||
### 5. Good/Base/Bad Cases
|
### 5. Good/Base/Bad Cases
|
||||||
|
|
||||||
- Good: three invoice PDFs and three PNG attachments produce one ordered image
|
- Good: three invoice PDFs and three images produce one ordered six-or-more-page
|
||||||
URL array; WeCom swipes natively and PC loads the same bounded pages.
|
PDF; WeCom and PC use their native PDF readers while originals remain intact.
|
||||||
- Base: one single-page PDF becomes one PNG preview while its original remains
|
- Base: one single-page PDF is copied into a one-page derived preview.
|
||||||
unchanged and separately auditable.
|
- Bad: use `ignoreEncryption`, skip a failed material, add PDF.js to the client,
|
||||||
- Bad: concatenate PDFs, expose OCR raw data, put invoice buttons back into
|
expose OCR raw data, or accept the first 40 pages of a 41-page file silently.
|
||||||
every expense item, or accept the first 40 pages of a 41-page file silently.
|
|
||||||
|
|
||||||
### 6. Tests Required
|
### 6. Tests Required
|
||||||
|
|
||||||
- Service tests assert material/page order, expiry, source/page/output/cache
|
- Service tests assert deterministic bytes, all-page copy, image embedding,
|
||||||
limits and absence of partial bundles.
|
20–30-page worker behavior, concurrent deduplication, expiry and limits.
|
||||||
- Route tests assert authorization, reason gate, token binding, cache miss,
|
- Route tests assert authorization, reason gate, token binding, cache miss,
|
||||||
issue/access audit, audit-failure fail-closed behavior and PNG response.
|
issue/access audit, audit-failure fail-closed behavior and exact PDF size.
|
||||||
- A target-image probe must generate synthetic PDF bytes and prove the built
|
|
||||||
Alpine container returns PNG through the production rasterizer.
|
|
||||||
- PC/WeCom Playwright asserts no invoice entry inside expense items, correct
|
- 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
|
editable actions and responsive layout. Run full server tests, Prisma
|
||||||
validation, frontend typecheck/build and Trellis governance gates.
|
validation, frontend typecheck/build and Trellis governance gates.
|
||||||
|
|
||||||
@@ -665,14 +667,74 @@ reply.header(
|
|||||||
#### Wrong
|
#### Wrong
|
||||||
|
|
||||||
```tsx
|
```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
|
#### Correct
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const bundle = await fetchReimbursementMaterialPreviewBundle(reimbursementId);
|
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
|
## Scenario: Payment Batch Receipt Attention Count
|
||||||
|
|||||||
@@ -64,11 +64,10 @@ or administration workflow has already migrated.
|
|||||||
body `{ reason }`; response uses the same file metadata shape.
|
body `{ reason }`; response uses the same file metadata shape.
|
||||||
- Reimbursement material bundle:
|
- Reimbursement material bundle:
|
||||||
`POST /api/reimbursements/:id/materials/preview-bundle`, optional body
|
`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
|
- Native call: `previewFile({ url, name, size })`; `name` includes an extension
|
||||||
and `size` is the persisted positive byte count.
|
and `size` is the persisted positive byte count.
|
||||||
- Multi-image native call: `previewImage({ current, urls })`; `current` is one
|
|
||||||
member of the non-empty ordered `urls` array.
|
|
||||||
|
|
||||||
### 3. Contracts
|
### 3. Contracts
|
||||||
|
|
||||||
@@ -86,10 +85,10 @@ or administration workflow has already migrated.
|
|||||||
- PC keeps its browser/authenticated download behavior. Only a detected WeCom
|
- PC keeps its browser/authenticated download behavior. Only a detected WeCom
|
||||||
WebView dynamically loads `@wecom/jssdk` and calls `previewFile`; do not send
|
WebView dynamically loads `@wecom/jssdk` and calls `previewFile`; do not send
|
||||||
the actual file through a WeCom message or native approval payload.
|
the actual file through a WeCom message or native approval payload.
|
||||||
- `previewImage` is the only native continuous-preview path for mixed original
|
- Whole-reimbursement continuous preview derives one deterministic PDF: PDF
|
||||||
PDF/image reimbursement materials: OA first rasterizes each PDF separately
|
pages are copied and images are embedded as pages, then WeCom receives one
|
||||||
into short-lived images. `previewFile` accepts one file only; never pass an
|
`previewFile` call. The derivative is explicitly not an original or digital-
|
||||||
invented array to it or merge originals merely to simulate navigation.
|
signature fact source; individual originals remain separately authorized.
|
||||||
- JS-SDK is allowed only on authenticated business pages that need a native
|
- JS-SDK is allowed only on authenticated business pages that need a native
|
||||||
capability and is dynamically imported behind WeCom detection. The hosted PC
|
capability and is dynamically imported behind WeCom detection. The hosted PC
|
||||||
login/password/email-code page remains script-free as required by the
|
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" }`.
|
- Missing required download reason -> `400 { reason: "required" }`.
|
||||||
- Expired, tampered, wrong-purpose or object-mismatched file grant -> `403`.
|
- Expired, tampered, wrong-purpose or object-mismatched file grant -> `403`.
|
||||||
- Access audit failure -> `5xx` with no file body.
|
- Access audit failure -> `5xx` with no file body.
|
||||||
- Empty or inconsistent `previewImage` URL arrays -> frontend stops before the
|
- Missing/invalid merged PDF URL, name or size -> frontend stops before the
|
||||||
bridge call. Bundle expiry/cache loss -> safe fallback to individually
|
bridge call. Bundle expiry/cache loss -> safe fallback to individually
|
||||||
authorized originals; do not broaden token scope or extend it silently.
|
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.
|
`previewFile`, sees the original in WeCom and receives no edit capability.
|
||||||
- Base: a PC user opens the same invoice through the browser and downloads an
|
- Base: a PC user opens the same invoice through the browser and downloads an
|
||||||
attachment through the existing authenticated Blob path.
|
attachment through the existing authenticated Blob path.
|
||||||
- Base: a WeCom approver requests one authorized reimbursement image bundle,
|
- Base: a WeCom approver requests one authorized reimbursement PDF bundle,
|
||||||
then `previewImage` opens the ordered PNG URLs with native swiping.
|
then `previewFile` opens it for continuous native scrolling.
|
||||||
- Bad: expose a long-lived generic `?token=`, trust a caller-supplied size/name,
|
- Bad: expose a long-lived generic `?token=`, trust a caller-supplied size/name,
|
||||||
put a provider ticket in the response, or use async `window.open(blob:)` in
|
put a provider ticket in the response, or use async `window.open(blob:)` in
|
||||||
WeCom and assume desktop popup behavior applies.
|
WeCom and assume desktop popup behavior applies.
|
||||||
@@ -132,9 +131,9 @@ or administration workflow has already migrated.
|
|||||||
- Playwright injects a synthetic `WeixinJSBridge`, asserts `previewFile` receives
|
- Playwright injects a synthetic `WeixinJSBridge`, asserts `previewFile` receives
|
||||||
the exact URL/name/size for invoice and attachment, and asserts no
|
the exact URL/name/size for invoice and attachment, and asserts no
|
||||||
`window.open` call in WeCom. PC assertions keep browser URL and Blob behavior.
|
`window.open` call in WeCom. PC assertions keep browser URL and Blob behavior.
|
||||||
- Continuous-preview Playwright asserts one `previewImage` call, the exact
|
- Continuous-preview Playwright asserts one `previewFile` call with exact URL,
|
||||||
ordered URL array and no JS-SDK load in a normal PC browser. Backend tests
|
filename and byte size. Backend tests separately prove deterministic output,
|
||||||
separately prove the page URLs remain object/actor/bundle/index bound.
|
exact response size and object/actor/bundle binding.
|
||||||
- Production deployment and real-device preview remain separate approvals.
|
- Production deployment and real-device preview remain separate approvals.
|
||||||
|
|
||||||
### 7. Wrong vs Correct
|
### 7. Wrong vs Correct
|
||||||
@@ -153,9 +152,9 @@ authenticated grant issue -> <=120s object-bound URL
|
|||||||
-> ww.previewFile({ url, nameWithExtension, exactSize })
|
-> ww.previewFile({ url, nameWithExtension, exactSize })
|
||||||
-> audit before file bytes
|
-> audit before file bytes
|
||||||
|
|
||||||
authenticated material-bundle issue -> ordered <=120s PNG URLs
|
authenticated material-bundle issue -> one <=120s deterministic PDF URL
|
||||||
-> ww.previewImage({ current: urls[0], urls })
|
-> ww.previewFile({ url, nameWithExtension, exactSize })
|
||||||
-> audit each page before image bytes
|
-> audit before exact cached PDF bytes
|
||||||
```
|
```
|
||||||
|
|
||||||
## Scenario: WeCom Member Data Is A Read-Only Source For OA
|
## Scenario: WeCom Member Data Is A Read-Only Source For OA
|
||||||
|
|||||||
@@ -206,9 +206,50 @@
|
|||||||
## 12. 报销明细与材料的 KISS 对齐
|
## 12. 报销明细与材料的 KISS 对齐
|
||||||
|
|
||||||
- 交互采用“一个入口、一次通览、语义保留”:费用明细只展示费用事实,不再渲染关联发票;`linkedItemId`、票据/附件类型、对象权限、审计和生命周期均不改变。付款回单仍属于付款阶段材料,不提前混入本次提交材料。
|
- 交互采用“一个入口、一次通览、语义保留”:费用明细只展示费用事实,不再渲染关联发票;`linkedItemId`、票据/附件类型、对象权限、审计和生命周期均不改变。付款回单仍属于付款阶段材料,不提前混入本次提交材料。
|
||||||
- 报销材料只读态首屏显示“发票 N · 补充附件 M”和一个明确的“查看全部材料”按钮,不为每份文件重复暴露技术操作。草稿/驳回编辑态仍保留发票添加/移除和附件上传/删除所需列表,数据变更动作与只读通览分离。
|
- 报销材料只读态首屏显示“发票 N · 补充附件 M”和一个明确的“查看材料合并预览”按钮,不为每份文件重复暴露技术操作。草稿/驳回编辑态仍保留发票添加/移除和附件上传/删除所需列表,数据变更动作与只读通览分离。
|
||||||
- 官方 `previewFile` 只有单文件参数;混合 PDF/图片不能直接连续切换。服务端新增报销级图片预览 grant:授权后读取当前绑定的发票 PDF 与补充附件,PDF 通过受控 Poppler `pdftoppm` 子进程逐页栅格化,JPG/PNG/WebP 通过现有 `sharp` 规范化为浏览器/企微兼容图片,生成短时图片预览包。企微调用一次 `previewImage({ current, urls })` 原生左右滑动,PC/普通浏览器在同一材料浏览页按顺序连续显示。
|
- 服务端新增报销级合并 PDF grant:授权后读取当前绑定的发票 PDF 与补充附件,PDF 原页全部复制,JPG/PNG/WebP 经 Sharp 规范化后各嵌入一页。企微调用一次 `previewFile({ url, name, size })`,PC/普通浏览器打开同一短时 PDF URL;不引入 PDF.js,不在客户端渲染 PDF。
|
||||||
- 预览顺序固定为发票创建顺序、补充附件创建顺序、PDF 内页顺序;不根据 `linkedItemId`、金额或文件名猜测排序。图片预览包不进入业务表、不形成长期附件,只在单进程有界内存缓存中保存至 grant 过期,token 绑定 purpose、reimbursement、actor、随机 bundle key、图片索引和过期时间。
|
- 预览顺序固定为发票 `created_at, id`、补充附件 `created_at, id`、PDF 内页顺序;不根据 `linkedItemId`、金额或文件名猜测排序。有序材料集合哈希覆盖材料类型、ID、文件哈希/大小/MIME,缓存键由报销单 ID、集合哈希和合并器版本组成。
|
||||||
- grant 签发前复用报销对象级读权限及附件治理/理由门禁;生成时限制材料数量、源文件总字节、PDF 总页数、单页像素和输出字节,Poppler 使用无 shell 的固定参数、超时和临时目录。每个图片 GET 验签、核对缓存 bundle 与 actor 后先写审计再发送。缓存缺失、进程重启、材料不支持/损坏、超限或栅格化失败时返回稳定错误,客户端展开现有逐份原件预览作为降级,不静默漏页。
|
- 合并在 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` 选择票种名称和适用字段;未知票种使用中性“发票 + 号码/文件名”,不显示“无代码”或对不适用字段宣称“未识别”。
|
- 后端从已脱敏持久化 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] 手工触发 automatic backup daily 与源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次、银行回单或付款写入。
|
||||||
- [x] 构建耗时主要来自首次加入 Poppler 系统包后的基础层失效:系统依赖安装及 client/server/init 重新构建;migration 无待执行项,不是本次耗时主因。
|
- [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. 完成条件
|
## 7. 完成条件
|
||||||
|
|
||||||
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
|
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
|
||||||
@@ -255,4 +269,10 @@
|
|||||||
- 本步骤只完成本地实现与验证;未推送、未部署、未修改生产配置或真实业务数据,不能据此通知真人线上测试。
|
- 本步骤只完成本地实现与验证;未推送、未部署、未修改生产配置或真实业务数据,不能据此通知真人线上测试。
|
||||||
- 整单材料生产发布:`8a9279efceee` 已部署,`SYNC_ENV=0`,62 个 migration 无待执行项;`/ready`、external/runtime smoke、公网扫描与未登录 401 边界通过,目标容器内 `pdftoppm` 可用。
|
- 整单材料生产发布:`8a9279efceee` 已部署,`SYNC_ENV=0`,62 个 migration 无待执行项;`/ready`、external/runtime smoke、公网扫描与未登录 401 边界通过,目标容器内 `pdftoppm` 可用。
|
||||||
- 本次长构建主要是 Poppler 首次进入生产基础层后引起系统包安装和 client/server/init 缓存失效;首轮路径权限、无进展 clone、启动前安全护栏拒绝和本地连接中断均已如实记录,未美化为一次成功。
|
- 本次长构建主要是 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`。
|
- 发布后运维对齐: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;普通员工不显示范围切换,不修改后端权限模型。
|
- 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。
|
||||||
- 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
|
- 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
|
||||||
- 生产可见性验收进一步确认:费用明细内再次展示并预览关联发票仍然重复,审批人希望一次进入后连续查看本单全部材料。PC 与移动端应统一采用“明细只讲费用、材料一次通览”:费用明细不再渲染发票摘要或原件入口;发票和补充附件仍保留各自后台类型、关联、权限和审计,但只在单一“报销材料”区汇总,并提供一个“查看全部材料”动作。
|
- 生产可见性验收进一步确认:费用明细内再次展示并预览关联发票仍然重复,审批人希望一次进入后连续查看本单全部材料。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 结果中的安全票种分类,前端按票种显示“电子火车票”或“数电发票”、号码、日期、金额及适用主体字段。
|
- 生产只读核查确认当前真实报销的 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
|
## Scope
|
||||||
|
|
||||||
@@ -82,9 +86,13 @@
|
|||||||
- [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。
|
- [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。
|
||||||
- [x] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,用户已确认能在企微查看当前真实报销材料。
|
- [x] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,用户已确认能在企微查看当前真实报销材料。
|
||||||
- [x] PC 与移动端费用明细不再展示或预览关联发票;`linkedItemId` 数据关系继续保留,仅不在费用明细 UI 重复呈现。
|
- [x] PC 与移动端费用明细不再展示或预览关联发票;`linkedItemId` 数据关系继续保留,仅不在费用明细 UI 重复呈现。
|
||||||
- [x] PC 与移动端单一“报销材料”区显示发票与补充附件计数,并提供一个“查看全部材料”动作;企微调用一次原生 `previewImage` 打开有序图片数组,PC 使用同一图片预览包连续浏览。
|
- [x] PC 与移动端单一“报销材料”区显示发票与补充附件计数,并提供一个“查看材料合并预览”动作;企微调用一次原生 `previewFile`,PC 用浏览器内置阅读器打开同一份合并 PDF。
|
||||||
- [x] 图片预览包只包含当前有权查看的报销所绑定发票与补充附件,按“发票 → 补充附件、各自稳定创建顺序、PDF 内页序”生成;短时授权、无持久业务副本、访问审计、数量/页数/源文件字节/输出像素和输出字节上限及失败降级均有验证。
|
- [x] 合并预览只包含当前有权查看的报销所绑定发票与补充附件,按“发票 → 补充附件、各自创建时间再按 ID、PDF 内原页序”生成;多页 PDF 不漏页,图片各占一页,短时授权、审计、全部上限和整包失败降级均有验证。
|
||||||
- [x] 票据摘要按 OCR `documentCategory` 展示正确票种与适用字段;数电发票和电子火车票不再出现误导性的“无代码”,电子火车票不再出现“未识别销售方”。当前真实票据不重跑 OCR、不改原始识别数据。
|
- [x] 票据摘要按 OCR `documentCategory` 展示正确票种与适用字段;数电发票和电子火车票不再出现误导性的“无代码”,电子火车票不再出现“未识别销售方”。当前真实票据不重跑 OCR、不改原始识别数据。
|
||||||
|
- [x] 移动端材料合并预览动作仅出现在“报销材料”区,不占用“报销明细”的主操作位。
|
||||||
|
- [x] 合并在 `worker_threads` 中受限执行;同样输入生成两次字节完全相同,签发 `fileSize` 等于 GET 实际字节,缓存丢失不返回漂移产物。
|
||||||
|
- [x] 用无业务数据的多页 PDF 与 JPG/PNG/WebP 合成样本验证页数、顺序、25 页边界和企微一次 `previewFile`;加密、损坏或不可复制 PDF 必须整包失败并回退逐份原件。
|
||||||
|
- [x] 存在差旅费用明细时,PC/移动详情首部均显示“出差事由、出差地点、起止日期”的轻量概要;草稿/驳回态可编辑且提交校验完整,审批中历史单不伪造缺失字段。
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
@@ -108,4 +116,4 @@
|
|||||||
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
|
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
|
||||||
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
|
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
|
||||||
- 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。
|
- 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。
|
||||||
- 用合成普通增值税发票、数电发票、电子火车票、PDF 与 JPG/PNG/WebP 补充附件验证 PC/移动一致性:费用明细无发票预览;票种标题不虚构缺失字段;统一材料区计数正确;一次图片预览包包含全部可读材料并保持顺序;异常/加密 PDF、超限和栅格化失败可安全降级;编辑态管理动作不回归。
|
- 用合成普通增值税发票、数电发票、电子火车票、多页 PDF 与 JPG/PNG/WebP 补充附件验证 PC/移动一致性:费用明细无发票预览;票种标题不虚构缺失字段;统一材料区计数正确;合并 PDF 包含全部材料并保持顺序;异常/加密 PDF、超限和合并失败可安全降级;编辑态管理动作不回归。
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ export type ReimbursementDetail = {
|
|||||||
reimbursementDate: string | null;
|
reimbursementDate: string | null;
|
||||||
totalAmount: string | null;
|
totalAmount: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
travelSummary: {
|
||||||
|
isTravel: boolean;
|
||||||
|
purpose: string | null;
|
||||||
|
location: string | null;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
};
|
||||||
payment: {
|
payment: {
|
||||||
paymentVoucherNo: string | null;
|
paymentVoucherNo: string | null;
|
||||||
paymentDate: string | null;
|
paymentDate: string | null;
|
||||||
@@ -276,6 +283,17 @@ export type ReimbursementCreatePayload = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
linkedTravelRequestId?: 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 = {
|
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(
|
export async function createReimbursementItem(
|
||||||
reimbursementId: string,
|
reimbursementId: string,
|
||||||
payload: ReimbursementItemPayload,
|
payload: ReimbursementItemPayload,
|
||||||
@@ -549,12 +577,11 @@ export async function fetchReimbursementAttachmentPreviewUrl(
|
|||||||
|
|
||||||
export type ReimbursementMaterialPreviewBundle = {
|
export type ReimbursementMaterialPreviewBundle = {
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
pages: Array<{
|
signedUrl: string;
|
||||||
url: string;
|
originalName: string;
|
||||||
materialType: "invoice" | "attachment";
|
mimeType: "application/pdf";
|
||||||
materialIndex: number;
|
fileSize: number;
|
||||||
pageNumber: number;
|
pageCount: number;
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchReimbursementMaterialPreviewBundle(
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ async function configuredWecomSdk() {
|
|||||||
}
|
}
|
||||||
ww.register({
|
ww.register({
|
||||||
corpId: config.corpId,
|
corpId: config.corpId,
|
||||||
jsApiList: ["previewFile", "previewImage"],
|
jsApiList: ["previewFile"],
|
||||||
async getConfigSignature(url) {
|
async getConfigSignature(url) {
|
||||||
const signed = await fetchWecomJsSdkSignature(url || currentPageUrl());
|
const signed = await fetchWecomJsSdkSignature(url || currentPageUrl());
|
||||||
return {
|
return {
|
||||||
@@ -58,19 +58,6 @@ export async function previewFileInWecom(file: NativePreviewFile) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewImagesInWecom(urls: string[], current = urls[0]) {
|
|
||||||
if (!isWecomWebView()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const normalized = urls.map((url) => url.trim()).filter(Boolean);
|
|
||||||
if (!normalized.length || !current || !normalized.includes(current)) {
|
|
||||||
throw new Error("图片预览信息不完整");
|
|
||||||
}
|
|
||||||
const ww = await configuredWecomSdk();
|
|
||||||
await ww.previewImage({ current, urls: normalized });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openFileInBrowser(url: string) {
|
export function openFileInBrowser(url: string) {
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { useAttachmentDownloadReasonDialog } from "../components/common/useAttac
|
|||||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||||
|
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +65,7 @@ import {
|
|||||||
} from "../lib/invoiceStatus";
|
} from "../lib/invoiceStatus";
|
||||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||||
|
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
|
||||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||||
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||||
import { TEST_IDS } from "../lib/testIds";
|
import { TEST_IDS } from "../lib/testIds";
|
||||||
@@ -71,7 +73,7 @@ import { withUploadTimeout } from "../lib/uploadTimeout";
|
|||||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { isWecomWebView } from "../lib/runtimeContext";
|
import { isWecomWebView } from "../lib/runtimeContext";
|
||||||
import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
|
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||||
|
|
||||||
type ItemFormState = {
|
type ItemFormState = {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
@@ -743,15 +745,13 @@ export function MobileReimbursementDetailPage() {
|
|||||||
setMaterialPreviewError("");
|
setMaterialPreviewError("");
|
||||||
if (isWecomWebView()) {
|
if (isWecomWebView()) {
|
||||||
try {
|
try {
|
||||||
await previewImagesInWecom(bundle.pages.map((page) => page.url));
|
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("企业微信滑动预览未打开,已切换到页面预览");
|
toast.error("企业微信材料预览未打开,已切换到浏览器预览");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
navigate(`/mobile/reimbursements/${reimbursementId}/materials`, {
|
openFileInBrowser(bundle.signedUrl);
|
||||||
state: { bundle, from: `/mobile/reimbursements/${reimbursementId}` },
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件";
|
const message = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件";
|
||||||
@@ -777,6 +777,12 @@ export function MobileReimbursementDetailPage() {
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!hasCompleteTravelSummary(detail)) {
|
||||||
|
const message = "请先补全出差事由、地点和起止日期";
|
||||||
|
setActionError(message);
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!detail.invoices.length) {
|
if (!detail.invoices.length) {
|
||||||
const message = "请先添加至少 1 张发票";
|
const message = "请先添加至少 1 张发票";
|
||||||
setActionError(message);
|
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}
|
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ReimbursementTravelSummary
|
||||||
|
detail={detail}
|
||||||
|
reimbursementId={reimbursementId}
|
||||||
|
editable={Boolean(canEdit && isParticipant)}
|
||||||
|
mobile
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
||||||
@@ -941,24 +954,6 @@ export function MobileReimbursementDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
|
||||||
<Button
|
|
||||||
className="mt-3 min-h-12 w-full"
|
|
||||||
data-testid="mobile-reimbursement-materials-open-all"
|
|
||||||
type="button"
|
|
||||||
disabled={materialPreviewMutation.isPending}
|
|
||||||
onClick={() => void handleOpenAllMaterials()}
|
|
||||||
>
|
|
||||||
{materialPreviewMutation.isPending ? "生成预览中..." : "查看全部材料"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{materialPreviewError ? (
|
|
||||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
|
||||||
{materialPreviewError},可在下方逐份打开原件。
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{itemError ? (
|
{itemError ? (
|
||||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{itemError}</div>
|
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{itemError}</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1074,6 +1069,27 @@ export function MobileReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{canOpenInvoiceDialog || canManageAttachments ? (
|
||||||
<div className="mobile-two-action-grid mt-3">
|
<div className="mobile-two-action-grid mt-3">
|
||||||
{canOpenInvoiceDialog ? (
|
{canOpenInvoiceDialog ? (
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ import {
|
|||||||
} from "../api/invoicePool";
|
} from "../api/invoicePool";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { isWecomWebView } from "../lib/runtimeContext";
|
import { isWecomWebView } from "../lib/runtimeContext";
|
||||||
import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
|
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||||
|
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||||
@@ -45,6 +46,7 @@ import { AttachmentGovernanceBadges } from "../components/common/AttachmentGover
|
|||||||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||||
|
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -736,15 +738,13 @@ export function ReimbursementDetailPage() {
|
|||||||
setMaterialPreviewError("");
|
setMaterialPreviewError("");
|
||||||
if (isWecomWebView()) {
|
if (isWecomWebView()) {
|
||||||
try {
|
try {
|
||||||
await previewImagesInWecom(bundle.pages.map((page) => page.url));
|
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("企业微信滑动预览未打开,已切换到页面预览");
|
toast.error("企业微信材料预览未打开,已切换到浏览器预览");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
navigate(`/reimbursements/${reimbursementId}/materials`, {
|
openFileInBrowser(bundle.signedUrl);
|
||||||
state: { bundle, from: `/reimbursements/${reimbursementId}` },
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件");
|
const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件");
|
||||||
@@ -891,6 +891,12 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setActionError("");
|
setActionError("");
|
||||||
|
if (!hasCompleteTravelSummary(detail)) {
|
||||||
|
const message = "请先补全出差事由、地点和起止日期";
|
||||||
|
setActionError(message);
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (detail.invoices.length < 1) {
|
if (detail.invoices.length < 1) {
|
||||||
setActionError(missingInvoiceSubmitMessage);
|
setActionError(missingInvoiceSubmitMessage);
|
||||||
toast.error(missingInvoiceSubmitMessage);
|
toast.error(missingInvoiceSubmitMessage);
|
||||||
@@ -1285,6 +1291,12 @@ export function ReimbursementDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|
||||||
|
<ReimbursementTravelSummary
|
||||||
|
detail={detail}
|
||||||
|
reimbursementId={reimbursementId}
|
||||||
|
editable={Boolean(canEditItems && isReimbursementParticipant)}
|
||||||
|
/>
|
||||||
|
|
||||||
<SectionPanel title="报销明细">
|
<SectionPanel title="报销明细">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-4" data-testid="reimbursement-items">
|
<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">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<p className="text-sm text-zinc-600">
|
<p className="text-sm text-zinc-600">
|
||||||
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
|
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
|
||||||
|
<span className="mt-1 block text-xs text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
||||||
@@ -1445,7 +1458,7 @@ export function ReimbursementDetailPage() {
|
|||||||
disabled={materialPreviewMutation.isPending}
|
disabled={materialPreviewMutation.isPending}
|
||||||
onClick={() => void handleOpenAllMaterials()}
|
onClick={() => void handleOpenAllMaterials()}
|
||||||
>
|
>
|
||||||
{materialPreviewMutation.isPending ? "生成预览中..." : "查看全部材料"}
|
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{canOpenInvoiceBindDialog ? (
|
{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;
|
|
||||||
@@ -62,7 +62,6 @@ import {
|
|||||||
ReimbursementDetailPage,
|
ReimbursementDetailPage,
|
||||||
ReimbursementDashboardPage,
|
ReimbursementDashboardPage,
|
||||||
ReimbursementPrintPage,
|
ReimbursementPrintPage,
|
||||||
ReimbursementMaterialsPage,
|
|
||||||
ReimbursementTemplatesPage,
|
ReimbursementTemplatesPage,
|
||||||
ReimbursementsPage,
|
ReimbursementsPage,
|
||||||
TravelRequestsPage,
|
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
|
<Route
|
||||||
path="leave-requests"
|
path="leave-requests"
|
||||||
element={
|
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
|
<Route
|
||||||
path="mobile/leave"
|
path="mobile/leave"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { ReimbursementMaterialsPage as default } from "../../pages/ReimbursementMaterialsPage";
|
|
||||||
@@ -51,7 +51,6 @@ export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"))
|
|||||||
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
||||||
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
|
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
|
||||||
export const ReimbursementPrintPage = lazy(() => import("./ReimbursementPrintPage"));
|
export const ReimbursementPrintPage = lazy(() => import("./ReimbursementPrintPage"));
|
||||||
export const ReimbursementMaterialsPage = lazy(() => import("./ReimbursementMaterialsPage"));
|
|
||||||
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
|
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
|
||||||
export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemplatesPage"));
|
export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemplatesPage"));
|
||||||
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
|
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
|
|||||||
reimbursementDate: "2026-05-08",
|
reimbursementDate: "2026-05-08",
|
||||||
totalAmount: "1683.00",
|
totalAmount: "1683.00",
|
||||||
description: "客户招待报销",
|
description: "客户招待报销",
|
||||||
|
travelSummary: {
|
||||||
|
isTravel: true,
|
||||||
|
purpose: "合成差旅概要",
|
||||||
|
location: "测试地点",
|
||||||
|
startDate: "2026-05-07",
|
||||||
|
endDate: "2026-05-08",
|
||||||
|
},
|
||||||
projectId: null,
|
projectId: null,
|
||||||
linkedTravelRequestId: null,
|
linkedTravelRequestId: null,
|
||||||
linkedTravelRequest: 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") {
|
if (pathName === `/api/reimbursements/${reimbursementId}/materials/preview-bundle` && request.method() === "POST") {
|
||||||
const pages = [
|
await fulfillJson(route, {
|
||||||
...state.invoices.map((_, index) => ({
|
expiresAt: "2026-07-31T10:02:00.000Z",
|
||||||
url: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/materials/preview/77777777-7777-4777-8777-777777777777/${index}?previewToken=invoice-${index}`,
|
signedUrl: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/materials/preview/77777777-7777-4777-8777-777777777777?previewToken=merged-pdf`,
|
||||||
materialType: "invoice",
|
originalName: "reimbursement-materials-preview.pdf",
|
||||||
materialIndex: index + 1,
|
mimeType: "application/pdf",
|
||||||
pageNumber: 1,
|
fileSize: 8192,
|
||||||
})),
|
pageCount: state.invoices.length + state.attachments.length,
|
||||||
...state.attachments.map((_, index) => ({
|
});
|
||||||
url: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/materials/preview/77777777-7777-4777-8777-777777777777/${state.invoices.length + index}?previewToken=attachment-${index}`,
|
|
||||||
materialType: "attachment",
|
|
||||||
materialIndex: index + 1,
|
|
||||||
pageNumber: 1,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
await fulfillJson(route, { expiresAt: "2026-07-31T10:02:00.000Z", pages });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathName.includes(`/api/reimbursements/${reimbursementId}/materials/preview/`) && request.method() === "GET") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,14 +658,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
});
|
});
|
||||||
await page.getByRole("button", { name: "上传附件" }).click();
|
await page.getByRole("button", { name: "上传附件" }).click();
|
||||||
await expect(page.getByText("付款截图.png")).toBeVisible();
|
await expect(page.getByText("付款截图.png")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("合成差旅概要");
|
||||||
|
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("测试地点");
|
||||||
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
|
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
|
||||||
await page.getByTestId("reimbursement-materials-open-all").click();
|
await page.getByTestId("reimbursement-materials-open-all").click();
|
||||||
await page.waitForURL(new RegExp(`/reimbursements/${reimbursementId}/materials$`));
|
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1);
|
||||||
await expect(page.getByTestId("reimbursement-material-viewer")).toContainText("共 3 页");
|
expect(await page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0])).toContain("previewToken=merged-pdf");
|
||||||
await expect(page.getByRole("img", { name: /预览/ })).toHaveCount(3);
|
|
||||||
await page.getByRole("button", { name: "返回报销单" }).click();
|
|
||||||
await page.waitForURL(new RegExp(`/reimbursements/${reimbursementId}$`));
|
|
||||||
expect(await page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(0);
|
|
||||||
|
|
||||||
const bodyText = await page.locator("body").innerText();
|
const bodyText = await page.locator("body").innerText();
|
||||||
expect(bodyText).not.toContain("绑定整单");
|
expect(bodyText).not.toContain("绑定整单");
|
||||||
@@ -783,6 +781,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
});
|
});
|
||||||
await mobile.page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: "上传附件" }).click();
|
await mobile.page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: "上传附件" }).click();
|
||||||
await expect(mobile.page.getByText("付款截图.png")).toBeVisible();
|
await expect(mobile.page.getByText("付款截图.png")).toBeVisible();
|
||||||
|
await expect(mobile.page.getByTestId("mobile-reimbursement-travel-summary")).toContainText("合成差旅概要");
|
||||||
|
await expect(mobile.page.getByTestId("mobile-reimbursement-travel-summary")).toContainText("测试地点");
|
||||||
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
|
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
|
||||||
const openAllButton = mobile.page.getByTestId("mobile-reimbursement-materials-open-all");
|
const openAllButton = mobile.page.getByTestId("mobile-reimbursement-materials-open-all");
|
||||||
const openAllTarget = await openAllButton.boundingBox();
|
const openAllTarget = await openAllButton.boundingBox();
|
||||||
@@ -790,19 +790,16 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
|||||||
await openAllButton.click();
|
await openAllButton.click();
|
||||||
await expect.poll(() => mobile.page.evaluate(() => (
|
await expect.poll(() => mobile.page.evaluate(() => (
|
||||||
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
|
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
|
||||||
).__r111BridgeCalls.filter((call) => call.name === "imagePreview").length)).toBe(1);
|
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(1);
|
||||||
const nativePreviewCalls = await mobile.page.evaluate(() => (
|
const nativePreviewCalls = await mobile.page.evaluate(() => (
|
||||||
window as typeof window & { __r111BridgeCalls: Array<{ name: string; params: Record<string, unknown> }> }
|
window as typeof window & { __r111BridgeCalls: Array<{ name: string; params: Record<string, unknown> }> }
|
||||||
).__r111BridgeCalls.filter((call) => call.name === "imagePreview"));
|
).__r111BridgeCalls.filter((call) => call.name === "previewFile"));
|
||||||
expect(nativePreviewCalls).toEqual([
|
expect(nativePreviewCalls).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
params: expect.objectContaining({
|
params: expect.objectContaining({
|
||||||
current: expect.stringContaining(`/materials/preview/`),
|
url: expect.stringContaining("previewToken=merged-pdf"),
|
||||||
urls: expect.arrayContaining([
|
name: "reimbursement-materials-preview.pdf",
|
||||||
expect.stringContaining("previewToken=invoice-0"),
|
size: 8192,
|
||||||
expect.stringContaining("previewToken=invoice-1"),
|
|
||||||
expect.stringContaining("previewToken=attachment-0"),
|
|
||||||
]),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ ENV VITE_BUILD_SHA=$VITE_BUILD_SHA
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache bash curl poppler-utils
|
RUN apk add --no-cache bash curl
|
||||||
|
|
||||||
FROM base AS client_deps
|
FROM base AS client_deps
|
||||||
COPY client/package.json client/package-lock.json ./client/
|
COPY client/package.json client/package-lock.json ./client/
|
||||||
|
|||||||
+31
@@ -34,6 +34,7 @@
|
|||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
|
"pdf-lib": "1.17.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"pg-boss": "^10.3.0",
|
"pg-boss": "^10.3.0",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
@@ -3412,6 +3413,24 @@
|
|||||||
"@opentelemetry/api": "^1.1.0"
|
"@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": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
@@ -7562,6 +7581,18 @@
|
|||||||
"node": ">= 14.16"
|
"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": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
|
"pdf-lib": "1.17.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"pg-boss": "^10.3.0",
|
"pg-boss": "^10.3.0",
|
||||||
"prisma": "^7.8.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;
|
||||||
@@ -1407,6 +1407,10 @@ model ReimbursementForm {
|
|||||||
paid_by String? @db.Uuid
|
paid_by String? @db.Uuid
|
||||||
project_id String? @db.Uuid
|
project_id String? @db.Uuid
|
||||||
linked_travel_request_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_at DateTime @default(now())
|
||||||
created_by String? @db.Uuid
|
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({
|
const REIMBURSEMENT_UPDATE_BODY_SCHEMA = z.object({
|
||||||
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
reimbursementDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
description: z.string().trim().max(500).nullable().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
|
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() })
|
.object({ reason: z.string().trim().max(200).optional() })
|
||||||
.strict();
|
.strict();
|
||||||
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
|
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
|
||||||
const REIMBURSEMENT_MATERIAL_PREVIEW_PAGE_SCHEMA = z.coerce.number().int().min(0).max(39);
|
|
||||||
|
|
||||||
function resolveSignedFileOrigin(config, request) {
|
function resolveSignedFileOrigin(config, request) {
|
||||||
const configured = String(config.appOrigin ?? "")
|
const configured = String(config.appOrigin ?? "")
|
||||||
@@ -123,9 +130,16 @@ const REIMBURSEMENT_CREATE_BODY_SCHEMA = z
|
|||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
templateId: z.string().uuid().optional(),
|
templateId: z.string().uuid().optional(),
|
||||||
linkedTravelRequestId: 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()
|
.strict()
|
||||||
.superRefine((value, ctx) => {
|
.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) {
|
if (value.templateId && value.linkedTravelRequestId) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
@@ -1540,6 +1554,18 @@ async function reimbursementRoutes(fastify) {
|
|||||||
timeZone: fastify.config.appTimezone,
|
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({
|
const created = await tx.reimbursementForm.create({
|
||||||
data: {
|
data: {
|
||||||
form_number: issued.documentNumber,
|
form_number: issued.documentNumber,
|
||||||
@@ -1549,6 +1575,10 @@ async function reimbursementRoutes(fastify) {
|
|||||||
reimbursement_date: parseDateOnlyString(reimbursementDate),
|
reimbursement_date: parseDateOnlyString(reimbursementDate),
|
||||||
description: resolvedDescription,
|
description: resolvedDescription,
|
||||||
linked_travel_request_id: linkedTravelRequestRecord?.id ?? null,
|
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,
|
created_by: request.employee.id,
|
||||||
updated_by: request.employee.id,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
@@ -1562,6 +1592,10 @@ async function reimbursementRoutes(fastify) {
|
|||||||
owner_id: true,
|
owner_id: true,
|
||||||
submitter_id: true,
|
submitter_id: true,
|
||||||
linked_travel_request_id: true,
|
linked_travel_request_id: true,
|
||||||
|
travel_purpose: true,
|
||||||
|
travel_location: true,
|
||||||
|
travel_start_date: true,
|
||||||
|
travel_end_date: true,
|
||||||
travel_request: {
|
travel_request: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -1658,6 +1692,12 @@ async function reimbursementRoutes(fastify) {
|
|||||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||||
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
|
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
|
||||||
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
|
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) : [],
|
items: Array.isArray(reimbursement.items) ? reimbursement.items.map(mapCreatedReimbursementItem) : [],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -2061,6 +2101,10 @@ async function reimbursementRoutes(fastify) {
|
|||||||
paid_at: true,
|
paid_at: true,
|
||||||
project_id: true,
|
project_id: true,
|
||||||
linked_travel_request_id: true,
|
linked_travel_request_id: true,
|
||||||
|
travel_purpose: true,
|
||||||
|
travel_location: true,
|
||||||
|
travel_start_date: true,
|
||||||
|
travel_end_date: true,
|
||||||
paid_by_employee: {
|
paid_by_employee: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -2261,6 +2305,12 @@ async function reimbursementRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
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, {
|
return sendData(reply, request, {
|
||||||
id: reimbursement.id,
|
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,
|
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
||||||
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
|
linkedTravelRequestId: reimbursement.linked_travel_request_id ?? null,
|
||||||
linkedTravelRequest: mapLinkedTravelRequestSummary(reimbursement.travel_request),
|
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,
|
owner: reimbursement.owner,
|
||||||
submitter: reimbursement.submitter,
|
submitter: reimbursement.submitter,
|
||||||
items: reimbursement.items.map((item) => ({
|
items: reimbursement.items.map((item) => ({
|
||||||
@@ -2357,7 +2414,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
|
|
||||||
const updated = await fastify.prisma.$transaction(async (tx) => {
|
const updated = await fastify.prisma.$transaction(async (tx) => {
|
||||||
const reimbursementRows = await tx.$queryRaw`
|
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"
|
FROM "reimbursement_forms"
|
||||||
WHERE id = ${reimbursementId}::uuid
|
WHERE id = ${reimbursementId}::uuid
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@@ -2392,6 +2449,20 @@ async function reimbursementRoutes(fastify) {
|
|||||||
|
|
||||||
const nextDescription =
|
const nextDescription =
|
||||||
body.description === undefined ? undefined : body.description?.trim() || null;
|
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({
|
return tx.reimbursementForm.update({
|
||||||
where: { id: reimbursementId },
|
where: { id: reimbursementId },
|
||||||
@@ -2400,6 +2471,10 @@ async function reimbursementRoutes(fastify) {
|
|||||||
? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) }
|
? { reimbursement_date: parseDateOnlyString(body.reimbursementDate) }
|
||||||
: {}),
|
: {}),
|
||||||
...(body.description !== undefined ? { description: nextDescription } : {}),
|
...(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,
|
updated_by: request.employee.id,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -2408,6 +2483,10 @@ async function reimbursementRoutes(fastify) {
|
|||||||
reimbursement_date: true,
|
reimbursement_date: true,
|
||||||
description: true,
|
description: true,
|
||||||
total_amount: 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),
|
reimbursementDate: updated.reimbursement_date.toISOString().slice(0, 10),
|
||||||
description: updated.description,
|
description: updated.description,
|
||||||
totalAmount: toMoneyString(updated.total_amount),
|
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,
|
storage_path: true,
|
||||||
mime_type: true,
|
mime_type: true,
|
||||||
file_size: true,
|
file_size: true,
|
||||||
|
file_sha256: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
attachments: {
|
attachments: {
|
||||||
@@ -3012,6 +3098,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
storage_path: true,
|
storage_path: true,
|
||||||
mime_type: true,
|
mime_type: true,
|
||||||
file_size: true,
|
file_size: true,
|
||||||
|
file_sha256: true,
|
||||||
governance_status: true,
|
governance_status: true,
|
||||||
sensitivity_level: true,
|
sensitivity_level: true,
|
||||||
attachment_type: true,
|
attachment_type: true,
|
||||||
@@ -3043,6 +3130,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
storagePath: invoice.storage_path,
|
storagePath: invoice.storage_path,
|
||||||
mimeType: invoice.mime_type,
|
mimeType: invoice.mime_type,
|
||||||
fileSize: invoice.file_size,
|
fileSize: invoice.file_size,
|
||||||
|
fileSha256: invoice.file_sha256,
|
||||||
})),
|
})),
|
||||||
...reimbursement.attachments.map((attachment, index) => ({
|
...reimbursement.attachments.map((attachment, index) => ({
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
@@ -3051,6 +3139,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
storagePath: attachment.storage_path,
|
storagePath: attachment.storage_path,
|
||||||
mimeType: attachment.mime_type,
|
mimeType: attachment.mime_type,
|
||||||
fileSize: attachment.file_size,
|
fileSize: attachment.file_size,
|
||||||
|
fileSha256: attachment.file_sha256,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
if (materials.some((material) => !ATTACHMENT_MIME_ALLOWLIST.has(material.mimeType))) {
|
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 origin = resolveSignedFileOrigin(fastify.config, request);
|
||||||
const pages = created.bundle.pages.map((page, pageIndex) => {
|
const previewToken = signPayload({
|
||||||
const previewToken = signPayload({
|
purpose: "reimbursement_material_preview",
|
||||||
purpose: "reimbursement_material_preview",
|
reimbursementId,
|
||||||
reimbursementId,
|
bundleId: created.bundleId,
|
||||||
bundleId: created.bundleId,
|
actorId: request.employee.id,
|
||||||
pageIndex,
|
exp: expiresAt,
|
||||||
actorId: request.employee.id,
|
}, fastify.config.jwtSecret);
|
||||||
exp: expiresAt,
|
const signedUrl = `${origin}/api/reimbursements/${reimbursementId}/materials/preview/${created.bundleId}?previewToken=${encodeURIComponent(previewToken)}`;
|
||||||
}, fastify.config.jwtSecret);
|
|
||||||
return {
|
|
||||||
url: `${origin}/api/reimbursements/${reimbursementId}/materials/preview/${created.bundleId}/${pageIndex}?previewToken=${encodeURIComponent(previewToken)}`,
|
|
||||||
materialType: page.materialType,
|
|
||||||
materialIndex: page.materialIndex,
|
|
||||||
pageNumber: page.pageNumber,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fastify.prisma.auditLog.create({
|
await fastify.prisma.auditLog.create({
|
||||||
@@ -3106,7 +3187,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
extra: {
|
extra: {
|
||||||
invoiceCount: reimbursement.invoice_pool_items.length,
|
invoiceCount: reimbursement.invoice_pool_items.length,
|
||||||
attachmentCount: reimbursement.attachments.length,
|
attachmentCount: reimbursement.attachments.length,
|
||||||
pageCount: pages.length,
|
pageCount: created.bundle.pageCount,
|
||||||
reason,
|
reason,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
},
|
},
|
||||||
@@ -3119,30 +3200,31 @@ async function reimbursementRoutes(fastify) {
|
|||||||
|
|
||||||
return sendData(reply, request, {
|
return sendData(reply, request, {
|
||||||
expiresAt: new Date(expiresAt).toISOString(),
|
expiresAt: new Date(expiresAt).toISOString(),
|
||||||
pages,
|
signedUrl,
|
||||||
|
originalName: "reimbursement-materials-preview.pdf",
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
fileSize: created.fileSize,
|
||||||
|
pageCount: created.bundle.pageCount,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.get(
|
fastify.get(
|
||||||
"/reimbursements/:id/materials/preview/:bundleId/:pageIndex",
|
"/reimbursements/:id/materials/preview/:bundleId",
|
||||||
{},
|
{},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const reimbursementId = parseReimbursementId(request.params?.id);
|
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||||
const parsedBundleId = UUID_SCHEMA.safeParse(request.params?.bundleId);
|
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 previewToken = typeof request.query?.previewToken === "string" ? request.query.previewToken : "";
|
||||||
const verified = previewToken ? verifyToken(previewToken, fastify.config.jwtSecret) : { ok: false };
|
const verified = previewToken ? verifyToken(previewToken, fastify.config.jwtSecret) : { ok: false };
|
||||||
const payload = verified.ok ? verified.payload : null;
|
const payload = verified.ok ? verified.payload : null;
|
||||||
const parsedActorId = UUID_SCHEMA.safeParse(payload?.actorId);
|
const parsedActorId = UUID_SCHEMA.safeParse(payload?.actorId);
|
||||||
if (
|
if (
|
||||||
!parsedBundleId.success ||
|
!parsedBundleId.success ||
|
||||||
!parsedPageIndex.success ||
|
|
||||||
!verified.ok ||
|
!verified.ok ||
|
||||||
payload?.purpose !== "reimbursement_material_preview" ||
|
payload?.purpose !== "reimbursement_material_preview" ||
|
||||||
payload?.reimbursementId !== reimbursementId ||
|
payload?.reimbursementId !== reimbursementId ||
|
||||||
payload?.bundleId !== parsedBundleId.data ||
|
payload?.bundleId !== parsedBundleId.data ||
|
||||||
payload?.pageIndex !== parsedPageIndex.data ||
|
|
||||||
typeof payload?.exp !== "number" ||
|
typeof payload?.exp !== "number" ||
|
||||||
payload.exp <= Date.now() ||
|
payload.exp <= Date.now() ||
|
||||||
!parsedActorId.success
|
!parsedActorId.success
|
||||||
@@ -3150,10 +3232,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = materialPreviewStore.getPage({
|
const resolved = materialPreviewStore.getBundle({ bundleId: parsedBundleId.data });
|
||||||
bundleId: parsedBundleId.data,
|
|
||||||
pageIndex: parsedPageIndex.data,
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
!resolved ||
|
!resolved ||
|
||||||
resolved.bundle.reimbursementId !== reimbursementId ||
|
resolved.bundle.reimbursementId !== reimbursementId ||
|
||||||
@@ -3170,19 +3249,17 @@ async function reimbursementRoutes(fastify) {
|
|||||||
targetType: "reimbursement",
|
targetType: "reimbursement",
|
||||||
targetId: reimbursementId,
|
targetId: reimbursementId,
|
||||||
extra: {
|
extra: {
|
||||||
materialId: resolved.page.materialId,
|
pageCount: resolved.bundle.pageCount,
|
||||||
materialType: resolved.page.materialType,
|
|
||||||
materialIndex: resolved.page.materialIndex,
|
|
||||||
pageNumber: resolved.page.pageNumber,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
reply.type("image/png");
|
reply.type("application/pdf");
|
||||||
reply.header("cache-control", "private, no-store");
|
reply.header("cache-control", "private, no-store");
|
||||||
reply.header("referrer-policy", "no-referrer");
|
reply.header("referrer-policy", "no-referrer");
|
||||||
reply.header("content-disposition", "inline");
|
reply.header("content-disposition", 'inline; filename="reimbursement-materials-preview.pdf"');
|
||||||
return reply.send(resolved.page.buffer);
|
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) => {
|
const submitResult = await fastify.prisma.$transaction(async (tx) => {
|
||||||
let budgetCheck = null;
|
let budgetCheck = null;
|
||||||
const reimbursementRows = await tx.$queryRaw`
|
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"
|
FROM "reimbursement_forms"
|
||||||
WHERE id = ${reimbursementId}::uuid
|
WHERE id = ${reimbursementId}::uuid
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@@ -3554,6 +3632,30 @@ async function reimbursementRoutes(fastify) {
|
|||||||
|
|
||||||
ensureNonNegativeAmount(reimbursement.total_amount);
|
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({
|
const activeInstance = await tx.workflowInstance.findFirst({
|
||||||
where: {
|
where: {
|
||||||
entity_type: "reimbursement",
|
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 crypto = require("node:crypto");
|
||||||
const fs = require("node:fs/promises");
|
|
||||||
const os = require("node:os");
|
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { promisify } = require("node:util");
|
const { Worker } = require("node:worker_threads");
|
||||||
const { execFile } = require("node:child_process");
|
const { materialCollectionHash } = require("./reimbursementMaterialPdf");
|
||||||
const sharp = require("sharp");
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
|
const MATERIAL_PREVIEW_MERGER_VERSION = "pdf-v1";
|
||||||
const MATERIAL_PREVIEW_LIMITS = Object.freeze({
|
const MATERIAL_PREVIEW_LIMITS = Object.freeze({
|
||||||
maxMaterials: 20,
|
maxMaterials: 20,
|
||||||
maxSourceBytes: 50 * 1024 * 1024,
|
maxSourceBytes: 50 * 1024 * 1024,
|
||||||
maxPages: 40,
|
maxPages: 40,
|
||||||
maxPageWidth: 1800,
|
maxImagePixels: 40_000_000,
|
||||||
maxPagePixels: 40_000_000,
|
maxOutputBytes: 50 * 1024 * 1024,
|
||||||
maxOutputBytes: 40 * 1024 * 1024,
|
maxCacheBytes: 100 * 1024 * 1024,
|
||||||
maxCacheBytes: 80 * 1024 * 1024,
|
mergeTimeoutMs: 20_000,
|
||||||
rasterDpi: 144,
|
|
||||||
rasterTimeoutMs: 20_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
class MaterialPreviewError extends Error {
|
class MaterialPreviewError extends Error {
|
||||||
@@ -28,183 +22,137 @@ class MaterialPreviewError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageNumberFromName(fileName) {
|
function runMergeWorker(materials, limits) {
|
||||||
const matched = fileName.match(/-(\d+)\.png$/i);
|
return new Promise((resolve, reject) => {
|
||||||
return matched ? Number(matched[1]) : Number.MAX_SAFE_INTEGER;
|
const worker = new Worker(path.join(__dirname, "reimbursementMaterialPreviewWorker.js"), {
|
||||||
}
|
workerData: { materials, limits },
|
||||||
|
});
|
||||||
async function normalizePreviewImage(buffer, limits = MATERIAL_PREVIEW_LIMITS) {
|
const timeout = setTimeout(() => {
|
||||||
try {
|
worker.terminate().catch(() => {});
|
||||||
return await sharp(buffer, { failOn: "error", limitInputPixels: limits.maxPagePixels })
|
reject(new MaterialPreviewError("material_preview_merge_timeout"));
|
||||||
.rotate()
|
}, limits.mergeTimeoutMs);
|
||||||
.resize({ width: limits.maxPageWidth, withoutEnlargement: true })
|
timeout.unref?.();
|
||||||
.png({ compressionLevel: 8, adaptiveFiltering: true })
|
worker.once("message", (message) => {
|
||||||
.toBuffer();
|
clearTimeout(timeout);
|
||||||
} catch {
|
if (!message?.ok) {
|
||||||
throw new MaterialPreviewError("material_preview_image_invalid");
|
reject(new MaterialPreviewError(message?.code || "material_preview_merge_failed"));
|
||||||
}
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
async function rasterizePdfWithPoppler(buffer, limits = MATERIAL_PREVIEW_LIMITS) {
|
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-material-preview-"));
|
|
||||||
const sourcePath = path.join(tempDir, "source.pdf");
|
|
||||||
const outputPrefix = path.join(tempDir, "page");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.writeFile(sourcePath, buffer, { flag: "wx" });
|
|
||||||
try {
|
|
||||||
await execFileAsync(
|
|
||||||
"pdftoppm",
|
|
||||||
[
|
|
||||||
"-png",
|
|
||||||
"-r",
|
|
||||||
String(limits.rasterDpi),
|
|
||||||
"-f",
|
|
||||||
"1",
|
|
||||||
"-l",
|
|
||||||
String(limits.maxPages + 1),
|
|
||||||
"-scale-to-x",
|
|
||||||
String(limits.maxPageWidth),
|
|
||||||
"-scale-to-y",
|
|
||||||
"-1",
|
|
||||||
sourcePath,
|
|
||||||
outputPrefix,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
timeout: limits.rasterTimeoutMs,
|
|
||||||
maxBuffer: 1024 * 1024,
|
|
||||||
windowsHide: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.code === "ENOENT") {
|
|
||||||
throw new MaterialPreviewError("material_preview_rasterizer_unavailable");
|
|
||||||
}
|
}
|
||||||
if (error?.killed || error?.signal) {
|
resolve({ buffer: Buffer.from(message.bytes), pageCount: message.pageCount });
|
||||||
throw new MaterialPreviewError("material_preview_raster_timeout");
|
});
|
||||||
|
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 = {}) {
|
function createReimbursementMaterialPreviewStore(options = {}) {
|
||||||
const limits = { ...MATERIAL_PREVIEW_LIMITS, ...(options.limits ?? {}) };
|
const limits = { ...MATERIAL_PREVIEW_LIMITS, ...(options.limits ?? {}) };
|
||||||
const rasterizePdf = options.rasterizePdf ?? ((buffer) => rasterizePdfWithPoppler(buffer, limits));
|
const mergePdf = options.mergePdf ?? ((materials) => runMergeWorker(materials, limits));
|
||||||
const normalizeImage = options.normalizeImage ?? ((buffer) => normalizePreviewImage(buffer, limits));
|
|
||||||
const now = options.now ?? (() => Date.now());
|
const now = options.now ?? (() => Date.now());
|
||||||
const randomUUID = options.randomUUID ?? (() => crypto.randomUUID());
|
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;
|
let cachedBytes = 0;
|
||||||
|
|
||||||
function deleteBundle(bundleId) {
|
function deleteBundle(bundleId) {
|
||||||
const bundle = bundles.get(bundleId);
|
const grant = grants.get(bundleId);
|
||||||
if (!bundle) return;
|
if (!grant) return;
|
||||||
cachedBytes -= bundle.outputBytes;
|
grants.delete(bundleId);
|
||||||
bundles.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() {
|
function pruneExpired() {
|
||||||
const current = now();
|
const current = now();
|
||||||
for (const [bundleId, bundle] of bundles) {
|
for (const [bundleId, grant] of grants) {
|
||||||
if (bundle.expiresAt <= current) deleteBundle(bundleId);
|
if (grant.expiresAt <= current) deleteBundle(bundleId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createBundle({ actorId, reimbursementId, materials, expiresAt, readSource }) {
|
async function createBundle({ actorId, reimbursementId, materials, expiresAt, readSource }) {
|
||||||
pruneExpired();
|
pruneExpired();
|
||||||
if (!Array.isArray(materials) || materials.length === 0) {
|
if (!Array.isArray(materials) || !materials.length) throw new MaterialPreviewError("material_preview_empty");
|
||||||
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 (materials.length > limits.maxMaterials) {
|
if (!Number.isSafeInteger(declaredBytes) || declaredBytes <= 0 || declaredBytes > limits.maxSourceBytes) {
|
||||||
throw new MaterialPreviewError("material_preview_material_limit");
|
|
||||||
}
|
|
||||||
const sourceBytes = materials.reduce((sum, material) => sum + Number(material.fileSize ?? 0), 0);
|
|
||||||
if (!Number.isSafeInteger(sourceBytes) || sourceBytes <= 0 || sourceBytes > limits.maxSourceBytes) {
|
|
||||||
throw new MaterialPreviewError("material_preview_source_size_limit");
|
throw new MaterialPreviewError("material_preview_source_size_limit");
|
||||||
}
|
}
|
||||||
|
|
||||||
const pages = [];
|
const collectionHash = materialCollectionHash(materials, MATERIAL_PREVIEW_MERGER_VERSION);
|
||||||
let outputBytes = 0;
|
const cacheKey = `${reimbursementId}:${collectionHash}:${MATERIAL_PREVIEW_MERGER_VERSION}`;
|
||||||
let actualSourceBytes = 0;
|
let artifact = artifacts.get(cacheKey);
|
||||||
for (const material of materials) {
|
if (!artifact) {
|
||||||
const source = await readSource(material);
|
let pending = pendingArtifacts.get(cacheKey);
|
||||||
if (!Buffer.isBuffer(source) || source.length === 0) {
|
if (!pending) pending = (async () => {
|
||||||
throw new MaterialPreviewError("material_preview_source_missing");
|
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;
|
let generated;
|
||||||
if (actualSourceBytes > limits.maxSourceBytes) {
|
try {
|
||||||
throw new MaterialPreviewError("material_preview_source_size_limit");
|
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"
|
if (!Buffer.isBuffer(generated?.buffer) || !generated.buffer.length) {
|
||||||
? await rasterizePdf(source)
|
throw new MaterialPreviewError("material_preview_merge_failed");
|
||||||
: [await normalizeImage(source)];
|
}
|
||||||
for (let index = 0; index < renderedPages.length; index += 1) {
|
if (generated.buffer.length > limits.maxOutputBytes) throw new MaterialPreviewError("material_preview_output_size_limit");
|
||||||
if (pages.length >= limits.maxPages) {
|
if (cachedBytes + generated.buffer.length > limits.maxCacheBytes) {
|
||||||
throw new MaterialPreviewError("material_preview_page_limit");
|
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 bundleId = randomUUID();
|
||||||
const bundle = { actorId, reimbursementId, expiresAt, outputBytes, pages };
|
const bundle = { actorId, reimbursementId, expiresAt, cacheKey, collectionHash, pageCount: artifact.pageCount };
|
||||||
bundles.set(bundleId, bundle);
|
grants.set(bundleId, bundle);
|
||||||
cachedBytes += outputBytes;
|
|
||||||
const timer = setTimeout(() => deleteBundle(bundleId), Math.max(1, expiresAt - now()));
|
const timer = setTimeout(() => deleteBundle(bundleId), Math.max(1, expiresAt - now()));
|
||||||
timer.unref?.();
|
timer.unref?.();
|
||||||
return { bundleId, bundle };
|
return { bundleId, bundle, fileSize: artifact.buffer.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPage({ bundleId, pageIndex }) {
|
function getBundle({ bundleId }) {
|
||||||
pruneExpired();
|
pruneExpired();
|
||||||
const bundle = bundles.get(bundleId);
|
const bundle = grants.get(bundleId);
|
||||||
if (!bundle || !Number.isInteger(pageIndex) || pageIndex < 0 || pageIndex >= bundle.pages.length) {
|
const artifact = bundle ? artifacts.get(bundle.cacheKey) : null;
|
||||||
return null;
|
return bundle && artifact ? { bundle, artifact } : null;
|
||||||
}
|
|
||||||
return { bundle, page: bundle.pages[pageIndex] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { createBundle, getPage, deleteBundle };
|
return { createBundle, getBundle, deleteBundle };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
MATERIAL_PREVIEW_LIMITS,
|
MATERIAL_PREVIEW_LIMITS,
|
||||||
|
MATERIAL_PREVIEW_MERGER_VERSION,
|
||||||
MaterialPreviewError,
|
MaterialPreviewError,
|
||||||
createReimbursementMaterialPreviewStore,
|
createReimbursementMaterialPreviewStore,
|
||||||
normalizePreviewImage,
|
runMergeWorker,
|
||||||
rasterizePdfWithPoppler,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 bundleId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
||||||
const previewBuffer = Buffer.from("synthetic-png-page");
|
const previewBuffer = Buffer.from("%PDF synthetic merged preview");
|
||||||
const materialPreviewStore = {
|
const materialPreviewStore = {
|
||||||
createBundle: vi.fn().mockImplementation(async ({ actorId, reimbursementId, materials, expiresAt }) => ({
|
createBundle: vi.fn().mockImplementation(async ({ actorId, reimbursementId, materials, expiresAt }) => ({
|
||||||
bundleId,
|
bundleId,
|
||||||
@@ -707,16 +707,11 @@ describe("reimbursement attachment routes", () => {
|
|||||||
actorId,
|
actorId,
|
||||||
reimbursementId,
|
reimbursementId,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
pages: [{
|
pageCount: 1,
|
||||||
buffer: previewBuffer,
|
|
||||||
materialId: materials[0].id,
|
|
||||||
materialType: materials[0].materialType,
|
|
||||||
materialIndex: materials[0].materialIndex,
|
|
||||||
pageNumber: 1,
|
|
||||||
}],
|
|
||||||
},
|
},
|
||||||
|
fileSize: previewBuffer.length,
|
||||||
})),
|
})),
|
||||||
getPage: vi.fn(),
|
getBundle: vi.fn(),
|
||||||
};
|
};
|
||||||
const { app, prisma, tempDir, reimbursementId } = await buildApp({ materialPreviewStore });
|
const { app, prisma, tempDir, reimbursementId } = await buildApp({ materialPreviewStore });
|
||||||
|
|
||||||
@@ -738,62 +733,58 @@ describe("reimbursement attachment routes", () => {
|
|||||||
payload: {},
|
payload: {},
|
||||||
});
|
});
|
||||||
expect(issued.statusCode).toBe(200);
|
expect(issued.statusCode).toBe(200);
|
||||||
expect(issued.json().data.pages).toEqual([
|
expect(issued.json().data).toEqual(expect.objectContaining({
|
||||||
expect.objectContaining({ materialType: "attachment", materialIndex: 1, pageNumber: 1 }),
|
mimeType: "application/pdf",
|
||||||
]);
|
fileSize: previewBuffer.length,
|
||||||
const pageUrl = new URL(issued.json().data.pages[0].url);
|
pageCount: 1,
|
||||||
const verified = verifyToken(pageUrl.searchParams.get("previewToken"), JWT_SECRET);
|
}));
|
||||||
|
const previewUrl = new URL(issued.json().data.signedUrl);
|
||||||
|
const verified = verifyToken(previewUrl.searchParams.get("previewToken"), JWT_SECRET);
|
||||||
expect(verified).toEqual(expect.objectContaining({
|
expect(verified).toEqual(expect.objectContaining({
|
||||||
ok: true,
|
ok: true,
|
||||||
payload: expect.objectContaining({
|
payload: expect.objectContaining({
|
||||||
purpose: "reimbursement_material_preview",
|
purpose: "reimbursement_material_preview",
|
||||||
reimbursementId,
|
reimbursementId,
|
||||||
bundleId,
|
bundleId,
|
||||||
pageIndex: 0,
|
|
||||||
actorId: ACTOR_ID,
|
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 tamperedToken = `${issuedToken.slice(0, -1)}${issuedToken.endsWith("a") ? "b" : "a"}`;
|
||||||
const tamperedResponse = await app.inject({
|
const tamperedResponse = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `${pageUrl.pathname}?previewToken=${encodeURIComponent(tamperedToken)}`,
|
url: `${previewUrl.pathname}?previewToken=${encodeURIComponent(tamperedToken)}`,
|
||||||
});
|
});
|
||||||
expect(tamperedResponse.statusCode).toBe(403);
|
expect(tamperedResponse.statusCode).toBe(403);
|
||||||
expect(materialPreviewStore.getPage).not.toHaveBeenCalled();
|
expect(materialPreviewStore.getBundle).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const missingBundleResponse = await app.inject({
|
const missingBundleResponse = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `${pageUrl.pathname}${pageUrl.search}`,
|
url: `${previewUrl.pathname}${previewUrl.search}`,
|
||||||
});
|
});
|
||||||
expect(missingBundleResponse.statusCode).toBe(403);
|
expect(missingBundleResponse.statusCode).toBe(403);
|
||||||
|
|
||||||
materialPreviewStore.getPage.mockReturnValue({
|
materialPreviewStore.getBundle.mockReturnValue({
|
||||||
bundle: {
|
bundle: {
|
||||||
actorId: ACTOR_ID,
|
actorId: ACTOR_ID,
|
||||||
reimbursementId,
|
reimbursementId,
|
||||||
expiresAt: verified.payload.exp,
|
expiresAt: verified.payload.exp,
|
||||||
},
|
},
|
||||||
page: {
|
artifact: { buffer: previewBuffer },
|
||||||
buffer: previewBuffer,
|
|
||||||
materialId: "bbbbbbbb-bbbb-4bbb-8bbb-000000000001",
|
|
||||||
materialType: "attachment",
|
|
||||||
materialIndex: 1,
|
|
||||||
pageNumber: 1,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const pageResponse = await app.inject({ method: "GET", url: `${pageUrl.pathname}${pageUrl.search}` });
|
const previewResponse = await app.inject({ method: "GET", url: `${previewUrl.pathname}${previewUrl.search}` });
|
||||||
expect(pageResponse.statusCode).toBe(200);
|
expect(previewResponse.statusCode).toBe(200);
|
||||||
expect(pageResponse.rawPayload).toEqual(previewBuffer);
|
expect(previewResponse.rawPayload).toEqual(previewBuffer);
|
||||||
expect(pageResponse.headers["content-type"]).toContain("image/png");
|
expect(previewResponse.headers["content-type"]).toContain("application/pdf");
|
||||||
expect(pageResponse.headers["cache-control"]).toBe("private, no-store");
|
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({
|
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
data: expect.objectContaining({ action: "REIMBURSEMENT_MATERIAL_PREVIEWED", actor_id: ACTOR_ID }),
|
data: expect.objectContaining({ action: "REIMBURSEMENT_MATERIAL_PREVIEWED", actor_id: ACTOR_ID }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
prisma.auditLog.create.mockRejectedValueOnce(new Error("synthetic audit outage"));
|
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.statusCode).toBe(500);
|
||||||
expect(auditFailureResponse.body).not.toContain(previewBuffer.toString());
|
expect(auditFailureResponse.body).not.toContain(previewBuffer.toString());
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -238,6 +238,13 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
|||||||
},
|
},
|
||||||
createdAt: "2026-04-10T09:00:00.000Z",
|
createdAt: "2026-04-10T09:00:00.000Z",
|
||||||
submittedAt: "2026-04-10T10: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: [
|
invoices: [
|
||||||
{
|
{
|
||||||
id: "invoice-1",
|
id: "invoice-1",
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ async function buildApp(options = {}) {
|
|||||||
invoicePool: {
|
invoicePool: {
|
||||||
count: vi.fn(async () => 1),
|
count: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
|
reimbursementItem: {
|
||||||
|
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
|
||||||
|
},
|
||||||
budgetLedgerEntry: {
|
budgetLedgerEntry: {
|
||||||
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
|
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
|
||||||
},
|
},
|
||||||
@@ -230,6 +233,18 @@ describe("reimbursements routes expense standard integration", () => {
|
|||||||
resolveRecipientsForAssigneeMock.mockResolvedValue([]);
|
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 () => {
|
it("returns standardChecks from reimbursement precheck", async () => {
|
||||||
resolveEntityPrecheckMock.mockResolvedValue(
|
resolveEntityPrecheckMock.mockResolvedValue(
|
||||||
buildPrecheck({
|
buildPrecheck({
|
||||||
|
|||||||
@@ -1,65 +1,100 @@
|
|||||||
const {
|
const { PDFDocument } = require("pdf-lib");
|
||||||
MaterialPreviewError,
|
const sharp = require("sharp");
|
||||||
createReimbursementMaterialPreviewStore,
|
const { mergeMaterialsToPdf } = require("../../../src/services/reimbursementMaterialPdf");
|
||||||
} = require("../../../src/services/reimbursementMaterialPreview");
|
const { MaterialPreviewError, createReimbursementMaterialPreviewStore, runMergeWorker } = require("../../../src/services/reimbursementMaterialPreview");
|
||||||
|
|
||||||
describe("reimbursement material preview store", () => {
|
const limits = { maxPages: 40, maxImagePixels: 40_000_000, maxOutputBytes: 10 * 1024 * 1024 };
|
||||||
it("keeps material and PDF page order in a bounded short-lived bundle", async () => {
|
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;
|
let currentTime = 1_000;
|
||||||
const store = createReimbursementMaterialPreviewStore({
|
const merged = Buffer.from("deterministic-pdf");
|
||||||
now: () => currentTime,
|
const mergePdf = vi.fn().mockResolvedValue({ buffer: merged, pageCount: 3 });
|
||||||
randomUUID: () => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
const store = createReimbursementMaterialPreviewStore({ now: () => currentTime, randomUUID: () => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", mergePdf });
|
||||||
rasterizePdf: vi.fn().mockResolvedValue([Buffer.from("pdf-page-1"), Buffer.from("pdf-page-2")]),
|
const args = {
|
||||||
normalizeImage: vi.fn().mockResolvedValue(Buffer.from("image-page")),
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await store.createBundle({
|
|
||||||
actorId: "actor-1",
|
actorId: "actor-1",
|
||||||
reimbursementId: "reimbursement-1",
|
reimbursementId: "reimbursement-1",
|
||||||
expiresAt: 2_000,
|
expiresAt: 2_000,
|
||||||
materials: [
|
materials: [{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", fileSize: 10, fileSha256: "a" }],
|
||||||
{ id: "invoice-1", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 10 },
|
readSource: async () => Buffer.from("source"),
|
||||||
{ id: "attachment-1", materialType: "attachment", materialIndex: 1, mimeType: "image/png", fileSize: 10 },
|
};
|
||||||
],
|
const created = await store.createBundle(args);
|
||||||
readSource: async (material) => Buffer.from(material.id),
|
expect(created.fileSize).toBe(merged.length);
|
||||||
});
|
expect(store.getBundle({ bundleId: created.bundleId })?.artifact.buffer).toBe(merged);
|
||||||
|
expect(mergePdf).toHaveBeenCalledTimes(1);
|
||||||
expect(created.bundle.pages.map((page) => [page.materialType, page.materialIndex, page.pageNumber])).toEqual([
|
|
||||||
["invoice", 1, 1],
|
|
||||||
["invoice", 1, 2],
|
|
||||||
["attachment", 1, 1],
|
|
||||||
]);
|
|
||||||
expect(store.getPage({ bundleId: created.bundleId, pageIndex: 1 })?.page.buffer.toString()).toBe("pdf-page-2");
|
|
||||||
|
|
||||||
currentTime = 2_001;
|
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 () => {
|
it("deduplicates concurrent generation for the same ordered collection", async () => {
|
||||||
const store = createReimbursementMaterialPreviewStore({
|
const mergePdf = vi.fn(async () => ({ buffer: Buffer.from("one-pdf"), pageCount: 1 }));
|
||||||
limits: { maxMaterials: 1, maxSourceBytes: 10, maxPages: 1 },
|
let sequence = 0;
|
||||||
rasterizePdf: vi.fn().mockResolvedValue([Buffer.from("one"), Buffer.from("two")]),
|
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({
|
await expect(store.createBundle({
|
||||||
actorId: "actor-1",
|
actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 1_000,
|
||||||
reimbursementId: "reimbursement-1",
|
|
||||||
expiresAt: Date.now() + 1_000,
|
|
||||||
materials: [
|
materials: [
|
||||||
{ id: "one", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 6 },
|
{ id: "one", materialType: "invoice", mimeType: "application/pdf", fileSize: 3 },
|
||||||
{ id: "two", materialType: "attachment", materialIndex: 1, mimeType: "image/png", fileSize: 6 },
|
{ id: "two", materialType: "attachment", mimeType: "image/png", fileSize: 3 },
|
||||||
],
|
],
|
||||||
readSource: async () => Buffer.from("source"),
|
readSource: async () => Buffer.from("source"),
|
||||||
})).rejects.toMatchObject({ code: "material_preview_material_limit" });
|
})).rejects.toMatchObject({ code: "material_preview_material_limit" });
|
||||||
|
|
||||||
await expect(store.createBundle({
|
await expect(store.createBundle({
|
||||||
actorId: "actor-1",
|
actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 1_000,
|
||||||
reimbursementId: "reimbursement-1",
|
materials: [{ id: "one", materialType: "invoice", mimeType: "application/pdf", fileSize: 3 }],
|
||||||
expiresAt: Date.now() + 1_000,
|
readSource: async () => Buffer.from("source-too-large"),
|
||||||
materials: [
|
|
||||||
{ id: "one", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 6 },
|
|
||||||
],
|
|
||||||
readSource: async () => Buffer.from("source"),
|
|
||||||
})).rejects.toBeInstanceOf(MaterialPreviewError);
|
})).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户