feat(reimbursement-small-team-form-validation): 完善付款批次核对与回单预览
这个提交包含在:
@@ -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
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
在新工单中引用
屏蔽一个用户