# 修复: 产品「补充信息」费用包含「根据行程自动生成」漏景区/活动/酒店

**类型**: 后端 BUG 修复
**关联**: 工单 #1567 / PR #1568
**日期**: 2026-04-30
**前端处理者**: mmg
**影响范围**: 管理后台「产品编辑 → 补充信息 → 费用说明 →『根据行程自动生成』」按钮

---

## 现象

点击「根据行程自动生成」后,前端展示的「费用包含」列表项严重不全:

- 同类型节点只展示**第一个**:产品有 6 个景区只回 1 条「门票」
- 酒店**完全不出现**

测试样本:产品 id=`2045018534152478721`「游牧的森林·南线经典」(用户原图)
- 旧逻辑只返回 2 条:活动「← ATV 全地形越野穿越」 + 门票「← 白桦林景区」
- 修复后返回 35 条:6 门票 + 2 活动 + 27 住宿(全部景区 / 全部活动 / 全部酒店)

## 根因

`hl-product-service-v2` `ProductValidationService.feeDeductionPreview()` 旧实现:

1. `Set<String> deducted` 按 `feeLabel`(门票/活动/住宿/餐饮/交通)去重 → 同 feeType 只保留**第一条**
2. 完全没查 `product_day_hotel` 表(酒店数据已早期迁离 `product_itinerary_node`,但 fee-deduction-preview 没同步更新)

## 修复

`feeDeductionPreview()` 重写为:

- **不再按 feeLabel 去重**,每个 `ProductItineraryNodeDO` 都生成 1 条 `FeeDeductionPreviewVO`(前提 `getFeeLabel() ≠ null`)
- **追加查 `dayHotelService.selectByProductId(productId)`**,全部酒店并入结果,`feeType="住宿"`,`sourceNodeName=酒店名`
- 节点按 `dayId + sortOrder` 排序,酒店按 `dayNumber + sortOrder` 排序
- 整体按 `门票 → 活动 → 住宿 → 餐饮 → 交通` 分组排序
- NPE 兜底:`allNodes / allHotels` null → 空集合;`hotelName / dayId / sortOrder` null → 排序用 `MAX_VALUE` 兜底

## 影响接口

| 方法 | 路径 | 改动 |
|------|------|------|
| GET  | `/admin/product/item/{id}/fee-deduction-preview` | 返回项数从去重后的 N 条 → 全节点 + 全酒店,字段不变 |

请求签名完全不变,响应字段不变,只是 `data` 数组**变长且含酒店**。

## 响应示例

```jsonc
GET /admin/product/item/2045018534152478721/fee-deduction-preview
{
  "code": 200,
  "data": [
    { "feeType": "门票", "name": "门票", "source": "AUTO", "sourceNodeName": "黑山头", "icon": null },
    { "feeType": "门票", "name": "门票", "source": "AUTO", "sourceNodeName": "白桦林景区", "icon": null },
    { "feeType": "门票", "name": "门票", "source": "AUTO", "sourceNodeName": "莫日格勒河", "icon": null },
    /* ... 共 6 条门票 */
    { "feeType": "活动", "name": "活动", "source": "AUTO", "sourceNodeName": "ATV 全地形越野车穿越", "icon": null },
    { "feeType": "活动", "name": "活动", "source": "AUTO", "sourceNodeName": "驯鹿拉车体验", "icon": null },
    { "feeType": "住宿", "name": "住宿", "source": "AUTO", "sourceNodeName": "海拉尔洲季酒店", "icon": null },
    /* ... 共 27 条住宿 */
  ]
}
```

## 前端要做的改动

### 已是 `v-for` 列表渲染 — 0 改动

`hl-ui/src/views/product/edit/components/supplement/CostDescPanel.vue` 第 109-119 行已经是
`v-for="(item, idx) in includedCosts"` 网格布局,每张 card 显示 `feeType + name + ← sourceNodeName`,**新方案完美适配**:
- 6 个景区会渲染 6 张「门票」card,每张带不同景区名(如「门票 ← 黑山头」「门票 ← 白桦林景区」…)
- 27 个酒店会渲染 27 张「住宿」card,每张带不同酒店名

UI 上多张同 feeType card 紧邻成一组排开,符合用户期望「看到所有节点」。

### 如果想做「按 feeType 聚合显示」(可选)

如果前端反馈「列表太长,希望同 feeType 合并为一张 card,内部分行列出来源」,可以前端 `Object.groupBy(includedCosts, c => c.feeType)` 自行聚合,无需后端改动。

## 测试覆盖

`mvn test -pl hl-product-service-v2`:1026/1026 全绿。
新增/增强 6 条 `@ExtendWith(MockitoExtension.class)` 单测覆盖:多景区 / 多活动 / 含酒店 / 空产品 / 未知类型(FREE/NOTE/SERVICE/CUSTOM)/ 忽略类型。

测试服 round-trip 验证(产品 `2045018534152478721`):返回 35 条,包含 6 景区 + 2 活动 + 27 酒店,排序正确,**用户原图问题修复**。

## 不动

- 数据库表结构不变(只是查询逻辑扩展)
- VO 字段不变(只是 `data` 数组长度)
- `feeIgnoredTypes` 过滤逻辑保留,被忽略的 feeType 仍会从结果中剔除

## 部署状态

- ✅ Deploy Panel 已部署测试服 hl-product-service-v2(2026-04-30 16:35)
- 正式环境暂未发布(等 dev → main release PR)
