---
schema: "hl-changelog/v2"
ticket: "5876"
title: "核单 finalize warnings 结构化带行id + summary 新增 settled 标识（warnings 出参破坏性变更）"
consumer: "admin"
change_type: "修改接口"
author: "yst"
backend_status: "deployed"
gateway_status: "pending"
frontend_status: "implemented"
frontend_owner: "mmg"
frontend_ref: "1f716683"
target_release: ""
verified_at: "2026-08-12"
status_note: "后端 PR #5881 已 merge 到 dev-v3 并部署测试服。finalize 出参 warnings 由字符串数组改为对象数组（破坏性，按字符串渲染的前端必须改读 message 字段）；summary 出参新增 settled 字段，判是否已核单改读 settled，不要再判 id==null。前端 2026-08-12 落地(1f716683)：实证唯一破坏性命中 finance/settlement/detail.vue 软预警 join，改读 item.message 兼容过渡期字符串元素；summary/settled 前端无封装零消费不改；detail.spec +2，settlement 36 全绿。"
updated_at: "2026-08-12"
base: "dev-v3"
---

# 【⚠️ 修改接口·管理后台】核单 finalize warnings 结构化 + summary 新增 settled 标识（#5876）

## 1. 接口背景

核单结算域的两个管理后台接口：

1. **完成核单 finalize**：核单页点「完成核单」时提交，原子冻结核单事实并生成核单汇总。出参带 `warnings` 软预警（现付缺凭证等，不阻塞提交），前端用于提示定制师补传凭证。
2. **核单汇总快照 summary**：核单页 / 财务报表页读取整单金额、成本、毛利汇总。

本次整改两个出参可读性问题：

1. **warnings 无法定位行**（旧结构）：warnings 是 `List<String>`，文案形如「住宿 D2 现付缺凭证」。同一天有多条住宿/门票明细时，前端拿到的多条文案完全相同，无法区分是哪一行缺凭证，也无法跳转/锚定到具体明细行。
2. **summary 空壳无法可靠判空**（旧行为）：订单未核单时接口返回一个空壳对象，此前前端用 `id == null` 判「尚未核单」，但 `advanceSummary` 字段恒有默认空对象（不是 null），单靠字段判空容易误判。

## 2. 变更清单

| # | 变更 | 类型 |
|---|------|------|
| 1 | finalize 出参 `warnings` 由 `List<String>` 改为 `List<WarningItemVO>`（每条带 settlementId / category / dayNumber / message） | ⚠️ 破坏性 |
| 2 | summary 出参新增 `settled` 字段（Boolean）：已核单 = true，未核单空壳 = false | ✨ 新增字段 |

无入参变化、无删除字段、无 DDL。

## 3. 接口详情

### 3.1 完成核单

| 项 | 值 |
|---|---|
| 方法 + 路径 | POST /v3/admin/order/{orderId}/settlement/finalize |
| 接口名 | 完成核单 |
| 使用场景 | 管理后台核单页，明细核对完成后提交，原子冻结核单事实并生成核单汇总快照 |
| 认证 | 管理后台 JWT（/v3/admin/* 走网关鉴权） |
| 角色限制 | 房务角色（HOUSE）不可访问，调了会被 403 拦截 |
| 幂等性 | 非幂等写操作；重复提交会被「缺少核单终态快照 / 快照已变化」类错误码拦截，前端不要自动重试 |
| 限流 | 走网关默认限流，无接口级特殊限流 |

### 3.2 查核单汇总快照

| 项 | 值 |
|---|---|
| 方法 + 路径 | GET /v3/admin/order/{orderId}/settlement/summary |
| 接口名 | 查核单汇总快照 |
| 使用场景 | 管理后台核单页 / 财务报表页读取整单金额、成本、毛利汇总 |
| 认证 | 管理后台 JWT |
| 角色限制 | 无接口级角色限制（网关鉴权通过即可） |
| 幂等性 | 只读查询，幂等 |
| 限流 | 走网关默认限流 |

## 4. 接口入参

### 4.1 路径参数

| 接口 | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|------|
| finalize | orderId | Long | 是 | 订单 ID，必须大于 0（否则 400「订单 ID 必须大于 0」） |
| summary | orderId | Long | 是 | 订单 ID |

### 4.2 请求体 / Query

两个接口均无请求体、无 Query 参数。

## 5. 出参字段

### 5.1 finalize 出参（Result<SettlementSubmitRespVO>，本次仅 warnings 变化）

| 字段 | 类型 | 说明 |
|------|------|------|
| summaryId | Long(String) | 新写入的 settlement_summary 主键，序列化为字符串 |
| finalSnapshotId | Long(String) | 核单终态快照 ID，序列化为字符串 |
| finalSnapshotVersionNo | Integer | 核单终态快照版本号 |
| finalSnapshotStatus | String | 核单终态快照状态，固定 FINALIZED |
| orderId | Long(String) | 订单 ID，序列化为字符串 |
| settledAt | String | 核单完成时间，格式 yyyy-MM-ddTHH:mm:ss |
| totalAmount | String | 订单总金额快照（BigDecimal 序列化为字符串，下同） |
| paidAmount | String | 已付金额快照 |
| balanceAmount | String | 尾款金额快照 |
| roomCost | String | 住宿实际成本 |
| ticketCost | String | 门票实际成本 |
| staffCost | String | 人员费用实际成本 |
| subsidyCost | String | 补助实际成本 |
| mealCost | String | 餐食实际成本 |
| vehicleCost | String | 车辆基础服务总车费 |
| otherExpenseCost | String | 其他支出实际成本 |
| insurancePremium | String | 保险实际保费（未出单/已撤单 = 0） |
| totalActualCost | String | 总实际成本 |
| driverTransferAmount | String | 给司机/主报账人转回金额（有付款类型的分类仅计 CASH_PAID，不含保险） |
| profitAmount | String | 公司毛利 = totalAmount - totalActualCost |
| profitRate | Number | 毛利率（小数，totalAmount=0 时填 0） |
| orderStatusAfter | String | 结算后订单状态，如「待财务复核」 |
| mqTriggered | Boolean | 当前版本固定 false；完成核单不发布结算 MQ |
| warnings | Array<WarningItemVO> | **本次变更**：软预警对象数组（原来为字符串数组）；无预警时为空数组 [] |

**WarningItemVO 字段表**：

| 字段 | 类型 | 说明 |
|------|------|------|
| settlementId | Long(String) | 触发预警的核单明细行 id（settlement_hotel / settlement_ticket 主键），序列化为字符串；前端据此锚定/跳转具体明细行 |
| category | String | 核单分类枚举名，当前仅 HOTEL（住宿）/ TICKET（门票/游玩项目）两类会触发预警 |
| dayNumber | Integer | 行程第几天 |
| message | String | 预警文案，与旧字符串元素一致，如「住宿 D2 现付缺凭证」 |

### 5.2 summary 出参（Result<SettlementSummaryRespVO>，本次新增 settled）

| 字段 | 类型 | 说明 |
|------|------|------|
| settled | Boolean | **本次新增**：是否已核单。summary 快照存在 = true；未核单空壳 = false |
| id | Long | settlement_summary 主键；未核单时为 null |
| orderId | Long | 订单 ID；空壳时也有值 |
| totalAmount | String | 订单总金额快照（BigDecimal 序列化为字符串，下同） |
| paidAmount | String | 已付金额快照 |
| balanceAmount | String | 尾款金额快照 |
| roomCost | String | 住宿实际成本 |
| ticketCost | String | 门票实际成本 |
| staffCost | String | 人员费用实际成本 |
| subsidyCost | String | 补助实际成本 |
| mealCost | String | 餐食实际成本 |
| vehicleCost | String | 车辆基础服务总车费（按 Fleet 派车组合计） |
| otherExpenseCost | String | 其他支出实际成本 |
| insurancePremium | String | 保险实际保费 |
| refundTotal | String | 返还合计（只读展示，不进利润公式） |
| totalActualCost | String | 总实际成本 |
| driverTransferAmount | String | 给司机/主报账人转回金额（仅计 CASH_PAID） |
| profitAmount | String | 公司毛利 |
| profitRate | Number | 毛利率（小数） |
| settledBy | Long | 核单人 id；未核单时为 null |
| settledByName | String | 核单人姓名快照 |
| settledAt | String | 核单完成时间，格式 yyyy-MM-ddTHH:mm:ss |
| remark | String | 核单备注 |
| finalSnapshotId | Long(String) | 当前核单终态快照 ID；未完成核单或已重新打开时为 null |
| finalSnapshotVersionNo | Integer | 当前核单终态快照版本号；无当前快照时为 null |
| finalSnapshotStatus | String | 有当前快照时固定 FINALIZED，否则为 null |
| finalizedAt | String | 当前核单终态快照完成时间；无当前快照时为 null |
| finalizedByName | String | 当前核单终态快照操作人姓名；无当前快照时为 null |
| advanceSummary | Object | 已审批通过的订单预支汇总；**恒下发对象，不是 null**（空壳时也有值） |

**advanceSummary 子对象字段表**：

| 字段 | 类型 | 说明 |
|------|------|------|
| approvedAmount | String | 已审批通过预支合计金额，默认 "0" |
| records | Array | 已审批通过预支记录列表，默认空数组 []；元素结构同预支接口出参 |

> 未核单空壳时：`settled=false`，仅 `orderId` 与 `advanceSummary`（默认空对象）有值，**其余字段全部为 null**。

## 6. 枚举 / 数据字典

| 字段 | 来源 | 取值 |
|------|------|------|
| warnings[].category | SettlementCategory 枚举 | 全量：HOTEL（住宿）/ TICKET（门票/游玩项目）/ MEAL（餐食）/ VEHICLE（车辆）/ GUIDE（导游）/ PHOTOGRAPHER（摄影）/ OTHER_INCOME（其他收入）/ OTHER_EXPENSE（其他支出）；**当前预警只会出现 HOTEL / TICKET** |
| finalSnapshotStatus | 快照状态枚举 | FINALIZED（已定稿） |
| orderStatusAfter | 订单状态中文文案 | 如「待财务复核」 |

本次无枚举值增删；category 只是从隐含在文案里变成显式字段。

## 7. 错误码

### 7.1 finalize

| code | message | 触发场景 |
|------|---------|----------|
| 581007 | 订单不存在 | orderId 查不到订单 |
| 584081 | 对账数据不一致：已付金额与支付流水和线下收款合计不符，禁止带病结算 | paid_amount 与实际入账总额不符（存量脏数据或并发漂移） |
| 584085 | 存在辅助人员结算未完成，请全部结算后提交 | 任一非主报账司机费用行未结算完成或缺转账凭证号 |
| 584321 | 当前订单缺少核单终态快照，请重新完成核单 | 缺终态快照（含重复提交场景） |
| 400 | 订单 ID 必须大于 0 | orderId 路径参数校验失败 |
| 403 | 无权限访问 | 房务角色（HOUSE）JWT 调用 |

> finalize 还有人员费用 / 其他收入未就绪等前置门禁错误码（584xxx 段），非本次变更，按响应 message 直接提示即可。

### 7.2 summary

只读接口，无业务错误码；orderId 查不到快照时返回 200 + 空壳对象（settled=false），**不会报「订单不存在」**。

## 8. 示例

### 8.1 典型成功

**finalize**（核单完成，D2 住宿与 D2 门票各一条现付缺凭证，同日两行靠 settlementId 区分）：

    POST /v3/admin/order/2087088947225038849/settlement/finalize

```json
{
  "code": 200,
  "message": "成功",
  "data": {
    "summaryId": "9600000000001",
    "finalSnapshotId": "9600000000002",
    "finalSnapshotVersionNo": 1,
    "finalSnapshotStatus": "FINALIZED",
    "orderId": "2087088947225038849",
    "settledAt": "2026-08-12T10:30:25",
    "totalAmount": "24800.00",
    "paidAmount": "24800.00",
    "balanceAmount": "0.00",
    "roomCost": "4280.00",
    "ticketCost": "3680.00",
    "staffCost": "14260.00",
    "subsidyCost": "720.00",
    "mealCost": "860.00",
    "vehicleCost": "5200.00",
    "otherExpenseCost": "1260.00",
    "insurancePremium": "180.00",
    "totalActualCost": "23120.00",
    "driverTransferAmount": "22940.00",
    "profitAmount": "1680.00",
    "profitRate": 0.0677,
    "orderStatusAfter": "待财务复核",
    "mqTriggered": false,
    "warnings": [
      {
        "settlementId": "9600000000003",
        "category": "HOTEL",
        "dayNumber": 2,
        "message": "住宿 D2 现付缺凭证"
      },
      {
        "settlementId": "9600000000011",
        "category": "TICKET",
        "dayNumber": 2,
        "message": "门票 D2 现付缺凭证"
      }
    ]
  },
  "success": true
}
```

**summary**（已核单订单）：

    GET /v3/admin/order/2087088947225038849/settlement/summary

```json
{
  "code": 200,
  "message": "成功",
  "data": {
    "settled": true,
    "id": "9600000000001",
    "orderId": "2087088947225038849",
    "totalAmount": "24800.00",
    "paidAmount": "24800.00",
    "balanceAmount": "0.00",
    "roomCost": "4280.00",
    "ticketCost": "3680.00",
    "staffCost": "14260.00",
    "subsidyCost": "720.00",
    "mealCost": "860.00",
    "vehicleCost": "5200.00",
    "otherExpenseCost": "1260.00",
    "insurancePremium": "180.00",
    "refundTotal": "2260.00",
    "totalActualCost": "23120.00",
    "driverTransferAmount": "22940.00",
    "profitAmount": "1680.00",
    "profitRate": 0.0677,
    "settledBy": "30001",
    "settledByName": "李定制师",
    "settledAt": "2026-08-12T10:30:25",
    "remark": "核单完成 出行 7 天 0 投诉",
    "finalSnapshotId": "9600000000002",
    "finalSnapshotVersionNo": 1,
    "finalSnapshotStatus": "FINALIZED",
    "finalizedAt": "2026-08-12T10:30:25",
    "finalizedByName": "财务终审员",
    "advanceSummary": {
      "approvedAmount": "2000.00",
      "records": []
    }
  },
  "success": true
}
```

### 8.2 边界情况

**边界 1：finalize 无任何软预警**——warnings 为空数组，不是 null：

```json
{
  "code": 200,
  "data": {
    "summaryId": "9600000000001",
    "warnings": []
  },
  "success": true
}
```

**边界 2：summary 查未核单订单**——返回空壳对象（HTTP 200，不报错），仅 orderId / advanceSummary 有值，其余全 null，settled=false：

    GET /v3/admin/order/2087088947225038850/settlement/summary

```json
{
  "code": 200,
  "message": "成功",
  "data": {
    "settled": false,
    "id": null,
    "orderId": "2087088947225038850",
    "totalAmount": null,
    "paidAmount": null,
    "balanceAmount": null,
    "roomCost": null,
    "ticketCost": null,
    "staffCost": null,
    "subsidyCost": null,
    "mealCost": null,
    "vehicleCost": null,
    "otherExpenseCost": null,
    "insurancePremium": null,
    "refundTotal": null,
    "totalActualCost": null,
    "driverTransferAmount": null,
    "profitAmount": null,
    "profitRate": null,
    "settledBy": null,
    "settledByName": null,
    "settledAt": null,
    "remark": null,
    "finalSnapshotId": null,
    "finalSnapshotVersionNo": null,
    "finalSnapshotStatus": null,
    "finalizedAt": null,
    "finalizedByName": null,
    "advanceSummary": {
      "approvedAmount": "0",
      "records": []
    }
  },
  "success": true
}
```

**边界 3：同一天同分类多条缺凭证**——旧结构下两条文案完全相同无法区分；新结构每条带独立 settlementId，可逐条定位/跳转：

```json
"warnings": [
  { "settlementId": "9600000000003", "category": "HOTEL", "dayNumber": 2, "message": "住宿 D2 现付缺凭证" },
  { "settlementId": "9600000000005", "category": "HOTEL", "dayNumber": 2, "message": "住宿 D2 现付缺凭证" }
]
```

### 8.3 业务失败

**finalize 辅助人员结算未完成**：

    POST /v3/admin/order/2087088947225038849/settlement/finalize

```json
{
  "code": 584085,
  "message": "存在辅助人员结算未完成，请全部结算后提交",
  "success": false
}
```

**finalize 订单不存在**：

    POST /v3/admin/order/999999999/settlement/finalize

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

**finalize 房务角色访问被拦**（House 角色 JWT 调用）：

```json
{
  "code": 403,
  "message": "无权限访问",
  "success": false
}
```

## 9. 业务边界

**适用**：

- finalize：订单核单明细已逐步保存完成、核对无误后点「完成核单」提交
- summary：核单页 / 财务报表页随时读取汇总；未核单订单也可调（返回空壳），前端据此展示「尚未核单」占位

**不适用**：

- finalize：明细未保存完整 / 辅助人员结算未完成 / 对账不一致的订单（被 584xxx 门禁拦截，见 §7）
- summary：想看明细行级数据时不要用它，走各分类明细接口；本接口只有汇总快照

**特殊边界**：

- warnings 是**软预警**，不阻塞 finalize 提交；有预警也照常返回 200 完成核单
- 预警仅覆盖 CASH_PAID（现付）且 voucher_urls 为空的住宿 / 门票明细行；签单、公司直付等其他支付方式不产生预警
- summary 空壳的 advanceSummary 恒为默认对象（approvedAmount="0"、records=[]），**不是 null**，不能拿它当判空依据

## 10. 修改前后对比

### 10.1 字段级对比

| 接口 | 字段 | 修改前 | 修改后 |
|------|------|--------|--------|
| finalize | warnings | `List<String>`，元素如 `"住宿 D2 现付缺凭证"` | `List<WarningItemVO>`，元素为对象 `{ settlementId, category, dayNumber, message }` |
| summary | settled | 无此字段 | 新增 Boolean：已核单 = true，未核单空壳 = false |

### 10.2 行为级对比

| 场景 | 修改前 | 修改后 |
|------|--------|--------|
| 同日多条同类缺凭证 | 多条相同文案，无法区分哪一行 | 每条带独立 settlementId，可定位/跳转具体明细行 |
| 前端渲染预警 | 直接渲染字符串 | 必须读 `warnings[].message`；可用 `settlementId` + `category` 做行锚定 |
| 前端判「是否已核单」 | 判 `data.id == null`（不可靠，advanceSummary 恒非 null 易误导） | 判 `data.settled === false` |
| 调旧结构（如把 warnings[i] 当字符串拼接） | 正常显示文案 | **显示 [object Object] 或报错**（字段结构已变） |

## 11. 影响评估 / 回滚

### 11.1 影响评估

- **破坏兼容性**：⚠️ 部分破坏。finalize 的 warnings 结构变化是破坏性的：原按字符串数组渲染 warnings 的前端代码会显示异常。summary 仅新增字段，向后兼容。
- **前端必须同步上线**：是（针对 finalize warnings 渲染处）。前端需要：
  1. warnings 渲染从「直接渲染字符串」改为读 `item.message`
  2. （可选）用 `item.settlementId` + `item.category` 实现点击预警跳转/高亮对应明细行
  3. 判「是否已核单」改读 `settled` 字段，删除 `id == null` 判空逻辑
- **后端兼容**：无 DDL、无数据迁移；未消费 warnings 的前端功能不受影响。

### 11.2 回滚方案

- 后端回滚 = revert PR #5881 的 merge commit（125bb9c96e），重启 hl-order-service-v3，warnings 恢复字符串数组、settled 字段消失。
- 前端回滚 = 切回旧版前端包。注意新旧前后端要配套：新前端 + 旧后端时 warnings[].message 取不到值、settled 恒 undefined。
- 零 DDL，回滚无残留风险。

## 12. 注意事项

1. **warnings 元素里的 settlementId 序列化为 JSON 字符串**（Long 防 JS 精度丢失），前端按 string 处理，不要 Number() 转换。
2. warnings 无预警时返回空数组 []，不是 null，渲染前正常遍历即可。
3. category 当前只会出现 HOTEL / TICKET；SettlementCategory 枚举还有其他值（MEAL / VEHICLE / GUIDE 等），前端如做映射表建议按全量枚举写，缺值兜底显示原 code。
4. summary 判空**只认 settled**：`settled === false` 即「尚未核单」，此时除 orderId / advanceSummary 外全部字段为 null，展示必须兜底，不要直接渲染金额。
5. advanceSummary 恒为对象（未核单也是 `{ approvedAmount: "0", records: [] }`），不要拿它判「是否已核单」。
6. finalize 是写操作且有前置门禁（对账一致、人员结算完成等），失败按错误码 message 提示，**不要静默重试**；重复提交会被快照类错误码拦截。
7. finalize / summary 两个接口的金额类字段（totalAmount / paidAmount / 各 cost / profitAmount 等）均序列化为字符串，前端展示直接用，计算需自行转数值。

## 13. 关联 / 联系人

- Issue：https://git.1814.love:8443/wx/HL/issues/5876
- PR：https://git.1814.love:8443/wx/HL/pulls/5881
- Commit：https://git.1814.love:8443/wx/HL/commit/125bb9c96e
- 服务：hl-order-service-v3（端口 8086）
- 后端负责人：yst（腰苏图）
