# 新项目交叉评查状态流转图与边界案例清单

> 目标：把新项目交叉评查的状态流转、时序过程和高风险边界案例一次性说清，避免实现时出现“以为如此，实际上不是”的尴尬。

## 1. 为什么需要这份文档

交叉评查最容易出错的地方，不是 CRUD，而是：

- 状态什么时候变
- 谁能触发状态变更
- 分数什么时候生效
- 文档什么时候算完成
- 任务什么时候算完成
- 新版本文档进来后旧版本怎么处理

这些地方一旦实现错了，前端看起来也许还能跑，但业务上会很尴尬。

所以这份文档专门回答三类问题：

- 状态怎么流转
- 链路怎么串起来
- 边界情况怎么处理

## 2. 五类核心状态

建议在实现时，把状态拆成五套，不要混用。

### 2.1 文档处理状态

来源：

- `leaudit_documents.processing_status`
- `leaudit_audit_runs.status`

这套状态表示：

- 文档是否完成机器评查

它不表示：

- 是否完成交叉评查

### 2.2 任务状态

来源：

- `leaudit_cross_review_tasks.status`

建议值：

- `pending`
- `in_progress`
- `completed`

这套状态表示：

- 任务层面的协作进度

### 2.3 任务文档状态

来源：

- `leaudit_cross_review_task_documents.audit_status`

建议值：

- `0`：未完成
- `1`：已完成

这套状态表示：

- 某个文档在某个任务里是否被负责人确认完成

### 2.4 提案状态

来源：

- `leaudit_cross_review_proposals.status`

建议值：

- `pending`
- `approved`
- `rejected`
- `cancelled`

### 2.5 投票状态

来源：

- `leaudit_cross_review_votes.vote_type`

建议值：

- `agree`
- `disagree`

取消投票建议通过软删除表达，不额外引入 `cancel` 作为终态值。

## 3. 任务状态流转图

```text
创建任务
  |
  v
in_progress
  |
  | 所有任务文档 audit_status = 1
  v
completed
```

### 3.1 任务创建后状态

- 默认直接进入 `in_progress`

### 3.2 任务完成条件

只有当：

- 该任务下所有未删除任务文档都为 `audit_status = 1`

才允许变为：

- `completed`

### 3.3 明确不是任务完成条件的事件

以下事件都不能直接导致任务完成：

- 文档机器评查跑完
- 所有提案都投票结束
- 没有提案
- 所有人都看过详情页
- 追加了新附件

## 4. 任务文档状态流转图

```text
文档被挂入任务
  |
  v
audit_status = 0
  |
  | assigner 或 principal 确认完成
  v
audit_status = 1
```

### 4.1 进入任务默认状态

- 新挂入任务的文档默认 `audit_status = 0`

### 4.2 谁能把 `0` 改成 `1`

只有：

- `assigner`
- `principal`

普通参与人不允许确认完成。

### 4.3 新版本文档进入任务后的状态

如果文档追加附件或上传了新版本：

- 新文档进入任务后必须重新记为 `audit_status = 0`

原因：

- 新版本代表新的评查对象
- 不能继承旧版本的“已完成”

## 5. 提案状态流转图

```text
创建提案
  |
  v
pending
  | \
  |  \
  |   \ 提案人主动撤销
  |    \
  |     v
  |   cancelled
  |
  | agree 票达到阈值
  v
approved

pending
  |
  | disagree 票达到阈值
  | 或剩余票全部同意也无法通过
  v
rejected
```

### 5.1 初始状态

- 提案创建后默认 `pending`

### 5.2 通过条件

若：

- `agree >= floor(n / 2) + 1`

则：

- `approved`

其中 `n` 为任务有效成员总数。

### 5.3 否决条件

若：

- `disagree >= floor(n / 2) + 1`

则：

- `rejected`

### 5.4 提前否决条件

若：

- 即使所有剩余未投票成员都改投同意，提案也不可能过阈值

则：

- 直接 `rejected`

### 5.5 撤销条件

只有提案人可以撤销，且必须满足：

- 当前状态仍为 `pending`

撤销后：

- 提案变为 `cancelled`

### 5.6 终态不可再操作

提案一旦进入：

- `approved`
- `rejected`
- `cancelled`

即视为终态：

- 不允许再投票
- 不允许再撤销
- 不允许再改状态

## 6. 投票时序图

```text
参与人 -> CrossReviewController: POST /proposals/{id}/votes
CrossReviewController -> CrossReviewService: VoteProposal()
CrossReviewService -> DB: 校验提案存在且状态= pending
CrossReviewService -> DB: 校验当前用户是任务成员
CrossReviewService -> DB: 写入或更新投票
CrossReviewService -> DB: 重新统计 agree/disagree/remaining
CrossReviewService -> DB: 如达阈值则更新 proposal.status
CrossReviewService --> CrossReviewController: 返回 proposal_status
CrossReviewController --> 参与人: success + 最新状态
```

### 6.1 投票后必须立即判定状态

不要把“投票成功”和“状态判定”拆成异步延迟任务。

推荐：

- 每次投票后同步判定提案状态

原因：

- 前端能立刻拿到最新状态
- 避免多人并发时出现短暂假状态

## 7. 提案创建时序图

```text
参与人 -> CrossReviewController: POST /proposals
CrossReviewController -> CrossReviewService: CreateProposal()
CrossReviewService -> DB: 校验任务/文档/规则结果关系
CrossReviewService -> DB: 校验用户是否为任务成员
CrossReviewService -> DB: 校验是否重复提案
CrossReviewService -> DB: 校验分值边界
CrossReviewService -> DB: 写入 proposal(status=pending)
CrossReviewService -> DB: 自动写入 proposer agree 投票
CrossReviewService -> DB: 判定 proposal 是否已直接过阈值
CrossReviewService --> CrossReviewController: proposal + latest_status
CrossReviewController --> 参与人: success
```

### 7.1 自动同意票后的特殊情况

如果任务成员数很少，例如：

- 只有 1 人

那么提案创建后可能因为自动同意票立即通过。

这是允许的，只要符合阈值规则。

## 8. 文档完成确认时序图

```text
负责人 -> CrossReviewController: POST /tasks/{taskId}/documents/{documentId}/complete
CrossReviewController -> CrossReviewService: CompleteTaskDocument()
CrossReviewService -> DB: 校验任务存在
CrossReviewService -> DB: 校验用户是 assigner 或 principal
CrossReviewService -> DB: 校验文档属于该任务
CrossReviewService -> DB: 更新 task_document.audit_status = 1
CrossReviewService -> DB: 统计任务下是否所有文档已完成
CrossReviewService -> DB: 若是，则更新 task.status = completed
CrossReviewService --> CrossReviewController: document completed / task completed
CrossReviewController --> 负责人: success
```

### 8.1 确认完成时是否强制要求“所有提案已投完”

不建议强阻断。

建议策略：

- 后端检测是否还有 `pending` 提案或未投票成员
- 返回提示信息
- 由前端给出二次确认

原因：

- 真实业务里负责人可能需要“带风险确认”
- 如果完全强阻断，会把流程卡死

## 9. 详情页分数计算时序图

```text
前端详情页 -> DocumentController: GET /v3/review-points/{documentId}
DocumentController -> DocumentServiceImpl: GetReviewPoints()
DocumentServiceImpl -> DB: 读取 leaudit_rule_results
DocumentServiceImpl -> DB: 读取 leaudit_review_point_audits
DocumentServiceImpl -> DB: 读取 approved proposals
DocumentServiceImpl: 计算 baseScore
DocumentServiceImpl: 计算 crossDelta
DocumentServiceImpl: 计算 finalScore = clamp(baseScore + crossDelta)
DocumentServiceImpl --> DocumentController: ReviewPointsAggregateVO
DocumentController --> 前端详情页: data + stats + scoring_proposals
```

### 9.1 分数生效时点

提案只有在：

- `approved`

后，才计入 `crossDelta`。

以下状态都不计入：

- `pending`
- `rejected`
- `cancelled`

## 10. 版本链时序图

```text
用户上传新版本/追加附件
  |
  v
DocumentService 创建新 leaudit_document
  |
  v
新文档进入原 versionGroupKey
  |
  v
CrossReviewService 将新 document_id 挂入任务
  |
  v
task_document.audit_status = 0
  |
  v
任务列表按 versionGroupKey 归组展示
```

### 10.1 旧版本是否自动失效

不建议把旧版本任务记录自动删掉。

建议：

- 保留历史版本
- 页面默认展示最新版本
- 历史版本折叠展示

### 10.2 新版本是否自动继承提案

不建议继承。

原因：

- 提案是针对具体 `rule_result_id`
- 新版本会产生新的 `rule_result_id`
- 旧提案不能自动迁移到新结果上

## 11. 边界案例清单

下面这些情况，后端必须提前定好策略。

## 11.1 只有 1 个任务成员

### 情况

- 创建者自己给自己建任务，且没有别的成员

### 正确处理

- 阈值 = 1
- 提案创建后自动同意票即可直接 `approved`

### 不要出错

- 不要硬编码“至少两人才能交叉评查”

## 11.2 只有 2 个任务成员

### 情况

- A 发起提案
- A 自动同意
- B 反对

### 正确处理

- 成员数 `n=2`
- 阈值 `floor(2/2)+1 = 2`
- A 同意数 1，不足通过
- B 反对数 1，也不足否决
- 但此时无剩余票，提案已不可能达到 2 票同意
- 应直接 `rejected`

### 不要出错

- 不要把它一直挂在 `pending`

## 11.3 提案人重复点击提交

### 情况

- 网络慢，用户多次点提交

### 正确处理

- 通过唯一性规则防重
- 返回“已存在有效提案”

### 不要出错

- 不要产生两条同时有效的同目标提案

## 11.4 文档已有新版本，但旧版本已完成

### 情况

- 旧版本在任务里已确认完成
- 新版本追加进来

### 正确处理

- 新版本独立新增一条 `task_document`
- `audit_status = 0`
- 旧版本保持历史完成状态

### 不要出错

- 不要把新版本直接继承旧版本的完成状态

## 11.5 提案通过后又有人想继续改

### 情况

- 某条提案已经 `approved`
- 又有人想针对同一个规则点继续提分或扣分

### 正确处理

- 允许新建后续提案，但必须满足唯一性规则
- 即同一用户不能重复，但不同用户可继续提出新的有效提案

### 不要出错

- 不要错误地把“某点已有 approved 提案”理解成“该点以后都不能再提案”

## 11.6 文档没有任何提案

### 情况

- 任务成员只看文档，不提出任何提案

### 正确处理

- 允许负责人直接确认完成

### 不要出错

- 不要把“没有提案”当成“不能完成”

## 11.7 存在 `pending` 提案，但负责人仍要完成

### 情况

- 业务上要强行收口

### 正确处理

- 推荐允许，但返回 warning 信息

### 不要出错

- 不要一刀切强阻断，除非产品明确要求

## 11.8 文档重跑评查后，旧提案怎么办

### 情况

- 同一个文档重新触发 audit run
- 产生新 `rule_result_id`

### 正确处理建议

- 提案绑定的是具体 `rule_result_id`
- 如果是同一 document 的新 run，建议只对“当前有效 run”的结果展示可新建提案
- 旧 run 上的提案保留历史，不再作为主展示对象

### 不要出错

- 不要把旧 run 的提案直接混到新 run 的点位上

## 11.9 删除任务成员后，历史票怎么办

### 情况

- 某成员中途被移出任务

### 正确处理建议

- 历史投票保留
- 后续阈值计算以当前有效成员还是任务创建时成员为准，必须提前定死

### 推荐口径

- 阈值按“当前有效成员数”计算

### 注意

- 这个规则一旦确定，就不能前后漂移

## 12. 推荐的最终业务口径

为了避免后续实现反复改口，建议最终统一采用以下口径：

- 任务默认 `in_progress`
- 文档进入任务默认未完成
- 文档完成只能由创建者或负责人确认
- 任务完成由“所有任务文档完成”推导
- 提案默认 `pending`
- 提案自动给提案人一票同意
- 提案采用绝对多数制
- 提案通过后只影响展示层最终分，不改机器原始结果
- 版本归纳优先使用 `versionGroupKey`
- 权限由后端统一判断

## 13. 开发前最后核对清单

开始写代码前，建议逐项确认：

- 是否已经把五类状态拆开
- 是否已经明确最终分不是回写机器结果
- 是否已经明确任务完成只看任务文档状态
- 是否已经明确新版本进任务后默认未完成
- 是否已经明确提案绑定到 `rule_result_id`
- 是否已经明确提案通过阈值算法
- 是否已经明确权限是 RBAC + 业务归属双校验
- 是否已经明确详情页主入口仍可复用 `GetReviewPoints()`

如果这几项都确认过，交叉评查的实现基本就不会跑偏。

