diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index 7e4f1588..c7645389 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -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 }); +}); +``` diff --git a/.trellis/tasks/08-05-contract-detail-workspace/implement.md b/.trellis/tasks/08-05-contract-detail-workspace/implement.md index da72ac9d..224e9f5c 100644 --- a/.trellis/tasks/08-05-contract-detail-workspace/implement.md +++ b/.trellis/tasks/08-05-contract-detail-workspace/implement.md @@ -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 未修改代码、未写数据库。 diff --git a/.trellis/tasks/08-05-contract-detail-workspace/prd.md b/.trellis/tasks/08-05-contract-detail-workspace/prd.md index 72449c3f..322b50f5 100644 --- a/.trellis/tasks/08-05-contract-detail-workspace/prd.md +++ b/.trellis/tasks/08-05-contract-detail-workspace/prd.md @@ -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 未修改代码、未写数据库。企业微信真机、生产/公网仍未验证。 diff --git a/.trellis/tasks/08-05-contract-detail-workspace/task.json b/.trellis/tasks/08-05-contract-detail-workspace/task.json index f73b4f2e..3f9da8a3 100644 --- a/.trellis/tasks/08-05-contract-detail-workspace/task.json +++ b/.trellis/tasks/08-05-contract-detail-workspace/task.json @@ -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, diff --git a/client/src/api/contractsRegistry.ts b/client/src/api/contractsRegistry.ts index 7676ebed..4294e668 100644 --- a/client/src/api/contractsRegistry.ts +++ b/client/src/api/contractsRegistry.ts @@ -2,7 +2,8 @@ import { apiRequestBlob, apiRequestData } from "./http"; import type { ConfidentialityLevel, EmployeeBasic } from "./projects"; export type ContractRegistryType = "purchase" | "sales" | "service" | "framework" | "other"; -export type ContractRegistryPersistedStatus = "draft" | "active" | "archived"; +export type ContractRegistryPersistedStatus = "draft" | "active" | "terminated" | "archived"; +export type ContractRegistryWritableStatus = Exclude; export type ContractRegistryStatus = ContractRegistryPersistedStatus | "expired"; export type ContractRegistryExportFormat = "csv" | "xlsx"; @@ -33,12 +34,46 @@ export type ContractRegistryItem = { ownerEmployeeId: string; status: ContractRegistryStatus; persistedStatus: ContractRegistryPersistedStatus; + /** Detail-only terminal evidence is omitted from list responses. */ + terminatedAt?: string | null; + terminatedReason?: string | null; + terminatedBy?: string | null; createdAt: string | null; updatedAt: string | null; owner: EmployeeBasic | null; + terminator?: EmployeeBasic | null; project: { id: string; projectNo: string; name: string } | null; }; +export type ContractDocumentRefSummary = { + id: string; + title: string; + documentNo: string | null; + confidentialityLevel: ConfidentialityLevel; + storageType: string; + offlineArchiveNo: string | null; + createdAt: string | null; + updatedAt: string | null; + owner: EmployeeBasic | null; +}; + +export type ContractHistoryItem = { + id: string; + action: string; + summary: string; + createdAt: string | null; + actor: EmployeeBasic | null; +}; + +export type ContractRegistryDetail = Omit & { + terminatedAt: string | null; + terminatedReason: string | null; + terminatedBy: string | null; + terminator: EmployeeBasic | null; + documentRefs: ContractDocumentRefSummary[]; + history: ContractHistoryItem[]; +}; + function buildFiltersSearch(filters?: ContractRegistryFilters | string) { const normalized = typeof filters === "string" @@ -67,6 +102,10 @@ export async function fetchExpiringContracts(days = 30) { ); } +export async function fetchContractRegistryDetail(id: string) { + return apiRequestData<{ item: ContractRegistryDetail }>(`/api/contracts-registry/${id}`); +} + export async function createContractRegistry(payload: { contractNo: string; projectId?: string | null; @@ -82,7 +121,7 @@ export async function createContractRegistry(payload: { archiveLocation: string; offlineArchiveNo?: string | null; ownerEmployeeId?: string; - status?: ContractRegistryPersistedStatus; + status?: ContractRegistryWritableStatus; }) { return apiRequestData<{ item: ContractRegistryItem }>("/api/contracts-registry", { method: "POST", @@ -107,7 +146,7 @@ export async function patchContractRegistry( archiveLocation: string; offlineArchiveNo: string | null; ownerEmployeeId: string; - status: ContractRegistryPersistedStatus; + status: ContractRegistryWritableStatus; }>, ) { return apiRequestData<{ item: ContractRegistryItem }>(`/api/contracts-registry/${id}`, { @@ -125,6 +164,20 @@ export async function renewContractRegistry(id: string) { ); } +export async function terminateContractRegistry(id: string, reason: string) { + return apiRequestData<{ item: ContractRegistryItem }>(`/api/contracts-registry/${id}/terminate`, { + method: "POST", + body: JSON.stringify({ reason }), + }); +} + +export async function appendContractRegistryCorrection(id: string, summary: string) { + return apiRequestData<{ accepted: true }>(`/api/contracts-registry/${id}/corrections`, { + method: "POST", + body: JSON.stringify({ summary }), + }); +} + export async function exportContractsRegistry(params: ContractRegistryFilters & { format: ContractRegistryExportFormat }) { return apiRequestBlob("/api/exports/contracts-registry", { method: "POST", diff --git a/client/src/features/contractsRegistry/presentation.ts b/client/src/features/contractsRegistry/presentation.ts new file mode 100644 index 00000000..9476be6f --- /dev/null +++ b/client/src/features/contractsRegistry/presentation.ts @@ -0,0 +1,53 @@ +import type { + ContractRegistryStatus, + ContractRegistryType, +} from "../../api/contractsRegistry"; +import type { ConfidentialityLevel } from "../../api/projects"; + +export const CONTRACT_TYPE_OPTIONS: ContractRegistryType[] = [ + "purchase", + "sales", + "service", + "framework", + "other", +]; + +export const CONTRACT_FILTER_STATUS_OPTIONS: Array<{ value: ContractRegistryStatus; label: string }> = [ + { value: "active", label: "生效中" }, + { value: "expired", label: "已过期" }, + { value: "draft", label: "草稿" }, + { value: "terminated", label: "已终止" }, + { value: "archived", label: "已归档" }, +]; + +export function contractTypeLabel(value: ContractRegistryType) { + if (value === "purchase") return "采购合同"; + if (value === "sales") return "销售合同"; + if (value === "service") return "服务合同"; + if (value === "framework") return "框架协议"; + return "其他"; +} + +export function contractStatusLabel(value: string | null | undefined) { + if (value === "active") return "生效中"; + if (value === "expired") return "已过期"; + if (value === "draft") return "草稿"; + if (value === "terminated") return "已终止"; + if (value === "archived") return "已归档"; + return "未知状态"; +} + +export function confidentialityLabel(value: ConfidentialityLevel) { + if (value === "public") return "公开"; + if (value === "internal") return "内部"; + if (value === "confidential") return "机密"; + return "限制"; +} + +export function documentStorageLabel(value: string) { + if (value === "offline_archive") return "线下归档"; + if (value === "nas") return "内网存储"; + if (value === "internal_share") return "内部共享区"; + if (value === "oa_link") return "OA 受控链接"; + return "已登记索引"; +} diff --git a/client/src/lib/pcDetailNavigation.ts b/client/src/lib/pcDetailNavigation.ts index ad3958e5..843568ab 100644 --- a/client/src/lib/pcDetailNavigation.ts +++ b/client/src/lib/pcDetailNavigation.ts @@ -2,7 +2,7 @@ export type PcDetailNavigationState = { from?: string; }; -export type PcDetailSourcePolicy = "reimbursement" | "purchase-request" | "asset"; +export type PcDetailSourcePolicy = "reimbursement" | "purchase-request" | "asset" | "contract"; export function buildPcDetailSource(pathname: string, search: string) { return `${pathname}${search}`; @@ -27,6 +27,8 @@ export function resolvePcDetailSource( ? url.pathname === "/purchase-requests" || /^\/projects\/[^/]+$/.test(url.pathname) : policy === "asset" ? url.pathname === "/assets" + : policy === "contract" + ? url.pathname === "/contracts-registry" || /^\/projects\/[^/]+$/.test(url.pathname) : url.pathname === "/reimbursements" || /^\/workflow\/tasks\/[^/]+$/.test(url.pathname); if (!allowed) return null; diff --git a/client/src/pages/ContractRegistryWorkspacePage.tsx b/client/src/pages/ContractRegistryWorkspacePage.tsx new file mode 100644 index 00000000..70e8ce47 --- /dev/null +++ b/client/src/pages/ContractRegistryWorkspacePage.tsx @@ -0,0 +1,634 @@ +import { useEffect, useState, type Dispatch, type SetStateAction } from "react"; +import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + appendContractRegistryCorrection, + createContractRegistry, + fetchContractRegistryDetail, + patchContractRegistry, + renewContractRegistry, + terminateContractRegistry, + type ContractRegistryDetail, + type ContractRegistryItem, + type ContractRegistryType, + type ContractRegistryWritableStatus, +} from "../api/contractsRegistry"; +import { fetchEmployees } from "../api/employees"; +import { fetchProjects, type ConfidentialityLevel } from "../api/projects"; +import { useAuth } from "../auth/AuthContext"; +import { hasAnyPermission } from "../auth/permissions"; +import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog"; +import { PageHeader } from "../components/common/PageHeader"; +import { StatusBadge } from "../components/common/StatusBadge"; +import { + usePostSaveNavigation, + useUnsavedChangesGuard, +} from "../components/common/UnsavedChangesGuard"; +import { + BusinessDetailActionPanel, + BusinessDetailAside, + BusinessDetailFact, + BusinessDetailFactGrid, + BusinessDetailMain, + BusinessDetailWorkspace, +} from "../components/patterns/BusinessDetailTemplate"; +import { + MOBILE_STICKY_ACTION_PAGE_CLASS, + MobileStickyActionBar, +} from "../components/patterns/MobileStickyActionBar"; +import { PageScaffold } from "../components/patterns/PageScaffold"; +import { SectionPanel } from "../components/patterns/SectionPanel"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { Textarea } from "../components/ui/textarea"; +import { + CONTRACT_TYPE_OPTIONS, + confidentialityLabel, + contractStatusLabel, + contractTypeLabel, + documentStorageLabel, +} from "../features/contractsRegistry/presentation"; +import { formatDisplayDate, formatDisplayDateTime, formatMoney } from "../lib/displayFormat"; +import { + buildPcDetailSource, + resolvePcDetailSource, + type PcDetailNavigationState, +} from "../lib/pcDetailNavigation"; +import { resolveErrorMessage } from "../lib/resolveErrorMessage"; +import { toast } from "sonner"; + +const CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"]; +const AMOUNT_PATTERN = /^\d{1,12}(\.\d{1,2})?$/; + +type ContractEditorState = { + contractNo: string; + projectId: string; + contractType: ContractRegistryType; + counterpartyName: string; + summary: string; + amount: string; + currency: string; + signedDate: string; + effectiveDate: string; + expiryDate: string; + confidentialityLevel: ConfidentialityLevel; + archiveLocation: string; + offlineArchiveNo: string; + ownerEmployeeId: string; + status: ContractRegistryWritableStatus; +}; + +function emptyEditor(ownerEmployeeId = ""): ContractEditorState { + return { + contractNo: "", + projectId: "", + contractType: "other", + counterpartyName: "", + summary: "", + amount: "", + currency: "CNY", + signedDate: "", + effectiveDate: "", + expiryDate: "", + confidentialityLevel: "internal", + archiveLocation: "", + offlineArchiveNo: "", + ownerEmployeeId, + status: "active", + }; +} + +function editorFromDetail(item: ContractRegistryDetail): ContractEditorState { + return { + contractNo: item.contractNo, + projectId: item.projectId ?? "", + contractType: item.contractType, + counterpartyName: item.counterpartyName, + summary: item.summary, + amount: item.amount ?? "", + currency: item.currency, + signedDate: item.signedDate ?? "", + effectiveDate: item.effectiveDate ?? "", + expiryDate: item.expiryDate ?? "", + confidentialityLevel: item.confidentialityLevel, + archiveLocation: item.archiveLocation, + offlineArchiveNo: item.offlineArchiveNo ?? "", + ownerEmployeeId: item.ownerEmployeeId, + status: item.persistedStatus === "draft" ? "draft" : "active", + }; +} + +function nextStepText(item: ContractRegistryDetail) { + if (item.persistedStatus === "terminated") return "合同已终止,仅保留事实与审计记录。"; + if (item.persistedStatus === "archived") return "普通字段已锁定;如需纠错,请追加修正记录。"; + if (item.status === "expired") return "合同已过期,可评估续约、归档或终止。"; + if (item.persistedStatus === "draft") return "补齐合同信息后,可转为生效合同或归档。"; + return "合同处于生效状态,请关注到期和归档安排。"; +} + +function buildPayload(form: ContractEditorState) { + return { + contractNo: form.contractNo.trim(), + projectId: form.projectId || null, + contractType: form.contractType, + counterpartyName: form.counterpartyName.trim(), + summary: form.summary.trim(), + amount: form.amount.trim() || null, + currency: form.currency.trim().toUpperCase() || "CNY", + signedDate: form.signedDate || null, + effectiveDate: form.effectiveDate || null, + expiryDate: form.expiryDate || null, + confidentialityLevel: form.confidentialityLevel, + archiveLocation: form.archiveLocation.trim(), + offlineArchiveNo: form.offlineArchiveNo.trim() || null, + ownerEmployeeId: form.ownerEmployeeId, + }; +} + +function validateForm(form: ContractEditorState) { + if (!form.contractNo.trim()) throw new Error("请填写合同编号"); + if (!form.counterpartyName.trim()) throw new Error("请填写相对方"); + if (!form.summary.trim()) throw new Error("请填写合同摘要"); + if (!form.archiveLocation.trim()) throw new Error("请填写归档位置"); + if (!form.ownerEmployeeId) throw new Error("请选择负责人"); + if (form.amount.trim() && !AMOUNT_PATTERN.test(form.amount.trim())) { + throw new Error("合同金额最多 12 位整数和 2 位小数"); + } +} + +function ContractEditorFields({ + form, + setForm, + projects, + employees, + creating, +}: { + form: ContractEditorState; + setForm: Dispatch>; + projects: Array<{ id: string; projectNo: string; name: string }>; + employees: Array<{ id: string; employeeNo: string; name: string }>; + creating: boolean; +}) { + return ( +
+ +
+ + + + + + {creating ? ( + + ) : null} +