# 审批流程时间线展示逻辑完整梳理

> 创建日期：2026-01-06  
> 目的：从根本上理解审批流程展示逻辑，避免技术债务

---

## 📊 1. 数据来源和流程

### 1.1 数据流向

```
后端数据库 → approval.service.ts (getProcessHistory)
    ↓
ApprovalHistoryResponse (DTO)
    ↓
前端 API 调用 (approval-center/page.tsx)
    ↓
nodeStatuses 映射构建
    ↓
LarkProcessPreview 组件渲染
```

### 1.2 核心 API

**接口**: `GET /api/approval/processes/:instanceId/history`

**返回数据结构**:
```typescript
interface ApprovalHistoryResponse {
  items: HistoryItem[];          // 按时间顺序的节点历史
  status?: InstanceStatus;       // 流程实例的最新状态
}

interface HistoryItem {
  nodeId: string;                // 节点ID
  nodeName: string;              // 节点名称
  nodeType: NodeType;            // 节点类型
  status: NodeStatus;            // ⚠️ 关键：节点状态
  startTime: string;
  endTime?: string;
  tasks: TaskHistoryItem[];      // 该节点的任务列表
}

interface TaskHistoryItem {
  id: string;
  assignee?: UserInfo;
  status: TaskStatus;            // 任务状态
  autoApproved: boolean;
  autoApproveReason?: string;
  actions: ActionLogItem[];      // ⚠️ 关键：操作日志
}

interface ActionLogItem {
  id: string;
  action: ApprovalAction;        // ⚠️ 关键：操作类型（APPROVE/REJECT等）
  operator: UserInfo;
  comment?: string;              // ⚠️ 关键：评论/拒绝原因
  actionTime: string;
  formDataChanges?: Record<string, { old: any; new: any }>;
}
```

---

## 📐 2. 状态定义和语义

### 2.1 后端状态类型

```typescript
// 节点状态（Node Level）
type NodeStatus = 
  | 'PENDING'      // 待执行
  | 'ACTIVE'       // 执行中
  | 'COMPLETED'    // ⚠️ 已完成（无论通过还是拒绝，都是这个状态！）
  | 'CANCELLED'    // 已取消
  | 'FAILED'       // 失败
  | 'SKIPPED';     // 跳过

// 任务状态（Task Level）
type TaskStatus = 
  | 'CREATED' 
  | 'PENDING' 
  | 'CLAIMED' 
  | 'ASSIGNED' 
  | 'IN_PROGRESS' 
  | 'COMPLETED'    // ⚠️ 已完成（无论通过还是拒绝，都是这个状态！）
  | 'CANCELLED' 
  | 'SUSPENDED' 
  | 'WITHDRAWN';

// 操作类型（Action Level）
type ApprovalAction =
  | 'SUBMIT'           // 提交
  | 'APPROVE'          // ⚠️ 通过
  | 'REJECT'           // ⚠️ 拒绝
  | 'RETURN'           // 退回
  | 'FORWARD'          // 转交
  | 'WITHDRAW'         // 撤回
  | 'APPROVER_WITHDRAW'// 审批人撤销
  | 'ADD_SIGN'         // 加签
  | 'CLAIM'            // 认领
  | 'UNCLAIM'          // 取消认领
  | 'DELEGATE'         // 委托
  | 'AUTO_APPROVE'     // 自动通过
  | 'AUTO_REJECT'      // 自动拒绝
  | 'ESCALATE'         // 升级
  | 'REMIND'           // 催办
  | 'EXECUTE'          // 执行
  | 'COMMENT';         // 评论
```

### 2.2 ⚠️ 关键设计理念

**后端设计原则**：
- **节点状态（NodeStatus）和任务状态（TaskStatus）只表示执行状态**，不表示业务结果
- **操作类型（ApprovalAction）表示业务动作**，决定了"通过"还是"拒绝"
- **一个节点可以完成（COMPLETED），但其结果需要通过 actions 判断**

**示例**：
```javascript
// 审批通过的节点
{
  nodeId: "node_1",
  status: "COMPLETED",     // ✅ 节点已完成
  tasks: [{
    status: "COMPLETED",   // ✅ 任务已完成
    actions: [{
      action: "APPROVE",   // ✅ 动作是通过
      comment: "同意"
    }]
  }]
}

// 审批拒绝的节点
{
  nodeId: "node_1",
  status: "COMPLETED",     // ✅ 节点已完成（不是REJECTED！）
  tasks: [{
    status: "COMPLETED",   // ✅ 任务已完成
    actions: [{
      action: "REJECT",    // ⚠️ 动作是拒绝
      comment: "不符合要求" // ⚠️ 拒绝原因
    }]
  }]
}
```

---

## 🐛 3. 原有Bug的根本原因

### 3.1 前端逻辑错误

**错误代码**（在 `approval-center/page.tsx`）：
```typescript
// ❌ 错误的逻辑
if (historyNode.status === 'COMPLETED') {
  status = 'completed';  // 直接标记为通过
  // ...
} else if (historyNode.status === 'REJECTED') {  // ⚠️ 这个分支永远不会执行！
  status = 'rejected';
  // ...提取拒绝原因
}
```

**问题分析**：
1. **假设错误**：前端假设节点状态会返回 `'REJECTED'`
2. **实际情况**：后端节点完成后永远返回 `'COMPLETED'`
3. **判断缺失**：没有检查 `actions` 数组中的 `action` 类型
4. **结果**：拒绝的节点被错误地标记为"通过"，拒绝原因丢失

### 3.2 数据映射逻辑缺陷

**问题层次**：
- **L1（数据层）**：后端正确返回了 action='REJECT' 和 comment
- **L2（映射层）**：❌ 前端映射逻辑错误，未正确识别 REJECT 动作
- **L3（显示层）**：前端渲染逻辑正确，但输入数据错误

**影响范围**：
- 所有被拒绝的审批节点
- 所有包含拒绝原因的场景
- 可能影响其他特殊操作（RETURN、AUTO_REJECT等）

---

## ✅ 4. 正确的实现逻辑

### 4.1 核心原则

1. **节点状态 + 操作类型 = 显示状态**
2. **优先检查操作类型（actions），再判断节点状态**
3. **考虑所有可能的操作类型，不仅仅是 APPROVE 和 REJECT**

### 4.2 状态判断决策树

```
节点状态是什么？
├─ PENDING → 前端显示: pending
├─ ACTIVE → 前端显示: active  
├─ COMPLETED → 需要进一步判断
│   ├─ 有 REJECT 动作？
│   │   ├─ 是 → 前端显示: rejected + comment
│   │   └─ 否 → 继续判断
│   ├─ 有 AUTO_REJECT 动作？
│   │   ├─ 是 → 前端显示: rejected + autoApproveReason
│   │   └─ 否 → 继续判断
│   ├─ 有 RETURN 动作？
│   │   ├─ 是 → 前端显示: returned + comment
│   │   └─ 否 → 继续判断
│   └─ 有 APPROVE/SUBMIT/AUTO_APPROVE 动作？
│       ├─ 是 → 前端显示: completed + comment (如果有)
│       └─ 否 → 前端显示: completed (默认)
├─ CANCELLED → 前端显示: cancelled
├─ FAILED → 前端显示: failed
└─ SKIPPED → 前端显示: skipped
```

### 4.3 完整实现代码

```typescript
// ✅ 正确的逻辑（零技术债务版本）
function mapNodeStatus(historyNode: HistoryItem): NodeDisplayStatus {
  let status: 'pending' | 'active' | 'completed' | 'rejected' | 'returned' | 'cancelled' = 'pending';
  let operator = '';
  let comment = '';
  let completedAt = historyNode.endTime || '';
  
  // 1. 收集所有操作动作（用于判断节点的业务结果）
  const allActions: ActionLogItem[] = [];
  historyNode.tasks.forEach(task => {
    if (task.actions && task.actions.length > 0) {
      allActions.push(...task.actions);
    }
  });
  
  // 2. 根据节点状态和操作类型综合判断
  switch (historyNode.status) {
    case 'COMPLETED':
      // 2.1 优先检查拒绝类操作
      const rejectAction = allActions.find(a => a.action === 'REJECT' || a.action === 'AUTO_REJECT');
      if (rejectAction) {
        status = 'rejected';
        operator = rejectAction.operator.displayName || rejectAction.operator.name;
        comment = rejectAction.comment || '';
        break;
      }
      
      // 2.2 检查退回操作
      const returnAction = allActions.find(a => a.action === 'RETURN');
      if (returnAction) {
        status = 'returned';
        operator = returnAction.operator.displayName || returnAction.operator.name;
        comment = returnAction.comment || '';
        break;
      }
      
      // 2.3 默认为通过（APPROVE/SUBMIT/AUTO_APPROVE）
      status = 'completed';
      const approveActions = allActions.filter(a => 
        a.action === 'APPROVE' || a.action === 'SUBMIT' || a.action === 'AUTO_APPROVE'
      );
      if (approveActions.length > 0) {
        operator = approveActions.map(a => a.operator.displayName || a.operator.name).join('、');
        // 取最后一个有 comment 的操作的评论
        const lastCommentAction = approveActions.reverse().find(a => a.comment);
        comment = lastCommentAction?.comment || '';
      }
      break;
      
    case 'ACTIVE':
    case 'ASSIGNED':
      status = 'active';
      break;
      
    case 'CANCELLED':
      status = 'cancelled';
      break;
      
    case 'PENDING':
    default:
      status = 'pending';
      break;
  }
  
  return {
    status,
    completedAt,
    operator,
    comment,
  };
}
```

---

## 🚨 5. 潜在技术债务和改进点

### 5.1 类型安全问题

**当前问题**：
- 前端使用字符串字面量 `'REJECT'`、`'APPROVE'` 等，没有类型检查
- 如果后端修改了 ApprovalAction 的值，前端不会报错

**改进方案**：
```typescript
// 在前端创建类型定义文件，从后端同步
// frontend/src/types/approval.ts
export type ApprovalAction = 
  | 'SUBMIT'
  | 'APPROVE'
  | 'REJECT'
  | 'RETURN'
  // ... 其他类型

// 使用枚举或常量
export const ApprovalAction = {
  SUBMIT: 'SUBMIT',
  APPROVE: 'APPROVE',
  REJECT: 'REJECT',
  RETURN: 'RETURN',
  // ...
} as const;

// 在代码中使用
if (action.action === ApprovalAction.REJECT) {
  // ...
}
```

### 5.2 数据验证缺失

**当前问题**：
- 前端没有验证后端返回的数据结构
- 如果后端字段缺失或类型错误，会导致运行时错误

**改进方案**：
- 使用 Zod 或其他 schema 验证库
- 在数据映射层添加防御性检查

### 5.3 业务逻辑分散

**当前问题**：
- 状态映射逻辑直接写在组件内（approval-center/page.tsx）
- 无法复用，难以测试

**改进方案**：
```typescript
// frontend/src/features/approval/utils/status-mapper.ts
export function mapApprovalHistory(
  history: ApprovalHistoryNode[]
): Record<string, NodeDisplayStatus> {
  // 纯函数，易于测试
  // 可在多个组件中复用
}
```

### 5.4 缺少单元测试

**当前问题**：
- 状态映射逻辑没有单元测试
- 边界情况未覆盖（多个任务、多个操作、特殊动作组合）

**改进方案**：
```typescript
// frontend/src/features/approval/utils/__tests__/status-mapper.test.ts
describe('mapApprovalHistory', () => {
  it('应该正确识别拒绝节点', () => {
    const history = [/* 测试数据 */];
    const result = mapApprovalHistory(history);
    expect(result['node_1'].status).toBe('rejected');
    expect(result['node_1'].comment).toBe('不符合要求');
  });
  
  it('应该处理多个审批人的通过', () => {
    // ...
  });
  
  it('应该处理自动拒绝', () => {
    // ...
  });
  
  // ... 更多测试用例
});
```

### 5.5 前后端类型不一致风险

**当前问题**：
- 前端手动定义 `ApprovalHistoryNode` 接口
- 后端修改 DTO 后，前端可能不同步

**改进方案**：
- 使用代码生成工具（如 openapi-typescript）从后端 API 文档生成前端类型
- 或者使用 tRPC 等类型安全的 RPC 方案
- 定期检查前后端类型一致性

### 5.6 国际化不完整

**当前问题**：
- 状态显示文本可能硬编码
- 操作类型标签未国际化

**改进方案**：
- 所有状态标签使用 i18n 翻译键
- 创建 `approvals.actions.*` 和 `approvals.statuses.*` 翻译

---

## 📝 6. 行动计划（零技术债务）

### 阶段1：立即修复（今天）
- [x] 分析根本原因
- [ ] 修复状态映射逻辑（支持 REJECT）
- [ ] 测试拒绝原因显示

### 阶段2：代码重构（本周）
- [ ] 提取状态映射为独立函数
- [ ] 添加类型定义和常量
- [ ] 添加防御性检查
- [ ] 移除调试日志

### 阶段3：质量保证（下周）
- [ ] 编写单元测试（至少10个用例）
- [ ] 添加集成测试
- [ ] 代码审查和文档完善

### 阶段4：长期改进（后续）
- [ ] 前后端类型同步机制
- [ ] 考虑 Schema 验证
- [ ] 性能优化（如果有大量历史记录）

---

## 🎯 7. 最佳实践总结

1. **理解数据结构**：深入理解后端数据模型，不做假设
2. **类型安全**：使用 TypeScript 严格模式，避免字符串魔法值
3. **防御性编程**：检查数据存在性，处理边界情况
4. **单一职责**：状态映射逻辑独立，不混入渲染逻辑
5. **可测试性**：纯函数设计，易于单元测试
6. **文档优先**：清晰记录业务逻辑和设计决策
7. **增量改进**：先修复紧急问题，再逐步重构

---

**附录：相关文件清单**

- 后端：
  - `backend/src/engines/approval/dto/approval-response.dto.ts` - 数据类型定义
  - `backend/src/engines/approval/approval.service.ts` - getProcessHistory 实现
  
- 前端：
  - `frontend/src/app/(modules)/approval-center/page.tsx` - 状态映射逻辑
  - `frontend/src/features/approval/designer/LarkProcessPreview.tsx` - 渲染组件
  
- 文档：
  - `docs/known-issues.md` - 已知问题列表
  - `docs/architecture/approval-timeline-logic.md` - 本文档

