--- schema: "hl-changelog/v2" ticket: "7324" title: "房务团期看板与整团按日订房计划 CRUD:H1-H6 + 整团释放共 7 端点;H1 total=-1 新契约、H5/release-all 各有一处「200 但看似没生效」的静默分支" consumer: "admin" author: "wx(GIT)" change_type: "新增接口" backend_status: "deployed" gateway_status: "not_required" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "6710ad4a" target_release: "" verified_at: "2026-09-11" status_note: "2026-09-10 已部署测试服并实测通过(squash 07feaffe0,PR #7465)。⚠️ 部署顺序:本单唯一跨服务契约变更是 HouseRoomTypeDTO.roomCategory(resource 侧新增字段,HouseRoomTypeDTO.java:51)与 HouseRoomTypeFeignVO.roomCategory(order-v3 侧消费,HouseRoomTypeFeignVO.java:47),hl-resource-service 必须先于 hl-order-service-v3 部署——反序会让 order-v3 拿到 roomCategory=null,H4/H5 的 fail-closed 房型守卫(HouseRoomTypeGuard.requireRoomTypes)把每次提交全部拒成 808691。2026-09-10 实测确实撞过一次短暂不同步窗口(GET /v3/admin/house/group-batches 一度 404),重滚 order-v3 后恢复,未影响最终验收。⚠️ 与工单 #7324 核对:工单口径「HouseGroupBatchErrorCode 新增 12 个错误码」与源码不符,squash 07feaffe0 实际新增 17 个常量(808600-808603 共 4 个 + 808608 共 1 个 + 808611-808619 共 9 个 + 808690-808692 共 3 个 = 17,见 HouseGroupBatchErrorCode.java 本次 diff +165/-2 行),本篇按源码 17 个如实列出,以代码为准。 前端已交付(commit 6710ad4a):H1 看板/H2 详情/H3 逐户/H4-H6 写口/release-all 七端点全接入,total=-1 降级显「—」+压掉虚构共N条、H5 脏字段检测+version 乐观锁、release-all 假成功分支改文案、组长只读隐藏写口;已知缺口(用户拍板留 API):H1 后端忽略 CANCELLED 过滤,整团释放按钮暂无可达入口,待后端放开 H1 即自动激活。 整团释放可达性需求已单列提交后端:backend-requests/2026-09/11_7324_放开H1看板batchStatus过滤CANCELLED-整团释放可达.md(commit 4aa3037,状态 proposed 待后端排期),前端零改动等待。" updated_at: "2026-09-10" base: "dev-v3" --- # 房务团期看板与整团按日订房计划 CRUD(H1-H6 + 整团释放) > **服务**: `hl-order-service-v3`(H1-H6 + release-all 全部落在此服务);跨服务契约方 `hl-resource-service`(房型大类字段回填) > **PR**: #7465 > **Issue**: #7324 > **日期**: 2026-09-10 > **影响范围**: 房务团期看板全新页面(列表 / 详情 / 逐日需求明细)+ 团期按日订房计划的新增 / 改 / 删 / 整团释放,管理后台全新功能,无存量前端调用方 --- ## ⚠️ 关键变化 **这是一批全新端点,没有存量调用方,但有四处「后端行为对,前端按直觉写会踩坑」的地方,逐一说明:** ### 1.【新契约】H1 列表的 `total` 可能是 `-1`,语义是「未统计」不是负数 `GET /v3/admin/house/group-batches` 在**不传 `planStatus`** 时走普通数据库分页,`total` 与全仓其它分页接口一样恒 `>= 0`。 但**一旦传了 `planStatus`(PENDING / CONFIRMED)**,后端走内存过滤降级路径(`HouseGroupBatchBoardManager.java:99-114`):先按认领/关键字/状态等条件扫一页最多 500 条候选(`PLAN_STATUS_SCAN_LIMIT = 500`,`HouseGroupBatchBoardManager.java:72`),逐条在内存里判定 `planStatus` 是否匹配再切页。**若候选总数超过 500(扫描页被填满)**,真实总数已经无法算出,此时 `PageResult.total` 返回 **`-1`**(`TOTAL_UNKNOWN`,`HouseGroupBatchBoardManager.java:81`、`268-275`)。 全仓所有既有分页接口的 `total` 都保证非负,前端惯用它算「共 N 条」文案或总页数(`Math.ceil(total / pageSize)`)。**不识别这个新语义会渲染出「共 -1 条」或算出负数页码**。前端拿到 `total === -1` 时应显示「—」或类似占位文案,并提示用户收窄筛选条件(缩小 `stayDateFrom/To`、`departDateFrom/To` 或去掉 `planStatus`),不要按负数处理分页控件。 同时注意 `PageResult.total` 是 Java 基本类型 `int`(`PageResult.java:15`),不是可空类型,正常场景下的 0 与「无匹配」是同一个值,只有 `planStatus` 降级路径的扫描填满态才会出现 `-1`。 ### 2.【易漏分支】H5 只传 `version` 字段:在 PENDING 行是「静默成功」,在 CONFIRMED 行是 808692 `PUT .../room-plans/{planId}` 的请求体语义是「字段为 `null` = 本次不改」(`GroupBatchRoomPlanItemReqVO.java:19-21`)。若前端「保存」按钮无差别提交整个表单对象、但用户实际没有改任何字段,等价于只传了必填的 `version`: - 若该计划行当前是 **`PENDING`**:五要素(入住日/酒店/房型/间数/大类)均判定为未变化,服务端进入「原地更新」分支(`isIdentityChanged` 返回 `false`),构造一个只设了 `planId`/`version`(其余字段原样取自请求,未传的为 `null`)的 `patch` 对象调用 `updateWithVersion`(`doUpdateInPlace`,`GroupBatchRoomPlanManager.java:230-249`)。`updateWithVersion` 的实现是 `roomPlanMapper.updateById(plan)`(`GroupBatchRoomPlanService.java:174-178`),MyBatis-Plus(`3.5.5`,根 `pom.xml:46`)`updateById` 的默认 `FieldStrategy` 是 `NOT_NULL`——**`null` 字段不会进 UPDATE 的 SET 子句**;`GroupBatchRoomPlanDO` 的 `protoPrice`/`settlementPrice`/`settleType`/`deductInventory`/`remark` 五个字段均未加 `@TableField(updateStrategy=...)` 覆盖(`GroupBatchRoomPlanDO.java:46/48/50/52/61`),全仓在 order-v3 与 hl-common 范围内也没有任何 `update-strategy`/`FieldStrategy` 全局覆盖。因此只传 `version` 的请求**返回 200,SET 子句里只有乐观锁 `version`(+1)与审计时间列,不会清空或改动任何业务字段**——这与 VO 注释承诺的「字段为 `null` = 本次不改」是一致的,是一次「空更新 + `version` 自增」,不是字段清空。 - 若该计划行当前是 **`CONFIRMED`**:同样判定五要素未变化,但已确认行禁止原地更新(`version` 是扣减幂等键的一部分,见「五、数据库行为」),服务端抛 **`808692`**(`GroupBatchRoomPlanManager.java:206-212`)。 前端仍应只把用户实际改过的字段放进请求体(未改的留 `null`),但理由**不是**「防止字段被清空」——PENDING 行的原地更新不会清空任何字段。真正需要注意的是:只要走进这条分支,无论字段是否真的变化,服务端都会把 `version` **+1**(本地缓存的旧 `version` 随之失效,下次操作若仍带旧值会撞 **808608**),并在团期时间线上留下一条 `UPDATE_IN_PLACE` 记录(`recordReviseTimeline`,`GroupBatchRoomPlanManager.java:245-246`)——用户没有实际改动时不应发起这次 PUT,前端应做「脏字段检测」,没有脏字段就不提交。 ### 3.【易漏分支】`release-all` 会返回「200 成功,但什么都没释放」 `POST .../room-plans/release-all` 只在**该团完全没有 active 计划行也没有 active 分房行**时才返回 **`808619`**(`GB_ROOM_PLAN_RELEASE_NOTHING`,幂等出口,零写入)。 但如果该团已流团(`CANCELLED`)、计划行**全部是入住日早于操作当日的已确认(CONFIRMED)行**——即整团都是「已发生的间夜」——服务端会判定为「有东西但全部该保留」,返回 **200**,且: - `releasedPlanIds` / `releasedAllocationIds` / `markedLogIds` 均为**空数组** - `retainedPastPlanIds` **非空**(列出被保留的已确认行 ID) - `warnings` 含 **`"PAST_STAY_RETAINED"`** (`GroupBatchRoomPlanManager.java:394-409`、`GroupBatchRoomReleaseResult.java:11-16` 显式把这两种情形拆成不同分支,理由见该 DTO 的类注释:「团里根本没有订房」要给 808619 的幂等出口,「订房全都是已发生的间夜、按规矩保留了」是正常业务态必须返 200。) 前端对 `release-all` 的成功响应,**不能只看 HTTP 200 / `success:true` 就提示「已全部释放」**,必须检查 `releasedPlanIds.length === 0 && retainedPastPlanIds.length > 0` 这一组合,命中时改用「已发生间夜已按规定保留,未做任何释放」这类文案,并建议展示 `retainedPastPlanIds` 供核对。 ### 4. H4 响应体携带两个恒为空数组的字段 `POST .../room-plans`(H4)与 `PUT .../room-plans/{planId}`(H5)**共用同一个响应 VO** `GroupBatchRoomPlanRespVO`(既是数组元素类型也是 H5 单行响应类型)。这个 VO 上有 `warnings: string[]` 与 `removedManualAllocIds: string[]` 两个字段(`GroupBatchRoomPlanRespVO.java:105-109`)——它们对 H5 的「删旧建新」「原地更新(核单期)」分支有真实语义(见下方接口详情),但**对 H4 新建恒为空数组 `[]`**(`GroupBatchRoomPlanConverter.toRespVO` 无条件 `setWarnings(Collections.emptyList())` / `setRemovedManualAllocIds(Collections.emptyList())`,`GroupBatchRoomPlanConverter.java:51-52`,H4 的 `doSave` 未再覆盖)。 若前端按「H4 是纯新增,字段集合不会变」的假设去做响应类型/契约测试,会把这两个恒空字段判定为「意外新增字段」。它们是设计如此(VO 复用),不是漏改。 --- ## 一、背景 `#7324` 是「团期房务」整体方案(`#7323` 定案)的第一张落地单,交付两块能力: 1. **只读看板**(H1-H3):房务在「已认领团」维度查看团期配房进度(列表 / 详情 / 逐日需求明细),供每日核对「需求 vs 已订」。 2. **写口**(H4-H6 + release-all):按日订房计划的新增 / 改 / 删,以及流团后的整团批量释放。 包落位与错误码段位按 `#7323` 定案 #25/#17 预先占号(`HouseGroupBatchErrorCode.java` 类头注释 `:6-32`):`808600-808603/808608/808611-808619/808690-808692` 归 `#7324`,`808604/808605/808607/808609/808610` 留给 `#7325`(按日确认与自动分房,尚未开发),`808606` 留给 `#7326`(分房与只读),`808650-808662` 已由已上线的 `#7322`(团期整团抢单)占用。**本次交付前段位表本身有笔误已被修正**:`808611` 此前误记在 `#7325` 行,与本单 `GB_ROOM_PLAN_BATCH_DATE_MISSING` 撞号;`808612-808619`、`808650-808662`、`808690-808694` 整段此前没写进表——已在本次 diff 里一并修正(`HouseGroupBatchErrorCode.java:15-29`)。 新增字段的跨服务契约:团期订房要在提交时把「房型大类」(`roomCategory`,判「对平」的分组键)以 `hl-resource-service` 权威值覆盖客户端传入值,防止客户端伪造已对平;为此 `hl-resource-service` 的 `HouseRoomTypeDTO` 与 `hl-order-service-v3` 消费侧的 `HouseRoomTypeFeignVO` 都新增了 `roomCategory` 字段(详见「五、数据库行为」与本文件顶部部署顺序说明)。网关侧 `/v3/admin/**` 路由块此前已通配到相应服务(`#3264`),本单零网关改动。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|---|---|---|---|---| | 1 | 房务团期看板列表(H1) | GET | `/v3/admin/house/group-batches` | 新增接口 | `planStatus` 过滤降级为内存扫描,候选超 500 时 `total=-1` | | 2 | 团期配房详情(H2) | GET | `/v3/admin/house/group-batches/{groupBatchId}` | 新增接口 | 逐日按房型需求/已订/差额 + 该日计划行 + 各户特殊诉求 | | 3 | 团期逐日用房需求明细(H3) | GET | `/v3/admin/house/group-batches/{groupBatchId}/room-requirements` | 新增接口 | 含各户拆分,按最近一次放行房务的需求版本 | | 4 | 整团按日提交订房计划(H4) | POST | `/v3/admin/house/group-batches/{groupBatchId}/room-plans` | 新增接口 | 追加式,不做集合对账;同格子拒 808614 | | 5 | 修改单条订房计划(H5) | PUT | `/v3/admin/house/group-batches/{groupBatchId}/room-plans/{planId}` | 新增接口 | 字段 `null`=不改;五要素变化=删旧建新,否则原地更新 | | 6 | 删除单条订房计划(软删,H6) | DELETE | `/v3/admin/house/group-batches/{groupBatchId}/room-plans/{planId}` | 新增接口 | 已流团团期也放行;已发生间夜仍冻结 | | 7 | 整团批量释放订房计划 | POST | `/v3/admin/house/group-batches/{groupBatchId}/room-plans/release-all` | 新增接口 | 仅已流团(CANCELLED)团期可用;可能 200 但零释放 | --- ## 三、接口详情 ### 1. 房务团期看板列表(H1) `GET /v3/admin/house/group-batches` **VO**: `HouseGroupBatchBoardPageReqVO(GET 直接绑定)→ Result>` (Controller:`HouseGroupBatchBoardController.java:52-59`;编排:`HouseGroupBatchBoardManager.page`,`:99-114`) #### 使用场景 房务每日打开的「我的团」工作台首页,只显示**已被房务整团认领**的团(未认领的团在团期抢单池页,两页以认领动作为界互斥,`HouseGroupBatchBoardController.java:48-50`)。 #### 入参字段表(`HouseGroupBatchBoardPageReqVO.java`) | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | `scope` | Query | String | 否 | ≤8 字;`MINE`(默认)/`ALL` | `ALL` 是组长/超管监督视图,普通房务传 `ALL` 得 **808092**(`:27-29`) | | `claimerAdminId` | Query | Long | 否 | 仅 `scope=ALL` 生效,`scope=MINE` 时忽略 | 按认领人筛(`:31-32`) | | `planStatus` | Query | String | 否 | ≤16 字;`PENDING`/`CONFIRMED`,不传=全部 | 触发内存过滤降级路径,见「关键变化 1」(`:42-44`) | | `batchStatus` | Query | String | 否 | ≤200 字,逗号分隔多选;不传取默认四态;传 `CANCELLED` 被忽略 | 默认四态=`RESOURCE_PREPARING,MATERIAL_PREPARING,PENDING_DEPARTURE,TRAVELLING`(`HouseGroupBatchBoardManager.java:60-64`);非法状态码同样被过滤掉,不报错(`:224-236`) | | `stayDateFrom` / `stayDateTo` | Query | LocalDate(ISO) | 否 | — | 住期区间筛选:团期 **`[出发日,结束日]`(两端闭)** 与查询窗口有交集即命中,谓词是 `depart_date <= stayDateTo AND end_date >= stayDateFrom`。⚠️ 这里是**闭区间**,与下文 H4/H5 校验入住日合法性用的 `[出发日,结束日)`(右开,结束日是离店日不产生间夜)**不是同一个口径**,别混用 | | `departDateFrom` / `departDateTo` | Query | LocalDate(ISO) | 否 | — | 出发日区间 | | `keyword` | Query | String | 否 | ≤32 字 | 团期号或产品名包含匹配 | | `page` | Query | Long | 否 | `>= 1`,默认 1 | — | | `pageSize` | Query | Long | 否 | `1~50`,默认 20 | 上限比房务其它列表(200)小得多——本页每行都要解析各户需求 JSON(`:75-84`) | #### 出参字段表 `PageResult` `PageResult`(`PageResult.java`): `records: T[]`、`total: int`(⚠️ 见「关键变化 1」,可能为 `-1`)、`page: int`、`pageSize: int`。 `HouseGroupBatchBoardSimpleRespVO` 每项(`HouseGroupBatchBoardSimpleRespVO.java:23-127`): | 字段 | 类型 | 说明 | |---|---|---| | `groupBatchId` | String(雪花 ID,`ToStringSerializer`) | 运营团期 ID | | `batchNo` | String | 团期号,如 `GB-26-000001` | | `productId` | String | 产品 ID | | `productName` / `batchName` / `batchLabel` | String | 产品名/班期名/第 N 期快照 | | `batchStatus` / `batchStatusText` | String | 状态码/中文,如 `RESOURCE_PREPARING`/「资源准备中」 | | `departDate` / `endDate` | LocalDate(可空) | 团期出发日/结束日(离店日) | | `nights` | Integer(可空) | 间夜数 = 结束日 − 出发日 | | `enrolledRooms` / `enrolledPeople` | Integer | 已报名房数(户数)/人数 | | `requirementConfirmed` | Boolean | 团级需求已确认标记;`false` 不代表从列表消失,只提示「待管理员重新确认」 | | `hotelReady` | Boolean | 配房完成标志;本单只在整团释放时置回 `false`,不置 `true` | | `claimerAdminId` / `claimerName` / `claimedAt` | String/String/LocalDateTime | 认领房务信息 | | `boardStatus` | String | `NOT_STARTED`/`PARTIAL`/`ALL_CONFIRMED`(`:86-93`) | | `demandDays` / `plannedDays` / `confirmedDays` / `mismatchDays` | Integer | 有需求/有计划行/全确认/存在差额的日历日数 | | `demandRoomNights` / `plannedRoomNights` / `confirmedRoomNights` | Integer | Σ需求/Σ已订/Σ已确认 间·晚 | | `outOfRangeHouseholdCount` | Integer | 越界/日期缺失户数(阻塞整团配房完成,但**不**从需求分母里剔除) | | `days[]` | `DaySummary[]` | 逐日摘要:`stayDate`、`demandRooms`、`plannedRooms`、`confirmedRooms`、`dayStatus`(`NONE`/`PENDING`/`CONFIRMED`/`MIXED`)、`mismatch`(Boolean) | #### 请求示例 ```http GET /v3/admin/house/group-batches?scope=MINE&planStatus=PENDING&page=1&pageSize=20 ``` #### 响应示例(正常态) ```json { "code": 200, "success": true, "data": { "records": [{ "groupBatchId": "20260610001", "batchNo": "GB-26-000001", "batchStatus": "RESOURCE_PREPARING", "batchStatusText": "资源准备中", "boardStatus": "PARTIAL", "demandDays": 5, "plannedDays": 3, "confirmedDays": 1, "demandRoomNights": 20, "plannedRoomNights": 12, "confirmedRoomNights": 4, "outOfRangeHouseholdCount": 0, "days": [ /* ... */ ] }], "total": 37, "page": 1, "pageSize": 20 } } ``` #### 响应示例(`total` 未统计,⚠️ 见关键变化 1) ```json { "code": 200, "success": true, "data": { "records": [ /* 20 条 */ ], "total": -1, "page": 1, "pageSize": 20 } } ``` #### 空数据 / 降级响应 - 无匹配团期时 `records` 返回空数组 `[]`,`total` 为 `0`(不走降级路径时恒准确)。 - `planStatus` 降级扫描路径若扫描到 0 条匹配,同样 `total=0`;`total=-1` 仅在候选超过 500 条扫描上限时出现(见「关键变化 1」),不会与「无匹配」混淆。 #### 错误响应 ```json { "code": 808092, "message": "无权查看全部房务订单(仅房务组长或超管可查看)", "success": false, "data": null } ``` (`808092` 属预先存在的 `HouseGrabErrorCode`,`module="house-grab"`,非本单新增;本单读端点复用它,见「六.5」。) #### 业务边界 - 未认领团**一个都不在本页**,即便它满足所有筛选条件。 - `planStatus` 过滤是唯一会触发降级扫描的参数,其余筛选条件走数据库分页,`total` 恒准确非负。 --- ### 2. 团期配房详情(H2) `GET /v3/admin/house/group-batches/{groupBatchId}` **VO**: `无请求体(仅 Path)→ Result` (Controller:`HouseGroupBatchBoardController.java:64-71`;编排:`HouseGroupBatchBoardManager.detail`,`:122-158`) #### 使用场景 点开某个团查看逐日按房型的需求/已订/差额,核对每天哪个房型还差几间。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | `groupBatchId` | Path | Long | 是 | 雪花 ID | 团期主订单 ID | #### 出参字段表 `HouseGroupBatchBoardRespVO`(`HouseGroupBatchBoardRespVO.java:26-258`) 团级字段与 H1 列表项**完全一致**(同名同型,见上表),另加: | 字段 | 类型 | 说明 | |---|---|---| | `maxRooms` | Integer | 班期最大房间数,`0`/空=不限 | | `inGroupHouseholds` | Integer | 在团户数(只排已取消,含已完成) | | `hotelHouseholds` | Integer | 需房户数(`needs_hotel=true`,已排除历史旧户冻结名单) | | `releasedHouseholds` / `pendingReviewHouseholds` / `rejectedHouseholds` / `notSubmittedHouseholds` | Integer | 有基准需求版本 / 另有待确认新版本 / 无基准且最新被打回 / 需房但从未提过需求,各自户数 | | `outOfRangeHouseholds[]` | `OutOfRangeHousehold[]` | 阻塞名单(不是剔除名单,需求已计入分母),字段:`orderId`(String)、`orderNo`、`customerName`、`departDate`(可空)、`reason`(`OUT_OF_RANGE`/`DATE_MISSING`) | | `specialTags[]` | `HouseholdSpecialTags[]` | 各户特殊诉求(取自基准需求版本),字段:`orderId`(String)、`customerName`、`tags: string[]` | | `days[]` | `DayDetail[]` | 逐日详情,**可能出现团期区间之外的日历日**(越界户按其实际入住日落格,此时 `dayNumber=null`) | `DayDetail`(`:155-189`):`stayDate`、`dayNumber`(Integer 可空)、`dayStatus`(`NONE`/`PENDING`/`CONFIRMED`/`MIXED`)、`demand[]`/`planned[]`(`RoomCategoryCount[]`:`roomCategory`+`rooms`)、`mismatch[]`(`CategoryMismatch[]`,只列 `diff≠0` 的:`roomCategory`/`demanded`/`planned`/`diff`)、`plannedTotal`、`exceedsMaxRooms`(Boolean)、`plans[]`(该日 active 计划行,元素即 H4/H5 返回的 `GroupBatchRoomPlanRespVO`,字段见 H4 出参表)。 #### 请求示例 ```http GET /v3/admin/house/group-batches/20260610001 ``` #### 响应示例 ```json { "code": 200, "success": true, "data": { "groupBatchId": "20260610001", "batchNo": "GB-26-000001", "maxRooms": 30, "outOfRangeHouseholds": [], "specialTags": [{ "orderId": "900001", "customerName": "张三", "tags": ["轮椅需求"] }], "days": [{ "stayDate": "2026-06-12", "dayNumber": 1, "dayStatus": "PENDING", "demand": [{ "roomCategory": "STANDARD", "rooms": 5 }], "planned": [{ "roomCategory": "STANDARD", "rooms": 3 }], "mismatch": [{ "roomCategory": "STANDARD", "demanded": 5, "planned": 3, "diff": -2 }], "plannedTotal": 3, "exceedsMaxRooms": false, "plans": [] }] } } ``` #### 空数据 / 降级响应 - 无空态:团期不存在直接返 589500;只要团期存在就必有完整响应体。`days[]`、`outOfRangeHouseholds[]`、`specialTags[]` 均可能是空数组(团期日期缺失或尚无需求提交时),前端应按空数组正常渲染而不是当作异常。 #### 错误响应 ```json { "code": 808612, "message": "该团期尚未被房务认领,请先到团期抢单池认领", "success": false, "data": null } ``` ```json { "code": 808613, "message": "该团期由其他房务认领,无权操作", "success": false, "data": null } ``` (组长/超管不受此限,见「四、契约约束」。团期不存在为既有 `589500`。) #### 业务边界 - 普通房务只能看本人认领的团(`HouseGroupBatchClaimGuard.assertReadableByCurrentUser`,`HouseGroupBatchClaimGuard.java:42-54`),组长/超管可看任意团(`HouseReadGuard.canViewOthersClaims()` 放行)。 --- ### 3. 团期逐日用房需求明细(H3) `GET /v3/admin/house/group-batches/{groupBatchId}/room-requirements` **VO**: `无请求体(仅 Path)→ Result` (Controller:`HouseGroupBatchBoardController.java:76-82`;编排:`HouseGroupBatchBoardManager.requirements`,`:166-199`) #### 使用场景 核对「每户每晚要几间什么房」的逐户明细,供订房前对照。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | `groupBatchId` | Path | Long | 是 | 雪花 ID | 团期主订单 ID(同 H2) | #### 出参字段表 `HouseGroupBatchRoomRequirementRespVO`(`HouseGroupBatchRoomRequirementRespVO.java:20-183`) | 字段 | 类型 | 说明 | |---|---|---| | `groupBatchId` / `batchNo` / `departDate` / `endDate` | — | 团期基本信息 | | `basisRule` | String | 固定文案「按各户最近一次放行房务的需求版本」,后端写死供前端原样展示,口径改动只改这一处 | | `days[]` | `Day[]` | 逐日明细:`stayDate`、`dayNumber`(可空)、`totals[]`(`RoomCategoryCount[]`)、`households[]` | | `householdsWithoutBasis[]` | `HouseholdWithoutBasis[]` | 需房但无放行基准版的户:`orderId`/`orderNo`/`customerName`、`reason`(`NOT_SUBMITTED`/`PENDING_REVIEW`/`REJECTED`)、`referenceRooms[]`(仅展示不计入合计) | | `outOfRangeHouseholds[]` | 同 H2 | 阻塞名单 | `Household`(`:69-123`):`orderId`(String)/`orderNo`/`customerName`/`consultantId`(String)/`peopleCount`/`householdDayNumber`/`dateShifted`(Boolean,该户出发日≠团期出发日)/`roomControlStatus`/`basisRequirementId`(String)/`basisVersion`/`basisStatus`(`PENDING`/`PROCESSING`/`DONE`)/`pendingNewVersion`(Boolean)/`rooms[]`(`RoomLine[]`:`roomCategory`+`roomCount`)/`specialTags: string[]`。 #### 请求示例 ```http GET /v3/admin/house/group-batches/20260610001/room-requirements ``` #### 响应示例 ```json { "code": 200, "success": true, "data": { "groupBatchId": "20260610001", "batchNo": "GB-26-000001", "basisRule": "按各户最近一次放行房务的需求版本", "days": [{ "stayDate": "2026-06-12", "dayNumber": 1, "totals": [{ "roomCategory": "STANDARD", "rooms": 5 }], "households": [{ "orderId": "900001", "orderNo": "ORD202606120001", "customerName": "张三", "peopleCount": 2, "householdDayNumber": 1, "dateShifted": false, "roomControlStatus": "PENDING", "basisVersion": 2, "basisStatus": "DONE", "pendingNewVersion": false, "rooms": [{ "roomCategory": "STANDARD", "roomCount": 1 }], "specialTags": [] }] }], "householdsWithoutBasis": [], "outOfRangeHouseholds": [] } } ``` #### 空数据 / 降级响应 - 无空态:团期不存在直接返 589500。`days[]`/`householdsWithoutBasis[]`/`outOfRangeHouseholds[]` 均可能是空数组(团尚无任何户提交过需求时),前端应按空数组正常渲染。 #### 错误响应 ```json { "code": 808612, "message": "该团期尚未被房务认领,请先到团期抢单池认领", "success": false, "data": null } ``` ```json { "code": 808613, "message": "该团期由其他房务认领,无权操作", "success": false, "data": null } ``` (与 H2 同一组错误码,判定逻辑完全一致。团期不存在为既有 `589500`。) #### 业务边界 - 归属判定与 H2 完全一致:普通房务只能看本人认领的团,组长/超管可看任意团(`HouseGroupBatchClaimGuard.assertReadableByCurrentUser`)。 - `basisRule` 文案由后端写死并原样透传,前端应直接展示该字段值,不要本地硬编码同义文案(口径变更只改后端一处)。 --- ### 4. 整团按日提交订房计划(H4) `POST /v3/admin/house/group-batches/{groupBatchId}/room-plans` **VO**: `GroupBatchRoomPlanSaveReqVO → Result>` (Controller:`HouseGroupBatchRoomPlanController.java:69-82`;编排:`GroupBatchRoomPlanManager.save`/`doSave`,`:120-173`) #### 使用场景 房务批量登记某团多天 × 多酒店 × 多房型的订房安排。**追加式,不做集合对账**:库里有而本次没提交的行不会被删(避免超出 3 秒幂等窗后重放请求把未重复提交的行整批删掉)。 #### 请求头 `@Idempotent`:`keyPrefix="house:gb:room-plan:save"`、`keyArg=#groupBatchId`、`timeout=3` 秒,窗口内重复提交返回「订房计划提交处理中,请勿重复提交」。角色门 `@HouseWriteGuarded` 在幂等/锁**之前**执行,非房务角色不会消耗幂等令牌。 #### 入参字段表 `GroupBatchRoomPlanSaveReqVO`(`GroupBatchRoomPlanSaveReqVO.java:35-40`): | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | `items` | Body | `GroupBatchRoomPlanItemReqVO[]` | 是 | 1-200 行 | 上限 200 是团期级锁(30 秒租约)与按酒店聚合 Feign 调用次数的取舍 | `items[]` 元素 `GroupBatchRoomPlanItemReqVO`(H4 走 `@Validated({Default.class, RoomPlanValidationGroups.Create.class})`,`RoomPlanValidationGroups.java`): | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | `stayDate` | Body | LocalDate(ISO) | 是(Create 组) | 须落在团期 `[出发日,结束日)` 内,否则 808602 | 入住日 | | `hotelId` | Body | Long | 是(Create 组) | — | 酒店 ID | | `roomTypeId` | Body | Long | 是(Create 组) | — | 房型 ID | | `roomCategory` | Body | String | 否 | ≤32 字 | **仅用于前端回显,服务端一律以 resource 权威值覆盖**,传与不传都不影响落库(`GroupBatchRoomPlanItemReqVO.java:44-52`) | | `roomCount` | Body | Integer | 是(Create 组) | `>= 1`,否则 808603 | 订房间数 | | `protoPrice` / `settlementPrice` | Body | BigDecimal | 否 | `>= 0.00` | 不传按「日历价→协议价」链路兜底 | | `settleType` | Body | String | 否 | 正则 `^(cash\|sign\|company)$` | 不传取酒店资源配置 | | `deductInventory` | Body | Boolean | 否 | — | 是否扣减 resource 库存,语义默认 true(真正扣减在按日确认时,#7325) | | `remark` | Body | String | 否 | ≤512 字 | 备注 | | `version` | Body | Integer | 否(H4 忽略) | — | 仅 H5 用 | | `replaceReason` | Body | String | 否(H4 忽略) | ≤256 字 | 仅 H5 删旧建新用 | 请求内同 `(stayDate, hotelId, roomTypeId)` 的多行**合并间数**(其余字段取首行),与库中已有 active 行撞同一格子拒 **808614**。 #### 出参字段表 `Result>` 按 `(入住日, 酒店, 房型)` 升序排列。`GroupBatchRoomPlanRespVO`(`GroupBatchRoomPlanRespVO.java:20-109`): | 字段 | 类型 | 说明 | |---|---|---| | `planId` / `groupBatchId` | String(雪花) | — | | `stayDate` | LocalDate | — | | `hotelId` | String | — | | `hotelName` | String(可空) | Feign 抖动时可为 null | | `roomTypeId` | String | — | | `roomTypeName` | String(可空) | — | | `roomCategory` | String | **resource 权威值**,非客户端传值 | | `roomCount` | Integer | — | | `protoPrice` / `settlementPrice` | String(可空,`ToStringSerializer`) | 价格快照 | | `settleType` | String(可空) | — | | `deductInventory` | Boolean | — | | `planStatus` | String | H4 新建行恒为 `PENDING` | | `confirmedAt` / `confirmedBy` | 可空 | H4 新建行恒 null | | `remark` / `replaceReason` | String | H4 新建行 `replaceReason` 恒 null | | `replacedFromPlanId` | String(可空) | H4 新建行恒 null | | `version` | Integer | H4 新建行恒 `0` | | `allocatedRooms` | Integer | 其下 active 分房行 Σ 间数,自动分房上线前恒 `0` | | `createTime` / `updateTime` | LocalDateTime | — | | `warnings` | `string[]` | ⚠️ **H4 恒为空数组 `[]`**,见「关键变化 4」 | | `removedManualAllocIds` | `string[]` | ⚠️ **H4 恒为空数组 `[]`**,见「关键变化 4」 | #### 请求示例 ```json POST /v3/admin/house/group-batches/20260610001/room-plans { "items": [ { "stayDate": "2026-06-12", "hotelId": 200001, "roomTypeId": 300001, "roomCount": 3, "settleType": "sign" } ] } ``` #### 响应示例 ```json { "code": 200, "success": true, "data": [{ "planId": "500001", "groupBatchId": "20260610001", "stayDate": "2026-06-12", "hotelId": "200001", "hotelName": "示例酒店", "roomTypeId": "300001", "roomTypeName": "标间", "roomCategory": "STANDARD", "roomCount": 3, "protoPrice": "320.00", "settlementPrice": "300.00", "settleType": "sign", "deductInventory": true, "planStatus": "PENDING", "confirmedAt": null, "confirmedBy": null, "remark": null, "replaceReason": null, "replacedFromPlanId": null, "version": 0, "allocatedRooms": 0, "createTime": "2026-09-10 10:00:00", "updateTime": "2026-09-10 10:00:00", "warnings": [], "removedManualAllocIds": [] }] } ``` #### 空数据 / 降级响应 - 无空态:`items` 非空是入参硬约束(`@NotEmpty`),不存在「空提交返回 200」的场景;`items` 为空数组会被 Bean Validation 拒在参数绑定阶段。 #### 错误响应(按触发顺序,见 `GroupBatchRoomPlanManager.java:123-126` 校验顺序注释) ```json { "code": 589500, "message": "团期不存在", "success": false, "data": null } { "code": 808600, "message": "团期当前阶段({0})不允许修改订房计划", "success": false, "data": null } { "code": 808611, "message": "团期出发日或结束日缺失,无法录入订房计划", "success": false, "data": null } { "code": 808612, "message": "该团期尚未被房务认领,请先到团期抢单池认领", "success": false, "data": null } { "code": 808613, "message": "该团期由其他房务认领,无权操作", "success": false, "data": null } { "code": 808602, "message": "入住日 {0} 不在团期出行区间内", "success": false, "data": null } { "code": 808603, "message": "订房间数必须大于 0", "success": false, "data": null } { "code": 808614, "message": "该入住日({0})在酒店 {1} 的房型 {2} 上已有订房计划,请改用修改单行", "success": false, "data": null } { "code": 808112, "message": "房型不属于该酒店", "success": false, "data": null } { "code": 808691, "message": "无法确定房型大类(酒店 {0} 房型 {1}),请稍后重试", "success": false, "data": null } ``` #### 业务边界 - **阶段闸门(808600)覆盖面含 CANCELLED**:`HouseGroupBatchPlanGate.PLAN_WRITABLE_STATUSES` 只含 `RESOURCE_PREPARING/MATERIAL_PREPARING/PENDING_DEPARTURE/TRAVELLING/TRIP_FINISHED/REVIEWING` 六态(`HouseGroupBatchPlanGate.java:42-48`),`RECRUITING`/`SETTLED`/`CANCELLED` 均抛 808600。 - **房型校验 fail-closed 两级**:`808691`=resource 压根没查到权威房型列表(Feign 失败/返回失败/空列表);`808112`=查到了非空列表但该 `roomTypeId` 不在其中(复用既有 `HouseAssignmentErrorCode`)。二者优先级:先判"拿不到列表整体"(808691),再判"列表里不含该行"(808112),逐酒店批量校验(见 `HouseRoomTypeGuard.requireRoomTypes`,`:93-157`)。 - 团期级 `@Lock4j` 锁租约 30 秒,写口内不允许逐行 Feign,按酒店聚合调用。 --- ### 5. 修改单条订房计划(H5) `PUT /v3/admin/house/group-batches/{groupBatchId}/room-plans/{planId}` **VO**: `GroupBatchRoomPlanItemReqVO → Result` (Controller:`HouseGroupBatchRoomPlanController.java:91-102`;编排:`GroupBatchRoomPlanManager.update`/`doUpdateInPlace`/`doReplace`,`:177-294`) #### 使用场景 改单条已录入的订房计划行。语义:**字段为 `null` = 本次不改**。五要素(`stayDate`/`hotelId`/`roomTypeId`/`roomCount`/`roomCategory`,已确认行还多算 `deductInventory`)任一变化 → **删旧建新**(旧行软删登记库存待释放,新行落 `PENDING`、`version` 归零,`replacedFromPlanId` 指回旧行);只改价格/结算方式/`deductInventory`(仅 PENDING 行)/备注 → **原地更新**(同 `planId`,`version+1`)。 #### 入参字段表 `GroupBatchRoomPlanItemReqVO`(Path:`groupBatchId`/`planId`;Body 走 `@Validated({Default.class, RoomPlanValidationGroups.Update.class})`): | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | `groupBatchId` | Path | Long | 是 | 雪花 ID | 团期主订单 ID | | `planId` | Path | Long | 是 | 雪花 ID | 计划行 ID | | `stayDate`/`hotelId`/`roomTypeId`/`roomCount`/`roomCategory` | Body | 同 H4 | 否 | `null`=本次不改,非 `null` 时约束同 H4(808602/808603) | 五要素,任一非 null 且发生变化即触发删旧建新 | | `protoPrice`/`settlementPrice`/`settleType`/`deductInventory`/`remark` | Body | 同 H4 | 否 | 同 H4 | `null`=本次不改 | | `version` | Body | Integer | **是**(`Update` 组强制,`:83-86`) | 须等于当前行版本号 | 不匹配抛 **808608**,不自动重试 | | `replaceReason` | Body | String | 否 | ≤256 字;核单中团期(`REVIEWING`)强制填写且 `>=10` 字,否则 **808617** | 走「删旧建新」时落到新行,原地更新时忽略 | `GroupBatchRoomPlanItemReqVO` 字段基础定义同 H4 请求体,H5 差异点: | 字段 | H5 差异 | |---|---| | `stayDate`/`hotelId`/`roomTypeId`/`roomCount` | 均可为 `null`(不在 `Update` 组强制),非 `null` 时才参与「是否变化」判定 | | `version` | **必填**(`Update` 组强制,`:83-86`),不匹配抛 **808608**,不自动重试 | | `replaceReason` | 走「删旧建新」时落到新行;核单中团期(`REVIEWING`)强制填写且 `>=10` 字,否则 **808617** | #### 出参字段表 | 字段 | 类型 | 说明 | |---|---|---| | 全部字段 | — | 同 H4 出参表 `GroupBatchRoomPlanRespVO`(见「4. 整团按日提交订房计划(H4)」),逐字段定义不再重复,下表只列 H5 场景下的语义差异 | 同 H4 的 `GroupBatchRoomPlanRespVO`,但语义不同: | 场景 | `planId` | `version` | `warnings` | `removedManualAllocIds` | |---|---|---|---|---| | 原地更新 | 不变 | `+1` | `REVIEWING` 阶段含 `"SETTLEMENT_IN_PROGRESS"`,否则 `[]` | 恒 `[]`(原地更新不触发连锁重算) | | 删旧建新(新行) | 新 ID | `0` | 同上,若旧行是 `CONFIRMED` 且触发连锁重算清了人工分房行,额外含 `"MANUAL_ALLOCATION_REMOVED"` | 若触发连锁重算,含被软删的人工分房行 ID;自动分房上线前恒 `[]` | #### 请求示例(原地改价) ```json PUT /v3/admin/house/group-batches/20260610001/room-plans/500001 { "version": 0, "protoPrice": "330.00", "remark": "临时涨价" } ``` #### 请求示例(五要素变化,删旧建新) ```json PUT /v3/admin/house/group-batches/20260610001/room-plans/500001 { "version": 0, "roomCount": 4, "replaceReason": "酒店临时超售,加订一间" } ``` #### 响应示例 ```json { "code": 200, "success": true, "data": { "planId": "500002", "groupBatchId": "20260610001", "stayDate": "2026-06-12", "roomCount": 4, "planStatus": "PENDING", "version": 0, "replacedFromPlanId": "500001", "replaceReason": "酒店临时超售,加订一间", "warnings": [], "removedManualAllocIds": [] } } ``` #### 空数据 / 降级响应 - 无空态:修改必须定位到一条存在的 `planId`,不存在直接返 808601,不会有「修改了但返回空对象」的场景。 #### 错误响应(按校验顺序,`GroupBatchRoomPlanManager.java:180-185`) ```json { "code": 808601, "message": "订房计划行不存在或已删除", "success": false, "data": null } { "code": 808615, "message": "订房计划行不属于该团期", "success": false, "data": null } { "code": 808600, "message": "团期当前阶段({0})不允许修改订房计划", "success": false, "data": null } { "code": 808617, "message": "该团期正在核单,修改订房计划必须填写不少于 10 字的原因", "success": false, "data": null } { "code": 808612, "message": "该团期尚未被房务认领,请先到团期抢单池认领", "success": false, "data": null } { "code": 808613, "message": "该团期由其他房务认领,无权操作", "success": false, "data": null } { "code": 808690, "message": "入住日 {0} 已发生,订房记录不可修改或删除", "success": false, "data": null } { "code": 808608, "message": "订房计划已被其他操作修改,请刷新后重试", "success": false, "data": null } { "code": 808692, "message": "已确认的订房计划不支持直接改价或改备注,差异请在核单中登记", "success": false, "data": null } { "code": 808611, "message": "团期出发日或结束日缺失,无法录入订房计划", "success": false, "data": null } { "code": 808602, "message": "入住日 {0} 不在团期出行区间内", "success": false, "data": null } { "code": 808603, "message": "订房间数必须大于 0", "success": false, "data": null } { "code": 808614, "message": "该入住日({0})在酒店 {1} 的房型 {2} 上已有订房计划,请改用修改单行", "success": false, "data": null } { "code": 808616, "message": "已确认订房计划的改删连锁能力尚未上线,请联系管理员", "success": false, "data": null } ``` #### 业务边界 - **⚠️ 完整校验顺序(不可假设某一步先于全局都先做完,前端做乐观 UI 时需注意)**:计划行存在 808601 → 属本团 808615 → 阶段闸门 808600 → 核单期原因门 808617 → 认领归属 808612/808613 → **已发生间夜冻结 808690**(第 6 步,只看日期与行状态,与本次改什么字段无关)→ 版本一致 808608 → 五要素判定分叉 → **已确认行禁原地更新 808692**(第 9 步)→(走替换分支时)团期日期齐 808611 → 区间/间数/同格子 808602/808603/808614 → 已确认行连锁重算未上线 808616。**808690 始终先于 808692 判定**:即便五要素完全没变、本该走「原地更新」分支,只要这一行是已发生的过去 CONFIRMED 行,也会先被 808690 拦下,不会等到走进"原地更新 vs 808692"分支再判断。 - 已确认行改删连锁重算(`#7325` 交付)当前**未接入**:五要素变化且旧行是 `CONFIRMED` 时,若触发到需要重算分房的场景会抛 **808616**、整事务回滚、`afterCommit` 不执行、零副作用。 --- ### 6. 删除单条订房计划(软删,H6) `DELETE /v3/admin/house/group-batches/{groupBatchId}/room-plans/{planId}` **VO**: `GroupBatchRoomPlanDeleteReqVO`(请求体可选) `→ Result` (Controller:`HouseGroupBatchRoomPlanController.java:107-117`;编排:`GroupBatchRoomPlanManager.delete`/`doDelete`,`:296-365`) #### 使用场景 删掉一条订房安排。**已流团(CANCELLED)团期同样放行删除**(否则已扣的酒店库存会永久滞留),但已发生的间夜仍然删不掉。**不动分房行**——那一晚的房还要按需求重新分给各户,删除本身不做「全删重建」的决定(`#7325` 负责)。 #### 入参字段表 `GroupBatchRoomPlanDeleteReqVO`(`GroupBatchRoomPlanDeleteReqVO.java`,请求体可整体不传): | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | `groupBatchId` | Path | Long | 是 | 雪花 ID | 团期主订单 ID | | `planId` | Path | Long | 是 | 雪花 ID | 计划行 ID | | `reason` | Body | String | 仅团期处于 `REVIEWING`(核单中)时必填 | ≤256 字,核单中团期需 ≥10 字,否则 **808617** | 删除原因 | #### 出参字段表 `GroupBatchRoomPlanDeleteRespVO`(`GroupBatchRoomPlanDeleteRespVO.java:20-35`) | 字段 | 类型 | 说明 | |---|---|---| | `planId` | String | 被软删的计划行 ID | | `releasedLogId` | String(可空) | 本次由 `NONE` 标为 `PENDING` 的扣减日志 ID;从未扣过库存的行为 `null` | | `warnings` | `string[]` | `SETTLEMENT_IN_PROGRESS`(核单期)/`MANUAL_ALLOCATION_REMOVED`(连锁重算清了人工分房行),无告警为空数组 | | `removedManualAllocIds` | `string[]` | 被连锁重算软删的人工分房行 ID,自动分房上线前恒为空 | #### 请求示例 ```http DELETE /v3/admin/house/group-batches/20260610001/room-plans/500001 ``` (核单中团期需带请求体:`{ "reason": "客人退团,该晚不再需要" }`) #### 响应示例 ```json { "code": 200, "success": true, "data": { "planId": "500001", "releasedLogId": "700001", "warnings": [], "removedManualAllocIds": [] } } ``` #### 空数据 / 降级响应 - 无空态:删除必须定位到一条存在的 `planId`,不存在(含二次删除)直接返 808601,不会有「删除了但返回空对象」的场景。 #### 错误响应 ```json { "code": 808601, "message": "订房计划行不存在或已删除", "success": false, "data": null } { "code": 808615, "message": "订房计划行不属于该团期", "success": false, "data": null } { "code": 808600, "message": "团期当前阶段({0})不允许修改订房计划", "success": false, "data": null } { "code": 808617, "message": "该团期正在核单,修改订房计划必须填写不少于 10 字的原因", "success": false, "data": null } { "code": 808612, "message": "该团期尚未被房务认领,请先到团期抢单池认领", "success": false, "data": null } { "code": 808613, "message": "该团期由其他房务认领,无权操作", "success": false, "data": null } { "code": 808690, "message": "入住日 {0} 已发生,订房记录不可修改或删除", "success": false, "data": null } ``` (H6 **不会**抛 808692——已确认行禁原地更新是 H5 专属场景,H6 是软删不涉及原地更新分支。) #### 业务边界 - **阶段闸门比 H4/H5 宽一档**:`assertDeletableStage`(`GroupBatchRoomPlanManager.java:422-429`)允许「可写六态」∪「`releaseOnly`(仅 `CANCELLED`)」,即 **`CANCELLED` 团期的 H6 不抛 808600**(与 H4/H5 相反)。仍只在 `RECRUITING`/`SETTLED` 抛 808600。 - 已发生间夜(CONFIRMED 行 ∧ 入住日早于操作当日 ∧ 团期处于 `TRAVELLING`/`TRIP_FINISHED`/`REVIEWING`/`CANCELLED`)一律 808690,不因软删豁免。 - 二次删除同一 `planId` 同样返回 808601(天然幂等出口,不是新错误)。 --- ### 7. 整团批量释放订房计划 `POST /v3/admin/house/group-batches/{groupBatchId}/room-plans/release-all` **VO**: `GroupBatchRoomPlanReleaseAllReqVO → Result` (Controller:`HouseGroupBatchRoomPlanController.java:122-135`;编排:`GroupBatchRoomPlanManager.releaseAll`,`:369-410`) #### 使用场景 团期流团(`CANCELLED`)后的收尾:一次性软删该团全部订房计划与分房、登记库存归还。是 `#7325` 流团自动钩子失败时的手动兜底入口,也是钩子上线前的过渡入口——两条路径调同一个实现方法。 #### 请求头 `@Idempotent`:`keyPrefix="house:gb:room-release"`、`keyArg=#groupBatchId`、`timeout=3` 秒。团期级锁租约 **60 秒**(`RELEASE_LOCK_EXPIRE_MILLIS`,比 H4/H5/H6 的 30 秒更长——整团批量处置行数更多)。 #### 入参字段表 `GroupBatchRoomPlanReleaseAllReqVO`(`GroupBatchRoomPlanReleaseAllReqVO.java`): | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | `groupBatchId` | Path | Long | 是 | 雪花 ID | 团期主订单 ID | | `reason` | Body | String | **是,无条件必填**(与团期状态无关) | 10-256 字 | 释放原因。10 字下限用于挡掉「删」「测试」这类等于没写的原因,因为本端点一次会把整个团的订房与分房全部软删 | #### 出参字段表 `GroupBatchRoomPlanReleaseAllRespVO`(`GroupBatchRoomPlanReleaseAllRespVO.java:16-46`) | 字段 | 类型 | 说明 | |---|---|---| | `groupBatchId` | String | 团期 ID | | `releasedPlanIds` | `string[]` | 本次软删的计划行 ID;⚠️ 见「关键变化 3」,可能为空 | | `releasedAllocationIds` | `string[]` | 本次软删的分房行 ID | | `markedLogIds` | `string[]` | 本次由 `NONE` 标为 `PENDING` 的扣减日志 ID(真正归还在事务提交后异步进行,代表「已确保会还」不代表「已还完」) | | `hotelReady` | Boolean | 处置后的配房完成标志,**恒为 `false`** | | `retainedPastPlanIds` | `string[]` | 入住日早于操作当日、因已发生而未释放的已确认计划行 ID | | `warnings` | `string[]` | `retainedPastPlanIds` 非空时含 `"PAST_STAY_RETAINED"`,否则 `[]` | #### 请求示例 ```json POST /v3/admin/house/group-batches/20260610001/room-plans/release-all { "reason": "团期已流团,释放剩余订房" } ``` #### 响应示例(正常全释放) ```json { "code": 200, "success": true, "data": { "groupBatchId": "20260610001", "releasedPlanIds": ["500001", "500002"], "releasedAllocationIds": [], "markedLogIds": ["700001", "700002"], "hotelReady": false, "retainedPastPlanIds": [], "warnings": [] } } ``` #### 响应示例(⚠️ 见关键变化 3:200 但零释放) ```json { "code": 200, "success": true, "data": { "groupBatchId": "20260610001", "releasedPlanIds": [], "releasedAllocationIds": [], "markedLogIds": [], "hotelReady": false, "retainedPastPlanIds": ["500001", "500002"], "warnings": ["PAST_STAY_RETAINED"] } } ``` #### 空数据 / 降级响应 - `releasedPlanIds`/`releasedAllocationIds`/`markedLogIds` 均可能返回空数组但整体仍是 200(该团计划行全部是已发生的已确认行、全部转入 `retainedPastPlanIds`),见「关键变化 3」——**不要把空数组当作请求失败**。 - 该团完全没有可处置内容(无 active 计划行也无 active 分房行)时不返回空数据,改为业务错误码 **808619**(见下方错误响应),这是零写入的幂等出口。 #### 错误响应 ```json { "code": 808618, "message": "团期当前阶段({0})不支持批量释放订房,仅已流团团期可用", "success": false, "data": null } { "code": 808613, "message": "该团期由其他房务认领,无权操作", "success": false, "data": null } { "code": 808619, "message": "该团期没有可释放的订房计划", "success": false, "data": null } ``` #### 业务边界 - **仅 `CANCELLED` 团期可用**,含 `SETTLED` 在内的其它阶段一律 **808618**(`HouseGroupBatchPlanGate.PLAN_RELEASE_ONLY_STATUSES` 只含 `CANCELLED`,`HouseGroupBatchPlanGate.java:58-60`);`SETTLED` 不放行的理由:已结算团每晚都住过,归还过去日期库存没有可售价值,还会让月度对账凭空少掉已发生房费。 - **归属校验与 H4/H5/H6 不同**:`assertReleasableByCurrentUser`(`HouseGroupBatchClaimGuard.java:72-94`)对**无人认领**的团直接放行(任意房务都能操作,不抛 808612)——团已是终态,谁来收尾都行;**有认领人**时仍只放行本人或超管,否则 808613。 - `808619` vs 200+`retainedPastPlanIds` 非空的区分逻辑见「关键变化 3」,`GroupBatchRoomReleaseResult.nothingToRelease` 是显式标志而非靠列表判空推断(`GroupBatchRoomReleaseResult.java:11-16`)。 - 只释放**今日及以后**的入住日,早于操作当日的已确认行一律保留进 `retainedPastPlanIds`(不因流团豁免,与 H6 的 808690 同一判据)。 --- ## 四、契约约束与正确调用方式 ### ✅ 正确 / ❌ 错误 payload 对照 - ✅ H5 只提交实际改动的字段(`version` + 变动字段),❌ 把整份读接口回显对象原样 PUT 回去——PENDING 行虽不会清空未提交字段(MyBatis-Plus 默认 `FieldStrategy.NOT_NULL`,`null` 不进 SET 子句),但仍会把 `version` 无意义地 `+1` 并留一条 `UPDATE_IN_PLACE` 时间线噪音记录;CONFIRMED 行会被直接拒 808692(见「关键变化 2」)。 - ✅ H1 判断 `total === -1` 时改用占位文案,❌ 直接拿 `total` 算 `Math.ceil(total/pageSize)` 或拼「共 N 条」。 - ✅ `release-all` 成功回调先判 `releasedPlanIds.length` 与 `retainedPastPlanIds.length` 再决定提示文案,❌ 只看 `success:true` 就提示「已全部释放」。 - ✅ H4 请求体里保留 `roomCategory`(供本地即时回显),但提交后**以响应体里的 `roomCategory` 为准刷新本地态**,❌ 假设服务端会原样接受客户端传入的 `roomCategory`。 - ✅ H4/H5 的 `settleType` 只能传 `cash`/`sign`/`company` 三值之一(大小写敏感),❌ 传其它字符串(会被 Bean Validation 拒在参数绑定阶段,报 400 级校验错误而非业务错误码)。 ### 认领态与角色的显隐建议 - `scope=ALL` 仅组长/超管可用,前端应按角色隐藏该切换项,而不是等 808092 才提示。 - H4/H5/H6 的操作入口只在「本人认领的团」(或超管)下展示;`release-all` 入口可对未认领的已流团团期展示(任意房务可操作)。 --- ## 五、数据库行为(涉及写操作时必写) - **新增表** `group_batch_room_plan`(团期按日订房计划,软删表;`GroupBatchRoomPlanDO`)与相关的团期分房支撑表(`GroupBatchRoomAllocationDO`),细节见后端 Flyway,前端不需要感知表结构。 - **写口全部落在团期级分布式锁内**(`@Lock4j`,锁名 `HouseGroupBatchLockConstants.GROUP_BATCH_HOUSE_LOCK_NAME`,H4/H5/H6 租约 30 秒、`release-all` 租约 60 秒),同一团期的并发写请求会串行化,锁等待超时对前端表现为请求超时/失败,可直接重试。 - **同日同酒店同房型只能有一条 active 计划行,这条不变量没有 DB 唯一键兜底**(软删表软删列可空,MySQL 唯一索引里 `NULL` 互不相等),只靠「请求内合并 + 团期级锁 + 应用层查重(808614)」三件事共同守住。 - **跨服务契约新增字段**:`hl-resource-service` 的 `HouseRoomTypeDTO.roomCategory`(`HouseRoomTypeDTO.java:51`)与 `hl-order-service-v3` 消费侧 `HouseRoomTypeFeignVO.roomCategory`(`HouseRoomTypeFeignVO.java:47`),来源 `room_type.room_category`,历史数据可能为 `null`。H4/H5 写入时**一律以此权威值覆盖客户端传入的 `roomCategory`**,取不到时 fail-closed 拒绝写入(808691),不同于同包内既有的 `assertRoomTypeBelongsToHotel`(fail-open,用于越权写回价格场景)——两者口径刻意不同,理由是本字段是「对平」判定的分组键,放行等于允许伪造已对平。 - 库存扣减/归还是**异步两阶段**:写口内只登记扣减日志状态(`NONE`→`PENDING`),真正归还发生在事务 `afterCommit`;若那一步失败,兜底任务凭 `PENDING` 状态捞回重做。响应体里的 `releasedLogId`/`markedLogIds` 代表「已确保会还」,不代表「已经还完」。 - **H5 原地更新走 MyBatis-Plus 默认 `FieldStrategy.NOT_NULL`**:`updateWithVersion` 底层是 `roomPlanMapper.updateById(plan)`(`GroupBatchRoomPlanService.java:174-178`),`GroupBatchRoomPlanDO` 的可选字段均未声明 `@TableField(updateStrategy=...)`,全仓也无全局覆盖——`patch` 对象里为 `null` 的字段不会进 SET 子句。这是「H5 只传 `version` 在 PENDING 行返回 200 但不清空字段」的底层依据(见「关键变化 2」)。 --- ## 六、边界行为 - H1 `planStatus` 过滤降级为内存扫描,扫描上限 500 条(`PLAN_STATUS_SCAN_LIMIT`),超限时 `total=-1`(见关键变化 1);不超限时 `total` 精确等于内存过滤后的匹配数。 - H1 `batchStatus` 若传入非法状态码或全部被过滤掉(如只传 `CANCELLED`),静默回退为默认四态,不报错。 - H2 `days[]` 可能出现团期区间之外的日历日(越界户按其**实际入住日**落格,此时 `dayNumber=null`)。 - H4 单次最多 200 行,超出报 400 级参数校验错误(`@Size(max=200)`),不占用团期级锁。 - H5 删旧建新分支的「五要素比较」把已确认行的 `deductInventory` 也算进比较范围(未确认行不算),因为它决定这一行是否占用 resource 库存,原地翻转会让库存持有与行状态脱节。 - H6 二次删除同一行幂等返回 808601,不是错误放大。 - `release-all` 的团期级锁租约(60 秒)比其它写口更长,且允许无人认领的团被任意房务操作。 --- ## 六.5、枚举 / 数据字典 ### 错误码新增清单(`HouseGroupBatchErrorCode.java`,段位 808600-808699 内本单实际占用 17 个常量) ⚠️ **与工单 #7324 核对发现的不符**:工单口径写「新增 12 个错误码」,但源码(squash `07feaffe0` diff `+165/-2`)实际新增 **17 个** `IErrorCode` 常量,以下按源码逐条列出: | Code | 常量名 | Message 模板 | module | 触发条件 | |---|---|---|---|---| | 808600 | `GB_ROOM_PLAN_BATCH_STATUS_INVALID` | 团期当前阶段({0})不允许修改订房计划 | order | H4/H5 阶段闸门(六态之外,含 CANCELLED);H6/release-all 阶段闸门更宽(CANCELLED 不触发) | | 808601 | `GB_ROOM_PLAN_NOT_FOUND` | 订房计划行不存在或已删除 | order | H5/H6 找不到 `planId`;二次删除同码天然幂等 | | 808602 | `GB_ROOM_PLAN_DATE_OUT_OF_RANGE` | 入住日 {0} 不在团期出行区间内 | order | H4/H5 入住日不在 `[出发日,结束日)` | | 808603 | `GB_ROOM_PLAN_COUNT_INVALID` | 订房间数必须大于 0 | order | H4/H5 `roomCount < 1` | | 808608 | `GB_ROOM_PLAN_VERSION_CONFLICT` | 订房计划已被其他操作修改,请刷新后重试 | order | H5 乐观锁 `version` 不匹配,不自动重试 | | 808611 | `GB_ROOM_PLAN_BATCH_DATE_MISSING` | 团期出发日或结束日缺失,无法录入订房计划 | order | H4 新建 / H5 改日期时团期 `departDate`/`endDate` 为空 | | 808612 | `GB_ROOM_PLAN_BATCH_NOT_CLAIMED` | 该团期尚未被房务认领,请先到团期抢单池认领 | order | H2/H3 只读与 H4/H5/H6 写操作,当前团期未认领(`release-all` 不抛此码) | | 808613 | `GB_ROOM_PLAN_BATCH_NOT_OWNED` | 该团期由其他房务认领,无权操作 | order | 同上场景,团期由他人认领(组长/超管读、超管写免检) | | 808614 | `GB_ROOM_PLAN_DUPLICATE_CELL` | 该入住日({0})在酒店 {1} 的房型 {2} 上已有订房计划,请改用修改单行 | order | H4/H5 与库中 active 行撞同一 `(stayDate,hotelId,roomTypeId)` 格子 | | 808615 | `GB_ROOM_PLAN_BATCH_MISMATCH` | 订房计划行不属于该团期 | order | H5/H6 路径参数 `groupBatchId` 与行数据的 `groupBatchId` 不一致 | | 808616 | `GB_ROOM_PLAN_CONFIRMED_REVISION_UNAVAILABLE` | 已确认订房计划的改删连锁能力尚未上线,请联系管理员 | order | H5/H6 对已确认(CONFIRMED)行做五要素变化的改/删,连锁重算能力(`#7325`)未接入 | | 808617 | `GB_ROOM_PLAN_REVISE_REASON_REQUIRED` | 该团期正在核单,修改订房计划必须填写不少于 10 字的原因 | order | H5/H6 团期处于 `REVIEWING`,`reason`/`replaceReason` 缺失或 <10 字 | | 808618 | `GB_ROOM_PLAN_RELEASE_BATCH_NOT_CLOSED` | 团期当前阶段({0})不支持批量释放订房,仅已流团团期可用 | order | `release-all` 团期非 `CANCELLED`(含 `SETTLED`) | | 808619 | `GB_ROOM_PLAN_RELEASE_NOTHING` | 该团期没有可释放的订房计划 | order | `release-all` 该团既无 active 计划行也无 active 分房行,零写入幂等出口 | | 808690 | `GB_ROOM_PLAN_PAST_STAY_LOCKED` | 入住日 {0} 已发生,订房记录不可修改或删除 | order | H5/H6:CONFIRMED 行 ∧ 入住日早于操作当日 ∧ 团期处于 `TRAVELLING`/`TRIP_FINISHED`/`REVIEWING`/`CANCELLED`;**始终先于 808692 判定** | | 808691 | `GB_ROOM_PLAN_ROOM_TYPE_UNRESOLVED` | 无法确定房型大类(酒店 {0} 房型 {1}),请稍后重试 | order | H4/H5 无法从 resource 取得权威房型行(Feign 失败/返回失败/空列表/该行 `roomCategory` 为空),fail-closed | | 808692 | `GB_ROOM_PLAN_CONFIRMED_IN_PLACE_UPDATE_FORBIDDEN` | 已确认的订房计划不支持直接改价或改备注,差异请在核单中登记 | order | H5:五要素未变化(走原地更新分支)且该行是 CONFIRMED;**在 808690 判定之后触发** | (复用的既有错误码,非本单新增:**808112**「房型不属于该酒店」属 `HouseAssignmentErrorCode`,H4/H5 权威房型批量校验查到非空列表但目标 ID 不在其中时复用;**808090/808091/808092** 属 `HouseGrabErrorCode`(`module="house-grab"`),是房务角色只读/只写/全量监督视图的通用角色门,本单只读端点复用 `HouseReadGuard`、写端点复用 `HouseWriteGuard` 触发;**589500** 团期不存在为 order 域既有码。) ### 枚举字典 - `planStatus`(H1 筛选 / H4-H6 响应):`PENDING`(未确认)/`CONFIRMED`(已确认)。H1 的 `PENDING` 档口径含「该团无 active 计划行」,不是单独一档。 - `boardStatus`(H1/H2):`NOT_STARTED`/`PARTIAL`/`ALL_CONFIRMED`。 - `dayStatus`(H1/H2 逐日):`NONE`/`PENDING`/`CONFIRMED`/`MIXED`。 - `warnings` 取值:`SETTLEMENT_IN_PROGRESS`(H5 核单期改动)/`MANUAL_ALLOCATION_REMOVED`(H5/H6 连锁重算清人工分房)/`PAST_STAY_RETAINED`(release-all 保留已发生行)。 - `settleType`:`cash`/`sign`/`company`。 - `batchStatus`(9 态,`GroupBatchStatus`):`RECRUITING`招募中 / `RESOURCE_PREPARING`资源准备中 / `MATERIAL_PREPARING`物料准备中 / `PENDING_DEPARTURE`待出发 / `TRAVELLING`出行中 / `TRIP_FINISHED`出行完毕 / `REVIEWING`核单中 / `SETTLED`已结算 / `CANCELLED`已取消。H1 默认四态=前四个(不含 `RECRUITING`);写口可写六态=`RESOURCE_PREPARING`~`REVIEWING`;仅释放态=`CANCELLED`。 --- ## 七、不影响范围 - 团期需求(H09 系列)、团期抢单池/我的团/接管(`#7322`)、团期核单/结算(`#7411` 等)现有端点**零改动**,本单只新增。 - 逐户(非团单)房务配房(`HouseAssignmentService` 等)零改动,`808112` 复用的是既有校验方法但走的是新的批量入口 `requireRoomTypes`,不影响原有的 `assertRoomTypeBelongsToHotel` 调用点(询房回填/配房 submit)。 - 按日确认、自动分房、分房微调(`#7325`/`#7326`)**尚未交付**,本单已预留错误码段位(`808604/808605/808607/808609/808610`、`808606`、`808640`)与阶段闸门单源(`HouseGroupBatchPlanGate`),但对应端点/能力均不在本次 PR 范围内——H5/H6 对已确认行的连锁重算目前 fail-closed 返回 808616。 - 网关路由零改动(`/v3/admin/**` 已由 `#3264` 通配)。 --- ## 八、测试环境已验证 - 已部署测试服并实测通过(`squash 07feaffe0`,PR #7465)。 - 部署过程中实测到一次跨服务不同步窗口:`hl-order-service-v3` 先于 `hl-resource-service` 完成部署的短暂时间段内,`GET /v3/admin/house/group-batches` 一度 404(网关侧尚未完成新路由注册的过渡态),重滚 `order-v3` 后恢复正常,最终按「`hl-resource-service` 先于 `hl-order-service-v3`」的正确顺序验收通过。 - 兼容性结论:全部为新增端点,无存量调用方,无向后兼容负担;唯一的跨服务契约是 `roomCategory` 字段新增(非破坏性字段新增),但**部署顺序错误会导致新端点短暂不可用/写口被 fail-closed 拒绝**,务必按本文件顶部 `status_note` 的顺序部署。 --- ## 十、相关文档 - `#7323`《团期房务实现方案 v1.0》§3.11(错误码段位分配定案) - `#7322` 团期整团抢单与抢单池分流(前置依赖:本单的「已认领」判定复用其 `house_claimer_id` 字段) - `#7325`(按日确认与自动分房,未交付)/ `#7326`(分房与只读,未交付)——本单为它们预留的段位与闸门单源 --- ## 关联 / 联系人 ### 链接 - **Issue**: [#7324](https://git.1814.love:8443/wx/HL/issues/7324) - **PR**: [#7465](https://git.1814.love:8443/wx/HL/pulls/7465) - **Merge commit**: [07feaffe0](https://git.1814.love:8443/wx/HL/commit/07feaffe09d7372f4d2eb828e5bf54ee776c7c90) ### 联系人 - **后端负责人**: @wx