feat(reimbursement-small-team-form-validation): 完善付款批次核对与回单预览

这个提交包含在:
2026-08-05 01:02:51 +08:00
父节点 9db8bbc338
当前提交 d4400116c5
共修改 24 个文件,包含 979 行新增和 245 行删除
@@ -514,6 +514,7 @@ const label = status === "draft" ? "草稿" : status;
- A reviewed bank batch with no active receipt distinguishes no order vs order generated; an active receipt changes guidance to registration. Payment-order count affects copy only when `requiresBankReceipt=true`.
- `failed` uses `exception`. A cancellation before review makes the review stage `exception` and payment stage `terminated`; a cancellation after review completes review and makes payment `exception`. `terminated` is a neutral stopped state, not pending, completed or failed.
- `actorSource=creator|reviewer|executor|canceller` is an internal semantic reference. The shared service never queries employees; each response assembler resolves only a nullable actor name and never exposes actor IDs inside progress.
- When an authorized reimbursement has entered a batch, its processing progress exposes at most one batch link. `draft` and cancellation before review attach it to `付款复核`; `reviewed|executed|failed` and cancellation after review attach it to `付款完成`. Unauthorized actors receive neither link.
### 4. Validation & Error Matrix
@@ -537,6 +538,7 @@ const label = status === "draft" ? "草稿" : status;
- Pure table-driven unit tests cover all five statuses, both cancellation positions, bank/non-bank channels, order count, receipt count and actorSource.
- A structural test extracts both route assembler functions and rejects route-local payment status matrices or duplicate `付款复核` / `付款完成` label definitions.
- Route tests assert actor name/time projection, list/detail field separation and absence of snapshot, bank ciphertext and actor IDs inside progress.
- Reimbursement route tests assert the five batch statuses, both cancellation positions and the no-permission case never produce duplicate batch links.
- PC/mobile E2E uses synthetic responses for all five states, verifies `terminated`, bank/non-bank copy, one visible primary action per viewport and upload-to-register in-place switching.
- Reimbursement E2E asserts the four-block order and that submit, print, remind, withdraw and delete retain their state/permission, reason/confirmation, request and refresh contracts after action-panel migration.
@@ -915,3 +915,92 @@ const needsReceipt = batches.filter((batch) =>
batch.items.some((item) => (item.paymentChannelOverride ?? batch.defaultPaymentChannel) === "bank"),
).length;
```
## Scenario: Payment Batch Expense Evidence And Receipt Preview
### 1. Scope / Trigger
- Trigger: changing the reimbursement facts shown in payment-batch detail or
the authenticated online-view path for bank receipts.
- This is a read-only evidence surface. It must not change reimbursement totals,
frozen batch amounts, payment state, maker-checker rules or receipt upload policy.
### 2. Signatures
- `GET /api/payment-batches/:id` adds item-level `expenseItems`,
`expenseItemsTotal` and `expenseItemsAmountMatches` only in the detail DTO.
- Each expense item contains only `id`, `expenseTypeName`, `expenseDate`,
`description` and money-string `amount`.
- `GET /api/payment-batches/:id/receipt-attachments/:attachmentId/file?mode=preview`
returns an authenticated Blob response; omitted mode remains download.
### 3. Contracts
- Load reimbursement items and expense types once for all entities in the
selected batch. List, create, review, execute and cancel responses do not add
expense evidence fields.
- `PaymentBatchItem.amount` remains the frozen payment fact. Expense item totals
are reconciliation evidence; a mismatch produces a warning and never rewrites
either source.
- Payment detail orders each card as reimbursement summary, expense evidence and
subtotal, payment information, then result/receipt. It links back to the source
reimbursement instead of copying invoice or attachment operations.
- Historical `travel_order` batches keep their own title and payment facts. They
do not render reimbursement expense evidence or a fabricated reimbursement link.
- Preview uses the fixed server purpose `付款批次内在线预览`, audits before bytes,
and permits only revalidated PDF/JPEG/PNG/WebP. Download still requires a user
purpose when governance policy is `reason_required`.
- The client fetches the preview through the authenticated API, displays the Blob
in the existing dialog and revokes every object URL on close, replacement or
unmount.
### 4. Validation & Error Matrix
- Batch/list reader lacks permission or department scope -> existing `403`/scope
rejection; no expense or receipt bytes.
- Reimbursement has no readable items -> detail returns an empty array and `0.00`;
UI shows the missing-evidence warning but not a second mismatch warning.
- Expense subtotal differs from frozen item amount ->
`expenseItemsAmountMatches=false`; UI directs the reviewer to the source form.
- Receipt is voided, quarantined, missing, oversized, corrupt or has unsupported
preview MIME -> no bytes; existing blocked audit/error contract applies.
- Preview audit write fails -> non-success response with no file body.
### 5. Good/Base/Bad Cases
- Good: one batch detail query loads three reimbursements' items in one batched
read and displays each subtotal beside its masked payment facts.
- Base: normal totals match and no warning is shown; an authorized PDF receipt
opens inline while its later download asks for a purpose.
- Base: a historical travel-order batch remains readable without an empty
reimbursement-evidence warning or a link to `/reimbursements/:id`.
- Bad: label masked payee facts as “报销明细”, attach expense arrays to the batch
list, trust stored MIME without byte validation, or expose a storage URL.
### 6. Tests Required
- Route tests assert detail DTO fields, batched loading, money totals, mismatch,
empty evidence and privacy-negative fields; list and write responses must omit
expense evidence.
- Receipt route tests assert PDF/image inline MIME, fixed-purpose preview audit,
download-purpose rejection, permission/scope, void/quarantine/size/content and
audit-before-send failures.
- Frontend typecheck/build and focused Playwright assert responsive expense cards,
subtotal/source link, empty/mismatch copy, preview without a purpose dialog,
download with a purpose dialog and object-URL cleanup behavior.
### 7. Wrong vs Correct
#### Wrong
```js
return mapBatch(batch, { expenseItems: await loadItemsForEveryListRow() });
```
#### Correct
```js
return mapBatchForResponse(prisma, batch, riskFlags, config, {
includeExpenseItems: true, // detail route only
});
```
+21 -3
查看文件
@@ -276,9 +276,11 @@ return decryptStringOrThrow(config, encrypted);
- Browser downloads of OA-stored attachments must use a currently
authenticated request, preferably an API-local `POST` with optional JSON
`{ "reason": "..." }`, rather than a query bearer URL. The only approved
exception is the at-most-120-second, purpose/object/actor-bound grant used by
the official WeCom `previewFile` native layer; follow `wecom-boundary.md` and
do not reuse that grant for ordinary browser downloads or other integrations.
query-grant exception is the at-most-120-second, purpose/object/actor-bound
grant used by the official WeCom `previewFile` native layer; follow
`wecom-boundary.md` and do not reuse that grant for ordinary browser downloads
or other integrations. Payment-batch receipt preview is a separate,
authenticated domain route described below; it never uses a bearer query URL.
### 3. Contracts
@@ -313,6 +315,15 @@ return decryptStringOrThrow(config, encrypted);
query-token download routes. The WeCom-native reimbursement invoice/attachment
exception retains the domain-specific `inline` response and must still audit
before bytes, use no-store/no-referrer and reject expired or mismatched grants.
- The only ordinary-browser inline exception is
`GET /api/payment-batches/:id/receipt-attachments/:attachmentId/file?mode=preview`.
It remains batch- and department-authorized, revalidates bounded actual bytes,
accepts only PDF/JPEG/PNG/WebP, writes `PAYMENT_BATCH_RECEIPT_PREVIEWED` with
fixed reason `付款批次内在线预览` before sending bytes, and returns the validated
MIME with `inline`, `nosniff`, private no-store, no-referrer, sandbox CSP and
same-origin resource policy. Default/download mode keeps forced binary download
and reason-required audit behavior. Do not generalize this exception into a
reusable file-center preview route.
### 4. Validation & Error Matrix
@@ -330,6 +341,8 @@ return decryptStringOrThrow(config, encrypted);
- Explicitly quarantined, missing, oversized, corrupt or unverifiable stored
object -> do not return file bytes; write a fixed blocked-reason audit where
possible.
- Payment receipt preview with a MIME outside PDF/JPEG/PNG/WebP -> blocked audit
with fixed reason code; no inline bytes.
- Successful-download audit write fails -> return 5xx before download headers
or file bytes.
@@ -337,6 +350,8 @@ return decryptStringOrThrow(config, encrypted);
- Good: a bank receipt upload returns `confidential`, `receipt`,
`reason_required`; download first asks for用途 and writes an audit log.
- Good: an authorized payment-detail preview uses the fixed server purpose and
writes its preview audit; downloading the same receipt still asks for用途.
- Base: a reimbursement supporting material upload defaults to `internal`,
`supporting_material`, `standard` and remains downloadable by authorized
participants.
@@ -364,6 +379,9 @@ return decryptStringOrThrow(config, encrypted);
before storage/DB writes, current authentication and object authorization,
historical revalidation, blocked-audit reasons, audit-before-send and the
complete forced-download header contract.
- Payment receipt tests additionally assert preview MIME/disposition/security
headers, fixed-purpose audit-before-send, PDF/image allowlist, blocked files,
and that default download still rejects a missing reason.
- Frontend typecheck/build after API type changes.
- Focused UI verification for pages that add attachment governance labels or
download-reason interactions.