# 合同/保险模块 TODO 接通 — 从占位 → 完整可用 (工单 #2586)

> **存放目录**: 二期(v3, `order-v3` 标签)→ `changelogs-v2/2026-05/`
>
> **服务**: hl-order-v3 (端口 8084 / 测试服 9443 网关 + `/v3/admin/...`)
> **PR**: #2590 (主体) + #2591 (CR 跟进) + #2603 (P2/P3 + Publisher 接入)
> **Issue**: #2586
> **日期**: 2026-05-19
> **影响范围**: 管理后台「合同」「保险」两大模块全部接口 + 「订单状态机 CONFIRM 转换」副作用

---

## ⚠️ 关键变化（前端必看 4 点）

### 1. 合同 / 保险模块从「占位返空 / 抛异常」转为「完整可用」（行为变化，非破坏性）

PR #2590 之前合同 / 保险服务的所有写接口都返 `UnsupportedOperationException` 占位，查询接口返空列表 + `log.warn TODO`。本次合并后**全部真实可用**：

- `POST /v3/admin/contract/create-by-scheme` 合同按方案创建 — 真实创建并接入第三方合同平台（12301 / 腾讯电子签）
- `POST /v3/admin/insurance/purchase` 手动投保 — 真实写 `insurance_order` + 调宝游 API
- `POST /v3/admin/insurance/auto-purchase` / `auto-purchase-by-scheme` 自动投保 — 真实链路
- `POST /v3/admin/contract/resend-sign-sms` 重发签署短信 — 真实做用户归属校验
- `POST /v3/admin/contract/void/{id}` 作废 — 真实状态流转
- 列表 / 详情 / scheme 列表 — 全部返真实数据

**前端可解除**任何针对这些接口的"待后端实现"占位提示，按真实响应处理。

### 2. `POST /v3/admin/insurance/purchase` 新增订单状态校验（业务规则收紧）

只允许以下组合的订单投保：

| 维度 | 允许值 |
|------|-------|
| `orderStatus` | `PENDING_DEPARTURE` / `PENDING_BALANCE` / `TRAVELLING` |
| `payStatus` | `DEPOSIT_PAID` / `FULLY_PAID` |

任一不符 → 返业务码 `540022` `ORDER_NOT_CONFIRMED_FOR_INSURANCE` 文案 `"请先确认订单后再配置保险（当前订单状态不允许投保）"`。

**前端处理建议**：
- 投保按钮的可点击态可加前置判断（不必依赖后端报错才提示）
- 若直接拦不到，把 540022 错误码处理成 toast 弹该文案即可

### 3. `POST /v3/admin/order/{id}/transition` event=CONFIRM 自动触发合同 / 保险创建（异步）

订单状态机 `CUSTOMIZING → PENDING_DEPARTURE` 的 CONFIRM 转换会**异步**触发：
- 自动创建合同（前提：`contract_scheme.status=ACTIVE` 且 `contract_template_code` 配置过）
- 自动投保（前提：`insurance_scheme.status=ACTIVE`）

接口同步返回 `triggeredEvents: ["ASYNC_CONTRACT_GENERATE", "ASYNC_INSURANCE_ISSUE"]`，**真正写库发生在事务 commit 后异步线程**（`@TransactionalEventListener(AFTER_COMMIT)`）。

**前端表现**：
- CONFIRM 转换 200 返回后，不要立刻显示「合同已生成 / 保险已购买」（异步还没跑完）
- 建议 transition 200 后 toast 提示「订单已确认，合同/保险将在 1 分钟内自动生成」，然后等用户主动刷新合同列表 / 保险列表看到记录
- 自动签约失败时合同/保险域有内部紧急待办 + 通知中心推送（通知中心走 NotificationDispatcher，渠道在 `notification_event_config` 配）

### 4. 出行人变更触发合同 / 保险作废+重做（已签约订单）

`TravelerService` 的 5 个写方法（`batchEdit` / `add` / `delete` / `addByInternal` / `updateByInternal`）末尾按状态白名单异步发布 `TravelerChangedEvent`：

| 订单 orderStatus | 是否触发作废重做 |
|---|---|
| `PENDING_DEPARTURE` / `PENDING_BALANCE` / `TRAVELLING` | ✅ 触发：作废旧合同 + 重新签约；作废旧保单 + 重投 |
| `CUSTOMIZING` / `PENDING_PAY` / `PENDING_COMPLETE` 等未签约 | ❌ 不触发（避免事件风暴） |
| `REVIEWING` / `SETTLED` / `CANCELLED` | ❌ 不触发 |

**前端表现**：已签约订单编辑出行人后，合同 / 保险记录会自动作废并重新生成（异步），用户应感知到合同 PDF / 保单号变化。建议在出行人编辑成功后给一个 toast：「已签约订单编辑出行人将触发合同/保险重新生成」。

---

## 一、背景

订单 v3 的 contract / insurance 域骨架在 yst 早期 PR 已从 v2 搬运，但接通订单核心域（`OrderInfoMapper` / `OrderTravelerMapper`）的位置全部以 `TODO(PR-order-core)` 占位，方法体抛 `UnsupportedOperationException` 或返 `Collections.emptyList()`。

PR #2552-2554 出行人三连完成后，`OrderInfoMapper` / `OrderTravelerMapper` 已在 v3 落位，本工单收尾 — 把 contract / insurance 完整对接、激活 publisher、补全单测、修复 CR 三轮深审找出的 3 P0 + 5 P1 + 10 P2/P3 + 3 P3-NEW。

---

## 二、变更接口清单

### 合同模块（admin）

| # | 接口 | 方法 | 路径 | 变更类型 |
|---|------|------|------|---------|
| 1 | 合同方案列表（启用） | GET | `/v3/admin/contract/scheme/list` | 行为变化（返真实数据） |
| 2 | 合同列表 | GET | `/v3/admin/contract/list` | 行为变化 |
| 3 | 按方案创建合同 | POST | `/v3/admin/contract/create-by-scheme` | 行为变化（真实写库 + 第三方调用） |
| 4 | 重发签署短信 | POST | `/v3/admin/contract/resend-sign-sms` | 行为变化（接入用户归属校验） |
| 5 | 作废合同 | POST | `/v3/admin/contract/void/{contractId}` | 行为变化 |
| 6 | 合同方案 CRUD | POST | `/v3/admin/contract/scheme/*` | 新增 `defaultDestination` / `defaultDepartureCity` 字段 |

### 合同模块（mp 内部）

| # | 接口 | 方法 | 路径 | 变更类型 |
|---|------|------|------|---------|
| 7 | 用户合同列表 | GET | `/internal/mp/contract/list` | 行为变化（按 userId 反查 orderIds） |
| 8 | 用户合同详情 | GET | `/internal/mp/contract/detail/{contractId}` | 行为变化（接入归属校验） |

### 保险模块（admin）

| # | 接口 | 方法 | 路径 | 变更类型 |
|---|------|------|------|---------|
| 9 | 保险方案列表（启用） | GET | `/v3/admin/insurance/scheme/list` | 行为变化 |
| 10 | 保单列表 | GET | `/v3/admin/insurance/orders` | 行为变化 |
| 11 | 手动投保 | POST | `/v3/admin/insurance/purchase` | 行为变化 + **状态校验新增** |
| 12 | 自动投保 | POST | `/v3/admin/insurance/auto-purchase` | 行为变化 |
| 13 | 按方案自动投保 | POST | `/v3/admin/insurance/auto-purchase-by-scheme` | 行为变化 |
| 14 | 取消保险 | POST | `/v3/admin/insurance/cancel` | 行为变化 |
| 15 | 投保预览 | POST | `/v3/admin/insurance/preview-apply` | 行为变化 |

### 订单状态机副作用

| # | 接口 | 方法 | 路径 | 变更类型 |
|---|------|------|------|---------|
| 16 | 订单状态流转 | POST | `/v3/admin/order/{id}/transition` | **CONFIRM 事件副作用扩展**：异步触发自动签约/投保 |
| 17 | 出行人 batchEdit | POST | `/v3/admin/order/{id}/traveler/batch-edit` | 已签约阶段触发作废重做 |
| 18 | 出行人 add | POST | `/v3/admin/order/{id}/traveler/add` | 同上 |
| 19 | 出行人 delete | POST | `/v3/admin/order/{id}/traveler/delete/{travelerId}` | 同上 |

---

## 三、字段变更（合同方案）

`ContractScheme` 实体新增 2 个字段（对应表 `contract_scheme`）：

| 字段名 | 类型 | 含义 | 业务规则 |
|--------|------|------|---------|
| `defaultDestination` | varchar(64) NULL | 方案默认目的地 | createByScheme 时取值优先级：scheme.defaultDestination → productName 关键字提取（呼伦贝尔/阿尔山/新疆/内蒙古/青海/西藏等） → 全局兜底「内蒙古呼伦贝尔」 |
| `defaultDepartureCity` | varchar(64) NULL | 方案默认出发地 | 同上，全局兜底「呼和浩特」 |

**前端影响**：admin 合同方案管理表单加 2 个文本输入框（选填）。

---

## 四、错误码新增

| 业务码 | 文案 | 触发条件 |
|--------|------|---------|
| `540022` | 请先确认订单后再配置保险（当前订单状态不允许投保） | 调 `/v3/admin/insurance/purchase` 时订单状态 / 支付状态不符合白名单 |
| `510206` | 合同方案未配置模板 | 调 `/v3/admin/contract/create-by-scheme` 时 `scheme.contractTemplateCode = null` |

其他既有错误码不变。

---

## 五、Publisher 异步链路

### `ChecklistConfirmedEvent`
- **发布点**：`OrderService.transition()` 守卫 `from=CUSTOMIZING && to=PENDING_DEPARTURE && event=CONFIRM`
- **携带**：`orderId / orderNo / operatorId / operatorType (ADMIN/SYSTEM)`
- **operatorId 来源**：`AdminContextUtil.getAdminId()`（请求上下文）→ 无则 null + SYSTEM
- **监听者**：`ContractEventListener` + `InsuranceEventListener` 都接 `@TransactionalEventListener(AFTER_COMMIT, fallbackExecution=true)`
- **下游行为**：listener 走 `contract.createByScheme` / `insurance.autoPurchase`，失败有紧急待办 + 通知中心降级通知

### `TravelerChangedEvent`
- **发布点**：`TravelerService` 5 个写方法末尾
- **守卫**：仅在订单 `orderStatus ∈ {PENDING_DEPARTURE, PENDING_BALANCE, TRAVELLING}` 时发布
- **携带**：`orderId / orderNo / changeType (ADD/MODIFY/REMOVE) / affectedTravelerIds`
- **下游行为**：listener 走 `contract.invalidateByOrderId + createByScheme`（作废重签）、`insurance.cancelByOrderId + autoPurchase`（退旧重投）

---

## 六、数据库迁移

| 文件 | 内容 |
|------|------|
| `V20260519_004__create_order_todo.sql` | 新增 `order_todo` 表（订单内部待办，自动签约/投保失败时生成紧急待办） |
| `V20260519_005__contract_scheme_add_default_destination.sql` | `contract_scheme` 加 `default_destination` + `default_departure_city` 两列 |

---

## 七、测试服真测结论

### 部署
- 测试服 v3 已部署 dev-v3 最新 HEAD `af7dd70` 双实例 8086+8186
- Flyway 应用 V20260519_004 + V20260519_005 成功
- Nacos test namespace 注册健康，9443 网关路由通

### API 真测（test_admin token 通过 trusted device 通路）
| 接口 | 结果 |
|------|------|
| GET 合同 scheme list / 合同 list / 保险 scheme list / 保单 list | ✅ 200 真实数据 |
| POST insurance.purchase CUSTOMIZING+UNPAID | ✅ 抛 540022 |
| POST insurance.purchase PENDING_DEPARTURE+UNPAID | ✅ 抛 540022（**证明 pay_status 单独校验起作用**） |
| POST insurance.purchase PENDING_DEPARTURE+DEPOSIT_PAID | ✅ 状态校验过，进入 planId 校验阶段 |
| POST order.transition CONFIRM | ✅ 200，triggeredEvents 含 ASYNC_CONTRACT_GENERATE + ASYNC_INSURANCE_ISSUE，listener 真收到（业务数据 scheme 缺所以 fail-soft） |

### 单测
- `mvn -pl hl-order-service-v3 test`：**Tests run 1096 / Failures 0 / Errors 0**（基线 1069 → +27）

---

## 八、已知限制（非缺陷）

1. **测试服当前 active contract_scheme 都没填 `contract_template_code`**，所以 transition CONFIRM 异步自动签约会 short-circuit「无可用合同方案」并发紧急待办。需要在 admin 后台进 `/v3/admin/contract/scheme/*` 表单配置 `contract_template_code` 后才能完整测端到端。
2. **测试服当前没有 active `insurance_scheme`**，同上。
3. 自动签约时按 v3 当前 list 第一个 ACTIVE 方案挑选（暂无 mchId/productId 路由），后续多公司多产品场景需要在 `ContractScheme` 加 `mchId` / `productId` 字段后过滤。

---

## 九、关联

- 主 PR：#2590 (https://git.1814.love:8443/wx/HL/pulls/2590)
- CR 跟进：#2591
- 最终零遗留：#2603
- 工单：#2586

---

**前端 mmg**：请同步检查投保 / 合同管理 / 出行人编辑 / 订单确认 4 个页面的 UI 提示逻辑，重点是「投保按钮置灰条件」+「订单确认后异步合同/保险生成提示」+「已签约后编辑出行人的告知 toast」。如有疑问随时找。
