---
schema: "hl-changelog/v2"
ticket: "5599"
title: "核单餐食餐厅下拉值独立存储"
consumer: "admin"
author: "yst"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "implemented"
frontend_owner: "mmg"
frontend_ref: "hl-admin@01bd3875046cd32e9497e2f00a9eb0f782a197dd"
verified_at: ""
target_release: ""
updated_at: "2026-08-10"
base: "dev-v3"
status_note: "PR #5600 已合并 dev-v3；2026-08-10 复核确认测试服已部署并验证（见文末验证证据章节）。restaurantId 关联资源服务餐厅下拉接口 PR #5583"
---

# 核单餐食餐厅下拉值独立存储（不覆盖餐食名称）

- **变更类型**：修改接口
- **端类型**：管理后台
- **服务**：hl-order-service-v3
- **日期**：2026-08-06

## 1. 接口背景

核单「餐食」Tab 原来只有手动录入的 `mealName`（餐食名称）一个文本字段承载餐食/餐厅信息。业务上需要把「从餐厅资源库选择的餐厅」作为独立结构化数据存下来，便于后续按餐厅维度统计与对账，且**不能覆盖**运营手动填写的餐食名称。

本次变更：餐食费用的「新增 / 修改 / 回显」三个接口同时新增 `restaurantId` + `restaurantName` 两个字段，与 `mealName` 完全独立、互不联动。

## 2. 变更清单

| # | 接口 | 变更 | 说明 |
|---|------|------|------|
| 1 | POST /v3/admin/order/{orderId}/settlement/meals | 入参 +2 字段 | 新增 restaurantId / restaurantName（成对、非必填） |
| 2 | PUT /v3/admin/order/{orderId}/settlement/meals/{settlementId} | 入参 +2 字段 | 新增 restaurantId / restaurantName（成对、非必填） |
| 3 | GET /v3/admin/order/{orderId}/settlement/meals | 出参 +2 字段 | 回显新增 restaurantId / restaurantName |

无删除字段、无改名字段、无枚举值变化。

## 3. 接口详情

### 3.1 新增餐食费用

- 方法/路径：`POST /v3/admin/order/{orderId}/settlement/meals`
- 接口名：新增餐食费用
- 认证：管理后台 JWT（网关统一鉴权）
- 幂等性：非幂等（重复提交会产生多条餐食记录）
- 限流：走网关默认限流，无接口级特殊限流

### 3.2 修改餐食费用

- 方法/路径：`PUT /v3/admin/order/{orderId}/settlement/meals/{settlementId}`
- 接口名：修改餐食费用
- 认证：管理后台 JWT（网关统一鉴权）
- 幂等性：幂等（相同 body 重复 PUT 结果一致）
- 限流：走网关默认限流

### 3.3 查询餐食费用（回显）

- 方法/路径：`GET /v3/admin/order/{orderId}/settlement/meals`
- 接口名：查询餐食费用
- 认证：管理后台 JWT（网关统一鉴权）
- 幂等性：只读接口
- 限流：走网关默认限流

## 4. 接口入参

### 4.1 路径参数

| 参数 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| orderId | path | Long | 是 | 订单ID（三个接口均有） |
| settlementId | path | Long | 是 | 餐食费用记录ID（仅 PUT 修改接口） |

无 Query 参数。

### 4.2 请求体字段（POST / PUT 共用同一请求结构）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| mealType | String | 是 | 餐型：BREAKFAST / LUNCH / DINNER |
| mealDate | Date（yyyy-MM-dd） | 否 | 发生日期 |
| mealName | String | 是 | 餐食名称，手动录入，最长 200 字符；**不受餐厅选择影响、不被覆盖** |
| quantity | Integer | 是 | 数量，1-10000 |
| unitPrice | Number | 是 | 单价，≥ 0，最多 8 位整数 + 2 位小数；unitPrice × quantity ≤ 99999999.99 |
| paymentMethod | String | 是 | 付款类型：CASH_PAID / COMPANY_PAID / SIGNED |
| sourceType | String | 是 | 来源类型：MEAL_ASSIGNMENT / MANUAL / SYSTEM |
| settlementConfirmStatus | String | 是 | 确认状态：UNCONFIRMED / CONFIRMED |
| voucherUrls | String[] | 否 | 凭证 URL 列表，最多 9 个，仅支持 http/https，单个最长 1024 字符 |
| restaurantId | Long | 否 | **本次新增**。餐厅资源ID，与 restaurantName 成对出现；不选餐厅则两者都不传 |
| restaurantName | String | 否 | **本次新增**。餐厅名称快照，与 restaurantId 成对出现，最长 200 字符 |
| remark | String | 否 | 备注，最长 512 字符 |

**成对校验（关键业务规则）**：`restaurantId` 与 `restaurantName` 必须**同时传或同时不传**（restaurantName 为空白字符串视为未传）。只传其一 → 400，message 为「restaurantId 与 restaurantName 必须成对出现」或「餐食费用字段超出允许范围」（错误码 584094，MEAL_EXPENSE_REQUEST_INVALID）。两字段与手动录入的 `mealName` **完全独立、不联动**——选了餐厅也不会改动/覆盖 mealName。

## 5. 出参字段

- POST / PUT 响应：`Result<SettlementMealRespVO>`（单条记录）
- GET 响应：`Result<List<SettlementMealRespVO>>`（列表，元素结构相同）

SettlementMealRespVO 字段表：

| 字段 | 类型 | 说明 |
|---|---|---|
| id | String | 餐食费用ID，雪花ID序列化为字符串 |
| mealType | String | 餐型 |
| mealDate | Date（yyyy-MM-dd） | 发生日期 |
| mealName | String | 餐食名称 |
| restaurantId | String \| null | **本次新增**。餐厅资源ID，雪花ID序列化为字符串（防 JS 精度丢失）；未选餐厅为 null |
| restaurantName | String \| null | **本次新增**。餐厅名称快照（选中时记录，餐厅改名/删除不影响已存核单记录）；未选餐厅为 null |
| quantity | Integer | 数量 |
| unitPrice | String | 单价（BigDecimal 序列化为字符串） |
| actualAmount | String | 实际金额 = unitPrice × quantity（BigDecimal 序列化为字符串） |
| paymentMethod | String | 付款类型 |
| sourceType | String | 来源类型 |
| sourceTypeName | String | 来源类型名称 |
| voucherUrls | String[] | 凭证 URL 列表 |
| settlementConfirmStatus | String | 确认状态 |
| settlementConfirmStatusName | String | 确认状态名称 |
| remark | String | 备注 |

外层为统一响应结构：`{ "code": 200, "message": "...", "data": ..., "traceId": ..., "success": ... }`；业务失败时 code 为错误码、data 为 null。

## 6. 枚举 / 数据字典

本次**无新增、无变更**枚举。以下现有枚举取值保持不变：

| 字段 | 取值 | 说明 |
|---|---|---|
| mealType | BREAKFAST / LUNCH / DINNER | 早餐 / 午餐 / 晚餐 |
| paymentMethod | CASH_PAID / COMPANY_PAID / SIGNED | 现金垫付 / 公司支付 / 签单 |
| sourceType | MEAL_ASSIGNMENT / MANUAL / SYSTEM | 餐食安排 / 手动录入 / 系统生成 |
| settlementConfirmStatus | UNCONFIRMED / CONFIRMED | 未确认 / 已确认 |

`restaurantId` 的取值来源：资源服务「餐厅资源下拉选项」接口 `GET /admin/resource-options/restaurants`（PR #5583 已上线）返回的 `resourceId`。餐厅选项是业务数据，不是枚举/字典。

## 7. 错误码

| HTTP | 错误码 | message | 触发条件 |
|---|---|---|---|
| 400 | 584094 | 餐食费用字段超出允许范围 | restaurantId 与 restaurantName 只传其一（不成对），service 层抛 MEAL_EXPENSE_REQUEST_INVALID |
| 400 | （参数校验失败） | restaurantId 与 restaurantName 必须成对出现 | 同上场景，bean validation 层先行拦截时的提示文案 |
| 400 | （参数校验失败） | mealType 必须是 BREAKFAST / LUNCH / DINNER 之一 等 | 既有字段校验失败（本次不变） |
| 401 | - | 未登录 / token 失效 | 未携带或携带无效的管理后台 JWT |

## 8. 示例

### 8.1 典型成功：选择餐厅保存 + 回显

请求：

```http
POST /v3/admin/order/1951234567890123456/settlement/meals
Authorization: Bearer {adminToken}
Content-Type: application/json
```

```json
{
  "mealType": "LUNCH",
  "mealDate": "2026-08-10",
  "mealName": "团队桌餐（10人标）",
  "quantity": 10,
  "unitPrice": 68.00,
  "paymentMethod": "COMPANY_PAID",
  "sourceType": "MANUAL",
  "settlementConfirmStatus": "UNCONFIRMED",
  "voucherUrls": ["https://oss.example.com/voucher/a1.jpg"],
  "restaurantId": 1889900112233445566,
  "restaurantName": "海拉尔XX手把肉餐厅",
  "remark": "导游现场确认"
}
```

响应（200）：

```json
{
  "code": 200,
  "message": "成功",
  "data": {
    "id": "1952345678901234567",
    "mealType": "LUNCH",
    "mealDate": "2026-08-10",
    "mealName": "团队桌餐（10人标）",
    "restaurantId": "1889900112233445566",
    "restaurantName": "海拉尔XX手把肉餐厅",
    "quantity": 10,
    "unitPrice": "68.00",
    "actualAmount": "680.00",
    "paymentMethod": "COMPANY_PAID",
    "sourceType": "MANUAL",
    "sourceTypeName": "手动录入",
    "voucherUrls": ["https://oss.example.com/voucher/a1.jpg"],
    "settlementConfirmStatus": "UNCONFIRMED",
    "settlementConfirmStatusName": "未确认",
    "remark": "导游现场确认"
  }
}
```

回显：`GET /v3/admin/order/1951234567890123456/settlement/meals` 返回 `data` 为列表，元素结构同上（含 restaurantId / restaurantName）。

### 8.2 边界：不选餐厅，纯手动录入（两字段都不传）

请求：

```http
POST /v3/admin/order/1951234567890123456/settlement/meals
Authorization: Bearer {adminToken}
Content-Type: application/json
```

```json
{
  "mealType": "BREAKFAST",
  "mealDate": "2026-08-11",
  "mealName": "酒店自助早餐",
  "quantity": 10,
  "unitPrice": 0,
  "paymentMethod": "SIGNED",
  "sourceType": "MANUAL",
  "settlementConfirmStatus": "CONFIRMED"
}
```

响应（200）：`data` 中 `restaurantId: null`、`restaurantName: null`，其余字段正常填充。存量历史餐食记录回显同样两字段为 null。

### 8.3 业务失败：只传 restaurantId、缺 restaurantName（不成对）

请求：

```json
{
  "mealType": "DINNER",
  "mealName": "涮羊肉",
  "quantity": 10,
  "unitPrice": 88.00,
  "paymentMethod": "CASH_PAID",
  "sourceType": "MANUAL",
  "settlementConfirmStatus": "UNCONFIRMED",
  "restaurantId": 1889900112233445566
}
```

响应（400）：

```json
{
  "code": 584094,
  "message": "餐食费用字段超出允许范围",
  "data": null
}
```

（若 bean validation 层先行拦截，message 为「restaurantId 与 restaurantName 必须成对出现」。反向场景——只传 restaurantName 不传 restaurantId——同样 400。）

## 9. 业务边界

**适用**：

- 核单「餐食」Tab 新增 / 编辑费用时，从餐厅资源下拉中选择餐厅，结构化保存 restaurantId + restaurantName
- 运营纯手动录入餐食（不关联餐厅）：两字段都不传即可，行为与本次变更前完全一致

**不适用**：

- restaurantId 只接受「餐厅资源下拉选项」接口返回的 resourceId，不接受其他类型资源ID
- 已提交核单（终态）订单不可再改餐食费用（核单提交守卫为既有逻辑，本次不变）

**特殊边界**：

- **快照语义**：restaurantName 是选中那一刻的名称快照。之后餐厅在资源库改名 / 删除，**不影响**已存核单记录的 restaurantName
- **与 mealName 完全独立**：选了餐厅也不会改动 / 覆盖 mealName；mealName 仍是必填手动录入字段
- 存量数据：历史餐食费用记录两字段均为 null，回显正常、无需迁移

## 10. 修改前后对比

### 字段级对比

| 接口 | 维度 | 修改前 | 修改后 |
|---|---|---|---|
| POST / PUT 保存 | 入参 | 无餐厅字段 | +restaurantId（Long，可选，成对）+restaurantName（String，最长 200，可选，成对） |
| GET 回显 | 出参 | 无餐厅字段 | +restaurantId（String，可 null）+restaurantName（String，可 null） |
| mealName | 语义 | 餐食/餐厅信息只能挤在这一个文本字段里 | 仍为必填手动录入；餐厅信息改由独立字段承载，不再占用 mealName |

### 行为级对比

| 场景 | 修改前 | 修改后 |
|---|---|---|
| 保存餐食费用 | 想记餐厅只能写进 mealName 文本 | 可结构化传 restaurantId + restaurantName，mealName 不被覆盖 |
| 回显 | 无法区分「餐食名称」和「餐厅」 | 两类信息独立字段回显，前端可分开展示 |
| 入参校验 | 无成对概念 | restaurantId / restaurantName 不成对 → 400 |

## 11. 影响评估 / 回滚

- **破坏性兼容**：无。两个字段均为可选新增；存量请求不传两字段时行为完全不变；存量数据两列为 NULL
- **前端同步上线**：不强制同步。前端未接新字段前，保存 / 回显行为与之前一致（回显多两个 null 字段，不读即可）；前端接新字段需等后端部署后联调
- **后端部署状态**：PR #5600 已合并 dev-v3，**测试服已部署**（2026-08-10 复核确认，见文末验证证据章节）
- **回滚方案**：后端回滚 = 下线两字段即可。回滚时前端需同步停止传这两字段；存量数据两列本就为 NULL 或历史快照值，无数据迁移、无回滚 SQL

## 12. 注意事项

1. `restaurantId` 出参是 **String**（雪花 ID 序列化），前端不要用 Number 解析，防 JS 精度丢失
2. `unitPrice` / `actualAmount` 出参同样是 String（BigDecimal 序列化），金额展示直接渲染字符串即可
3. 编辑时「清掉已选餐厅」= 两字段都不传（或都传 null），保持成对空态
4. restaurantName 是快照不是实时关联，餐厅后续改名 / 删除不影响历史核单记录显示，属预期
5. 餐厅下拉数据源接口 `GET /admin/resource-options/restaurants` 属资源服务（PR #5583），不在本变更范围内
6. 本变更只涉及接口契约；后端代码已合并 dev-v3，测试服部署时间以后端通知为准

## 13. 关联 / 联系人

- Issue：https://git.1814.love:8443/wx/HL/issues/5599
- PR：https://git.1814.love:8443/wx/HL/pulls/5600
- Commit：https://git.1814.love:8443/wx/HL/commit/9884585a292924c1a0a7a5fec6b7dc8ab87aa59d
- 关联依赖：餐厅资源下拉接口 PR #5583（资源服务，已上线）
- 后端负责人：yst（腰苏图）

## 验证证据（2026-08-10 复核回填，wx）

> 本条 changelog 2026-08-06 推送时后端尚未部署测试服（原 status_note 自述），违反「测试服部署+实测后才通知前端」流程。2026-08-10 复核补齐部署与验证证据如下：

- 测试服 hl-order-service-v3 运行版本为 2026-08-10 15:10 构建（dev-v3），晚于 PR #5600 合并点（2026-08-06 14:56），本变更代码已在运行实例中。
- 测试服 DB `hl_order_service_v3.order_settlement_meal` 已存在 `restaurant_id` / `restaurant_name` 两列（Flyway 已执行）。
- `SettlementMealRespVO` 含 `restaurantId` / `restaurantName` 字段（dev-v3 源码核对）。
- `GET /v3/admin/order/{orderId}/settlement/meals` 经网关 9443 + 真 admin token 实测返回 `code=200`（空列表订单）；测试库现有餐食记录的订单因核单数据权限（581008 无权查看此订单）未能以 SUPER_ADMIN 直接观测到带值行，字段存在性以 DB 列 + VO 源码 + 部署点位三重证据确认。
