实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
在新工单中引用
屏蔽一个用户