# 修复: 费用包含按 feeType 合并为单条 name 拼接

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

---

## 演进

| PR | 行为 | 产品 2045018534152478721 返回数 |
|---|---|---|
| #1568 | 修复漏景区/活动/酒店,每节点 1 条+追加酒店 | 35 条 |
| #1577 | 按名字去重,name 改具体名 | 17 条 |
| **#1579 本次** | **按 feeType 合并为单条,name 拼接所有具体名** | **3 条** |

用户期望:每种 feeType 一条记录,中间 label 显示完整字符串。

## 用户原文

> 「门票：黑山头，莫日格勒河  门票是一条数据」
> 「其他也是」(指活动/住宿/餐饮/交通也都按 feeType 合并)

## 设计

```
name = "{feeType}：{name1，name2，...}"   (中文「：」前缀 + 中文「，」分隔)
```

| 字段 | 值 |
|---|---|
| `feeType` | 门票 / 活动 / 住宿 / 餐饮 / 交通(chip 用) |
| `name` | `"门票：黑山头，白桦林景区，莫日格勒河，..."` 完整拼接串 |
| `sourceNodeName` | **null**(避免重复) |
| `icon` | **null**(PR #1573 行为不变) |

## 影响接口

| 方法 | 路径 | 改动 |
|------|------|------|
| GET | `/admin/product/item/{id}/fee-deduction-preview` | 数组**长度从 N 条压缩到 ≤ 5 条**(每 feeType 1 条),`name` 字段值变为完整拼接串,`sourceNodeName` 设为 null |

请求签名完全不变,响应字段不变。

## 响应示例

```jsonc
GET /admin/product/item/2045018534152478721/fee-deduction-preview
{
  "code": 200,
  "data": [
    {
      "feeType": "门票",
      "name": "门票：黑山头，白桦林景区，莫日格勒河，莫日格勒河断崖，呼伦湖，阿尔山火车站",
      "source": "AUTO",
      "sourceNodeName": null,
      "icon": null
    },
    {
      "feeType": "活动",
      "name": "活动：ATV全地形越野车穿越，驯鹿拉车体验",
      "source": "AUTO",
      "sourceNodeName": null,
      "icon": null
    },
    {
      "feeType": "住宿",
      "name": "住宿：海拉尔洲季酒店，呼伦贝尔香格里拉大酒店，额尔古纳玖成万豪水汇智能酒店，额尔古纳豪星酒店，额尔古纳白桦大酒店，海拉尔海棠酒店，满洲里饭店（百年俄式），敖根栖息地，阿尔善国际维景度假温泉酒店",
      "source": "AUTO",
      "sourceNodeName": null,
      "icon": null
    }
  ]
}
```

## 实现要点

```java
// 1. LinkedHashMap<feeType, LinkedHashSet<name>> 双保序去重收集
Map<String, LinkedHashSet<String>> feeTypeToNames = new LinkedHashMap<>();
// (扫节点 + 扫酒店,按 feeType 分桶)

// 2. feeIgnoredTypes 在 map 阶段移除 entry(效率优于 result.removeIf)
feeTypeToNames.keySet().removeAll(ignored);

// 3. 排序(门票/活动/住宿/餐饮/交通)+ 拼接 + 输出
return feeTypeToNames.entrySet().stream()
    .filter(e -> !e.getValue().isEmpty())
    .sorted(byFeeTypeOrder)
    .map(e -> FeeDeductionPreviewVO.builder()
        .feeType(e.getKey())
        .name(e.getKey() + "：" + String.join("，", e.getValue()))
        .source(SOURCE_AUTO)
        .sourceNodeName(null)
        .build())
    .toList();
```

## 前端要做的改动

**0 改动**。前端模板 `{{ item.name }}` 直接渲染就显示 `"门票：黑山头，白桦林景区，..."` 完整字符串。

`sourceNodeName=null` 后,前端 `v-if="item.sourceNodeName"` 来源标签自动不渲染,UI 自然更清爽。

## 测试覆盖

`mvn test -pl hl-product-service-v2`: **1046/1046 全绿**。

10 现有断言更新(从「按 name 多条」改为「按 feeType 一条」)+ 1 新增 `feeDeductionPreview_mixedTypes_groupedByFeeType` 混合类型分组测试。

测试服 round-trip(产品 `2045018534152478721`)实测:
- 总计 **3 条**(门票/活动/住宿 各一)
- 每条 `name` 是 `"{feeType}：{name1，name2，...}"` 完整串
- `sourceNodeName` 全 null
- `feeType` 排序正确(门票 → 活动 → 住宿)
- `icon` 全 null

## 部署状态

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