收紧付款复核资料与阶段编辑(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-02 15:15:01 +08:00
父节点 9afebdb44d
当前提交 185d64dcd6
共修改 14 个文件,包含 777 行新增和 121 行删除
+60 -6
查看文件
@@ -343,7 +343,11 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
defaultPaymentDate?, defaultPaymentChannel?, voucherPrefix? }`.
- Review: `POST /api/payment-batches/:id/review`.
- Register completed payment: `POST /api/payment-batches/:id/execute`.
- Payment metadata: `PATCH /api/payment-batches/:id/items/:itemId` accepts
only `paymentVoucherNoOverride?` and `paymentDateOverride?`.
- Receipt API: `GET|POST /api/payment-batches/:id/receipt-attachments`.
- Receipt void API:
`POST /api/payment-batches/:id/receipt-attachments/:attachmentId/void`.
- Internal status machine:
`draft -> reviewed -> executed|failed|cancelled`; there is no separate submit
endpoint or submitted database status.
@@ -353,9 +357,11 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
### 3. Contracts
- For the normal reimbursement UI, creation sends only `entityType` and
selected `entityIds`. The server still accepts optional fields for controlled
compatibility and exceptional item overrides.
- The normal reimbursement UI sends `entityType`, selected `entityIds` and
`defaultPaymentChannel`; the channel defaults to `bank` and becomes an
immutable review fact after creation. Item PATCH must not accept a channel
override. A persisted legacy item override remains readable/executable for
compatibility but is never exposed as a new write path.
- Missing name becomes `<company-local YYYY-MM-DD> 付款批次`; the server creates
the unique batch number. Missing channel becomes `bank`. Missing actual
payment date is resolved to the company-local registration date only when
@@ -379,6 +385,23 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
an active receipt exists may the UI expose `登记付款完成`. Loading or failed
receipt queries expose neither action rather than guessing that the
prerequisite is satisfied.
- Stage editing is fixed: `draft` is read-only apart from cancel/review;
`reviewed|failed` permits voucher/date and receipt upload/void;
`executed|cancelled` is entirely read-only. Authorized receipt list/download
remains available in every state.
- Payment-batch responses never expose stored `snapshot_json` or a
`snapshotJson` DTO field. Detail responses rebuild an allowlisted review DTO
with only `payeeName`, `bankName` and a format-validated
`bankAccountMasked`; list responses keep those detail-only fields null and do
not decrypt owner accounts. New items persist the same low-sensitivity
`payee` object in `snapshot_json`; full bank accounts and encrypted source
values never enter new batch snapshots or any response.
- A legacy or malformed stored payee is not trusted merely because its field is
named `bankAccountMasked`. Invalid text, ciphertext-looking values and account
values without a valid mask cause a read-only fallback to the current
reimbursement owner. The fallback must not backfill history, and an existing
but unreadable encrypted account fails closed rather than returning the raw
stored value.
### 4. Validation & Error Matrix
@@ -395,6 +418,20 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
shown independently and the user may retry.
- Existing null-channel reimbursement batch -> project as bank and apply the
receipt guard without updating `default_payment_channel`.
- Item PATCH in `draft|executed|cancelled` -> `409 VALID_001` with
`details.status=payment_metadata_not_editable`; zero item/audit writes.
- Item PATCH contains `paymentChannelOverride` -> strict schema rejection;
zero writes.
- Receipt upload/void outside `reviewed|failed` -> `409 VALID_001` with
`details.status=receipt_attachment_not_editable`; zero durable storage,
attachment or audit side effects.
- Receipt upload starts in `reviewed|failed` but the locked batch becomes
`executed|cancelled` before DB write -> the transaction rejects and every
object already written to storage is deleted by compensation; no attachment
or audit row remains.
- Missing legacy payee source -> return null display fields. Encryption-key or
ciphertext authentication failure -> fail closed; never substitute a full
account, ciphertext or unrelated employee.
### 5. Good/Base/Bad Cases
@@ -403,9 +440,14 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
eligible non-maker receives the task notification.
- Base: after review, finance completes the bank transfer outside OA, uploads
the receipt, then registers payment completion using the company-local date.
- Good: reviewer sees the payee name, bank and masked account while every
payment-registration control and receipt mutation is absent in `draft`.
- Bad: label `draft` as `草稿`, ask the user to find a nonexistent submit button,
show `登记付款完成` before receipt status is known, or silently rewrite a
legacy null channel in production.
- Bad: permit voucher/channel/receipt changes while a reviewer is checking the
batch, trust a persisted field name as proof of masking, or return
`snapshotJson`, `bank_account_encrypted`, ciphertext or a full account.
### 6. Tests Required
@@ -414,9 +456,18 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
null-channel compatibility without mutation.
- PostgreSQL concurrency regression asserts double create, double execute,
execute/cancel and direct-pay/batch races remain mutually exclusive.
- Client E2E asserts creation sends only `entityType+entityIds`, all consumers
use the shared labels, and receipt upload switches the sole primary action
from `上传银行回单` to `登记付款完成`.
- Route tests assert the full state matrix, channel strict rejection, zero
mutation side effects, masked new snapshots, legacy fallback and malicious
stored snapshots without historical writes or full-account/ciphertext
response values. They also assert list requests do not perform detail-level
payee lookup/decryption.
- Receipt tests cover both `reviewed` and `failed` positive paths, all three
read-only states, zero stored files on immediate rejection, and storage
compensation when the locked status changes after upload parsing.
- Client E2E asserts creation sends the selected `defaultPaymentChannel`, all
consumers use the shared labels, `draft` has no payment/receipt editors,
`executed|cancelled` remains read-only, and receipt upload switches the sole
primary action from `上传银行回单` to `登记付款完成`.
- Build/typecheck, Prisma validation and focused payment-batch route/export tests
are required before commit.
@@ -426,6 +477,7 @@ await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)]
```tsx
const label = status === "draft" ? "草稿" : status;
<ChannelSelect onChange={(value) => patchItem({ paymentChannelOverride: value })} />
<Button onClick={execute}>执行付款</Button>
```
@@ -433,5 +485,7 @@ const label = status === "draft" ? "草稿" : status;
```tsx
<StatusBadge label={paymentBatchStatusLabel(batch.status)} />
{batch.status === "draft" ? <PayeeReviewFacts item={item} /> : null}
{["reviewed", "failed"].includes(batch.status) ? <PaymentRegistrationFields /> : null}
{needsBankReceipt ? <UploadReceipt /> : canRegister ? <RegisterPaid /> : null}
```