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 });
});
```
@@ -2,65 +2,74 @@
## 1. Schema 与迁移
- [ ] 在 `ContractRegistry` 增加 `terminated_at/reason/by`、terminator 关系,在 `Employee` 增加反向关系。
- [ ] `terminated_reason` 限制 1000,`terminated_by` 使用 `onDelete: Restrict`;按查询需要添加索引。
- [ ] 生成纯增量 migration,检查只有可空列、外键/索引,无历史回填、drop 或状态改写。
- [ ] 运行 `cd server && npm run prisma:format && npm run prisma:validate && npm run prisma:generate`。
- [x] 在 `ContractRegistry` 增加 `terminated_at/reason/by`、terminator 关系,在 `Employee` 增加反向关系。
- [x] `terminated_reason` 限制 1000,`terminated_by` 使用 `onDelete: Restrict`;按查询需要添加索引。
- [x] 生成纯增量 migration,检查只有可空列、外键/索引,无历史回填、drop 或状态改写。
- [x] 运行 `cd server && npm run prisma:format && npm run prisma:validate && npm run prisma:generate`。
## 2. 状态与服务端不变量
- [ ] 持久/筛选状态加入 `terminated`,一般 create/PATCH 可写状态仍排除 terminated。
- [ ] 增加 terminated 筛选、effective status、expiring 排除和 renew 拒绝测试。
- [ ] 增加详情 GET,按列表相同范围查询 owner/project/terminator/document refs,并投影安全 history。
- [ ] 增加专用 terminate schema 和接口,reason trim 后 10–1000。
- [ ] 终止 transaction 原子写三字段、status 和 required audit;模拟 audit failure 证明全回滚。
- [ ] 阻止 archived/terminated 普通字段覆盖与终止绕过。
- [ ] 归档/恢复改为 required audit;实现归档后追加修正记录,不覆盖主合同。
- [ ] 保持续约 active/expired 语义和事务审计。
- [x] 持久/筛选状态加入 `terminated`,一般 create/PATCH 可写状态仍排除 terminated。
- [x] 增加 terminated 筛选、effective status、expiring 排除和 renew 拒绝测试。
- [x] 增加详情 GET,按列表相同范围查询 owner/project/terminator/document refs,并投影安全 history。
- [x] 增加专用 terminate schema 和接口,reason trim 后 10–1000。
- [x] 终止 transaction 原子写三字段、status 和 required audit;模拟 audit failure 证明全回滚。
- [x] 阻止 archived/terminated 普通字段覆盖与终止绕过;导入脚本也拒绝终止状态和覆盖既有终止合同。
- [x] 归档/恢复改为 required audit;实现归档后追加修正记录,不覆盖主合同;修正使用 `updated_at` 乐观保护。
- [x] 保持续约 active/expired 语义和事务审计,并覆盖续约审计失败回滚。
## 3. 客户端 API 与状态映射
- [ ] 客户端合同类型加入 `terminated` 与三个终止字段、terminator、document refs、history DTO。
- [ ] `contractStatusLabel` 增加“已终止”,未知兜底为“未知状态”而非原值。
- [ ] 增加 fetch detail、terminate、correction 等必要 API 函数,不暴露 raw audit/document path。
- [x] 客户端合同类型加入 `terminated` 与三个终止字段、terminator、document refs、history DTO。
- [x] `contractStatusLabel` 增加“已终止”,未知兜底为“未知状态”而非原值。
- [x] 增加 fetch detail、terminate、correction 等必要 API 函数,不暴露 raw audit/document path。
## 4. 列表、详情和表单
- [ ] 合同列表移除内联 editor,仅保留筛选、提醒、导出、状态和查看详情。
- [ ] 新建合同详情路由和页面元数据,权限 guard 与现有列表一致。
- [ ] 建设状态/下一步、摘要、证据区、历史和动作面板;窄屏复用共享底部动作容器。
- [ ] 证据区展示归档位置、离线档案号、涉密资料索引和强制引导文案;不显示空附件区或附件完整性列。
- [ ] 迁移编辑、续约、归档/恢复;按动作矩阵隐藏或禁用。
- [ ] 终止输入持续显示至少 10 字要求;禁用时显示原因;确认包含合同编号、相对方、金额和后果。
- [ ] 归档后只提供授权修正追加入口,不覆盖普通字段。
- [ ] 只展示真实 project 摘要与跳转;采购/付款无稳定关联时不渲染。
- [x] 合同列表移除内联 editor,仅保留筛选、提醒、导出、状态和查看详情。
- [x] 新建合同详情路由和页面元数据,权限 guard 与现有列表一致。
- [x] 建设状态/下一步、摘要、证据区、历史和动作面板;窄屏复用共享底部动作容器,并隐藏 PC 管理动作。
- [x] 证据区展示归档位置、离线档案号、涉密资料索引和强制引导文案;不显示空附件区或附件完整性列。
- [x] 迁移编辑、续约、归档/恢复;按动作矩阵隐藏或禁用。
- [x] 终止输入持续显示至少 10 字要求;禁用时显示原因;确认包含合同编号、相对方、金额和后果。
- [x] 归档后只提供授权修正追加入口,不覆盖普通字段。
- [x] 只展示真实 project 摘要与跳转;采购/付款无稳定关联时不渲染。
## 5. 后端与迁移验证
- [ ] `cd server && node --check src/routes/contractsRegistry.js`
- [ ] `cd server && npm test -- tests/unit/routes/contractsRegistry.test.js tests/unit/routes/exports.contractsRegistry.test.js`
- [ ] `cd server && npm run prisma:validate`
- [ ] `cd server && npx prisma migrate status`(只读;不得未经批准 deploy/reset)。
- [ ] 检查生成 migration SQL 和既有数据兼容,记录应用回滚前/后首次终止写入的不同边界。
- [x] `cd server && node --check src/routes/contractsRegistry.js`(并检查 `src/scripts/import-project-ledgers.js`)。
- [x] `cd server && npm test -- tests/unit/routes/contractsRegistry.test.js tests/unit/routes/exports.contractsRegistry.test.js tests/unit/scripts/import-project-ledgers.test.js --run`:25 passed。
- [x] `cd server && npm run prisma:validate`。
- [x] `cd server && npx prisma migrate status`(只读;不得未经批准 deploy/reset):65 migrations,`Database schema is up to date!`。
- [x] 检查生成 migration SQL 和既有数据兼容,记录应用回滚前/后首次终止写入的不同边界。
## 6. 前端与交互验证
- [ ] `cd client && npm run typecheck`
- [ ] `cd client && npm run build`
- [ ] 增加合同详情聚焦 Playwright:列表进入、深链刷新、无权限/不存在、dirty guard、涉密引导、无附件区。
- [ ] 覆盖终止原因不足、确认摘要、成功状态、重复终止、审计失败和窄屏动作安全区。
- [ ] 覆盖归档锁定、修正追加、恢复、续约和未知状态兜底。
- [x] `cd client && npm run typecheck`。
- [x] `cd client && npm run build`。
- [x] 增加合同详情聚焦 Playwright:列表进入、深链刷新、无权限/不存在、dirty guard、涉密引导、无附件区。
- [x] 覆盖终止原因不足、确认摘要、成功状态、重复终止、审计失败和窄屏动作安全区;合同详情 + Wave3 共 12 passed。
- [x] 覆盖归档锁定、修正追加、恢复、续约和未知状态兜底。
## 7. 只读 Review 与治理
- [ ] 实施完成后启动只读 reviewer,按 design 第 9 节逐项审查,明确 reviewer 不改代码。
- [ ] 主会话修复 finding 后重新运行聚焦测试并请求只读复查。
- [ ] 运行 task validate、process contract、docs registry、Trellis governance、敏感内容检查和 `git diff --check`。
- [ ] 判断稳定规则是否需要更新现有合同规约或 Trellis spec,不新建平行文档。
- [x] 实施完成后启动只读 reviewer,按 design 第 9 节逐项审查,明确 reviewer 不改代码;首轮 finding 已记录并修复。
- [x] 主会话修复 finding 后重新运行聚焦测试并请求只读复查;最终结论 `PASS`。
- [x] 运行 task validate、process contract、docs registry、Trellis governance、敏感内容检查和 `git diff --check`。
- [x] 将终态字段、required audit、导入写路径和修正乐观保护沉淀到 `.trellis/spec/hyb-oa/backend-fastify-prisma.md`,不新建平行文档。
## 8. 提交、部署与回滚
- [ ] 使用中文 commit message 并引用 `08-05-contract-detail-workspace`。
- [x] 使用中文 commit message 并引用 `08-05-contract-detail-workspace`。
- [ ] 本地实现提交不等于批准生产 migration 或部署。
- [ ] 生产发布另行获批后,显式验证 migration status、`/ready`、合同详情和权限负向路径。
- [ ] 首次生产终止写入前保留应用回滚窗口;产生 terminated 行后优先 roll-forward,不自动 drop 列或回退到不识别 terminated 的旧应用。
## 实施证据与边界(2026-08-07)
- migration SQL 为三个可空列、一个 `Restrict` 外键和索引;未执行生产 migration、reset、db push、resolve 或手工 DDL。
- 只读迁移原始结论:`Datasource=hyb_oa_regression at localhost:5432`;`65 migrations found`;`Database schema is up to date!`。
- 合同聚焦后端 25/25 passed;合同详情与受影响 Wave3 Playwright 12/12 passed;client typecheck/build、Prisma validate、node syntax、task/process/governance/sensitive/flow/route-manifest gates 均通过。
- 仓库全量 Vitest 结果为 179 个文件(177 passed、2 failed)、1248 个测试(1245 passed、3 failed);失败仅为既有付款批次/BIFF8 模板环境问题(`paymentBatches.reimbursement.test.js` 1 条、`paymentOrderXls.test.js` 2 条),不属于本合同任务改动,未通过改页面或数据库掩盖。
- 本地 `127.0.0.1:3000/ready` 返回 200;企业微信真机、生产/公网部署仍未验证,不能据此宣称用户可测试。
- 首轮只读 reviewer 结论为 `PASS_WITH_FINDINGS`;主会话修复并补测后,最终只读复查结论为 `PASS`,reviewer 未修改代码、未写数据库。
@@ -74,19 +74,19 @@
## Acceptance Criteria
- [ ] 合同列表不再承载记录编辑器,只保留查找、筛选、提醒、导出和进入详情。
- [ ] 合同详情具有稳定深链,并按“状态与下一步、业务摘要、证据区、历史、动作面板”展示。
- [ ] 证据区展示归档位置、离线档案号和涉密资料索引,并出现“涉密资料请登记线下归档位置”;不渲染空附件区或列表附件完整性列。
- [ ] 本轮没有新增合同正文/非涉密附件上传、预览或下载;PRD 明确其为后续独立能力而非永久禁止。
- [ ] 状态值域支持 `terminated`;终止原因至少 10 个字,操作者和时间可在详情直接读取,合同编号不释放。
- [ ] 服务层与聚焦测试证明 `terminated` 与三个终止字段满足全有或全空不变量,`terminated_by` 使用 Restrict 外键。
- [ ] 终止原因不足 10 字时界面邻近显示禁用原因;满足要求后,确认展示合同编号、相对方、金额和后果,状态、三字段与审计同事务成功或共同失败。
- [ ] 归档后普通字段锁定;授权修正追加记录,不覆盖原历史。
- [ ] 编辑、续约、归档/恢复按权限和状态显示;审批类与付款类写动作不进入合同详情。
- [ ] 项目/采购/付款只做有权限且有真实数据来源的摘要与跳转,不复制对方流程。
- [ ] 无权限详情请求不泄露合同事实;不存在、深链刷新、返回和 dirty-form 行为可解释。
- [ ] 如包含 migration,Prisma 校验、迁移兼容、聚焦后端测试、前端 Playwright、typecheck/build 和 Trellis 门禁通过。
- [ ] 只读 reviewer 完成状态机、权限、事务审计、迁移与隐私审查,且未修改代码。
- [x] 合同列表不再承载记录编辑器,只保留查找、筛选、提醒、导出和进入详情。
- [x] 合同详情具有稳定深链,并按“状态与下一步、业务摘要、证据区、历史、动作面板”展示。
- [x] 证据区展示归档位置、离线档案号和涉密资料索引,并出现“涉密资料请登记线下归档位置”;不渲染空附件区或列表附件完整性列。
- [x] 本轮没有新增合同正文/非涉密附件上传、预览或下载;PRD 明确其为后续独立能力而非永久禁止。
- [x] 状态值域支持 `terminated`;终止原因至少 10 个字,操作者和时间可在详情直接读取,合同编号不释放。
- [x] 服务层与聚焦测试证明 `terminated` 与三个终止字段满足全有或全空不变量,`terminated_by` 使用 Restrict 外键。
- [x] 终止原因不足 10 字时界面邻近显示禁用原因;满足要求后,确认展示合同编号、相对方、金额和后果,状态、三字段与审计同事务成功或共同失败。
- [x] 归档后普通字段锁定;授权修正追加记录,不覆盖原历史。
- [x] 编辑、续约、归档/恢复按权限和状态显示;审批类与付款类写动作不进入合同详情。
- [x] 项目/采购/付款只做有权限且有真实数据来源的摘要与跳转,不复制对方流程。
- [x] 无权限详情请求不泄露合同事实;不存在、深链刷新、返回和 dirty-form 行为可解释。
- [x] 如包含 migration,Prisma 校验、迁移兼容、聚焦后端测试、前端 Playwright、typecheck/build 和 Trellis 门禁通过。
- [x] 只读 reviewer 完成状态机、权限、事务审计、迁移与隐私审查,且未修改代码。
## Out of Scope
@@ -99,7 +99,7 @@
## Accepted Risk
- “已终止”是不可逆终态,本轮不提供恢复、续约或再归档路径。必填至少 10 个字的原因、合同编号/相对方/金额/后果确认和事务审计用于降低误操作风险;若后续出现真实误终止案例,另建 task 评估受控管理员修正路径。
- “已终止”是不可逆终态,本轮不提供恢复、续约、再归档或终止后的追加说明/更正入口,已提交的终止原因也不可编辑。必填至少 10 个字的原因、合同编号/相对方/金额/后果确认和事务审计用于降低误操作与漏写风险;若后续出现真实误终止或补充说明需求,另建 task 评估受控管理员修正路径,或只追加且必审计的补充说明入口。
## Open Questions
@@ -109,9 +109,17 @@
- `cd server && npm run prisma:format && npm run prisma:validate && npm run prisma:generate`
- `cd server && node --check src/routes/contractsRegistry.js`
- `cd server && npm test -- tests/unit/routes/contractsRegistry.test.js tests/unit/routes/exports.contractsRegistry.test.js`
- `cd server && node --check src/scripts/import-project-ledgers.js`
- `cd server && npm test -- tests/unit/routes/contractsRegistry.test.js tests/unit/routes/exports.contractsRegistry.test.js tests/unit/scripts/import-project-ledgers.test.js --run`
- `cd server && npx prisma migrate status`(只读;不得用 reset 或未经批准 deploy)。
- `cd client && npm run typecheck && npm run build`
- 聚焦 Playwright 覆盖合同列表/详情、证据引导、终止原因禁用提示、确认摘要、状态成功/重复终止、归档锁定、权限负向和窄屏动作。
- 实施后启动只读 reviewer,检查状态不变量、专用终止接口、事务审计失败回滚、`terminated` 不参与过期/续约、Restrict 外键、迁移兼容和敏感字段裁剪。
- 运行 task validate、process contract、docs registry、Trellis governance、敏感内容检查和 `git diff --check`。
## Evidence Notes
- 2026-08-07 本地只读迁移状态:`hyb_oa_regression` at `localhost:5432`,65 migrations,`Database schema is up to date!`;未执行生产 migration、reset、db push、resolve 或手工 DDL。
- 聚焦后端 25/25 passed,合同详情与 Wave3 Playwright 12/12 passed,client typecheck/build、Prisma validate、node syntax 与治理门禁通过。
- 仓库全量 Vitest 179 files(177 passed、2 failed)/ 1248 tests(1245 passed、3 failed)中有 3 条既有付款批次/BIFF8 模板环境失败;本任务不改动该路径,也不把全量结果写成全绿。
- 只读 reviewer 首轮 finding 已修复并补测,最终复查结论为 `PASS`;reviewer 未修改代码、未写数据库。企业微信真机、生产/公网仍未验证。
@@ -3,7 +3,7 @@
"name": "contract-detail-workspace",
"title": "合同详情页与高风险动作归位",
"description": "独立规划合同详情信息结构、权限边界、证据区和高风险动作归位,不与采购/资产路由搬迁混做。",
"status": "planning",
"status": "in_progress",
"dev_type": null,
"scope": "server-contract-schema-api-and-client-detail",
"package": null,