实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-01 09:25:44 +08:00
父节点 3d9ec4c5a7
当前提交 da35591bd5
共修改 29 个文件,包含 907 行新增和 527 行删除
@@ -581,9 +581,9 @@ reply.header(
### 1. Scope / Trigger
- 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.
category labels or the whole-reimbursement merged-preview grant.
- Purpose: keep expense items concise while allowing an approver to open one
derived PDF and continuously inspect every invoice and supporting attachment.
### 2. Signatures
@@ -591,34 +591,37 @@ reply.header(
`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.
`{ reason }`; response `{ expiresAt, signedUrl, originalName, mimeType,
fileSize, pageCount }`.
- Bundle fetch: `GET /api/reimbursements/:id/materials/preview/:bundleId?previewToken=...`.
- Runtime: `pdf-lib` and `sharp` execute inside Node `worker_threads`; this does
not enable the Compose worker profile and does not add a frontend PDF viewer.
### 3. Contracts
- 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.
- 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.
- PDF originals contribute every original page; JPG/PNG/WebP materials are
normalized with `sharp` and embedded one per page. The merged PDF is a
derived approval view, never an original or digital-signature fact source.
- A stable collection hash covers the explicitly ordered material type, ID,
file hash, MIME and byte count. The cache key also includes reimbursement ID
and merger version. Fix CreationDate, ModificationDate, Creator, Producer and
save options so identical input produces byte-identical output.
- The in-memory bundle is bounded to 20 materials, 50 MiB source bytes, 40
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`.
pages, 40 megapixels per image, 50 MiB output and 100 MiB process cache.
Worker execution has a fixed timeout; concurrent requests for one collection
share one in-flight generation.
- Tokens bind purpose, reimbursement, actor, bundle and expiry. Issue requires
current object access plus any attachment download reason; bundle GET audits
before bytes and returns exact cached bytes with `private, no-store`.
- Category labels are `vat_invoice -> 增值税发票`,
`full_vat_invoice -> 数电发票`, and
`electronic_train_ticket -> 电子火车票`. Known no-code categories never show
@@ -627,36 +630,35 @@ reply.header(
### 4. Validation & Error Matrix
- 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.
- Encrypted, unsupported or corrupt PDF/image, worker error or timeout -> the
entire merge fails and exposes 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.
- Bundle-issue audit failure deletes the bundle; bundle-access audit failure ->
`5xx` with no PDF body.
- Cache loss/process restart after issue -> `403`; never regenerate different
bytes behind an already-issued `fileSize`.
- Unknown or malformed OCR category -> neutral `发票` label; never guess a
category or rerun production OCR merely to fix display text.
### 5. Good/Base/Bad Cases
- 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.
- Good: three invoice PDFs and three images produce one ordered six-or-more-page
PDF; WeCom and PC use their native PDF readers while originals remain intact.
- Base: one single-page PDF is copied into a one-page derived preview.
- Bad: use `ignoreEncryption`, skip a failed material, add PDF.js to the client,
expose OCR raw data, or accept the first 40 pages of a 41-page file silently.
### 6. Tests Required
- Service tests assert material/page order, expiry, source/page/output/cache
limits and absence of partial bundles.
- Service tests assert deterministic bytes, all-page copy, image embedding,
20–30-page worker behavior, concurrent deduplication, expiry and limits.
- 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.
issue/access audit, audit-failure fail-closed behavior and exact PDF size.
- PC/WeCom Playwright asserts no invoice entry inside expense items, correct
category text, no `无代码/未识别销售方`, ordered `previewImage.urls`, fallback,
category text, no `无代码/未识别销售方`, one exact `previewFile` call, fallback,
editable actions and responsive layout. Run full server tests, Prisma
validation, frontend typecheck/build and Trellis governance gates.
@@ -665,14 +667,74 @@ reply.header(
#### Wrong
```tsx
const bundlePdf = mergePdfFiles(detail.invoices); // changes originals and adds a second document pipeline
const pages = await rasterizeEveryPdfOnThePhone(); // slow, blank-prone and duplicates PDF rendering
```
#### Correct
```tsx
const bundle = await fetchReimbursementMaterialPreviewBundle(reimbursementId);
await previewImagesInWecom(bundle.pages.map((page) => page.url));
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
```
## Scenario: Lightweight Travel Summary On Reimbursements
### 1. Scope / Trigger
- Trigger: a reimbursement contains an expense item whose expense-type category
is `travel`, or links an approved travel request.
### 2. Signatures
- DB nullable columns: `travel_purpose varchar(200)`, `travel_location
varchar(200)`, `travel_start_date date`, `travel_end_date date`.
- `PATCH /api/reimbursements/:id` accepts nullable camel-case counterparts.
- Detail response includes `travelSummary: { isTravel, purpose, location,
startDate, endDate }`.
### 3. Contracts
- Only `draft`/`rejected` applicant-editable reimbursements may change the four
fields. Reviewing records are immutable business facts.
- Linked travel requests seed purpose, ordered segment cities and dates. Blank
reimbursements persist user input directly.
- Historical read-only travel records may display description as purpose and
min/max travel-item dates; location remains `未填写` and is never guessed.
- Submit requires all four fields only for travel reimbursements; ordinary
reimbursements remain unaffected.
### 4. Validation & Error Matrix
- String over 200 chars or malformed date -> `400 VALID_001`.
- Start after end -> `400` with `travelEndDate: invalid_date_range`.
- Travel submit with missing fields -> `400` with
`travelSummary: incomplete` and stable `missingFields`.
- Edit while not draft/rejected -> existing `409 invalid_edit_status`.
### 5. Good/Base/Bad Cases
- Good: travel item plus complete purpose/location/date range submits normally.
- Base: office-supply reimbursement has no travel summary and is not blocked.
- Bad: infer a destination from invoice OCR, attachment name or free text.
### 6. Tests Required
- Prisma validation and all migrations against an empty PostgreSQL database.
- Route tests for detail fallback, complete/incomplete travel submit, date order
and non-travel compatibility; frontend typecheck/build and PC/mobile UI tests.
### 7. Wrong vs Correct
#### Wrong
```js
travelLocation = invoice.ocrResult.sellerName;
```
#### Correct
```js
travelLocation = reimbursement.travel_location ?? null;
```
## Scenario: Payment Batch Receipt Attention Count
+15 -16
查看文件
@@ -64,11 +64,10 @@ or administration workflow has already migrated.
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.
`{ reason }`; response contains one short-lived merged PDF URL plus exact
`name`, `size` and `pageCount`.
- Native call: `previewFile({ url, name, size })`; `name` includes an extension
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
@@ -86,10 +85,10 @@ 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.
- Whole-reimbursement continuous preview derives one deterministic PDF: PDF
pages are copied and images are embedded as pages, then WeCom receives one
`previewFile` call. The derivative is explicitly not an original or digital-
signature fact source; individual originals remain separately authorized.
- 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
@@ -106,7 +105,7 @@ 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
- Missing/invalid merged PDF URL, name or size -> 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.
@@ -116,8 +115,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.
- Base: a WeCom approver requests one authorized reimbursement PDF bundle,
then `previewFile` opens it for continuous native scrolling.
- 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.
@@ -132,9 +131,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.
- Continuous-preview Playwright asserts one `previewFile` call with exact URL,
filename and byte size. Backend tests separately prove deterministic output,
exact response size and object/actor/bundle binding.
- Production deployment and real-device preview remain separate approvals.
### 7. Wrong vs Correct
@@ -153,9 +152,9 @@ 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
authenticated material-bundle issue -> one <=120s deterministic PDF URL
-> ww.previewFile({ url, nameWithExtension, exactSize })
-> audit before exact cached PDF bytes
```
## Scenario: WeCom Member Data Is A Read-Only Source For OA