--- schema: "hl-changelog/v2" ticket: "7323" title: "团期房务地基:新建 group_batch_room_plan / group_batch_room_allocation / order_group_batch_house_legacy 三表 + house_hotel_assignment.replace_reason 列 + house_dual_deduction_log.order_id 放宽可空/加 source_type;核心配房提交 items[].replaceReason 新增可选字段(重复提交不覆写);新增内部错误码 808620-808622" consumer: "admin" author: "wx(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "not_required" frontend_owner: "mmg" frontend_ref: "" target_release: "" verified_at: "" status_note: "not_required 2026-09-10 mmg: 纯新增可选 items[].replaceReason(<=256,仅替换行落库、重复提交不覆写),不传时行为逐字一致向后兼容;changelog 自答非必须同步上线。grep 实证前端 postAssignments(api/housekeeper/assignment.js) 只透传 {items} 无 replaceReason,唯一真实变化=配房提交可选原因输入框,属后续 #7324-#7327 团期房务整族一并做的能力,非本条强制。arrange 的 inquiring 系既有缺陷非本单引入,前端兜底随 #7324+ 接线处理。本条地基单 0 外部可达改动。" updated_at: "2026-09-10" base: "dev-v3" --- # 团期房务:地基库表 + 核心配房替换原因字段 replaceReason > **服务**: hl-order-service-v3 > **PR**: #7413 > **Issue**: #7323 > **日期**: 2026-09-10 > **影响范围**: 核心/定制订单房务配房提交接口新增 1 个可选字段;团期房务地基(3 张新表 + 2 处 ALTER + 3 个内部错误码,暂无任何外部可达接口读写) --- ## ⚠️ 关键变化 1. **items[].replaceReason 是"仅记首次、不覆写"的语义,不是"每次提交都会更新"**:同一天对同一配房重复提交(未产生新的换店/改间数替换行,即 swap_from_id 不变的 UNCHANGED 情形)时,即使这次 payload 里的 replaceReason 文案与上次不同,后端**也不会更新** house_hotel_assignment.replace_reason,数据库里仍是第一次落库的值。已在测试服网关实测复现(见"三、接口详情"与"八、测试环境已验证")。**要修改已落库的原因文案,必须先撤销/删除该天已有配房,让新的替换动作产生新行,新行才会重新落 replaceReason**。 2. **提示(本次实测发现,非本单引入,既有缺陷)**:配房提交响应 items[].arrange 的 Swagger 文档(AssignmentSubmitRespVO.Item.arrange 的 @ApiModelProperty)只列了 pending / waiting / confirmed / problem 四个值,但本次网关实测(AC-9)复现响应里 arrange 实际返回过 inquiring——该值来自另一条既有派生逻辑(house_hotel_assignment.confirm_status = INQUIRING 时派生为 inquiring,HouseAssignmentService.arrangeOf),文档一直没跟着补。**前端对 arrange 做分支/switch 时不要写成不带 default 的穷举**,务必对未列举值做兜底展示。详见"六.5、枚举"。 3. 本单是"地基单":**不新增任何端点、不下线任何端点、网关零改动**。3 张新表 + 2 处 ALTER + 段位 808600-808699 下新增的 3 个错误码(808620/808621/808622)目前只服务于尚未合入的后续单(#7324 订房 CRUD、#7325 按日确认与自动分房)的团期分支扣减逻辑,**当前任何已上线业务流程都不会命中这三个码**,列出仅为错误码表完整、供后续单核对。 --- ## 一、背景 团期房务(房务按团整体订一次房,系统按各户已确认需求自动还原到户)此前在 order-v3 没有任何表承载,#7324-#7327 一行业务代码都写不了。本单铺两张地基表(按日订房计划 group_batch_room_plan、分房到户 group_batch_room_allocation)及其 Flyway/实体/Mapper,顺带做两条配套 ALTER:house_hotel_assignment 加 replace_reason(wx 2026-09-04 拍板"替换可选填原因,核心配房也要加")、house_dual_deduction_log.order_id 放宽可空 + 加 source_type(团期计划行扣库存复用既有 HouseDualDeductionService 链路,但该链路的审计表此前强绑 order_id,团期计划行没有 order_id,不松绑就插不进去)。同一 PR 里还做了旧户冻结名单(order_group_batch_house_legacy + order_group_batch.house_legacy_frozen_at),承接团期归属误判问题(另一复审修订,不影响本 changelog 面向的对外接口)。 对前端唯一直接可见的变化,是核心配房提交端点新增了 1 个可选字段 items[].replaceReason。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 提交配房方案 | POST | `/v3/admin/order/hotel-requirements/{requirementId}/assignments` | 请求体新增可选字段 | items[].replaceReason 选填 ≤256 字;仅当该项在本次提交中被判定为"替换"(新行携带 swap_from_id)时落库;重复提交同一配房不覆写 | --- ## 三、接口详情 ### 1. 提交配房方案 `POST /v3/admin/order/hotel-requirements/{requirementId}/assignments` **VO**: `AssignmentSubmitReqVO → AssignmentSubmitRespVO` #### 使用场景 房务配房工作台(hl-ui)为某条用房需求批量提交/调整逐日房型方案;房务角色抢单后,逐天选择酒店房型并提交,配房行进入"询房中"状态。同一天再次提交且换了酒店/改了间数会被判定为"替换"(原行软删、新行携带 swap_from_id),此时可选带上 replaceReason 记录换店原因。 #### 入参字段表 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | requirementId | Path | Long | 是 | 逻辑 FK 到 order_hotel_requirement.requirement_id | 用房需求 ID | | items | Body | List of AssignmentItemReqVO | 是 | NotEmpty | 一项 = 一晚 乘 一组房间 | | items[].dayNumber | Body | Integer | 是 | Min 1 | 第几天(1=Day1) | | items[].hotelId | Body | Long | 是 | 非空 | resource 酒店 ID | | items[].roomTypeId | Body | Long | 是 | 非空;不属于该酒店抛 808112 | resource 房型 ID | | items[].roomCategory | Body | String | 是 | NotBlank | 房型字典 code(room_category) | | items[].roomCount | Body | Integer | 是 | Min 1 | 间数 | | items[].protoPrice | Body | BigDecimal | 否 | DecimalMin 0.00 | 协议价快照(元/间·晚);不传按房型当日协议价兜底 | | items[].settlementPrice | Body | BigDecimal | 否 | DecimalMin 0.00 | 结算价快照;不传按结算价再兜底协议价 | | items[].settleType | Body | String | 否 | cash / sign / company | 支付方式快照 | | items[].deductInventory | Body | Boolean | 是(业务层) | 缺失抛 808124 | 是否扣减资源房型库存 | | items[].syncProtocolPrice | Body | Boolean | 否 | 默认 false | 是否把 protoPrice 同步写回价格日历 | | items[].syncSettlementPrice | Body | Boolean | 否 | 默认 false | 是否把 settlementPrice 同步写回价格日历 | | items[].syncSettleType | Body | Boolean | 否 | 默认 false | 是否把 settleType 同步写回酒店资源 | | items[].remark | Body | String | 否 | 无 | 备注 | | items[].replaceReason | Body | String | 否 | 新增;Size max 256 | 替换原因。仅当本项在本次提交中被判定为替换(该天已有旧行、本项换店或改间数,新行写了 swap_from_id)时落库;NEW(无对应旧行)与未变项后端忽略该字段;同一天两个 item 各自换店时,原因按提交顺序与被替换的旧行按位置配对 | #### 出参字段表 `Result` | 字段 | 类型 | 说明 | |------|------|------| | data.successCount | Integer | 成功条数 | | data.failCount | Integer | 失败条数 | | data.items | List of Item | 每条配房结果 | | data.items[].dayNumber | Integer | 第几天 | | data.items[].assignmentId | Long(JSON 字符串) | 配房行 ID(雪花) | | data.items[].arrange | String | 配房状态;Swagger 文档列 pending/waiting/confirmed/problem,实测另有 inquiring(既有缺陷,见"关键变化"第 2 条与"六.5、枚举") | | data.items[].deductInventory | Boolean | 本行是否扣减资源库存 | #### 请求示例 ```json { "items": [ { "dayNumber": 1, "hotelId": "2029926129876320258", "roomTypeId": "2029944763138936833", "roomCategory": "STANDARD", "roomCount": 1, "deductInventory": true, "replaceReason": "酒店A满房" } ] } ``` (以上为测试服网关实测 AC-9 第二步真实请求体:Day1 已有酒店 A 的配房,本次改配酒店 B 并带 replaceReason。) #### 响应示例 ```json { "code": 200, "message": "成功", "data": { "successCount": 1, "failCount": 0, "items": [ { "dayNumber": 1, "assignmentId": "2097856298618941442", "arrange": "inquiring", "deductInventory": true } ] }, "success": true } ``` (测试服网关实测 AC-9 第二步真实响应;arrange 实测返回 inquiring,与 Swagger 文档列举的四值不同,见"关键变化"第 2 条。) #### 空数据 / 降级响应 本接口不存在"空数据"语义:items 为空数组在契约层已被 NotEmpty 拦截为 400/808111,不会返回成功空列表;下游资源服务(价格日历库存扣减)不可用时直接返回 808900 错误,不做静默降级或返回部分成功的伪结果。 #### 错误响应 ```json { "code": 808111, "message": "配房项不能为空, 请至少选择一家酒店/房型", "success": false, "data": null } ``` #### 业务边界 - replaceReason 仅在本项被 reconcileDay 判定为"替换"(新行携带非空 swap_from_id)时写入新行;NEW(该天此前无配房行)与"无对应旧行的纯新增房型"两种情形该字段恒被清空/忽略。 - UNCHANGED(原地更新旧行,未产生新的替换行)不合并该字段——重复提交同一配房、仅改 replaceReason 文案,不会覆盖已落库的值(已实测确认,见"关键变化"第 1 条)。 - 同一天提交多个换店 item 时,原因文本按提交顺序与被替换的旧行按位置(zip)配对,不按酒店名匹配;同日多项替换请按提交顺序对应理解归属。 - 权限:HouseWriteGuarded 注解——ROOM_MANAGER/SUPER_ADMIN 可写;其余角色(含 HOUSE_KEEPER_LEAD 组长)拒绝写入,走 808090/808091(Controller 入口前置校验,先于 Idempotent/Lock4j)。 - 幂等:Idempotent 同 requirementId 3 秒窗口内重复提交直接拒绝(100502 处理中);并发保护:Lock4j 需求级锁 house:req:write:{requirementId},与单日确认/释放/转单互斥。 - 需求不属于当前用户 → 808110;需求已作废 → 808113;未抢单 → 808116;同一次提交内同天同酒店同房型重复 → 808117;订单异常处置中 → 808119;deductInventory 缺失 → 808124。 - arrange 字段可能返回文档未列举的 inquiring(见"六.5、枚举"),前端渲染该字段务必留兜底分支。 --- ## 四、契约约束与正确调用方式 > 本节只写后端接受/拒绝 payload 的规则,不写 UI 渲染建议。 ### 正确 / 错误 payload 对照 | 场景 | payload | 结果 | |------|---------|------| | 正确:首次配房,不带原因 | items[0] 不含 replaceReason 字段 | 200;house_hotel_assignment.replace_reason 为 NULL | | 正确:同天换店并带原因 | items[0].replaceReason = 酒店A满房 | 200;产生新行,swap_from_id 指向旧行,replace_reason 落库该文案,旧行软删(deleted_at 非空) | | 需注意:对同一未变配房重放请求并改 replaceReason 文案 | 与上次请求内容一致(同酒店同房型,即 UNCHANGED),仅 replaceReason 文案不同 | 200,但不覆写:assignmentId 不变,replace_reason 仍是第一次落库的值 | | 错误:replaceReason 超 256 字 | items[0].replaceReason 长度 257 | 400,Size 校验拒绝 | ### 切换状态时的必要动作 若需要修改一条配房行已经落库的 replaceReason 文案,不能寄望于"再传一次不同的值"——后端只在产生新的替换行时才写该字段。正确做法:先撤销/删除该天配房(走已有的 DELETE 端点或触发新的换店动作产生新行),让替换重新发生,新行才会带上新的 replaceReason。 --- ## 五、数据库行为 - 本次 Flyway(V20260908_311)新建 3 张表:group_batch_room_plan(25 列,团期按日订房计划)、group_batch_room_allocation(18 列,分房到户)、order_group_batch_house_legacy(6 列,历史旧户冻结名单);order_group_batch 加列 house_legacy_frozen_at;house_hotel_assignment 加列 replace_reason;house_dual_deduction_log.order_id 由 NOT NULL 放宽为可空,并加列 source_type(VARCHAR(32) NOT NULL,默认值 HOUSE_ASSIGNMENT)。已在测试服 SHOW CREATE TABLE 逐列比对零差异,flyway_schema_history 记录 version=20260908.311、success=1,无 312/313 残留草稿。 - 以上表/列当前均无任何外部可达接口读写(地基单,专供尚未合入的 #7324/#7325/#7326/#7327 使用),本次不改变任何既有查询接口的返回结果。 - house_dual_deduction_log.source_type 加默认值后,存量行 100% 落默认值 HOUSE_ASSIGNMENT(已实测 COUNT(source_type 不等于 HOUSE_ASSIGNMENT) = 0),不影响既有幂等键与对账逻辑。 - house_hotel_assignment.replace_reason 写入规则(外部可观察): | 提交场景 | swap_from_id | replace_reason | |----------|-----------------|-------------------| | Day1 首次配酒店 A(不带 replaceReason) | 空 | 空 | | 同一天改配酒店 B,带 replaceReason=酒店A满房 | 指向 A 行 id | 酒店A满房 | | 再次提交同一酒店 B(UNCHANGED),带 replaceReason=换一个理由试试 | 不变(仍指向 A 行) | 仍是酒店A满房,未被覆写 | --- ## 六、边界行为 - 未登录/无权限 → 401(网关)或 808090/808091(房务写权限门禁,先于业务逻辑)。 - requirementId 对应需求不存在或不属于当前用户 → 808110。 - 需求已作废 → 808113;未抢单 → 808116。 - 同一次提交内出现同天同酒店同房型重复项 → 808117,禁止静默合并,需前端自己合并间数后再提交。 - 下游资源服务(库存/价格日历)异常 → 808900/808901/808902,不做静默降级、不返回部分成功。 - 团期计划行扣减来源相关的 808620/808621/808622 三个错误码当前不会被本端点触发(仅服务尚未合入的团期分支,见"关键变化"第 3 条)。 --- ## 六.5、枚举 / 数据字典 ### arrange(AssignmentSubmitRespVO.Item.arrange) **所属字段**: data.items[].arrange | **类型**: String | 值 | Swagger 文档是否列出 | 触发条件 | |----|----|------| | pending | 是 | 该天尚未配房/待处理 | | waiting | 是 | 候选待定 | | confirmed | 是 | 行 confirm_status 非 INQUIRING(HouseAssignmentService.arrangeOf 派生) | | problem | 是 | 存在异常需处理 | | inquiring | 否,文档遗漏(既有缺陷,非本单引入) | 行 confirm_status = INQUIRING(询房中候选,HouseAssignmentService.arrangeOf 方法派生)。本单网关实测(AC-9)两次真实复现该返回值 | **前端处理建议**:对 arrange 做展示/分支逻辑时不要写不带 default 的穷举 switch;未列举值(至少已知有 inquiring)应有兜底展示,不能因为遇到未知值而崩溃或空白。 ### source_type(house_dual_deduction_log.source_type,新列,内部审计字段,不对外暴露) **所属字段**: 数据库列,无对应 API 出参字段 | **类型**: VARCHAR(32) | 值 | 含义 | |----|------| | HOUSE_ASSIGNMENT | 逐单配房扣减(assignment_id = house_hotel_assignment.id);当前所有扣减记录均为此值 | | GROUP_BATCH_PLAN | 团期计划行扣减(assignment_id = group_batch_room_plan.plan_id);供 #7325 落地后使用,当前无记录 | --- ## 六.6、修改前后对比 ### 字段级对比 | 字段 | 改前 | 改后 | |------|------|------| | items[].replaceReason | 不存在 | 新增,选填,Size max 256 | | house_hotel_assignment.replace_reason 列 | 不存在 | 新增,VARCHAR(256) NULL | | house_dual_deduction_log.order_id | BIGINT NOT NULL | BIGINT NULL | | house_dual_deduction_log.source_type | 不存在 | 新增,VARCHAR(32) NOT NULL,默认值 HOUSE_ASSIGNMENT | | 端点其余请求/响应字段 | 无 | 完全不变 | ### 行为级对比 | 行为 | 改前 | 改后 | |------|------|------| | 换店/改间数替换 | 只记 swap_from_id/swap_count,无原因文本 | 可选记录 replace_reason | | 重复提交同一配房、只改 replaceReason 文案 | 字段不存在,无此场景 | 后端不覆写,仍是第一次落库的值(UNCHANGED 语义) | | 团期计划行扣库存 | 无此来源(表不存在) | 复用既有扣减链路,按 source_type=GROUP_BATCH_PLAN 与 orderId=null 走团期分支(当前无调用方,供 #7325 使用) | --- ## 六.7、影响评估 - **是否破坏向后兼容**:否。items[].replaceReason 是新增可选字段,老前端不传该字段完全不受影响;新表新列对现有查询零影响。 - **前端是否必须同步上线**:否。不传 replaceReason 时端点行为与改前逐字一致。 - **前端 workaround 清理点**:无强制清理项。建议(非强制):一是在配房提交 UI 中,仅当"该天已有配房"(即本次提交会产生替换)时显示 replaceReason 输入框;二是对 arrange 字段的展示/分支逻辑补一个 default 兜底,覆盖 inquiring 等文档未列举的值。 --- ## 七、不影响范围 - **仅影响**:核心/定制订单房务配房提交端点新增 1 个可选请求字段;团期房务地基库表与内部扣减错误码段位(当前无外部可达入口)。 - **零影响**: - 无新增端点、无端点下线、无路径变化、网关路由零改动。 - 该端点其余请求字段(dayNumber/hotelId/roomTypeId/roomCategory/roomCount/protoPrice/settlementPrice/settleType/deductInventory/syncProtocolPrice/syncSettlementPrice/syncSettleType/remark)与响应字段(successCount/failCount/items[].dayNumber/items[].assignmentId/items[].arrange/items[].deductInventory)一个都没删、没改类型。 - 内部端点 POST /v3/internal/house/deduct-room-stock、POST /v3/internal/house/restore-room-stock 请求/响应结构不变,source_type 走默认值 HOUSE_ASSIGNMENT,键格式与落库行为逐字一致。 - 契约 HotelAssignmentReadDTO 的既有 15 个字段(assignmentId/requirementId/hotelId/roomTypeId/dayNumber/stayDate/hotelName/roomCategory/roomTypeName/protoPrice/settlementPrice/settleType/deductInventory/roomCount/remark)不动;新增的 sourceType/replaceReason 两个字段未映射到任何对外 VO,11 个消费方(OrderDetailService/OrderDetailConverter/SettlementService 等)本单未改,对外 JSON 零变化。 - 已有酒店/房型/价格日历读取接口、订单创建/支付/详情、抢单/单日确认/删除配房等其余房务端点不受影响。 - **历史数据**:存量 house_dual_deduction_log 行 100% 落 source_type=HOUSE_ASSIGNMENT 默认值,不迁移不回填。 --- ## 八、测试环境已验证 **环境**:测试服网关,hl-order-service-v3 @ 1a3d3c43f(Deploy Panel 记录 dev-v3 @ 50c93199e,1a3d3c43f 为其祖先),双实例 8186/8086 均 UP,健康检查通过。 ```text POST /v3/admin/order/hotel-requirements/{requirementId}/assignments 第一步 Day1 提交酒店A(不带 replaceReason) -> 200, successCount=1, assignmentId=2097856273935433730, arrange=inquiring [验证通过] 第二步 同日改提酒店B + replaceReason=酒店A满房 -> 200, successCount=1, 新 assignmentId=2097856298618941442, arrange=inquiring [验证通过] DB 核对: 新行(2097856298618941442) swap_from_id=2097856273935433730(回指旧行), replace_reason=酒店A满房 [验证通过] DB 核对: 旧行(2097856273935433730) deleted_at=2026-09-10 09:14:52(非空,已软删) [验证通过] house_dual_deduction_log 核对: 两条记录 source_type 均为 HOUSE_ASSIGNMENT, 幂等键分别为 asgn-2097856215588499458-d1-rt2023727403196502017-h2023714929877450753(基础键) 与 asgn-2097856215588499458-d1-rt2029944763138936833-h2029926129876320258-s1(替换叠 -s1) [验证通过] 第三步 再次提交与第二步完全相同的配房(同酒店B)但 replaceReason=换一个理由试试 -> 200, successCount=1, 返回同一个 assignmentId=2097856298618941442(未新建行) [验证通过] DB 核对: 该行 replace_reason 仍是酒店A满房, 未被换一个理由试试覆写(UNCHANGED 语义) [验证通过] ``` DDL 验证: ```text SHOW CREATE TABLE group_batch_room_plan / group_batch_room_allocation -> 与 Flyway V20260908_311 定义列/索引/注释逐列比对零差异 [验证通过] SHOW COLUMNS FROM house_hotel_assignment LIKE replace_reason -> 有该行 [验证通过] SHOW COLUMNS FROM house_dual_deduction_log WHERE Field IN (order_id, source_type) -> order_id Null=YES, source_type Default=HOUSE_ASSIGNMENT [验证通过] SELECT COUNT(*) FROM house_dual_deduction_log WHERE source_type 不等于 HOUSE_ASSIGNMENT -> 0 [验证通过] flyway_schema_history: version=20260908.311, success=1, execution_time=397ms, 无 312/313 残留 [验证通过] ``` 验证订单:orderId=2097856215588499458,requirementId=2097856250409582593(AC-9 造数,测试用户 AC9测试客户,标签 ac9-7323)。 --- ## 十、相关文档 - 关联 Issue: wx/HL#7323 https://git.1814.love:8443/wx/HL/issues/7323 - 关联 PR: wx/HL#7413 https://git.1814.love:8443/wx/HL/pulls/7413 - 后续依赖本单地基表/错误码/契约字段的单据:#7324(订房 CRUD 与团期看板)、#7325(按日确认与自动分房,落地团期分支扣减,808620-808622 起真正可达)、#7326(分房与只读)、#7327(契约合流,sourceType=GROUP_BATCH_PLAN 起真正出现在 HotelAssignmentReadDTO)。 ## 关联 / 联系人 ### 链接 - **Issue**: [#7323](https://git.1814.love:8443/wx/HL/issues/7323) - **PR**: [#7413](https://git.1814.love:8443/wx/HL/pulls/7413) - **Merge commit**: [1a3d3c43f](https://git.1814.love:8443/wx/HL/commit/1a3d3c43f) ### 联系人 - **后端负责人**: @wx