feat(08-05-contract-detail-workspace): 完成合同详情与终止状态治理
这个提交包含在:
@@ -597,3 +597,92 @@ await tx.$executeRaw`
|
||||
`;
|
||||
const latest = await readLatestSequence(tx);
|
||||
```
|
||||
|
||||
## Scenario: Terminal Contract State and Required Audit
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: adding a terminal state or irreversible action to a Prisma-backed
|
||||
business ledger, especially when the state needs explanatory fields and an
|
||||
audit trail.
|
||||
- Applies to `ContractRegistry` termination, archive corrections, all import
|
||||
or batch writers, and any future endpoint that can mutate the same row.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- `POST /api/contracts-registry/:id/terminate` with `{ reason }` where the
|
||||
trimmed reason is 10–1000 characters.
|
||||
- `POST /api/contracts-registry/:id/corrections` with `{ summary }` for an
|
||||
archived row only.
|
||||
- DB fields: `status`, `terminated_at`, `terminated_reason`,
|
||||
`terminated_by`; the employee foreign key uses `ON DELETE RESTRICT`.
|
||||
- Required audit helper: `writeRequiredAudit(tx, request, action, targetId,
|
||||
extra)`; it must throw on insert failure.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- `status = terminated` iff all three `terminated_*` fields are non-null and
|
||||
non-empty; every other persisted status iff all three are null.
|
||||
- Only the dedicated termination transaction may create `terminated`; general
|
||||
POST/PATCH and CSV/import writers reject it. Existing terminated rows are
|
||||
immutable to import updates, and existing archived rows are immutable to
|
||||
import updates (use the correction API).
|
||||
- Termination, status changes, renewal and correction audit writes stay inside
|
||||
the same transaction as the protected write. A correction claims the current
|
||||
`status + updated_at` version before appending its audit record.
|
||||
- List DTOs use a minimal mapper; terminal reason, operator and timestamp are
|
||||
detail-only fields.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Partial termination fields or a terminal row with missing fields -> stable
|
||||
`500 SYS_001`; do not serialize an inconsistent record.
|
||||
- General write with `terminated`, terminal edit/renew/archive, or a stale
|
||||
correction version -> `409 VALID_001` and zero business/audit side effects.
|
||||
- Required audit insert failure -> transaction rollback and `5xx`; never use a
|
||||
swallowing best-effort helper for an irreversible/status-changing operation.
|
||||
- Import status `terminated`/`expired`, a status transition, or update of an
|
||||
existing terminated/archived row -> recorded validation error; no upsert.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: termination writes status, three fields and audit atomically; a failed
|
||||
audit leaves the source row active and the audit table unchanged.
|
||||
- Base: an archived correction updates only the version timestamp and appends
|
||||
a safe audit summary; a concurrent restore loses the optimistic guard and
|
||||
returns `409`.
|
||||
- Bad: write status first, call a best-effort audit afterward, or let a CSV
|
||||
upsert set `terminated` without the three fields and dedicated audit.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Focused route tests for complete/partial invariants, terminal filters and
|
||||
effective status, general-write rejection, required-audit rollback,
|
||||
renewal rollback, correction version conflicts, and safe list/detail DTOs.
|
||||
- Unit test every non-HTTP import status guard and existing-terminal refusal.
|
||||
- Prisma validate, migration status, `node --check` on touched scripts/routes,
|
||||
focused Vitest, and Playwright coverage for desktop/mobile action boundaries.
|
||||
- Read-only reviewer must inspect all write paths, including scripts and
|
||||
exports, after implementation.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
await prisma.contractRegistry.update({ where: { id }, data: { status: "terminated" } });
|
||||
await writeAudit(prisma, request, "CONTRACT_REGISTRY_TERMINATED", id); // swallows errors
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const current = await readAndValidateContract(tx, id);
|
||||
await tx.contractRegistry.updateMany({
|
||||
where: { id, status: current.status, updated_at: current.updated_at },
|
||||
data: buildTerminalFields(request, reason),
|
||||
});
|
||||
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_TERMINATED", id, { reason });
|
||||
});
|
||||
```
|
||||
|
||||
在新工单中引用
屏蔽一个用户