统一付款详情与批次短编号(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-02 09:02:40 +08:00
父节点 171fc09524
当前提交 5d274af8d4
共修改 11 个文件,包含 467 行新增和 82 行删除
@@ -514,3 +514,86 @@ const finalSummary = summarizeFinalItemStatuses(finalItemStatuses);
batch.execution_summary_json = finalSummary;
batch.status = finalSummary.failedCount > 0 ? "failed" : "executed";
```
## Scenario: PostgreSQL Advisory-Lock Namespaces
### 1. Scope / Trigger
- Trigger: allocating an application-level sequence or serializing a small
cross-row critical section that cannot be protected by locking one existing
row.
- Use this only for PostgreSQL-backed paths whose correctness is also verified
against a disposable real database; do not introduce advisory locks for
ordinary single-row updates already covered by `FOR UPDATE`.
### 2. Signatures
- Shared registry: `server/src/constants/advisoryLocks.js`.
- Lock shape: `pg_advisory_xact_lock(namespace_int4, object_key_int4)`.
- Registered namespace: `paymentBatchDailySequence = 1001`; its object key is
the company-local `YYYYMMDD` integer.
- Prisma call for a void-returning lock statement: `tx.$executeRaw`.
### 3. Contracts
- Register every new first-argument namespace in the shared constant file;
never reuse an existing namespace for an unrelated critical section.
- Use the two-`int4` form with an explicit, inspectable object key. Do not use
`hashtext` or an undocumented magic pair at the call site.
- Acquire the transaction-level lock before reading the protected maximum or
current state. Lock acquisition, protected read and write must stay in the
same Prisma interactive transaction so commit or rollback releases the lock.
- Keep one stable lock order when a transaction needs more than one lock.
- A PostgreSQL uniqueness error aborts the transaction. Do not catch `P2002`
and retry inside the same interactive transaction; any fallback must retry
the complete transaction from the beginning.
### 4. Validation & Error Matrix
- `$queryRaw` reads `SELECT pg_advisory_xact_lock(...)` -> Prisma driver may
fail with `P2010` because PostgreSQL returns a `void` column.
- `$executeRaw` executes the same `SELECT` -> lock is acquired without result
deserialization; continue to the protected query.
- Namespace or object key outside signed `int4` -> fail before business writes;
correct the registry/key design rather than silently casting or hashing.
- Protected daily sequence exhausted -> stable `409 VALID_001` and rollback;
do not widen or randomize the public identifier format at the boundary.
- Concurrent callers with the same pair -> one waits, then reads the committed
maximum and allocates the next value.
### 5. Good/Base/Bad Cases
- Good: two simultaneous payment-batch creations lock `(1001, YYYYMMDD)`, then
commit distinct consecutive identifiers.
- Base: different company-local dates use different object keys and do not
contend.
- Bad: query the maximum before taking the lock, use a process mutex, or retry a
unique violation inside the already-aborted transaction.
### 6. Tests Required
- Unit test the exact call order and error boundary with mocked
`$executeRaw`/`$queryRaw`; assert no create, item or audit write on exhaustion.
- Run `bash scripts/qa/run-web-gates.sh payment-concurrency-local` against a
disposable PostgreSQL database. The daily-sequence race must create two
different sources concurrently and assert two consecutive unique numbers.
- Run `node --check` on the route, namespace registry and concurrency script,
plus Prisma validate and a read-only review for affected payment paths.
### 7. Wrong vs Correct
#### Wrong
```js
await tx.$queryRaw`SELECT pg_advisory_xact_lock(${namespace}, ${dateKey})`;
const latest = await readLatestSequence(tx);
```
#### Correct
```js
await tx.$executeRaw`
SELECT pg_advisory_xact_lock(${namespace}::int, ${dateKey}::int)
`;
const latest = await readLatestSequence(tx);
```