--- schema: "hl-changelog/v2" ticket: "7326" title: "团期房务「分房」页三端点(H9 总览 / H10 人工微调 / H11 重算)+ 团期详情「配房明细」只读区块(H12);days[].balanced 统一为纯算术口径" consumer: "admin" author: "wx(GIT)" change_type: "新增接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "ad34c8ab" target_release: "" verified_at: "2026-09-14" status_note: "部署面只有 hl-order-service-v3 一个服务(本分支改动文件全在该模块 + hl-common 零命中),未改 hl-common-*,无消费方需连带滚;无 Flyway、无表变更。网关零改动:/v3/admin/** 已由 #3264 通配,四个端点均返回业务层响应而非路由未命中。⚠️ 覆盖范围写在脸上:测试服网关实测的是 hl-order-service-v3 @ e9855bf21(2026-09-13 15:58 部署),其后的 4df361bf6(days[].balanced 两端公式统一为纯算术)只有单测覆盖(GroupBatchRoomAllocationManagerTest 46/0/0/0 + 三个 ArchTest 合计 58/0/0/0),未再经网关实测——本篇「关键变化 2」描述的就是这一次改动,前端按新口径接即可,但它在测试服上的实证要等下一次部署。 另有 5fd39de3b(三处契约描述与实现对不上的订正:H9 日级 balanced 的 @ApiModelProperty 漏改、H11 根级 balanced 写「无人工冲突」而实际是 noStale&&noBlocked、808131 文案与 BedType label 四个词错三个),**这三处改的正是前端在 Swagger 上读到的定义**,同样只有单测覆盖(63/0/0/0)。⚠️ 网关实测那一轮的两处前置是 SQL 直更达成的(把团期推到 SETTLED 取 808600、直接插 order_hotel_requirement v2 造过时基线),所以「团期推进到 SETTLED」与「定制师改需求→管理员确认→房务收到新基线」这两条业务链路本轮没验过,只验了闸门读到该状态会拒。⚠️ 未实测(盲区):808612 未认领团、808091 房务组长写口、589507 的 Feign 降级支、planStatus 的 PENDING/NONE 两档、manualConflicts[]/outOfRange[]/skippedDays[] 三类不平项(全程恒空)、travelerRoomGroups[](order_traveler.room_group_no 全 NULL)、hotel_ready 由 false 置 true 的方向、并发与锁。以上各项的契约按源码写,不按实测写。【前端 hl-admin】【前端 hl-admin】全量闭环(ref ad34c8ab,2026-09-14):批1 H12 配房明细只读块(RoomPlansTab+getGroupBatchRoomPlans)+批2 分房页 H9/H10/H11(group-batch-room-plan.js 三端点+AllocationSection/AdjustModal/RebuildModal+BoardDetailModal 分房 Tab);根级 balanced 与日级 days[].balanced 分开渲染,六类不平项一条不吞,808643 引导重算,床型/roomGroupNo/身份键守红线;两批各 checkpoint 13 项全绿。" updated_at: "2026-09-13" base: "dev-v3" --- # 团期房务「分房」页(H9·H10·H11)与团期详情「配房明细」只读区块(H12) > **服务**: `hl-order-service-v3` > **PR**: 分支 `feature/7326-room-allocation`(合并后回填 PR 号) > **Issue**: #7326 > **日期**: 2026-09-13 > **影响范围**: 管理后台房务团期看板新增「分房」页(H9 读 + H10 人工微调 + H11 重算),团期详情新增「配房明细」只读区块(H12) --- ## ⚠️ 关键变化 **四个端点全是新增,无存量调用方。但有五处「按直觉写会写错」的地方,逐条列在下面。** ### 1. 🔴 H9 的 `stayDate` 格式错走的是全局绑定兜底,返回体 `code=400`,**不是** `100001` 按 `100001` 写的分支**永远命不中**。实际观察到的是: ```json { "code": 400, "message": "参数【stayDate】格式不正确(日期请用 yyyy-MM-dd,日期时间请用 yyyy-MM-dd'T'HH:mm:ss)", "data": null, "success": false } ``` 两点必须说清: - **HTTP 状态码仍是 200**。`OrderGlobalExceptionHandler#handleBind` 标了 `@ResponseStatus(HttpStatus.OK)`,`400` 是**响应体 `code` 字段**的值,不是 HTTP status。axios 那种「非 2xx 才进 catch」的拦截器**不会**被触发,必须按 `code` 判。 - **括号里的格式提示不是恒有的**。只有当被拒的值里含形似日期的数字串(匹配 `\d{4}-\d{1,2}-\d{1,2}`)时才拼上;传 `?stayDate=abc` 或 `?stayDate=2026/10/01` 拿到的是**没有括号**的 `参数【stayDate】格式不正确`。**别对 message 做全等匹配或前缀截断**,直接整串展示。 参数位置本身是对的:`stayDate` 是 **Query 参数**(GET 用 VO 绑定,`@Valid` 且**无** `@RequestBody`),`?stayDate=2026-10-01` 生效且只返回该日。**不是**那类「文档写 Body 实际 Query、传了像没传」的静默丢弃。 ### 2. 🔴 `days[].balanced` 现在只答「够不够」一个问题——别再当「这天完全 OK」用 2026-09-13 把 H9 与 H11 两端的日级公式统一成**纯算术**: | 端点 | 旧公式(本次改掉) | 新公式(两端逐字相同) | |---|---|---| | H9 `days[].balanced` | `leftover 全 0 && 逐户平 && `**`无过时户`** | `leftover 全 0 && 逐户 shortage/surplus 全 0` | | H10 / H11 `days[].balanced` | `逐户平 && leftover 空 && `**`无越界户`** | `leftover 全 0 && 逐户 shortage/surplus 全 0` | 改的原因:两端各多了一项对方没有的非算术量,**在真实数据上会对同一天给出相反结论**(越界户那一支 H9=true / H11=false;过时户那一支 H9=false / H11=true)。日级字段吃全团判定还会让「6-13 的事实」污染「6-12 的结论」——房务被告知 6-12 分得不对,而问题在 6-13。 🔴 **根级 `balanced` 与 `hotelReady` 一个字没动**:它们仍然一票否决过时户与阻塞户。所以**「这一天算平了」和「这个团好了」现在是两个独立的量**,`days[].balanced` 全 true + 根级 `balanced=false` 是**正常且常见**的一组值,不是数据错乱。 #### 「我想知道 X,该看哪个字段」对照表 | 我想知道 | 看哪个字段 | 出现在 | |---|---|---| | 这一天房够不够(订房没剩、每户每房型都配齐) | `days[].balanced` | H9 / H10 / H11 | | 这一天订房订实了没有(计划行确认到哪一步) | `days[].planStatus`:`NONE` / `PENDING` / `CONFIRMED` / `MIXED` | H9 / H10 / H11 / H12 | | 这一户的分房还是不是按它当前已确认的需求分的 | `households[].stale`(户级)、`plans[].allocations[].stale`(行级,取值同该户) | H9;H12 是 `allocations[].stale` | | 全团有几户过时 | `staleOrderCount`(H9 根级)、`staleOrderIdsBefore[]`(**只有 H11 会填**,是本次重算开始前的名单;H10 恒为空数组) | H9 / H11 | | 有没有房务自己解不了的阻塞户、该找谁 | `blockedHouseholds[]`(根级,含 `reason` / `owner` / `message`) | H9 | | 这一天有哪些户改期改出了团期区间 | `days[].outOfRange[]`(含 `reason` / `dayNumber`) | H10 / H11 | | 哪条计划行还有房没分出去 | `days[].plans[].leftoverRooms`(H9,**可以为负数 = 超分**)、`days[].leftover[]`(H10 / H11,**只在 > 0 时出现**) | H9 / H10 / H11 | | 哪一户还欠房 | `households[].shortage[]`(H9)、`days[].shortage[]`(H10 / H11) | H9 / H10 / H11 | | 哪一户被多分了 | `households[].surplus[]`(H9) | H9 | | 人工分房行跟新需求打架了 | `days[].manualConflicts[]`(含 `roomCategory`) | H10 / H11 | | 哪几天这次没被重算 | `skippedDays[]`(`reason=PLAN_NOT_CONFIRMED`) | H11 | | 整团配房算不算完成 | `hotelReady`(根级) | H9 / H10 / H11 / H12 | | 这个团 / 这一次操作是不是全好了(含过时与阻塞) | `balanced`(根级) | H9 / H10 / H11 | ⚠️ **`balanced=true` + `planStatus=MIXED` + `hotelReady=false` 是一组相容的值**:房够了、但库存还没扣完(有计划行仍是 `PENDING`),整团完成标志按「逐日全覆盖 + 全 `CONFIRMED`」判,所以仍为 false。别把这组值渲染成矛盾态。 ### 3. 四个端点三套门,`GET` 也拦 | 角色 | H9 `GET /allocations` | H10 / H11 两个写口 | H12 `GET /room-plans` | |---|---|---|---| | `ROOM_MANAGER`(房务管理员) | 放行 | 放行 | 看是否持 `group-batch:view` | | `SUPER_ADMIN`(超管) | 放行 | 放行 | 放行 | | `house_keeper_lead`(房务组长) | **放行**(只读监督角色) | **808091** | 看是否持 `group-batch:view` | | 其它后台角色(定制师 / 运营 / 客服 / 财务…) | **808090** | **808090** | 看是否持 `group-batch:view`,无则 **589507** | 再叠一层**团期归属门**(H9 / H10 / H11 都有,H12 没有): - 组长与超管可读任意团;**普通房务只能操作本人认领的团**——团未被认领 **808612**、被别人认领 **808613**。 - 超管写口免归属校验(人离职 / 团转手的唯一处置口)。 🔴 前端两个要点:**组长看得到「分房」页、点不了保存与重算**(点了拿 808091,文案可直接展示);**非房务角色连 H9 都调不到**,拿到的是 808090 业务码(HTTP 仍 200),**不是空数据**——别渲染成「暂无分房」。 ### 4. 🔴 不平项一条都不许吞:拿到 200 不等于「都安排好了」 H10 / H11 的响应里有六个列表会带出「操作成功、但有事实要知道」:`days[].leftover[]`、`days[].shortage[]`、`days[].manualConflicts[]`、`days[].outOfRange[]`、`skippedDays[]`、根级 `warnings[]`。 后端的不变量是「缺口一律显式返回、不静默兜底」。**只弹一个「保存成功」而不展开这六个列表,等于把不变量作废**——房务会以为都安排好了,实际有户没房住。 ### 5. 错误码 message 里的雪花 ID 不再带千分位 本单修掉了「`MessageFormat` 把 `Long` 渲染成 `70,001`」的问题(涉及 808606 / 808641 / 808642 / 808640 / 808643 等带 ID 占位符的码)。前端如果做过「把 message 里的逗号去掉」的兼容,现在可以不做了;**但别反过来依赖「一定没有逗号」**——这些 message 一律整串展示,不要解析。 --- ## 一、背景 `#7324` 落了房务团期看板与整团按日订房计划 CRUD(H1–H6),`#7325` 落了按日 / 整团确认与自动分房(H7 / H8 / 预检)。到这一步,系统能算出「哪一天订几间、谁该住几间」,但房务**看不到分房结果、改不了分房、需求变了之后没法重算**。 本单补齐这三件事(H9 / H10 / H11),并给团期管理员一个**不含金额**的只读明细(H12)——团期详情原型里的「逐日住宿表」此前读的是前端写死数据、按「一户一间」渲染,与真实分房结果不符,本次起以 H12 为准重做。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 团期分房总览(只读) | GET | `/v3/admin/house/group-batches/{groupBatchId}/allocations` | 新增 | 逐日 × 计划行 × 各户,含对平差额、来源与过时标记 | | 2 | 人工微调分房 | POST | `/v3/admin/house/group-batches/{groupBatchId}/allocations` | 新增 | 按计划行全量覆盖其下人工行,随后重跑该日自动分房 | | 3 | 重算分房 | POST | `/v3/admin/house/group-batches/{groupBatchId}/allocations/rebuild` | 新增 | 按新基线重跑自动分房,人工行默认保留 | | 4 | 团期配房明细(只读) | GET | `/v3/admin/order/group-batch/{groupBatchId}/room-plans` | 新增 | 团期管理员侧订房层 + 分房层,不含金额 | --- ## 三、接口详情 ### 1. 团期分房总览(只读) `GET /v3/admin/house/group-batches/{groupBatchId}/allocations` **VO**: `GroupBatchRoomAllocationOverviewReqVO` → `Result` #### 使用场景 房务团期看板 →「分房」页首屏与每次操作后的刷新。零副作用,可随意轮询。页面上的三块内容全部来自本接口:逐日的计划行 × 分房行表、逐户对平表(欠分 / 多分)、以及顶部的阻塞户提示条。 日期筛选器切换某一天时带 `stayDate` 再请求一次即可,不必前端切片——带了就只返回该日。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long(JSON 里是字符串数字) | ✅ | - | 团期主订单 ID | | stayDate | Query | String | ❌ | `yyyy-MM-dd` | 只看某一入住日;不传 = 全部「有计划行或有需求」的日 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | groupBatchId | String | 团期主订单 ID | | batchNo | String | 团期号 | | requirementConfirmed | Boolean | 团期需求是否已整体确认 | | hotelReady | Boolean | 团期配房完成标志 | | balanced | Boolean | **根级**:返回范围内所有日都对平、且无过时户、无阻塞户 | | staleOrderCount | Integer | 过时户数(去重,**全团口径,不受 `stayDate` 筛选影响**) | | days[] | 数组 | 逐日明细,按入住日升序 | | days[].stayDate | String | 入住日 `yyyy-MM-dd` | | days[].planStatus | String | 该日计划行状态汇总:`NONE` / `PENDING` / `CONFIRMED` / `MIXED` | | days[].balanced | Boolean | **日级纯算术**:该日全部计划行 `leftoverRooms=0` 且全部户 `shortage`/`surplus` 均空 | | days[].plannedRooms / allocatedRooms / demandRooms | Integer | 该日订房总间数 / 已分总间数 / 已确认需求总间数 | | days[].plans[] | 数组 | 该日计划行,按 `planId` 升序 | | days[].plans[].planId | String | 计划行 ID | | days[].plans[].hotelId / hotelName | String / String | 酒店 ID 与名称快照 | | days[].plans[].roomTypeId / roomTypeName / roomCategory | String / String / String | 房型 ID / 名称快照 / 房型大类 | | days[].plans[].planStatus | String | 行状态:`PENDING` / `CONFIRMED` | | days[].plans[].plannedRooms / allocatedRooms | Integer | 订房间数 / 已分间数 | | days[].plans[].leftoverRooms | Integer | 剩余 = 订房 − 已分,**负数表示超分** | | days[].plans[].allocations[] | 数组 | 该计划行下的分房行,按 `(orderId, roomGroupNo)` 升序 | | days[].plans[].allocations[].allocId | String | 分房行 ID | | days[].plans[].allocations[].orderId / orderNo | String / String | 分给哪一户 | | days[].plans[].allocations[].roomCount | Integer | 占几间 | | days[].plans[].allocations[].allocSource | String | 来源:`AUTO` / `MANUAL` | | days[].plans[].allocations[].roomGroupNo | String | 家庭分组号 `F{N}`,可空 | | days[].plans[].allocations[].travelerCount | Integer | 该房入住人数,可空 | | days[].plans[].allocations[].bedType / bedTypeLabel | String / String | 床型 code / 中文名,可空 | | days[].plans[].allocations[].remark | String | 备注,可空 | | days[].plans[].allocations[].confirmedRequirementId | String | 本行依据的已确认需求版本 ID,可空 | | days[].plans[].allocations[].stale | Boolean | **所属户**的分房是否过时(户级判定,同户各行同值) | | days[].households[] | 数组 | 该日各户对平,按 `orderId` 升序(**含一条分房行都没有的户**) | | days[].households[].orderId / orderNo | String / String | 子订单 | | days[].households[].demandState | String | `CONFIRMED` / `PENDING_REVIEW` / `NONE` / `OUT_OF_RANGE` | | days[].households[].stale | Boolean | 该户分房是否过时 | | days[].households[].currentConfirmedRequirementId | String | 该户当前已确认需求版本 ID,可空 | | days[].households[].demand[] | 数组 `{roomCategory, rooms}` | 已确认需求(按房型大类) | | days[].households[].allocated[] | 数组 `{roomCategory, rooms}` | 已分(按所属计划行的房型大类汇总) | | days[].households[].shortage[] | 数组 `{roomCategory, rooms}` | 欠分(需求 − 已分,逐房型;只列缺口) | | days[].households[].surplus[] | 数组 `{roomCategory, rooms}` | 多分(已分 − 需求,逐房型;只列溢出) | | days[].households[].travelerRoomGroups[] | 数组 `{roomGroupNo, travelerCount}` | 出行人侧家庭分组,供 H10 的 `roomGroupNo` 下拉取值 | | blockedHouseholds[] | 数组 | **根级**:阻塞整团配房完成、且房务无法自行解除的户 | | blockedHouseholds[].orderId / orderNo / customerName | String / String / String | 户 | | blockedHouseholds[].reason | String | `NO_BASELINE` / `REJECTED_NOT_RESUBMITTED` / `STAY_DATE_OUT_OF_RANGE` | | blockedHouseholds[].owner | String | 责任人角色:`CUSTOMIZER` / `GROUP_BATCH_ADMIN` | | blockedHouseholds[].message | String | 面向房务的说明:卡在哪、该找谁(可直接展示) | #### 请求示例 ```http GET /v3/admin/house/group-batches/1936482073991827457/allocations?stayDate=2026-06-12 HTTP/1.1 Authorization: Bearer {token} ``` #### 响应示例 ```json { "code": 200, "message": "成功", "success": true, "data": { "groupBatchId": "1936482073991827457", "batchNo": "GB20260612001", "requirementConfirmed": true, "hotelReady": false, "balanced": false, "staleOrderCount": 1, "days": [ { "stayDate": "2026-06-12", "planStatus": "CONFIRMED", "balanced": true, "plannedRooms": 3, "allocatedRooms": 3, "demandRooms": 3, "plans": [ { "planId": "1936482074001827460", "hotelId": "1901234567890123456", "hotelName": "香格里拉大酒店", "roomTypeId": "1901234567890123999", "roomTypeName": "高级双床房", "roomCategory": "STANDARD", "planStatus": "CONFIRMED", "plannedRooms": 3, "allocatedRooms": 3, "leftoverRooms": 0, "allocations": [ { "allocId": "1936482074011827470", "orderId": "1936482070001827001", "orderNo": "HL2026061200001", "roomCount": 2, "allocSource": "MANUAL", "roomGroupNo": "F1", "travelerCount": 3, "bedType": "twin", "bedTypeLabel": "双床", "remark": "老人住低楼层", "confirmedRequirementId": "2099031373212775178", "stale": true }, { "allocId": "1936482074011827471", "orderId": "1936482070001827002", "orderNo": "HL2026061200002", "roomCount": 1, "allocSource": "AUTO", "roomGroupNo": "F1", "travelerCount": 2, "bedType": null, "bedTypeLabel": null, "remark": null, "confirmedRequirementId": "2099031373212775180", "stale": false } ] } ], "households": [ { "orderId": "1936482070001827001", "orderNo": "HL2026061200001", "demandState": "CONFIRMED", "stale": true, "currentConfirmedRequirementId": "2099031373212775179", "demand": [{ "roomCategory": "STANDARD", "rooms": 2 }], "allocated": [{ "roomCategory": "STANDARD", "rooms": 2 }], "shortage": [], "surplus": [], "travelerRoomGroups": [{ "roomGroupNo": "F1", "travelerCount": 3 }] }, { "orderId": "1936482070001827002", "orderNo": "HL2026061200002", "demandState": "CONFIRMED", "stale": false, "currentConfirmedRequirementId": "2099031373212775180", "demand": [{ "roomCategory": "STANDARD", "rooms": 1 }], "allocated": [{ "roomCategory": "STANDARD", "rooms": 1 }], "shortage": [], "surplus": [], "travelerRoomGroups": [] } ] } ], "blockedHouseholds": [ { "orderId": "1936482070001827003", "orderNo": "HL2026061200003", "customerName": "张三", "reason": "NO_BASELINE", "owner": "CUSTOMIZER", "message": "该户尚未提交住宿需求,请联系定制师提交" } ] } } ``` > 上例正是「`days[0].balanced=true` 而根级 `balanced=false`」的典型形态:这一天算术上分平了,但全团有 1 户过时、1 户无基线。 #### 空数据 / 降级响应 团期存在但一条计划行、一条需求都没有时,`days` 为空数组,`balanced` 按「无阻塞无过时」取 `true`,**不 404 也不 500**: ```json { "code": 200, "message": "成功", "success": true, "data": { "groupBatchId": "1936482073991827457", "batchNo": "GB20260612001", "requirementConfirmed": false, "hotelReady": false, "balanced": true, "staleOrderCount": 0, "days": [], "blockedHouseholds": [] } } ``` #### 错误响应 ```json { "code": 808090, "message": "未登录或非房务角色,无权操作", "data": null, "success": false } ``` `stayDate` 格式错的形态见「关键变化 1」(`code=400`,不是 `100001`)。 #### 业务边界 - **零副作用**:不写任何表,可随意刷新 / 轮询。 - **角色门**:`ROOM_MANAGER` / `SUPER_ADMIN` / `house_keeper_lead` 放行,其余 808090。组长能读、不能写。 - **归属门**:组长与超管可读任意团;普通房务只能读本人认领的团(未认领 808612 / 他人认领 808613)。 - **`stayDate` 只影响 `days[]`**:根级 `staleOrderCount`、`blockedHouseholds[]`、`hotelReady` 一律是**全团口径**,不随筛选变。 - **`stale` 是户级判定**:同一户在同一天的每一条分房行 `stale` 取值相同;行上的 `confirmedRequirementId` 照常返回,需要逐行标红自己比。 - **越界户的需求计入对平分母**(不是剔除):它在区间内的那些晚照常参与 `demand` / `shortage` 计算,同时进 `blockedHouseholds[]` 一票否决 `hotelReady`。 --- ### 2. 人工微调分房 `POST /v3/admin/house/group-batches/{groupBatchId}/allocations` **VO**: `GroupBatchRoomAllocationSaveReqVO` → `Result` #### 使用场景 「分房」页上房务手工指定「谁住哪条计划行、占几间、算哪个家庭分组、什么床型」。保存按钮提交本接口。 **覆盖粒度是「本次提交里出现过的 `planId`」,不是整团**:`items` 里没出现的计划行一行都不动。所以前端可以按计划行(或按天)分批保存,不必每次回传全团。 「恢复自动分房」按钮走 `clearPlanIds`,**不是**提交一个空 `items`——覆盖语义下空 `items` 无法区分「这次没有人工行要提交」和「把这条计划行的人工行清空」。 `roomGroupNo` 的下拉取值用 H9 同日该户的 `households[].travelerRoomGroups[]`。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long | ✅ | - | 团期主订单 ID | | items | Body | 数组 | ❌ | `@Size(max=500)`;与 `clearPlanIds` 至少一个非空 | 覆盖后的人工分房行集合 | | items[].planId | Body | Long | ✅ | 须属本团、未软删、已确认 | 订房计划行 ID | | items[].orderId | Body | Long | ✅ | 须为本团在团子订单 | 分给哪一户 | | items[].roomCount | Body | Integer | ✅ | `@Min(1)` `@Max(99)` | 该家庭分组占几间 | | items[].roomGroupNo | Body | String | ❌ | `^F[1-9][0-9]{0,2}$`;不填按 `F1` | 家庭分组号;`(planId, orderId, roomGroupNo)` 是身份键 | | items[].travelerCount | Body | Integer | ❌ | `@Min(1)` `@Max(9)` | 该房入住人数 | | items[].bedType | Body | String | ❌ | `single` / `double` / `twin` / `family` | 床型 code,非法落 808131 | | items[].remark | Body | String | ❌ | `@Size(max=256)` | 备注 | | clearPlanIds | Body | 数组(Long) | ❌ | `@Size(max=200)`;与 `items` 至少一个非空 | 这些计划行下的人工分房全部软删,回到纯自动分房 | ⚠️ **`clearPlanIds[]` 实现是 `List`**(早期文档写成 String 数组)。实测传 JSON 数字可用,Jackson 对数字字符串也能收进 `Long` ⇒ **两种写法都不会被静默丢弃**,属口径不准不是缺陷。建议与其它雪花 ID 一样**统一传字符串**,避免 JS 大整数精度问题。 ⚠️ **同一个 `planId` 不得同时出现在 `items` 与 `clearPlanIds` 里**(两条指令相反),违反落 `100001`,message 点名是哪条计划行。 #### 出参 `Result` 与 H11 共用同一个返回结构(字段表见下一节)。H10 的五点固定差别: | 字段 | 类型 | 说明 | |------|------|------| | force | Boolean | H10 恒 `false` | | days[] | 数组 | **只含受影响日**(由本次提交的 `planId` 反查出的入住日),不是整团 | | days[].manualReset | Integer | H10 恒 `0`(H10 不重置人工行,只覆盖) | | staleOrderIdsBefore[] | 数组 | H10 恒为空数组(只有 H11 会填)——受影响户一旦过时,H10 在写入前就已经按 808643 整单拒了 | | skippedDays[] | 数组 | H10 恒为空数组:计划行未确认在 H10 是 808640 直接拒,不是「跳过」 | #### 请求示例 ```json { "items": [ { "planId": "1936482074001827460", "orderId": "1936482070001827001", "roomCount": 2, "roomGroupNo": "F1", "travelerCount": 3, "bedType": "twin", "remark": "老人住低楼层" } ], "clearPlanIds": ["1936482074001827461"] } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "success": true, "data": { "groupBatchId": "1936482073991827457", "force": false, "balanced": true, "hotelReady": true, "days": [ { "stayDate": "2026-06-12", "planStatus": "CONFIRMED", "balanced": true, "autoInserted": 1, "autoUpdated": 0, "autoDeleted": 1, "manualKept": 1, "manualReset": 0, "leftover": [], "shortage": [], "manualConflicts": [], "outOfRange": [] } ], "skippedDays": [], "staleOrderIdsBefore": [], "replacedAllocIds": ["1936482074011827472"], "warnings": [] } } ``` #### 空数据 / 降级响应 本接口不存在「空数据」形态:`items` 与 `clearPlanIds` 同时为空直接落 `100001`(不会返回一个空结果让人以为保存成功)。只清空人工行(只传 `clearPlanIds`)时,`days[]` 仍会带出被影响日重算后的完整结果。 ```json { "code": 100001, "message": "items 与 clearPlanIds 不能同时为空", "data": null, "success": false } ``` #### 错误响应 ```json { "code": 808643, "message": "该团有 2 户的分房已过时(首个订单 1936482070001827001),请先重算分房后再确认", "data": null, "success": false } ``` 🔴 **808643 的处置是引导用户先点「重算」**(H11,`force=false`),不是让他重试保存——重试多少次都还是 808643。 #### 业务边界 - **写门 + 归属门**:`@HouseWriteGuarded` 在 `@Idempotent` / `@Lock4j` **之前**执行(组长 808091 / 其它角色 808090),随后阶段闸门 808600、归属门 808612 / 808613。 - **校验顺序固定,任一失败整单拒绝、零写入**:参数 `100001` → 团期存在 `589500` → 阶段 `808600` → 归属 `808612`/`808613` → 计划行存在且属本团 `808601` → 已确认 `808640` → 户在团 `808642` → 过时闸 `808643` → 数量守卫 `808606` / `808641`。 - **幂等窗口 3 秒,键只取 `groupBatchId`**:同一个团 3 秒内的**第二次提交(哪怕 body 不同)**会拿到 `100502`。整单覆盖本就是整团一把,前端连点保存要做防抖。 - **团期级分布式锁** `house:gb:room:{groupBatchId}`,租约 60 秒;与按日确认(H7)互斥。 - **写入行 `allocSource=MANUAL`**;覆盖后对受影响日重跑自动分房,自动行按差异 insert / update / 软删。 - **后置联动**:分平的户置「配房完成」、不平的户回退;整团按「逐日全覆盖 + 全 `CONFIRMED` + 无阻塞」判 `hotelReady`;团级时间线记一条 `BATCH_ROOM_ALLOC_MANUAL`。 --- ### 3. 重算分房 `POST /v3/admin/house/group-batches/{groupBatchId}/allocations/rebuild` **VO**: `GroupBatchRoomAllocationRebuildReqVO` → `Result` #### 使用场景 团期管理员重新确认了住宿需求之后,房务在「分房」页点「重算」,按新基线重跑自动分房。H9 的 `staleOrderCount > 0` 或 H10 撞到 808643 时,都要引导用户来点这个按钮。 两档差别很大,前端要分开做: - **默认档 `force=false`**:人工分房行**业务列零改动、零删除**(只刷新它记录的需求版本号),与新需求冲突的人工行**保留**并进 `manualConflicts[]`。 - **`force=true`**:把范围内人工行整片软删重来,**不可逆**,因此 `reason` 必填,且会写进团级时间线。前端必须做二次确认弹窗 + 原因输入框。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long | ✅ | - | 团期主订单 ID | | force | Body | Boolean | ❌ | 默认 `false` | `true` = 先软删范围内全部人工分房行 | | stayDate | Body | String | ❌ | `yyyy-MM-dd` | 只重算某一入住日;不传 = 整团全部已确认日 | | reason | Body | String | `force=true` 时必填 | `@Size(max=256)` | 重置原因,写进团级时间线 | ⚠️ `reason` 的条件必填**不在 Bean Validation 上**,由编排层判并抛 `100001`(message:`force=true 时 reason 必填`)。 #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | groupBatchId | String | 团期主订单 ID | | force | Boolean | 回显本次是否重置了人工分房 | | balanced | Boolean | **根级**:本次范围内所有日都对平、且无过时户、无阻塞户 | | hotelReady | Boolean | 后置判定之后的团期配房完成标志 | | days[] | 数组 | 本次处理过的入住日,按日期升序 | | days[].stayDate | String | 入住日 | | days[].planStatus | String | 该日计划行状态汇总:`NONE` / `PENDING` / `CONFIRMED` / `MIXED`(与 H9 同取值域、同算法) | | days[].balanced | Boolean | **日级纯算术**,与 H9 同公式;与 `planStatus` 正交 | | days[].autoInserted / autoUpdated / autoDeleted | Integer | 自动分房行本次新插 / 原地改写 / 软删的行数 | | days[].manualKept / manualReset | Integer | 保留的人工行数 / 被 `force` 软删的人工行数 | | days[].leftover[] | 数组 `{planId, hotelName, roomTypeName, roomCategory, rooms}` | 计划行还有几间没分出去(**只在 > 0 时出现**) | | days[].shortage[] | 数组 `{orderId, orderNo, roomCategory, rooms}` | 某户某房型还欠几间 | | days[].manualConflicts[] | 数组 `{allocId, orderId, orderNo, roomCategory, manualRooms, demandRooms}` | 人工行占额超过该户当前基线需求(`force=false` 时行未动) | | days[].outOfRange[] | 数组 `{orderId, orderNo, departDate, dayNumber, reason}` | 该户改期后这一天落在团期区间外;`reason` = `OUT_OF_RANGE` / `DATE_MISSING`;`dayNumber` 按**该户自己的出发日**算(出发日 = 第 1 天),出发日缺失时为 null | | skippedDays[] | 数组 `{stayDate, reason}` | 未处理的日,`reason=PLAN_NOT_CONFIRMED` | | staleOrderIdsBefore[] | String 数组 | 本次开始前判定为分房过时的户(结束后已全部回填为当前基线) | | replacedAllocIds[] | String 数组 | 本次被软删的分房行 ID(自动 + 人工),供核单排查 | | warnings[] | 数组 `{code, orderId, message}` | 操作已成功,但有事实需要知晓;取值见「六.5、枚举 / 数据字典」 | ⚠️ `warnings[]`、`days[].planStatus`、`manualConflicts[].roomCategory`、`outOfRange[].reason` 四处是**早期契约表里没有的**,以本篇为准。 #### 请求示例 ```json { "force": true, "stayDate": "2026-06-12", "reason": "客户临时并房,原人工安排整体作废,按新需求重来" } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "success": true, "data": { "groupBatchId": "1936482073991827457", "force": true, "balanced": false, "hotelReady": false, "days": [ { "stayDate": "2026-06-12", "planStatus": "MIXED", "balanced": false, "autoInserted": 2, "autoUpdated": 1, "autoDeleted": 0, "manualKept": 0, "manualReset": 2, "leftover": [ { "planId": "1936482074001827460", "hotelName": "香格里拉大酒店", "roomTypeName": "高级双床房", "roomCategory": "STANDARD", "rooms": 1 } ], "shortage": [ { "orderId": "1936482070001827004", "orderNo": "HL2026061200004", "roomCategory": "FAMILY", "rooms": 1 } ], "manualConflicts": [], "outOfRange": [ { "orderId": "1936482070001827003", "orderNo": "HL2026061200003", "departDate": "2026-06-08", "dayNumber": 5, "reason": "OUT_OF_RANGE" } ] } ], "skippedDays": [ { "stayDate": "2026-06-13", "reason": "PLAN_NOT_CONFIRMED" } ], "staleOrderIdsBefore": ["1936482070001827001"], "replacedAllocIds": ["1936482074011827470", "1936482074011827473"], "warnings": [ { "code": "DAY_OUT_OF_RANGE", "orderId": "1936482070001827003", "message": "该户有住宿晚落在团期区间之外,需求已计入对平分母" } ] } } ``` #### 空数据 / 降级响应 范围内**一条已确认计划行都没有**时不返回空结果,直接落 808644(见下)——返回空的话房务会以为「重算完了、没事」。只有 `PENDING` 计划行的日进 `skippedDays[]`,`days[]` 相应为空数组: ```json { "code": 200, "message": "成功", "success": true, "data": { "groupBatchId": "1936482073991827457", "force": false, "balanced": true, "hotelReady": false, "days": [], "skippedDays": [{ "stayDate": "2026-06-13", "reason": "PLAN_NOT_CONFIRMED" }], "staleOrderIdsBefore": [], "replacedAllocIds": [], "warnings": [] } } ``` #### 错误响应 ```json { "code": 808644, "message": "该范围内没有已确认的订房计划,请先按日确认订房再重算分房", "data": null, "success": false } ``` #### 业务边界 - **权限、阶段闸、归属门与 H10 完全一致**(808090 / 808091 / 808600 / 808612 / 808613)。 - **幂等键带上了 `stayDate`**(`{groupBatchId}:{stayDate}`,3 秒):所以「连着重算两天」不会撞 `100502`,而「3 秒内重算同一天两次」会。不传 `stayDate` 的整团重算,键退化成同一个值。 - **不扣库存、不还库存**:重算只动分房行,订房与库存归 H4–H8。 - **`force=false` 时人工行业务列零改动**(`orderId` / `stayDate` / `planId` / `roomCount` / `roomGroupNo` / `bedType` 全不变、零删除),但**会刷新**它记录的需求版本号——这是「重算完就不再过时」的实现方式。 - **不平项显式返回**:`leftover` / `shortage` / `manualConflicts` / `outOfRange` / `skippedDays` 逐项列出,前端不得只弹「成功」。 - **`force=true` 的 `reason` 会进团级时间线**(事件 `BATCH_ROOM_ALLOC_REBUILD`),是事后唯一能回答「谁凭什么清了我的人工安排」的记录。 --- ### 4. 团期配房明细(只读) `GET /v3/admin/order/group-batch/{groupBatchId}/room-plans` **VO**: `GroupBatchRoomPlanDetailRespVO` #### 使用场景 团期详情页「配房明细」只读区块:团期管理员看整团订了哪些房、分给了谁。**纯读、不含任何金额**。 与房务侧的 `/v3/admin/house/group-batches/{id}/room-plans` 不是同一个东西:那边是写口(录订房、改删、按日确认)走房务角色门;这边是只读、走 `group-batch:view` 平台权限码,且**结构上就没有金额字段**。 原型里的「逐日住宿表」按「一户一间」渲染的是前端写死数据,与真实分房结果不符,请按本接口字段表重做。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long | ✅ | - | 团期主订单 ID | 无查询参数、无请求体。 #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | groupBatchId / batchNo | String / String | 团期 | | departDate / endDate | String / String | 团期出发日 / 结束日 `yyyy-MM-dd`,可空 | | hotelReady | Boolean | 团期配房完成标志 | | progress | String | 订房进度:`NOT_STARTED` / `PARTIAL` / `COMPLETE` | | allocationState | String | 分房状态:`NONE` / `PARTIAL` / `BALANCED` / `UNBALANCED` | | days[] | 数组 | 逐日明细,按入住日升序 | | days[].stayDate | String | 入住日 | | days[].dayNumber | Integer | **团期口径**第 N 天 = 入住日 − 团期出发日 + 1;团期出发日为空时为 null | | days[].planStatus | String | 该日计划行状态汇总:`PENDING` / `CONFIRMED` / `MIXED` | | days[].plannedRooms / allocatedRooms | Integer | 该日订房总间数 / 已分总间数 | | days[].plans[] | 数组 | 订房层 | | days[].plans[].planId | String | 计划行 ID | | days[].plans[].hotelName / roomTypeName / roomCategory | String | 酒店名 / 房型名快照、房型大类 | | days[].plans[].plannedRooms | Integer | 订房间数 | | days[].plans[].planStatus | String | 行状态:`PENDING` / `CONFIRMED` | | days[].plans[].replaceReason | String | 同日替换原因,可空 | | days[].plans[].allocations[] | 数组 | 分房层,按 `(orderId, roomGroupNo)` 升序 | | days[].plans[].allocations[].orderId / orderNo | String / String | 分给哪一户 | | days[].plans[].allocations[].roomCount | Integer | 占几间 | | days[].plans[].allocations[].allocSource | String | `AUTO` / `MANUAL` | | days[].plans[].allocations[].roomGroupNo / travelerCount | String / Integer | 家庭分组号 / 入住人数,可空 | | days[].plans[].allocations[].bedType / bedTypeLabel | String / String | 床型 code / 中文名,可空 | | days[].plans[].allocations[].stale | Boolean | 该户分房依据的需求版本是否已过时 | 🔒 **结构上不存在的字段**(不是「不填」,是**没有**):`protoPrice`、`settlementPrice`、`settleType`、`allocId`、`confirmedRequirementId`,以及出行人姓名与联系方式。**户名请按 `orderId` 关联既有的配房逐户明细取**。 #### 请求示例 ```http GET /v3/admin/order/group-batch/1936482073991827457/room-plans HTTP/1.1 Authorization: Bearer {token} ``` #### 响应示例 ```json { "code": 200, "message": "成功", "success": true, "data": { "groupBatchId": "1936482073991827457", "batchNo": "GB20260612001", "departDate": "2026-06-11", "endDate": "2026-06-15", "hotelReady": false, "progress": "PARTIAL", "allocationState": "UNBALANCED", "days": [ { "stayDate": "2026-06-12", "dayNumber": 2, "planStatus": "CONFIRMED", "plannedRooms": 3, "allocatedRooms": 3, "plans": [ { "planId": "1936482074001827460", "hotelName": "香格里拉大酒店", "roomTypeName": "高级双床房", "roomCategory": "STANDARD", "plannedRooms": 3, "planStatus": "CONFIRMED", "replaceReason": null, "allocations": [ { "orderId": "1936482070001827001", "orderNo": "HL2026061200001", "roomCount": 2, "allocSource": "MANUAL", "roomGroupNo": "F1", "travelerCount": 3, "bedType": "twin", "bedTypeLabel": "双床", "stale": true } ] } ] } ] } } ``` #### 空数据 / 降级响应 该团一条计划行都没有时,`days` 为空数组、`progress=NOT_STARTED`、`allocationState=NONE`,**不 404**: ```json { "code": 200, "message": "成功", "success": true, "data": { "groupBatchId": "1936482073991827457", "batchNo": "GB20260612001", "departDate": "2026-06-11", "endDate": "2026-06-15", "hotelReady": false, "progress": "NOT_STARTED", "allocationState": "NONE", "days": [] } } ``` #### 错误响应 ```json { "code": 589507, "message": "无操作权限(非团期管理员 / 非本定制师名下)", "data": null, "success": false } ``` #### 业务边界 - **权限判定排在团期存在性校验之前**:无权限的人拿到的一律是 589507,**探不出某个团期存不存在**(反过来会成为越权探测口)。 - **权限码是 `group-batch:view`**(复用,未新增权限码);判权走的是网关透传的当前角色,不是 adminId。 - **权限查询失败按「关闭」处理**:底层 Feign 异常降级为「无权限」,返回 589507 而不是放行。 - **零写入**,`@Transactional(readOnly = true)`。 - **无归属门**:持 `group-batch:view` 即可看任意团期的配房明细(与房务侧的「只能看自己认领的团」不同)。 - `days[].dayNumber` 是**团期口径**(按团期出发日算);H11 `outOfRange[].dayNumber` 是**该户自己的行程口径**(按该户出发日算)。两者同名不同基准,**不要混用**。 --- ## 四、契约约束与正确调用方式 > 本节只写**后端接受 / 拒绝 payload 的规则**,不写 UI 渲染建议。 ### ✅ 正确 / ❌ 错误 payload 对照(H10) | 场景 | payload | |------|---------| | ✅ 只提交人工行 | `{ "items": [{ "planId": "701", "orderId": "801", "roomCount": 1 }], "clearPlanIds": [] }` | | ✅ 只恢复自动分房 | `{ "items": [], "clearPlanIds": ["702"] }` | | ✅ 一次提交里两件事都做(**不同的 planId**) | `{ "items": [{ "planId": "701", ... }], "clearPlanIds": ["702"] }` | | ✅ 同户两个家庭分组各占一间 | `items: [{planId:"701", orderId:"801", roomGroupNo:"F1", roomCount:1}, {planId:"701", orderId:"801", roomGroupNo:"F2", roomCount:1}]` | | ❌ 两个集合都空 | `{ "items": [], "clearPlanIds": [] }` → `100001` | | ❌ 同一个 planId 同时出现在两边 | `{ "items": [{ "planId": "701", ... }], "clearPlanIds": ["701"] }` → `100001` | | ❌ 同户同计划行提交两条都不填分组 | 不填按 `F1` 归一 ⇒ 身份键撞车 → `100001` | | ❌ 用空 items 表达「清空这条计划行」 | 计划行不在 `items` 里就**一行都不动**,不是清空 | ### ✅ 正确 / ❌ 错误 payload 对照(H11) | 场景 | payload | |------|---------| | ✅ 默认重算整团 | `{}` 或 `{ "force": false }` | | ✅ 只重算一天 | `{ "stayDate": "2026-06-12" }` | | ✅ 强制重置并说明原因 | `{ "force": true, "reason": "客户临时并房,原安排作废" }` | | ❌ 强制重置不给原因 | `{ "force": true }` → `100001`(`force=true 时 reason 必填`) | ### 调用顺序 1. 进页面 → **H9**(读)。 2. `staleOrderCount > 0` → 先 **H11**(`force=false`)再做别的;**直接 H10 会被 808643 拒**。 3. 手工调整 → **H10** → 用它返回的 `days[]` 就地刷新,或重新拉 **H9**。 4. 团期管理员侧看结果 → **H12**。 ### 错误码总表 | 码 | 触发 | 出现在 | 前端处置 | |---|---|---|---| | `400`(响应体 code) | `stayDate` 格式非法(全局绑定兜底) | H9 | 整串展示 message;**HTTP 仍 200** | | `100001` | 参数非法:两集合同时为空 / 同一 planId 两边都有 / 身份三元组重复 / `force=true` 缺 `reason` / `@Valid` 失败 | H10 / H11 | 整串展示 message(点名到具体计划行) | | `100502` | 3 秒幂等窗内重复提交 | H10 / H11 | 提示「处理中,请勿重复提交」,按钮防抖 | | `589500` | 团期不存在 | 四个端点 | 返回列表页 | | `589507` | 无 `group-batch:view` 权限(含降级) | H12 | 隐藏「配房明细」区块 | | `808090` | 未登录或非房务角色 | H9 / H10 / H11 | **别渲染成空数据** | | `808091` | 房务组长为只读监督角色 | H10 / H11 | 组长侧直接隐藏写按钮 | | `808131` | 床型非法(`single`/`double`/`twin`/`family` 之外) | H10 | 表单侧用固定下拉,不让用户自由输入 | | `808600` | 团期当前阶段不允许(message 含当前阶段) | H10 / H11 | 整串展示;写按钮按阶段禁用 | | `808601` | 计划行不存在 / 不属本团 / 已软删 | H10 | 刷新 H9 重来 | | `808606` | 某计划行人工分房合计超出订房间数(message 含计划行 ID 与两个间数) | H10 | 整串展示 | | `808612` | 该团期尚未被房务认领 | H9 / H10 / H11 | 引导去团期抢单池认领 | | `808613` | 该团期由其他房务认领 | H9 / H10 / H11 | 只读展示或引导联系认领人 | | `808640` | 计划行尚未确认 | H10 | 引导先做按日确认订房 | | `808641` | 超该户该日该房型已确认需求(message 含 `roomCategory` 与两个间数) | H10 | 整串展示 | | `808642` | 订单不是该团期的在团子订单 | H10 | 刷新 H9 重取户列表 | | `808643` | 该团有 N 户分房已过时 | H10 | 🔴 **引导先点「重算」(H11)**,重试保存无效 | | `808644` | 范围内没有已确认的订房计划 | H11 | 引导先做按日确认订房 | ⚠️ **实测覆盖范围**:上表 `808090` / `808600` / `808601` / `808606` / `808640` / `808641` / `808642` / `808643` / `808644` / `100001` / `100502` / `589500` / `589507` / `400` 已在测试服经网关实测;`808612`(没造未认领团)、`808091`(库里没有房务组长可登录账号)、`589507` 的降级支(只验了「角色确实没这个权限码」这一支)**按源码写、未实测**。 --- ## 五、数据库行为 只写外部可观察的行为,不涉及表结构细节。**本单无表变更、无 Flyway**。 | 前端动作 | 可观察结果 | |----------|------------| | H9 任意调用 | 零写入 | | H12 任意调用 | 零写入 | | H10 提交 `items` | 出现在 `items` 里的计划行,其下**人工分房行被整体替换**(未出现的计划行一行不动);随后该日自动分房行按差异新插 / 原地改写 / 软删 | | H10 提交 `clearPlanIds` | 这些计划行下的人工分房行全部软删,该日回到纯自动分房 | | H11 `force=false` | 自动分房行按新基线差异重算;人工行**业务内容不变**、仅刷新它记录的需求版本号 | | H11 `force=true` | 范围内人工分房行全部软删后重来,被删的 ID 出现在 `replacedAllocIds[]`,`reason` 进团级时间线 | | H10 / H11 结束时 | 分平的户被置「配房完成」,不平的户从「已完成」退回;团期 `hotelReady` 按「逐日全覆盖 + 全 `CONFIRMED` + 无阻塞户」置位或清零 | | H10 / H11 结束时 | 团级时间线各记一条:`BATCH_ROOM_ALLOC_MANUAL` / `BATCH_ROOM_ALLOC_REBUILD` | **失败零写入**:H10 / H11 的全部校验在同一个事务内,任一条不过整单回滚,不存在「改了一半」。 **不动库存**:分房层的任何操作都不扣、不还酒店库存——库存只在按日确认(H7 / H8)与计划行删除(H6)时变动。 --- ## 六、边界行为 - 未登录 → 401(网关拦截)。 - 团期不存在 → `589500`(业务码,HTTP 200),不是 404。 - 角色 / 权限不足 → `808090` / `808091` / `589507`(业务码,HTTP 200),**不是空数据**。 - `stayDate` 格式错 → 响应体 `code=400`,HTTP 200。 - 团期一条计划行都没有 → H9 `days=[]`、H12 `days=[]` + `progress=NOT_STARTED` + `allocationState=NONE`,不异常。 - 团期 `departDate` 为空 → H12 的 `days[].dayNumber` 为 null(算不出第几天),其余字段照常返回。 - 户没有出行人分组数据 → H9 `travelerRoomGroups[]` 为空数组,前端的 `roomGroupNo` 下拉退化为手填 `F1`。 - 并发:同一团期的 H7 / H10 / H11 走同一把团期锁,后到的请求等待而不是报错;超出锁租约才失败。 --- ## 六.5、枚举 / 数据字典 每个枚举单独一节,值以后端常量为准。 ### 计划行状态 `planStatus`(行级) **所属字段**: H9 `days[].plans[].planStatus`、H12 `days[].plans[].planStatus` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `PENDING` | 未确认 | 已录入但还没按日确认,库存未扣 | | `CONFIRMED` | 已确认 | 已按日确认,库存已扣 | ### 日聚合状态 `planStatus`(日级) **所属字段**: H9 / H11 / H12 的 `days[].planStatus` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `NONE` | 无计划行 | 该日一条 active 计划行都没有 | | `PENDING` | 全未确认 | 该日计划行全是 `PENDING` | | `CONFIRMED` | 全已确认 | 该日计划行全是 `CONFIRMED` | | `MIXED` | 混合 | 两种都有(例如已确认的日又新补了一行) | ### 分房来源 `allocSource` **所属字段**: H9 / H12 的 `allocations[].allocSource` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `AUTO` | 自动分房 | 算法生成,会被重算改写 / 软删 | | `MANUAL` | 人工分房 | H10 写入,重算默认保留(`force=true` 才重置) | ### 户需求状态 `demandState` **所属字段**: H9 `days[].households[].demandState` | **类型**: `String` 四态互斥且有优先级:越界 > 有基线(再分「另有新版待审」与「就是当前版」)> 无基线。 | 值 | 中文 | 说明 | |----|------|------| | `OUT_OF_RANGE` | 改期越界 | 该户有住宿晚落在团期区间外;需求**仍计入**对平分母,同时阻塞 `hotelReady` | | `PENDING_REVIEW` | 有新版待确认 | 该户当前 active 需求还没被管理员确认,对平仍按上一个已确认版本 | | `CONFIRMED` | 正常 | 按当前已确认版本对平 | | `NONE` | 无需求 | 该户该日没有已确认需求 | ### 阻塞原因 `blockedHouseholds[].reason` 与责任人 `owner` **所属字段**: H9 根级 `blockedHouseholds[]` | **类型**: `String` | `reason` | `owner` | 含义 / 解锁出口 | |----|----|----| | `NO_BASELINE` | `CUSTOMIZER` | 该户尚未提交住宿需求 → 找定制师提交 | | `NO_BASELINE` | `GROUP_BATCH_ADMIN` | 该户需求有新版待团期管理员确认 → 找管理员确认 | | `REJECTED_NOT_RESUBMITTED` | `CUSTOMIZER` | 需求被打回后没重新提交 → 找定制师重提 | | `STAY_DATE_OUT_OF_RANGE` | `GROUP_BATCH_ADMIN` | 该户改期改出团期区间 → 找管理员改期回区间或置为不需配房 | ⚠️ **三个解锁出口全不在房务手上**,所以这三个端点**都不提供**「强制完成」入口。`message` 字段已写好面向房务的完整说明,直接展示即可。 ### 越界原因 `outOfRange[].reason` **所属字段**: H10 / H11 `days[].outOfRange[]` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `OUT_OF_RANGE` | 落在区间外 | 该户有出发日,但这一晚算出来不在团期区间内 | | `DATE_MISSING` | 出发日缺失 | 该户 `departDate` 为空,算不出第几天,`dayNumber` 为 null | ### 跳过原因 `skippedDays[].reason` **所属字段**: H11 `skippedDays[]` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `PLAN_NOT_CONFIRMED` | 计划行未确认 | 该日还有未确认的订房计划,本次不重算 | ### 告警码 `warnings[].code` **所属字段**: H10 / H11 根级 `warnings[]` | **类型**: `String` **这两个端点只会产出下面两个码**(同一个告警结构在按日确认 H7 上还有另外四个码,那是 `#7325` 的场景,本单两个端点不产出): | 值 | 中文 | 说明 | |----|------|------| | `BASELINE_INACTIVE` | 基线未生效 | 该户有新版需求待管理员确认,本次未把它置为配房完成 | | `DAY_OUT_OF_RANGE` | 有晚越界 | 该户有住宿晚落在团期区间外,需求已计入分母,整团完成标志置不上 | ### 床型 `bedType` / `bedTypeLabel` **所属字段**: H10 入参 `items[].bedType`;H9 / H12 出参 `allocations[].bedType` + `bedTypeLabel` | **类型**: `String` | `bedType` | `bedTypeLabel` | |----|----| | `single` | 单人床 | | `double` | 大床 | | `twin` | 双床 | | `family` | 家庭房 | ⚠️ 传这四个之外的值落 `808131`。 🔴 **2026-09-13 本篇发布前订正**:本段初稿写「`808131` 的 message 用的是另一套旧词(应为单床/双床/双床房/家庭房)、别拿它做选项」——**那个错误文案已在 `5fd39de3b` 修掉**,现在 message 是「床型取值非法(应为**单人床/大床/双床/家庭房**)」,与上表 `bedTypeLabel` 一致。 **但下拉仍然请按 `bedTypeLabel` 渲染,不要解析错误文案**——理由变了:不是因为它现在错,而是**错误 message 本来就不是契约**,它随时可能再被改写,而 `bedTypeLabel` 是接口出参、改它要走 changelog。 ### 订房进度 `progress` 与分房状态 `allocationState`(H12) **所属字段**: H12 根级 | **类型**: `String` | `progress` | 含义 | |----|----| | `NOT_STARTED` | 一条计划行都没有 | | `PARTIAL` | 有计划行,但还没覆盖全部服务日、或还有行没确认 | | `COMPLETE` | 每个服务日都有计划行且全部已确认 | | `allocationState` | 含义 | |----|----| | `NONE` | 一条分房行都没有 | | `PARTIAL` | 有分房行,但订房尚未全部确认(「平不平」还没有定论) | | `BALANCED` | 订房全确认、每条计划行都分完、且无过时户 | | `UNBALANCED` | 订房全确认,但有计划行没分完 / 超分,或存在过时户 | ### 房型大类 `roomCategory` **所属字段**: 四个端点的 `roomCategory`(计划行快照与逐房型对平项) | **类型**: `String` 取值随订房计划行携带(由 `#7324` 落,单源在资源侧房型字典,例如 `STANDARD`),本单不新增取值、不做翻译——**直接展示后端返回的字符串**。 --- ## 七、不影响范围 - **仅影响**:管理后台房务团期看板的「分房」页(H9 / H10 / H11)与团期详情的「配房明细」只读区块(H12)。 - **零影响**:`#7324` 的团期看板与订房计划 CRUD(H1–H6)、`#7325` 的按日 / 整团确认与预检(H7 / H8 / H8c)、逐户(非团单)房务配房、团期需求提交与审核、团期抢单池、核单与结算——本单只新增端点,未改任何既有端点的入参、出参或错误码。 - **网关零改动**:`/v3/admin/**` 已通配,四个端点无需新增路由。 - **无表变更、无 Flyway、无权限码新增**(H12 复用 `group-batch:view`)。 - **小程序端零影响**。 - **一处内部行为变化,前端看不见**:团期基线变更事件不再触发旧的「差量重配」链路(它会绕过本单的分房口径),改由房务显式点 H11 重算。 --- ## 八、测试环境已验证 - **部署面**:只有 `hl-order-service-v3` 一个服务。本分支改动文件全部落在该模块,对 `hl-common` / `hl-gateway` / 其它服务命中数 = 0,无消费方需连带滚。 - **网关实测**:2026-09-13 15:58 部署 `hl-order-service-v3 @ e9855bf21` 后,经网关实测四个端点各一次(H9 / H12 的 GET、H10 / H11 的 POST),另实测 H10 的 808643 与 H11 的 `force=true`。四个端点均返回业务层响应而非路由未命中,证明既有 `/v3/admin/**` 通配已覆盖,网关零改动成立。 - **单测**:`GroupBatchRoomAllocationManagerTest` 46 / 0 / 0 / 0;连同 `HouseModuleBoundaryArchTest`(5)、`HouseRoomAllocationStaleSingleSourceArchTest`(3)、`GroupBatchRoomLifecycleDesignTest`(4) 合计 58 / 0 / 0 / 0 全绿。 - 🔴 **本篇「关键变化 2」那次改动(`days[].balanced` 两端公式统一)在上述部署之后才提交**,只有单测覆盖,**未再经网关实测**。契约按源码写,测试服上的实证等下一次部署。 - 🔴 **网关实测那一轮的两处前置是 SQL 直更达成的,不是走业务链路**:① 把团期改成「已结算」取 808600(取完已复原,并配了「复原后同一请求 200」的阳性对照);② 直接插一版新的住宿需求造「分房过时」。⇒ 「团期推进到已结算」与「定制师改需求 → 管理员确认 → 房务收到新基线」这两条**业务链路本轮没验过**,只验了闸门读到该状态会拒。 - 🔴 **明确未覆盖(盲区)**:`808612`(没造未认领团)、`808091`(库里没有房务组长可登录账号)、`589507` 的降级支、`planStatus` 的 `PENDING` / `NONE` 两档(只见到 `CONFIRMED` / `MIXED`)、`manualConflicts[]` / `outOfRange[]` / `skippedDays[]` 三类不平项(全程恒空,装配逻辑一次都没被执行到)、`travelerRoomGroups[]`(测试数据里出行人分组全为空)、`hotelReady` 由 false 置 true 的方向、并发与锁。**这些字段的契约按源码写,联调时请按本篇字段表准备,不要因为「实测样例里没见过」就当它不会出现。** --- ## 九、相关历史 PR - `#7324` 房务团期看板与整团按日订房计划 CRUD(H1–H6)——本单的计划行、房型大类、归属门 / 阶段闸来源。 - `#7325` 团期房务按日订房确认(H7 / H8 + 只读预检)——本单的自动分房算法、对平判据、告警结构来源。 --- ## 十、相关文档 - `changelogs-v2/2026-09/10_7324_房务团期看板与整团按日订房计划CRUD-新增接口-管理后台.md` - `changelogs-v2/2026-09/11_7325_团期房务按日订房确认H7-H8-预检-新增接口-管理后台.md` - 仓内设计稿:`docs/group/团期房务实现方案-v1.0.html`、`docs/group/实施单/05-团期房务.html`(H9–H12 行已随本单标注「已实现」) --- ## 关联 / 联系人 ### 链接 - **Issue**: [#7326](https://git.1814.love:8443/wx/HL/issues/7326) - **分支**: `feature/7326-room-allocation` ### 联系人 - **后端负责人**: @wx - **前端**: mmg(hl-ui 管理后台)