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

这个提交包含在:
2026-08-01 00:17:54 +08:00
父节点 b6485a278e
当前提交 8a9279efce
共修改 26 个文件,包含 1211 行新增和 178 行删除
@@ -576,96 +576,103 @@ reply.header(
);
```
## Scenario: Reimbursement Material Presentation And Item Linking
## Scenario: Reimbursement Material Image Bundle And Category-Aware Summary
### 1. Scope / Trigger
- 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) => <Button>查看原件</Button>);
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) => (
<button aria-label={`查看关联发票:${formatInvoiceDisplayTitle(invoice)}`}>
{formatInvoiceDisplayTitle(invoice)}
</button>
));
const bundle = await fetchReimbursementMaterialPreviewBundle(reimbursementId);
await previewImagesInWecom(bundle.pages.map((page) => page.url));
```
## Scenario: Payment Batch Receipt Attention Count
+25
查看文件
@@ -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