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