# 【🔧 修改接口·管理后台】Step2 票种规格默认值（#5185）

> **PR**: #5191 | **更新时间**: 2026-07-23

## 1. 接口背景

Step2 门票/游玩项目明细原先可能返回或保存空的 `specName`，前端无法稳定展示票种/规格。现在查询和保存统一补齐“成人票”默认值，同时保留已有的非空规格。

## 2. 变更清单

| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | Step2 查询门票核单明细 | GET | `/v3/admin/order/{orderId}/settlement/step2` | 修改接口 | 无明确规格的明细统一返回 `specName=成人票` |
| 2 | Step2 保存门票核单明细 | PUT | `/v3/admin/order/{orderId}/settlement/step2` | 修改接口 | `specName` 为 `null`、空串或纯空白时按“成人票”保存 |

## 3. 接口详情

### 3.1 Step2 查询门票核单明细

- **使用场景**：进入或刷新核单 Step2 时查询门票/游玩项目明细。
- **认证**：需要管理后台 JWT。
- **幂等性**：是，只读查询。
- **限流**：无接口专属限流约定。
- **默认语义**：自动生成且无明确规格、或已有明细规格为空时，`specName` 返回“成人票”。
- **保留语义**：已有非空规格原样返回，例如“骑马体验”。

### 3.2 Step2 保存门票核单明细

- **使用场景**：全量保存 Step2 门票/游玩项目明细。
- **认证**：需要管理后台 JWT。
- **幂等性**：业务数据为全量替换语义；重复提交相同明细得到相同业务内容，行 ID 可能重新生成。
- **限流**：无接口专属限流约定。
- **默认语义**：`items[].specName` 为 `null`、`""` 或纯空白时，保存并回读为“成人票”。
- **保留语义**：非空规格原样保存，例如“骑马体验”不会被替换。

## 4. 接口入参

### 4.1 路径参数

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `orderId` | Long / String | 是 | 订单 ID，必须大于 `0`；19 位 ID 建议按字符串拼入路径 |

GET 无 Query 参数、无请求体。

### 4.2 PUT 请求体

推荐使用对象形式；接口同时兼容直接提交明细数组。

```json
{
  "items": []
}
```

| 字段 | 类型 | 必填 | 说明 | 校验规则 |
|------|------|------|------|----------|
| `items` | Array | 是 | 门票/游玩项目明细，全量替换 | 可为空数组；空数组表示清空已保存草稿 |

### 4.3 `items[]` 字段

| 字段 | 类型 | 必填 | 说明 | 校验规则 |
|------|------|------|------|----------|
| `id` | Long / String | 否 | 已存在行 ID；新增或自动生成行可为空 | 19 位 ID 建议使用字符串 |
| `sourceType` | String | 是 | 来源类型 | `SCENIC_ASSIGNMENT`、`ACTIVITY_ASSIGNMENT`、`CUSTOM_ASSIGNMENT` |
| `sourceTypeName` | String | 否 | 来源类型中文名 | 最长 32 字符 |
| `scenicAssignmentId` | Long / String | 否 | 来源记录 ID；手动补充行为空 | 19 位 ID 建议使用字符串 |
| `dayNumber` | Integer | 否 | 行程第几天，从 `1` 开始；保存时按 `dayDate` 计算 | 无需前端计算 |
| `dayDate` | String | 是 | 项目日期 | `yyyy-MM-dd`，不得早于订单出发日期 |
| `scenicName` | String | 是 | 景区或游玩项目名称 | 非空，最长 200 字符 |
| `specName` | String | 否 | 票种/规格名称 | 最长 128 字符；`null`、空串、纯空白统一为“成人票” |
| `ticketCount` | Integer | 是 | 实际购票数量 | 套餐含项目可填 `0` |
| `ticketUnitPrice` | Decimal | 否 | 参考单价，单位元 | 自费项目可填；包价项目可为 `null` |
| `sellPrice` | Decimal | 否 | 客户成交单价，单位元 | 大于等于 `0` |
| `totalAmount` | Decimal | 否 | 客户成交小计，单位元 | 大于等于 `0`；为空时按 `sellPrice × ticketCount` 计算 |
| `plannedCost` | Decimal | 是 | 计划成本，单位元 | 大于等于 `0` |
| `actualCost` | Decimal | 是 | 实际成本，单位元 | 大于等于 `0` |
| `paymentMethod` | String | 否 | 付款方式 | `SIGNED`、`COMPANY_PAID`、`CASH_PAID`；为空时为 `COMPANY_PAID` |
| `paymentMethodName` | String | 否 | 付款方式中文名 | 最长 32 字符 |
| `voucherUrls` | Array\<String> | 否 | 凭证图片 URL 列表 | 可为空数组 |
| `remark` | String | 否 | 备注 | 最长 500 字符 |

## 5. 出参（响应）

### 5.1 统一响应字段

| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | Integer | 业务状态码，成功为 `200` |
| `message` | String | 响应消息，成功为“成功” |
| `data` | Object / Array | GET 为明细数组，PUT 为保存结果对象 |
| `traceId` | String / null | 链路追踪 ID |
| `success` | Boolean | `code=200` 时为 `true` |

### 5.2 GET `data[]`

| 字段 | 类型 | 可为空 | 说明 |
|------|------|--------|------|
| `id` | String / null | 是 | 已保存的 19 位行 ID 按字符串返回；未保存的自动生成行可为 `null` |
| `sourceType` | String | 否 | 来源类型，取值见 §6.2 |
| `sourceTypeName` | String | 是 | 来源类型中文名 |
| `scenicAssignmentId` | String / null | 是 | 19 位来源记录 ID 按字符串返回；手动补充行为空 |
| `dayNumber` | Integer | 是 | 根据项目日期与订单行程计算的天序 |
| `dayDate` | String | 否 | 项目日期，格式为 `yyyy-MM-dd` |
| `scenicName` | String | 否 | 景区或游玩项目名称 |
| `specName` | String | 否 | 无明确规格时返回“成人票”；已有非空规格原样返回 |
| `ticketCount` | Integer | 否 | 实际购票数量 |
| `ticketUnitPrice` | Decimal | 是 | 参考单价，单位元 |
| `sellPrice` | Decimal | 是 | 客户成交单价，单位元 |
| `totalAmount` | Decimal | 是 | 客户成交小计，单位元 |
| `plannedCost` | Decimal | 否 | 计划成本，单位元 |
| `actualCost` | Decimal | 否 | 实际成本，单位元 |
| `paymentMethod` | String | 是 | 付款方式，取值见 §6.3 |
| `paymentMethodName` | String | 是 | 付款方式中文名 |
| `voucherUrls` | Array\<String> | 是 | 凭证图片 URL 列表 |
| `remark` | String | 是 | 备注 |

### 5.3 PUT `data`

| 字段 | 类型 | 说明 |
|------|------|------|
| `addedIds` | Array\<String> | 本次新增行 ID 列表 |
| `updatedIds` | Array\<String> | 本次更新行 ID 列表 |
| `deletedIds` | Array\<String> | 本次删除行 ID 列表 |
| `totalActualCost` | String | 保存后实际成本合计，单位元 |

## 6. 枚举 / 数据字典

### 6.1 `specName`（数据字典 `settlement_ticket_spec`）

**所属字段**：`items[].specName` | **类型**：String | **必填**：否

| 值 | 中文 | 说明 |
|----|------|------|
| `成人票` | 成人票 | 当前默认票种/规格；字典接口返回的 `dictValue` |

加载选项使用：

```http
GET /admin/dict/data/settlement_ticket_spec
```

展示使用字典项 `dictLabel`，提交使用 `dictValue`。本次没有新增 `specCode` 字段。

### 6.2 `sourceType`

**所属字段**：`items[].sourceType` | **类型**：String | **必填**：是

| 值 | 中文 | 说明 |
|----|------|------|
| `SCENIC_ASSIGNMENT` | 景区 | 来源于景区项目 |
| `ACTIVITY_ASSIGNMENT` | 游玩项目 | 来源于游玩项目 |
| `CUSTOM_ASSIGNMENT` | 手动补充 | 核单时手动新增 |

### 6.3 `paymentMethod`

**所属字段**：`items[].paymentMethod` | **类型**：String | **必填**：否

| 值 | 中文 | 说明 |
|----|------|------|
| `SIGNED` | 签单 | 供应商签单 |
| `COMPANY_PAID` | 公司付款 | 未传付款方式时的默认值 |
| `CASH_PAID` | 现付 | 现场付款，可附凭证 |

## 7. 错误码

| code | 含义 | 触发场景 |
|------|------|----------|
| `200` | 成功 | 查询或保存成功 |
| `400` | 请求参数校验失败 | 必填字段为空、枚举值不合法、金额为负数、字段超长或日期格式错误 |
| `401` | 未认证或认证失效 | 未携带有效管理后台 JWT |
| `584011` | 当前核单状态不允许录门票核单 | PUT 时订单核单状态不是“待核单”或“核单中” |
| `584017` | 订单缺出发日期 | PUT 时无法根据 `dayDate` 计算 `dayNumber` |
| `584018` | 项目日期早于订单出发日期 | PUT 的 `items[].dayDate` 早于订单出发日期 |
| `500` | 系统异常 | 查询或保存过程发生未预期异常 |

## 8. 示例

### 8.1 典型成功：查询自动生成明细

**请求**：

```http
GET /v3/admin/order/2079454953641836546/settlement/step2
Authorization: Bearer <admin-token>
```

无请求体。

**响应**：

```json
{
  "code": 200,
  "message": "成功",
  "data": [
    {
      "id": null,
      "sourceType": "SCENIC_ASSIGNMENT",
      "sourceTypeName": "景区",
      "scenicAssignmentId": "2079454953641837001",
      "dayNumber": 1,
      "dayDate": "2026-07-21",
      "scenicName": "示例景区",
      "specName": "成人票",
      "ticketCount": 2,
      "ticketUnitPrice": 100.00,
      "sellPrice": 120.00,
      "totalAmount": 240.00,
      "plannedCost": 200.00,
      "actualCost": 200.00,
      "paymentMethod": "COMPANY_PAID",
      "paymentMethodName": "公司付款",
      "voucherUrls": [],
      "remark": null
    }
  ],
  "traceId": "a1b2c3d4-e5f6-7890",
  "success": true
}
```

### 8.2 典型成功：提交字典选中的“成人票”

**请求**：

```http
PUT /v3/admin/order/2079454953641836546/settlement/step2
Authorization: Bearer <admin-token>
Content-Type: application/json
```

```json
{
  "items": [
    {
      "id": null,
      "sourceType": "SCENIC_ASSIGNMENT",
      "sourceTypeName": "景区",
      "scenicAssignmentId": "2079454953641837001",
      "dayNumber": 1,
      "dayDate": "2026-07-21",
      "scenicName": "示例景区",
      "specName": "成人票",
      "ticketCount": 2,
      "ticketUnitPrice": 100.00,
      "sellPrice": 120.00,
      "totalAmount": 240.00,
      "plannedCost": 200.00,
      "actualCost": 200.00,
      "paymentMethod": "COMPANY_PAID",
      "paymentMethodName": "公司付款",
      "voucherUrls": [],
      "remark": null
    }
  ]
}
```

**响应**：

```json
{
  "code": 200,
  "message": "成功",
  "data": {
    "addedIds": ["2079454953641840001"],
    "updatedIds": [],
    "deletedIds": [],
    "totalActualCost": "200.00"
  },
  "traceId": "b2c3d4e5-f6a7-8901",
  "success": true
}
```

### 8.3 边界情况：空规格归一为“成人票”

**保存请求**：

```http
PUT /v3/admin/order/2079454953641836546/settlement/step2
Authorization: Bearer <admin-token>
Content-Type: application/json
```

```json
{
  "items": [
    {
      "sourceType": "ACTIVITY_ASSIGNMENT",
      "sourceTypeName": "游玩项目",
      "scenicAssignmentId": "2079454953641837002",
      "dayDate": "2026-07-22",
      "scenicName": "示例游玩项目",
      "specName": null,
      "ticketCount": 2,
      "ticketUnitPrice": null,
      "sellPrice": 0,
      "totalAmount": 0,
      "plannedCost": 0,
      "actualCost": 0,
      "paymentMethod": "COMPANY_PAID",
      "voucherUrls": [],
      "remark": null
    }
  ]
}
```

**保存响应**：

```json
{
  "code": 200,
  "message": "成功",
  "data": {
    "addedIds": ["2079454953641840002"],
    "updatedIds": [],
    "deletedIds": [],
    "totalActualCost": "0"
  },
  "traceId": "c3d4e5f6-a7b8-9012",
  "success": true
}
```

随后 GET 回读时，该行的关键字段为：

```json
{
  "scenicName": "示例游玩项目",
  "specName": "成人票"
}
```

### 8.4 业务失败：非法来源类型

**请求**：

```http
PUT /v3/admin/order/2079454953641836546/settlement/step2
Authorization: Bearer <admin-token>
Content-Type: application/json
```

```json
{
  "items": [
    {
      "sourceType": "UNKNOWN",
      "dayDate": "2026-07-21",
      "scenicName": "示例项目",
      "specName": "成人票",
      "ticketCount": 1,
      "plannedCost": 0,
      "actualCost": 0
    }
  ]
}
```

**响应**：

```json
{
  "code": 400,
  "message": "sourceType 必须是 SCENIC_ASSIGNMENT / ACTIVITY_ASSIGNMENT / CUSTOM_ASSIGNMENT 之一",
  "data": null,
  "traceId": "d4e5f6a7-b8c9-0123",
  "success": false
}
```

## 9. 业务边界

- GET：自动生成明细没有明确规格时，返回 `specName=成人票`。
- GET：历史已保存明细的 `specName` 为 `null`、空串或纯空白时，也返回“成人票”。
- GET/PUT：已有非空规格保持不变，例如“骑马体验”不会被覆盖。
- PUT：`specName` 最大 128 字符；空值会归一为“成人票”而不是报错。
- PUT：`items` 为全量数据；遗漏的旧明细不会继续保留。
- PUT：仅订单核单状态为“待核单”或“核单中”时允许保存。
- 前端从 `settlement_ticket_spec` 字典读取选项，展示 `dictLabel`，提交 `dictValue` 到 `specName`。

## 10. 修改前后对比

### 10.1 字段级对比

| 字段 | 改前 | 改后 |
|------|------|------|
| `items[].specName` | String，可返回或保存为空 | String；查询和保存的空值统一为“成人票” |
| `items[].specCode` | 不存在 | 仍不存在，本次未新增 |

### 10.2 行为级对比

| 行为 | 改前 | 改后 |
|------|------|------|
| 自动生成明细没有明确规格 | `specName` 可能为空或与项目名重复 | `specName=成人票` |
| 保存 `specName=null`、空串或纯空白 | 可能按空值保存和回显 | 保存、回读均为“成人票” |
| 保存非空自定义规格 | 原样保存 | 仍原样保存 |
| 接口数量 | GET、PUT 两个既有接口 | 不变，没有新增 Step2 接口 |

## 11. 影响评估

- **是否破坏向后兼容**：否，接口路径、请求结构和响应字段均未改变；只收紧了空规格的返回语义。
- **前端是否必须同步上线**：否；前端可逐步接入字典下拉，未接入时也会收到稳定的“成人票”默认值。

## 12. 注意事项

- 前端如有 `specName || "成人票"` 的临时兜底，可在确认接口已覆盖当前环境后移除。
- 不要新增或提交 `specCode`；当前契约只使用 `specName`。
- 选择字典项后提交 `dictValue`，不要提交 `dictLabel` 以外的展示元数据或 `dictDataId`。
- 自定义非空规格可以继续提交，接口不会强制替换为字典当前默认项。

## 13. 关联 / 联系人

### 13.1 链接

- **Issue**: [#5185](https://git.1814.love:8443/wx/HL/issues/5185)
- **PR**: [#5191](https://git.1814.love:8443/wx/HL/pulls/5191)
- **Merge commit**: [03ab19b46](https://git.1814.love:8443/wx/HL/commit/03ab19b463be4b00f92848cb348ce6158918e041)

### 13.2 联系人

- **后端负责人**: @yst
