fix: 增加回单核对与付款登记门禁(09-08-reimbursement-payment-closeout)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-15 07:21:55 +08:00
父节点 229867f5fd
当前提交 8d08d46d16
共修改 33 个文件,包含 3691 行新增和 11 行删除
@@ -18,6 +18,107 @@ QA script patterns over new abstractions.
## Common Checks
### Receipt OCR Matching Service Boundary
- Failed evidence persistence is not OCR failure. In a separate bounded transaction,
lock the batch then conditionally update the attachment by version/SHA/processing
and active governance predicates. Persist only allowlisted system_failed,
key_failed or snapshot_failed states; never an exception message or OCR payload.
A failed fallback write returns failure_recording_failed, not a claimed durable
failure. Do not automatically retry cloud calls or overwrite a newer job/void.
- System failure states do not permit ordinary OCR retry or manual proposals.
Administrator recovery requires fixing the prerequisite and a separately
authorized version-bound requeue; no bulk reset or silent cloud retransmission.
- Bank reimbursement execute calls claimVerifiedReceipts inside the existing
payment transaction after batch/source locks. Receipt existence alone never
permits registration. Existing non-bank and travel-history paths are not
silently converted into the reimbursement BankSlipOCR contract.
- Claims reference the exact approved manual proposal. A unique transaction-alias
table reserves both original and corrected identities against one claim.
Original-reference reuse, concurrent manual/automatic claims and changed current
manual-reviewer roles/participants fail closed; aliases and payment roll back
together. Keep the migration's existing-claim alias backfill.
- GET receipt-readiness uses RepeatableRead READ ONLY with the explicit readOnly
service mode: no FOR UPDATE/SHARE, advisory locks or inserts. It is a UI hint;
execution always repeats the locked validation. Never pass readOnly from user
input into payment execution.
- Manual persistence uses a separate proposal tied to original verification, SHA
and incremented attachment version. Save and review audit share the transaction;
only id/status/version leave mutation endpoints. Association-ready means
unchanged item association, not independent approval; manual approval does
not itself record payment. Rejected proposals remain immutable history and
manual rejection permits explicit OCR retry.
- Manual endpoints preserve finance management or manager/ceo review permission,
source-department and feature gates. Persistence locks batch, claim advisory,
then attachment, rechecks original and corrected transaction claims, and rejects
stale version/source or self-review. Approval revalidates matching; execute must
still revalidate and claim in the actual payment transaction.
- Manual proposal preparation in paymentReceiptManualReview is not authorization:
corrections or unavailable OCR require independent review; unchanged exact-match
item association does not. Preserve original evidence, changed-field provenance,
and original transaction fingerprints for persistent duplicate checks.
- Never rewrite original failed/pending transactions into successful evidence,
including conflicting status aliases. Original cross-page duplicate identities
remain blocked even when manual fields change the reference or payer account.
Internal normalized accounts must not be serialized into API responses.
- Independent exception review requires explicit review permission and manager/ceo
business role, outside maker, proposer and every source owner/submitter. Missing
identity fails closed. Pure helper validation does not replace route scope,
transaction/version guards, audit or execute-time revalidation.
- OCR upload/retry eligibility requires reimbursement plus a pending/failed bank
item. The worker rechecks under the batch lock before reading storage or calling
cloud OCR; out-of-scope queued receipts return to legacy without transmission.
Cash and travel attachment storage is not permission to send them to BankSlipOCR.
- New eligible receipt uploads queue in the attachment/audit transaction; legacy attachments
are not automatically sent to cloud OCR. cron and pg-boss share DB claiming;
the dedicated `payment.receipt.ocr` queue disables automatic retries.
- `POST /payment-batches/:id/receipt-attachments/:attachmentId/ocr-retry` requires
finance management, feature/department and batch-state gates plus an integer
`expectedVersion`. Version/state rejection uses
`payment_batch.receipt_retry_unavailable`, not a generic unexplained failure.
Requeue and audit commit together. Processing is eligible only after 15 minutes;
queued/matched are not eligible. No amount/plaintext or OCR call in this route.
- Attachment GET exposes `ocrState/ocrVersion/ocrStartedAt/ocrRetryAvailable`;
the same eligibility function is used on retry. Eligibility is not permission:
callers still need authorized editable batch context.
- Persistence: `storeReceiptRecognition(tx, config, { batchId, attachmentId,
sourceSha256, expectedVersion, recognition })` locks batch then attachment,
rejects voided/changed/superseded input, and writes encrypted allowlisted evidence.
Future scheduling/correction must increment the attachment version under the same locks.
- `claimVerifiedReceipts(tx, config, batch)` requires a locked current batch and
the same transaction as payment recording. Revalidate latest evidence and frozen
data, then claim unique transaction fingerprint and item. Never call in a separate
committed transaction or consider it complete HTTP authorization by itself.
- Claim lock namespace is `ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims` (1002).
With Prisma, project an integer from `pg_advisory_xact_lock`; selecting its void
return directly can fail with P2010. Do not remove serialization to hide that failure.
- Different fingerprint key IDs in historical claims block registration until an
explicitly reviewed key migration; key rotation must not defeat deduplication.
DB validation: `RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-payment-receipt-db.cjs`.
- Internal services: `normalizeBankSlip(fields)`, `matchBankSlip(receipt, candidates)`
in paymentReceiptMatching and `recognizePaymentReceipt({ buffer, mimeType, config })`
in paymentReceiptOcr. These helpers are not themselves payment authorization.
- BankSlipOCR keys vary by layout; support known aliases, reject conflicting or
repeated semantic keys as ambiguous, never silently pick the first transaction.
Currency/account/principal matching is per item, not against the batch total.
- Normalize money with strict grouping/precision and integer minor units; preserve
account leading zeroes. Masked accounts and non-final transaction states cannot
establish a match. OCR matching never establishes bank authenticity.
- Process all PDF pages, retain expected/processed counts, and never accept a
partially failed response as a complete receipt. Provider errors are sanitized;
no raw response or public receipt URL. Normalized accounts remain internal
sensitive data, not API/log payloads.
- Unit contracts: paymentReceiptMatching.test.js and paymentReceiptOcr.test.js.
Their passing does not prove persistent duplicate prevention, independent
manual review, source-state revalidation, or execute transaction safety; those
gates require separate database/HTTP acceptance before wiring payment writes.
- `cd server && node --check src/routes/reimbursements.js`
- `cd server && node --check src/routes/workflow.js`
- `cd server && ./scripts/regression-workflow-actions.sh`
@@ -1,5 +1,21 @@
# Frontend React / Vite / Design
## Receipt Verification Dialog
- Reuse the receipt card and existing Dialog/Input/Button patterns. No separate
receipt center. Show original and proposed summaries; account fields remain masked.
Original-file preview keeps its existing authorized path.
- Unchanged association submits page/item only. Corrections submit changed fields
only; never round-trip account masks as bank accounts. Unedited fields are merged
against the server's version-bound original evidence, not a client snapshot.
- Detail query keys include actor/batch/attachment and forward AbortSignal. Disable
background form refetch; explicit refresh clears selections, corrections and reason.
Identity or batch switches close the dialog, and unmounted mutation callbacks must
not refresh another identity's screen.
- Backend canReview is an eligibility projection, not authorization. Hide review
actions when false; actual review still checks role, permission and all participants.
Save/review never call payment execution or upload.
## Source Of Truth
Frontend visual and interaction design uses the installed `frontend-design`