docs(order-v3): 团期房务收口五份 changelog(#7325 越界户预检与改期出口、#7326 重判不平退回、#7327 REFUND owner 回落、#7459 取消处置 outbox 化)
changelog-filename-gate / validate (push) Failing after 1s

- 15_7325:confirm-check outOfRangeOrders[] 新增 tripNights/stayDate、按越界晚逐行;旧单户 finalize 越界拒绝(PR #7700)
- 15_7325b:管理端单户改期对团期子订单只允许改回团期出发日,错误码 587039~587042(PR #7743,测试服实测改期→重新确认→补订全链路)
- 15_7326:H10/H11 重算把重判不平的已完成户退回处理中,时间线 extra.reopenedOrderIds(PR #7689)
- 15_7327:团单取消/终止 REFUND 待办 owner 回落团级认领人,todos 字段按实测报文订正(PR #7679)
- 15_7459:订单取消的房务处置改走 outbox 耐久命令,取消后待办约 1 秒内异步产生、流团逐户 REFUND(PR #7731)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mg1eNKoacprUHNvuEKxjqq
这个提交包含在:
API Changelog Bot
2026-09-15 15:42:19 +08:00
共同撰写人 Claude Opus 5
父节点 207425a9af
当前提交 ba4ef6c376
共修改 5 个文件,包含 2003 行新增和 0 行删除
@@ -0,0 +1,388 @@
---
schema: "hl-changelog/v2"
ticket: "7325"
title: "团期越界户:预检 outOfRangeOrders 改按越界晚一行;旧单户 finalize 新增拒绝与错误码 808632"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "not_required"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-15"
status_note: "PR #7700(Issue #7325)已 squash 合并 dev-v3(合并提交 e7e11cf7d,2026-09-15 09:13)。2026-09-15 09:39-09:47 在测试服部署基线 hl-order-service-v3@40f08d9be(经 git merge-base --is-ancestor 核实为 e7e11cf7d 的后代,含本次改动)上做了真实网关取证:GET confirm-check 两次独立抓包(09:42:39、09:43:26)均命中 outOfRangeOrders[0] 含 tripNights=2、stayDate=2026-12-18、reason=OUT_OF_RANGE,新字段契约坐实。POST finalize 对同一团期三次尝试(户未抢单态、抢单重试、对照户)全部先撞既有守卫 808116(订单未抢单)——团期订单不支持逐户抢单(808650),越界户走不到抢单态,因而本轮未能端到端触达 808632;808632 的正确性目前只有单测 finalize_groupBatchOutOfRangeHousehold_throws808632WithoutAnyWrite 覆盖,非测试服实测。backend_status 记 deployed(代码已部署且核心新字段已实测),808632 分支覆盖情况见八、测试环境已验证。gateway_status=not_required:两个端点均为已有路由,未新增/修改路径或方法。frontend_status=pending:待前端确认 outOfRangeOrders 改为按越界晚一行后,页面渲染的行 key 与去重逻辑是否已按 orderId+stayDate 调整。"
updated_at: "2026-09-15"
base: "dev-v3"
---
# 团期越界户: 预检列出末晚,旧单户配房 finalize 拒绝越界户
> **存放目录**:
> - 一期(v2,无 order-v3 标签的工单)→ changelogs/{YYYY-MM}/
> - 二期(v3,order-v3 标签的工单)→ changelogs-v2/{YYYY-MM}/
>
> **服务**: hl-order-service-v3 (端口 8086)
> **PR**: #7700
> **Issue**: #7325
> **日期**: 2026-09-15
> **影响范围**: 管理后台团期订房确认预检 outOfRangeOrders 的字段与行粒度;旧版单户配房工作台 finalize 新增一类拒绝
---
## 关键变化
- 本次变了什么:GET room-plans/confirm-check 的 outOfRangeOrders 新增 tripNights(Integer)、stayDate(yyyy-MM-dd)两个字段,并且行粒度从一户一行改为一晚一行——同一户如果有多晚落在团期区间外,会出现多行、orderId 重复;出发日缺失的户仍整户一行(stayDate 为空)。旧版单户配房工作台的最终确认端点 finalize,此前对团期越界户没有任何拦截,会把该户需求直接置 DONE;本次新增拒绝,越界户点确认会拿到新错误码 808632。
- 前端调用方以前以为的是什么:outOfRangeOrders 一户一行,orderId 在这个数组里唯一;只有 orderId、orderNo、departDate、reason 四个字段。旧单户工作台的 finalize 只要满足配房覆盖度和状态闸口就能确认,不会因为团期越界被拦。
- 实际现在是什么:同一户多晚越界会重复出现多行,前端如果拿 orderId 当行 key 或做去重,会漏渲染除第一晚外的其余越界晚;正确的行 key 是 orderId 加 stayDate。旧单户工作台对团期越界户点 finalize 会被 808632 拒绝、零写入,需求状态不变;这是新发现并修复的一个缺陷(此前会静默放过,把越界户的需求错误地标记为已完成)。
---
## 一、背景(选填)
工单 7325 AC-18 与 AC-54:团期看板新引入的按日确认与重算分房链路已经能正确识别越界户并阻塞团级配房完成标志,但两处配套能力没跟上——预检列表只报户不报晚,房务看不出订不了的具体是哪一晚;而另一条历史更早的入口(旧版单户配房工作台的 finalize,工单 2770 遗留)完全不检查团期越界,会绕过团期看板的判定直接把越界户标记完成。
| 维度 | 证据 |
|------|------|
| outOfRangeOrders 展开逻辑 | GroupBatchRoomDayConfirmManager.java 第 533 至 550 行 toOutOfRangeOrders:按越界入住日逐晚生成一行,晚列表为空时降级为整户一行 |
| finalize 新拒绝点 | HouseAssignmentService.java 第 4305 至 4324 行 assertNotGroupBatchOutOfRangeHousehold:判定依据与预检共用同一个契约 |
| 808632 撞号核查 | PR 正文自报:合入前对 origin dev-v3 全仓 grep 808632 零命中 |
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 订房确认预检 | GET | `/v3/admin/house/group-batches/{groupBatchId}/room-plans/confirm-check` | 响应字段新增+行粒度变更 | outOfRangeOrders 新增 tripNights、stayDate,改为按越界晚一行 |
| 2 | 旧单户最终确认 | POST | `/admin/house/assignments/requirements/{requirementId}/finalize` | 新增拒绝分支 | 团期越界户新增 808632 拒绝,零写入 |
---
## 三、接口详情
### 1. 订房确认预检 `GET /v3/admin/house/group-batches/{groupBatchId}/room-plans/confirm-check`
**VO**: `GroupBatchRoomConfirmCheckReqVO → GroupBatchRoomConfirmCheckRespVO`
#### 使用场景
房务在团期看板发起按日确认或整团确认之前调用,零副作用,展示逐日差额表、阻塞名单(无基线户、越界户)与整团能否确认的判定。本次起,越界户名单里能看到具体是哪一晚越界,便于房务判断是该团期管理员改期、还是等房务自己释放分房。完整端点契约(stayDate 查询参数、days、noBaselineOrders 等其余字段、错误码、角色门)见既有 changelog:changelogs-v2/2026-09/11_7325_团期房务按日订房确认H7-H8-预检-新增接口-管理后台.md,本文件只描述 outOfRangeOrders 的变化。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | 是 | - | 团期主订单 ID |
| stayDate | Query | LocalDate | 否 | yyyy-MM-dd,不传则返回全部日 | 本次未改;只影响 days,不影响 outOfRangeOrders(后者恒返回全团越界名单) |
#### 出参字段表 `Result<GroupBatchRoomConfirmCheckRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.outOfRangeOrders | Array | 本次改动:越界或出发日缺失户,按越界晚展开的行列表 |
| data.outOfRangeOrders[].orderId | String | 子订单 ID;同一户多晚越界时在数组内重复出现 |
| data.outOfRangeOrders[].orderNo | String | 订单号 |
| data.outOfRangeOrders[].departDate | String | 该户出发日,缺失为空 |
| data.outOfRangeOrders[].tripNights | Integer | 新增字段,该户行程晚数,两者皆空则为空 |
| data.outOfRangeOrders[].stayDate | String | 新增字段,越界的入住日;出发日缺失时为空 |
| data.outOfRangeOrders[].reason | String | 原因 OUT_OF_RANGE 或 DATE_MISSING,取值域本次未变 |
#### 请求示例
```http
GET /v3/admin/house/group-batches/9001/room-plans/confirm-check
```
#### 响应示例
2026-09-15 09:42:39 测试服真实网关抓包(roleId=4 房务,团期 2099674449308545025,户 2099674449094635522 出发日被 SQL 改到团期区间外制造越界,落在区间外的一晚是 2026-12-18):
```json
{
"code": 200,
"message": "成功",
"data": {
"groupBatchId": "2099674449308545025",
"batchStatus": "RESOURCE_PREPARING",
"stageAllowed": true,
"baselineExists": true,
"hotelReady": false,
"maxRooms": 10,
"ready": false,
"blockedByOutOfRange": true,
"days": [
{ "stayDate": "2026-12-16", "planStatus": "NONE", "plannedTotal": 0, "demandedTotal": 2, "exceedsMaxRooms": false, "demand": { "STANDARD": 2 }, "mismatch": [ { "roomCategory": "STANDARD", "planned": 0, "demanded": 2, "diff": -2 } ], "stale": false, "dayReady": false, "outOfBatchRange": false },
{ "stayDate": "2026-12-17", "planStatus": "NONE", "plannedTotal": 0, "demandedTotal": 3, "exceedsMaxRooms": false, "demand": { "STANDARD": 3 }, "mismatch": [ { "roomCategory": "STANDARD", "planned": 0, "demanded": 3, "diff": -3 } ], "stale": false, "dayReady": false, "outOfBatchRange": false },
{ "stayDate": "2026-12-18", "planStatus": "NONE", "plannedTotal": 0, "demandedTotal": 1, "exceedsMaxRooms": false, "demand": { "STANDARD": 1 }, "mismatch": [ { "roomCategory": "STANDARD", "planned": 0, "demanded": 1, "diff": -1 } ], "stale": false, "dayReady": false, "outOfBatchRange": true }
],
"noBaselineOrders": [],
"outOfRangeOrders": [
{ "orderId": "2099674449094635522", "orderNo": "HL20260915093933212", "departDate": "2026-12-17", "tripNights": 2, "stayDate": "2026-12-18", "reason": "OUT_OF_RANGE" }
]
},
"success": true
}
```
第二次独立抓包(09:43:26,H7 逐日确认 12-16/12-17 两天订房后重新查询,outOfRangeOrders 内容不变,证明新字段不是偶然值):
```json
{ "orderId": "2099674449094635522", "orderNo": "HL20260915093933212", "departDate": "2026-12-17", "tripNights": 2, "stayDate": "2026-12-18", "reason": "OUT_OF_RANGE" }
```
出发日缺失的户(整户一行,stayDate 为空;本次未实测,结构取自源码定义):
```json
{ "orderId": "900004", "orderNo": "ORD202606120004", "departDate": null, "tripNights": null, "stayDate": null, "reason": "DATE_MISSING" }
```
#### 空数据 / 降级响应
无越界户或出发日缺失户时 outOfRangeOrders 为空数组,本次未改此形态:
```json
{ "code": 200, "data": { "outOfRangeOrders": [] }, "success": true }
```
#### 错误响应
本端点错误码本次未改,见既有 changelog(808611、808612、808613 归属门,808090、808091 角色门):
```json
{ "code": 808612, "message": "该团期尚未被房务认领,请先到团期抢单池认领", "data": null, "success": false }
```
#### 业务边界
- outOfRangeOrders 不再是一户一条的唯一名单:调用方若把它当 Map 用 orderId 做 key 去重,会丢掉同一户的其余越界晚;正确用法是把 orderId 加 stayDate 当联合 key。
- 名单本身仍按户去重:只在出参这一层展开成多行,阻塞判定 blockedByOutOfRange 不受展开影响,仍是名单非空即阻塞。
- tripNights 与 stayDate 可能同时为空:当该户出发日缺失(DATE_MISSING)时两个字段都为空,不代表数据异常。
- 鉴权、角色门、其余字段行为:与既有 confirm-check 契约一致,本次未改。
---
### 2. 旧单户最终确认 `POST /admin/house/assignments/requirements/{requirementId}/finalize`
**VO**: `无请求体(仅 requirementId 路径参数) → Result<FinalizeRespVO>`
#### 使用场景
房务在旧版单户配房工作台(非团期看板)对某个用房需求点最终确认时调用,触发状态机 ASSIGNMENT_CONFIRM_FULL 到 FINALIZE,把需求推进到 DONE。本次起,若该需求所属订单是团期子订单、且被团期侧判定为越界户,点确认会被拒绝,前端需要引导房务改走团期看板处理(改期或等团期管理员处理),不能再从这个旧入口把越界户标记完成。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| requirementId | Path | Long | 是 | - | 用房需求主键,本次未改 |
#### 出参字段表 `Result<FinalizeRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.newStatus | String | 需求新状态,DONE 表示已最终确认,本次未改 |
| data.finalizedAt | String | 确认时间戳,格式 yyyy-MM-ddTHH:mm:ss,本次未改 |
#### 请求示例
```http
POST /admin/house/assignments/requirements/2099319285540110337/finalize
```
#### 响应示例
正常放行(散客单或团期非越界户,行为与改动前逐字节相同):
```json
{ "code": 200, "message": "成功", "data": { "newStatus": "DONE", "finalizedAt": "2026-09-15T15:30:00" }, "success": true }
```
#### 空数据 / 降级响应
本端点不存在成功但空数据的形态,校验全部通过即返回 newStatus 与 finalizedAt。
#### 错误响应
新增错误码,源码定义在 HouseGroupBatchErrorCode.java 第 312 行,占位符为订单号(下方 JSON 取自源码定义,不是测试服抓包——本轮 2026-09-15 三次真实调用 finalize 全部先被更前置的既有守卫 808116(订单未抢单,请先抢单再配房)拦下,没能走到本次新增的 808632 判定;越界拒绝码 808632 的正确性目前只由单测 finalize_groupBatchOutOfRangeHousehold_throws808632WithoutAnyWrite 覆盖,见八、测试环境已验证):
```json
{
"code": 808632,
"message": "该户(订单 HL20260915115141720)有住宿晚落在团期出行区间之外或出发日缺失,需团期管理员处理后才能最终确认",
"data": null,
"success": false
}
```
既有错误码,本次未改,举例:
```json
{ "code": 808182, "message": "该需求已最终确认,请勿重复操作", "data": null, "success": false }
```
2026-09-15 测试服真实抓包(更前置的既有守卫拦截,说明团期越界户走这条入口时更常先撞到抢单守卫而不是本次新增的越界拒绝):
```json
{ "code": 808116, "message": "订单未抢单, 请先抢单再配房", "data": null, "success": false }
```
#### 业务边界
- 拒绝时机在状态闸口之后、任何写之前:HouseAssignmentService.java 第 2884 至 2890 行——先判需求当前 status 必须是 PROCESSING,否则既有 808182 类错误码;status 闸口通过后立刻判团期越界,808632 抛出前该请求零写入,不改需求状态、不改配房、不写操作日志。
- 只拒团期越界户,不拒散客单:判定入口先判是否团期订单,散客单直接跳过,零额外开销。
- 判定名单与团期看板预检共用同一个契约:与本文件端点 1 的 outOfRangeOrders 名单同源,不会出现预检说没越界、finalize 却拒绝的口径分裂。
- 团期未成团时走既有闸口,不会误判越界:越界判定只在团期已成团、能解析出 groupBatchId 时才生效。
---
## 四、契约约束与正确调用方式(接口类必写)
本节只写后端接受拒绝 payload 的规则与调用后必须知道的取值规则,不写 UI 渲染建议。
### 正确与错误调用方式对照
| 场景 | 说明 |
|------|------|
| 正确:用 orderId 加 stayDate 作为 outOfRangeOrders 的行 key | 同一户多晚越界会重复出现多行,orderId 单独不唯一 |
| 正确:旧单户工作台点 finalize 前,先看该需求所属订单是否团期子订单 | 团期越界户会被 808632 拒绝,可提前在 UI 上禁用按钮或给出引导文案 |
| 错误:把 outOfRangeOrders 当一户一条处理并直接渲染成列表 | 会重复渲染同一户多次,且遗漏除第一晚外的其余越界晚信息 |
| 错误:认为旧单户工作台 finalize 只要 200 就代表已完成 | 团期越界户会先拿到 808632,需要处理引导后才能重试 |
### 切换状态时的必要动作
端点 1(预检)是只读查询,无状态切换。端点 2(finalize)被 808632 拒绝时状态不发生任何变化,需求仍停留在拒绝前的状态,前端不需要做任何回滚或刷新之外的动作,正常刷新一次该需求详情即可。
---
## 五、数据库行为(涉及写操作时必写)
| 端点 | 场景 | 写行为 |
|------|------|--------|
| 端点 1(confirm-check) | 任意 | 无写操作,本端点全程只读 |
| 端点 2(finalize) | 团期越界户,808632 拒绝 | 零写入:不改需求状态与配房,不写操作日志 |
| 端点 2(finalize) | 非越界户,正常放行 | 与改动前逐字节相同,本次未改 |
---
## 六、边界行为
- 端点 1:未登录返回 401;角色门与归属门错误码见既有契约(808090、808091、808611、808612、808613)
- 端点 2:未登录返回 401;需求不存在或非本人抢单为既有错误码,本次未改;状态非 PROCESSING 为既有 808182 类错误码,本次未改;团期越界户新增 808632,零写入
- 团期未成团:端点 2 的越界判定不生效,未成团时既有闸口已先拒绝
---
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
### 越界原因(outOfRangeOrders 的 reason 字段)
**所属字段**: `outOfRangeOrders[].reason` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `OUT_OF_RANGE` | 出发日外 | 该户出发日落在团期区间之外;本次起该原因下会按越界晚展开多行 |
| `DATE_MISSING` | 日期缺失 | 该户出发日为空,无法判定是否在团期内;整户一行,stayDate 为空 |
本次未新增枚举取值,仅改变了同一取值域下的行粒度。
---
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| outOfRangeOrders 行粒度 | 一户一行 | 一户一晚一行,多晚越界重复出现,orderId 不唯一 |
| outOfRangeOrders 的 tripNights | 无此字段 | 新增,Integer,该户行程晚数 |
| outOfRangeOrders 的 stayDate | 无此字段 | 新增,String,具体越界的入住日 |
| outOfRangeOrders 的 orderId、orderNo、departDate、reason | 存在 | 不变 |
| finalize 响应体字段结构 | newStatus、finalizedAt | 不变,新增的是拒绝分支,不是字段 |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 旧单户工作台对团期越界户点 finalize | 放行,需求置 DONE,绕过团期看板判定,属缺陷 | 拒绝(808632),零写入 |
| outOfRangeOrders 展示同一户多晚越界 | 只看得到户,看不出具体哪几晚 | 每晚一行,可直接对照 days 定位 |
---
## 六.7、影响评估(修改/删除类必写)
- 是否破坏向后兼容:端点 1 的字段是新增,旧前端忽略未知字段不受影响,但行粒度变化是破坏性的——若前端按 orderId 做 Map 或去重,会静默丢数据,不报错,只是少渲染。端点 2 新增的 808632 是新错误码,前端若无兜底分支会按未知错误码展示,不会崩溃但文案可能不友好。
- 前端是否必须同步上线:端点 1 建议同步,否则多晚越界的户可能只展示其中一晚。端点 2 不同步上线也不会导致调用失败,只是越界户点 finalize 会看到不认识的错误码文案。
- 前端 workaround 清理点:无。
---
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- 仅影响:团期订房确认预检 outOfRangeOrders 的字段与行粒度;旧版单户配房工作台 finalize 对团期越界户新增拒绝。
- 零影响:
- confirm-check 的 days、noBaselineOrders、ready、hotelReady、blockedByOutOfRange 等其余字段
- 团期看板其余端点(按日确认、整团确认、人工微调分房、重算分房)
- 散客单(非团期子订单)的 finalize 行为
- finalize 既有的状态闸口、配房覆盖度校验、幂等窗口
- 工单 7459(订单取消 outbox 改造)、工单 7326(分房重算已完成户回退)等同批次改动,见另外的 changelog 文件
---
## 八、测试环境已验证
部署:PR #7700(合并提交 e7e11cf7d,2026-09-15 09:13)经 git merge-base --is-ancestor e7e11cf7d 40f08d9be 核实是测试服 2026-09-15 09:39 部署基线 hl-order-service-v3@40f08d9be 的祖先;三次独立 deploy-status.sh 探测(09:39/09:42/09:43)均返回同一版本号,取证期间部署未漂移。
端点 1(confirm-check)新字段真实网关取证:
| 时刻 | 场景 | 结果 |
|---|---|---|
| 09:42:39 | 团期 2099674449308545025,户 2099674449094635522(出发日被 SQL 改到 2026-12-17,返程日 2026-12-19,制造 2026-12-18 越界晚) | outOfRangeOrders[0] 含 tripNights=2、stayDate=2026-12-18、reason=OUT_OF_RANGE,字段真实存在 |
| 09:43:26 | 同一团期,H7 逐日确认 12-16/12-17 两天订房后重新查询 | outOfRangeOrders 内容不变(仍是同一条越界记录),证明字段不受确认动作影响、非偶然值 |
端点 2(finalize)拒绝路径实测:
| 时刻 | 场景 | 返回 |
|---|---|---|
| 09:46:07 | 户 2099674451917402113 首次 finalize(未抢单态) | 808116「订单未抢单,请先抢单再配房」 |
| 09:46:29 | 同户先尝试 POST .../claim 转单 | 808650「团期订单不支持逐户抢单/转单,请到团期抢单池整团认领」——团期子订单结构性走不了逐户抢单入口 |
| 09:46:30 | 同户重试 finalize | 仍 808116 |
| 09:47:19 | 对照户 2099674452341026817(同团另一户,同样未抢单) | 仍 808116 |
结论(如实标注):本轮三次尝试全部先撞更前置的既有守卫 808116,团期越界户在这条入口下没有能触达 808632 的可行路径(团期订单不支持逐户抢单,808650 直接堵死了让户进入「已抢单」态从而继续走到越界判定的路子)。808632 的正确性目前只有单测覆盖,未在测试服端到端验证:
```
finalize_groupBatchOutOfRangeHousehold_throws808632WithoutAnyWrite(HouseAssignmentServiceTest.java:3797-3798)
→ 团期越界户经单户入口最终确认被拒且零写入 ✓(本机自动化单测,非测试服抓包)
```
PR 正文自报的其余本机定向验证:
```
定向 6 类(AdjustmentServiceSubmitTest 78 例、AdjustmentServiceTest 10 例、AdjustmentSnapshotTest 43 例、
HouseAssignmentServiceTest 183 例含 Skipped 6、RedLineArchTest 12 例、HouseModuleBoundaryArchTest 5 例)
基线与复绿一致,MVN_EXIT=0
```
网关:本次未新增/修改路径,两个端点均沿用既有路由,取证均经网关(via: gateway)完成。
---
## 十、相关文档
- 关联 Issue: wx/HL#7325
- 关联 PR: wx/HL#7700
- confirm-check 端点完整契约(本次未变部分): changelogs-v2/2026-09/11_7325_团期房务按日订房确认H7-H8-预检-新增接口-管理后台.md
- 同一 Issue 下的另一改动(团期子订单改期出口,PR #7743,已合并): changelogs-v2/2026-09/15_7325b_团期越界户改期出口只能回团期出发日-修改接口-管理后台.md
## 关联 / 联系人
### 链接
- Issue: [#7325](https://git.1814.love:8443/wx/HL/issues/7325)
- PR: [#7700](https://git.1814.love:8443/wx/HL/pulls/7700)
- Merge commit: [e7e11cf7d](https://git.1814.love:8443/wx/HL/commit/e7e11cf7d1e115d6aca97d2548ed19946744e455)
### 联系人
- 后端负责人: @wx
@@ -0,0 +1,287 @@
---
schema: "hl-changelog/v2"
ticket: "7325"
title: "团期子订单改期出口:只允许改回所属团期出发日,新增 4 个错误码 587039-587042"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "not_required"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-15"
status_note: "PR #7743(Issue #7325)已合并 dev-v3(合并提交 88cab06dc,2026-09-15 13:54,squash 直接落在 dev-v3 上)。2026-09-15 14:07:35 测试服部署 dev-v3@88cab06dc(含本次改动,经 deploy-status.sh 实测确认),并做了真实网关端到端取证:越界户 O1(团期 2099674449308545025)POST adjustment/submit 提交 departDate=2026-12-16(该团期出发日)返回 200 成功;整团重新确认需求后 confirm-check 的 outOfRangeOrders 变为空数组、blockedByOutOfRange=false;房务补齐分房并确认后 hotelReady=true、ready=true,验证了本单要解决的产品问题(越界户可经这条出口回到团期区间内)全链路打通。但新增的 4 个拒绝错误码 587039-587042 本轮均未触发(实测走的是成功路径),其响应内容取自源码 AdjustmentErrorCode.java 定义,非测试服抓包。backend_status 记 deployed。gateway_status=not_required:改动的端点 POST /v3/admin/order/{orderId}/adjustment/submit 是已有路由,未新增/修改路径。frontend_status=pending:待前端确认订单调整弹窗改期 tab 对团期子订单是否已限制日期选择器只能选团期出发日,以及新错误码 587039-587042 的文案展示。"
updated_at: "2026-09-15"
base: "dev-v3"
---
# 团期越界户: 单户改期出口只能回团期出发日
> **存放目录**:
> - 一期(v2,无 order-v3 标签的工单)→ changelogs/{YYYY-MM}/
> - 二期(v3,order-v3 标签的工单)→ changelogs-v2/{YYYY-MM}/
>
> **服务**: hl-order-service-v3 (端口 8086)
> **PR**: #7743(已合并 dev-v3,合并提交 88cab06dc)
> **Issue**: #7325
> **日期**: 2026-09-15
> **影响范围**: 管理后台订单调整弹窗改期 tab,对团期子订单(挂了 productBatchId 的订单)提交新出发日期时的校验规则与新错误码
---
## 关键变化
- 本次变了什么:管理端 POST /v3/admin/order/{orderId}/adjustment/submit(改期 tab,即提交体 updates.schedule.departDate)对团期子订单新增一段专属校验:新出发日期必须精确等于所属团期的出发日,不能是团期区间内外的其它任意日期;同时团期子订单改期不再按个人价格日历算改期差价(团期本身按班期定价,改期不产生价格增量)。新增 4 个错误码 587039-587042。
- 前端调用方以前以为的是什么:团期子订单和散客单一样,改期 tab 的日期选择器可选范围由个人价格日历的可售日期决定;提交一个团期区间外的日期会拿到既有的「日期不可售」类错误码(581041)。
- 实际现在是什么:团期子订单改期前先判定是否团期订单,若是则只放行「改回团期出发日」这一个值,其余任何日期一律拒绝(587039),且不再查个人价格日历、不产生改期差价;越界户借由这条路径可以改回团期出发日从而回到团期区间内(这是本次要解决的产品问题:越界户此前既不能保持越界也不能靠个人改期机制回到区间内,双向都被挡死)。
---
## 一、背景(选填)
工单 7325:团期越界户(住宿晚落到团期出行区间 [出发日, 结束日) 外)此前没有产品内出口。管理端单户改期原本按个人价格日历报价,团期子订单查不到对应日期,会抛出误导性的 581041「日期不可售」,导致越界户无论是推出区间还是改回区间都被挡住。本次给出唯一出口:团期子订单改期只允许改回所属团期出发日。
| 维度 | 证据 |
|------|------|
| 团期子订单判定 | AdjustmentService.java 第 1627-1629 行 isGroupSubOrder:product_batch_id 非空即团期子订单,与 RequirementService 改期重置分支同口径 |
| 守卫触发时机 | AdjustmentService.java 第 290 行:resolveGroupBatchRescheduleTarget 在报价与一切写之前调用,拒绝即零写入 |
| 判定顺序 | 团期/出发日解析失败(587040) → 日期不等于团期出发日(587039) → 晚数不匹配(587041) → 仍有区间外分房(587042) |
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 订单调整统一提交(改期 tab) | POST | `/v3/admin/order/{orderId}/adjustment/submit` | 请求校验新增分支 + 错误码新增 | 团期子订单改期新增专属校验链,新增 4 个错误码 587039-587042 |
---
## 三、接口详情
### 1. 订单调整统一提交(改期 tab) `POST /v3/admin/order/{orderId}/adjustment/submit`
**VO**: `AdjustmentSubmitReqVO → Result<AdjustmentSubmitRespVO>`
#### 使用场景
管理后台订单详情页的调整弹窗,改期 tab 填写新出发日期后点提交调用(同一接口也承载出行人、行程、房需求、车需求等其它 tab 的改动,本文件只描述改期 tab 涉及团期子订单时新增的那段校验)。完整的请求体结构、其它子领域字段、既有校验规则见既有契约:changelogs-v2/2026-06/27_4488_订单调整snapshot与submit契约重制-修改接口-管理后台.md;本文件只补团期子订单改期这一新增分支。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| orderId | Path | Long | 是 | - | 本次未改 |
| updates.schedule.departDate | Body | String | 提交改期 tab 时必填 | ISO yyyy-MM-dd,不可与原出发日相同 | 本次未改字段本身,但团期子订单提交此字段时会触发下方新校验链 |
(其它子领域字段 updates.people/travelers/itinerary/hotelRequirement/vehicleRequirement 本次未改,见既有契约)
#### 出参字段表 `Result<AdjustmentSubmitRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data | Object | 提交结果,字段结构本次未改,见既有契约 |
(本次不新增响应字段,改动完全体现在请求校验与错误码上)
#### 请求示例
2026-09-15 14:14:26 测试服真实网关请求(团期 2099674449308545025 出发日为 2026-12-16,户 O1 此前已被 SQL 改到区间外,本次提交改回团期出发日):
```json
{ "updates": { "schedule": { "departDate": "2026-12-16" } } }
```
#### 响应示例
同一请求的真实响应(roleId=2):
```json
{ "code": 200, "message": "成功", "data": { "success": true }, "success": true }
```
#### 空数据 / 降级响应
本端点不存在成功但空数据的形态,校验通过即按既有契约返回变更结果。
#### 错误响应
新增错误码(源码 AdjustmentErrorCode.java 第 82、86、94、107 行;⚠️ 以下四个 JSON 均取自源码定义,本轮 2026-09-15 测试服实测走的是成功路径(见上方请求/响应示例),未触发任何一个拒绝分支,拒绝码未实测,文案取自 AdjustmentErrorCode):
```json
{ "code": 587039, "message": "团期子订单只能改回所属团期的出发日(2026-06-08)", "data": null, "success": false }
```
```json
{ "code": 587040, "message": "团期子订单未找到所属团期出发日,无法改期,请先核对团期信息", "data": null, "success": false }
```
```json
{ "code": 587041, "message": "行程 3 晚与团期 4 晚不一致,改期无法回到团期区间,请走转期或由团期管理员处理", "data": null, "success": false }
```
```json
{ "code": 587042, "message": "该户仍有 2 条团期分房落在团期区间外,改期前请先由房务释放这些分房", "data": null, "success": false }
```
#### 业务边界
- 只对团期子订单(product_batch_id 非空)且本次提交确实会改变出发日期时才触发这条校验链;散客单沿用既有改期规则,不受影响。
- 四个错误码在任何写操作之前判定完,触发任意一个都是零写入(不改订单、不改需求、不扣库存),与既有的「拒绝即零写入」调用约定一致。
- 判定顺序固定:团期/出发日解析失败(587040) 优先于 日期不等于团期出发日(587039) 优先于 晚数不匹配(587041) 优先于 仍有区间外分房(587042);前端可按此顺序设计错误码分支的优先级,但不需要自己复刻这个顺序判断,后端只会返回命中的第一个。
- 团期子订单改期不再走个人价格日历报价:本次提交若只改期不改其它子领域,不会产生改期差价(团期按班期定价,出发日只能改回团期出发日,价格不因此变化)。
---
## 四、契约约束与正确调用方式(接口类必写)
本节只写后端接受拒绝 payload 的规则与调用后必须知道的取值规则,不写 UI 渲染建议。
### 正确与错误调用方式对照
| 场景 | 说明 |
|------|------|
| 正确:团期子订单改期前先查该户所属团期的出发日,日期选择器只放开这一个值 | 提交其它任何日期都会拿到 587039,不如提前在 UI 上限制选择范围 |
| 正确:587042 出现时引导用户联系房务先释放区间外分房,而不是重试提交 | 该错误码是兜底防御,正常路径下不应触发;重试同样的请求不会成功,需要先由房务操作 |
| 错误:对团期子订单沿用散客单的个人价格日历可选日期范围渲染选择器 | 团期子订单的可选范围只有团期出发日一个值,与个人价格日历无关 |
| 错误:把 587039/587040/587041 当成同一类日期错误统一兜底文案 | 三者含义不同(改期目标不对 / 团期数据缺失 / 晚数不匹配),建议分别给出引导文案 |
### 切换状态时的必要动作
改期成功(团期子订单改回团期出发日)后,后端会连带触发:该子订单用车需求按团期口径重新生成版本、团期 hotel_ready 置回待重判、整团需求确认标记清零(触发整团重新确认流程)。前端若在改期成功后停留在该订单详情页,建议主动刷新一次房需求/车需求/团期状态区块,因为这些数据可能在改期这次提交里被后端连带改动,而不是显式出现在改期本身的响应体里。
---
## 五、数据库行为(涉及写操作时必写)
| 场景 | 写行为 |
|------|--------|
| 四个新错误码任一命中 | 零写入 |
| 团期子订单改期成功 | order_main 出发日/返程日更新(本次未改这一步本身);该户用车需求按团期口径重新生成版本(沿用转期同款入口,散客改期入口对团期子订单直接返回 0);group_batch.hotel_ready 置回待重判;group_batch 的整团需求确认标记(requirement_confirmed)条件清零,仅当此前确实是已确认状态才会真的清成未确认并写团期时间线 |
| 团期时间线新增记录 | 若确认标记被真的清零,会在既有事件 BATCH_REQUIREMENT_REOPENED 下新写一条时间线,extra 里的 resourceType 字段本次新增一个取值 RESCHEDULE(此前该字段只出现 HOTEL/VEHICLE 两种取值,见「六.5」) |
---
## 六、边界行为
- 未登录 → 401(网关拦截,本次未改)
- 非团期子订单提交改期 → 不触发本次新增的任何校验,走既有散客改期规则
- 团期子订单提交与当前出发日相同的日期 → 不算改期,不触发本次新增校验(既有的「无实际变化」判定,本次未改)
- 团期解析失败或团期缺出发日 → 587040,fail-closed,不回退到个人价格日历
- 出发日正确但行程晚数与团期晚数不一致 → 587041
- 出发日与晚数都正确但仍有分房行落在团期区间外 → 587042,需房务先释放
---
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
### resourceType(团期状态流水 extra.resourceType,既有字段新增一个取值)
**所属字段**: `GET /v3/admin/order/group-batch/{groupBatchId}/status-logs` 里 `eventType=BATCH_REQUIREMENT_REOPENED` 记录的 `extra.resourceType`(既有字段,非本文件改动的端点直接返回,但本次改动会让它出现新取值) | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `HOTEL` | 房需求触发 | 既有取值,定制师改酒店需求触发确认标记清零 |
| `VEHICLE` | 车需求触发 | 既有取值,定制师改车需求触发确认标记清零 |
| `RESCHEDULE` | 改期触发 | 本次新增取值,团期子订单改期触发确认标记清零;前端若对这个字段做了枚举映射,需要补上这个新值的展示文案 |
---
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| adjustment/submit 请求体/响应体字段结构 | 见既有契约 | 不变 |
| 团期状态流水 extra.resourceType 取值域 | HOTEL、VEHICLE | 新增 RESCHEDULE |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 团期子订单提交改期到团期区间外的日期 | 按个人价格日历查不到该日期,抛误导性的 581041「日期不可售」 | 明确拒绝,587039,文案指出唯一合法目标是团期出发日 |
| 团期子订单改回团期出发日 | 同样走个人价格日历报价,可能因查不到日期而失败 | 放行,不查个人价格日历,不产生改期差价 |
| 团期子订单改期成功后的连带效果 | 无(此前这条路径走不通) | 用车需求按团期口径重版、hotel_ready 置回待重判、整团确认标记清零 |
---
## 六.7、影响评估(修改/删除类必写)
- 是否破坏向后兼容:对散客单无影响。对团期子订单,此前改期到区间外日期会拿到 581041(且报价环节可能有不可预期的副作用),现在会拿到语义明确的 587039-587042 四个新码之一;如果前端对 581041 有团期专属的兜底文案,需要确认新码是否需要补充对应文案。
- 前端是否必须同步上线:建议同步——四个新错误码的文案与既有 581041 不同,若前端没有兜底会展示成未知错误码。
- 前端 workaround 清理点:若前端此前为团期子订单改期失败做过 581041 特殊文案的 workaround,本次上线后可以针对新码调整为更准确的引导文案。
---
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- 仅影响:团期子订单(product_batch_id 非空)通过 adjustment/submit 改期 tab 提交新出发日期时的校验与错误码;连带触发的用车需求重版、hotel_ready 重判、整团确认标记清零。
- 零影响:
- 散客单(无 productBatchId)的改期行为
- adjustment/submit 的其它子领域(出行人、行程、房需求、车需求)
- 团期子订单的行程天数/人数等非改期字段的调整
- 团期看板 H7-H11 系列端点(本身不受本次改动触碰,只是本次改动的后置效果会让它们在下次重判时看到最新状态)
---
## 八、测试环境已验证
部署:PR #7743(合并提交 88cab06dc,2026-09-15 13:54)已合并 dev-v3;测试服 2026-09-15 14:07:35 部署 hl-order-service-v3@88cab06dc(deploy-status.sh 实测确认),本节取证全程在此版本上进行。
真实网关端到端取证(团期 2099674449308545025,户 O1=2099674449094635522,出发日 2026-12-16;该户之前已被 SQL 改到出发日 2026-12-17/返程 2026-12-19、制造 2026-12-18 越界晚,即 15_7325 文件里记录的越界样本):
| 时刻 | 步骤 | 接口 | 结果 |
|---|---|---|---|
| 14:14:21 | 改期前 confirm-check | GET confirm-check | outOfRangeOrders 含 O1 一条(tripNights=2, stayDate=2026-12-18, reason=OUT_OF_RANGE),blockedByOutOfRange=true |
| 14:14:26 | 提交改期回团期出发日 | POST adjustment/submit,body={"updates":{"schedule":{"departDate":"2026-12-16"}}} | 200,data={"success":true} |
| 14:14:40 | 整团重新确认需求 | POST requirement/confirm | 200 |
| 14:14:40 | 改期+重新确认后 confirm-check | GET confirm-check | outOfRangeOrders=[],blockedByOutOfRange=false——越界户已回到区间内 |
| 14:14:54-14:15:43 | 房务补订房(H4 新增 1 间 → H5 调整到 3 间 → H7 确认 2026-12-16) | POST/PUT/POST room-plans 系列 | 均 200 |
| 14:15:45 | 补订房后 confirm-check | GET confirm-check | hotelReady=true,ready=true,blockedByOutOfRange=false,两日 dayReady 均为 true |
结论:本单要解决的产品问题(越界户此前无法靠改期机制回到团期区间)经真实网关全链路验证已打通——改期成功、越界名单清空、房务补齐后配房完成标志正确置真。
新增的 4 个拒绝错误码 587039-587042 本轮未触发(实测走的是成功路径),如实标注:
```
本轮测试服请求参数(departDate=2026-12-16)恰好是合法目标值(团期出发日本身),
未构造「提交非法日期」的对照请求,故 587039-587042 四个拒绝分支本轮无测试服实测证据;
四个错误码的响应内容取自源码 AdjustmentErrorCode.java 定义。
```
以下为 PR 正文自报的本机单测/变异测试验证,不是测试服抓包:
```
定向 6 类(AdjustmentServiceSubmitTest 78 例、AdjustmentServiceTest 10 例、AdjustmentSnapshotTest 43 例、
HouseAssignmentServiceTest 183 例含 Skipped 6、RedLineArchTest 12 例、HouseModuleBoundaryArchTest 5 例)
基线与复绿一致,MVN_EXIT=0
变异 11 个逐个变红:团期子订单判定、团期跳过个人价日历、587039/587040/587041/587042 四个拒绝分支、
团期出发日解析、改期后团期分支、分房删除计数、清除整团需求确认、单户 finalize 守卫顺序;还原后复绿
已 rebase 到 dev-v3 64c3f72a3(含工单 7441 PR-2 对 GroupBatchService/RequirementService 的改动)后定向复跑绿
```
网关:本次未新增/修改路径,沿用既有路由,取证均经网关(via: gateway)完成。
---
## 十、相关文档
- 关联 Issue: wx/HL#7325
- 关联 PR: wx/HL#7743(已合并 dev-v3)
- 改期 tab 完整契约背景: changelogs-v2/2026-06/27_4488_订单调整snapshot与submit契约重制-修改接口-管理后台.md
- 同一 Issue 下的另一改动(confirm-check 预检 + 旧 finalize 拒绝越界户,PR #7700,已合并): changelogs-v2/2026-09/15_7325_越界户预检列出末晚与旧finalize拒绝越界户-修改接口-管理后台.md
## 关联 / 联系人
### 链接
- Issue: [#7325](https://git.1814.love:8443/wx/HL/issues/7325)
- PR: [#7743](https://git.1814.love:8443/wx/HL/pulls/7743)
- Merge commit: [88cab06dc](https://git.1814.love:8443/wx/HL/commit/88cab06dcdd9cadd1773b928894d180b8d3442a3)
### 联系人
- 后端负责人: @wx
@@ -0,0 +1,567 @@
---
schema: "hl-changelog/v2"
ticket: "7326"
title: "团期分房重算/人工微调:重判后不平的已完成户退回处理中,时间线新增 reopenedOrderIds"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "not_required"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-15"
status_note: "PR #7689(Issue #7326 AC-8)已 squash 合并 dev-v3(合并提交 b57413ab8)。2026-09-15 代码已确认部署在测试服(deploy-status.sh 实测),并对 GET status-logs 做了真实网关取证,证实 extra.reopenedOrderIds 字段真实存在(恒为数组)。backend_status 记 deployed:代码已部署且字段契约已实测。⚠️ 如实标注未覆盖范围:取证样本恰好都是空数组,未采到非空(真的退回)场景——该场景目前只有单测覆盖,非测试服端到端验证,详见正文「八」节。gateway_status=not_required:本次涉及的三个端点均为已有路由(/v3/admin/house/group-batches/**、/v3/admin/order/group-batch/**),未新增/修改任何路径或方法。frontend_status=pending:待前端确认「人工微调/重算分房提交后,之前已标记完成的户可能被静默退回处理中」这件事是否需要在结果弹窗提示,或在待配房列表/订单详情页对这类回退做特殊展示,故不定为 not_required。"
updated_at: "2026-09-15"
base: "dev-v3"
---
# 团期房务: 分房重算/人工微调把重判不平的已完成户退回处理中
> **存放目录**:
> - 一期(v2,无 `order-v3` 标签的工单)→ `changelogs/{YYYY-MM}/`
> - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/{YYYY-MM}/`
>
> **服务**: hl-order-service-v3 (端口 8086)
> **PR**: [#7689](https://git.1814.love:8443/wx/HL/pulls/7689)(Issue #7326 AC-8)
> **Issue**: #7326
> **日期**: 2026-09-15
> **影响范围**: 管理后台团期「人工微调分房」「重算分房」两个写口新增的副作用(已完成户可能被退回处理中);团期状态流水(时间线)接口的 `extra` 新增 `reopenedOrderIds` 字段
---
## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
- **本次变了什么**:H10(人工微调分房,`POST /v3/admin/house/group-batches/{groupBatchId}/allocations`)与 H11(重算分房,`POST .../allocations/rebuild`)在判定「团级配房完成标志判不出就绪、需要置回 false」这一分支时(`resetWhenNotReady=true`),新增了一个户级动作:把「此前已经是住宿已完成(`house_status=CONFIRMED` 且需求 `status=DONE`),但按最新分房重判后又不平」的户,CAS 退回处理中——需求 `status` 从 `DONE` 回退到 `PROCESSING`,`house_status` 改写为 `PENDING_CLAIM`,订单 `flow_status` 尝试从 `PENDING_CONFIRM` CAS 到 `RESOURCE_PREPARING`,并重新同步一次该订单的房务待办(`GroupBatchRoomDayConfirmManager.java:707-744`、`:833-843`;`GroupBatchRoomLifecycleManager.java:547-559`)。
- **前端/调用方以前以为的是什么**:H10/H11 只会影响「团级」的 `hotelReady` 之类聚合标志;某个户一旦被标记为「住宿已完成」,除非房务在这户自己的详情页手动操作,否则它的状态不会被别的团级操作(人工微调另一天、或重算另一晚)连带改动。
- **实际现在是什么**:只要本次 H10/H11 调用判不出团级就绪(`resetWhenNotReady=true` 分支被触发),凡是重判后不平的已完成户都会被一并静默退回处理中;H10/H11 自身的直接响应体(`GroupBatchRoomAllocationRebuildRespVO`)**不携带**这批被退回的户名单——调用方必须另外读团期状态流水接口(`GET /v3/admin/order/group-batch/{groupBatchId}/status-logs`),在 `eventType=BATCH_ROOM_ALLOC_MANUAL`/`BATCH_ROOM_ALLOC_REBUILD` 的记录里读 `extra.reopenedOrderIds` 才能知道具体是哪些子订单(`GroupBatchRoomAllocationManager.java:250-258`、`:329-338`)。不读时间线,前端只能看到该户重新出现在待配房列表/订单详情的房务进度条回退到「待配房」,却不知道原因。
---
## 一、背景(选填)
Issue #7326 AC-8:H10/H11 共用的 `GroupBatchRoomDayConfirmManager#settleHouseholdsAndHotelReady` 此前只做团级 `resetHotelReady`(把团级 `hotelReady` 置回 false),户级的 `DONE` + `house_status=CONFIRMED` 原样留着不动(`GroupBatchRoomDayConfirmManager.java:728`,2026-09-15 对照 origin/dev-v3 核实更新,2026-09-14 起草时的 708-712 因中间插入 #7700/#7459 等提交已漂移约 +20 行;javadoc 原话:「同一个假信号的户级版本」)。这会导致:核单侧依赖 `house_status=CONFIRMED`/需求 `status=DONE` 的户级闸门继续放行,待办也已经从房务列表里消失,但这户实际上已经因为重算/人工微调而少了房,两边状态不一致。
本次改动让 `resetWhenNotReady=true` 时,团级 `resetHotelReady` 与户级 `reopenUnbalancedHouseholds` 两个反向动作成对执行(同一个开关,不新增第二个开关;理由见 `GroupBatchRoomDayConfirmManager.java:713-716` javadoc:拆成两个参数就是同一条判据两份实现,容易两条链路各改各的、对「这个团安排好了没有」给出矛盾答案)。
H7/H8(另外两个会触发 `settleHouseholdsAndHotelReady` 的入口,见 `GroupBatchRoomDayConfirmManager.java:233`、`:346`)传的固定是 `resetWhenNotReady=false`——它们只会让事实变好(确认计划行、扣库存),不会触发户级回退,本次改动**不影响** H7/H8 这两个入口的行为。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 人工微调分房 | POST | `/v3/admin/house/group-batches/{groupBatchId}/allocations` | 副作用扩大 | 判不出团级就绪时,重判后不平的已完成户被 CAS 退回处理中;时间线 `extra` 新增 `reopenedOrderIds` |
| 2 | 重算分房 | POST | `/v3/admin/house/group-batches/{groupBatchId}/allocations/rebuild` | 副作用扩大 | 同上 |
| 3 | 团期状态流水(时间线) | GET | `/v3/admin/order/group-batch/{groupBatchId}/status-logs` | 响应内容新增字段 | `eventType=BATCH_ROOM_ALLOC_MANUAL`/`BATCH_ROOM_ALLOC_REBUILD` 两类记录的 `extra` 新增 `reopenedOrderIds`(数组,恒不为 null,可为空数组) |
---
## 三、接口详情
三个端点的请求参数、错误码均未变化;H10/H11 的直接响应体字段也未变化,只是多了一个不反映在响应体里的副作用;端点 3 的响应体新增一个 `extra` 子字段。以下逐接口自包含描述。
### 1. 人工微调分房 `POST /v3/admin/house/group-batches/{groupBatchId}/allocations`
**VO**: `GroupBatchRoomAllocationSaveReqVO → Result<GroupBatchRoomAllocationRebuildRespVO>`
#### 使用场景
房务在「团期分房」页面对某几条已确认计划行提交人工分房覆盖时调用(H10)。本次改动后,若提交这次微调导致判不出团级就绪,且团内存在此前已标记「住宿已完成」但按新分房结果不平的户,这些户会被自动退回处理中——这是本接口触发的副作用,不在直接响应体里体现,前端需要另外查询时间线(见本文件端点 3)或该户的订单详情才能感知。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| items | Body | Array\<Object\> | ❌ | 与 clearPlanIds 至少一个非空;最多 500 行 | 覆盖后的人工分房行集合(本次未改) |
| items[].planId | Body | String(雪花 ID) | ✅ | 须属本团、未软删且已确认 | 订房计划行 ID |
| items[].orderId | Body | String(雪花 ID) | ✅ | 须为本团在团子订单 | 分给哪一户 |
| items[].roomCount | Body | Integer | ✅ | 1-99 | 占用间数 |
| items[].roomGroupNo | Body | String | ❌ | 形如 F1~F999,不填按 F1 | 家庭分组号 |
| items[].travelerCount | Body | Integer | ❌ | 1-9 | 该房入住人数 |
| items[].bedType | Body | String | ❌ | single/double/twin/family,非法 808131 | 床型 code |
| items[].remark | Body | String | ❌ | ≤256 字 | 备注 |
| clearPlanIds | Body | Array\<String\>(雪花 ID) | ❌ | 与 items 至少一个非空;最多 200 条 | 这些计划行下的人工分房全部软删(回到纯自动分房) |
(以上入参字段结构本次未改,见 `GroupBatchRoomAllocationSaveReqVO.java`、`GroupBatchRoomAllocationItemReqVO.java`)
#### 出参 `Result<GroupBatchRoomAllocationRebuildRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.groupBatchId | String(雪花 ID) | 团期主订单 ID |
| data.force | Boolean | 本次是否重置了人工分房(H10 恒 false) |
| data.balanced | Boolean | 本次范围内所有日都对平、且无过时户、无阻塞户 |
| data.hotelReady | Boolean | 后置判定之后团期的配房完成标志;**本次起:若有已完成户被退回处理中,通常伴随此值为 false** |
| data.days[] | Array\<Object\> | 本次处理过的入住日结果(`leftover`/`shortage`/`manualConflicts`/`outOfRange` 等,字段结构本次未改) |
| data.skippedDays[] | Array\<Object\> | 未处理的入住日(计划行未确认) |
| data.staleOrderIdsBefore[] | Array\<String\> | 本次开始前判定为分房过时的户 |
| data.replacedAllocIds[] | Array\<String\> | 本次被软删的分房行 ID |
| data.warnings[] | Array\<Object\> | 户完成联动与团级判定过程中产生的告警 |
**⚠️ 本次改动不在这里新增字段**:被退回处理中的户 ID 列表**不在** `GroupBatchRoomAllocationRebuildRespVO` 里,只能从本文件端点 3(时间线)的 `extra.reopenedOrderIds` 读到。
#### 请求示例
```json
{
"items": [
{ "planId": "880101", "orderId": "70001", "roomCount": 1, "roomGroupNo": "F1" }
],
"clearPlanIds": []
}
```
#### 响应示例
以下取自 `GroupBatchRoomAllocationManagerTest#saveManual_householdsReopened_writesThemIntoTimeline` 测试夹具(团期 `groupBatchId=9001`,提交人工分房时触发户 `orderId=70002` 被判不平并退回),不是测试服抓包报文;响应体本身的字段结构与本次改动前逐字节相同:
```json
{
"code": 200,
"message": "成功",
"data": {
"groupBatchId": "9001",
"force": false,
"balanced": false,
"hotelReady": false,
"days": [],
"skippedDays": [],
"staleOrderIdsBefore": [],
"replacedAllocIds": [],
"warnings": []
},
"success": true
}
```
#### 空数据 / 降级响应
`items`/`clearPlanIds` 均传空数组时按 100001 拒绝(既有校验,本次未改,见「四、契约约束」);本接口不存在「有效提交但返回空数据」的形态——只要通过校验,`days` 至少包含受影响的入住日。
#### 错误响应
计划行未确认或不属本团(既有错误码,本次未改):
```json
{
"code": 808601,
"message": "计划行不存在或未确认",
"data": null,
"success": false
}
```
受影响户存在过时分房时整单拒绝(既有错误码,本次未改):
```json
{
"code": 808643,
"message": "存在分房过时的户,请先重算",
"data": null,
"success": false
}
```
#### 业务边界
- **鉴权**:`@HouseWriteGuarded`,非房务角色在幂等/锁窗口之前即被拦截;团期须处于可配房阶段,否则 `assertConfirmableStage` 拒绝(既有逻辑,本次未改)。
- **团期级锁**:`@Lock4j`(`house-groupbatch-lock`),同团期并发提交串行化,本次未改。
- **新增副作用只在 `resetWhenNotReady=true` 分支触发**:本端点内部固定传 `true`(`GroupBatchRoomAllocationManager.java:392` 起的 `settleAndFinish` 私有方法),即 H10 每次调用都参与户级回退判定,不是可选行为,前端不能假设「只微调了没关系的一天就不会影响别的已完成户」。
- **只退真的从 DONE 被 CAS 掉的户**:若某户虽然「不平」但此前并不是完成态(`status≠DONE`),CAS 直接返回 0 行,`reopenedOrderIds` 不计入它,也不会有任何写操作发生在它身上(`GroupBatchRoomLifecycleManager.java:601-613` `reopenHousehold` 方法体,2026-09-15 核实更新,2026-09-14 起草时的 551-553 已漂移约 +50 行)。
- **全自订户不受影响**:全自订户走的是 `completeSelfBookedHouseholds` 独立判定路径,不进入 `evaluateHouseholds` 的返回集合,因而结构性地碰不到本次新增的退回逻辑(`GroupBatchRoomDayConfirmManager.java:826-829`)。
---
### 2. 重算分房 `POST /v3/admin/house/group-batches/{groupBatchId}/allocations/rebuild`
**VO**: `GroupBatchRoomAllocationRebuildReqVO → Result<GroupBatchRoomAllocationRebuildRespVO>`
#### 使用场景
团期管理员重新确认需求后,房务在「团期分房」页面点击「重算分房」调用(H11)。可指定 `stayDate` 只重算某一天,或不传重算整团全部已确认日;`force=true` 会先把范围内的人工分房整片软删。本次改动后,重算若导致某个已完成户不再平,同样会被自动退回处理中。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| force | Body | Boolean | ❌ | 默认 false | 是否先重置全部人工分房 |
| stayDate | Body | String(日期,`yyyy-MM-dd`) | ❌ | 不传 = 整团全部已确认日 | 只重算某一入住日 |
| reason | Body | String | `force=true` 时 ✅ | ≤256 字 | 重置原因,写进团级时间线 |
(以上入参字段结构本次未改,见 `GroupBatchRoomAllocationRebuildReqVO.java`)
#### 出参 `Result<GroupBatchRoomAllocationRebuildRespVO>`
字段结构与端点 1 完全相同(两端点共用同一个 `GroupBatchRoomAllocationRebuildRespVO`),差异只在于 `force`/`days` 的取值来源不同;`reopenedOrderIds` 同样不出现在这里。
| 字段 | 类型 | 说明 |
|------|------|------|
| data.groupBatchId | String(雪花 ID) | 团期主订单 ID |
| data.force | Boolean | 本次是否重置了人工分房(随请求 `force` 回显) |
| data.balanced | Boolean | 本次范围内所有日都对平、且无过时户、无阻塞户 |
| data.hotelReady | Boolean | 后置判定之后团期的配房完成标志;**本次起:若有已完成户被退回处理中,通常伴随此值为 false** |
| data.days[] | Array\<Object\> | 本次处理过的入住日结果(字段结构本次未改) |
| data.skippedDays[] | Array\<Object\> | 未处理的入住日(计划行未确认) |
| data.staleOrderIdsBefore[] | Array\<String\> | 本次开始前判定为分房过时的户 |
| data.replacedAllocIds[] | Array\<String\> | 本次被软删的分房行 ID |
| data.warnings[] | Array\<Object\> | 户完成联动与团级判定过程中产生的告警 |
#### 请求示例
```json
{
"force": false,
"stayDate": "2026-06-12"
}
```
#### 响应示例
以下取自 `GroupBatchRoomAllocationManagerTest#rebuild_householdsReopened_writesThemIntoTimeline` 测试夹具(团期 `groupBatchId=9001`,重算导致户 `orderId=70002` 判不平并退回),不是测试服抓包报文:
```json
{
"code": 200,
"message": "成功",
"data": {
"groupBatchId": "9001",
"force": false,
"balanced": false,
"hotelReady": false,
"days": [],
"skippedDays": [],
"staleOrderIdsBefore": [],
"replacedAllocIds": [],
"warnings": []
},
"success": true
}
```
#### 空数据 / 降级响应
范围内无已确认计划行时拒绝(既有错误码 808644,本次未改);未确认的日进 `skippedDays`,不算错误。
#### 错误响应
范围内无已确认计划行(既有错误码,本次未改):
```json
{
"code": 808644,
"message": "范围内无已确认计划行,无法重算",
"data": null,
"success": false
}
```
`force=true` 但未填 `reason`(既有校验,本次未改):
```json
{
"code": 100001,
"message": "reason 不能为空",
"data": null,
"success": false
}
```
#### 业务边界
- **鉴权/锁**:同端点 1。
- **新增副作用同样固定触发**:本端点内部同样固定传 `resetWhenNotReady=true`(`GroupBatchRoomAllocationManager.java:329` 起),`force=false`(默认档,只重跑自动分房、保留人工分房)与 `force=true`(先软删人工分房再重来)两档都会触发户级回退判定,不因 `force` 取值而豁免。
- **`stayDate` 只影响本次重算的范围,不影响户级回退的判定范围**:`reopenUnbalancedHouseholds` 对全团重新判定平衡(`evaluateHouseholds` 判的是每户全部基线晚,不按 `stayDate` 收窄),因此即使只重算了某一天,如果这户在**另一天**早就不平,本次也会一并把它退回处理中(`GroupBatchRoomDayConfirmManager.java:821-823` javadoc)。
- **只退真的从 DONE 被 CAS 掉的户**:同端点 1。
---
### 3. 团期状态流水(时间线) `GET /v3/admin/order/group-batch/{groupBatchId}/status-logs`
**VO**: `Result<List<GroupBatchStatusLogItemVO>>`(无请求体,仅路径参数)
#### 使用场景
管理后台团期详情页查看操作历史/时间线时调用,按 `changedAt` 升序返回该团期全部流水,不分页。本次起,H10/H11 触发的 `BATCH_ROOM_ALLOC_MANUAL`/`BATCH_ROOM_ALLOC_REBUILD` 两类记录的 `extra` 多一个 `reopenedOrderIds` 字段,前端可用它在时间线条目上提示「本次操作把 N 户退回了处理中」。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | - | 团期主订单 ID |
#### 出参 `Result<List<GroupBatchStatusLogItemVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data[].logId | String(雪花 ID) | 流水 ID |
| data[].groupBatchId | String(雪花 ID) | 团期 ID |
| data[].changeType | String | `STATUS`/`DATA`;本次涉及的两类事件恒为 `DATA` |
| data[].eventType | String | 事件类型值,本次涉及 `BATCH_ROOM_ALLOC_MANUAL`/`BATCH_ROOM_ALLOC_REBUILD` |
| data[].eventTypeName | String | 事件类型中文标签,如「房务人工微调分房」「房务重算分房」 |
| data[].content | String | 展示文本,如「房务人工微调分房(1 行,涉及 1 天)」 |
| data[].extra | Object/null | 附加快照(已从 JSON 字符串解析为对象);**本次起,`BATCH_ROOM_ALLOC_MANUAL`/`BATCH_ROOM_ALLOC_REBUILD` 两类记录新增 `extra.reopenedOrderIds` 字段** |
| data[].extra.reopenedOrderIds | Array\<Number\> | **新增字段**。本次操作真被 CAS 退回处理中的子订单 ID 数组;恒为数组(不会是 null),CAS 未命中任何户时为 `[]` |
| data[].changedAt | String(时间) | 变更时间 |
(`operatorType`/`operatorId`/`operatorName`/`reason` 等既有字段本次未改,未逐一列出)
#### 请求示例
```http
GET /v3/admin/order/group-batch/9001/status-logs
Authorization: Bearer <token>
```
#### 响应示例
以下取自 `GroupBatchRoomAllocationManagerTest#rebuild_householdsReopened_writesThemIntoTimeline` 测试夹具中对 `extra` 的断言(该用例只断言 `extra` map 的内容,未经过完整的 `GroupBatchStatusLogItemVO` 序列化链路;`logId`/`changedAt` 等字段为示意值,不是取自该用例):
```json
{
"code": 200,
"message": "成功",
"data": [
{
"logId": "90501",
"groupBatchId": "9001",
"changeType": "DATA",
"eventType": "BATCH_ROOM_ALLOC_REBUILD",
"eventTypeName": "房务重算分房",
"fromStatus": "RESOURCE_PREPARING",
"fromStatusName": "资源准备中",
"toStatus": "RESOURCE_PREPARING",
"toStatusName": "资源准备中",
"content": "房务重算分房(1 天,force=false)",
"reason": null,
"operatorType": "ADMIN",
"operatorId": "1001",
"operatorName": "小呼",
"extra": {
"force": false,
"reason": null,
"stayDate": "2026-06-12",
"unbalancedDays": ["2026-06-12"],
"manualReset": 0,
"reopenedOrderIds": [70002]
},
"changedAt": "2026-09-14 10:21:33"
}
],
"success": true
}
```
未触发任何户级回退时,同一 `extra` 里 `reopenedOrderIds` 为空数组(取自 `rebuild_manualExceedsNewDemand_keepsManualAndReportsConflict` 等既有用例默认桩 `SettleResult(List.of(), List.of(), false)`):
```json
{ "extra": { "reopenedOrderIds": [] } }
```
#### 空数据 / 降级响应
该团期从未发生过任何生命周期事件(理论场景,团期一旦创建即有 `BATCH_GROUP` 建团记录)时返回空数组:
```json
{ "code": 200, "message": "成功", "data": [], "success": true }
```
`extra` 解析失败或该条记录本身无附加数据时,`extra` 为 `null`,不是空对象(既有降级规则,本次未改,见 `GroupBatchStatusLogItemVO.java:17-19` javadoc)。
#### 错误响应
`groupBatchId` 指向不存在的团期(既有错误码,本次未改):
```json
{
"code": 808001,
"message": "团期不存在",
"data": null,
"success": false
}
```
#### 业务边界
- **鉴权**:走网关统一鉴权,未登录 401;本端点不带额外角色限制(与改动前一致)。
- **`reopenedOrderIds` 只对 `BATCH_ROOM_ALLOC_MANUAL`/`BATCH_ROOM_ALLOC_REBUILD` 两类事件出现**:其余事件类型(如 `BATCH_GROUP`、`BATCH_HOUSE_CLAIM`)的 `extra` 结构不受本次改动影响。
- **该字段只反映「CAS 真命中」的户**:调用方不应据此反推「团内还有多少户不平」——不平但本来就不是完成态的户不会出现在这里(不属于「退回」,因为它们从未处于「已完成」态)。
- **写入是尽力而为、不阻断主链路**:时间线写入走 `recordHouseTimelineQuietly` 降级包装,DB 类异常之外的失败只记日志不抛出;但注意它与主事务共用一个事务边界,DB 类异常仍会导致整个 H10/H11 调用连带回滚(`GroupBatchService.java:346-360`(origin 行号)javadoc 明确写了这条边界)。
- **只读**:本端点不提供任何写口。
---
## 四、契约约束与正确调用方式(接口类必写)
> 本节只写**后端接受/拒绝 payload 的规则与调用后必须做的动作**,不写 UI 渲染建议。
### ✅ 正确 / ❌ 错误 调用方式对照
| 场景 | 说明 |
|------|------|
| ✅ 提交 H10/H11 后,若 `hotelReady=false`,前端另外查一次时间线确认是否有户被退回 | `reopenedOrderIds` 只在时间线里,响应体的 `hotelReady=false` 只说明团级判不出就绪,不说明是不是有已完成户被动了状态 |
| ✅ 展示某户的房务进度条前,以 `GET /admin/house/orders/{orderId}` 返回的 `progress.houseStatus` 为准 | 该户是否被退回处理中,最终权威落在这个字段上,不要用前端本地缓存的「已完成」状态继续渲染 |
| ❌ 认为「H10/H11 返回 200 且响应体没有报错字段」就等于「除了本次显式提交的行,其他户状态不受影响」 | 已完成户被退回是**静默副作用**,不在响应体里报告,200 不代表没有连带影响 |
| ❌ 把 `extra.reopenedOrderIds` 缺失当成「没有户被退回」 | 该字段本次才新增;改动前产生的历史时间线记录(`changedAt` 早于本次上线)不会有这个字段,前端按字段缺失兜底为「未知」而不是「一定为空」 |
### 切换状态时的必要动作
被退回处理中的户,其需求会重新出现在房务「待配房」相关列表/抢单池、待办也会重新生成(`orderTodoSyncContract.syncForOrder(orderId, SYNC_REASON_REOPEN)`,实现在 `GroupBatchRoomLifecycleManager.java:611`,2026-09-15 核实更新;⚠️ 2026-09-14 起草时把这个内部原因常量的字面值错写成了 `"REOPEN"`,源码实际值是 `"group room plan revised"`——该值只用于待办同步的内部 reason 参数,不出现在任何对外响应字段里,故本节此前的错误不影响契约本身,只更正引用准确性)。前端若维护了「已完成」本地状态缓存或已勾选/已处理的 UI 状态,收到时间线里的 `reopenedOrderIds` 非空后应主动刷新这些户的展示,不要依赖用户手动刷新页面。
---
## 五、数据库行为(涉及写操作时必写)
| 前置条件 | `order_hotel_requirement.status` 列 | `order_hotel_requirement.house_status` 列 | 子订单 `order_main.flow_status` 列 |
|----------|--------------------------------------|---------------------------------------------|--------------------------------------|
| 该户当前 `status=DONE`,重判后不平,CAS 命中 | `DONE` → `PROCESSING`(CAS,仅当前恰为 `DONE` 时生效) | → `PENDING_CLAIM` | 仅当前恰为 `PENDING_CONFIRM` 时 CAS → `RESOURCE_PREPARING`,否则原样不动(CAS 结果不影响 `reopenedOrderIds` 是否计入该户) |
| 该户当前 `status≠DONE`(已经不是完成态) | 不变(CAS 0 行) | 不变 | 不变 |
| 该户重判后仍平(`balanced=true`) | 不变 | 不变 | 不变 |
**幂等/并发**:`reopenHousehold` 的第一条语句就是 `requirement.status` 的 DONE-only CAS,非完成态或已被并发操作改动的户会在这一步返回 0 行,后续 `house_status`/`flow_status`/待办同步三个写操作都不会执行(`RequirementService.java:3560-3566`(origin 行号,未在本轮复核);`GroupBatchRoomLifecycleManager.java:601-613`,2026-09-15 核实更新)。
---
## 六、边界行为
- 未登录 → 401(网关拦截)
- 团期不存在 → 808001(端点 3)/ 已有错误码(端点 1/2 建团期不存在时的行为本次未改)
- 计划行未确认/不属本团 → 808601(既有,未改)
- 存在过时分房 → 808643(既有,未改)
- 范围内无已确认计划行 → 808644(既有,未改)
- 户重判后不平但本来就不是完成态 → 不产生任何写操作,不出现在 `reopenedOrderIds` 里,不是错误
- 全自订户 → 结构性不参与本次新增的判定,不受影响
- 时间线写入失败(非 DB 类异常)→ 降级为只记日志,H10/H11 主链路不受影响;DB 类异常仍会导致整次调用回滚
---
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
以下枚举不是本文件端点 1/2/3 直接返回的字段,但是理解「已完成户被退回处理中」这一行为对页面展示影响的必要背景——它们出现在**另一个未被本次改动的既有端点** `GET /admin/house/orders/{orderId}`(房务侧订单详情聚合,§2.0)的 `data.itinerary.progress.houseStatus` 字段上;本次改动只是让部分订单的这个字段值出现回退,字段本身与端点契约均未变。
### houseStatus(`com.hulalv.house.statemachine.HouseStateEnum`)
**所属字段**: `HouseOrderDetailRespVO.Itinerary.Progress.houseStatus`(`GET /admin/house/orders/{orderId}`,非本次变更接口) | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `PENDING_CLAIM` | 待配房 | **本次改动后,被退回处理中的户会落到这个值**(`HouseStateEnum.java:34`) |
| `CLAIMING` | 抢单中 | 本次未涉及 |
| `PENDING_FINALIZE` | 待核实 | 本次未涉及 |
| `CONFIRMED` | 已完成 | **被退回前的原值**(`HouseStateEnum.java:48`) |
| `EXCEPTION` | 异常 | 本次未涉及 |
### flow_status(`com.hulalv.order.core.enums.OrderFlowStatus`,节选)
**所属字段**: 子订单 `order_main.flow_status`(内部字段,无对外只读端点直接暴露该原始枚举值;本次列出仅为解释 CAS 的判定条件) | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `PENDING_CONFIRM` | 待确认 | 被退回时 CAS 的**判定条件**:仅当子订单当前恰为此值,回退才会把它 CAS 成 `RESOURCE_PREPARING`(`OrderFlowStatus.java:28`) |
| `RESOURCE_PREPARING` | 资源准备 | CAS 命中后的**目标值**(`OrderFlowStatus.java:27`) |
---
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| H10/H11 直接响应体(`GroupBatchRoomAllocationRebuildRespVO`) | 字段结构不变 | **字段结构完全不变**——本次不改响应体任何字段 |
| `status-logs` 响应 `extra`(`BATCH_ROOM_ALLOC_MANUAL`/`BATCH_ROOM_ALLOC_REBUILD`) | 无 `reopenedOrderIds` 字段 | 新增 `reopenedOrderIds: Array<Number>`,恒为数组(不为 null) |
| 已完成户的 `house_status`(经 `GET /admin/house/orders/{orderId}`) | 一旦 `CONFIRMED`,不会因为**别的户**的分房调整而改变 | 团级 H10/H11 判不出团级就绪时,重判不平的已完成户可能被静默改回 `PENDING_CLAIM` |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| H10/H11 判不出团级就绪时(`resetWhenNotReady=true` 分支) | 只置回团级 `hotelReady`;户级 `DONE`/`CONFIRMED` 原样保留 | 团级置回 + 重判不平的已完成户 CAS 退回处理中(需求 `status`/`house_status`/子订单 `flow_status`/待办同步四件事) |
| 房务待配房列表/抢单池 | 已完成户不会出现 | 被退回处理中的户会重新出现(`orderTodoSyncContract.syncForOrder` 触发待办重同步) |
| 时间线可追溯性 | H10/H11 的时间线记录只报告受影响日/受影响计划行,不报告户级状态回退 | 新增 `extra.reopenedOrderIds`,可精确定位是哪些子订单被退回 |
| H7/H8(`resetWhenNotReady=false` 的两个入口) | 不触发户级回退 | **仍不触发**,本次改动对它们零影响 |
---
## 六.7、影响评估(修改/删除类必写)
- **是否破坏向后兼容**:否。三个端点的路径、方法、请求参数、错误码均未变化;H10/H11 响应体字段结构未变;端点 3 只新增一个此前不存在的可选字段(`extra.reopenedOrderIds`),旧前端忽略未知字段不受影响。
- **前端是否必须同步上线**:不同步上线不会导致接口调用失败,但存在一个真实的展示风险——已完成户可能被静默退回,若前端在人工微调/重算分房的结果弹窗、以及待配房相关列表页对此毫无提示,运营/房务可能不知道「为什么这户又出现在待办里了」。建议前端读取端点 3 的 `extra.reopenedOrderIds` 并在结果里提示。
- **前端 workaround 清理点**:无(此前没有相关字段可供 workaround)。
---
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- **仅影响**:H10(人工微调)、H11(重算分房)判不出团级就绪分支下的已完成户状态;团期状态流水接口 `BATCH_ROOM_ALLOC_MANUAL`/`BATCH_ROOM_ALLOC_REBUILD` 两类记录的 `extra`。
- **零影响**:
- H9 分房总览(只读,`GET /v3/admin/house/group-batches/{groupBatchId}/allocations`)——零副作用,未调用 `settleHouseholdsAndHotelReady`
- H7/H8(`resetWhenNotReady=false` 的两个既有入口)
- 全自订户的完成态判定
- 团级 `hotelReady`/`resetHotelReady` 本身的既有语义与既有判定逻辑
- 月度对账、核单结算等其他房务只读端点(另见 `changelogs-v2/2026-09/14_7327_月度对账并入团期计划与分房行-修改接口-管理后台.md`)
- 团期车务(fleet)域
---
## 八、测试环境已验证
**部署与实测状态**:修复本身(PR #7689,合并提交 `b57413ab8`)已合入 dev-v3 并是测试服当前部署基线(`hl-order-service-v3` 2026-09-15 13:12 部署于 `6a7b43a17`,经 `deploy-status.sh` 实测确认)的祖先,代码已在测试服跑着。2026-09-15 对 `GET /v3/admin/order/group-batch/{groupBatchId}/status-logs` 做了真实网关取证(账号 1001,只读 GET),**证实了 `extra.reopenedOrderIds` 字段真实存在于线上响应**,但取证用的团期(`groupBatchId=2099692512066105346`,另一会话当天为工单 #7327 AC-24/25 造的真实夹具)当时只有单户、且该户处于单户团自动回填场景,本轮 7 条 `BATCH_ROOM_ALLOC_MANUAL` 记录的 `extra.reopenedOrderIds` 均为空数组——**证实了字段契约(恒为数组、不为 null),但没有证实非空场景**(即真的有已完成户被退回处理中的那条主线)。这不是负面证据,单测已覆盖非空场景,backend_status 仍记 `deployed`(代码已部署、字段契约已实测),但「真的有户被退回处理中」这一核心场景请前端联调时按「单测覆盖、未端到端验证」对待,待专项造数(多户团、其中一户先置 DONE 再改分房让它不平)后补测。以下先列本机自动化单测断言,**不是测试服抓包**:
```
GroupBatchRoomDayConfirmManagerTest(origin/dev-v3,新增 4 例):
settleHouseholdsAndHotelReady_unbalancedDoneHousehold_reopenedWithItsOwnBasis
→ 重判不平的已完成户被退回,reopenedOrderIds=[ORDER_ID],doneOrderIds 不含该户 ✓
settleHouseholdsAndHotelReady_unbalancedNotDoneHousehold_notListedAsReopened
→ 不平但原非完成态的户,CAS 落空,不进回退名单(verify(reopenHousehold) 正向对照) ✓
settleHouseholdsAndHotelReady_mixedBalance_reopensOnlyTheUnbalancedHousehold
→ 同一次调用,已平户走完成、不平户走回退,两条路互斥 ✓
settleHouseholdsAndHotelReady_unbalancedDoneHouseholdButResetNotRequested_neverReopens
→ resetWhenNotReady=false(H7/H8 语义)时,同样的不平事实不触发任何回退 ✓
GroupBatchRoomAllocationManagerTest(origin/dev-v3,新增 2 例):
rebuild_householdsReopened_writesThemIntoTimeline
→ H11 时间线 extra.reopenedOrderIds=[OTHER_ORDER_ID=70002] ✓
saveManual_householdsReopened_writesThemIntoTimeline
→ H10 时间线同样落 extra.reopenedOrderIds=[OTHER_ORDER_ID=70002] ✓
全量(PR 描述自报):Tests run: 81, Failures: 0, Errors: 0(GroupBatchRoomAllocationManagerTest=49 + GroupBatchRoomDayConfirmManagerTest=32)
```
**2026-09-15 测试服真实取证(账号 1001,只读 GET,证实字段契约,未证实非空场景)**:
```
GET /v3/admin/order/group-batch/2099692512066105346/status-logs → 200
→ 共 16 条流水,其中 7 条 eventType=BATCH_ROOM_ALLOC_MANUAL
→ 逐条 extra 均含 reopenedOrderIds 字段,7 条全部为空数组 []
→ 例:{"stayDates": ["2027-01-25"], "planIds": ["2099692682136743937"], "itemCount": 2,
"clearedPlanIds": [], "reopenedOrderIds": []}
→ 该团期是当天另一会话为工单 #7327 AC-24/25 造的真实夹具(单户团),字段存在但本次未观察到
非空样本;非空场景(真的有已完成户被退回)目前仍只有单测覆盖
```
网关:本次无新增/修改路径,三个端点沿用既有路由。测试服部署已确认(见上),`extra.reopenedOrderIds` 字段的存在性已实测,但「非空、真的退回了户」这一核心场景仍待专项造数验证,建议按此专项补测。
---
## 十、相关文档
- 关联 Issue: [wx/HL#7326](https://git.1814.love:8443/wx/HL/issues/7326)
- 关联 PR: [wx/HL#7689](https://git.1814.love:8443/wx/HL/pulls/7689)
- 同一 Issue 下与本次相关的既有背景:H10/H11 的完整契约、错误码见工单 #7326 正文
- 团期状态流水端点的通用契约背景: `changelogs-v2/2026-09/14_7327_月度对账并入团期计划与分房行-修改接口-管理后台.md`(引用了同一个 `GroupBatchStatusLogItemVO`)
- 同一 Issue 下 #7325 的另一改动(confirm-check 预检行粒度变化 + 旧 finalize 拒绝越界户,PR #7700,已合并): `changelogs-v2/2026-09/15_7325_越界户预检列出末晚与旧finalize拒绝越界户-修改接口-管理后台.md`
## 关联 / 联系人
### 链接
- **Issue**: [#7326](https://git.1814.love:8443/wx/HL/issues/7326)
- **PR**: [#7689](https://git.1814.love:8443/wx/HL/pulls/7689)
- **Merge commit**: [b57413ab8](https://git.1814.love:8443/wx/HL/commit/b57413ab8eb201d41110e2330909de51a6524b13)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,399 @@
---
schema: "hl-changelog/v2"
ticket: "7327"
title: "团单取消/终止产生的 REFUND 待办:owner 回落到团级认领人,不再恒为广播态"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "not_required"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-15"
status_note: "PR #7679(Issue #7327 AC-17)已 squash 合并 dev-v3(合并提交 6169a612a)。2026-09-15 代码已确认部署在测试服(deploy-status.sh 实测),并对 GET /v3/admin/order/todos 做了真实网关取证,证实了字段契约本身(含 2026-09-14 起草时写错的 todoTypeName/groupBatchId 已更正为 todoTypeLabel/teamNo,并补充了此前遗漏的 todoTypes[] 聚合数组)。backend_status 记 deployed:代码已部署且响应字段契约已实测。⚠️ 如实标注未覆盖范围:本单核心修复点(团单户级为空、回落读团级认领人这一分支)本轮未能采到「团单+户级为空+团期已认领+OPEN」四条件同时成立的真实样本,抓到的 REFUND 样本都是户级直接抢单的散客单——该分支目前只有单测覆盖,非测试服端到端验证,详见「八、测试环境已验证」。另需注意 #7459(PR #7731)已把本条讨论的取消触发路径改为约 1 秒的异步 outbox,详见正文「切换状态时的必要动作」更正。PR 正文已记录 2026-09-14 一次测试服实测(取消订单 2099318713927778306 产出 owner_user_id=NULL 的缺陷现象),那是修复前的缺陷复现证据,不是修复后的验证。gateway_status=not_required:受影响的 GET /v3/admin/order/todos 是已有路由,本次未新增/修改任何路径。frontend_status=pending:待前端确认房务待办列表页是否需要对「owner 从广播态变为团级认领人」这类变化做任何界面提示,故不定为 not_required。"
updated_at: "2026-09-15"
base: "dev-v3"
---
# 房务待办: 团单取消/终止产生的 REFUND 待办 owner 回落到团级认领人
> **存放目录**:
> - 一期(v2,无 `order-v3` 标签的工单)→ `changelogs/{YYYY-MM}/`
> - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/{YYYY-MM}/`
>
> **服务**: hl-order-service-v3 (端口 8086)
> **PR**: [#7679](https://git.1814.love:8443/wx/HL/pulls/7679)(Issue #7327 AC-17)
> **Issue**: #7327
> **日期**: 2026-09-15
> **影响范围**: 管理后台房务待办列表 `GET /v3/admin/order/todos` 中,团单取消/终止产生的 `todoType=REFUND` 记录的 `ownerUserId`/`ownerName` 取值,以及该记录归属的 `scope`(我的/同事在跟)
---
## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
- **本次变了什么**:`HouseAssignmentService#findClaimerIdByOrder`(`HouseAssignmentService.java:767-777`,2026-09-15 对照 origin/dev-v3 核实,因中间插入 #7459 outbox 改造行号较 2026-09-14 起草时的 752-762 漂移约 +15 行)在户级 `order_hotel_requirement.claimer_id` 为空时,新增回落逻辑——查该订单所属团期,读团级认领人(`GroupBatchService#listHouseClaimersIncludeDeleted`)作为兜底;`HouseTodoService.handleOrderCancelled`/`handleOrderTerminated`(`HouseTodoService.java:1785`、`:1886`,同日一并核实更新)取 owner 时调的就是这个方法,两处均无需改动即自动受益。⚠️ `handleOrderCancelled` 的**触发方式**已在 #7459(PR #7731,见另一份 changelog)改为异步 outbox 命令,详见本节末尾追加说明。
- **前端/调用方以前以为的是什么**:团单(`order_main` 挂了团期)取消或终止行程时产生的 `REFUND` 待办,`ownerUserId` 会像散客单一样,取自该订单用房需求的户级抢单人(`order_hotel_requirement.claimer_id`);抢单人存在就应该有值。
- **实际现在是什么,以及此前的真实缺陷**:**团单的户级 `claimer_id` 结构性恒为 NULL**——团单房务归属走的是团级认领(认领入口是抢单池,而抢单池的基础过滤 `HotelRequirementMapper.java:718` `w.isNull(OrderInfo::getProductBatchId)` 把团单排除在外,团单根本进不了户级抢单流程)。**修复前**:这导致团单取消/终止产生的 `REFUND` 待办 `ownerUserId` 恒为 `NULL`,全部落入广播桶(PR 正文记录了 2026-09-14 的一次测试服实测:取消订单 `2099318713927778306` 产出 `house_todo id=2099341634310201345, todoType=REFUND, owner_user_id=NULL`,同团 `house_claimer_id=1001` 却没有被用上)。**修复后**:户级取不到时会回落读团级认领人,团单产生的 `REFUND` 待办会有真实 owner;两级都无归属时仍保留广播态(`ownerUserId=null`),不是失败,是设计内语义。
---
## 一、背景(选填)
Issue #7327 AC-17:口径 8 把 `HouseAssignmentService#hasActiveAssignment` 扩成两段判定后,团单取消/终止也会走进 `HouseTodoService` 的「已配房」分支产 `REFUND` 待办(`HouseTodoService.java:1750`、原 handleOrderCancelled 分支 2a)。但取 owner 的那条链路当时没有跟着扩——两处都只调 `findClaimerIdByOrder`,而它此前只读户级 `claimer_id`。
| 维度 | 证据 |
|------|------|
| 户级 `claimer_id` 写口 | 全仓只有两处:`HotelRequirementMapper.casClaimByRequirementId`(抢单,WHERE `claimer_id IS NULL`)、`casTransferByRequirementId`(转单,WHERE `claimer_id` 已非空) |
| 抢单入口的过滤 | `HotelRequirementMapper.java:718`:`w.isNull(OrderInfo::getProductBatchId)`,团单(`product_batch_id` 非空)结构性进不去 |
| 结论 | 团单户级 `claimer_id` 永远是 NULL,不是偶发脏数据 |
(以上引用自 PR #7679 正文「🔴 户级恒 NULL 是结构性的,不是『数据没跑到』」一节)
修复只动了 owner 取值这一条链路,不改「是否产生 REFUND 待办」的既有判定(`hasActiveAssignment`/口径 8 是先于本次改动就存在的逻辑,不在本次变更范围内)。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 房务待办列表 | GET | `/v3/admin/order/todos` | 响应内容修正 | 团单取消/终止产生的 `todoType=REFUND` 记录,`ownerUserId`/`ownerName` 从恒为 `null` 改为回落团级认领人 |
---
## 三、接口详情
响应字段结构、请求参数、错误码均未变化,本次只修正了特定条件下返回内容的**取值**(一个此前恒为 `null` 的字段,在团单场景下会有真实值)。
### 1. 房务待办列表 `GET /v3/admin/order/todos`
**VO**: `HouseTodoPageReqVO → Result<HouseTodoListRespVO>`
#### 使用场景
房务在「待办」页面查看自己的/同事的/全部待办时调用,13 个查询参数支持按归属范围、类型、状态、紧急度等筛选。本次起,团单(挂了团期的子订单)取消或终止后产生的 `todoType=REFUND` 待办,若该团有房务认领人,会带着真实 `ownerUserId` 出现——这决定了它出现在**谁的** `scope=mine` 列表里,以及是否出现在别人的 `scope=others` 列表里。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| scope | Query | String | ❌ | mine(默认)/others/all | mine=owner=当前用户 或 owner=NULL;others=owner≠当前用户 且 非 NULL;all=不过滤(仅房务组长/超管可用)。本次未改语义,但受影响记录会因 owner 从 NULL 变为团级认领人 ID 而换到不同 scope 桶 |
| todoType | Query | String | ❌ | 逗号分隔多选,如 `REFUND,SWAP_HOTEL` | 待办类型过滤,空=全部 |
| status | Query | String | ❌ | OPEN/RESOLVED | 状态过滤,空=全部 |
| urgency | Query | String | ❌ | danger/warn/normal | 紧急度过滤(运行时推导值) |
| keyword | Query | String | ❌ | ≤32 字 | 模糊搜 title/reason/团号 |
| orderId | Query | Long | ❌ | - | 订单 ID 过滤 |
| ownerUserId | Query | Long | ❌ | 配合 scope=others/all | 归属房务 ID 过滤 |
| hotelId | Query | Long | ❌ | - | 酒店 ID 过滤 |
| createTimeFrom / createTimeTo | Query | String(ISO 8601) | ❌ | - | 创建时间范围 |
| overdueMinutes | Query | Integer | ❌ | - | 仅看超时 N 分钟以上 |
| sortBy | Query | String | ❌ | 默认 `urgency,desc,createTime,desc` | 排序 |
| pageNo / pageSize | Query | Integer | ❌ | 继承自 `PageParam`,本次未改 | 分页 |
(以上入参字段结构本次未改,见 `HouseTodoPageReqVO.java`)
#### 出参 `Result<HouseTodoListRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.list[] | Array\<Object\> | ⚠️ 2026-09-15 核实更正:这是**订单聚合行**(一个订单一行,同订单多类型待办聚合进 `todoTypes[]`),不是「待办列表(已按默认排序)」这种一条待办一行的旧描述——2026-09-14 起草时未对照源码,按字面猜测成了扁平列表,实际结构见 `HouseTodoItemVO.java`。本条 changelog 讨论的 `REFUND` 类型走顶层「主标签」兼容字段,读顶层字段即可拿到 owner,不强制读 `todoTypes[]` |
| data.list[].id | Long | 该行「主标签」(本单最高紧急度类型)对应的待办 ID |
| data.list[].todoType | String | 该行「主标签」类型 code,本次涉及 `REFUND` |
| data.list[].todoTypeLabel | String | 待办类型中文标签。⚠️ 字段名更正:字段是 `todoTypeLabel`,不是 2026-09-14 起草时写的 `todoTypeName`(源码 `HouseTodoItemVO.java` 无 `todoTypeName` 字段) |
| data.list[].title | String | 标题,如「客人取消订单 · 请处理酒店退订」/「客人终止行程 · 请处理酒店退订」 |
| data.list[].orderId | Long | 订单 ID |
| data.list[].orderNo | String | 订单号 |
| data.list[].teamNo | String/null | 团号(未生成时为 null)。⚠️ 字段名更正:字段是 `teamNo`,不是 2026-09-14 起草时写的 `groupBatchId`(该 VO 里不存在 `groupBatchId` 字段,写成这个名字是未对照源码的臆造)。本次涉及的记录该字段应非空(团单才会走到本次修复的回落逻辑) |
| data.list[].todoTypes[] | Array\<Object\> | 本单待办类型标签数组(同订单多类型聚合,一类型一标签),2026-09-14 起草时遗漏未记录。子字段含 `typeCode`/`typeLabel`/`urgency`/`count`/`derived`/`todoId`/`requirementId`/`unreadCount`,见 `HouseTodoItemVO.TodoTypeTag` |
| data.list[].**ownerUserId** | Long/null | **本次修正取值来源**。归属房务 ID,`null`=广播态。团单场景下:户级抢单人为空时,本次起改为读团级认领人;两级都空仍为 `null`(设计内广播态) |
| data.list[].**ownerName** | String/null | **随 ownerUserId 联动**。归属房务姓名;`ownerUserId` 非空时应有对应姓名 |
| data.total | Long | 总条数(过滤后,分页前) |
| data.stats | Object | 按 `todoType` 分类的统计(9 类),本次未改结构 |
(`fromValue`/`toValue`/`reason`/`urgency`/`status`/`productType`/`orderTodoCount`/`guestName`/`personsDesc`/`hotelId`/`assignmentId`/`createTime`/`elapsedMinutes`/`requirementSummary`/`requirementVersion`/`hasUnresolvedReturn`/`departDate`/`derived`/`unreadCount`/`requirementId` 等其余字段本次未改,且非本条 changelog 讨论重点,完整定义见 `HouseTodoItemVO.java`)
(`reason`/`urgency`/`status`/`hotelId`/`assignmentId`/`travelerCount`/`departDate` 等其余字段本次未改,未逐一列出,见 `HouseTodoItemVO.java`)
#### 请求示例
```http
GET /v3/admin/order/todos?scope=mine&todoType=REFUND&status=OPEN
Authorization: Bearer <token>
```
#### 响应示例
以下字段名称/结构 2026-09-15 已对照源码 `HouseTodoItemVO.java` 更正(2026-09-14 起草版把字段名写成了 `todoTypeName`/`groupBatchId`,源码实际是 `todoTypeLabel`/`teamNo`,且遗漏了 `todoTypes[]` 聚合数组,见「出参」节说明);`orderId=30456`/`teamNo=90001` 取自 `HouseAssignmentServiceTest#findClaimerIdByOrder_householdNullAndGroupClaimed_returnsGroupClaimerId` 用例夹具(团级认领人 `houseClaimerId=2002`),其余展示性字段(`id`/`title`/`orderNo`/时间等)为示意值,不是测试服抓包报文:
```json
{
"code": 200,
"message": "成功",
"data": {
"list": [
{
"id": "70500",
"todoType": "REFUND",
"todoTypeLabel": "退订",
"title": "客人取消订单 · 请处理酒店退订",
"reason": "客人临时取消行程",
"fromValue": null,
"toValue": null,
"urgency": "danger",
"status": "OPEN",
"orderId": "30456",
"orderNo": "26-0518",
"teamNo": "90001",
"productType": "GROUP",
"orderTodoCount": 1,
"todoTypes": [
{ "typeCode": "REFUND", "typeLabel": "退订", "urgency": "danger", "count": 1, "derived": false, "todoId": "70500", "requirementId": null, "unreadCount": null }
],
"guestName": "赵先生",
"hotelId": 11,
"ownerUserId": "2002",
"ownerName": "小呼",
"createTime": "2026-09-14T10:13:00",
"elapsedMinutes": 30
}
],
"total": 1,
"stats": { }
},
"success": true
}
```
2026-09-15 测试服真实抓包对照(账号 1001,只读 GET,见「八、测试环境已验证」)——字段名与上方更正后的一致,证明 `todoTypeLabel`/`teamNo`/`todoTypes[]` 是真实线上契约而不是本次更正时的另一次猜测:
```json
{
"id": "2099707745790779394",
"todoType": "REFUND",
"todoTypeLabel": "退订",
"title": "客人取消订单 · 请处理酒店退订",
"status": "RESOLVED",
"orderId": "2099707703508054018",
"orderNo": "HL20260915115141720",
"teamNo": "26-6171",
"productType": "CORE",
"ownerUserId": "1001",
"ownerName": "admin",
"createTime": "2026-09-15 11:51:52"
}
```
⚠️ 上面这条真实抓包是**散客单**(`productType=CORE`,户级直接抢单,不经过本条 changelog 的团级回落分支),只用来坐实字段名与 `todoTypes[]` 结构;它不是团单回落场景的证据,团单回落场景(户级为空、读团级认领人)目前仍只有单测覆盖,见「八、测试环境已验证」的说明。
两级都无归属(团期未认领)时的广播态形态(取自 `HouseAssignmentServiceTest#findClaimerIdByOrder_householdNullAndGroupUnclaimed_returnsNullForBroadcast` 用例:户级为空、`listHouseClaimersIncludeDeleted` 返回空列表):
```json
{ "ownerUserId": null, "ownerName": null }
```
#### 空数据 / 降级响应
筛选条件下无匹配待办:
```json
{
"code": 200,
"message": "成功",
"data": { "list": [], "total": 0, "stats": { } },
"success": true
}
```
#### 错误响应
`keyword` 超长(既有校验,本次未改):
```json
{
"code": 100001,
"message": "keyword 最长 32 字",
"data": null,
"success": false
}
```
#### 业务边界
- **鉴权**:走网关统一鉴权,未登录 401;`scope=all` 仅房务组长/超管可用(既有逻辑,本次未改)。
- **本次只改变 `REFUND` 类型记录、且仅限满足两个条件同时成立时**:①该订单是团单(`orderService.resolveGroupBatchLinks` 能解析出 `groupBatchId`);②户级 `order_hotel_requirement.claimer_id` 为空。散客单(无团期归属)的 `REFUND` 待办行为**逐字节不变**——`findClaimerIdByOrder_householdClaimerPresent_returnsHouseholdIdWithoutGroupLookup` 用例断言了户级有值时不会额外查团期(`HouseAssignmentServiceTest.java` 新增用例)。
- **归团判定走 `OrderService#resolveGroupBatchLinks` 门面,不直读 `order_main.group_batch_id`**:工单 #7083 遗留的、`group_batch_id` 未回填的老团单,只有经这个门面才能被正确识别为团单;直读该列会把它们误判为散客单,本次修复对它们**不生效**(`HouseAssignmentService.java:788-` 起 `findGroupBatchClaimerIdByOrder` 私有方法 javadoc,2026-09-15 核实更新)。
- **团级认领人只经 `GroupBatchService#listHouseClaimersIncludeDeleted` 读**:与既有 `HouseGroupBatchClaimGuard`、`HouseGroupBatchBoardManager` 等同一单源,不引入第二个认领人数据来源。
- **两级都空仍返回广播态 `null`,不是缺陷**:这是既有设计语义(未认领时任一房务可接),本次未改这条语义,只是改了「取不到户级值时该不该再查一层」。
---
## 四、契约约束与正确调用方式(接口类必写)
> 本节只写**后端接受/拒绝查询参数的规则与调用后必须知道的取值规则**,不写 UI 渲染建议。
### ✅ 正确 / ❌ 错误 理解对照
| 场景 | 说明 |
|------|------|
| ✅ 团单 `REFUND` 待办 `ownerUserId` 非空时,展示为「该团认领房务在跟」 | 这个 owner 来自团级认领人,不是户级抢单人;前端不应把它误标为「已抢单」(散客单语义),应标「团期认领人」或直接沿用既有的房务姓名展示(字段名不变,语义按订单是否团单区分即可,无需新增字段) |
| ✅ 团单 `REFUND` 待办 `ownerUserId` 为 `null` 时,视为广播态(任一房务可处理) | 与散客单未抢单时的语义完全一致,不是新语义 |
| ❌ 认为团单 `REFUND` 待办的 `ownerUserId` 一定等于团级认领人 | 只有「户级 `claimer_id` 为空」这一条件成立时才回落团级;理论上若某天户级抢单口径变化导致团单也能写户级 `claimer_id`(当前无此写口),则仍以户级值优先 |
| ❌ 用 `groupBatchId` 字段是否非空来判断「这条待办的 owner 一定不是广播态」 | 团期未认领(`listHouseClaimersIncludeDeleted` 返回空)时,团单待办的 `ownerUserId` 依然是 `null` |
### 切换状态时的必要动作
无状态切换(本端点为只读查询)。⚠️ 2026-09-15 更正:2026-09-14 起草时写的「立即可查到」已不成立——同日晚些时候合并的 #7459(PR #7731,另一份 changelog)把**订单取消**(`handleOrderCancelled`)触发的房务处置改成了异步 outbox 命令,实测约 1 秒内完成;**订单终止**(`handleOrderTerminated`)未受 #7459 影响,仍走原 `HouseTodoEventListener` 同步事件路径。也就是说:团单**终止**触发的带真实 owner 的 `REFUND` 待办目前仍是同步可查;团单**取消**触发的这类待办现在需要等约 1 秒(详见 changelogs-v2/2026-09/15_7459_订单取消房务处置改走outbox耐久命令流团逐户REFUND-修改接口-管理后台.md),前端如果在取消成功后立即查询本端点校验 owner 是否正确,需要预留这个延迟窗口。
---
## 五、数据库行为
本次改动位于**读路径**(`findClaimerIdByOrder` 只读团级认领人,不写任何列),但它的调用方 `createRefundTodo`(现分两个重载:3 参业务入口 `HouseTodoService.java:2043` 起,4 参实现 `:2066` 起,2026-09-15 核实更新)会把取到的值写进 `house_todo.owner_user_id` 列。**本次不新增任何写口/写路径**,只是让既有写口(团单取消/终止触发的 REFUND 待办创建)在写这一列时,从「恒写 NULL」变成「先查户级、再查团级、最后才写 NULL」。
| 场景 | `house_todo.owner_user_id`(新建 REFUND 待办时) |
|------|----------------------------------------------------|
| 散客单,户级已抢单 | 户级 `claimer_id`(本次未改) |
| 散客单,未抢单 | `NULL`(本次未改) |
| 团单,团期已认领 | **本次起**:团级 `house_claimer_id` |
| 团单,团期未认领 | `NULL`(不变,广播态) |
该待办创建走 `@Transactional(rollbackFor = Exception.class)`(3 参入口的注解在 `HouseTodoService.java:2042`,2026-09-15 核实更新),`dedupKey="REFUND-{orderId}"` 幂等复用,本次未改。
---
## 六、边界行为
- 未登录 → 401(网关拦截)
- `keyword` 超长 → 100001(既有,未改)
- 无匹配待办 → 200 + 空列表 + `total=0`
- 订单查不到(理论场景,触发方是已存在的订单事件)→ `findClaimerIdByOrder` 返回 `null`,不抛错(`HouseAssignmentServiceTest#findClaimerIdByOrder_orderMissing_returnsNullWithoutGroupResolve` 覆盖)
- 非团单 → 不查团级认领人,直接返回户级值或 `null`(`findClaimerIdByOrder_nonGroupOrder_returnsNullWithoutClaimerLookup` 覆盖)
- 团期未认领 → owner 为 `null`,广播态,不是错误
---
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
本次改动**不引入任何新枚举值**。受影响记录的 `todoType` 恒为既有值 `REFUND`,未新增/修改该枚举的取值域,仅作为定位记录的上下文列出:
### todoType(`com.hulalv.house.enums.HouseTodoType`,节选)
**所属字段**: `HouseTodoItemVO.todoType`(`GET /v3/admin/order/todos`,既有字段) | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `REFUND` | 退订 | 本次唯一涉及的类型;订单取消/终止且已配房时产生,本次只改其 `ownerUserId` 取值来源,不改该枚举值本身 |
---
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `list[].ownerUserId`(团单 `REFUND` 待办,团期已认领) | 恒为 `null` | 团级认领人 ID(如 `2002`) |
| `list[].ownerName`(同上条件) | 恒为 `null` | 团级认领人姓名快照 |
| `list[].ownerUserId`(散客单 `REFUND` 待办) | 户级抢单人或 `null` | **不变** |
| `list[].ownerUserId`(团单 `REFUND` 待办,团期未认领) | `null` | **不变**(仍为 `null`,广播态) |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 团单取消/终止产 `REFUND` 待办的 owner 归属 | 恒落广播桶,全体房务在自己的 `scope=mine` 都能看到 | 团期已认领时,归属到该团认领人的 `scope=mine`;其他房务改为在 `scope=others`/`all` 里看到(归属他人) |
| `findClaimerIdByOrder` 内部查询次数(团单,户级为空场景) | 只查一次(户级) | 新增两次库查(`orderService.getById` + `resolveGroupBatchLinks`),仅发生在取消/终止产 REFUND 时,非热路径 |
| 散客单 `REFUND` 待办 owner 归属 | 不变 | **不变** |
---
## 六.7、影响评估(修改/删除类必写)
- **是否破坏向后兼容**:否。请求参数、响应字段结构、错误码均未变化;改动完全体现在**特定条件下**(团单 + 户级为空 + 团期已认领)的 `ownerUserId`/`ownerName` 取值上。
- **前端是否必须同步上线**:否,不同步上线不会导致接口调用失败或报错。但会有真实的行为差异:此前所有房务都能在自己的「我的待办」里看到团单 `REFUND` 待办(广播态),本次上线后,团期已认领的这类待办会**收窄**到认领人一人的「我的待办」,其他房务需要切到「同事在跟」或「全部」才能看到。若前端/运营对「REFUND 待办为什么从我的列表消失了」没有心理准备,可能产生疑问,建议知会一线房务。
- **前端 workaround 清理点**:无(此前没有相关字段可供 workaround)。
---
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- **仅影响**:`GET /v3/admin/order/todos` 中,团单取消/终止产生的 `todoType=REFUND` 记录的 `ownerUserId`/`ownerName` 取值及其 `scope` 归属。
- **零影响**:
- 散客单的 `REFUND` 待办 owner 取值(逐字节不变)
- `REFUND` 待办是否产生的判定(`hasActiveAssignment`/口径 8,先于本次改动即已存在)
- `REFUND` 待办的 RESOLVE 闭环(由主 API V5.49 退款成功触发,本次未改)
- 其余 8 类待办类型(`SWAP_HOTEL`/`HOTEL_REPLY_TIMEOUT`/`PENDING_ARRANGE`/`PENDING_FINALIZE`/`UNREAD_CHAT` 等)
- 户级抢单/转单流程(`casClaimByRequirementId`/`casTransferByRequirementId`)
- 团级认领/释放流程本身(`GroupBatchService#listHouseClaimersIncludeDeleted` 只读,未新增写口)
- `#7326`(分房重算/人工微调)相关端点,见 `changelogs-v2/2026-09/15_7326_团期分房重算人工微调把重判不平已完成户退回处理中-修改接口-管理后台.md`
---
## 八、测试环境已验证
**部署与实测状态**:修复本身(PR #7679,合并提交 `6169a612a`)已合入 dev-v3 并是测试服当前部署基线(`hl-order-service-v3` 2026-09-15 13:12 部署于 `6a7b43a17`,经 `deploy-status.sh` 实测确认)的祖先,代码已在测试服跑着。2026-09-15 对 `GET /v3/admin/order/todos` 做了真实网关取证(账号 1001,只读 GET),**证实了字段契约本身**(见下方「2026-09-15 测试服真实取证」)。⚠️ **如实标注未覆盖范围**:本单核心修复点(团单户级为空、回落读团级认领人这一分支)本轮**没有证实**——当天在测试服上没能定位到一个满足「团单 + 户级 `claimer_id` 为空 + 团期已认领 + REFUND 待办处于 OPEN 状态」四个条件同时成立的真实样本,抓到的 REFUND 样本都是户级直接抢单的散客单。这不是负面证据(不代表修复无效,单测已覆盖该分支),backend_status 仍记 `deployed`(代码已部署、字段契约已实测),但团单回落分支这一核心场景请前端联调时按「单测覆盖、未端到端验证」对待。以下分三部分:
**修复前的缺陷复现(PR 正文自报,2026-09-14 测试服实测,验证的是问题存在,不是修复生效)**:
```
取消订单 2099318713927778306
→ house_todo id=2099341634310201345, todo_type=REFUND, owner_user_id=NULL
同团 order_group_batch 2099318712929566721: house_claimer_id=1001, house_claimed_at=10:13:13
该户 order_hotel_requirement.claimer_id 取消前快照即为 NULL(非本次取消动作清掉)
```
**修复后的本机自动化单测(origin/dev-v3,`HouseAssignmentServiceTest.java` 新增 5 例)**:
```
findClaimerIdByOrder_householdClaimerPresent_returnsHouseholdIdWithoutGroupLookup
→ 户级有值时直接返回,不查团期(verify never resolveGroupBatchLinks/listHouseClaimersIncludeDeleted) ✓
findClaimerIdByOrder_householdNullAndGroupClaimed_returnsGroupClaimerId
→ 户级为空、团期已认领(houseClaimerId=2002) → 返回 2002(本单修复点:修复前此处恒为 null) ✓
findClaimerIdByOrder_householdNullAndGroupUnclaimed_returnsNullForBroadcast
→ 户级为空、团期未认领 → 返回 null,广播态(verify 确实查过团级,不是没问就落空) ✓
findClaimerIdByOrder_nonGroupOrder_returnsNullWithoutClaimerLookup
→ 非团单(resolveGroupBatchLinks 返回空 Map)→ 返回 null,不查团级认领人 ✓
findClaimerIdByOrder_orderMissing_returnsNullWithoutGroupResolve
→ 订单查不到 → 返回 null,不判团、不抛错 ✓
```
PR 自报定向轮:`Tests run: 179, Failures: 0, Errors: 0, Skipped: 6`(`Skipped` 为既有 `@Disabled`,非本次引入)。
**2026-09-15 测试服真实取证(账号 1001,只读 GET,覆盖字段契约,未覆盖团单回落分支)**:
```
GET /v3/admin/order/todos?scope=mine&orderId=2099707703508054018&status=RESOLVED&pageNo=1&pageSize=10 → 200
→ 命中 1 条 REFUND,字段名与「出参」节更正后的一致:
todoTypeLabel="退订"(不是 todoTypeName)、teamNo="26-6171"(不是 groupBatchId)、
存在 todoTypes[] 聚合数组、ownerUserId="1001" 正确回显
→ 该订单 productType=CORE,是散客单直接抢单场景,不是本单要修的团单回落场景
GET /v3/admin/order/todos?scope=mine&todoType=REFUND&status=OPEN → 200,list=[],stats.REFUND=9
→ 说明当前测试库里存在 9 条 OPEN 状态的 REFUND(按 owner 过滤后),但受「订单聚合改造」
的聚合/排序规则影响,未能在这次会话里筛出具体是哪些订单、是否含团单回落样本,
本节不据此下任何结论,如实记录为未查明
```
网关:本次未新增/修改路径,沿用既有路由。测试服部署已确认(见上),字段契约已实测,但团单回落分支这一核心修复点仍待专项造数验证(造一个团单:团期已认领 + 该户从未走户级抢单 + 触发取消或终止),验证方式建议按此专项补测。
---
## 十、相关文档
- 关联 Issue: [wx/HL#7327](https://git.1814.love:8443/wx/HL/issues/7327)
- 关联 PR: [wx/HL#7679](https://git.1814.love:8443/wx/HL/pulls/7679)
- 同一 Issue 相关的既有背景(住宿核单/月度对账): `changelogs-v2/2026-09/13_7327_团期配房行合流核单结算-修改接口-管理后台.md`、`changelogs-v2/2026-09/14_7327_月度对账并入团期计划与分房行-修改接口-管理后台.md`
- 关联改动(订单取消房务处置改走 outbox,改变本条讨论的取消路径时序,工单 #7459,已部署已实测): `changelogs-v2/2026-09/15_7459_订单取消房务处置改走outbox耐久命令流团逐户REFUND-修改接口-管理后台.md`
## 关联 / 联系人
### 链接
- **Issue**: [#7327](https://git.1814.love:8443/wx/HL/issues/7327)
- **PR**: [#7679](https://git.1814.love:8443/wx/HL/pulls/7679)
- **Merge commit**: [6169a612a](https://git.1814.love:8443/wx/HL/commit/6169a612ad3ade8fa2a2a0fcfb404f25e27833e0)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,362 @@
---
schema: "hl-changelog/v2"
ticket: "7459"
title: "订单取消的房务处置改走 outbox 耐久命令:取消后房务待办与需求状态改为异步产生(约 1 秒内),流团逐户各产 REFUND"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "not_required"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-15"
status_note: "PR #7731(Issue #7459)已 squash 合并 dev-v3(合并提交 4a63bba2b,2026-09-15 10:44),测试服 hl-order-service-v3 部署基线 816b1b522 即已包含该提交(更晚于它),并有真实网关+SQL 联测证据(工单 7459 评论 54437、54430,2026-09-15 实测),故 backend_status 记 deployed。gateway_status=not_required:本次未新增/修改任何路径,cancel/pre-trip 与 todos 均为已有路由。frontend_status=pending:待前端确认取消后的待办轮询/刷新逻辑是否已经假设了同步可见(若之前是取消接口返回即刷新一次列表,现在需要等约 1 秒或改为短轮询/延迟刷新)。"
updated_at: "2026-09-15"
base: "dev-v3"
---
# 房务待办: 订单取消的房务处置改走异步 outbox 命令
> **存放目录**:
> - 一期(v2,无 order-v3 标签的工单)→ changelogs/{YYYY-MM}/
> - 二期(v3,order-v3 标签的工单)→ changelogs-v2/{YYYY-MM}/
>
> **服务**: hl-order-service-v3 (端口 8086)
> **PR**: #7731
> **Issue**: #7459
> **日期**: 2026-09-15
> **影响范围**: 订单取消(出行前取消、退团/解散批量取消)之后,房务待办与需求 house_status 由该订单事务同步产生改为异步产生(约 1 秒内完成);流团批量取消时按户各自独立产生 REFUND 待办
---
## 关键变化
- 本次变了什么:POST /v3/admin/order/{id}/cancel/pre-trip 取消订单成功后,房务侧的处置(判定是否已配房、生成 REFUND 待办、需求 house_status 翻为 EXCEPTION)此前是在取消这次请求的同一个数据库事务里同步完成的;本次改为取消事务提交前把一条耐久命令写入 order_fleet_command_outbox(命令类型 HOUSE_ORDER_CANCELLED),由异步处理器读取并执行。命令的判定依据是取消那一刻冻结的快照(是否已配房、active 分房行 ID 列表),不是处理器执行时刻重新查询的最新状态。
- 前端调用方以前以为的是什么:取消接口(cancel/pre-trip)返回 200 之后,立即查询房务待办列表(GET /v3/admin/order/todos)或该订单的需求详情,就能看到 REFUND 待办与 house_status=EXCEPTION;退团/解散批量取消一批子订单时,房务侧的处置是随批量取消这次请求一起完成的。
- 实际现在是什么:取消接口返回 200 之后,房务待办与需求状态是异步产生的,实测约 1 秒内完成(不是立即、也不需要用户手动重试);如果前端在取消成功的同一个事件循环里立即查询待办列表,可能看不到刚产生的 REFUND 待办,需要等一小段时间或做一次延迟刷新/短轮询。退团/解散批量取消时,每个子订单各自独立产生一条 outbox 命令、各自异步处理,逐户产生 REFUND 待办,不是整团一条。
---
## 一、背景(选填)
PR 正文:订单取消的房务处置改走 order_fleet_command_outbox 耐久命令 HOUSE_ORDER_CANCELLED(取消事务 BEFORE_COMMIT 入队,去重键 HOUSE_ORDER_CANCELLED:{orderId}),处理器按取消时冻结的快照判定,替代原 AFTER_COMMIT 实时判定;流团路径在清房前冻结快照,已配房户逐户产生房务 REFUND 待办(对应工单 7459 AC-9)。
| 维度 | 证据 |
|------|------|
| 触发点 | OrderCancelledFleetListener(原监听 OrderCancelledEvent,改为落 outbox 命令而非直接处理) |
| 处理器 | OrderFleetCommandOutboxProcessor,命中 HOUSE_ORDER_CANCELLED 类型后分派给 HouseOrderCancelledCommandService |
| 去重键 | HOUSE_ORDER_CANCELLED:{orderId},同订单重复入队/重放不会产生重复 REFUND 待办 |
| 实测时延 | 工单 7459 评论 54437:两单取消请求发出到 outbox 落终态耗时均约 1 秒,create_time 与 update_time 同秒 |
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 取消订单(出行前) | POST | `/v3/admin/order/{id}/cancel/pre-trip` | 副作用时序变更 | 房务待办与需求状态处置改为异步(约 1 秒内),直接响应体字段结构未变 |
| 2 | 房务待办列表 | GET | `/v3/admin/order/todos` | 数据可见时点变化 | 取消触发的 REFUND 待办在约 1 秒后才可查到,响应字段结构未变 |
---
## 三、接口详情
### 1. 取消订单(出行前) `POST /v3/admin/order/{id}/cancel/pre-trip`
**VO**: `OrderCancelPreTripReqVO → Result<OrderCancelPreTripRespVO>`
#### 使用场景
管理后台在订单出行前发起取消时调用,一次性完成状态流转、退款申请发起与(本次涉及的)房务处置触发。请求体与直接响应体字段结构本次均未改动;变化在于响应返回之后,房务侧的连带处置不再与本次请求同一事务同步完成,而是异步执行。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| id | Path | Long | 是 | - | 订单 ID,本次未改 |
| cancelReason | Body | String | 是 | 非空 | 取消原因,本次未改 |
| cancelDetail | Body | String | 否 | - | 详细说明,本次未改 |
| refundMode | Body | String | 否 | POLICY(默认)/FULL_DEPOSIT/PARTIAL | 退款模式,本次未改 |
| refundAmount | Body | BigDecimal | refundMode=PARTIAL 时必填 | 大于 0 且不超过已付金额 | 部分退金额,本次未改 |
#### 出参字段表 `Result<OrderCancelPreTripRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.refundApplicationId | String | 退款申请 ID,恒返回 null(退款单由既有 OrderCancelledEvent 异步建单,与本次改动的房务处置是两条独立的异步链路),本次未改 |
| data.refundAmount | String | 退款金额,按政策计算,本次未改 |
| data.pendingApprovalMsg | String | 审批中提示文案,本次未改 |
| data.newStatus | String | 取消后订单状态,本次未改 |
(本次改动不在这个响应体里新增字段:房务待办与 house_status 的变化通过下方端点 2 或订单/需求详情另行查询)
#### 请求示例
```json
{ "cancelReason": "客户临时有事无法出行", "refundMode": "POLICY" }
```
#### 响应示例
以下字段结构取自源码 OrderCancelPreTripRespVO 定义,不是测试服抓包报文(真实抓包见工单 7459 评论 54437 的 DB 观测,未附原始 HTTP 报文):
```json
{
"code": 200,
"message": "成功",
"data": {
"refundApplicationId": null,
"refundAmount": "6864.00",
"pendingApprovalMsg": null,
"newStatus": "CANCELLED"
},
"success": true
}
```
#### 空数据 / 降级响应
本端点不存在成功但空数据的形态,校验通过即返回上述字段。
#### 错误响应
错误码本次未改,举例(订单状态不允许取消):
```json
{ "code": 100001, "message": "订单当前状态不允许取消", "data": null, "success": false }
```
#### 业务边界
- 本次改动不影响本端点自身的成功/失败判定与直接响应体:取消是否成功、退款金额计算、订单状态流转均与改动前逐字节相同。
- 房务侧处置(判定是否已配房、生成 REFUND 待办、需求 house_status 翻为 EXCEPTION)改为异步:调用方拿到 200 响应后,不能假设房务侧状态已经落库,需要另外查询(见端点 2)且预期约 1 秒的延迟。
- 幂等:命令去重键为 HOUSE_ORDER_CANCELLED:{orderId},同一订单的取消只会产生一条待处理命令;即使处理失败被重试或手动重放,也不会重复生成 REFUND 待办(工单 7459 评论 54437 的 internal replay 接口实测:已处理完的订单重放返回 data=0,即扫描到 0 条待重放)。
- 退团/解散批量取消同样复用这条取消链路:批量取消 N 个子订单会产生 N 条独立的 outbox 命令,各自异步处理,互不阻塞、互不合并(工单 7459 评论 54430:两户 A、B 同批解散取消,各自独立产生/不产生 REFUND 待办,取决于各自是否已配房)。
---
### 2. 房务待办列表 `GET /v3/admin/order/todos`
**VO**: `HouseTodoPageReqVO → Result<HouseTodoListRespVO>`
#### 使用场景
房务在待办页面查看自己或同事的待办。本次改动后,订单取消触发的 REFUND 待办不会在取消接口返回的同一瞬间就查得到,需要约 1 秒的异步处理时间。入参字段与响应字段结构均未改动,完整契约见另一份 changelog(15_7327_团单取消终止REFUND待办owner回落团级认领人-修改接口-管理后台.md);本文件只描述可见时点的变化。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| scope | Query | String | 否 | mine(默认)/others/all | 本次未改 |
| todoType | Query | String | 否 | 逗号分隔多选 | 本次未改,可传 REFUND 过滤 |
| orderId | Query | Long | 否 | - | 本次未改,可按订单精确查询 |
(其余入参字段本次未改,见既有契约)
#### 出参字段表 `Result<HouseTodoListRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.list[] | Array | 待办列表,字段结构本次未改 |
| data.list[].todoType | String | 本次涉及 REFUND,取值域未变 |
| data.list[].ownerUserId | String | 归属房务 ID,字段本身未变;取消触发的这条记录本次起延迟约 1 秒才出现 |
(完整字段表见 15_7327_团单取消终止REFUND待办owner回落团级认领人-修改接口-管理后台.md,本文件不重复列出全部字段)
#### 请求示例
```http
GET /v3/admin/order/todos?scope=mine&orderId=2099707703508054018&status=RESOLVED&pageNo=1&pageSize=10
```
#### 响应示例
以下取自本次会话 2026-09-15 的测试服真实抓包(账号 1001,只读 GET,命中工单 7459 评论 54437 里的取证订单,该待办因退款联动已被自动 RESOLVE,故用 status=RESOLVED 查询到):
```json
{
"id": "2099707745790779394",
"todoType": "REFUND",
"todoTypeLabel": "退订",
"title": "客人取消订单 · 请处理酒店退订",
"status": "RESOLVED",
"orderId": "2099707703508054018",
"orderNo": "HL20260915115141720",
"ownerUserId": "1001",
"ownerName": "admin",
"createTime": "2026-09-15 11:51:52"
}
```
(完整字段的响应示例见 15_7327_团单取消终止REFUND待办owner回落团级认领人-修改接口-管理后台.md;本文件只强调这条记录本身:取消请求发出时刻是 11:51:51,本记录 createTime 是 11:51:52,两者相差约 1 秒,与本次改动的异步时延一致)
#### 空数据 / 降级响应
本次改动带来的一个新形态:取消接口刚返回 200 的极短窗口内(约 1 秒以内)查询该订单的待办,可能仍是空列表,不代表处置失败,稍后重试即可查到:
```json
{ "code": 200, "data": { "list": [], "total": 0 }, "success": true }
```
#### 错误响应
本端点错误码本次未改,举例(keyword 超长,既有校验):
```json
{ "code": 100001, "message": "keyword 最长 32 字", "data": null, "success": false }
```
完整错误码表见 15_7327_团单取消终止REFUND待办owner回落团级认领人-修改接口-管理后台.md。
#### 业务边界
- 本次不改变字段结构,只改变数据出现的时点:调用方若在取消成功后立即查询本端点校验房务处置是否生效,应预留约 1 秒的等待或改为短轮询(例如间隔 500ms 重试 2-3 次),不要把「刚取消完查不到」当成失败。
- 该延迟只影响本次取消动作新产生的记录,历史已存在的待办查询行为不受影响。
- 幂等重放不会导致本端点出现重复的 REFUND 记录(见端点 1 业务边界的去重键说明)。
---
## 四、契约约束与正确调用方式(接口类必写)
本节只写后端接受拒绝 payload 的规则与调用后必须知道的取值规则,不写 UI 渲染建议。
### 正确与错误调用方式对照
| 场景 | 说明 |
|------|------|
| 正确:取消成功后延迟约 1 秒或短轮询再查待办列表/需求详情 | 房务处置是异步的,立即查询可能查不到 |
| 正确:把「取消接口 200」与「房务处置已完成」视为两个独立事件 | 前者是同步事实,后者是异步结果,不能用前者代表后者 |
| 错误:取消返回 200 后立即断言待办列表包含新的 REFUND 记录 | 约 1 秒内可能仍是空,不代表处置失败 |
| 错误:重复调用取消或手动重放来「确保」待办生成 | 命令按订单去重,重放不会产生第二条待办,也不会加快处理速度 |
### 切换状态时的必要动作
无需前端主动触发任何补偿动作。若产品体验上需要「取消成功后立即看到房务待办已生成」的即时反馈,建议前端自行做一次短轮询(如 500ms 间隔、最多 2-3 次)而不是让用户手动刷新页面。
---
## 五、数据库行为(涉及写操作时必写)
| 阶段 | 表 | 行为 |
|------|-----|------|
| 取消事务内(同步) | order_fleet_command_outbox | 新增一行 HOUSE_ORDER_CANCELLED 命令,携带取消时冻结的快照(是否已配房、active 分房行 ID),初始状态待处理 |
| 取消事务内(同步) | order_main | 订单状态流转,本次未改 |
| 异步处理阶段(约 1 秒后) | order_hotel_requirement | house_status 翻为 EXCEPTION(已配房场景),status 与 claimer_id 不变,不软删 |
| 异步处理阶段(约 1 秒后) | house_todo | 已配房场景新增 1 条 REFUND 待办,dedup_key 为 REFUND-{orderId};未配房场景不新增(本身就不满足产生条件,非回归) |
| 异步处理阶段 | order_fleet_command_outbox | 该行状态翻为 SUCCEEDED(实测均在 1 秒内) |
幂等:命令与生成的 REFUND 待办均按各自的去重键(HOUSE_ORDER_CANCELLED:{orderId}、REFUND-{orderId})保证不重复;重放已处理完的命令是安全的空操作。
---
## 六、边界行为
- 未登录取消接口 → 401(网关拦截,本次未改)
- 订单状态不允许取消 → 既有错误码(本次未改)
- 取消成功但房务处置尚未完成(约 1 秒窗口内)→ 待办列表/需求详情暂时看不到新记录,不是错误,稍后可查到
- outbox 命令处理失败 → 由既有重试机制处理(超出本文件描述范围,前端无需感知);internal 重放接口不经网关,仅供后端运维排障使用
---
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
本次改动不引入新枚举值。涉及的 house_status 恒为既有值 EXCEPTION,todoType 恒为既有值 REFUND,取值域均未变,仅列出作为定位上下文:
### todoType(GET /v3/admin/order/todos,既有字段)
**所属字段**: `data.list[].todoType` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `REFUND` | 退订 | 本次唯一涉及的类型,取值不变,只是产生时点改为异步 |
---
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| cancel/pre-trip 请求体/响应体字段结构 | 见既有契约 | 不变 |
| GET /v3/admin/order/todos 响应字段结构 | 见既有契约 | 不变 |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 取消订单后房务待办/house_status 生成时点 | 与取消请求同一事务,取消返回即已完成 | 异步,约 1 秒内完成 |
| 流团批量取消(退团/解散)时房务处置粒度 | 原实时判定路径(AFTER_COMMIT) | 逐户各自独立的 outbox 命令,逐户异步处理,逐户产生 REFUND |
| 命令处理失败后的恢复方式 | 依赖原事务重试语义 | 命令持久化在 outbox 表,可通过 internal 重放接口显式重放,失败不丢 |
| 重复触发是否产生重复待办 | 依原实现而定 | 按 orderId 维度去重键幂等,明确不重复 |
---
## 六.7、影响评估(修改/删除类必写)
- 是否破坏向后兼容:否。两个端点的请求/响应字段结构均未变化,错误码未变化。真实的行为差异是时序上的(同步变异步,约 1 秒)。
- 前端是否必须同步上线:不是强制的(不同步上线不会导致接口报错),但如果前端在取消成功的回调里同步查询待办列表并据此更新 UI(例如「已生成退订待办」提示),现在可能会在约 1 秒内查询落空,建议前端补一次延迟刷新或短轮询,否则用户体验上会出现「取消成功了,但待办列表看起来没反应」的观感。
- 前端 workaround 清理点:无(此前没有相关字段可供 workaround)。
---
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- 仅影响:订单取消(出行前取消及复用同一链路的退团/解散批量取消)之后,房务待办与需求 house_status 的产生时点;流团批量取消时的处置粒度(逐户而非整团)。
- 零影响:
- cancel/pre-trip 端点自身的请求校验、退款金额计算、订单状态流转结果
- GET /v3/admin/order/todos 的请求参数、响应字段结构、错误码
- 未涉及取消的其余订单流转路径(终止行程 terminate 走的是既有 MQ 事件监听路径,本次未改)
- internal 重放接口 `/v3/internal/jobs/fleet-command-outbox/replay` 本身不经网关(既有限制,本次未改,实测经网关调用返回业务码 403「接口不可访问」)
---
## 八、测试环境已验证
真实网关 + DB 联测(工单 7459 评论 54437、54430,2026-09-15,部署基线 816b1b522,deploy-status 起跑与全程核对无漂移):
```
S1(已配房散客单取消):POST /v3/admin/order/2099707703508054018/cancel/pre-trip 11:51:51 → 200
→ 取消后 11:51:52(约 1 秒):order_hotel_requirement.house_status 翻为 EXCEPTION
→ house_todo 新增 1 行 REFUND(owner_user_id=1001)
→ order_fleet_command_outbox 落 1 行 HOUSE_ORDER_CANCELLED,create_time=update_time=11:51:52,status=SUCCEEDED
S2(未配房散客单取消):POST /v3/admin/order/2099707743991476225/cancel/pre-trip 11:51:51 → 200
→ 取消后 11:51:52:order_hotel_requirement 软删;house_todo 仍 0 行(未配房场景本不产生该待办,非回归)
→ outbox 同样约 1 秒内 SUCCEEDED
internal 重放接口实测(S1/S2 均已 SUCCEEDED 后重放):
POST /v3/internal/jobs/fleet-command-outbox/replay?orderId=2099707703508054018 → 200,data=0(无待重放行,未重复生成)
POST /v3/internal/jobs/fleet-command-outbox/replay?orderId=2099707743991476225 → 200,data=0
流团批量解散取消(评论 54430,gb=2099703524806823938,户 A/B):
A(已配房,had active 分房行)→ house_status=EXCEPTION,house_todo 新增 1 条 REFUND(dedup_key 唯一)
B(未配房,未付款户)→ 无 REFUND 待办(未配房场景符合预期)
两户各自独立的 outbox 命令,各自 payload 记录取消时冻结的 assignedAtCancel/activeAllocationIds 快照
网关可达性(源码 + 实测双证):/v3/internal/** 不能经网关调用,经网关请求返回业务码 403「接口不可访问」;internal 重放只能直连服务 8086 端口
```
(真实抓包报文的原始 JSON 未在本轮会话中重新采集,端点 1/2 的响应示例基于源码字段定义与上述 DB/时间证据组装,已在正文标注)
---
## 十、相关文档
- 关联 Issue: wx/HL#7459
- 关联 PR: wx/HL#7731
- 房务待办列表端点完整契约: changelogs-v2/2026-09/15_7327_团单取消终止REFUND待办owner回落团级认领人-修改接口-管理后台.md
## 关联 / 联系人
### 链接
- Issue: [#7459](https://git.1814.love:8443/wx/HL/issues/7459)
- PR: [#7731](https://git.1814.love:8443/wx/HL/pulls/7731)
- Merge commit: [4a63bba2b](https://git.1814.love:8443/wx/HL/commit/4a63bba2b9e90e624bee0eb8c7e2992377ac23a8)
### 联系人
- 后端负责人: @wx