--- schema: "hl-changelog/v2" ticket: "7994" title: "团期配车受控重开窗口:分组列上线前的历史派车行现在可以被收编,不再让团期卡死" consumer: "admin" author: "wx(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "2044ec5281e0eb4a0fb3b1567a92e73916bd4204" target_release: "" verified_at: "2026-09-21" status_note: "gateway_status=verified 的判据:2026-09-21 经测试服网关 https://api.test.1814.love:9443 对团期 2099959465330556929 实跑了完整四步(重开窗口 → 重配 → 确认配车 → 确认需求),全部 200,三行历史派车行的分组由空写成 GC、团期车务就绪位由 0 置 1,逐步请求/响应已记入工单 #7994 AC-3。backend_status=deployed 有两条互相独立的判据:①行为自证——这四步在本次修复之前必然报 602013(工单背景节实测过两次),能走通本身就说明跑着的字节里含本次修复;②测试服上 /opt/hulalv/jars/hl-fleet-service-1.0.0-SNAPSHOT.jar 的 mtime 是 2026-09-21 10:18:22,晚于本次修复合入 dev-v3 的 09:47:35。🔴 顺带订正一条会误导人的登记值:另一条取证线记录的 fleet 部署点 51571c58a 与上述两条判据矛盾(51571c58a 的提交时刻是 04:25,且本次修复不是它的祖先)——那是 10:18 重滚之前的陈旧读数,别再据它判断「某修复还没上测试服」。⚠️ 未覆盖:团期状态推进(本次只解开死路,未推进 batch_status);计划刷新是同步还是异步未区分(两者终态相同)。" updated_at: "2026-09-21" base: "dev-v3" --- # 团期车务:受控重开窗口内,无分组的历史派车行可以被收编(工单 #7994) > **存放目录**: 二期(order-v3 标签工单)→ `changelogs-v2/2026-09/` > > **服务**: hl-fleet-service(8087) > **PR**: #8085 > **Issue**: #7994 > **日期**: 2026-09-21 > **影响范围**: 管理后台「团期配车页」——受控重开窗口内的重新配车操作,以及它报错时的提示文案 --- ## ⚠️ 关键变化 - **请求体和响应体的字段一个都没变**,前端**不需要改接口对接代码**。变的是两件事:①一类原先必定被拒的请求现在会成功;②被拒时的提示文案变长了,且**新文案里带着操作指引**。 - 🔴 **唯一需要前端确认的一点:`602013` 的提示文案现在可能很长,必须完整展示给车务,不能截断、不能只显示前一行。** 新增的那半句正是告诉车务「该怎么自救」的部分,截掉了这条路就没人找得到(详见「三、接口详情 → 错误响应」)。 - 这次改动**没有放松任何权限或范围校验**:窗口授权范围以外的分组、以外的日期,行为一字未变,仍然整批拒绝且零写入。 --- ## 一、背景 「分组」这一列是 2026-09-17 才加到团期派车记录上的。在那之前排的车,记录上**没有分组**——不是脏数据,是那个事实当时就不存在。 车务给一个团期开「受控重开窗口」重新配车时,系统要检查每一行改动有没有越出窗口授权的范围。此前的规则是:**没有分组的历史行一律判越界**(说不出它属于哪个组,就不敢让窗口里的操作动它)。 这条规则本身没错,但它和另一条规则撞上了:团期进入「物资准备」阶段后,**不开窗口就不许配车**。两条一叠加,一个带历史派车行的团期就成了单向死路: | 车务的走法 | 结果 | |---|---| | 开窗口后重新配车 | 拒绝:「本次配车改动越出重开窗口授权范围」(602013) | | 不开窗口直接配车 | 拒绝:「团期当前状态不可配车: 物资准备中」(600010) | | 跳过配车直接确认 | 拒绝:「整组未排车」(602008,历史行不算数) | 而开窗口这个动作本身会把团期的**车务就绪位清零**,于是团期再也过不了发团门禁——**出不了团,也修不回去**。测试环境实际撞上这个状态的团期有 1 个。 ### 改后的判定口径:按「这一行会被怎么处理」分,而不是按「它属于哪个组」分 没有分组的历史行,在一次重新配车里只有两种下场,风险完全不对称: | 下场 | 什么时候发生 | 改后 | |---|---|---| | **被收编**——这一行被写上分组 | 车务在本次请求里**带上了同一辆车、同一天**,并给了分组 | ✅ **放行** | | **被删除**——这一行被撤掉、占用被释放 | 车务在本次请求里**没写**这辆车这一天 | ❌ **仍然拒绝**(602013),一字未变 | 放行为什么是安全的:收编时写进去的那个分组,**本身仍要经过窗口授权范围的检查**。想把历史行收编进一个没被授权的分组,一样会被拒。⇒ 历史行永远不可能被写进未授权的分组,也永远不可能被窗口删掉。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 整团逐日配车提交(重新配车) | POST | `/admin/fleet/group-dispatch/batches/{groupBatchId}/reconfigure` | 行为放宽 + 错误提示文案变化 | 请求体/响应体结构零变化 | --- ## 三、接口详情 ### 1. 整团逐日配车提交 `POST /admin/fleet/group-dispatch/batches/{groupBatchId}/reconfigure` **VO**: `GroupDispatchReconfigureReqVO` → `GroupDispatchReconfigureRespVO`(**字段无增删改**) #### 使用场景 管理后台「团期配车页」,车务在受控重开窗口有效期内提交整团逐日的配车安排。本次改动只影响**团期带有「分组列上线前的历史派车行」**这一种情形;不带历史行的团期,行为逐字不变。 #### 入参(**本次无变化**) | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |---|---|---|---|---|---| | groupBatchId | path | Long | 是 | — | 团期主订单 ID | | requirementId | body | Long | 是 | 须等于基线当前活跃需求 | 正式团级用车需求 ID,落后抛 602005 | | requirementVersion | body | Integer | 是 | 须等于基线当前版本 | 需求版本,落后抛 602005 | | clearAll | body | Boolean | 否 | 默认 false | 显式整团清零标志 | | reconfigureWindowToken | body | String | 条件必填 | 团期已过资源准备阶段时必填 | 受控重开窗口令牌;缺失/不匹配/过期抛 602012 | | survivorPolicy | body | String | 条件必填 | clearAll=true 且存在 active 共用关系时必填 | 幸存共用派单处置策略 | | demands | body | Array | 条件必填 | clearAll=false 时必填 | 逐日配车需求列表 | | demands[].tripDate | body | LocalDate | 是 | `yyyy-MM-dd` | 行程日期 | | demands[].assignments | body | Array | 是 | 非空 | 当日排车项列表 | | demands[].assignments[].groupId | body | String | 是 | 非空白;须在窗口授权范围内 | 乘车分组键(= 需求侧 group_code) | | demands[].assignments[].vehicleId | body | Long | 是 | — | 派出车辆 ID | | demands[].assignments[].driverId | body | Long | 否 | 可空 = 仅排车未排司机 | 派出司机 ID | | demands[].assignments[].remark | body | String | 否 | — | 备注 | 🔴 **收编一条历史行的做法就落在这张表里,没有任何新字段**:把历史行所在的「`tripDate` + `vehicleId`」原样写进 `demands`,并给它一个 `groupId`。 #### 出参(**本次无变化**,字段来源:`GroupDispatchReconfigureRespVO` 的 `@ApiModelProperty` 声明) | 字段 | 类型 | 说明 | |---|---|---| | groupBatchId | Long | 团期主订单 ID | | requirementId | Long | 正式团级用车需求 ID | | requirementVersion | Integer | 正式团级用车需求版本 | | planVersion | Long | 团期计划版本 | | addedCount | Integer | 新增派车记录数 | | removedCount | Integer | 软删派车记录数 | | keptCount | Integer | 保留未变派车记录数 | | updatedCount | Integer | **就地更新派车记录数**——收编走的就是这一格 | | aliveCount | Integer | 存活派车记录总数 | | addedDispatchIds | List<Long> | 新增派车记录主键列表 | | idempotentShortCircuit | Boolean | 本次是否被计划去重短路(幂等成功,**非失败**) | | coverage | Object | 按乘车分组的覆盖明细,含 `groups` / `missingGroupCodes` / `wholeBatchSatisfied` | | legacyGroupRowCount | Integer | **无分组键的历史派车行数(非错误,仅留痕)**——这一格就是本次改动针对的那类行 | | releasedShareGroupIds | List<Long> | 本次连带解除的共用关系 ID 清单 | | keptSourceIds | List<Long> | 保留占用的 claim 来源 ID 清单 | | releasedSourceIds | List<Long> | 占用已被真正释放的派单 ID 清单 | | pendingReassignSourceIds | List<Long> | 待人工改派的派单 ID 清单(占用已释放、当前无车) | #### 请求示例 收编三行历史派车行(同一辆车、三个连续日期),给它们分组 `GC`: ```json { "requirementId": 2099959465330556930, "requirementVersion": 5, "clearAll": false, "reconfigureWindowToken": "<重开窗口返回的令牌>", "demands": [ { "tripDate": "2026-11-27", "assignments": [ { "groupId": "GC", "vehicleId": 2064995255698010113, "driverId": 2065272145289658370 } ] }, { "tripDate": "2026-11-28", "assignments": [ { "groupId": "GC", "vehicleId": 2064995255698010113, "driverId": 2065272145289658370 } ] }, { "tripDate": "2026-11-29", "assignments": [ { "groupId": "GC", "vehicleId": 2064995255698010113, "driverId": 2065272145289658370 } ] } ] } ``` #### 响应示例 ```json { "code": 0, "msg": "操作成功", "data": { "groupBatchId": 2099959465330556929, "planVersion": 2, "addedCount": 0, "removedCount": 0, "keptCount": 0, "updatedCount": 3, "aliveCount": 3, "addedDispatchIds": [], "idempotentShortCircuit": false, "coverage": { "missingGroupCodes": [], "wholeBatchSatisfied": true }, "legacyGroupRowCount": 0 } } ``` ⚠️ **上例中只有 `updatedCount` / `addedCount` / `removedCount` / `wholeBatchSatisfied` 四项是 2026-09-21 实测读数**(见「八、测试环境已验证」);其余字段按其语义填的示意值,且为便于阅读省略了 `coverage.groups` 与几个空数组字段——**不要拿它当字段全集**,字段全集以上面的出参表为准。 📌 收编是**就地更新**,不是删掉重建:三行历史行被收编后 `updatedCount=3` / `addedCount=0` / `removedCount=0`,派车记录的 id 不变。前端若按派车记录 id 做过本地缓存或选中态,**不会失效**。 #### 空数据 / 降级响应 - **团期一条派车行都没有**:`aliveCount=0`、`coverage.wholeBatchSatisfied=false`、`coverage.missingGroupCodes` 列出缺的组码;这是正常响应不是错误。 - **重复提交同一份计划**(超出 10 秒防重窗口):正常受理并返回 `idempotentShortCircuit=true`,`addedCount/removedCount/updatedCount` 全 0——**那是成功**(计划未变、未落库),前端不要按错误提示。 - **10 秒内重复提交**:被防重窗口拒绝,提示「团期配车重配处理中,请勿重复提交」,前端按「稍后重试」处理。 - 以上三种在本次改动中**行为一字未变**。 #### 错误响应 `602013` 的 `message` 此前只有越界项清单。改后,**当越界项里含「(无分组历史行)」时**,末尾会追加一段操作指引: ```json { "code": 602013, "msg": "本次配车改动越出重开窗口授权范围: (无分组历史行):2026-11-27,(无分组历史行):2026-11-28; 其中 (无分组历史行) 是分组列上线前的历史派车行, 本次请求没有它因而会被删除; 窗口内不允许删除它, 请把该日期该车一并写进本次配车请求并给出正确的乘车分组, 即可将其收编", "data": null } ``` ⇒ 🔴 **请确认「团期配车页」的错误提示区能完整展示这段文字**(多行换行展示即可,不要单行截断、不要因为 tooltip 放不下就省略)。这段话是车务自救的唯一入口。 📌 越界项里**不含**「(无分组历史行)」时(即普通的分组越界、日期越界),`message` 与改前**逐字节相同**,不会变长: ```json { "code": 602013, "msg": "本次配车改动越出重开窗口授权范围: GA:2026-11-27", "data": null } ``` 错误码清单(**本次不新增、不删除任何错误码**): | code | 说明 | 本次是否变化 | |---|---|---| | 602013 | 本次配车改动越出重开窗口授权范围 | **触发条件收窄**;含历史行时文案追加指引 | | 602012 | 重开窗口令牌缺失/不匹配/已过期 | 不变 | | 602011 | 窗口内不允许整团清空 | 不变 | | 602008 | 整组未排车 | 不变(但历史行被收编后不再误报) | | 602005 | 需求身份落后 | 不变 | | 602000 | 配车请求缺少乘车分组 | 不变 | | 600010 | 团期当前状态不可配车 | 不变 | | 600006 / 600007 | 车辆/司机当天已被占用 | 不变 | #### 业务边界 - 只有**同时满足「同一行程日 + 同一车辆」**的历史行才会被收编;只对上日期或只对上车辆都不算,仍按删除处理并拒绝。 - 收编时给的 `groupId` **照样要过窗口授权范围校验**:不在范围内仍返 602013,且**零写入**。 - 本次改动**不触碰**窗口授权范围本身的计算,也不触碰「窗口内不允许整团清空」(602011)。 - 一次请求里可以同时收编多行;三行历史行在一次请求里全部收编是实测过的形态。 - 收编不改变派车记录的主键,也不产生新的派车记录。 --- ## 四、契约约束与正确调用方式 ### ✅ 正确 / ❌ 错误 的应对方式 遇到「越出重开窗口授权范围」且提示里出现「(无分组历史行)」时: | | 做法 | 结果 | |---|---|---| | ❌ | 去找管理员**重开一个范围更大的窗口** | 没用。窗口里写什么分组都授权不了「没有分组」的行——这条路改前改后都走不通 | | ✅ | 把提示里**点名的那个日期、那辆车**一并写进本次 `demands`,并给它一个正确的乘车分组 | 该行被就地收编,计入 `updatedCount` | ### 前端需要做什么 | 事项 | 是否需要改 | |---|---| | 请求体字段 | ❌ 不用改 | | 响应体字段 | ❌ 不用改 | | 错误码分支 | ❌ 不用改(没有新错误码) | | **602013 提示文案的展示** | ✅ **确认能完整展示长文案,不截断** | | 页面文案/引导 | 选做:若「团期配车页」有配车失败的帮助文案,可以补一句「提示里出现『无分组历史行』时,把它点名的日期和车辆一并加进本次配车即可」 前端已交付(mmg 2026-09-21, hl-admin 2044ec52):配车计划编辑器(GroupDispatchPlanEditor)提交失败时若 message 含「无分组历史行」,在编辑器内常驻 n-alert 完整展示该指引(自然换行不截断),再次提交自动清空;其余错误仍走拦截器 toast。请求/响应字段零变化,对接代码未动。editor spec 6 例全过(长文案常驻展示+普通失败不出常驻块),checkpoint 通过。 | --- ## 五、数据库行为 - 收编走的是**就地 UPDATE**:派车记录表 `fleet_group_dispatch` 中被收编的行,`group_id` 由 NULL 写成请求里给的分组码,`version` +1,**主键 `dispatch_id` 不变**,不产生新行、不软删旧行。 - 实测三行(2026-11-27/28/29,同车同司机):`group_id` NULL → `GC`、`status` ASSIGNED → CONFIRMED(第 3 步确认配车所致)、`version` 0 → 1。 - 被拒绝时(602013)**零写入**——这一点改前改后相同,本次未放松。 - 无新增表、无新增列、**无 Flyway 迁移**。 --- ## 六、边界行为 | 情形 | 改前 | 改后 | |---|---|---| | 历史行所在的「日期 + 车辆」出现在本次 `demands` 里 | 602013 | ✅ **成功**,该行分组被写成请求里给的值,计入 `updatedCount` | | 历史行所在的「日期 + 车辆」**没有**出现在本次 `demands` 里 | 602013 | **602013**(不变) | | 收编时给的分组不在窗口授权范围内 | 602013 | **602013**(不变) | | 团期里没有任何无分组历史行 | 按窗口授权范围判 | **完全不变**(越界集合与错误文案两侧都逐字节相同) | | 普通的分组越界 / 日期越界 | 602013 | **602013**,文案逐字节不变 | --- ## 六.6、修改前后对比 ### 字段级对比 **无任何字段变化**——请求体、响应体、错误信封三处的字段名、类型、层级全部与改前一致。这也是本篇不要求前端改对接代码的原因。 ### 行为级对比 | 维度 | 改前 | 改后 | |---|---|---| | 判定依据 | 这一行**属于哪个组**(没有组 ⇒ 一律越界) | 这一行**会被怎么处理**(被收编 ⇒ 放行;被删除 ⇒ 越界) | | 带历史行的团期 | 单向死路,出不了团也修不回 | 车务可自行收编后继续走确认流程 | | 602013 文案 | 只有越界项清单 | 含历史行时追加操作指引;不含时逐字节不变 | --- ## 六.7、影响评估 - **前端**:只有一处——602013 长文案的展示。无字段改动、无错误码改动。 - **后端**:仅 hl-fleet-service 一个服务,改动落在受控重开窗口的范围校验与错误文案两处。 - **数据**:不需要任何存量数据订正。历史行被收编是**车务在页面上的正常操作**,不需要刷库。 - **回归面**:不带无分组历史行的团期,越界判定与错误文案两侧结构性不可达本次改动(非空分组码走的仍是改前那条判断,一字未改),且该类的 10 条既有单测一字未动全绿。 --- ## 七、不影响范围 显式声明**没有**被这次改动碰到的东西,帮前端/QA 缩小排查面: - **整团清空**(`clearAll=true`)与 `survivorPolicy` 的处理:**未碰**。 - 窗口授权范围本身怎么算出来的:**未碰**。 - 602012(令牌)、602011(窗口内禁清空)、602005(需求身份落后)、600010(团期状态不可配车)、600006/600007(车/人被占)的触发条件:**全部未碰**。 - 共用关系(share-group)的建立、解除、收缩:**未碰**。 - 权限点 `fleet:group-dispatch:write` / `fleet:group-dispatch:view`:**未碰**。 - 防重窗口与幂等短路(`idempotentShortCircuit`):**未碰**。 - 团期状态机的推进:**未碰**——本次只解开死路,`batch_status` 不因这次改动而变化。 --- ## 八、测试环境已验证 2026-09-21 经测试服网关 `https://api.test.1814.love:9443`,对团期 `2099959465330556929`(三行 2026-11-27/28/29 的无分组历史派车行)走完整链路,四步全部 200: | # | 动作 | 结果 | |---|---|---| | 1 | 重开受控窗口 | 200,拿到新窗口令牌 | | 2 | 带窗口重新配车(请求里带上那辆车 + 三天 + 分组 `GC`) | 200,`updatedCount=3` / `addedCount=0` / `removedCount=0`,`wholeBatchSatisfied=true` | | 3 | 确认配车 | 200,`confirmedCount=3`(**改前此处报 602008「整组未排车」**) | | 4 | 确认团期车务需求 | 200,需求状态转 `CONFIRMED` | 三行派车记录的分组由**空**变为 `GC`,团期**车务就绪位由 0 置 1**,单向死路解除。 ⚠️ **取证中踩到的一个坑**(与本次改动无关,但会影响联调):以 `ADMIN` 角色调 `/admin/fleet/**` 全部端点返 **403「无权限访问车务管理」**,必须切到**车务角色或超级管理员**。这是既有的路径级角色门禁,不是本次引入的。 ⚠️ **本次未覆盖**:团期状态推进(本次只解开死路,`batch_status` 仍是物资准备中);「计划刷新」是同步完成还是极快的异步消费,两者终态相同,本次没有做间隔采样因而分不出来——这只影响时序假设,不影响上表任何一行结论。 --- ## 九、相关历史 PR | PR | 说明 | |---|---| | #8085 | 本次修复(工单 #7994) | | 工单 #7442 | 分组列与受控重开窗口的来源——本次要修的死路正是这两条规则叠加出来的 | --- ## 十、相关文档 - 工单 #7994(含逐条验收取证与本次改动的结构性论证) - 工单 #7442(分组列 + 受控重开窗口的原始需求) - 同期同服务的相邻变更:`20_8051_解除共用关系不再跨服务日跨团误清派车行-修改接口-管理后台.md`、`20_8061_解除共用关系只清成员占用同槽非成员不动-修改接口-管理后台.md` --- ## 关联 / 联系人 ### 链接 - Issue: #7994 - PR: #8085 - 服务: hl-fleet-service(8087) - 网关: `https://api.test.1814.love:9443` ### 联系人 - 后端: wx - 前端(管理后台): mmg