feat(08-05-contract-detail-workspace): 完成合同详情与终止状态治理

这个提交包含在:
2026-08-08 02:02:52 +08:00
父节点 7546d372d6
当前提交 21851774dd
共修改 25 个文件,包含 2516 行新增和 1021 行删除
@@ -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 });
});
```