--- schema: "hl-changelog/v2" ticket: "7513" title: "团期转期同步回写归属两列(group_batch_id 不再停留源期)+ 在途退单转期守卫 589589" consumer: "admin" author: "jw(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "pending" frontend_owner: "mmg" frontend_ref: "" target_release: "" verified_at: "2026-09-12" status_note: "转订单端点的请求/响应结构一行未动,前端无需改造即可继续运行。两处需要知道:①转期后订单详情的团期编号/名称会正确显示为【目标期】(此前错显源期,属修复非回归);②新增拒绝码 589589——该户有在途退单审核时转期会被拒,前端需按普通业务错误弹提示,文案后端已给全。" updated_at: "2026-09-12" base: "dev-v3" --- # order-v3: 团期转期同步回写归属两列 **服务**: hl-order-service-v3 **PR**: #7589 **Issue**: #7513 --- ## ⚠️ 关键变化 🟢 **请求与响应结构零改动。** `transfer-in` 的 path、body、`Result` 的字段名/类型/null 语义一行没动,前端**不改任何代码**即可继续工作。 🔴 **转期后订单的团期归属显示会变**:此前转期只改了 `order_main.product_batch_id`、没改 `order_main.group_batch_id`,导致同一订单两列指向两个不同的期。凡按 `group_batch_id` 取团期编号/名称的展示位(订单详情、聊天会话归属等)此前**显示的是源期**。本次修复后显示**目标期**。**这是纠错,不是回归**——如果 QA 对比历史截图发现团号变了,是本次修好了。 🔴 **新增拒绝码 `589589`**:该户存在**在途(PENDING)退单审核**时,转期会被直接拒绝。 ``` {"code": 589589, "message": "该户有退单审核在途,请先在原团期驳回或完成审核后再转期", "success": false} ``` **前端按普通业务错误弹 `message` 即可**,文案已含出口指引,无需自造提示。 **为什么加**:此前「先提退单 → 被转走 → 审批通过」这条路能跑通,但跑出来是**错账**——按源期阶段算退款、时间线记源期,而人数扣减按 `product_batch_id` 扣到目标期,钱与账落在两个团,接口却返回成功。修好归属两列后,这种审批会在归属校验处抛 `589512`,那张申请**再也批不掉**;故把冲突提前到转期入口,给运营看得懂的提示与出口。 🔴 **历史脏数据本次不修**:本次只保证**新发生的转期**两列一致。修复上线**之前**已转过期的订单,两列仍分叉,需 **#7586** 单独处理(不能与本次同版发迁移,理由见该单)。 --- ## 一、背景 转订单功能(#7095)合入于 2026-09-04 17:32,而 `order_main.group_batch_id` 这一「一跳直连」列由 #7083 于**同日 18:09** 才加上——相差 37 分钟,两个工单并行落地,此后无人回头补转期链路的回写。 后果比「两列不一致」更重:团期在团名册走的是「一跳列 ∪ 两跳列(且一跳为 NULL)」的双通道并集读,回退通道带 `group_batch_id IS NULL` 硬闸。转期户该列非空被排除,于是**在目标期的在团名册里整体消失**(房务需求、整团需求确认、团期核单、用车派单全看不到),同时仍留在源期;而人数计数走 `product_batch_id` 已正确搬走——「人数」与「成员名单」互相矛盾。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|---|---|---|---|---| | 1 | 转订单(子订单跨期转入) | POST | `/v3/admin/order/group-batch/:groupBatchId/sub-order/:orderId/transfer-in` | 修改接口 | 请求/响应结构不变;写库时同步回写归属第二列,并新增拒绝码 589589 | --- ## 三、接口详情 ### 1. 转订单(子订单跨期转入) `POST /v3/admin/order/group-batch/:groupBatchId/sub-order/:orderId/transfer-in` **VO**: `TransferSubOrderReqVO` → `Result` #### 使用场景 团期管理员把同一产品其他期的一个子订单转入本期:不退钱、订金跟人走、尾款按目标期价重算、不可跨产品转。path 上的 `:groupBatchId` 是**转入**期,源期在 body 的 `fromGroupBatchId`。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | `groupBatchId` | path | Long | 是 | 团期主订单 ID,非产品侧班期 ID | **转入**期 | | `orderId` | path | Long | 是 | 须为源期下的在团子订单 | 待转入的子订单 | | `fromGroupBatchId` | body | Long | 是 | 团期主订单 ID;须与订单当前排期一致 | 源期 | | `reason` | body | String | 否 | ≤512 字 | 转期原因;不传记「管理员手动转期」 | | `notify` | body | Boolean | 否 | — | 转入后是否通知客户;只落标志位,**不实发** | **本次未改动任何入参。** #### 出参 `Result` | 字段 | 类型 | 说明 | |---|---|---| | `orderId` | String | 子订单 ID | | `fromBatchNo` / `toBatchNo` | String | 源期 / 目标期期号 | | `oldOrderAmount` / `newOrderAmount` | BigDecimal | 转期前 / 后应收 | | `paidAmount` | BigDecimal | 已付(转期不动钱) | | `newBalanceDue` | BigDecimal | 新待收尾款 | | `warnings` | String[] | 非阻断提示 | **本次未改动任何出参字段。** #### 请求示例 ```http POST /v3/admin/order/group-batch/2096412454643802114/sub-order/2097498673159028737/transfer-in Content-Type: application/json { "fromGroupBatchId": 2097498512387104770, "reason": "客户改期,转到第 7 期", "notify": false } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "data": { "orderId": "2097498673159028737", "fromBatchNo": "Q202611202097498511120478209", "toBatchNo": "Q202610012052935476548939777", "oldOrderAmount": 1000.0, "newOrderAmount": 3425.0, "paidAmount": 0.0, "newBalanceDue": 3425.0, "warnings": [] }, "success": true } ``` #### 空数据 / 降级响应 - 目标期班期天数取不到(产品域 Feign 失败)时**不阻断**:订单沿用原天数,`warnings` 追加「目标团期行程天数未取到,订单沿用原天数,请人工核对」,`code` 仍为 200。 - 本接口无「空数据」形态:要么整体成功,要么抛业务错误码,不返回半截数据。 #### 错误响应 | code | message | 何时出现 | 本次变化 | |---|---|---|---| | **589589** | 该户有退单审核在途,请先在原团期驳回或完成审核后再转期 | 该子订单存在 PENDING 退单审批 | **🆕 本次新增** | | 589510 | 团期状态不允许转期 | 源期或目标期不在「招募中 / 资源准备中」 | 不变 | | 589512 | 该订单不属于本团期 | 源期与订单当前排期不一致 | 不变 | | 589560 | 该户在源期已分房,不可转期({0} 条) | 源期已有生效分房行 | 不变 | | 589527 | 获取目标团期报价失败,请稍后重试 | 目标期报价取不到或非正 | 不变 | | 589528 | 目标团期价低于该户已付金额,需先走退款流程 | 目标期价 < 已付 | 不变 | 本次新增的 `589589` 实际响应体(TEST 网关实测原样): ```json { "code": 589589, "message": "该户有退单审核在途,请先在原团期驳回或完成审核后再转期", "data": null, "success": false } ``` #### 业务边界 - **单单语义**:一次调用只转一个子订单,请求体内没有订单列表。 - **幂等**:按 `orderId` 5 秒防重;同时按两期 ID 升序各下一次行栅栏,避免两期互转死锁。 - 可转订单状态白名单:`PENDING_PAY` / `PENDING_DEPARTURE`。 - 可转团期状态白名单:`RECRUITING` / `RESOURCE_PREPARING`。因此**已出账 / 已配房 / 已出行的团在守卫层就转不了**。 --- ## 四、契约约束与正确调用方式 ### ✅ 正确 / ❌ 错误 payload 对照 ❌ **错误——把源期写在 path 上**(这是最容易搞反的一处): ```http POST /v3/admin/order/group-batch/{源期ID}/sub-order/{orderId}/transfer-in {"fromGroupBatchId": 目标期ID} ``` ✅ **正确——path 是转入期,body 是源期**: ```http POST /v3/admin/order/group-batch/{目标期ID}/sub-order/{orderId}/transfer-in {"fromGroupBatchId": 源期ID} ``` 两个 ID 都是**团期主订单 ID(`groupBatchId`)**,不是产品侧班期 ID(`productBatchId`)。一个接口不混用两套口径。 ### 切换状态时的必要动作 前端收到 `589589` 时的正确引导:**先在源期把该户的退单申请驳回**(驳回路径不校验归属,任何时候都可用),再重新发起转期;或等该退单审批走完(走完即离团,也就不需要转期了)。**不要**引导用户去目标期重新提交退单——会被 `589529`「该户已有退单审核在途」拦。 --- ## 五、数据库行为 | 表 | 列 | 变更 | |---|---|---| | `order_main` | `product_batch_id` | 转期时改写为目标期排期 ID(**行为不变**) | | `order_main` | `group_batch_id` | **🆕 本次起同步改写为目标期团期 ID**;此前不写,停留源期 | 两列在**同一条 UPDATE、同一个事务**内落库(转期方法整体 `@Transactional(rollbackFor = Exception.class)`),不存在「只更新了一列」的中间态。**未新增 SQL 语句、未加表、未加列、无 DDL。** `group_batch_transfer` 转期流水表行为不变,仍逐次留痕。 --- ## 六、边界行为 - **多次转期**:每次转期两列同步搬到当次目标期,流水表逐条累积,不做合并。 - **目标期 ID 不可能为空**:目标期对象按主键查出,查不到直接抛「团期不存在」,因此不存在「把归属清成 null」的路径。 - **归属守卫仍比 `product_batch_id`**(不比 `group_batch_id`):这样 `group_batch_id` 尚未回填的历史订单仍能正常转期,不会被挡死。 - **人数计数(enrolled)口径不变**:仍按显式 ±N 搬移,本次未改。 - **在途退单守卫只拦 `PENDING`**:已通过 / 已驳回 / 已撤销的审批行不影响转期。 ## 六.6、修改前后对比 ### 字段级对比 | 数据位 | 修复前 | 修复后 | |---|---|---| | `order_main.product_batch_id` | 目标期排期 | 目标期排期(不变) | | `order_main.group_batch_id` | **停留源期** ❌ | **目标期** ✅ | ### 行为级对比 | 场景 | 修复前 | 修复后 | |---|---|---| | 目标期在团名册是否含该户 | **否**(被两跳回退的空值闸排除) | **是** | | 源期在团名册是否仍含该户 | **是**(错误滞留) | **否** | | 订单详情展示的团期编号 | **源期**(错) | **目标期** | | 该户有在途退单时转期 | 允许,且后续审批产生错账且返回成功 | **拒绝 589589**,给出口 | | 每日对账 `inconsistent` 指标 | 每转一次 +1,永不归零 | 新转期不再增加 | ## 六.7、影响评估 | 受影响面 | 影响 | 需要前端动作 | |---|---|---| | `transfer-in` 请求/响应结构 | 无变化 | **无** | | 转期后团期编号/名称展示 | 由源期改为目标期(纠错) | **无**,但 QA 对比历史截图时需知情 | | 转期错误码集合 | 新增 589589 | **有**:按普通业务错误弹 `message` | | 房务需求 / 整团需求 / 团期核单 / 用车派单的在团名册 | 转期户从此正确出现在目标期 | 无(后端口径修正) | --- ## 七、不影响范围 - **不影响**任何接口的请求参数、响应字段名/类型/null 语义。 - **不影响**判团读写法:本次一行未动各处「这张订单属于哪个团」的判据写法,只保证两列数据一致。 - **不影响**退款金额计算、退改政策、订金/尾款口径。 - **不影响**人数计数(enrolled)的搬移口径。 - **不影响**小程序端:本次改动不涉及 mp 链路。 - **不影响**历史已转期订单的存量数据(见 #7586)。 - **无 DDL、无新增表/列、无网关路由变更、无 Feign 契约变更、无 MQ 变更。** --- ## 八、测试环境已验证 部署:`hl-order-service-v3` ← 分支 `fix/7513-transfer-groupbatchid`,双实例滚动,2026-09-12 17:01 在线。取证经真实网关 `api.test.1814.love` + TEST 库直查。 | # | 用例 | 结果 | |---|---|---| | 1 | 真实转期 `transfer-in` | `code 200`,金额 1000.0 → 3425.0 | | 2 | 转期后两列归属 | `group_batch_id` 与 `product_batch_id` **同指目标期**(SQL 判定 CONSISTENT) | | 3 | 全局两列分叉计数 | 转期前 1 → 转期后 1(**新转期未制造新分叉**;那 1 条是历史脏行,归 #7586) | | 4 | 在团名册(双通道并集口径,经 `requirement-summary` 的 `activeOrderCount`) | 目标期 56→55、源期 3→4,**名册随转期正确移动** | | 5 | 五种判团形态一致性 | A / B / C / D / E 五种口径**全部指向同一个团** | | 6 | 人数计数 | 源期 6→5、目标期 110→111 | | 7 | **589589 负向** | 先提交退单(200)→ 转期得 **589589**,文案一致 | | 8 | **589589 逃生口** | 驳回退单(200)→ 再转期得 **200**,确认是卡壳不是死锁 | | 9 | 全量单测 + ArchTest | 基线 10132/0/13/49 → 本分支 10135/0/13/49,**仅 +3(本次新增用例),零新增失败**;13 条 Errors 两边逐类一致,均为本机无 Docker 的 Testcontainers 类 | 取证用的既有订单已**转出后转回**,两列、金额、两侧人数、分叉计数全部回到基线值。 --- ## 九、相关历史 PR - #7095 转订单功能初版(`V20260904_002`,2026-09-04 17:32)——设计时 `order_main.group_batch_id` 尚不存在 - #7083 新增 `order_main.group_batch_id` 一跳直连列(`V20260904_004`,同日 18:09)——回填了当时存量、接管创单写入,但未回头改转期链路 --- ## 十、相关文档 - 工单 #7513(缺陷与口径定案) - 工单 #7586(存量数据修复,代码全量滚完后执行) - `docs/group/团期模块详细设计-v3.0.html` — 归属列写入时机与一致性约束的冻结口径 --- ## 关联 / 联系人 ### 链接 - Issue: #7513 - PR: #7589 - 后续单: #7586 ### 联系人 - 后端: jw - 前端: mmg