From 0207e508001eee7359b2f61c571f66f72fec2710 Mon Sep 17 00:00:00 2001 From: API Changelog Bot Date: Sun, 13 Sep 2026 18:10:00 +0800 Subject: [PATCH] =?UTF-8?q?docs(changelog):=20#7326=20=E5=9B=A2=E6=9C=9F?= =?UTF-8?q?=E6=88=BF=E5=8A=A1=E5=88=86=E6=88=BF=20H9-H11=20+=20=E5=9B=A2?= =?UTF-8?q?=E6=9C=9F=E9=85=8D=E6=88=BF=E6=98=8E=E7=BB=86=20H12=EF=BC=88?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8E=A5=E5=8F=A3=EF=BC=8C=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面向 mmg:房务「分房」页三端点(H9 总览 / H10 人工微调 / H11 重算) 与团期详情「配房明细」只读区块(H12),四个端点全部新增。 覆盖范围写在 frontmatter 的 status_note 里,不藏在正文: - 网关实测的是 e9855bf21;其后的 4df361bf6(days[].balanced 两端统一为纯算术) 与 5fd39de3b(三处契约描述订正)只有单测覆盖; - 网关实测那轮有两处前置是 SQL 直更达成的,对应的两条业务链路本轮没验过; - 八项明确未覆盖的盲区逐条列出,这些项的契约按源码写、不按实测写。 几条前端最容易踩的,正文里逐条点名: 1. stayDate 格式错是 HTTP 200 + 响应体 code=400,不是 HTTP 400 —— axios 那种「非 2xx 才进 catch」的拦截器不会被触发,必须在 then 里判 body.code; 且格式提示括号不是恒有的(被拒值不像日期时只给不带括号的短句),别做 message 全等匹配。 2. days[].balanced 是纯算术口径(该日 leftover 全 0 且逐户 shortage/surplus 全 0), 不含过时户与越界户——那两项只进根级 balanced。配了「我想知道 X 该看哪个字段」对照表。 3. H10 的 items 与 clearPlanIds 不得指向同一 planId(100001);三条上限 500/200/99。 4. 床型下拉按 bedTypeLabel 渲染,不要解析错误 message —— 理由不是它现在错(808131 文案已在 5fd39de3b 修对),而是错误 message 本来就不是契约。 PR 号待合并后回填(分支 feature/7326-room-allocation)。 两道校验器均通过: - validate-changelog-frontmatter.mjs --verbose:4 个端点逐条回显、endpoint-count 4,与正文标题数对上; - validate-changelog-filenames.mjs 的 validateChangelogPath:TARGET errors=[], 阳性对照(日前缀改 12_)正确报 E_DAY,证明该门禁有分辨力。 Refs #7326 Co-Authored-By: Claude Opus 5 (1M context) --- ...†房H9-H11与团期配房明细H12-新增接口-管理后台.md | 1096 +++++++++++++++++ 1 file changed, 1096 insertions(+) create mode 100644 changelogs-v2/2026-09/13_7326_团期房务分房H9-H11与团期配房明细H12-新增接口-管理后台.md diff --git a/changelogs-v2/2026-09/13_7326_团期房务分房H9-H11与团期配房明细H12-新增接口-管理后台.md b/changelogs-v2/2026-09/13_7326_团期房务分房H9-H11与团期配房明细H12-新增接口-管理后台.md new file mode 100644 index 00000000..ba0ff8f2 --- /dev/null +++ b/changelogs-v2/2026-09/13_7326_团期房务分房H9-H11与团期配房明细H12-新增接口-管理后台.md @@ -0,0 +1,1096 @@ +--- +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: "pending" +frontend_owner: "" +frontend_ref: "" +target_release: "" +verified_at: "" +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 的方向、并发与锁。以上各项的契约按源码写,不按实测写。" +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 管理后台)