--- schema: "hl-changelog/v2" ticket: "7095" title: "团期转订单:同产品其他期的子订单跨期转入本期" consumer: "admin" author: "jw(GIT)" change_type: "新增接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "cc07a40d" target_release: "" verified_at: "2026-09-08" status_note: "PR #7103 合入 dev-v3(6c1124db5),后续修复 #7270(订单侧 timeline)、#7278(容量守卫 0=不限)均已合入,2026-09-07 部署测试服过网关实测(候选查询/成功转期/容量守卫拒绝全通过)。前端待接:团期详情底部「转订单」弹窗接真——候选下拉替掉本地 mock 改调 GET transfer-candidates(手机号仅整串精确匹配,加密列),转期调 POST transfer-in(body fromGroupBatchId,转期原因选填,勾选通知仅影响 warnings 文案不实发)。决策:入 backlog 排在 #7067 U3-U7 与 #7244 之后统一汇总审派发。本条保持 pending。" 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` | 字段 | 类型 | 说明 | |------|------|------| | 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>` | 字段 | 类型 | 说明 | |------|------|------| | 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": }` → 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