diff --git a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md index c6cc6e1f..8e4864e3 100644 --- a/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md +++ b/.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md @@ -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 -- Trigger: changing PC or mobile reimbursement-detail presentation for invoice - originals, supporting attachments or their relationship to expense items. -- Purpose: let finance reconcile each item with its invoice without exposing a - separate technical “open file” button for every material. +- Trigger: changing PC/mobile reimbursement material presentation, invoice OCR + category labels, PDF rasterization or the continuous-preview grant. +- Purpose: keep expense items concise while allowing an approver to enter once + and continuously inspect every invoice and supporting attachment. ### 2. Signatures -- Existing detail payload: `detail.items[]`, `detail.invoices[]` with - `invoice.linkedItemId: string | null`, and `detail.attachments[]`. -- Existing preview actions: `openInvoiceMutation` and - `handleOpenAttachment`; WeCom continues through `previewFile` and PC through - its existing browser-safe path. -- Stable UI selectors: `reimbursement-materials` and - `mobile-reimbursement-materials`; existing invoice/attachment child test IDs - remain available for focused regressions. +- Detail payload keeps `invoice.linkedItemId` and adds the safe fields + `documentCategory`, `documentSubtype` and `buyerName`; it never exposes the + raw OCR provider response. +- Bundle issue: `POST /api/reimbursements/:id/materials/preview-bundle`, optional + `{ reason }`; response `{ expiresAt, pages: [{ url, materialType, + materialIndex, pageNumber }] }`. +- Page fetch: `GET /api/reimbursements/:id/materials/preview/:bundleId/:pageIndex?previewToken=...`. +- Runtime dependency: production Alpine image contains `poppler-utils`; + `reimbursementMaterialPreview` invokes `pdftoppm` with `execFile`, never a shell. ### 3. Contracts -- Present invoice originals and supporting attachments under one user-facing - `报销材料` section with separate type labels and counts. Do not merge their - database types, API fields, authorization, audit or lifecycle rules. -- Within each expense item, render only invoices whose - `linkedItemId === item.id`. The linked invoice summary is itself a semantic - preview button and shows an invoice identifier, seller and amount. -- Keep every invoice, including unlinked or stale-linked invoices, in the - material section. Never infer a link from amount, order or list position. -- The material information row is the preview target; do not append a repeated - visible `查看原件` button to each file. Draft-only add/remove/upload/delete - actions remain separate buttons so preview cannot mutate data. -- Do not introduce a file-browser route, carousel, bulk download or aggregate - backend endpoint for the small-team detail page unless later real use proves - that the inline material list is insufficient. -- Clickable rows have a descriptive `aria-label`, visible focus, disabled state - during open, a chevron affordance and a minimum 44px mobile touch target. +- Expense-item UI does not render or preview linked invoices. Preserve + `linkedItemId` for finance/audit semantics; presentation must not delete or + rewrite the relationship. +- The read-only detail shows one `报销材料` count and one `查看全部材料` action. + Editable drafts may retain per-record management rows; read-only users see + them only when continuous preview fails and the original-file fallback opens. +- Material order is invoices by `created_at,id`, then attachments by + `created_at,id`, then PDF page order. Never infer order from amount, file name + or `linkedItemId`. +- PDFs are rasterized separately; images are normalized with `sharp`. Do not + merge PDFs, strip their signatures, use PDF.js in the WeCom page, or persist + preview images as business attachments. +- The in-memory bundle is bounded to 20 materials, 50 MiB source bytes, 40 + pages, 1800px page width, 40 megapixels per page, 40 MiB bundle output and + 80 MiB process cache. + Poppler gets a fixed DPI, timeout and temporary directory; render one page + beyond the limit so an oversized PDF fails instead of being silently cut. +- Tokens bind purpose, reimbursement, actor, bundle, page index and expiry. + Issue requires current object access plus any attachment download reason; + every page access writes audit before bytes and returns `private, no-store`. +- 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 -- Matching `linkedItemId` -> show the invoice inside that item and in the - material section. -- `linkedItemId=null` or target item absent -> show only in the material - section; do not attach it to the first or amount-matching item. -- No invoices and no attachments -> one `暂无报销材料` empty state. -- Preview pending -> disable repeated opens; existing preview errors and - download-reason behavior remain authoritative. -- Read-only approver -> preview rows visible, mutation actions absent. +- No materials -> do not issue a bundle; show the existing empty state. +- Unsupported/corrupt input, missing Poppler or raster timeout -> stable + continuous-preview error and expose authorized original-file fallback. +- Material/source/page/output/cache limit -> bounded validation error; never + return a partial bundle or silently omit pages. +- Missing required reason -> `400`; invalid/expired/tampered/object-mismatched + token, absent cache bundle or process restart -> `403` with no bytes. +- Bundle-issue audit failure deletes the bundle; page-access audit failure -> + `5xx` with no image body. +- Unknown or malformed OCR category -> neutral `发票` label; never guess a + category or rerun production OCR merely to fix display text. ### 5. Good/Base/Bad Cases -- Good: a hotel item shows its linked hotel invoice inline, while an unlinked - payment screenshot remains under `报销材料` and both originals are previewable. -- Base: one item, one linked invoice and no attachment renders one inline - invoice summary plus one typed material row. -- Bad: flatten invoices away from items, guess links by equal amount, merge all - file records into one backend type, or add a `查看原件` button beside every - row. +- Good: three invoice PDFs and three PNG attachments produce one ordered image + URL array; WeCom swipes natively and PC loads the same bounded pages. +- Base: one single-page PDF becomes one PNG preview while its original remains + unchanged and separately auditable. +- Bad: concatenate PDFs, expose OCR raw data, put invoice buttons back into + every expense item, or accept the first 40 pages of a 41-page file silently. ### 6. Tests Required -- Focused PC and WeCom Playwright fixtures contain at least one linked and one - unlinked invoice plus one supporting attachment. -- Assert the linked invoice appears only in the matching item; the unlinked - invoice appears in materials but not in any item. -- Assert material counts, absence of exact visible `查看原件` buttons, PC open - URL behavior, WeCom `previewFile` name/size/URL, 44px touch target and mobile - portrait/landscape overflow. -- Preserve editable add/remove/upload/delete regression coverage, then run - frontend typecheck/build and Trellis governance gates. +- Service tests assert material/page order, expiry, source/page/output/cache + limits and absence of partial bundles. +- Route tests assert authorization, reason gate, token binding, cache miss, + issue/access audit, audit-failure fail-closed behavior and PNG response. +- A target-image probe must generate synthetic PDF bytes and prove the built + Alpine container returns PNG through the production rasterizer. +- PC/WeCom Playwright asserts no invoice entry inside expense items, correct + category text, no `无代码/未识别销售方`, ordered `previewImage.urls`, fallback, + editable actions and responsive layout. Run full server tests, Prisma + validation, frontend typecheck/build and Trellis governance gates. ### 7. Wrong vs Correct #### Wrong ```tsx -detail.invoices.map((invoice) => ); +const bundlePdf = mergePdfFiles(detail.invoices); // changes originals and adds a second document pipeline ``` #### Correct ```tsx -detail.invoices - .filter((invoice) => invoice.linkedItemId === item.id) - .map((invoice) => ( - - )); +const bundle = await fetchReimbursementMaterialPreviewBundle(reimbursementId); +await previewImagesInWecom(bundle.pages.map((page) => page.url)); ``` ## Scenario: Payment Batch Receipt Attention Count diff --git a/.trellis/spec/hyb-oa/wecom-boundary.md b/.trellis/spec/hyb-oa/wecom-boundary.md index 71363297..7b9209a8 100644 --- a/.trellis/spec/hyb-oa/wecom-boundary.md +++ b/.trellis/spec/hyb-oa/wecom-boundary.md @@ -62,8 +62,13 @@ or administration workflow has already migrated. - Reimbursement attachment grant: `POST /api/reimbursements/:id/attachments/:attachmentId/preview-url`, optional 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 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 @@ -81,6 +86,14 @@ or administration workflow has already migrated. - PC keeps its browser/authenticated download behavior. Only a detected WeCom WebView dynamically loads `@wecom/jssdk` and calls `previewFile`; do not send the actual file through a WeCom message or native approval payload. +- `previewImage` is the only native continuous-preview path for mixed original + PDF/image reimbursement materials: OA first rasterizes each PDF separately + into short-lived images. `previewFile` accepts one file only; never pass an + invented array to it or merge originals merely to simulate navigation. +- 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 @@ -93,6 +106,9 @@ or administration workflow has already migrated. - Missing required download reason -> `400 { reason: "required" }`. - Expired, tampered, wrong-purpose or object-mismatched file grant -> `403`. - Access audit failure -> `5xx` with no file body. +- Empty or inconsistent `previewImage` URL arrays -> frontend stops before the + 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 @@ -100,6 +116,8 @@ or administration workflow has already migrated. `previewFile`, sees the original in WeCom and receives no edit capability. - Base: a PC user opens the same invoice through the browser and downloads an attachment through the existing authenticated Blob path. +- Base: a WeCom approver requests one authorized reimbursement image bundle, + then `previewImage` opens the ordered PNG URLs with native swiping. - Bad: expose a long-lived generic `?token=`, trust a caller-supplied size/name, put a provider ticket in the response, or use async `window.open(blob:)` in WeCom and assume desktop popup behavior applies. @@ -114,6 +132,9 @@ or administration workflow has already migrated. - Playwright injects a synthetic `WeixinJSBridge`, asserts `previewFile` receives the exact URL/name/size for invoice and attachment, and asserts no `window.open` call in WeCom. PC assertions keep browser URL and Blob behavior. +- Continuous-preview Playwright asserts one `previewImage` call, the exact + ordered URL array and no JS-SDK load in a normal PC browser. Backend tests + separately prove the page URLs remain object/actor/bundle/index bound. - Production deployment and real-device preview remain separate approvals. ### 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 -> ww.previewFile({ url, nameWithExtension, exactSize }) -> audit before file bytes + +authenticated material-bundle issue -> ordered <=120s PNG URLs +-> ww.previewImage({ current: urls[0], urls }) +-> audit each page before image bytes ``` ## Scenario: WeCom Member Data Is A Read-Only Source For OA diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md index 7454aae5..c7dd7c8d 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/design.md @@ -205,9 +205,10 @@ ## 12. 报销明细与材料的 KISS 对齐 -- 交互采用“展示统一、语义保留”:用户界面把发票原件和补充附件统一归入“报销材料”,但 API、数据库类型、授权、审计和生命周期保持不变;付款后材料不在本轮提前接入。 -- 财务核对以费用明细为主。两端页面按 `invoice.linkedItemId === item.id` 将发票摘要渲染在对应明细内,摘要至少包含发票标识、销售方和金额;摘要整行是明确的键盘/触控预览入口,并复用现有 `openInvoiceMutation`。 -- 未关联或关联目标已不存在的发票只出现在“报销材料”区,不猜测关联关系。材料区显示“发票 N · 补充附件 M”,并继续列出全部材料,方便主管快速通览以及处理未关联材料。 -- 材料列表以带类型标签的文件信息行作为预览入口,不再给每份材料追加“查看原件”按钮。草稿编辑态的添加发票、移除发票、上传附件和删除附件仍作为独立显式动作,避免把预览与数据变更混在同一点击目标。 -- 不新增材料浏览页、底部抽屉、上一份/下一份状态、批量下载、缩略图瀑布流或新的后端聚合接口。PC 与移动端保持相同信息结构,但沿用各自已有桌面表格和移动卡片组件。 -- 可点击信息行必须有可见焦点、自然中文 `aria-label`,移动端触控目标不小于 44px;加载中禁用重复打开。企微仍走 `previewFile`,PC 仍走当前安全预览路径。 +- 交互采用“一个入口、一次通览、语义保留”:费用明细只展示费用事实,不再渲染关联发票;`linkedItemId`、票据/附件类型、对象权限、审计和生命周期均不改变。付款回单仍属于付款阶段材料,不提前混入本次提交材料。 +- 报销材料只读态首屏显示“发票 N · 补充附件 M”和一个明确的“查看全部材料”按钮,不为每份文件重复暴露技术操作。草稿/驳回编辑态仍保留发票添加/移除和附件上传/删除所需列表,数据变更动作与只读通览分离。 +- 官方 `previewFile` 只有单文件参数;混合 PDF/图片不能直接连续切换。服务端新增报销级图片预览 grant:授权后读取当前绑定的发票 PDF 与补充附件,PDF 通过受控 Poppler `pdftoppm` 子进程逐页栅格化,JPG/PNG/WebP 通过现有 `sharp` 规范化为浏览器/企微兼容图片,生成短时图片预览包。企微调用一次 `previewImage({ current, urls })` 原生左右滑动,PC/普通浏览器在同一材料浏览页按顺序连续显示。 +- 预览顺序固定为发票创建顺序、补充附件创建顺序、PDF 内页顺序;不根据 `linkedItemId`、金额或文件名猜测排序。图片预览包不进入业务表、不形成长期附件,只在单进程有界内存缓存中保存至 grant 过期,token 绑定 purpose、reimbursement、actor、随机 bundle key、图片索引和过期时间。 +- grant 签发前复用报销对象级读权限及附件治理/理由门禁;生成时限制材料数量、源文件总字节、PDF 总页数、单页像素和输出字节,Poppler 使用无 shell 的固定参数、超时和临时目录。每个图片 GET 验签、核对缓存 bundle 与 actor 后先写审计再发送。缓存缺失、进程重启、材料不支持/损坏、超限或栅格化失败时返回稳定错误,客户端展开现有逐份原件预览作为降级,不静默漏页。 +- 后端从已脱敏持久化 OCR `result_json` 只映射 `documentCategory` / `documentSubtype` 到报销详情,不下发原始腾讯响应。前端共享格式化函数按 `vat_invoice`、`full_vat_invoice`、`electronic_train_ticket` 选择票种名称和适用字段;未知票种使用中性“发票 + 号码/文件名”,不显示“无代码”或对不适用字段宣称“未识别”。 +- “查看全部材料”和降级行均使用语义化 button、可见焦点、加载反馈和至少 44px 触控目标;保留可预测返回。PC 与移动端共享业务文案、票种格式化逻辑和材料浏览页;不引入 PDF.js,企微优先原生 `previewImage`,PC/普通浏览器在短时 URL 过期前主动加载有界图片包。 diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md index 02351a6d..9864c36d 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/implement.md @@ -158,7 +158,18 @@ - [x] 发布脚本的来源、环境合同、init、migration、`/ready`、external/runtime smoke 和公网资源扫描通过;公网构建标识为 `0e44aa72f3f5`,活动资源包含 PC/移动统一材料区标识。 - [x] 复用现有 unit 参数和运行时间刷新 automatic backup 三项与 WARCH3;4 个 service 构建身份一致,4 个 timer 均 enabled/active。 - [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. 完成条件 @@ -225,3 +236,10 @@ - 本次发布第一次本地命令因包含临时目录递归清理,被安全护栏在进程启动前拒绝,未生成快照、未连接或改变生产。第二次移除清理动作后,从干净 clone 发布成功;发布后按提交标识和受限临时目录模式删除本轮唯一快照,未保留构建日志。 - 发布后运维对齐:只读检查先发现 automatic backup 三项与 WARCH3 均仍为旧构建身份;复用现有 unit 参数和运行时间刷新后,4 个 service 匹配 `0e44aa7`、4 个 timer enabled/active。新 automatic backup daily 与源端 watchdog 均为 `success/0`。 - 本轮未同步生产 `.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;该失败未执行生产或业务写入。 +- 本步骤只完成本地实现与验证;未推送、未部署、未修改生产配置或真实业务数据,不能据此通知真人线上测试。 diff --git a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md index 4ebc0d73..91aa71bd 100644 --- a/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md +++ b/.trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md @@ -43,7 +43,9 @@ - 真实审批继续暴露材料查看阻断:PC 能打开普通附件,但 PC 与移动报销详情均没有发票原件入口,企微 WebView 中异步 `window.open(blob:)` 只下载附件而不能预览。企业微信自建应用内必须优先使用官方 `ww.previewFile` / `wx.previewFile` 原生能力查看 PDF;普通 PC 浏览器保留浏览器预览,两端保持“查看原件”的业务能力一致。 - 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。 - 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 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 @@ -79,8 +81,10 @@ - [x] 移动报销列表仅对具备 `expense.view_all` 的用户显示“我的 / 全部”切换,默认“我的”;切换“全部”后能通过现有列表进入他人报销详情,普通员工始终只能查看本人列表。 - [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。 - [x] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,用户已确认能在企微查看当前真实报销材料。 -- [x] PC 与移动端每条费用明细展示通过 `linkedItemId` 关联的发票摘要,点击该摘要可沿用现有安全预览能力打开原件;未关联发票不错误归入任何明细。 -- [x] PC 与移动端将原“发票 / 附件材料”收敛为单一“报销材料”区,显示发票与补充附件计数;材料信息行可点击预览,不再为每份材料重复渲染“查看原件”按钮,同时保留草稿编辑态的添加、移除、上传和删除能力。 +- [x] PC 与移动端费用明细不再展示或预览关联发票;`linkedItemId` 数据关系继续保留,仅不在费用明细 UI 重复呈现。 +- [x] PC 与移动端单一“报销材料”区显示发票与补充附件计数,并提供一个“查看全部材料”动作;企微调用一次原生 `previewImage` 打开有序图片数组,PC 使用同一图片预览包连续浏览。 +- [x] 图片预览包只包含当前有权查看的报销所绑定发票与补充附件,按“发票 → 补充附件、各自稳定创建顺序、PDF 内页序”生成;短时授权、无持久业务副本、访问审计、数量/页数/源文件字节/输出像素和输出字节上限及失败降级均有验证。 +- [x] 票据摘要按 OCR `documentCategory` 展示正确票种与适用字段;数电发票和电子火车票不再出现误导性的“无代码”,电子火车票不再出现“未识别销售方”。当前真实票据不重跑 OCR、不改原始识别数据。 ## Out of Scope @@ -104,4 +108,4 @@ - 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。 - 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。 - 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。 -- 用合成多明细、已关联发票、未关联发票和补充附件验证 PC/移动一致性:关联发票出现在正确明细内;统一材料区计数正确且所有材料仍可预览;页面不再出现逐文件“查看原件”按钮;编辑态管理动作不回归。 +- 用合成普通增值税发票、数电发票、电子火车票、PDF 与 JPG/PNG/WebP 补充附件验证 PC/移动一致性:费用明细无发票预览;票种标题不虚构缺失字段;统一材料区计数正确;一次图片预览包包含全部可读材料并保持顺序;异常/加密 PDF、超限和栅格化失败可安全降级;编辑态管理动作不回归。 diff --git a/client/src/api/invoicePool.ts b/client/src/api/invoicePool.ts index 8fa3d7df..13bace9e 100644 --- a/client/src/api/invoicePool.ts +++ b/client/src/api/invoicePool.ts @@ -28,6 +28,8 @@ export type InvoicePoolListItem = { ocrAttempts: number; ocrLastError: string | null; ocrCompletedAt: string | null; + documentCategory: string | null; + documentSubtype: string | null; invoiceCode: string | null; invoiceNumber: string | null; invoiceDate: string | null; @@ -94,6 +96,8 @@ export type InvoicePoolDetail = { ocrLastRequestedAt: string | null; ocrLastError: string | null; ocrCompletedAt: string | null; + documentCategory: string | null; + documentSubtype: string | null; invoiceCode: string | null; invoiceNumber: string | null; invoiceDate: string | null; @@ -136,6 +140,9 @@ export type BindableInvoiceSummary = { invoiceNumber: string | null; invoiceDate: string | null; sellerName: string | null; + buyerName: string | null; + documentCategory: string | null; + documentSubtype: string | null; totalAmount: string | null; originalName: string | null; createdAt: string | null; @@ -156,6 +163,9 @@ export type InvoiceStatusItem = { invoiceNumber: string | null; invoiceDate: string | null; sellerName: string | null; + buyerName: string | null; + documentCategory: string | null; + documentSubtype: string | null; totalAmount: string | null; originalName: string | null; linkedReimbursementId: string | null; diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 408212cc..f1836546 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -173,6 +173,9 @@ export type ReimbursementDetail = { invoiceNumber: string | null; invoiceDate: string | null; sellerName: string | null; + buyerName: string | null; + documentCategory: string | null; + documentSubtype: string | null; totalAmount: string | null; originalName: 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( + `/api/reimbursements/${reimbursementId}/materials/preview-bundle`, + { + method: "POST", + body: JSON.stringify(reason ? { reason } : {}), + }, + ); +} + 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`, { diff --git a/client/src/lib/invoiceStatus.ts b/client/src/lib/invoiceStatus.ts index 4fe68a02..c0617a32 100644 --- a/client/src/lib/invoiceStatus.ts +++ b/client/src/lib/invoiceStatus.ts @@ -7,6 +7,8 @@ type InvoiceDisplayLike = { invoiceNumber?: string | null; invoiceDate?: string | null; sellerName?: string | null; + buyerName?: string | null; + documentCategory?: string | null; originalName?: string | null; unavailableReason?: string | null; unavailableMessage?: string | null; @@ -18,6 +20,26 @@ type InvoiceDisplayLike = { ocrLastError?: string | null; }; +const INVOICE_CATEGORY_LABELS: Record = { + 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 = { pending_ocr: "识别中", ocr_failed: "识别失败", @@ -37,11 +59,16 @@ export function formatInvoiceDisplayTitle(invoice: InvoiceDisplayLike | null | u return "未识别发票"; } - if (invoice.invoiceCode || invoice.invoiceNumber) { - return `${invoice.invoiceCode || "无代码"}-${invoice.invoiceNumber || "无号码"}`; + const typeLabel = resolveInvoiceTypeLabel(invoice); + 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) { diff --git a/client/src/lib/wecomFilePreview.ts b/client/src/lib/wecomFilePreview.ts index 6945ebde..b210acf8 100644 --- a/client/src/lib/wecomFilePreview.ts +++ b/client/src/lib/wecomFilePreview.ts @@ -24,7 +24,7 @@ async function configuredWecomSdk() { } ww.register({ corpId: config.corpId, - jsApiList: ["previewFile"], + jsApiList: ["previewFile", "previewImage"], async getConfigSignature(url) { const signed = await fetchWecomJsSdkSignature(url || currentPageUrl()); return { @@ -58,6 +58,19 @@ export async function previewFileInWecom(file: NativePreviewFile) { return true; } +export async function previewImagesInWecom(urls: string[], current = urls[0]) { + if (!isWecomWebView()) { + return false; + } + const normalized = urls.map((url) => url.trim()).filter(Boolean); + if (!normalized.length || !current || !normalized.includes(current)) { + throw new Error("图片预览信息不完整"); + } + const ww = await configuredWecomSdk(); + await ww.previewImage({ current, urls: normalized }); + return true; +} + export function openFileInBrowser(url: string) { window.open(url, "_blank", "noopener,noreferrer"); } diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index 347a516f..44d56268 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -23,6 +23,7 @@ import { downloadReimbursementAttachment, fetchReimbursementAttachmentPreviewUrl, fetchReimbursementDetail, + fetchReimbursementMaterialPreviewBundle, MAX_REIMBURSEMENT_ATTACHMENT_FILES, precheckReimbursement, submitReimbursement, @@ -56,6 +57,7 @@ import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFi import { buildPrecheckDialogMessage } from "../lib/precheck"; import { formatInvoiceDisplayTitle, + resolveInvoicePartyText, resolveInvoiceNextStepText, resolveInvoiceStatusLabel, resolveInvoiceUserMessage, @@ -63,13 +65,13 @@ import { import { resolveErrorMessage } from "../lib/resolveErrorMessage"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; 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 { withUploadTimeout } from "../lib/uploadTimeout"; import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker"; import { cn } from "../lib/utils"; import { isWecomWebView } from "../lib/runtimeContext"; -import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview"; +import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview"; type ItemFormState = { mode: "create" | "edit"; @@ -194,6 +196,7 @@ export function MobileReimbursementDetailPage() { const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState([]); const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState([]); const [invoiceActionError, setInvoiceActionError] = useState(""); + const [materialPreviewError, setMaterialPreviewError] = useState(""); const [invoiceActionMessage, setInvoiceActionMessage] = useState(""); const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState([]); const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 }); @@ -285,6 +288,9 @@ export function MobileReimbursementDetailPage() { invoiceNumber: item.invoiceNumber, invoiceDate: item.invoiceDate, sellerName: null, + buyerName: null, + documentCategory: item.documentCategory, + documentSubtype: item.documentSubtype, totalAmount: item.totalAmount, originalName: item.originalName, createdAt: item.createdAt, @@ -731,6 +737,36 @@ export function MobileReimbursementDetailPage() { 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 () => { if (!detail) { return; @@ -905,6 +941,24 @@ export function MobileReimbursementDetailPage() { ) : null} + {detail.invoices.length || (detail.attachments ?? []).length ? ( + + ) : null} + + {materialPreviewError ? ( +

+ {materialPreviewError},可在下方逐份打开原件。 +

+ ) : null} + {itemError ? (
{itemError}
) : null} @@ -984,28 +1038,6 @@ export function MobileReimbursementDetailPage() {
{formatCurrency(item.amount)}
{item.description ?

{item.description}

: null} - {detail.invoices - .filter((invoice) => invoice.linkedItemId === item.id) - .map((invoice) => ( - - ))} {canEdit ? (
diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 1b9655cc..272e6adc 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -15,6 +15,7 @@ import { downloadReimbursementAttachment, fetchReimbursementAttachmentPreviewUrl, fetchReimbursementDetail, + fetchReimbursementMaterialPreviewBundle, MAX_REIMBURSEMENT_ATTACHMENT_FILES, precheckReimbursement, submitReimbursement, @@ -34,7 +35,7 @@ import { } from "../api/invoicePool"; import { toast } from "sonner"; import { isWecomWebView } from "../lib/runtimeContext"; -import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview"; +import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { CardSkeleton } from "../components/common/CardSkeleton"; @@ -61,13 +62,14 @@ import { PageScaffold } from "../components/patterns/PageScaffold"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { formatInvoiceDisplayTitle, + resolveInvoicePartyText, resolveInvoiceNextStepText, resolveInvoiceStatusLabel, resolveInvoiceUserMessage, } from "../lib/invoiceStatus"; import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker"; 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 { TEST_IDS } from "../lib/testIds"; import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles"; @@ -152,6 +154,7 @@ export function ReimbursementDetailPage() { const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState([]); const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null); const [invoiceActionError, setInvoiceActionError] = useState(""); + const [materialPreviewError, setMaterialPreviewError] = useState(""); const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState([]); const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 }); const [editorInitialValues, setEditorInitialValues] = useState({ @@ -727,6 +730,36 @@ export function ReimbursementDetailPage() { 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) { return (
@@ -1339,28 +1372,6 @@ export function ReimbursementDetailPage() { {check.standardName}:当前金额超出费用标准。
))} - {detail.invoices - .filter((invoice) => invoice.linkedItemId === item.id) - .map((invoice) => ( - - ))} {canEditItems ? ( @@ -1426,18 +1437,36 @@ export function ReimbursementDetailPage() {

发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}

- {canOpenInvoiceBindDialog ? ( - - ) : null} +
+ {detail.invoices.length || (detail.attachments ?? []).length ? ( + + ) : null} + {canOpenInvoiceBindDialog ? ( + + ) : null} +
+ {materialPreviewError ? ( +

+ {materialPreviewError},可在下方逐份打开原件。 +

+ ) : null} + {invoiceActionError ? (

) : null} + {canOpenInvoiceBindDialog || materialPreviewError ? (

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

全部报销材料

+

按发票、补充附件和文件页序排列

+
+ {bundle ? 共 {bundle.pages.length} 页 : null} +
+ + {bundleQuery.isLoading && !bundle ? : null} + {bundleQuery.isError && !bundle ? ( +
+

{resolveErrorMessage(bundleQuery.error)}

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