# 【修改接口·管理后台】订单详情「合同·保险」Tab：无合同/无保险时子对象返 null (#4460)

> **PR**: #4461 | **服务**: hl-order-service-v3 | **更新时间**: 2026-06-26

## 1. 接口背景

订单详情「合同·保险」Tab 懒加载接口，在订单**从未签约 / 从未投保**时，原本仍返回一个字段全为 `null` 的 `contract` / `insurance` 壳对象，前端要判断"这单到底有没有合同/保险"只能逐字段判空，体验差且容易误判。

本次改为：合同、保险**各自独立**判断是否存在，不存在则整个子对象直接返 `null`，前端一层判断即可。

## 2. 变更清单

| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 合同·保险 Tab | GET | `/v3/admin/order/{id}/contract-insurance` | 修改接口 | 无合同时 `contract` 返 null、无保险时 `insurance` 返 null（原为字段全 null 的壳对象） |

## 3. 接口详情

### 3.1 获取订单合同·保险 Tab 数据

- **使用场景**：管理后台订单详情页，点开「合同·保险」Tab 时懒加载
- **认证**：需要 JWT（管理后台登录态）
- **幂等性**：是（纯查询）
- **限流**：无

## 4. 接口入参

### 4.1 路径参数

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | String | ✅ | 订单 ID（雪花 ID，字符串传递） |

### 4.2 请求体字段

无（GET 请求）。

## 5. 出参（响应）

### 5.1 顶层结构

| 字段 | 类型 | 说明 |
|------|------|------|
| `contract` | Object \| null | 合同信息；该订单**从未签约**时为 `null` |
| `insurance` | Object \| null | 保险信息；该订单**从未投保**时为 `null` |

> **判定口径**：
> - `contract` 为 null：合同状态为空 **且** 无合同方案头 **且** 无合同事件
> - `insurance` 为 null：保险聚合状态为空 **且** 无保单
> 三者/两者任一非空即视为"存在"，照常返回完整对象。

### 5.2 `contract` 对象字段（存在时）

| 字段 | 类型 | 说明 |
|------|------|------|
| `contractStatus` | String | 合同状态，见 §6.1 |
| `contractSignedAt` | String | 签署时间（`yyyy-MM-dd HH:mm:ss`），未签为 null |
| `contractFileUrl` | String | 合同文件地址，未生成为 null |
| `contractSchemeName` | String | 合同方案名 |
| `signerName` | String | 签署人 |
| `signMethod` | String | 签署方式 |
| `events` | Array | 合同流程事件（固定 5 步模板），见 §5.4 |

### 5.3 `insurance` 对象字段（存在时）

| 字段 | 类型 | 说明 |
|------|------|------|
| `insuranceStatus` | String | 保险聚合状态，见 §6.2 |
| `insuranceStatusName` | String | 保险状态中文名 |
| `totalPremium` | Number | 总保费 |
| `policyCount` | Number | 保单数量 |
| `policies` | Array | 逐张保单列表（存在时；可能为空数组） |

### 5.4 `events` / `policies[].events` 事件字段

| 字段 | 类型 | 说明 |
|------|------|------|
| `eventType` / `stepCode` | String | 步骤码 |
| `eventName` | String | 步骤名 |
| `actorType` | String | 操作方类型，见 §6.3 |
| `actorTypeName` | String | 操作方中文名 |
| `actorLabel` | String | 操作方标签 |
| `statusLabel` | String | 状态标签（如 已完成/进行中/待处理） |
| `description` | String | 描述 |
| `occurredAt` | String | 发生时间 |

## 6. 枚举 / 数据字典

### 6.1 contractStatus（合同状态）

**所属字段**：`contract.contractStatus` | **类型**：`String`

| 值 | 中文 | 说明 |
|----|------|------|
| `SIGNED` | 已签约 | 合同已签署完成 |
| 其他业务态 | — | 按合同流程取值（生成/报备/作废等） |

### 6.2 insuranceStatus（保险聚合状态）

**所属字段**：`insurance.insuranceStatus` | **类型**：`String`

| 值 | 中文（insuranceStatusName） | 说明 |
|----|------|------|
| `INSURED` | 已出单 | — |
| `INSURING` | 出单中 | — |
| `CANCELLED` | 已取消 | — |
| `FAILED` | 出单失败 | — |

### 6.3 actorType（操作方类型）

**所属字段**：`events[].actorType` / `policies[].events[].actorType` | **类型**：`String`

| 值 | 中文（actorTypeName） | 说明 |
|----|------|------|
| `SYSTEM` | 系统 | — |
| `THIRD_PARTY` | 第三方 | 如电子合同平台 / 保险公司 |
| `CUSTOMER` | 客户 | — |

## 7. 错误码

| code | 含义 | 触发场景 |
|------|------|----------|
| `581007` | 订单不存在 | 路径 `id` 对应订单不存在 |

## 8. 示例

### 8.1 典型成功（无合同无保险订单）

**请求**：
```
GET /v3/admin/order/2070039058075017217/contract-insurance
Authorization: Bearer <token>
无请求体
```

**响应**：
```json
{
  "code": 200,
  "message": "成功",
  "data": {
    "contract": null,
    "insurance": null
  },
  "success": true
}
```

### 8.2 边界（有合同·有保险订单）

**场景说明**：订单已签约（SIGNED）且已投保，两子对象照常返回完整结构。

**响应**：
```json
{
  "code": 200,
  "message": "成功",
  "data": {
    "contract": {
      "contractStatus": "SIGNED",
      "contractSignedAt": "2026-04-18 17:12:00",
      "contractFileUrl": "https://oss.example.com/contract/xxx.pdf",
      "contractSchemeName": "标准国内单团签约方案",
      "signerName": "张三",
      "signMethod": "电子签约",
      "events": [
        { "stepCode": "GENERATE", "eventName": "生成合同", "actorType": "SYSTEM", "actorTypeName": "系统", "statusLabel": "已完成", "occurredAt": "2026-04-18 17:10:00" }
      ]
    },
    "insurance": {
      "insuranceStatus": "INSURED",
      "insuranceStatusName": "已出单",
      "totalPremium": 88.00,
      "policyCount": 1,
      "policies": [
        { "policyNo": "PICC-001", "status": "INSURED", "statusName": "已出单", "events": [] }
      ]
    }
  },
  "success": true
}
```

### 8.3 业务失败（订单不存在）

**请求**：
```
GET /v3/admin/order/88888888/contract-insurance
Authorization: Bearer <token>
```

**响应**：
```json
{
  "code": 581007,
  "message": "订单不存在",
  "success": false
}
```

## 9. 业务边界

- ✅ **返完整对象**：合同侧状态/方案头/事件任一存在 → `contract` 完整返回；保险侧聚合状态或保单任一存在 → `insurance` 完整返回
- ⚠️ **返 null**：从未签约 → `contract` 整体为 `null`；从未投保 → `insurance` 整体为 `null`
- 两者相互独立：可能出现「有合同无保险」（`contract` 非 null、`insurance` 为 null）或反之

## 10. 修改前后对比

### 10.1 字段级对比

| 字段 | 改前 | 改后 |
|------|------|------|
| `data.contract`（无合同时） | 字段全为 null 的对象 `{contractStatus:null, ..., events:[]}` | `null` |
| `data.insurance`（无保险时） | 字段全为 null 的对象 `{insuranceStatus:null, ..., policyCount:0, policies:[]}` | `null` |

### 10.2 行为级对比

| 行为 | 改前 | 改后 |
|------|------|------|
| 前端判断有无合同/保险 | 逐字段判空（如 `contract.contractStatus == null && ...`） | 一层判断 `if (data.contract)` / `if (data.insurance)` |

## 11. 影响评估 / 回滚

### 11.1 影响评估

- **是否破坏向后兼容**：是（无合同/无保险订单的 `contract`/`insurance` 由对象变为 null，前端需调整判空逻辑）
- **前端是否必须同步上线**：是（原逐字段判空逻辑需改为判子对象是否为 null）

### 11.2 回滚方案

- **回滚方式**：revert PR #4461
- **回滚耗时**：预计 5 分钟（重新部署）

## 12. 注意事项

- 前端 workaround 清理点：原先「逐字段判空来识别是否有合同/保险」的逻辑可改为「判 `data.contract` / `data.insurance` 是否为 null」
- 「有合同·有保险」「有合同·无保险」「无合同·有保险」「无合同·无保险」四种组合均需正确处理

## 13. 关联 / 联系人

### 13.1 链接

- **Issue**: [#4460](https://git.1814.love:8443/wx/HL/issues/4460)
- **PR**: [#4461](https://git.1814.love:8443/wx/HL/pulls/4461)
- **Merge commit**: [8ae5d58](https://git.1814.love:8443/wx/HL/commit/8ae5d5819268a01910a8ec93908489d728ad7005)

### 13.2 联系人

- **后端负责人**: @yst
- **前端对接（管理后台）**: —
