# 行程编排集合地/解散地：行政区 → 高德 POI 精准定位

**类型**: 后端 FEAT(字段扩展+读路径增强) + 前端 picker 切换 + regeo 调用
**关联**: 工单 #1563 / PR #1570 + follow-up 工单 #1574 / PR #1575（镇级行政区字段）
**日期**: 2026-04-30
**前端处理者**: mmg
**影响范围**: 管理后台产品编辑页 Step 2「行程编排」→ DAY X 的「开始 / 结束」字段（即 `gatherPlace` / `dismissalPlace`）

> ⚠️ **2026-04-30 16:25 update（PR #1575）**：行政区精度要求**到镇/街道级**。POI 自身只到区县，前端选 POI 后**必须额外调高德 regeo (逆地理编码)** 拿镇级数据。详见下方 schema 和 mmg 前端 mapping。

---

## 业务目的

集合地 / 解散地从「行政区」(如「海拉尔区」)升级为「精准 POI」(如「海拉尔东山国际机场 / 海拉尔火车站」)。前端 `gatherPlace/dismissalPlace` 选择器从行政区下拉换成**高德 POI Autocomplete**。

途经路线 (`routePoints`) 不在本次范围内（已是 lng/lat 结构）。

---

## JSON Schema 升级

### 新格式（POI 精准定位 + 镇级行政区，2026-04-30 起新数据按这个落库）

```jsonc
{
  "name": "海拉尔东山国际机场",                          // POI 名称
  "address": "内蒙古呼伦贝尔市海拉尔区机场路",             // 详细地址
  "location": { "lng": 119.825, "lat": 49.205 },          // POI 精确坐标
  "poiId": "B0FFGTEST1",                                   // 高德 POI ID
  "adcode": "150702",                                      // 区县编码（POI 自带）
  "provinceName": "内蒙古自治区",                          // 省（regeo addressComponent.province）
  "cityName": "呼伦贝尔市",                                // 市（regeo addressComponent.city）
  "districtName": "海拉尔区",                              // 区（regeo addressComponent.district）
  "township": "东山街道",                                  // 镇/街道（regeo addressComponent.township）
  "towncode": "150702002000",                              // 镇编码（regeo addressComponent.towncode）
  "fullPath": "内蒙古自治区/呼伦贝尔市/海拉尔区/东山街道"   // 4 段（前端拼）
}
```

**关键点**：POI 本身（高德 PlaceSearch 返回）只到区县级 `adname`，**到镇级必须前端额外调高德 regeo (`AMap.Geocoder.getAddress`)** 拿 `addressComponent.{township, towncode}` 后 merge 到 gatherPlace JSON。

### 老格式（行政区，2026-04-30 之前已落库的存量数据，零迁移继续工作）

```jsonc
{
  "name": "海拉尔区",
  "level": "district",
  "adcode": "150702",
  "fullPath": "内蒙古自治区/呼伦贝尔市/海拉尔区",
  "levelName": "区/县"
}
```

后端**同时兼容三种格式**：
- 新 POI 精准定位（含 `location`）
- 老行政区无 `center`（依赖 `adcode` 反查）
- 更早期行政区带 `center: 'lng,lat'`（直接 split）

---

## mmg 前端要做的事（hl-ui 仓库）

### 1. 替换 picker 组件

`src/views/product/edit/Step2*.vue` 行程编排 DAY X 块里，「开始」「结束」两个 `n-select` / 行政区 picker，**替换成高德 POI Autocomplete 输入框**。

参考高德 JS API：[`AMap.Autocomplete` + `AMap.PlaceSearch`](https://lbs.amap.com/api/javascript-api-v2/documentation#autocomplete)；输入关键字 → 用户选择 POI → 把 POI 数据组装成上面的新 JSON 结构。

### 2. 落库 JSON 字段映射（高德 POI → 后端字段）

```js
// 选 POI 后，先用 location 调 regeo 拿镇级行政区，再 merge
function poiToPlace(poi, callback) {
  const geocoder = new AMap.Geocoder();
  geocoder.getAddress([poi.location.lng, poi.location.lat], (status, result) => {
    if (status !== 'complete') {
      // 降级：regeo 失败时退回到 POI 自带数据（无 township/towncode）
      callback({
        name: poi.name,
        address: poi.address || '',
        location: { lng: Number(poi.location.lng), lat: Number(poi.location.lat) },
        poiId: poi.id || '',
        adcode: poi.adcode || '',
        cityName: poi.cityname || '',
        districtName: poi.adname || ''
        // 此时 provinceName/township/towncode/fullPath 留空，后端兼容
      });
      return;
    }
    const ac = result.regeocode.addressComponent;
    callback({
      name: poi.name,
      address: poi.address || result.regeocode.formattedAddress,
      location: { lng: Number(poi.location.lng), lat: Number(poi.location.lat) },
      poiId: poi.id || '',
      adcode: poi.adcode || ac.adcode,
      provinceName: ac.province || '',
      cityName: (ac.city && ac.city.length) ? ac.city : '',  // 直辖市 city 是空数组
      districtName: ac.district || '',
      township: ac.township || '',
      towncode: ac.towncode || '',
      fullPath: [ac.province, ac.city, ac.district, ac.township].filter(s => s && s.length).join('/')
    });
  });
}
```

### 3. PUT 行程接口

接口路径不变：`PUT /admin/product/item/{productId}/itinerary`，`days[].gatherPlace` / `days[].dismissalPlace` 用上面的新 JSON 结构。后端字段全保留（向后兼容）。

### 4. 详情回显

`GET /admin/product/item/{productId}` 的 `data.itinerary[i].gatherPlace` / `dismissalPlace`：
- **新数据**：包含 `name / address / location / poiId / adcode / cityName / districtName`（7 字段全在）
- **老数据**：包含 `name / level / adcode / fullPath / levelName`（5 字段）
- 前端组件渲染时按字段存在性切换：有 `location.lng` 就用 POI 模式回显；否则按老行政区回显（兼容期）

---

## 后端测试服 round-trip（已完成）

测试服部署：commit `982558f9` 已部署 hl-product-service-v2 + hl-order-service-v2 双实例。

```bash
# 1) 老格式产品 GET 详情 → PlaceItem 老字段完整回显（零回归）
GET /admin/product/item/2047268852147875842
→ day1.gatherPlace = {"name":"海拉尔区","level":"district","adcode":"150702","fullPath":"内蒙古...","levelName":"区/县"}  ✅

# 2) PUT 新 POI 格式 → GET 详情回显 7 字段全在
PUT /admin/product/item/{draftId}/itinerary
  body: { days:[{ gatherPlace:{name,address,location:{lng,lat},poiId,adcode,cityName,districtName} }] }
→ HTTP 200 「行程保存成功」
GET → day1.gatherPlace 7 字段完整含 location.lng=119.825/lat=49.205 ✅

# 3) 同 day 混合格式 (gather=POI, dismiss=老) 兼容
→ 两边各自字段完整回显，互不干扰 ✅
```

---

## 后端改动（参考）

13 个文件 +846 / -48，PR #1570：

- `InternalProductDetailVO.PlaceItem` + `ProductDetailVO.PlaceItem` (Feign 镜像) **扩展 7 个 POI 字段 + 嵌套 Location 静态类**，旧字段保留兼容
- `MpProductRouteMapService.parsePlaceVO`：路径优先级 `location → center → adcode 反查`
- `AmapDrivingService.parseCenterToLngLat`：同上优先级
- `TeamReportService.parseCityFromSnapshot`：取城市优先 `cityName/districtName`，回退 `fullPath`
- VO 注释批量升级三种格式说明
- 单测 +13（含 Hutool BeanUtil 嵌套 Map → Location 运行时验证）
- DB schema 不变（仍是 JSON 列）
- 老数据零迁移

---

## 风险 / 兼容性

- **零回归承诺**：老行政区数据继续按原行为工作（路线图 origin/destination / 每日里程 / 团报城市 全部正常）
- **新旧混合期**：同一产品 itinerary 不同 day 可以混存新 POI / 老行政区结构，互不影响
- **降级**：POI 缺 `location` 时自动回退到 `adcode` 反查中心点（高德 API），与现行 `MpProductRouteMapService` 老路径一致

---

## 联调建议

mmg 前端改完后：
1. 本地起 mock 服务返回新 POI 结构，验证组件回显
2. 接到测试服 `api.test.1814.love:9443` 后端，PUT → GET round-trip 验证字段对齐
3. 看路线图 / 团报 / 每日里程，确认坐标用的是 POI 精确坐标（比之前 adcode 中心点更精准）
