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 });
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ContractRegistryPersistedStatus, "terminated">;
|
||||
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<ContractRegistryItem, "terminatedAt" | "terminatedReason" | "terminatedBy" | "terminator"> & {
|
||||
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",
|
||||
|
||||
@@ -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 "已登记索引";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<SetStateAction<ContractEditorState>>;
|
||||
projects: Array<{ id: string; projectNo: string; name: string }>;
|
||||
employees: Array<{ id: string; employeeNo: string; name: string }>;
|
||||
creating: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5" data-testid="contract-editor">
|
||||
<SectionPanel title="基本信息">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
||||
合同编号
|
||||
<Input data-testid="contract-no" value={form.contractNo} onChange={(event) => setForm((current) => ({ ...current, contractNo: event.target.value }))} />
|
||||
</label>
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
||||
合同类型
|
||||
<select className="select-std" value={form.contractType} onChange={(event) => setForm((current) => ({ ...current, contractType: event.target.value as ContractRegistryType }))}>
|
||||
{CONTRACT_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{contractTypeLabel(item)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
||||
相对方
|
||||
<Input data-testid="contract-counterparty" value={form.counterpartyName} onChange={(event) => setForm((current) => ({ ...current, counterpartyName: event.target.value }))} />
|
||||
</label>
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
||||
关联项目
|
||||
<select className="select-std" value={form.projectId} onChange={(event) => setForm((current) => ({ ...current, projectId: event.target.value }))}>
|
||||
<option value="">不关联项目</option>
|
||||
{projects.map((item) => <option key={item.id} value={item.id}>{item.projectNo} · {item.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
||||
负责人
|
||||
<select className="select-std" value={form.ownerEmployeeId} onChange={(event) => setForm((current) => ({ ...current, ownerEmployeeId: event.target.value }))}>
|
||||
<option value="">请选择负责人</option>
|
||||
{employees.map((item) => <option key={item.id} value={item.id}>{item.name} ({item.employeeNo})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{creating ? (
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
||||
登记状态
|
||||
<select className="select-std" value={form.status} onChange={(event) => setForm((current) => ({ ...current, status: event.target.value as ContractRegistryWritableStatus }))}>
|
||||
<option value="active">生效中</option>
|
||||
<option value="draft">草稿</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700 md:col-span-2">
|
||||
合同摘要
|
||||
<Textarea data-testid="contract-summary" className="min-h-24" value={form.summary} onChange={(event) => setForm((current) => ({ ...current, summary: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="金额与日期">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">合同金额<Input data-testid="contract-amount" inputMode="decimal" value={form.amount} onChange={(event) => setForm((current) => ({ ...current, amount: event.target.value }))} /></label>
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">币种<Input value={form.currency} maxLength={8} onChange={(event) => setForm((current) => ({ ...current, currency: event.target.value }))} /></label>
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">签署日期<Input type="date" value={form.signedDate} onChange={(event) => setForm((current) => ({ ...current, signedDate: event.target.value }))} /></label>
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">生效日期<Input type="date" value={form.effectiveDate} onChange={(event) => setForm((current) => ({ ...current, effectiveDate: event.target.value }))} /></label>
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">到期日期<Input type="date" value={form.expiryDate} onChange={(event) => setForm((current) => ({ ...current, expiryDate: event.target.value }))} /></label>
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
||||
敏感级别
|
||||
<select className="select-std" value={form.confidentialityLevel} onChange={(event) => setForm((current) => ({ ...current, confidentialityLevel: event.target.value as ConfidentialityLevel }))}>
|
||||
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="归档证据">
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
涉密资料请登记线下归档位置
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
||||
归档位置
|
||||
<Input data-testid="contract-archive-location" value={form.archiveLocation} onChange={(event) => setForm((current) => ({ ...current, archiveLocation: event.target.value }))} />
|
||||
</label>
|
||||
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
||||
离线档案号
|
||||
<Input data-testid="contract-offline-archive-no" value={form.offlineArchiveNo} onChange={(event) => setForm((current) => ({ ...current, offlineArchiveNo: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContractRegistryCreatePage() {
|
||||
return <ContractRegistryWorkspace mode="create" />;
|
||||
}
|
||||
|
||||
export function ContractRegistryDetailPage() {
|
||||
const { contractId = "" } = useParams();
|
||||
return <ContractRegistryWorkspace mode="detail" contractId={contractId} />;
|
||||
}
|
||||
|
||||
function ContractRegistryWorkspace({ mode, contractId = "" }: { mode: "create" | "detail"; contractId?: string }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
const creating = mode === "create";
|
||||
const canManage = hasAnyPermission(permissions, ["project.manage", "project.view_all", "system.settings"]);
|
||||
const currentPath = buildPcDetailSource(location.pathname, location.search);
|
||||
const navigationState = location.state as PcDetailNavigationState | null;
|
||||
const backSource = resolvePcDetailSource(navigationState?.from, currentPath, "contract") ?? "/contracts-registry";
|
||||
const [editing, setEditing] = useState(creating);
|
||||
const [form, setForm] = useState<ContractEditorState>(() => emptyEditor(me?.id ?? ""));
|
||||
const [savedSignature, setSavedSignature] = useState(() => JSON.stringify(emptyEditor(me?.id ?? "")));
|
||||
const [formError, setFormError] = useState("");
|
||||
const [terminateReason, setTerminateReason] = useState("");
|
||||
const [correctionSummary, setCorrectionSummary] = useState("");
|
||||
const [confirmTerminate, setConfirmTerminate] = useState(false);
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
const [confirmRenew, setConfirmRenew] = useState(false);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: !creating && Boolean(contractId),
|
||||
queryKey: ["contracts-registry", "detail", contractId],
|
||||
queryFn: () => fetchContractRegistryDetail(contractId),
|
||||
});
|
||||
const projectsQuery = useQuery({
|
||||
enabled: canManage,
|
||||
queryKey: ["projects", "contract-options"],
|
||||
queryFn: () => fetchProjects(),
|
||||
});
|
||||
const employeesQuery = useQuery({
|
||||
enabled: canManage,
|
||||
queryKey: ["employees", "contract-owner-options"],
|
||||
queryFn: () => fetchEmployees({ page: 1, pageSize: 100, status: "active" }),
|
||||
});
|
||||
|
||||
const detail = detailQuery.data?.item ?? null;
|
||||
useEffect(() => {
|
||||
if (!detail || editing) return;
|
||||
const next = editorFromDetail(detail);
|
||||
setForm(next);
|
||||
setSavedSignature(JSON.stringify(next));
|
||||
setFormError("");
|
||||
}, [detail, editing]);
|
||||
|
||||
const dirty = editing && JSON.stringify(form) !== savedSignature;
|
||||
const runGuarded = useUnsavedChangesGuard(dirty, "合同表单");
|
||||
const runAfterSuccessfulSave = usePostSaveNavigation();
|
||||
const mergeDetailCache = (item: ContractRegistryItem) => {
|
||||
queryClient.setQueryData<{ item: ContractRegistryDetail }>(
|
||||
["contracts-registry", "detail", contractId],
|
||||
(current) => current ? { item: { ...current.item, ...item } } : current,
|
||||
);
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
validateForm(form);
|
||||
const payload = buildPayload(form);
|
||||
if (creating) return createContractRegistry({ ...payload, status: form.status });
|
||||
return patchContractRegistry(contractId, payload);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
setFormError("");
|
||||
setSavedSignature(JSON.stringify(form));
|
||||
if (creating) {
|
||||
if (!data.item.id) {
|
||||
const message = "合同已创建,但未返回可用标识,请返回列表确认。";
|
||||
setFormError(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
toast.success("合同已创建");
|
||||
runAfterSuccessfulSave(() => {
|
||||
navigate(`/contracts-registry/${data.item.id}`, { replace: true, state: { from: backSource } });
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
mergeDetailCache(data.item);
|
||||
setEditing(false);
|
||||
toast.success("合同信息已更新");
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["contracts-registry", "detail", contractId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["contracts-registry", "list"] }),
|
||||
]).catch(() => undefined);
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveErrorMessage(error, "保存合同失败");
|
||||
setFormError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: () => patchContractRegistry(contractId, { status: detail?.persistedStatus === "archived" ? "active" : "archived" }),
|
||||
onSuccess: (data) => {
|
||||
mergeDetailCache(data.item);
|
||||
toast.success(detail?.persistedStatus === "archived" ? "合同已恢复" : "合同已归档");
|
||||
setConfirmArchive(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
|
||||
},
|
||||
onError: (error) => toast.error(resolveErrorMessage(error, "更新合同状态失败")),
|
||||
});
|
||||
|
||||
const renewMutation = useMutation({
|
||||
mutationFn: () => renewContractRegistry(contractId),
|
||||
onSuccess: (data) => {
|
||||
toast.success("续约草稿已创建,原合同已归档");
|
||||
navigate(`/contracts-registry/${data.item.id}`, { replace: true, state: { from: backSource } });
|
||||
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
|
||||
},
|
||||
onError: (error) => toast.error(resolveErrorMessage(error, "合同续约失败")),
|
||||
});
|
||||
|
||||
const terminateMutation = useMutation({
|
||||
mutationFn: () => terminateContractRegistry(contractId, terminateReason.trim()),
|
||||
onSuccess: (data) => {
|
||||
setTerminateReason("");
|
||||
setConfirmTerminate(false);
|
||||
mergeDetailCache(data.item);
|
||||
toast.success("合同已终止");
|
||||
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
|
||||
},
|
||||
onError: (error) => toast.error(resolveErrorMessage(error, "终止合同失败")),
|
||||
});
|
||||
|
||||
const correctionMutation = useMutation({
|
||||
mutationFn: () => appendContractRegistryCorrection(contractId, correctionSummary.trim()),
|
||||
onSuccess: () => {
|
||||
setCorrectionSummary("");
|
||||
toast.success("修正记录已追加");
|
||||
void queryClient.invalidateQueries({ queryKey: ["contracts-registry", "detail", contractId] }).catch(() => undefined);
|
||||
},
|
||||
onError: (error) => toast.error(resolveErrorMessage(error, "追加修正记录失败")),
|
||||
});
|
||||
|
||||
const terminateReasonLength = terminateReason.trim().length;
|
||||
const canEdit = Boolean(canManage && detail && ["draft", "active"].includes(detail.persistedStatus));
|
||||
const canRenew = Boolean(canManage && detail && ["active", "expired"].includes(detail.status));
|
||||
const canArchive = Boolean(canManage && detail && ["draft", "active"].includes(detail.persistedStatus));
|
||||
const canRestore = Boolean(canManage && detail?.persistedStatus === "archived");
|
||||
const canTerminate = Boolean(canManage && detail && ["active", "expired"].includes(detail.status));
|
||||
const mobileActionsVisible = editing || canEdit;
|
||||
const headerTitle = creating ? "新增合同" : detail?.contractNo ?? "合同详情";
|
||||
|
||||
const cancelEditing = () => {
|
||||
if (!detail) return;
|
||||
const next = editorFromDetail(detail);
|
||||
setForm(next);
|
||||
setSavedSignature(JSON.stringify(next));
|
||||
setEditing(false);
|
||||
setFormError("");
|
||||
};
|
||||
|
||||
if (!creating && detailQuery.isLoading) {
|
||||
return <PageScaffold><PageHeader title="合同详情" /><div className="text-sm text-zinc-500">加载中...</div></PageScaffold>;
|
||||
}
|
||||
if (!creating && detailQuery.isError) {
|
||||
return (
|
||||
<PageScaffold>
|
||||
<PageHeader title="合同详情" actions={<Button asChild variant="secondary"><Link to={backSource}>返回合同列表</Link></Button>} />
|
||||
<WorkspaceError message="记录可能不存在或你无权访问" />
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
if (!creating && !detail) return null;
|
||||
|
||||
return (
|
||||
<PageScaffold className={mobileActionsVisible ? `${MOBILE_STICKY_ACTION_PAGE_CLASS} lg:pb-0` : undefined}>
|
||||
<PageHeader
|
||||
title={headerTitle}
|
||||
meta={!creating && detail ? <StatusBadge status={detail.status} label={contractStatusLabel(detail.status)} /> : null}
|
||||
actions={(
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!creating && canEdit && !editing ? <Button className="hidden lg:inline-flex" type="button" onClick={() => setEditing(true)}>编辑</Button> : null}
|
||||
<Button asChild type="button" variant="secondary"><Link to={backSource}>返回合同列表</Link></Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{formError ? <WorkspaceError message={formError} /> : null}
|
||||
|
||||
<BusinessDetailWorkspace testId="contract-detail-workspace">
|
||||
<BusinessDetailMain>
|
||||
{editing ? (
|
||||
<ContractEditorFields
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
projects={(projectsQuery.data?.items ?? []).map(({ id, projectNo, name }) => ({ id, projectNo, name }))}
|
||||
employees={(employeesQuery.data?.items ?? []).map(({ id, employeeNo, name }) => ({ id, employeeNo, name }))}
|
||||
creating={creating}
|
||||
/>
|
||||
) : detail ? (
|
||||
<>
|
||||
<SectionPanel title="状态与下一步" actions={<StatusBadge status={detail.status} label={contractStatusLabel(detail.status)} />}>
|
||||
<p className="text-sm leading-6 text-zinc-700">{nextStepText(detail)}</p>
|
||||
{detail.persistedStatus === "terminated" ? (
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
|
||||
<div>终止时间:{formatDisplayDateTime(detail.terminatedAt)}</div>
|
||||
<div>终止人:{detail.terminator?.name ?? "-"}</div>
|
||||
<div>终止原因:{detail.terminatedReason ?? "-"}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="业务摘要">
|
||||
<BusinessDetailFactGrid>
|
||||
<BusinessDetailFact label="合同编号">{detail.contractNo}</BusinessDetailFact>
|
||||
<BusinessDetailFact label="合同类型">{contractTypeLabel(detail.contractType)}</BusinessDetailFact>
|
||||
<BusinessDetailFact label="相对方">{detail.counterpartyName}</BusinessDetailFact>
|
||||
<BusinessDetailFact label="金额">{formatMoney(detail.amount)}</BusinessDetailFact>
|
||||
<BusinessDetailFact label="签署日期">{formatDisplayDate(detail.signedDate)}</BusinessDetailFact>
|
||||
<BusinessDetailFact label="生效日期">{formatDisplayDate(detail.effectiveDate)}</BusinessDetailFact>
|
||||
<BusinessDetailFact label="到期日期">{formatDisplayDate(detail.expiryDate)}</BusinessDetailFact>
|
||||
<BusinessDetailFact label="负责人">{detail.owner?.name ?? "-"}</BusinessDetailFact>
|
||||
<BusinessDetailFact label="敏感级别">{confidentialityLabel(detail.confidentialityLevel)}</BusinessDetailFact>
|
||||
<BusinessDetailFact label="关联项目">
|
||||
{detail.project ? <Link className="text-primary underline underline-offset-2" to={`/projects/${detail.project.id}`}>{detail.project.projectNo} · {detail.project.name}</Link> : "-"}
|
||||
</BusinessDetailFact>
|
||||
</BusinessDetailFactGrid>
|
||||
<div className="mt-4 rounded-lg bg-zinc-50 px-3 py-2 text-sm leading-6 text-zinc-700">{detail.summary}</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="证据区">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
涉密资料请登记线下归档位置
|
||||
</div>
|
||||
<BusinessDetailFactGrid className="mt-4">
|
||||
<BusinessDetailFact label="归档位置">{detail.archiveLocation}</BusinessDetailFact>
|
||||
<BusinessDetailFact label="离线档案号">{detail.offlineArchiveNo ?? "-"}</BusinessDetailFact>
|
||||
</BusinessDetailFactGrid>
|
||||
{detail.documentRefs.length ? (
|
||||
<div className="mt-4 space-y-3" data-testid="contract-document-refs">
|
||||
<h4 className="text-sm font-semibold text-zinc-900">涉密资料索引</h4>
|
||||
{detail.documentRefs.map((item) => (
|
||||
<article key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{item.documentNo ?? "无资料编号"} · {documentStorageLabel(item.storageType)} · {item.offlineArchiveNo ?? "无离线档案号"}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="变更与操作历史">
|
||||
<div className="space-y-3" data-testid="contract-history">
|
||||
{detail.history.length ? detail.history.map((item) => (
|
||||
<article key={item.id} className="border-l-2 border-zinc-200 pl-3 text-sm">
|
||||
<div className="font-medium text-zinc-900">{item.summary}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.actor?.name ?? "系统"} · {formatDisplayDateTime(item.createdAt)}</div>
|
||||
</article>
|
||||
)) : <div className="text-sm text-zinc-500">暂无操作历史</div>}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</>
|
||||
) : null}
|
||||
</BusinessDetailMain>
|
||||
|
||||
<BusinessDetailAside className="hidden lg:block">
|
||||
<BusinessDetailActionPanel
|
||||
status={!creating && detail ? <StatusBadge status={detail.status} label={contractStatusLabel(detail.status)} /> : null}
|
||||
primaryAction={editing ? (
|
||||
<Button className="hidden w-full lg:inline-flex" type="button" data-testid="contract-save" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
||||
{saveMutation.isPending ? "保存中..." : creating ? "创建合同" : "保存合同"}
|
||||
</Button>
|
||||
) : canEdit ? (
|
||||
<Button className="hidden w-full lg:inline-flex" type="button" onClick={() => setEditing(true)}>编辑</Button>
|
||||
) : null}
|
||||
secondaryActions={!editing && detail ? (
|
||||
<>
|
||||
{canRenew ? <Button type="button" size="sm" variant="secondary" onClick={() => setConfirmRenew(true)}>续约</Button> : null}
|
||||
{canArchive || canRestore ? <Button type="button" size="sm" variant="secondary" onClick={() => setConfirmArchive(true)}>{canRestore ? "恢复" : "归档"}</Button> : null}
|
||||
</>
|
||||
) : null}
|
||||
summary={!creating && detail ? (
|
||||
<>
|
||||
<div>合同编号:{detail.contractNo}</div>
|
||||
<div>相对方:{detail.counterpartyName}</div>
|
||||
<div>金额:{formatMoney(detail.amount)}</div>
|
||||
<div>负责人:{detail.owner?.name ?? "-"}</div>
|
||||
</>
|
||||
) : null}
|
||||
testId="contract-detail-action-panel"
|
||||
>
|
||||
{editing && !creating ? (
|
||||
<Button type="button" variant="secondary" className="w-full" onClick={() => runGuarded(cancelEditing)}>取消编辑</Button>
|
||||
) : null}
|
||||
|
||||
{!editing && canTerminate && detail ? (
|
||||
<div className="space-y-3" data-testid="contract-terminate-panel">
|
||||
<div className="text-sm font-semibold text-zinc-900">终止合同</div>
|
||||
<Textarea
|
||||
aria-label="终止原因"
|
||||
className="min-h-24"
|
||||
value={terminateReason}
|
||||
onChange={(event) => setTerminateReason(event.target.value)}
|
||||
placeholder="请填写终止原因,至少 10 个字"
|
||||
/>
|
||||
{terminateReasonLength < 10 ? (
|
||||
<p className="text-sm text-red-600" role="alert">请填写终止原因,至少 10 个字(当前 {terminateReasonLength} 个字)</p>
|
||||
) : <p className="text-xs text-zinc-500">终止后不可恢复、续约或再归档,合同编号不会释放。</p>}
|
||||
<Button type="button" variant="destructive" className="w-full" disabled={terminateReasonLength < 10 || terminateMutation.isPending} onClick={() => setConfirmTerminate(true)}>
|
||||
终止合同
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!editing && detail?.persistedStatus === "archived" && canManage ? (
|
||||
<div className="space-y-3" data-testid="contract-correction-panel">
|
||||
<div className="text-sm font-semibold text-zinc-900">追加修正记录</div>
|
||||
<Textarea aria-label="修正说明" className="min-h-20" value={correctionSummary} onChange={(event) => setCorrectionSummary(event.target.value)} placeholder="说明需要修正的事实,至少 10 个字;不会覆盖主记录" />
|
||||
<Button type="button" className="w-full" variant="secondary" disabled={correctionSummary.trim().length < 10 || correctionMutation.isPending} onClick={() => correctionMutation.mutate()}>
|
||||
追加记录
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</BusinessDetailActionPanel>
|
||||
</BusinessDetailAside>
|
||||
</BusinessDetailWorkspace>
|
||||
|
||||
{mobileActionsVisible ? (
|
||||
<MobileStickyActionBar className="lg:hidden" testId="contract-mobile-sticky-actions">
|
||||
{editing ? (
|
||||
<>
|
||||
{!creating ? <Button type="button" variant="secondary" onClick={() => runGuarded(cancelEditing)}>取消</Button> : <Button asChild variant="secondary"><Link to={backSource}>取消</Link></Button>}
|
||||
<Button type="button" data-testid="contract-mobile-save" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{saveMutation.isPending ? "保存中..." : creating ? "创建合同" : "保存合同"}</Button>
|
||||
</>
|
||||
) : canEdit ? <Button type="button" onClick={() => setEditing(true)}>编辑</Button> : null}
|
||||
</MobileStickyActionBar>
|
||||
) : null}
|
||||
|
||||
<ActionConfirmDialog
|
||||
open={confirmArchive}
|
||||
onOpenChange={setConfirmArchive}
|
||||
kind={canRestore ? "enable" : "archive"}
|
||||
targetName={detail?.contractNo}
|
||||
description={canRestore ? "恢复后合同重新进入生效台账和到期提醒。" : "归档后普通字段锁定,后续修正只能追加记录。"}
|
||||
confirmLabel={canRestore ? "确认恢复" : "确认归档"}
|
||||
onConfirm={async () => { await statusMutation.mutateAsync(); }}
|
||||
/>
|
||||
<ActionConfirmDialog
|
||||
open={confirmRenew}
|
||||
onOpenChange={setConfirmRenew}
|
||||
kind="submit"
|
||||
title="确认创建续约草稿"
|
||||
targetName={detail?.contractNo}
|
||||
description="系统将创建下一版草稿并归档当前合同;原合同编号和历史仍保留。"
|
||||
confirmLabel="确认续约"
|
||||
onConfirm={async () => { await renewMutation.mutateAsync(); }}
|
||||
/>
|
||||
<ActionConfirmDialog
|
||||
open={confirmTerminate}
|
||||
onOpenChange={setConfirmTerminate}
|
||||
kind="cancel"
|
||||
title="确认终止合同"
|
||||
targetName={detail?.contractNo}
|
||||
description={`合同编号:${detail?.contractNo ?? "-"};相对方:${detail?.counterpartyName ?? "-"};金额:${formatMoney(detail?.amount)}。确认后合同进入不可逆的“已终止”,编号不释放,普通编辑、续约、归档和恢复均不可用。`}
|
||||
confirmLabel="确认终止"
|
||||
variant="destructive"
|
||||
onConfirm={async () => { await terminateMutation.mutateAsync(); }}
|
||||
/>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceError({ message }: { message: string }) {
|
||||
return <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{message}</div>;
|
||||
}
|
||||
文件差异内容过多而无法显示
加载差异
@@ -59,6 +59,8 @@ import {
|
||||
ProjectsPage,
|
||||
ProjectDetailPage,
|
||||
ContractsRegistryPage,
|
||||
ContractRegistryCreatePage,
|
||||
ContractRegistryDetailPage,
|
||||
SystemSettingsPage,
|
||||
TeamPresencePage,
|
||||
TodosPage,
|
||||
@@ -165,6 +167,22 @@ const routeElements = (
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts-registry/new"
|
||||
element={
|
||||
<RequirePermission anyOf={["project.view_all", "project.manage", "system.settings"]}>
|
||||
{withRouteFallback(<ContractRegistryCreatePage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts-registry/:contractId"
|
||||
element={
|
||||
<RequirePermission anyOf={["project.view_own", "project.view_all", "project.manage", "system.settings"]}>
|
||||
{withRouteFallback(<ContractRegistryDetailPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="weekly-reports" element={withRouteFallback(<WeeklyReportsPage />)} />
|
||||
<Route path="todos" element={withRouteFallback(<TodosPage />)} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ContractRegistryCreatePage as default } from "../../pages/ContractRegistryWorkspacePage";
|
||||
@@ -0,0 +1 @@
|
||||
export { ContractRegistryDetailPage as default } from "../../pages/ContractRegistryWorkspacePage";
|
||||
@@ -50,6 +50,8 @@ export const PortalHomePage = lazy(() => import("./PortalHomePage"));
|
||||
export const ProjectsPage = lazy(() => import("./ProjectsPage"));
|
||||
export const ProjectDetailPage = lazy(() => import("./ProjectDetailPage"));
|
||||
export const ContractsRegistryPage = lazy(() => import("./ContractsRegistryPage"));
|
||||
export const ContractRegistryCreatePage = lazy(() => import("./ContractRegistryCreatePage"));
|
||||
export const ContractRegistryDetailPage = lazy(() => import("./ContractRegistryDetailPage"));
|
||||
export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"));
|
||||
export const PurchaseRequestDetailPage = lazy(() => import("./PurchaseRequestDetailPage"));
|
||||
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
const screenshotDir = path.resolve(process.cwd(), "../output/playwright/08-05-contract-detail-workspace");
|
||||
mkdirSync(screenshotDir, { recursive: true });
|
||||
|
||||
function screenshotPath(fileName: string) {
|
||||
return path.join(screenshotDir, fileName);
|
||||
}
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { requestId: "mock-contract-detail-workspace", data };
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(envelope(data)) });
|
||||
}
|
||||
|
||||
const employee = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
employeeNo: "HYD000001",
|
||||
name: "合同管理员",
|
||||
phone: "13800000000",
|
||||
email: "[email protected]",
|
||||
departmentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["manager"],
|
||||
bankAccount: { bankName: null, bankInstitutionCode: null, accountNumberMasked: null },
|
||||
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: false, hasAvatar: false, hasIdNumber: false, score: 25 },
|
||||
wecomBinding: null,
|
||||
};
|
||||
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function buildContract(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: contractId,
|
||||
contractNo: "HT-S-2026-0001",
|
||||
projectId,
|
||||
contractType: "service",
|
||||
counterpartyName: "合成客户有限公司",
|
||||
summary: "年度技术服务合同摘要",
|
||||
amount: "120000.00",
|
||||
currency: "CNY",
|
||||
signedDate: "2026-08-01",
|
||||
effectiveDate: "2026-08-01",
|
||||
expiryDate: "2027-07-31",
|
||||
confidentialityLevel: "restricted",
|
||||
archiveLocation: "线下档案柜 A-01",
|
||||
offlineArchiveNo: "OFF-CONTRACT-001",
|
||||
ownerEmployeeId: employee.id,
|
||||
status: "active",
|
||||
persistedStatus: "active",
|
||||
terminatedAt: null,
|
||||
terminatedReason: null,
|
||||
terminatedBy: null,
|
||||
createdAt: "2026-08-01T08:00:00.000Z",
|
||||
updatedAt: "2026-08-07T08:00:00.000Z",
|
||||
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
||||
terminator: null,
|
||||
project: { id: projectId, projectNo: "PRJ-2026-001", name: "合成项目" },
|
||||
documentRefs: [
|
||||
{
|
||||
id: "77777777-7777-4777-8777-777777777777",
|
||||
title: "涉密资料线下索引",
|
||||
documentNo: "DOC-2026-001",
|
||||
confidentialityLevel: "restricted",
|
||||
storageType: "offline_archive",
|
||||
offlineArchiveNo: "OFF-DOC-001",
|
||||
createdAt: "2026-08-01T08:00:00.000Z",
|
||||
updatedAt: "2026-08-01T08:00:00.000Z",
|
||||
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
||||
},
|
||||
],
|
||||
history: [
|
||||
{
|
||||
id: "audit-created",
|
||||
action: "CONTRACT_REGISTRY_CREATED",
|
||||
summary: "创建合同台账",
|
||||
createdAt: "2026-08-01T08:00:00.000Z",
|
||||
actor: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function navigationItem(key: string, href: string, label: string, icon: string) {
|
||||
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
||||
}
|
||||
|
||||
async function installContractMocks(page: Page, initialStatus: "active" | "archived" | "terminated" = "active") {
|
||||
let contract = buildContract(
|
||||
initialStatus === "archived"
|
||||
? { status: "archived", persistedStatus: "archived" }
|
||||
: initialStatus === "terminated"
|
||||
? {
|
||||
status: "terminated",
|
||||
persistedStatus: "terminated",
|
||||
terminatedAt: "2026-08-07T09:00:00.000Z",
|
||||
terminatedReason: "双方确认提前终止合同并保留原编号",
|
||||
terminatedBy: employee.id,
|
||||
terminator: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
||||
}
|
||||
: {},
|
||||
);
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pathName = url.pathname;
|
||||
|
||||
if (pathName === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, { enabled: false, entryEnabled: false, oauthEnabled: false, corpId: "", agentId: "", loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [] });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(envelope({ accessToken: "mock-contract-token", expiresIn: 900, mustChangePassword: false })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/me") {
|
||||
await fulfillJson(route, employee);
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/system/navigation") {
|
||||
await fulfillJson(route, {
|
||||
workspace: { id: "workspace-contract", persisted: true, profile: "small_team_internal", orgMode: "single_company", primaryEntryMode: "password", notificationStrategy: "in_app", devicePriority: "desktop", moduleVisibility: {}, createdAt: null, updatedAt: null, updatedBy: null },
|
||||
sections: { main: [navigationItem("portal", "/portal", "工作台", "home_app_logo"), navigationItem("contracts", "/contracts-registry", "合同", "description"), navigationItem("projects", "/projects", "项目", "topic")], admin: [], backup: [] },
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/notifications") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/portal/home") {
|
||||
await fulfillJson(route, { workspace: { id: "workspace-contract", persisted: true, profile: "small_team_internal" }, greeting: { name: employee.name }, cards: [], pendingTasks: { count: 0, href: "/workflow/tasks" }, myApplications: [], riskSummary: null, announcements: [], quickLinks: [], recentActivities: [] });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/projects") {
|
||||
await fulfillJson(route, { items: [{ id: projectId, projectNo: "PRJ-2026-001", name: "合成项目", clientName: "合成客户", clientAlias: null, status: "active", startsAt: "2026-08-01", targetEndAt: null, actualEndAt: null, summary: null, isConfidential: true, owner: null, manager: null, counts: { members: 1, milestones: 0, tasks: 0, risks: 0, overdueMilestones: 0, highPriorityTasks: 0, openRisks: 0 }, updatedAt: null }] });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/employees") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 100, total: 1, items: [{ ...employee, department: null, title: "项目经理", employmentStatus: "active", hireDate: null, avatarUrl: null, createdAt: null, updatedAt: null }] });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/contracts-registry/expiring") {
|
||||
await fulfillJson(route, { days: 30, total: 0, items: [] });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/contracts-registry" && request.method() === "GET") {
|
||||
const {
|
||||
documentRefs: _documentRefs,
|
||||
history: _history,
|
||||
terminatedAt: _terminatedAt,
|
||||
terminatedReason: _terminatedReason,
|
||||
terminatedBy: _terminatedBy,
|
||||
terminator: _terminator,
|
||||
...item
|
||||
} = contract;
|
||||
await fulfillJson(route, { items: [item] });
|
||||
return;
|
||||
}
|
||||
if (pathName === `/api/contracts-registry/${contractId}` && request.method() === "GET") {
|
||||
await fulfillJson(route, { item: contract });
|
||||
return;
|
||||
}
|
||||
if (pathName === `/api/contracts-registry/${contractId}` && request.method() === "PATCH") {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
contract = {
|
||||
...contract,
|
||||
...body,
|
||||
status: body.status === "archived" ? "archived" : body.status === "active" ? "active" : contract.status,
|
||||
persistedStatus: body.status === "archived" ? "archived" : body.status === "active" ? "active" : contract.persistedStatus,
|
||||
history: [{ id: `audit-${Date.now()}`, action: "CONTRACT_REGISTRY_STATUS_CHANGED", summary: body.status === "archived" ? "状态由“生效中”变为“已归档”" : "状态由“已归档”变为“生效中”", createdAt: "2026-08-07T10:00:00.000Z", actor: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name } }, ...contract.history],
|
||||
};
|
||||
await fulfillJson(route, { item: contract });
|
||||
return;
|
||||
}
|
||||
if (pathName === `/api/contracts-registry/${contractId}/terminate` && request.method() === "POST") {
|
||||
const body = request.postDataJSON() as { reason: string };
|
||||
contract = {
|
||||
...contract,
|
||||
status: "terminated",
|
||||
persistedStatus: "terminated",
|
||||
terminatedAt: "2026-08-07T10:30:00.000Z",
|
||||
terminatedReason: body.reason,
|
||||
terminatedBy: employee.id,
|
||||
terminator: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
|
||||
history: [{ id: "audit-terminated", action: "CONTRACT_REGISTRY_TERMINATED", summary: `终止原因:${body.reason}`, createdAt: "2026-08-07T10:30:00.000Z", actor: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name } }, ...contract.history],
|
||||
};
|
||||
await fulfillJson(route, { item: contract });
|
||||
return;
|
||||
}
|
||||
if (pathName === `/api/contracts-registry/${contractId}/corrections` && request.method() === "POST") {
|
||||
const body = request.postDataJSON() as { summary: string };
|
||||
contract = {
|
||||
...contract,
|
||||
history: [{ id: "audit-correction", action: "CONTRACT_REGISTRY_CORRECTION_APPENDED", summary: `追加修正:${body.summary}`, createdAt: "2026-08-07T11:00:00.000Z", actor: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name } }, ...contract.history],
|
||||
};
|
||||
await fulfillJson(route, { accepted: true });
|
||||
return;
|
||||
}
|
||||
if (pathName === "/api/contracts-registry" && request.method() === "POST") {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
contract = { ...contract, ...body, id: contractId, status: body.status ?? "active", persistedStatus: body.status ?? "active" };
|
||||
await fulfillJson(route, { item: contract }, 201);
|
||||
return;
|
||||
}
|
||||
if (pathName === `/api/contracts-registry/${contractId}/renew`) {
|
||||
await fulfillJson(route, { item: { ...contract, id: "55555555-5555-4555-8555-555555555552", contractNo: "HT-S-2026-0001-R1", status: "draft", persistedStatus: "draft" }, sourceItem: { ...contract, status: "archived", persistedStatus: "archived" } });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { message: `unhandled mock api: ${pathName}` }, 404);
|
||||
});
|
||||
}
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByTestId("login-identifier").fill(employee.email);
|
||||
await page.getByTestId("login-password").fill("ContractPassword1234");
|
||||
await page.getByTestId("login-submit").click();
|
||||
await expect(page).toHaveURL(/\/portal$/);
|
||||
await page.evaluate(() => {
|
||||
document.cookie = "has_session=1; path=/; SameSite=Lax";
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("合同详情工作区", () => {
|
||||
test("列表只负责查找,新增与详情使用稳定路由和证据结构", async ({ page }) => {
|
||||
await installContractMocks(page);
|
||||
await login(page);
|
||||
await page.goto("/contracts-registry");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "合同", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("contract-editor")).toHaveCount(0);
|
||||
await expect(page.getByTestId("contracts-registry-table").getByRole("link", { name: "查看详情" })).toBeVisible();
|
||||
|
||||
await page.getByTestId("contracts-registry-table").getByRole("link", { name: "查看详情" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/contracts-registry/${contractId}$`));
|
||||
await expect(page.locator(".section-panel__title").first()).toBeVisible();
|
||||
const sectionTitles = await page.locator(".section-panel__title").allTextContents();
|
||||
expect(sectionTitles.slice(0, 4)).toEqual(["状态与下一步", "业务摘要", "证据区", "变更与操作历史"]);
|
||||
await expect(page.getByText("涉密资料请登记线下归档位置", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("涉密资料线下索引", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("附件完整性", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("上传合同", { exact: false })).toHaveCount(0);
|
||||
await page.screenshot({ path: screenshotPath("contract-detail-desktop.png"), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await expect(page.getByTestId("contract-mobile-sticky-actions")).toBeVisible();
|
||||
await expect(page.getByTestId("contract-detail-action-panel")).toBeHidden();
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(2);
|
||||
await page.screenshot({ path: screenshotPath("contract-detail-mobile-375x812.png"), fullPage: true });
|
||||
|
||||
await page.goto("/contracts-registry");
|
||||
await page.getByRole("link", { name: "新增合同" }).click();
|
||||
await expect(page).toHaveURL(/\/contracts-registry\/new$/);
|
||||
await expect(page.getByText("涉密资料请登记线下归档位置", { exact: true })).toBeVisible();
|
||||
await page.getByTestId("contract-counterparty").fill("未保存的相对方");
|
||||
await page.getByRole("link", { name: "取消" }).click();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("合同表单尚未保存");
|
||||
await page.getByTestId("unsaved-changes-continue").click();
|
||||
await expect(page).toHaveURL(/\/contracts-registry\/new$/);
|
||||
await page.getByRole("link", { name: "取消" }).click();
|
||||
await page.getByTestId("unsaved-changes-discard").click();
|
||||
await expect(page).toHaveURL(/\/contracts-registry$/);
|
||||
});
|
||||
|
||||
test("终止原因禁用说明、对象摘要确认和不可逆终态完整可见", async ({ page }) => {
|
||||
await installContractMocks(page);
|
||||
await login(page);
|
||||
await page.goto(`/contracts-registry/${contractId}`);
|
||||
|
||||
const terminateButton = page.getByRole("button", { name: "终止合同", exact: true });
|
||||
await expect(terminateButton).toBeDisabled();
|
||||
await expect(page.getByRole("alert")).toContainText("请填写终止原因,至少 10 个字");
|
||||
await page.getByLabel("终止原因").fill("原因太短");
|
||||
await expect(terminateButton).toBeDisabled();
|
||||
await page.getByLabel("终止原因").fill("客户项目已取消,双方确认不再继续履行");
|
||||
await expect(terminateButton).toBeEnabled();
|
||||
await terminateButton.click();
|
||||
|
||||
const confirm = page.getByTestId("action-confirm-dialog");
|
||||
await expect(confirm).toContainText("HT-S-2026-0001");
|
||||
await expect(confirm).toContainText("合成客户有限公司");
|
||||
await expect(confirm).toContainText("120,000.00");
|
||||
await expect(confirm).toContainText("不可逆");
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
|
||||
await expect(page.getByText("已终止", { exact: true }).first()).toBeVisible();
|
||||
const statusSection = page.locator(".section-panel").filter({
|
||||
has: page.getByRole("heading", { name: "状态与下一步", exact: true }),
|
||||
});
|
||||
await expect(statusSection).toContainText("客户项目已取消,双方确认不再继续履行");
|
||||
await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "续约", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "归档", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "恢复", exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("归档合同锁定编辑并通过追加记录修正,恢复需二次确认", async ({ page }) => {
|
||||
await installContractMocks(page, "archived");
|
||||
await login(page);
|
||||
await page.goto(`/contracts-registry/${contractId}`);
|
||||
|
||||
await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("普通字段已锁定;如需纠错,请追加修正记录。", { exact: true })).toBeVisible();
|
||||
await page.getByLabel("修正说明").fill("原归档位置登记有误,实际以离线档案号为准");
|
||||
await page.getByRole("button", { name: "追加记录" }).click();
|
||||
await expect(page.getByTestId("contract-history")).toContainText("追加修正:原归档位置登记有误,实际以离线档案号为准");
|
||||
|
||||
await page.getByRole("button", { name: "恢复", exact: true }).click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("恢复后合同重新进入生效台账和到期提醒");
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
await expect(page.getByText("生效中", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "编辑", exact: true }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -497,6 +497,30 @@ const secondContractRegistryItem = {
|
||||
offlineArchiveNo: "OFF-2026-W3-02",
|
||||
};
|
||||
|
||||
const contractRegistryDetail = {
|
||||
...contractRegistryItem,
|
||||
terminatedAt: null,
|
||||
terminatedReason: null,
|
||||
terminatedBy: null,
|
||||
terminator: null,
|
||||
documentRefs: [],
|
||||
history: [
|
||||
{
|
||||
id: "audit-contract-wave3",
|
||||
action: "CONTRACT_REGISTRY_CREATED",
|
||||
summary: "创建合同台账",
|
||||
createdAt: "2026-05-20T09:00:00.000Z",
|
||||
actor: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const secondContractRegistryDetail = {
|
||||
...contractRegistryDetail,
|
||||
...secondContractRegistryItem,
|
||||
history: contractRegistryDetail.history,
|
||||
};
|
||||
|
||||
const purchaseDetail = {
|
||||
id: "purchase-wave3",
|
||||
requestNo: "CG20260527001",
|
||||
@@ -997,6 +1021,16 @@ async function mockWave3Apis(page: Page) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "GET" && ["/api/contracts-registry/contract-wave3", "/api/contracts-registry/contract-wave3-second"].includes(pathName)) {
|
||||
await fulfillJson(route, { item: pathName.endsWith("-second") ? secondContractRegistryDetail : contractRegistryDetail });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/contracts-registry/contract-wave3" && request.method() === "PATCH") {
|
||||
await fulfillJson(route, { item: { ...contractRegistryDetail, status: "archived", persistedStatus: "archived" } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/contracts-registry") {
|
||||
await fulfillJson(route, { items: [contractRegistryItem, secondContractRegistryItem] });
|
||||
return;
|
||||
@@ -1409,19 +1443,18 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
const contractRows = page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row");
|
||||
await expect(contractRows).toHaveCount(2);
|
||||
await expect(contractRows.first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "筛选", exact: true }).click();
|
||||
await expect(page.getByTestId("contracts-advanced-filters")).toBeVisible();
|
||||
await page.getByRole("button", { name: "新增合同" }).click();
|
||||
await expect(page.getByTestId("contracts-editor-panel")).toBeVisible();
|
||||
await page.getByRole("button", { name: "关闭" }).click();
|
||||
await expect(contractRows).toHaveCount(2);
|
||||
await page.getByTestId("contracts-registry-table").getByRole("button", { name: "更多" }).first().click();
|
||||
await page.getByRole("menuitem", { name: "归档" }).click();
|
||||
await page.getByRole("button", { name: "更多筛选", exact: true }).click();
|
||||
await expect(page.getByLabel("到期开始日期")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "编辑", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "更多", exact: true })).toHaveCount(0);
|
||||
await page.getByTestId("contracts-registry-table").getByRole("link", { name: "查看详情" }).first().click();
|
||||
await expect(page).toHaveURL(/\/contracts-registry\/contract-wave3$/);
|
||||
await expect(page.getByText("状态与下一步", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("涉密资料请登记线下归档位置", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "归档", exact: true }).click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认归档");
|
||||
await page.getByTestId("action-confirm-cancel").click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toHaveCount(0);
|
||||
await page.locator(".ops-command-bar__actions").getByRole("button", { name: "更多" }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "导出 CSV" })).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true });
|
||||
});
|
||||
|
||||
@@ -1464,7 +1497,7 @@ test("/contracts-registry 在 768x1024 与 1024x768 下操作列可达", async (
|
||||
expect(["auto", "scroll"]).toContain(metrics.overflowX);
|
||||
expect(metrics.scrollWidth).toBeGreaterThanOrEqual(metrics.clientWidth);
|
||||
expect(metrics.documentOverflow).toBeLessThanOrEqual(2);
|
||||
const operation = table.getByRole("button", { name: "更多" }).first();
|
||||
const operation = table.getByRole("link", { name: "查看详情" }).first();
|
||||
await expect(operation).toBeVisible();
|
||||
await operation.click({ trial: true });
|
||||
await page.screenshot({
|
||||
@@ -1474,24 +1507,27 @@ test("/contracts-registry 在 768x1024 与 1024x768 下操作列可达", async (
|
||||
}
|
||||
});
|
||||
|
||||
test("合同长表单从记录 A 切换到记录 B 时支持取消保持与确认放弃", async ({ page }) => {
|
||||
test("合同详情编辑离开路由时支持取消保持与确认放弃", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto("/contracts-registry");
|
||||
const table = page.getByTestId("contracts-registry-table");
|
||||
const editButtons = table.getByRole("button", { name: "编辑", exact: true });
|
||||
await editButtons.first().click();
|
||||
const counterparty = page.getByPlaceholder("合同对方名称");
|
||||
await table.getByRole("link", { name: "查看详情" }).first().click();
|
||||
await expect(page).toHaveURL(/\/contracts-registry\/contract-wave3$/);
|
||||
await page.getByRole("button", { name: "编辑", exact: true }).first().click();
|
||||
const counterparty = page.getByTestId("contract-counterparty");
|
||||
await counterparty.fill("合成未保存合同相对方");
|
||||
|
||||
await editButtons.nth(1).click();
|
||||
await page.getByRole("link", { name: "返回合同列表" }).click();
|
||||
await expect(page.getByTestId("unsaved-changes-dialog")).toContainText("合同表单尚未保存");
|
||||
await page.getByTestId("unsaved-changes-continue").click();
|
||||
await expect(page).toHaveURL(/\/contracts-registry$/);
|
||||
await expect(page).toHaveURL(/\/contracts-registry\/contract-wave3$/);
|
||||
await expect(counterparty).toHaveValue("合成未保存合同相对方");
|
||||
|
||||
await editButtons.nth(1).click();
|
||||
await page.getByRole("link", { name: "返回合同列表" }).click();
|
||||
await page.getByTestId("unsaved-changes-discard").click();
|
||||
await expect(counterparty).toHaveValue(secondContractRegistryItem.counterpartyName);
|
||||
await expect(page).toHaveURL(/\/contracts-registry$/);
|
||||
await page.getByTestId("contracts-registry-table").getByRole("link", { name: "查看详情" }).nth(1).click();
|
||||
await expect(page.getByText(secondContractRegistryItem.counterpartyName, { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Wave3 移动关键页视觉截图", async ({ page }) => {
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"client/tests/e2e/internal-readiness-ui.spec.ts",
|
||||
"client/tests/e2e/system-action-confirmation.spec.ts",
|
||||
"client/tests/e2e/payment-reviewer-permissions.spec.ts",
|
||||
"client/tests/e2e/r5-real-device-feedback-mobile-ux.spec.ts"
|
||||
"client/tests/e2e/r5-real-device-feedback-mobile-ux.spec.ts",
|
||||
"client/tests/e2e/contract-detail-workspace.spec.ts"
|
||||
],
|
||||
"localCandidateFlows": [
|
||||
{
|
||||
@@ -129,6 +130,8 @@
|
||||
{ "path": "/budgets", "classification": "supported-noncore" },
|
||||
{ "path": "/change-password", "classification": "supported-noncore" },
|
||||
{ "path": "/contracts-registry", "classification": "core", "bundle": "project-contract", "behaviorSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts"] },
|
||||
{ "path": "/contracts-registry/:contractId", "classification": "core", "bundle": "project-contract", "behaviorSpecs": ["client/tests/e2e/contract-detail-workspace.spec.ts"], "visualRequired": true, "visualSpecs": ["client/tests/e2e/contract-detail-workspace.spec.ts"] },
|
||||
{ "path": "/contracts-registry/new", "classification": "supported-noncore", "behaviorSpecs": ["client/tests/e2e/contract-detail-workspace.spec.ts"] },
|
||||
{ "path": "/dashboard", "classification": "deferred" },
|
||||
{ "path": "/delegations", "classification": "supported-noncore" },
|
||||
{ "path": "/forgot-password", "classification": "supported-noncore" },
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "contract_registry"
|
||||
ADD COLUMN "terminated_at" TIMESTAMP(3),
|
||||
ADD COLUMN "terminated_reason" VARCHAR(1000),
|
||||
ADD COLUMN "terminated_by" UUID;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_contract_registry_terminator_terminated"
|
||||
ON "contract_registry"("terminated_by", "terminated_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "contract_registry"
|
||||
ADD CONSTRAINT "contract_registry_terminated_by_fkey"
|
||||
FOREIGN KEY ("terminated_by") REFERENCES "employees"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -716,6 +716,7 @@ model Employee {
|
||||
created_project_communication_logs ProjectCommunicationLog[] @relation("ProjectCommunicationLogCreator")
|
||||
completed_project_closure_checklists ProjectClosureChecklistItem[] @relation("ProjectClosureChecklistItemCompleter")
|
||||
owned_contract_registries ContractRegistry[] @relation("ContractRegistryOwner")
|
||||
terminated_contract_registries ContractRegistry[] @relation("ContractRegistryTerminator")
|
||||
owned_confidential_document_refs ConfidentialDocumentRef[] @relation("ConfidentialDocumentRefOwner")
|
||||
contracts EmployeeContract[]
|
||||
trainings EmployeeTraining[]
|
||||
@@ -2905,18 +2906,23 @@ model ContractRegistry {
|
||||
offline_archive_no String?
|
||||
owner_employee_id String @db.Uuid
|
||||
status String @default("active") @db.VarChar(32)
|
||||
terminated_at DateTime?
|
||||
terminated_reason String? @db.VarChar(1000)
|
||||
terminated_by String? @db.Uuid
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
|
||||
owner Employee @relation("ContractRegistryOwner", fields: [owner_employee_id], references: [id], onDelete: Restrict)
|
||||
terminator Employee? @relation("ContractRegistryTerminator", fields: [terminated_by], references: [id], onDelete: Restrict)
|
||||
document_refs ConfidentialDocumentRef[]
|
||||
|
||||
@@index([project_id, status], map: "idx_contract_registry_project_status")
|
||||
@@index([status, expiry_date], map: "idx_contract_registry_status_expiry")
|
||||
@@index([contract_type, status], map: "idx_contract_registry_type_status")
|
||||
@@index([owner_employee_id, created_at], map: "idx_contract_registry_owner_created")
|
||||
@@index([terminated_by, terminated_at], map: "idx_contract_registry_terminator_terminated")
|
||||
@@map("contract_registry")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const {
|
||||
CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES,
|
||||
CONTRACT_REGISTRY_TYPE_VALUES,
|
||||
buildContractRegistryExpiringWhere,
|
||||
buildContractRegistryScopeWhere,
|
||||
buildContractRegistryValidationError,
|
||||
buildContractRegistryWhere,
|
||||
canManageContractRegistry,
|
||||
@@ -24,7 +24,8 @@ const UUID_SCHEMA = z.string().uuid();
|
||||
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const CONFIDENTIALITY_SCHEMA = z.enum(["public", "internal", "confidential", "restricted"]);
|
||||
const CONTRACT_TYPE_SCHEMA = z.enum(CONTRACT_REGISTRY_TYPE_VALUES);
|
||||
const STATUS_WRITE_SCHEMA = z.enum(CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES);
|
||||
const GENERAL_WRITE_STATUS_VALUES = ["draft", "active", "archived"];
|
||||
const STATUS_WRITE_SCHEMA = z.enum(GENERAL_WRITE_STATUS_VALUES);
|
||||
const MONEY_INPUT_SCHEMA = z.string().regex(/^\d{1,12}(\.\d{1,2})?$/);
|
||||
const CURRENCY_SCHEMA = z
|
||||
.string()
|
||||
@@ -57,11 +58,51 @@ const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value)
|
||||
message: "empty_patch",
|
||||
});
|
||||
|
||||
const TERMINATE_SCHEMA = z
|
||||
.object({
|
||||
reason: z.string().trim().min(10).max(1000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const CORRECTION_SCHEMA = z
|
||||
.object({
|
||||
summary: z.string().trim().min(10).max(1000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const CONTRACT_REGISTRY_INCLUDE = {
|
||||
owner: { select: { id: true, employee_no: true, name: true } },
|
||||
project: { select: { id: true, project_no: true, name: true } },
|
||||
terminator: { select: { id: true, employee_no: true, name: true } },
|
||||
};
|
||||
|
||||
const CONTRACT_REGISTRY_DETAIL_INCLUDE = {
|
||||
...CONTRACT_REGISTRY_INCLUDE,
|
||||
document_refs: {
|
||||
orderBy: [{ updated_at: "desc" }, { id: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
document_no: true,
|
||||
confidentiality_level: true,
|
||||
storage_type: true,
|
||||
offline_archive_no: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
owner: { select: { id: true, employee_no: true, name: true } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CONTRACT_HISTORY_ACTIONS = [
|
||||
"CONTRACT_REGISTRY_CREATED",
|
||||
"CONTRACT_REGISTRY_UPDATED",
|
||||
"CONTRACT_REGISTRY_STATUS_CHANGED",
|
||||
"CONTRACT_REGISTRY_RENEWED",
|
||||
"CONTRACT_REGISTRY_TERMINATED",
|
||||
"CONTRACT_REGISTRY_CORRECTION_APPENDED",
|
||||
];
|
||||
|
||||
function parseBody(schema, body) {
|
||||
const parsed = schema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -102,11 +143,78 @@ async function writeAudit(prisma, request, action, targetId, extra = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRequiredAudit(prisma, request, action, targetId, extra = {}) {
|
||||
return prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action,
|
||||
targetType: "contract_registry",
|
||||
targetId,
|
||||
extra,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function parseOptionalDate(value) {
|
||||
return value ? parseDateOnlyString(value) : null;
|
||||
}
|
||||
|
||||
function mapEmployee(item) {
|
||||
return item ? { id: item.id, employeeNo: item.employee_no, name: item.name } : null;
|
||||
}
|
||||
|
||||
function assertTerminationInvariant(item) {
|
||||
const terminationFields = [item?.terminated_at, item?.terminated_reason, item?.terminated_by];
|
||||
const allPresent = terminationFields.every((value) => value !== null && value !== undefined && value !== "");
|
||||
const anyPresent = terminationFields.some((value) => value !== null && value !== undefined && value !== "");
|
||||
const valid = item?.status === "terminated" ? allPresent : !anyPresent;
|
||||
if (!valid) {
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "合同状态数据不一致",
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function contractStatusLabel(value) {
|
||||
if (value === "draft") return "草稿";
|
||||
if (value === "active") return "生效中";
|
||||
if (value === "expired") return "已过期";
|
||||
if (value === "archived") return "已归档";
|
||||
if (value === "terminated") return "已终止";
|
||||
return "未知状态";
|
||||
}
|
||||
|
||||
function mapHistorySummary(item) {
|
||||
const extra = item.extra && typeof item.extra === "object" && !Array.isArray(item.extra) ? item.extra : {};
|
||||
if (item.action === "CONTRACT_REGISTRY_CREATED") return "创建合同台账";
|
||||
if (item.action === "CONTRACT_REGISTRY_UPDATED") return "更新合同信息";
|
||||
if (item.action === "CONTRACT_REGISTRY_RENEWED") {
|
||||
return extra.renewedContractNo ? `续约为 ${String(extra.renewedContractNo)}` : "创建续约草稿";
|
||||
}
|
||||
if (item.action === "CONTRACT_REGISTRY_TERMINATED") {
|
||||
const reason = extra.reasonSummary ?? extra.reason;
|
||||
return reason ? `终止原因:${String(reason)}` : "合同已终止";
|
||||
}
|
||||
if (item.action === "CONTRACT_REGISTRY_CORRECTION_APPENDED") {
|
||||
return extra.summary ? `追加修正:${String(extra.summary)}` : "追加归档修正记录";
|
||||
}
|
||||
if (item.action === "CONTRACT_REGISTRY_STATUS_CHANGED") {
|
||||
const fromStatus = contractStatusLabel(extra.fromStatus);
|
||||
const toStatus = contractStatusLabel(extra.toStatus);
|
||||
return `状态由“${fromStatus}”变为“${toStatus}”`;
|
||||
}
|
||||
return "合同记录已更新";
|
||||
}
|
||||
|
||||
function buildAuditReasonSummary(value) {
|
||||
const normalized = String(value ?? "").replace(/\s+/g, " ").trim();
|
||||
return normalized.length > 160 ? `${normalized.slice(0, 160)}…` : normalized;
|
||||
}
|
||||
|
||||
function mapRow(item, today) {
|
||||
assertTerminationInvariant(item);
|
||||
return {
|
||||
id: item.id,
|
||||
contractNo: item.contract_no,
|
||||
@@ -125,17 +233,63 @@ function mapRow(item, today) {
|
||||
ownerEmployeeId: item.owner_employee_id,
|
||||
status: resolveContractRegistryEffectiveStatus(item, today),
|
||||
persistedStatus: item.status,
|
||||
terminatedAt: item.terminated_at?.toISOString?.() ?? null,
|
||||
terminatedReason: item.terminated_reason ?? null,
|
||||
terminatedBy: item.terminated_by ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
owner: item.owner
|
||||
? { id: item.owner.id, employeeNo: item.owner.employee_no, name: item.owner.name }
|
||||
: null,
|
||||
owner: mapEmployee(item.owner),
|
||||
terminator: mapEmployee(item.terminator),
|
||||
project: item.project
|
||||
? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapListRow(item, today) {
|
||||
const row = mapRow(item, today);
|
||||
const {
|
||||
terminatedAt: _terminatedAt,
|
||||
terminatedReason: _terminatedReason,
|
||||
terminatedBy: _terminatedBy,
|
||||
terminator: _terminator,
|
||||
...listRow
|
||||
} = row;
|
||||
return listRow;
|
||||
}
|
||||
|
||||
function mapDocumentRef(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
documentNo: item.document_no ?? null,
|
||||
confidentialityLevel: item.confidentiality_level,
|
||||
storageType: item.storage_type,
|
||||
offlineArchiveNo: item.offline_archive_no ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
owner: mapEmployee(item.owner),
|
||||
};
|
||||
}
|
||||
|
||||
function mapHistoryItem(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
action: item.action,
|
||||
summary: mapHistorySummary(item),
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
actor: mapEmployee(item.actor),
|
||||
};
|
||||
}
|
||||
|
||||
function buildScopedContractWhere(fastify, request, contractId) {
|
||||
const scopeWhere = buildContractRegistryScopeWhere({
|
||||
employee: request.employee,
|
||||
hasPermission: fastify.hasPermission,
|
||||
});
|
||||
return Object.keys(scopeWhere).length ? { AND: [{ id: contractId }, scopeWhere] } : { id: contractId };
|
||||
}
|
||||
|
||||
function buildNotFoundError() {
|
||||
return new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
@@ -154,6 +308,57 @@ function buildRenewalContractNo(baseContractNo, rows) {
|
||||
return `${baseContractNo}-R${maxRenewalIndex + 1}`;
|
||||
}
|
||||
|
||||
function buildContractPatchData(body) {
|
||||
return {
|
||||
...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}),
|
||||
...(body.projectId !== undefined ? { project_id: body.projectId ?? null } : {}),
|
||||
...(body.contractType !== undefined ? { contract_type: body.contractType } : {}),
|
||||
...(body.counterpartyName !== undefined ? { counterparty_name: body.counterpartyName } : {}),
|
||||
...(body.summary !== undefined ? { summary: body.summary } : {}),
|
||||
...(body.amount !== undefined ? { amount: body.amount ?? null } : {}),
|
||||
...(body.currency !== undefined ? { currency: body.currency } : {}),
|
||||
...(body.signedDate !== undefined ? { signed_date: parseOptionalDate(body.signedDate) } : {}),
|
||||
...(body.effectiveDate !== undefined ? { effective_date: parseOptionalDate(body.effectiveDate) } : {}),
|
||||
...(body.expiryDate !== undefined ? { expiry_date: parseOptionalDate(body.expiryDate) } : {}),
|
||||
...(body.confidentialityLevel !== undefined ? { confidentiality_level: body.confidentialityLevel } : {}),
|
||||
...(body.archiveLocation !== undefined ? { archive_location: body.archiveLocation } : {}),
|
||||
...(body.offlineArchiveNo !== undefined ? { offline_archive_no: body.offlineArchiveNo ?? null } : {}),
|
||||
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}),
|
||||
...(body.status !== undefined ? { status: body.status } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildContractStateGuard(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
updated_at: item.updated_at,
|
||||
terminated_at: item.terminated_at ?? null,
|
||||
terminated_reason: item.terminated_reason ?? null,
|
||||
terminated_by: item.terminated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateContractWithStateGuard(prisma, existing, data) {
|
||||
const changed = await prisma.contractRegistry.updateMany({
|
||||
where: buildContractStateGuard(existing),
|
||||
data,
|
||||
});
|
||||
if (changed.count !== 1) {
|
||||
throw buildContractRegistryValidationError({ status: "contract_state_conflict" }, 409);
|
||||
}
|
||||
|
||||
const updated = await prisma.contractRegistry.findUnique({
|
||||
where: { id: existing.id },
|
||||
include: CONTRACT_REGISTRY_INCLUDE,
|
||||
});
|
||||
if (!updated) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
assertTerminationInvariant(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function contractsRegistryRoutes(fastify) {
|
||||
fastify.get("/contracts-registry", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
@@ -170,7 +375,7 @@ async function contractsRegistryRoutes(fastify) {
|
||||
include: CONTRACT_REGISTRY_INCLUDE,
|
||||
});
|
||||
|
||||
return sendData(reply, request, { items: rows.map((item) => mapRow(item, today)) });
|
||||
return sendData(reply, request, { items: rows.map((item) => mapListRow(item, today)) });
|
||||
});
|
||||
|
||||
fastify.get("/contracts-registry/expiring", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -191,7 +396,39 @@ async function contractsRegistryRoutes(fastify) {
|
||||
return sendData(reply, request, {
|
||||
days,
|
||||
total: rows.length,
|
||||
items: rows.map((item) => mapRow(item, today)),
|
||||
items: rows.map((item) => mapListRow(item, today)),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get("/contracts-registry/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
const contractId = UUID_SCHEMA.parse(request.params?.id);
|
||||
const today = resolveContractRegistryToday(fastify.config.appTimezone);
|
||||
const item = await fastify.prisma.contractRegistry.findFirst({
|
||||
where: buildScopedContractWhere(fastify, request, contractId),
|
||||
include: CONTRACT_REGISTRY_DETAIL_INCLUDE,
|
||||
});
|
||||
if (!item) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
|
||||
const history = await fastify.prisma.auditLog.findMany({
|
||||
where: {
|
||||
target_type: "contract_registry",
|
||||
target_id: contractId,
|
||||
action: { in: CONTRACT_HISTORY_ACTIONS },
|
||||
},
|
||||
orderBy: [{ created_at: "desc" }, { id: "desc" }],
|
||||
take: 100,
|
||||
include: { actor: { select: { id: true, employee_no: true, name: true } } },
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: {
|
||||
...mapRow(item, today),
|
||||
documentRefs: (item.document_refs ?? []).map(mapDocumentRef),
|
||||
history: history.map(mapHistoryItem),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,50 +490,153 @@ async function contractsRegistryRoutes(fastify) {
|
||||
const body = parseBody(PATCH_SCHEMA, request.body);
|
||||
const existing = await fastify.prisma.contractRegistry.findUnique({
|
||||
where: { id: contractId },
|
||||
select: { id: true, status: true, contract_no: true },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
contract_no: true,
|
||||
terminated_at: true,
|
||||
terminated_reason: true,
|
||||
terminated_by: true,
|
||||
updated_at: true,
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
|
||||
assertTerminationInvariant(existing);
|
||||
if (existing.status === "terminated") {
|
||||
throw buildContractRegistryValidationError({ status: "terminated_is_final" }, 409);
|
||||
}
|
||||
const patchFields = Object.keys(body);
|
||||
if (existing.status === "archived" && !(patchFields.length === 1 && body.status === "active")) {
|
||||
throw buildContractRegistryValidationError({ status: "archived_fields_locked" }, 409);
|
||||
}
|
||||
|
||||
const today = resolveContractRegistryToday(fastify.config.appTimezone);
|
||||
const updated = await fastify.prisma.contractRegistry.update({
|
||||
where: { id: contractId },
|
||||
data: {
|
||||
...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}),
|
||||
...(body.projectId !== undefined ? { project_id: body.projectId ?? null } : {}),
|
||||
...(body.contractType !== undefined ? { contract_type: body.contractType } : {}),
|
||||
...(body.counterpartyName !== undefined ? { counterparty_name: body.counterpartyName } : {}),
|
||||
...(body.summary !== undefined ? { summary: body.summary } : {}),
|
||||
...(body.amount !== undefined ? { amount: body.amount ?? null } : {}),
|
||||
...(body.currency !== undefined ? { currency: body.currency } : {}),
|
||||
...(body.signedDate !== undefined ? { signed_date: parseOptionalDate(body.signedDate) } : {}),
|
||||
...(body.effectiveDate !== undefined ? { effective_date: parseOptionalDate(body.effectiveDate) } : {}),
|
||||
...(body.expiryDate !== undefined ? { expiry_date: parseOptionalDate(body.expiryDate) } : {}),
|
||||
...(body.confidentialityLevel !== undefined
|
||||
? { confidentiality_level: body.confidentialityLevel }
|
||||
: {}),
|
||||
...(body.archiveLocation !== undefined ? { archive_location: body.archiveLocation } : {}),
|
||||
...(body.offlineArchiveNo !== undefined ? { offline_archive_no: body.offlineArchiveNo ?? null } : {}),
|
||||
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}),
|
||||
...(body.status !== undefined ? { status: body.status } : {}),
|
||||
},
|
||||
include: CONTRACT_REGISTRY_INCLUDE,
|
||||
const statusChanged = body.status !== undefined && body.status !== existing.status;
|
||||
const updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const next = await updateContractWithStateGuard(tx, existing, buildContractPatchData(body));
|
||||
if (statusChanged) {
|
||||
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_STATUS_CHANGED", next.id, {
|
||||
fromStatus: existing.status,
|
||||
toStatus: body.status,
|
||||
});
|
||||
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_UPDATED", next.id, {
|
||||
fields: patchFields,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
await writeAudit(fastify.prisma, request, "CONTRACT_REGISTRY_UPDATED", updated.id, {
|
||||
fields: Object.keys(body),
|
||||
});
|
||||
if (body.status !== undefined && body.status !== existing.status) {
|
||||
await writeAudit(fastify.prisma, request, "CONTRACT_REGISTRY_STATUS_CHANGED", updated.id, {
|
||||
fromStatus: existing.status,
|
||||
toStatus: body.status,
|
||||
if (!statusChanged) {
|
||||
await writeAudit(fastify.prisma, request, "CONTRACT_REGISTRY_UPDATED", updated.id, {
|
||||
fields: patchFields,
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, { item: mapRow(updated, today) });
|
||||
});
|
||||
|
||||
fastify.post("/contracts-registry/:id/terminate", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!canManageContractRegistry(fastify.hasPermission, request.employee)) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { permission: "project.manage" },
|
||||
});
|
||||
}
|
||||
|
||||
const contractId = UUID_SCHEMA.parse(request.params?.id);
|
||||
const body = parseBody(TERMINATE_SCHEMA, request.body);
|
||||
const today = resolveContractRegistryToday(fastify.config.appTimezone);
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.contractRegistry.findUnique({
|
||||
where: { id: contractId },
|
||||
include: CONTRACT_REGISTRY_INCLUDE,
|
||||
});
|
||||
if (!existing) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
assertTerminationInvariant(existing);
|
||||
|
||||
const effectiveStatus = resolveContractRegistryEffectiveStatus(existing, today);
|
||||
if (!["active", "expired"].includes(effectiveStatus)) {
|
||||
throw buildContractRegistryValidationError({ status: "terminate_not_allowed" }, 409);
|
||||
}
|
||||
|
||||
const changed = await tx.contractRegistry.updateMany({
|
||||
where: buildContractStateGuard(existing),
|
||||
data: {
|
||||
status: "terminated",
|
||||
terminated_at: new Date(),
|
||||
terminated_reason: body.reason,
|
||||
terminated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
if (changed.count !== 1) {
|
||||
throw buildContractRegistryValidationError({ status: "terminate_conflict" }, 409);
|
||||
}
|
||||
|
||||
const updated = await tx.contractRegistry.findUnique({
|
||||
where: { id: contractId },
|
||||
include: CONTRACT_REGISTRY_INCLUDE,
|
||||
});
|
||||
assertTerminationInvariant(updated);
|
||||
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_TERMINATED", contractId, {
|
||||
contractNo: existing.contract_no,
|
||||
fromStatus: existing.status,
|
||||
toStatus: "terminated",
|
||||
reasonSummary: buildAuditReasonSummary(body.reason),
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
return sendData(reply, request, { item: mapRow(result, today) });
|
||||
});
|
||||
|
||||
fastify.post("/contracts-registry/:id/corrections", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!canManageContractRegistry(fastify.hasPermission, request.employee)) {
|
||||
throw new AppError({
|
||||
code: "PERM_001",
|
||||
message: "无权限",
|
||||
statusCode: 403,
|
||||
details: { permission: "project.manage" },
|
||||
});
|
||||
}
|
||||
|
||||
const contractId = UUID_SCHEMA.parse(request.params?.id);
|
||||
const body = parseBody(CORRECTION_SCHEMA, request.body);
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const item = await tx.contractRegistry.findUnique({
|
||||
where: { id: contractId },
|
||||
select: { id: true, status: true, updated_at: true },
|
||||
});
|
||||
if (!item) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
if (item.status !== "archived") {
|
||||
throw buildContractRegistryValidationError({ status: "correction_requires_archived" }, 409);
|
||||
}
|
||||
|
||||
const claimed = await tx.contractRegistry.updateMany({
|
||||
where: { id: item.id, status: "archived", updated_at: item.updated_at },
|
||||
data: { updated_at: new Date() },
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
throw buildContractRegistryValidationError({ status: "correction_state_conflict" }, 409);
|
||||
}
|
||||
|
||||
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_CORRECTION_APPENDED", contractId, {
|
||||
summary: body.summary,
|
||||
});
|
||||
});
|
||||
return sendData(reply, request, { accepted: true });
|
||||
});
|
||||
|
||||
fastify.post("/contracts-registry/:id/renew", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
if (!canManageContractRegistry(fastify.hasPermission, request.employee)) {
|
||||
@@ -318,6 +658,7 @@ async function contractsRegistryRoutes(fastify) {
|
||||
if (!original) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
assertTerminationInvariant(original);
|
||||
|
||||
const effectiveStatus = resolveContractRegistryEffectiveStatus(original, today);
|
||||
if (!["active", "expired"].includes(effectiveStatus)) {
|
||||
@@ -352,18 +693,14 @@ async function contractsRegistryRoutes(fastify) {
|
||||
include: CONTRACT_REGISTRY_INCLUDE,
|
||||
});
|
||||
|
||||
const archivedSource = await tx.contractRegistry.update({
|
||||
where: { id: original.id },
|
||||
data: { status: "archived" },
|
||||
include: CONTRACT_REGISTRY_INCLUDE,
|
||||
});
|
||||
const archivedSource = await updateContractWithStateGuard(tx, original, { status: "archived" });
|
||||
|
||||
await writeAudit(tx, request, "CONTRACT_REGISTRY_RENEWED", renewed.id, {
|
||||
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_RENEWED", renewed.id, {
|
||||
sourceContractId: original.id,
|
||||
sourceContractNo: original.contract_no,
|
||||
renewedContractNo: renewed.contract_no,
|
||||
});
|
||||
await writeAudit(tx, request, "CONTRACT_REGISTRY_STATUS_CHANGED", archivedSource.id, {
|
||||
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_STATUS_CHANGED", archivedSource.id, {
|
||||
fromStatus: original.status,
|
||||
toStatus: "archived",
|
||||
reason: "renew",
|
||||
|
||||
@@ -411,8 +411,9 @@ function contractRegistryStatusLabel(value) {
|
||||
if (value === "draft") return "草稿";
|
||||
if (value === "active") return "生效中";
|
||||
if (value === "expired") return "已过期";
|
||||
if (value === "terminated") return "已终止";
|
||||
if (value === "archived") return "已归档";
|
||||
return value ?? "";
|
||||
return "未知状态";
|
||||
}
|
||||
|
||||
function paymentBatchEntityTypeLabel(value) {
|
||||
|
||||
@@ -7,6 +7,23 @@ const ExcelJS = require('exceljs');
|
||||
const { createPrismaClient } = require('../prismaClient');
|
||||
|
||||
const SUPPORTED_TYPES = ['projects', 'members', 'milestones', 'risks', 'contracts'];
|
||||
const CONTRACT_IMPORT_STATUS_VALUES = new Set(['draft', 'active', 'archived']);
|
||||
|
||||
function validateContractImportStatus(importedStatus, currentStatus = null) {
|
||||
if (!CONTRACT_IMPORT_STATUS_VALUES.has(importedStatus)) {
|
||||
return `status_not_importable:${importedStatus}`;
|
||||
}
|
||||
if (currentStatus === 'terminated') {
|
||||
return 'terminated_contract_is_final';
|
||||
}
|
||||
if (currentStatus === 'archived') {
|
||||
return 'archived_contract_requires_correction_api';
|
||||
}
|
||||
if (currentStatus && currentStatus !== importedStatus) {
|
||||
return 'status_change_requires_contract_api';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`用法:
|
||||
@@ -310,7 +327,7 @@ async function handleRisks({ prisma, rows, apply, summary }) {
|
||||
async function handleContracts({ prisma, rows, apply, summary }) {
|
||||
assertRequiredHeaders(rows, ['contract_no', 'counterparty_name', 'summary', 'archive_location', 'owner_employee_no']);
|
||||
const refs = await buildReferenceMaps(prisma);
|
||||
const existing = new Map((await prisma.contractRegistry.findMany({ select: { id: true, contract_no: true } })).map((item) => [item.contract_no, item]));
|
||||
const existing = new Map((await prisma.contractRegistry.findMany({ select: { id: true, contract_no: true, status: true } })).map((item) => [item.contract_no, item]));
|
||||
|
||||
for (const row of rows) {
|
||||
const data = row.data;
|
||||
@@ -325,6 +342,14 @@ async function handleContracts({ prisma, rows, apply, summary }) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const importedStatus = data.status || 'active';
|
||||
const current = existing.get(data.contract_no);
|
||||
const statusError = validateContractImportStatus(importedStatus, current?.status ?? null);
|
||||
if (statusError) {
|
||||
pushError(summary, row.rowNumber, statusError);
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
contract_no: data.contract_no,
|
||||
project_id: project?.id ?? null,
|
||||
@@ -334,10 +359,9 @@ async function handleContracts({ prisma, rows, apply, summary }) {
|
||||
archive_location: data.archive_location,
|
||||
offline_archive_no: data.offline_archive_no || null,
|
||||
owner_employee_id: owner.id,
|
||||
status: data.status || 'active',
|
||||
status: importedStatus,
|
||||
};
|
||||
|
||||
const current = existing.get(data.contract_no);
|
||||
if (!current) summary.createCount += 1;
|
||||
else summary.updateCount += 1;
|
||||
|
||||
@@ -400,7 +424,11 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.message ?? error);
|
||||
process.exit(1);
|
||||
});
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error?.message ?? error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { validateContractImportStatus };
|
||||
|
||||
@@ -4,7 +4,7 @@ const { AppError } = require("../utils/AppError");
|
||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||
|
||||
const CONTRACT_REGISTRY_TYPE_VALUES = ["purchase", "sales", "service", "framework", "other"];
|
||||
const CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES = ["draft", "active", "archived"];
|
||||
const CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES = ["draft", "active", "terminated", "archived"];
|
||||
const CONTRACT_REGISTRY_EFFECTIVE_STATUS_VALUES = [...CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES, "expired"];
|
||||
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
@@ -115,6 +115,10 @@ function buildEffectiveStatusWhere(status, today) {
|
||||
return { status: "archived" };
|
||||
}
|
||||
|
||||
if (status === "terminated") {
|
||||
return { status: "terminated" };
|
||||
}
|
||||
|
||||
if (status === "expired") {
|
||||
return {
|
||||
status: "active",
|
||||
@@ -221,6 +225,7 @@ module.exports = {
|
||||
CONTRACT_REGISTRY_PERSISTED_STATUS_VALUES,
|
||||
CONTRACT_REGISTRY_TYPE_VALUES,
|
||||
buildContractRegistryExpiringWhere,
|
||||
buildContractRegistryScopeWhere,
|
||||
buildContractRegistryValidationError,
|
||||
buildContractRegistryWhere,
|
||||
canManageContractRegistry,
|
||||
|
||||
@@ -45,6 +45,9 @@ function buildContractRow(overrides = {}) {
|
||||
offline_archive_no: "OFF-001",
|
||||
owner_employee_id: OWNER_ID,
|
||||
status: "active",
|
||||
terminated_at: null,
|
||||
terminated_reason: null,
|
||||
terminated_by: null,
|
||||
created_at: new Date("2026-03-01T08:00:00.000Z"),
|
||||
updated_at: new Date("2026-04-01T08:00:00.000Z"),
|
||||
owner: {
|
||||
@@ -57,6 +60,8 @@ function buildContractRow(overrides = {}) {
|
||||
project_no: "PRJ-001",
|
||||
name: "试点项目",
|
||||
},
|
||||
terminator: null,
|
||||
document_refs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -79,16 +84,34 @@ function cloneRow(row) {
|
||||
...row,
|
||||
owner: row.owner ? { ...row.owner } : null,
|
||||
project: row.project ? { ...row.project } : null,
|
||||
terminator: row.terminator ? { ...row.terminator } : null,
|
||||
document_refs: (row.document_refs ?? []).map((item) => ({
|
||||
...item,
|
||||
owner: item.owner ? { ...item.owner } : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatefulPrisma(initialContracts) {
|
||||
function findIdInWhere(where) {
|
||||
if (!where || typeof where !== "object") return null;
|
||||
if (typeof where.id === "string") return where.id;
|
||||
for (const condition of where.AND ?? []) {
|
||||
const id = findIdInWhere(condition);
|
||||
if (id) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildStatefulPrisma(initialContracts, options = {}) {
|
||||
const state = {
|
||||
contracts: initialContracts.map((item) => cloneRow(item)),
|
||||
audits: [],
|
||||
};
|
||||
|
||||
const auditCreate = vi.fn(async ({ data }) => {
|
||||
if (options.failAuditAction === data.action) {
|
||||
throw new Error("audit unavailable");
|
||||
}
|
||||
state.audits.push(data);
|
||||
return data;
|
||||
});
|
||||
@@ -123,6 +146,13 @@ function buildStatefulPrisma(initialContracts) {
|
||||
}
|
||||
return cloneRow(row);
|
||||
}),
|
||||
findFirst: vi.fn(async ({ where, select, include }) => {
|
||||
const id = findIdInWhere(where);
|
||||
const row = id ? state.contracts.find((item) => item.id === id) : state.contracts[0];
|
||||
if (!row) return null;
|
||||
if (select) return mapSelectedRow(row, select);
|
||||
return include ? cloneRow(row) : cloneRow(row);
|
||||
}),
|
||||
create: vi.fn(async ({ data, include }) => {
|
||||
const created = buildContractRow({
|
||||
id: `66666666-6666-4666-8666-${String(state.contracts.length + 1).padStart(12, "0")}`,
|
||||
@@ -141,6 +171,9 @@ function buildStatefulPrisma(initialContracts) {
|
||||
offline_archive_no: data.offline_archive_no,
|
||||
owner_employee_id: data.owner_employee_id,
|
||||
status: data.status,
|
||||
terminated_at: data.terminated_at ?? null,
|
||||
terminated_reason: data.terminated_reason ?? null,
|
||||
terminated_by: data.terminated_by ?? null,
|
||||
});
|
||||
state.contracts.push(created);
|
||||
return include ? cloneRow(created) : created;
|
||||
@@ -157,6 +190,25 @@ function buildStatefulPrisma(initialContracts) {
|
||||
};
|
||||
return include ? cloneRow(state.contracts[index]) : state.contracts[index];
|
||||
}),
|
||||
updateMany: vi.fn(async ({ where, data }) => {
|
||||
const sameValue = (current, expected) =>
|
||||
current instanceof Date && expected instanceof Date
|
||||
? current.getTime() === expected.getTime()
|
||||
: current === expected;
|
||||
const index = state.contracts.findIndex((item) =>
|
||||
Object.entries(where).every(([key, value]) => sameValue(item[key], value)),
|
||||
);
|
||||
if (index < 0) return { count: 0 };
|
||||
state.contracts[index] = {
|
||||
...state.contracts[index],
|
||||
...data,
|
||||
updated_at: new Date("2026-04-04T12:00:00.000Z"),
|
||||
terminator: data.terminated_by
|
||||
? { id: data.terminated_by, employee_no: "E-ACTOR", name: "Actor" }
|
||||
: state.contracts[index].terminator,
|
||||
};
|
||||
return { count: 1 };
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -167,13 +219,30 @@ function buildStatefulPrisma(initialContracts) {
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
findMany: vi.fn(async () =>
|
||||
state.audits.map((item, index) => ({
|
||||
id: `audit-${index + 1}`,
|
||||
...item,
|
||||
created_at: new Date(`2026-04-04T10:0${index}:00.000Z`),
|
||||
actor: { id: ACTOR_ID, employee_no: "E-ACTOR", name: "Actor" },
|
||||
})),
|
||||
),
|
||||
},
|
||||
contractRegistry,
|
||||
$transaction: async (callback) =>
|
||||
callback({
|
||||
auditLog: { create: auditCreate },
|
||||
contractRegistry,
|
||||
}),
|
||||
$transaction: async (callback) => {
|
||||
const contractsBefore = state.contracts.map((item) => cloneRow(item));
|
||||
const auditsBefore = [...state.audits];
|
||||
try {
|
||||
return await callback({
|
||||
auditLog: { create: auditCreate },
|
||||
contractRegistry,
|
||||
});
|
||||
} catch (error) {
|
||||
state.contracts = contractsBefore;
|
||||
state.audits = auditsBefore;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
auditCreate,
|
||||
};
|
||||
@@ -195,12 +264,15 @@ async function buildApp(options = {}) {
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
contractRegistry: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(buildContractRow()),
|
||||
update: vi.fn().mockResolvedValue(buildContractRow()),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
};
|
||||
fallback.$transaction = async (callback) => callback(fallback);
|
||||
@@ -274,6 +346,10 @@ describe("contracts registry routes", () => {
|
||||
amount: "100000.00",
|
||||
}),
|
||||
);
|
||||
expect(payload.data.items[0]).not.toHaveProperty("terminatedAt");
|
||||
expect(payload.data.items[0]).not.toHaveProperty("terminatedReason");
|
||||
expect(payload.data.items[0]).not.toHaveProperty("terminatedBy");
|
||||
expect(payload.data.items[0]).not.toHaveProperty("terminator");
|
||||
expect(contractFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
@@ -297,6 +373,43 @@ describe("contracts registry routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps terminated contracts terminal even when their expiry date is in the past", async () => {
|
||||
const contractFindMany = vi.fn().mockResolvedValue([
|
||||
buildContractRow({
|
||||
status: "terminated",
|
||||
expiry_date: new Date("2026-03-31T00:00:00.000Z"),
|
||||
terminated_at: new Date("2026-03-20T08:00:00.000Z"),
|
||||
terminated_reason: "双方确认提前终止合同并保留原编号",
|
||||
terminated_by: ACTOR_ID,
|
||||
terminator: { id: ACTOR_ID, employee_no: "E-ACTOR", name: "Actor" },
|
||||
}),
|
||||
]);
|
||||
const { app } = await buildApp({
|
||||
prisma: {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockImplementation(async ({ where }) => buildFeatureFlagRecord(where.flag_key)),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
contractRegistry: {
|
||||
findMany: contractFindMany,
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/contracts-registry" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items[0]).toEqual(expect.objectContaining({
|
||||
status: "terminated",
|
||||
persistedStatus: "terminated",
|
||||
}));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects invalid expiry date range", async () => {
|
||||
const { app } = await buildApp();
|
||||
const response = await app.inject({
|
||||
@@ -379,6 +492,316 @@ describe("contracts registry routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns a scoped contract detail with safe document references and history", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([
|
||||
buildContractRow({
|
||||
id: contractId,
|
||||
document_refs: [
|
||||
{
|
||||
id: "77777777-7777-4777-8777-777777777777",
|
||||
title: "线下涉密资料索引",
|
||||
document_no: "DOC-001",
|
||||
confidentiality_level: "restricted",
|
||||
storage_type: "offline_archive",
|
||||
storage_path: "/sensitive/full/path",
|
||||
offline_archive_no: "OFF-DOC-001",
|
||||
access_rule_json: { raw: true },
|
||||
created_at: new Date("2026-04-01T08:00:00.000Z"),
|
||||
updated_at: new Date("2026-04-02T08:00:00.000Z"),
|
||||
owner: { id: OWNER_ID, employee_no: "E001", name: "Owner" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
stateful.state.audits.push({
|
||||
request_id: "request-1",
|
||||
actor_id: ACTOR_ID,
|
||||
action: "CONTRACT_REGISTRY_UPDATED",
|
||||
target_type: "contract_registry",
|
||||
target_id: contractId,
|
||||
extra: { fields: ["summary"], rawPayload: "不得返回" },
|
||||
});
|
||||
const { app } = await buildApp({
|
||||
prisma: stateful.prisma,
|
||||
actor: {
|
||||
id: OWNER_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: ["project.view_own"],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/contracts-registry/${contractId}` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const item = response.json().data.item;
|
||||
expect(item.documentRefs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "线下涉密资料索引",
|
||||
documentNo: "DOC-001",
|
||||
storageType: "offline_archive",
|
||||
offlineArchiveNo: "OFF-DOC-001",
|
||||
}),
|
||||
);
|
||||
expect(item.documentRefs[0]).not.toHaveProperty("storagePath");
|
||||
expect(item.documentRefs[0]).not.toHaveProperty("accessRuleJson");
|
||||
expect(item.history[0]).toEqual(expect.objectContaining({ summary: "更新合同信息" }));
|
||||
expect(item.history[0]).not.toHaveProperty("extra");
|
||||
expect(stateful.prisma.contractRegistry.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ AND: expect.arrayContaining([{ id: contractId }]) }),
|
||||
}),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("terminates an active or expired contract with complete fields and required audit", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId, status: "active" })]);
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${contractId}/terminate`,
|
||||
payload: { reason: "客户项目已取消,双方确认不再继续履行" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "terminated",
|
||||
persistedStatus: "terminated",
|
||||
terminatedReason: "客户项目已取消,双方确认不再继续履行",
|
||||
terminatedBy: ACTOR_ID,
|
||||
terminator: expect.objectContaining({ id: ACTOR_ID }),
|
||||
}),
|
||||
);
|
||||
const stored = stateful.state.contracts.find((item) => item.id === contractId);
|
||||
expect(stored).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "terminated",
|
||||
terminated_reason: "客户项目已取消,双方确认不再继续履行",
|
||||
terminated_by: ACTOR_ID,
|
||||
}),
|
||||
);
|
||||
expect(stored.terminated_at).toBeInstanceOf(Date);
|
||||
expect(stateful.state.audits.map((item) => item.action)).toContain("CONTRACT_REGISTRY_TERMINATED");
|
||||
const terminationAudit = stateful.state.audits.find((item) => item.action === "CONTRACT_REGISTRY_TERMINATED");
|
||||
expect(terminationAudit?.extra).toEqual(expect.objectContaining({
|
||||
reasonSummary: "客户项目已取消,双方确认不再继续履行",
|
||||
}));
|
||||
expect(terminationAudit?.extra).not.toHaveProperty("reason");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rolls back all termination fields when required audit creation fails", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })], {
|
||||
failAuditAction: "CONTRACT_REGISTRY_TERMINATED",
|
||||
});
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${contractId}/terminate`,
|
||||
payload: { reason: "客户项目已取消,双方确认不再继续履行" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(stateful.state.contracts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "active",
|
||||
terminated_at: null,
|
||||
terminated_reason: null,
|
||||
terminated_by: null,
|
||||
}),
|
||||
);
|
||||
expect(stateful.state.audits).toEqual([]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects terminated through general PATCH and keeps terminated contracts final", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const terminated = buildContractRow({
|
||||
id: contractId,
|
||||
status: "terminated",
|
||||
terminated_at: new Date("2026-04-04T08:30:00.000Z"),
|
||||
terminated_reason: "双方确认提前终止合同并保留原编号",
|
||||
terminated_by: ACTOR_ID,
|
||||
terminator: { id: ACTOR_ID, employee_no: "E-ACTOR", name: "Actor" },
|
||||
});
|
||||
const stateful = buildStatefulPrisma([terminated]);
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const bypassResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { status: "terminated" },
|
||||
});
|
||||
expect(bypassResponse.statusCode).toBe(400);
|
||||
|
||||
const editResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { summary: "试图覆盖已终止合同" },
|
||||
});
|
||||
expect(editResponse.statusCode).toBe(409);
|
||||
expect(editResponse.json().error.details).toEqual(expect.objectContaining({ status: "terminated_is_final" }));
|
||||
|
||||
const renewResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${contractId}/renew`,
|
||||
});
|
||||
expect(renewResponse.statusCode).toBe(409);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("locks archived fields and appends corrections without overwriting the contract", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId, status: "archived" })]);
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const editResponse = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { archiveLocation: "新位置" },
|
||||
});
|
||||
expect(editResponse.statusCode).toBe(409);
|
||||
expect(editResponse.json().error.details).toEqual(expect.objectContaining({ status: "archived_fields_locked" }));
|
||||
|
||||
const correctionResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${contractId}/corrections`,
|
||||
payload: { summary: "原归档位置登记有误,实际以线下档案号为准" },
|
||||
});
|
||||
expect(correctionResponse.statusCode).toBe(200);
|
||||
expect(stateful.state.contracts[0].archive_location).toBe("/archive/contracts/001");
|
||||
expect(stateful.state.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
action: "CONTRACT_REGISTRY_CORRECTION_APPENDED",
|
||||
extra: expect.objectContaining({ summary: "原归档位置登记有误,实际以线下档案号为准" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects a stale archived correction when the contract changed concurrently", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId, status: "archived" })]);
|
||||
stateful.prisma.contractRegistry.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${contractId}/corrections`,
|
||||
payload: { summary: "原归档位置登记有误,实际以线下档案号为准" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toEqual(expect.objectContaining({ status: "correction_state_conflict" }));
|
||||
expect(stateful.state.audits).toEqual([]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rolls back an archive status change when required audit creation fails", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })], {
|
||||
failAuditAction: "CONTRACT_REGISTRY_STATUS_CHANGED",
|
||||
});
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { status: "archived" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(stateful.state.contracts[0].status).toBe("active");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rolls back an archive status change when its history audit fails", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })], {
|
||||
failAuditAction: "CONTRACT_REGISTRY_UPDATED",
|
||||
});
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { status: "archived" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(stateful.state.contracts[0].status).toBe("active");
|
||||
expect(stateful.state.audits).toEqual([]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("fails closed when a contract row violates the termination field invariant", async () => {
|
||||
const stateful = buildStatefulPrisma([
|
||||
buildContractRow({
|
||||
terminated_reason: "存在终止原因但状态尚未切换",
|
||||
}),
|
||||
]);
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/contracts-registry" });
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.json().error).toEqual(expect.objectContaining({ code: "SYS_001" }));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects a stale general update instead of overwriting a concurrent terminal transition", async () => {
|
||||
const contractId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([buildContractRow({ id: contractId })]);
|
||||
stateful.prisma.contractRegistry.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/contracts-registry/${contractId}`,
|
||||
payload: { summary: "并发状态变化后不得覆盖合同" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toEqual(
|
||||
expect.objectContaining({ status: "contract_state_conflict" }),
|
||||
);
|
||||
expect(stateful.state.contracts[0].summary).toBe("合同摘要");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("uses the same non-enumerating 404 for an unavailable contract detail", async () => {
|
||||
const { app } = await buildApp({
|
||||
actor: { id: ACTOR_ID, department_id: DEPARTMENT_ID, permissionCodes: ["project.view_own"] },
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/contracts-registry/55555555-5555-4555-8555-555555555551",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toEqual(expect.objectContaining({ code: "SYS_404", message: "Not Found" }));
|
||||
expect(response.body).not.toContain("合同摘要");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("renews an active contract into the next draft revision and archives the source", async () => {
|
||||
const stateful = buildStatefulPrisma([
|
||||
buildContractRow({
|
||||
@@ -425,6 +848,28 @@ describe("contracts registry routes", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rolls back the renewal draft and source archive when renewal audit fails", async () => {
|
||||
const sourceId = "55555555-5555-4555-8555-555555555551";
|
||||
const stateful = buildStatefulPrisma([
|
||||
buildContractRow({ id: sourceId, contract_no: "HT-2026-001", status: "active" }),
|
||||
], {
|
||||
failAuditAction: "CONTRACT_REGISTRY_RENEWED",
|
||||
});
|
||||
const { app } = await buildApp({ prisma: stateful.prisma });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/contracts-registry/${sourceId}/renew`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(stateful.state.contracts).toHaveLength(1);
|
||||
expect(stateful.state.contracts[0]).toEqual(expect.objectContaining({ id: sourceId, status: "active" }));
|
||||
expect(stateful.state.audits).toEqual([]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects renewing a draft contract", async () => {
|
||||
const stateful = buildStatefulPrisma([
|
||||
buildContractRow({
|
||||
|
||||
@@ -203,4 +203,30 @@ describe("contracts registry export route", () => {
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("exports terminated contracts with a Chinese status label", async () => {
|
||||
const { app } = await buildApp({
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
buildContractRow({
|
||||
status: "terminated",
|
||||
expiry_date: new Date("2026-03-31T00:00:00.000Z"),
|
||||
}),
|
||||
])
|
||||
.mockResolvedValue([]),
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/exports/contracts-registry",
|
||||
payload: { format: "csv", status: "terminated" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain("已终止");
|
||||
expect(response.body).not.toContain(",terminated,");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const { validateContractImportStatus } = require("../../../src/scripts/import-project-ledgers");
|
||||
|
||||
describe("contract ledger import status guard", () => {
|
||||
it("allows only non-terminal persisted statuses", () => {
|
||||
expect(validateContractImportStatus("draft")).toBeNull();
|
||||
expect(validateContractImportStatus("active")).toBeNull();
|
||||
expect(validateContractImportStatus("archived")).toBeNull();
|
||||
expect(validateContractImportStatus("expired")).toBe("status_not_importable:expired");
|
||||
expect(validateContractImportStatus("terminated")).toBe("status_not_importable:terminated");
|
||||
});
|
||||
|
||||
it("does not overwrite an already terminated contract", () => {
|
||||
expect(validateContractImportStatus("active", "terminated")).toBe("terminated_contract_is_final");
|
||||
expect(validateContractImportStatus("archived", "terminated")).toBe("terminated_contract_is_final");
|
||||
});
|
||||
|
||||
it("does not perform an unaudited status transition for an existing row", () => {
|
||||
expect(validateContractImportStatus("archived", "active")).toBe("status_change_requires_contract_api");
|
||||
expect(validateContractImportStatus("active", "active")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not overwrite ordinary fields on an archived row", () => {
|
||||
expect(validateContractImportStatus("archived", "archived")).toBe("archived_contract_requires_correction_api");
|
||||
expect(validateContractImportStatus("active", "archived")).toBe("archived_contract_requires_correction_api");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户