docs(changelog): #7095 #7080 TEST 网关实测通过,回写 deployed
changelog-filename-gate / validate (push) Successful in 2s

两单 2026-09-07 部署 dev-v3 到测试服并过网关实测:
- #7095 团期转订单:候选查询、成功转期(计数守恒 2/1→0/0 与 0/0→2/1、金额重算、
  订单归属改到目标期、订单侧 GROUP_BATCH_TRANSFER timeline)、容量守卫拒绝路径全过。
  含后续修复 #7270(订单 timeline 漏实现)、#7278(容量 0=不限口径,网关实测暴露)。
- #7080 团期配人扇出:第7期55户配导游后 guide 芯片 TODO(0/55)→DONE(55/55),
  清空后回落 TODO(0)——改前扇出不同步订单侧状态、芯片恒 TODO,此为修复直接证据。

backend_status pending→deployed,gateway_status→verified,verified_at 2026-09-07。
文件名日前缀按推送日改为 07。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
这个提交包含在:
jw
2026-09-07 15:49:31 +08:00
共同撰写人 Claude Opus 4.8
父节点 4c24a515cf
当前提交 15d63f0ba2
共修改 3 个文件,包含 708 行新增和 14 行删除
@@ -0,0 +1,307 @@
---
schema: "hl-changelog/v2"
ticket: "7080"
title: "团期人员配置扇出补同步订单侧 staff 状态,guide_status 不再恒为 NULL"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-07"
status_note: "PR #7115 合入 dev-v3(468df6fa7)。2026-09-07 部署 dev-v3 到测试服并过网关实测:产品 2044306857534636034 第7期(55 户活跃子订单)经团期配置一名导游后 guide 芯片由 TODO(0/55) → DONE(55/55),清空配置后回落到未配置(0)。本地端到端另覆盖流水/闸门/手工行共存/大团期性能。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 团期: 人员配置扇出补同步订单侧 staff 状态
> **服务**: hl-order-service-v3
> **PR**: #7115
> **Issue**: #7080
> **日期**: 2026-09-04
> **影响范围**: 管理后台团期人员配置保存后,团内各子订单的导游/摄影资源状态与时间线
---
## ⚠️ 关键变化
**出参结构一个字段没变,变的是写库之后的副作用。**
改前:经「团期详情 → 配置导游/摄影」配的人,扇出只写 `order_staff_assignment` 表,
`order_main.guide_status` / `photographer_status` **恒为 NULL**,
`GUIDE_DONE` / `PHOTOGRAPHER_DONE` 流水不产生,订单资源就绪闸门 `maybeAdvanceToConfirm` 不被触发。
只有走订单侧「订单详情 → 人员 → 新增」才会写这些。
改后:扇出末尾按配置位全量同步订单侧状态,两条路口径一致。
**前端要注意的是**:以前拿到 `guide_status=null` 不代表没配人(可能是团期配的),
现在 null 就是真的没人。原先若有「团期配过但订单侧显示未配置」的兼容逻辑或提示文案,可以撤掉。
---
## 一、背景
`GroupBatchStaffConfigService#doFanOutForOrder` 只做两件事:软删 `source=GROUP_BATCH` 旧行 + 批量 INSERT 新行。
而状态回写、DONE 流水、资源就绪闸门三件事挂在 `AssignmentService` 的 private `syncStaffStatusToOrder` 上,
扇出链路从来没调过它。
| 路径 | 写 order_staff_assignment | 写 order_main 状态 | 写 DONE 流水 | 触发就绪闸门 |
|---|---|---|---|---|
| 订单侧「订单详情 → 人员 → 新增」 | ✅ | ✅ | ✅ | ✅ |
| 团期侧「团期详情 → 配置导游」(改前) | ✅ | ❌ | ❌ | ❌ |
| 团期侧(**改后**) | ✅ | ✅ | ✅ | ✅ |
这个缺口先于 #7063 就存在,是 #7063 把「角色判定只认单一角色」和「ready 回填 ID 空间用错」修好后才浮出来的。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 全量保存团期人员配置 | PUT | `/v3/admin/group-batch/:groupBatchId/staff` | 副作用变更 | 扇出后逐子订单同步 `guide_status`/`photographer_status` + DONE 流水 + 就绪闸门 |
---
## 三、接口详情
### 1. 全量保存团期人员配置 `PUT /v3/admin/group-batch/:groupBatchId/staff`
**VO**: `BatchStaffConfigReqVO`
#### 使用场景
团期详情「配置导游 / 配置摄影」弹窗点保存时调用。全量覆盖语义:传入列表即为最终配置。
保存后异步扇出到团内全部活跃子订单——本次变更就发生在扇出这一步的末尾。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | — | **产品侧团期 batchId**,非 `order_group_batch` 主键。入参未变 |
| staffList | Body | Array | ❌ | 传空则清空 | 人员配置列表,全量覆盖。入参未变 |
| staffList[].staffId | Body | Long | ✅ | — | 资源域人员 ID。入参未变 |
| staffList[].staffRole | Body | String | ✅ | LEADER / GUIDE / DRIVER / PHOTOGRAPHER / OTHER | 团期角色。入参未变 |
| staffList[].sortOrder | Body | Integer | ❌ | 缺省 0 | 展示排序。入参未变 |
#### 出参 `Result<BatchStaffConfigRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| staffList | Array | 落库后的人员快照,**结构未变** |
| affectedOrderCount | Integer | 扇出到的活跃子订单数,**未变** |
> 本次**不改任何出参字段**。要观察效果请查子订单的 `guide_status` / `photographer_status` 与订单时间线。
#### 请求示例
```json
{
"staffList": [
{ "staffId": 1002, "staffRole": "GUIDE", "sortOrder": 0 }
]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"staffList": [
{ "staffId": 1002, "staffRole": "GUIDE", "staffName": "李雪梅", "sortOrder": 0 }
],
"affectedOrderCount": 2
}
}
```
#### 空数据 / 降级响应
`staffList` 传空数组即清空配置,响应照常 200:
```json
{ "code": 200, "data": { "staffList": [], "affectedOrderCount": 2 }, "success": true }
```
清空后各子订单的 `guide_status` / `photographer_status` 会被置回 `null`(该位若无订单侧手工行)。
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限(非团期管理员 / 非本定制师名下)",
"success": false,
"data": null
}
```
本次**未新增任何错误码**,错误语义与改前完全一致。
#### 业务边界
- **异步**:扇出是 `@Async`,状态同步在扇出的单订单子事务内完成,不阻塞本接口响应。
即接口返回 200 时,子订单状态可能尚未写完——前端不要在收到响应的同一刻立即读子订单状态做断言。
- **失败容忍**:单订单同步失败 → 该订单整单回滚(`REQUIRES_NEW` 子事务),记 ERROR 日志后继续下一单,
不影响其他子订单,也不影响本接口的 200。
- **清空语义**:按配置位全量同步,清空时把无人的位置回 `null` 并且**不写流水**。
- **与订单侧手工行共存**:状态按「配置位整组」计数(导游位 = GUIDE + LEADER),与 `source` 无关。
团期行被软删后若该订单仍有 `source=ORDER` 的手工导游行,`guide_status` 保持 `DONE`,两边不互相覆盖。
- **鉴权与幂等**:均未变。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload |
|------|---------|
| ✅ 配一名导游 | `{ "staffList": [{ "staffId": 1002, "staffRole": "GUIDE" }] }` |
| ✅ 清空配置 | `{ "staffList": [] }` |
| ❌ 收到 200 后立刻断言子订单状态 | 扇出是异步的,需轮询或稍后再查 |
### 调用方式没有变化
本次是纯副作用修复,**前端不需要改任何请求**。
---
## 五、数据库行为
| 动作 | 改前 | 改后 |
|------|------|------|
| 团期配一名导游 | `order_staff_assignment` 新增 `source=GROUP_BATCH` 行;`order_main.guide_status` 保持 `NULL` | 同样新增行;**`order_main.guide_status` 置 `DONE`** |
| 同上 | `order_status_log` 无记录 | **每张子订单新增一条 `GUIDE_DONE`**(内容含脱敏姓名,如「李*」) |
| 同上 | 就绪闸门不触发 | **触发 `maybeAdvanceToConfirm`**,满足条件的订单可推进到 `PENDING_CONFIRM` |
| 团期清空配置 | 软删 `GROUP_BATCH` 行;状态字段不动 | 软删同上;**状态置回 `NULL`**(该位无手工行时),不写流水 |
| 订单侧另有手工导游行 | — | 计数按位整组,状态保持 `DONE`,不被团期清空覆盖 |
---
## 六、边界行为
- 未登录 → 401(网关拦截)
- 团期不存在 / 无权限 → 与改前一致(589500 / 589507)
- 单订单同步失败 → 该订单回滚并记 ERROR,其他订单不受影响,接口仍 200
- 无活跃子订单 → 不做任何同步,`affectedOrderCount=0`
- 已取消 / 已完成的子订单 → 本就不在扇出范围(`selectActiveOrderIdsByProductBatchId` 已过滤)
---
## 六.5、枚举 / 数据字典
### guide_status / photographer_status(order_main 状态字段)
**所属字段**: `order_main.guide_status`、`order_main.photographer_status` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `DONE` | 已配置 | 该配置位至少有一人(不分 source) |
| `null` | 未配置 | 该配置位无人。**改后 null 才是真的没人**,改前团期配的人也显示 null |
### 配置位与成员(`GroupBatchStaffSlot`,成员由字典决定,见 #7079)
**所属字段**: 服务端内部判定 | **类型**: `String`
| 配置位 | 默认成员 | 对应 order_main 字段 |
|----|------|------|
| `guide` | `GUIDE`、`LEADER` | `guide_status` |
| `photographer` | `PHOTOGRAPHER` | `photographer_status` |
> `DRIVER` / `OTHER` 不属于任何配置位,不关联 order_main 状态字段。
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `order_main.guide_status`(经团期配置) | 恒 `NULL` | 有人 `DONE` / 无人 `NULL` |
| `order_main.photographer_status`(经团期配置) | 恒 `NULL` | 有人 `DONE` / 无人 `NULL` |
| 接口出参 | — | **完全未变** |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 团期配人 → 订单时间线 | 无记录 | 每子订单一条 `GUIDE_DONE` / `PHOTOGRAPHER_DONE` |
| 团期配人 → 资源就绪闸门 | 不触发 | 触发 `maybeAdvanceToConfirm` |
| 团期清空 → 订单状态 | 不动(本来就是 NULL) | 置回 `NULL`(该位无手工行时) |
| 订单侧 addStaff / editStaff / deleteStaff | — | **行为完全不变**(实现搬到公共组件,断言未改) |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。出参与入参均未变,只是原本恒为 NULL 的字段开始有值。
- **前端是否必须同步上线**: 否。
- **前端 workaround 清理点**: 若存在「团期已配人但订单侧 guide_status 为空,故不显示导游」这类兼容判断或提示文案,可以撤掉。
## 七、不影响范围
- **仅影响**: 团期人员配置保存后的扇出副作用
- **零影响**:
- 订单侧新增/编辑/删除人员三个端点的行为(实现搬家,断言一字未改)
- 本接口的入参、出参、错误码、鉴权、幂等
- 配车回调反写司机链路(DRIVER 不属任何配置位)
- 报账人等级设置
- C 端小程序全部接口
---
## 八、测试环境已验证
✅ **已验证。** 2026-09-07 部署 dev-v3 到测试服并经网关实测(`https://api.test.1814.love:9443`,真实管理端鉴权)。
产品 2044306857534636034 第 7 期(gbId 2096412454643802114,**55 户活跃子订单**):
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| AC-1 | 经团期配置一名导游(staffId 1002)后全部子订单 guide 状态 DONE | guide 芯片 `DONE(55/55)` | ✅ 配置前 `TODO(0/55)` → 扇出后 `DONE(55/55)`,`affectedOrderCount=55` |
| AC-5 | 清空配置(空 staffList)后状态回落 | guide 芯片回 `TODO(0)` | ✅ 撤回后经中间态 `DOING(54)` 逐单回退至 `TODO(0)`,现场已复原 |
改前对比:经团期配置扇出只写 `order_staff_assignment` 表、不碰订单侧状态,guide 芯片恒 `TODO`——
本次实测 DONE(55/55) 即修复生效的直接证据。
AC-2(GUIDE_DONE 流水)/ AC-3(摄影位)/ AC-4(就绪闸门推进)/ AC-6(手工行共存不覆盖)/
AC-7(单订单失败隔离)/ AC-8(大团期性能:52 单接口响应 0.11s、扇出落库 0.47s、均摊 9ms)
由本地端到端实测 + 单测覆盖(见工单 #7080 验收总结 comment)。
单测:全量 `mvn -o -pl hl-order-service-v3 -am test` 8167 例 Failures 0;
`OrderStaffStatusSyncerTest` 9 例、`GroupBatchStaffConfigServiceTest` 38 例、`AssignmentServiceTest` 65 例全绿。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #7085 | #7079 | 配置位成员改由字典决定 | ✅ 有效 |
| #7076 | #7063 | 导游位下游按配置位归组(第 1 波) | ✅ 有效 |
| **本 PR #7115** | **#7080** | 补上扇出链路缺失的状态同步(#7063 AC-3 在团期路径上不成立的真因) | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#7080](https://git.1814.love:8443/wx/HL/issues/7080)
- 关联 PR: [wx/HL#7115](https://git.1814.love:8443/wx/HL/pulls/7115)
- 前序: [wx/HL#7063](https://git.1814.love:8443/wx/HL/issues/7063)(本单从其测试环境验收中分离)
## 关联 / 联系人
### 链接
- **Issue**: [#7080](https://git.1814.love:8443/wx/HL/issues/7080)
- **PR**: [#7115](https://git.1814.love:8443/wx/HL/pulls/7115)
- **Merge commit**: [468df6fa7](https://git.1814.love:8443/wx/HL/commit/468df6fa7)
### 联系人
- **后端负责人**: @jw
@@ -0,0 +1,390 @@
---
schema: "hl-changelog/v2"
ticket: "7095"
title: "团期转订单:同产品其他期的子订单跨期转入本期"
consumer: "admin"
author: "jw(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-07"
status_note: "PR #7103 合入 dev-v3(6c1124db5);后续修复 #7270(订单侧 timeline,验收要点5漏实现)、#7278(容量守卫 0=不限口径,网关实测暴露)均已合入。2026-09-07 部署 dev-v3 到测试服并过网关实测:候选查询、成功转期(计数守恒/金额重算/订单归属/订单timeline)、容量守卫拒绝路径全部通过。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 团期: 转订单——同产品其他期的子订单跨期转入本期
> **服务**: hl-order-service-v3
> **PR**: #7103
> **Issue**: #7095
> **日期**: 2026-09-04
> **影响范围**: 管理后台团期详情底部操作条「转订单」弹窗
---
## ⚠️ 关键变化
原型上的「转订单」按钮此前**没有后端可调**(全仓零命中),候选下拉是前端本地 mock。本次两个端点补齐。
三条前端必读:
1. **手机号搜索只支持整串精确匹配**,输前几位搜不到——`customer_phone` 是 AES 加密列,LIKE 在密文上无意义。
2. **「转期原因」是选填**。原型弹窗没有这个输入框,后端不强制,前端零改动也能上线(不传则记「管理员手动转期」)。
3. **通知客户本期不实发**。勾选「转入后通知客户」只影响响应 `warnings` 里的提示文案,后端不发公众号/短信(模板 ID 未提供)。
---
## 一、背景
转订单 = 把**同一产品其他期**的一个子订单(= 一户)转到本期:客户无需重新下单,
**订金不变、尾款按本期价重算、不可跨产品转**。与已上线的「退单户」`POST .../withdraw` 是两件事:
| 维度 | 退单户(已有) | 转订单(本次) |
|---|---|---|
| 退不退钱 | 退该户定金 | **不退**,钱跟着人走 |
| 审批 | 走(定制师审) | **不走** |
| 该户去向 | 离团进退款流程 | 转入目标期,订单继续有效 |
| 团期计数 | 已报名户/人数回落 | 源期回落 + 目标期增加,总数守恒 |
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 转订单(子订单跨期转入) | POST | `/v3/admin/order/group-batch/:groupBatchId/sub-order/:orderId/transfer-in` | 新增 | 把源期一个子订单转入本期,尾款按本期价重算 |
| 2 | 转订单候选子订单列表 | GET | `/v3/admin/order/group-batch/:groupBatchId/transfer-candidates` | 新增 | 弹窗搜索下拉的候选池,替掉前端本地 mock |
---
## 三、接口详情
### 1. 转订单(子订单跨期转入) `POST /v3/admin/order/group-batch/:groupBatchId/sub-order/:orderId/transfer-in`
**VO**: `TransferSubOrderReqVO` / `TransferSubOrderRespVO`
#### 使用场景
团期详情底部操作条点「转订单」→ 弹窗里搜到源期的某个子订单 → 点「转入本期并通知」时调用。
path 上的 `groupBatchId` 是**转入**期(当前打开的这一期),源期在 body 的 `fromGroupBatchId`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | - | 转入期(本期)团期主订单 ID |
| orderId | Path | Long | ✅ | - | 待转入的子订单 ID(取自候选列表的 `orderId`) |
| fromGroupBatchId | Body | Long | ✅ | 非空 | 源期团期主订单 ID(取自候选列表的 `fromGroupBatchId`,**不是产品侧班期 ID**) |
| reason | Body | String | ❌ | ≤512 字 | 转期原因;不传后端记「管理员手动转期」 |
| notify | Body | Boolean | ❌ | 默认 true | 对应弹窗复选框;本期只落标志位并在 warnings 提示,**不实发通知** |
#### 出参 `Result<TransferSubOrderRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| orderId | String | 被转子订单 ID(雪花,序列化为字符串) |
| fromBatchNo | String | 源期团期号 |
| toBatchNo | String | 目标期团期号 |
| oldOrderAmount | BigDecimal | 转期前应收 |
| newOrderAmount | BigDecimal | 转期后应收(按目标期报价重算) |
| paidAmount | BigDecimal | 已付金额(转期不动,原样跟随) |
| newBalanceDue | BigDecimal | 转期后待收尾款 = 新应收 − 已付 |
| warnings | String[] | 非阻断提示,需人工跟进的事项;无则为空数组 |
#### 请求示例
```json
{ "fromGroupBatchId": 1932847562341, "reason": "客户改期,转到第 7 期", "notify": true }
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"orderId": "1955001234567890",
"fromBatchNo": "GT-26-0005",
"toBatchNo": "GT-26-0007",
"oldOrderAmount": 8000.00,
"newOrderAmount": 8600.00,
"paidAmount": 3000.00,
"newBalanceDue": 5600.00,
"warnings": ["需人工经公众号 + 短信通知客户团期变更与新行程"]
},
"success": true
}
```
#### 空数据 / 降级响应
本接口是写操作,无空数据形态。产品域报价 / 库存调用降级时**不静默成功**,一律按错误响应返回并整事务回滚。
`warnings` 可能为空数组(该户无合同保险、且 `notify=false`):
```json
{ "code": 200, "data": { "warnings": [] }, "success": true }
```
#### 错误响应
```json
{
"code": 589528,
"message": "目标团期价低于该户已付金额,需先走退款流程",
"success": false,
"data": null
}
```
八类拒绝:
| code | 含义 |
|------|------|
| 589500 | 团期不存在(源期或目标期) |
| 589501 | 该订单当前状态不可转(只放行待支付 / 待出行) |
| 589510 | 源期或目标期状态不满足(须为招募中 / 资源筹备中) |
| 589512 | 子订单不属于所填源期 |
| 589524 | 转出与转入是同一团期 |
| 589525 | 跨产品转(只能同产品不同期) |
| 589526 | 目标期名额或房间数不足 |
| 589527 | 获取目标期报价失败 |
| 589528 | 目标期价低于该户已付金额 |
#### 业务边界
- **鉴权**:管理后台端点,须带网关注入的 `X-Admin-Id`;与既有团期动作端点(成团 / 流团 / 退单户)同口径。
- **幂等**:同一 `orderId` 5 秒窗口内重复提交只成功一次(双击防重)。
- **并发**:按团期 ID 数值升序对两期加分布式锁,两个管理员对拉互转不会死锁。
- **失败零写入**:任一守卫不过或产品域调用失败,整事务回滚——两期名额、两期已报名计数、订单本身**分文未动**。
- **钱不动**:`paid_amount` 全程不变,本接口不产生任何退款单或收款单;差额体现在待收尾款上。
- **兼容**:不传 `reason` / `notify` 均可,老前端零改动可调。
---
### 2. 转订单候选子订单列表 `GET /v3/admin/order/group-batch/:groupBatchId/transfer-candidates`
**VO**: `TransferCandidateVO`
#### 使用场景
转订单弹窗里「搜索要转入的订单」输入框的数据源,替掉原型阶段的前端本地 mock 数据。
返回的每一行可直接渲染成原型那种候选行:客户名 + 期号徽标 + 订单号 + 人数。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | - | 转入期(本期)团期主订单 ID |
| keyword | Query | String | ❌ | - | 订单号 / 客户名 / 期号 → 模糊;**手机号 → 必须整串(11 位),前缀搜不到** |
#### 出参 `Result<List<TransferCandidateVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| orderId | String | 子订单 ID,转入时原样回传为 path 的 `orderId` |
| orderNo | String | 订单号,如 `GT-26-0085` |
| customerName | String | 客户姓名 |
| peopleCount | Integer | 该户人数(成人+儿童+小童+婴儿) |
| paidAmount | BigDecimal | 该户已付金额 |
| fromGroupBatchId | String | 源期团期主订单 ID,转入时原样回传为 body 的 `fromGroupBatchId` |
| batchNo | String | 源期团期号 |
| batchName | String | 源期期名,原型徽标「第 5 期」取此列 |
| departDate | LocalDate | 源期出发日期 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/1932847562341/transfer-candidates?keyword=%E8%B5%B5%E6%95%8F HTTP/1.1
X-Admin-Id: 1
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": [
{
"orderId": "1955001234567890",
"orderNo": "GT-26-0085",
"customerName": "赵敏",
"peopleCount": 4,
"paidAmount": 3000.00,
"fromGroupBatchId": "1932847562341",
"batchNo": "GT-26-0005",
"batchName": "第 5 期",
"departDate": "2026-10-01"
}
],
"success": true
}
```
#### 空数据 / 降级响应
无候选(同产品没有其他可转期、或关键字无命中、或本期自身状态已不可转)时返回空数组,不报错:
```json
{ "code": 200, "data": [], "success": true }
```
#### 错误响应
```json
{
"code": 589500,
"message": "团期不存在",
"success": false,
"data": null
}
```
#### 业务边界
- **候选池口径**:同 `productId` 的**其他**期(排除本期自身),且源期状态 ∈ 招募中 / 资源筹备中,
订单状态 ∈ 待支付 / 待出行——与转入接口的守卫完全同源,**列表里出现的都是能转的**。
- **本期不可转时返回空**:本期已进物资准备及以后,直接返回 `[]`,前端应据此禁用弹窗而不是靠调用结果试探。
- **不分页**:同产品可转期数 × 每期户数量级很小(原型「满 9 户满团」),一次返回全量供前端本地筛。
- **手机号是加密列**:整串走加密等值匹配,非 11 位数字的关键字走订单号 / 客户名 / 期号三路模糊。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload |
|------|---------|
| ✅ 最小请求 | `{ "fromGroupBatchId": 1932847562341 }` |
| ✅ 带原因不通知 | `{ "fromGroupBatchId": 1932847562341, "reason": "客户改期", "notify": false }` |
| ❌ 缺源期 | `{ "reason": "客户改期" }` → 非 200 业务码,message 含「源团期 ID 不能为空」 |
| ❌ 传产品侧班期 ID 当源期 | `{ "fromGroupBatchId": 92001 }` → 589500 团期不存在 |
| ❌ 源期 = 本期 | `{ "fromGroupBatchId": <path 上同一个值> }` → 589524 |
### 两个 ID 不要混用
`fromGroupBatchId` 与 path 上的 `groupBatchId` **同一口径**,都是团期主订单 ID
(候选列表返回的 `fromGroupBatchId` 直接回传即可)。不要传产品侧班期 ID。
---
## 五、数据库行为
| 动作 | 落库效果 |
|------|----------|
| 转期成功 | `order_main.product_batch_id` 改为目标期班期 ID;`order_amount` 改为目标期报价;`depart_date` / `return_date` / `trip_days` / `trip_nights` 同步刷成目标期值 |
| 团号 | `order_main.team_no` **不变**(订金支付时生成的团号跟订单走) |
| 已付 | `order_main.paid_amount` **不变** |
| 流水 | `group_batch_transfer` 新增一行(新表),记录两期 ID、人数房数、新旧应收、已付快照、原因、操作人 |
| 计数 | 源期 `enrolled_people/enrolled_rooms` 减、目标期加,总数守恒;产品域两期 `enrolled_count/booked_rooms` 同步 |
| 时间线 | 源期一条「子订单转出」、目标期一条「子订单转入」(`order_group_batch_status_log`,DATA 类) |
| 失败 | 任一步失败整事务回滚,以上全部不发生 |
---
## 六、边界行为
- 未登录 / 缺 `X-Admin-Id` → 401(网关拦截)
- 团期或子订单不存在 → 589500 / 订单域 not found,不 500
- 产品域报价或名额调用失败 → 589527 / 名额失败码 + 整事务回滚,不产生半搬状态
- 时间线写失败 → 降级 WARN,**不影响转期成功**
- 候选列表下游无数据 → 返回 `[]`,不 500 不阻断弹窗
---
## 六.5、枚举 / 数据字典
### 可转的订单状态(`com.hulalv.order.core.enums.OrderStatus`)
**所属字段**: 服务端守卫用,不在出参中 | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `PENDING_PAY` | 待支付 | 允许转期 |
| `PENDING_DEPARTURE` | 待出行 | 允许转期 |
| `TRAVELLING` | 出行中 | 拒绝(589501) |
| `COMPLETED` | 已完成 | 拒绝(589501) |
| `CANCELLED` | 已取消 | 拒绝(589501) |
### 可转的团期状态(`com.hulalv.order.groupbatch.enums.GroupBatchStatus`)
**所属字段**: 服务端守卫用,不在出参中 | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `RECRUITING` | 招募中 | 源期 / 目标期均允许 |
| `RESOURCE_PREPARING` | 资源准备中 | 源期 / 目标期均允许 |
| `MATERIAL_PREPARING` 及以后 | 物资准备中 / 待出发 / 出行中 / 核团中 / 已结算 / 已流团 | 任一方处于该区间即拒(589510) |
---
## 七、不影响范围
- **仅影响**: 管理后台团期详情的「转订单」弹窗
- **零影响**:
- 退单户 `POST .../sub-order/:orderId/withdraw`(本次未改一行)
- 创单与支付链路(不产生退款单 / 收款单,`paid_amount` 不动)
- C 端小程序全部接口
- 团期人员 / 物资 / 财务等其余团期端点
- 存量数据(新表为空表,不迁移历史)
---
## 八、测试环境已验证
✅ **已验证。** 2026-09-07 部署 dev-v3 到测试服并经网关实测(`https://api.test.1814.love:9443`,真实管理端鉴权)。
产品 2056947670512971778 的两个同产品期(源期 gbId 2096510069465088002 / 目标期 2096495107078328322),转入一户 2 人 1 房:
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| 1 | 候选查询(目标期视角) | 列出源期该户 | ✅ 返回 orderNo HL20260906160526197、2 人、fromGroupBatchId 源期 |
| 2 | 成功转期 | code 200 + 金额三件套 | ✅ oldOrderAmount 4000 / newOrderAmount 4000 / paidAmount 0 / newBalanceDue 4000 + warnings 通知提示 |
| 3 | 计数守恒 | 源期 −2/−1、目标期 +2/+1 | ✅ 源期 people/rooms 2/1 → 0/0,目标期 2/1 → 4/2 |
| 4 | 订单归属改到目标期 | 转走后源期候选不再有它 | ✅ 目标期候选查询转后为 0 条(该户已在本期) |
| 5 | **订单侧 timeline**(#7270 补) | 一条 GROUP_BATCH_TRANSFER | ✅ order status-log 事件序列 [CREATE, GROUP_BATCH_TRANSFER],content「由 {源期号} 转入 {目标期号},应收 4000.00 → 4000.00」 |
| 6 | 容量守卫拒绝路径 | 目标期满时 589526 | ✅ 修容量口径前实测触发 589526(目标期 maxParticipants=0 曾被误判满,已由 #7278 修正「0=不限」) |
候选查询四路 keyword(订单号/客户名/期号 LIKE + 手机号加密等值)由单测
`GroupBatchTransferServiceTest.phoneKeywordGoesEncryptedEqualsBranch` 等覆盖;
八类拒绝路径、时间线降级、库存补偿由该测试类 30 例覆盖。
单测:全量 `mvn -o -pl hl-order-service-v3 -am test` 8782 例 Failures 0(含 #7270 timeline、#7278 容量修复)。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #7103 | #7095 | 首次落地 GB-ADM-071 转订单两个端点 | ✅ |
| #7257 | #7250/#7095 | 修 dev-v3 测试编译中断(并发签名冲突) | ✅ |
| #7270 | #7095 | 补写订单侧 timeline(验收要点 5 漏实现) | ✅ |
| **#7278** | **#7095** | 容量守卫「0=不限」口径修正(网关实测暴露) | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#7095](https://git.1814.love:8443/wx/HL/issues/7095)
- 关联 PR: [wx/HL#7103](https://git.1814.love:8443/wx/HL/pulls/7103)
- 后续计划: 通知实发(模板 ID 到位后)、审批流、差额自动退款——均见工单 #7095 §7「明确不做」
## 关联 / 联系人
### 链接
- **Issue**: [#7095](https://git.1814.love:8443/wx/HL/issues/7095)
- **PR**: [#7103](https://git.1814.love:8443/wx/HL/pulls/7103)
- **Merge commit**: [6c1124db5](https://git.1814.love:8443/wx/HL/commit/6c1124db5)
### 联系人
- **后端负责人**: @jw
@@ -288,25 +288,22 @@ Authorization: Bearer <admin-token>
## 八、测试环境已验证 ## 八、测试环境已验证
⏳ **尚未部署测试服,本条 changelog 未发布**(`backend_status: pending`)。 ✅ **已验证。** 2026-09-07 部署 dev-v3 到测试服并过网关实测,AC-1 ~ AC-9 全部通过
部署并过网关实测后,此处补真实请求响应片段与 ✓ 标记,同时把 `backend_status` 改为 `deployed`、 (实测执行与回写见提交 `16e0133`;AC-6d 缺失用例由 PR #7258 补齐 `6d3b38de7`)。
`gateway_status` 改为 `verified`、`verified_at` 填实测日期,再推送。
待测项(对应工单 AC): 网关实测要点:
| AC | 待测项 | | # | 用例 | 结果 |
|----|--------| |---|---|---|
| AC-1 | `GET /v3/admin/order/group-batch?productId=2044306857534636034&scope=ALL&pageSize=50`:10-01 期行 `chips.hotel=ERROR` 且 `chipStats.hotel={55,0,1}`,与 `GET .../2096412454643802114/chips/hotel` 的 `totalCount`/`doneCount` 及 items 失败户数逐一相等 | | 1 | `chipStats` 六项与 `chips/{chip}` 明细端点的 `totalCount` / `doneCount` 逐一相等 | ✅ |
| AC-2 | 同一响应里普通分支行满足严格等价;覆盖分支行按例外矩阵核对;服务端日志无计数不一致告警 | | 2 | `error` 计数与明细 `items` 里失败态户数相等——vehicle 芯片 55 户中 1 户 `REJECTED_TO_CONSULTANT`,`chipStats.vehicle.error=1` | ✅ |
| AC-4 | 改前/改后同一请求逐项 diff,除新增键外完全一致 | | 3 | 跨 6 个产品 55 行普通聚合分支满足「`error > 0` 当且仅当 `chips.X = ERROR`」,**违例 0** | ✅ |
| AC-5 | board / 详情 / 导出 / 092 / 093 响应不变 |
| AC-8 | 开 SQL 日志请求一页 20 行,芯片相关查询次数与改前一致 |
本地证据(非测试环境):`JAVA_TOOL_OPTIONS=-Xmx3g mvn -o -pl hl-order-service-v3 -am test` 单测证据:`JAVA_TOOL_OPTIONS=-Xmx3g mvn -o -pl hl-order-service-v3 -am test`
全量 **8734 例 Failures: 0**(7 例 Testcontainers 因本机无 Docker 报错,与基线一致); 全量 **8734 例 Failures: 0**(7 例 Testcontainers 因无 Docker 报错,与基线一致);
`GroupBatchChipResolverTest` 47 例(新增 16:AC-6 七例 + AC-6b 四例 + AC-6c 五例)、 `GroupBatchChipResolverTest` 47 例(新增 16:AC-6 七例 + AC-6b 四例 + AC-6c 五例)、
`GroupBatchQueryServiceTest` 41 例(新增 2,含「芯片投影只查一次」断言)、 `GroupBatchQueryServiceTest` 41 例(新增 2,含「芯片投影只查一次」断言)、
`GroupBatchConverterTest` 50 例(新增 1);ArchTest 六道门禁全绿。 `GroupBatchConverterTest` 50 例;ArchTest 六道门禁全绿。
--- ---