# 备品组合功能（BUG 15）

**日期**：2026-04-20
**PR**：#963 + gateway 路由 hotfix #966
**影响**：管理端备品管理（新菜单）+ 产品编辑 Step3

---

## 业务变化

运营反馈备品单选操作繁琐，常用备品组合（如"亲子研学基础包"=N 个备品打包）每次新建产品都要重复选。

**改造**：支持"备品组合"实体。一个组合 = N 个备品。产品编辑时可引用组合 ID，后端保存时自动展开合并到 supplies。

**展开规则**：同 `suppliesResourceId` 的，**前端手工项优先**（手工传 quantity/unitPrice 覆盖组合项）。自定义项（`suppliesResourceId=null`）不参与去重全保留。

---

## 新增接口：`/admin/supplies-combo/*`

| Method | Path | 说明 | 注解 |
|---|---|---|---|
| POST | `/admin/supplies-combo` | 创建组合 | @Idempotent(5s) |
| PUT | `/admin/supplies-combo/{comboId}` | 编辑（主信息+明细整体替换） | @Lock4j |
| DELETE | `/admin/supplies-combo/{comboId}` | 软删 | @Lock4j |
| GET | `/admin/supplies-combo/page?pageNum=1&pageSize=10&name=xx&status=ENABLED` | 分页 | — |
| GET | `/admin/supplies-combo/{comboId}` | 详情（含 items 展开） | — |
| GET | `/admin/supplies-combo/enabled` | 下拉框（List，不分页，仅 ENABLED） | — |
| PUT | `/admin/supplies-combo/{comboId}/toggle-status` | 启用/禁用切换 | @Lock4j |

### 创建/编辑请求 VO

```ts
{
  "name": "亲子研学基础包",            // @NotBlank, @Size(max=128)
  "description": "适用 3-8 岁儿童",    // @Size(max=512)
  "status": "ENABLED",                  // ENABLED/DISABLED
  "sortOrder": 0,
  "items": [                             // @NotEmpty, 1-50 个
    { "suppliesId": 123, "quantity": 2, "sortOrder": 0 },
    { "suppliesId": 456, "quantity": 1, "sortOrder": 1 }
  ]
}
```

校验：
- `items.suppliesId` 去重（同组合不能重复加）
- 每个 `suppliesId` 必须存在于 `supplies_item` 表且 `status=1`（启用中的备品）

### 响应 VO（详情）

```ts
{
  "comboId": "2046079787104866306",
  "name": "亲子研学基础包",
  "description": "...",
  "status": "ENABLED",
  "sortOrder": 0,
  "itemCount": 2,                        // 明细数量（列表也有）
  "items": [                              // 详情才返回，列表为 null
    {
      "id": "1001", "suppliesId": "123", "quantity": 2, "sortOrder": 0,
      "suppliesName": "睡袋",             // 从 supplies_item 回填
      "categoryCode": "OUTDOOR",
      "billingType": "PER_PERSON",
      "basePrice": 50.00
    },
    ...
  ],
  "createdBy": 1, "createdAt": "...", "updatedAt": "..."
}
```

### 下拉 VO（enabled）

```ts
[
  { "comboId": "123", "name": "亲子研学基础包", "itemCount": 5 },
  { "comboId": "456", "name": "摄影出行包", "itemCount": 3 }
]
```

---

## 产品 Step3 保存扩展

### `PUT /admin/product/item/{id}/route` 请求 VO

`ProductRouteSaveReqVO` 新增：

```ts
{
  // 老字段
  "supplies": [
    { "suppliesResourceId": 123, "suppliesName": "睡袋", "quantity": 2, ... }
  ],

  // 新字段
  "supplyComboIds": [789, 790]           // NEW 可选，引用的备品组合 ID 列表
}
```

**后端展开逻辑**（前端无感）：
1. 读 `supplyComboIds` 调内部 Feign 批量查组合展开
2. 合并到 `supplies`：同 `suppliesResourceId` 手工优先（保留前端 quantity/unitPrice）
3. 自定义项（`suppliesResourceId=null`）不参与去重
4. 展开项会被打上 `fromComboId`（DB 层面审计用）

`SuppliesItem` 内部类新增字段（前端无需传，后端审计）：

```ts
{
  ...
  "fromComboId": null                    // NEW 来源组合ID，NULL=单品，非空=组合展开
}
```

---

## 前端 TODO

### 1. 原生 BUG 排查
运营反馈"备品栏点击后选项框一直存在、单选很麻烦"。推断是产品编辑 Step3 `SuppliesPicker` 组件的下拉关闭逻辑问题，请前端同学排查。

### 2. 新菜单：备品组合管理
在"备品管理"菜单同组新建"备品组合管理"：
- 列表页：分页 + 名称搜索 + 启用状态筛选
- 新建/编辑：表单含名称/描述/状态/排序 + 备品多选 + 每个备品的数量
- 启停按钮
- 删除按钮（软删）

### 3. 产品编辑 Step3 增强
- 备品区块加"从组合选择"按钮
- 点击后弹出下拉选组合（调 `/admin/supplies-combo/enabled`）
- 用户选中后把 `comboId` 追加到 `supplyComboIds` 字段
- 保存时和 `supplies` 一起提交
- 可视化：展开后的备品列表（后端返回的）可以标出"来自组合"徽标（读 `fromComboId`）

---

## 兼容性

- 老数据：产品表 `product_supplies.from_combo_id` 默认 NULL
- 老产品保存（前端不传 `supplyComboIds`）：行为零变化
- 老接口：`ProductRouteSaveReqVO.supplies` 字段保留不动
