实现报销材料连续预览(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -576,96 +576,103 @@ reply.header(
|
||||
);
|
||||
```
|
||||
|
||||
## Scenario: Reimbursement Material Presentation And Item Linking
|
||||
## Scenario: Reimbursement Material Image Bundle And Category-Aware Summary
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
在新工单中引用
屏蔽一个用户