文件
hl-api-changelog/changelogs-v2/2026-09/23_8278_团级用车分组座位校验扣司机座-修改接口-管理后台.md
T
2026-09-23 21:37:20 +08:00

35 KiB
原始文件 Blame 文件历史

schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
schema ticket title consumer author change_type backend_status gateway_status frontend_status frontend_owner frontend_ref target_release verified_at status_note updated_at base
hl-changelog/v2 8278 团级用车分组座位充足性校验(809116)改为扣司机座,与子订单级/fleet 单车派车口径统一 admin wx(GIT) 修改接口 deployed verified not_required PR #8296 已合并 dev-v3(a0973867f)。部署:hl-order-service-v3 dev-v3 @ a0973867f,2026-09-23 21:09:02 部署,deploy-status 读数 BEHIND=0 STATE=ok,8086/8186 两实例 Nacos 健康。测试服网关真实 PUT 实测(groupBatchId=2102749115823919105,20 座大巴 × 2 辆,两次请求均晚于部署时刻):headcount=40 时返回 809116(扣司机座后可载 38 人,不足 40);headcount=38 时返回 200,remainingPassengerSeats=0。定向单测 mvn -o -pl hl-order-service-v3 -am test:17 个外层类 148/0/0(含 9 个 @ArchTest 载体),BUILD SUCCESS。存量影响核查(测试服只读 SELECT,2026-09-23 20:14:20):活跃团级用车需求 131 个,其中带完整规格(seats/count 均非空)的分组 3 组,按新口径重算全部仍满足要求,按新口径会被拒的活跃分组数为 0;生产环境二期尚未开放,无生产存量。gateway_status: verified —— 路径本就在既有 /v3/admin/** order-service-v3 路由下,本次零路由改动,且已通过真实网关实测。;前端判 not_required:809116 全仓零文本/占位符解析(整句 toast+violations detail 直显);余座负值三处消费不 clamp 不拦截;spec 无刚好坐满放行预期 2026-09-23 dev-v3

order-v3 团期需求: 团级用车分组座位充足性校验改为扣司机座

存放目录: 二期(v3) → changelogs-v2/2026-09/

服务: hl-order-service-v3(团期需求域) PR: #8296 Issue: #8278 日期: 2026-09-23 影响范围: 管理后台「团期详情 → 查看需求」Tab 的团级正式用车需求编辑弹窗、用车需求汇总草稿预览


⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)

  • 本次变化:PUT /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement 的 809116 座位充足性校验,判据从「座位数 × 车辆数 < 该组最大单日乘车人数」改为「(座位数 − 1) × 车辆数 < 该组最大单日乘车人数」——每辆车扣 1 个司机座,与子订单级用车需求校验、fleet 单车派车(AssignmentService)口径统一。
  • 前端以前以为的(对应 22_8152_... changelog 描述的旧行为):座位数 × 车辆数刚好等于该组最大单日人数的分组能保存成功,例如 19 座 × 1 辆、最大日人数 19 能保存;同一响应体里 remainingPassengerSeats(余座回显)却是扣了司机座算出来的,可能已经是负数,前端只需原样展示负值提示缺口,不作为提交是否成功的依据。
  • 实际现在的行为:同样 19 座 × 1 辆、最大日人数 19 这组输入,现在直接被 809116 拒绝。「拦截口径」与「回显口径」现在共用同一份计算(VehicleSeatCalculator.SeatSummary),不再互相矛盾——凡是回显会算出 remainingPassengerSeats 为负的组合,保存时就先被拒绝,不会写入库。
  • 809116 报文占位符从 5 个变成 6 个,新模板与两个换算样例见「三、接口详情 → 1 → 业务边界」。
  • 本条取代 changelogs-v2/2026-09/22_8152_团期用车需求补车辆规格与接送机汇总-修改接口-管理后台.md 中关于 809116 判据与文案的描述,具体被取代的位置见「十、相关文档」。

一、背景(选填)

#8278 AC-1 定案:旧口径下「拦截不扣司机座、回显扣司机座」是对称子域的口径漂移(CODE_RULES §15.7)——子订单级容量校验与 fleet 单车派车早已扣司机座,只有团级拦截没扣,导致「刚好坐满」的组合能保存成功,但回显立刻显示余座为负。本单让团级拦截口径向已有的、更严格的口径看齐(扣司机座),而不是放松回显。

最强反例(评审已确认,留档):「刚好坐满、司机另开一辆车」这类特殊排法在新口径下会被拒绝——这是业务上刻意收紧,因为司机座不能卖给乘客,运营应当据此把车辆规格填成真实的乘客运力,而不是靠"整车都算乘客座"的旧口径蒙混过关。


二、变更接口清单

# 接口 方法 路径 变更类型 说明
1 保存团期正式用车需求 PUT /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement 校验判据变更 + 错误报文格式变更 809116 扣司机座
2 用车需求汇总草稿 GET /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement/aggregate-draft 响应字段语义说明 violations[] 里的 809116 条目共用同一份新判据/新报文

三、接口详情

1. 保存团期正式用车需求 PUT /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement

VO: GroupVehicleRequirementSaveReqVO → GroupVehicleRequirementRespVO

使用场景

团期详情「查看需求 → 团级正式用车需求」编辑弹窗点保存。语义仍是整份全量替换:未出现在本次提交里的分组会被移出当前版本。请求体与响应体结构均不变,本条只改 809116 的判据与报文。

入参

字段 位置 类型 必填 约束 说明
groupBatchId Path Long ✅ - 团期聚合主键
version Body Integer ❌ 首次保存传 null,其后回传上次拿到的值 乐观锁;不一致抛 809102
remark Body String ❌ ≤500 整份需求备注
groups Body Array ✅ 可以是空数组(@NotNull 非 @NotEmpty) 全部乘车分组;有在团需车户却零分组抛 809103
groups[].groupId Body Long ❌ 新增分组传 null 带上它即声明「这就是库里那一组」,此时 groupCode 不得变更(否则 809104)
groups[].groupCode Body String ✅ ≤32,同一份内不得重复 直接作为车费 alloc_group
groups[].vehicleType Body String ✅ ≤64 车型文本/字典值
groups[].serviceStartDate Body LocalDate ✅ yyyy-MM-dd 本组服务开始日
groups[].serviceEndDate Body LocalDate ✅ 不早于开始日 本组服务结束日
groups[].seats Body Integer ❌ @Min(1);与 count 同填或同空 单车座位数(含驾驶位);本次改动后参与扣司机座的容量判据,存量分组可不传
groups[].count Body Integer ❌ @Min(1);与 seats 同填或同空 车辆数量;只填一半抛 809118
groups[].specialTags Body Array<String> ❌ 取值须在字典 vehicle_special_demand 内 特殊诉求标签编码数组;含字典外编码整份拒绝(809117)
groups[].remark Body String ❌ ≤500 该组备注 / 其他诉求
groups[].days Body Array ✅ 非空,且正好铺满本组服务日范围 逐日用车人数与成员
groups[].days[].tripDate Body LocalDate ✅ 落在本组服务日范围内、不重复、不缺日 越界或重复抛 809105,缺日抛 809106
groups[].days[].headcount Body Integer ✅ @Min(1),且 ≥ 当日成员户数 该组该日乘车人数;本次改动后这个值与「(seats − 1) × count」比较,决定是否触发 809116
groups[].days[].memberOrderIds Body Array<Long> ✅ 非空,须全属本团在团户 该组该日实际乘车的子订单集合

出参 Result<GroupVehicleRequirementRespVO>

字段 类型 说明
requirementId String 正式需求主键(Long 序列化为字符串)
groupBatchId String 团期聚合主键(Long 序列化为字符串)
status String DRAFT / CONFIRMED / DISPATCHED / DONE / PENDING_RECONFIRM / CANCELLED;PUT 后为 DRAFT
version Integer 版本号,下次提交须回传
remark String 整份备注
confirmedBy / confirmedAt String / LocalDateTime 整份确认人/时间;DRAFT 时为 null
planRefreshState / planRefreshReplayCount / blockedStage / planRefreshStalled / planRefreshStalledReason / planRefreshTimeoutAt / planRefreshReplayExhausted - 配车刷新状态相关字段,本次改动未涉及
groups Array 全部乘车分组;整团免车态为空数组
groups[].groupId String 分组主键(Long 序列化为字符串)
groups[].groupCode String 分组键 = 车费 alloc_group
groups[].vehicleType / vehicleTypeName String 车型文本/字典值 / 车型中文名
groups[].serviceStartDate / serviceEndDate LocalDate 本组服务日范围
groups[].seats Integer 单车座位数(含驾驶位);存量分组为 null
groups[].count Integer 车辆数量;存量分组为 null
groups[].specialTags Array<SpecialTagItem> 特殊诉求标签(code + name)
groups[].remark String 该组备注
groups[].totalSeatCount Integer 总座位数 = seats × count(含驾驶位);座位或数量缺一即为 null
groups[].maxHeadcount Integer 该组 days 里的最大用车人数
groups[].remainingPassengerSeats Integer 余座 = 扣司机座后的可乘座位 − maxHeadcount;可为负;计算公式本次未变,但新提交里凡是会算出负值的组合,保存时已被 809116 拦在前面,不会写入库——负值目前只可能出现在改动前已保存、尚未被下一次提交重新校验的存量分组上
groups[].days[].tripDate / headcount / memberOrderIds / memberOrderCount - 逐日行程与成员,未变

请求示例

{
  "version": null,
  "remark": "#8278-AC6",
  "groups": [
    {
      "groupId": null,
      "groupCode": "AC6BUS",
      "vehicleType": "bus",
      "serviceStartDate": "2027-03-24",
      "serviceEndDate": "2027-03-25",
      "seats": 20,
      "count": 2,
      "specialTags": [],
      "remark": "#8278-AC6",
      "days": [
        { "tripDate": "2027-03-24", "headcount": 38, "memberOrderIds": [2102749115559677953] },
        { "tripDate": "2027-03-25", "headcount": 38, "memberOrderIds": [2102749115559677953] }
      ]
    }
  ]
}

响应示例

真实网关实测响应(20 座 × 2 辆,headcount=38,(20−1)×2=38 恰好用完):

{
  "code": 200,
  "message": "成功",
  "data": {
    "requirementId": "2102750063359135746",
    "groupBatchId": "2102749115823919105",
    "status": "DRAFT",
    "version": 1,
    "remark": "#8278-AC6",
    "confirmedBy": null,
    "confirmedAt": null,
    "planRefreshState": null,
    "planRefreshReplayCount": 0,
    "blockedStage": null,
    "planRefreshStalled": false,
    "planRefreshStalledReason": null,
    "planRefreshTimeoutAt": null,
    "planRefreshReplayExhausted": false,
    "groups": [
      {
        "groupId": "2102750063363330049",
        "groupCode": "AC6BUS",
        "vehicleType": "bus",
        "vehicleTypeName": "大巴系列",
        "serviceStartDate": "2027-03-24",
        "serviceEndDate": "2027-03-25",
        "seats": 20,
        "count": 2,
        "specialTags": [],
        "remark": "#8278-AC6",
        "totalSeatCount": 40,
        "maxHeadcount": 38,
        "remainingPassengerSeats": 0,
        "days": [
          { "tripDate": "2027-03-24", "headcount": 38, "memberOrderIds": ["2102749115559677953"], "memberOrderCount": 1 },
          { "tripDate": "2027-03-25", "headcount": 38, "memberOrderIds": ["2102749115559677953"], "memberOrderCount": 1 }
        ]
      }
    ]
  },
  "success": true
}

空数据 / 降级响应

  • 整团没有在团需车户时,groups: [] 是合法提交,响应 groups 为空数组;不受本次改动影响。
  • 存量分组(本次改动之前已保存的组)在数据库里原样保留旧值,不会因为本次上线被批量重算或清退;只有当它下次出现在某次 PUT 提交的 groups 数组里(哪怕自身字段一个没改,只是跟其他组一起整份提交)时,才会按新口径重新校验——如果它本身坐不下(扣司机座后不够),这次整份保存会被 809116 拒绝、全部字段零写入。
  • 车型中文名依赖车队侧车型库,降级行为不变:查不到编码或车队不可用时 vehicleTypeName 为 null,接口仍 200,不阻断页面。
{ "code": 200, "message": "成功", "data": { "groups": [] }, "success": true }

错误响应

真实网关实测响应(20 座 × 2 辆,headcount=40,(20−1)×2=38 < 40):

{
  "code": 809116,
  "message": "第 AC6BUS 组座位数不足:20 座 × 2 辆,扣除 2 个司机座后可载客 38 人,少于该组最大乘车人数 40 人",
  "success": false,
  "data": null
}

业务边界

  • 报文里「第 {0} 组」的 {0} 仍是 groupCode,不是序号(不变);一次只抛一条,按校验遍历顺序收集(不变)。
  • 新模板与占位符:第 {0} 组座位数不足:{1} 座 × {2} 辆,扣除 {3} 个司机座后可载客 {4} 人,少于该组最大乘车人数 {5} 人。{1}=单车座位数(含司机座),{2}=车辆数,{3}=扣除的司机座数(恒等于 {2}),{4}=(seats−1)×count 算出的可载客座位,{5}=该组最大单日乘车人数。占位符从旧模板的 5 个({0}{4})变成 6 个({0}{5}),其中两个位置的语义变了:旧模板 第 {0} 组座位数不足:{1} 座 × {2} 辆 = {3} 座,少于该组最大乘车人数 {4} 人 里,{3} 是总座位数(seats × count),{4} 是该组最大乘车人数;新模板里 {3} 改为扣除的司机座数,{4} 改为可载客座位数,最大乘车人数挪到 {5}。{0}/{1}/{2} 语义不变。前端如果曾按位置/正则解析这句报文(而不是原样展示整句 message),必须同步改;如果只是原样 toast 整句 message 字符串,零改动即可。
  • 判据公式:passengerSeatCapacity = max(0, seats × count − count),passengerSeatCapacity < maxHeadcount 时拒绝。两个换算样例(公式自算,均为「改前放行、改后拒绝」的收窄场景):
    • 19 座 × 1 辆、该组最大日人数 19:passengerSeatCapacity = max(0, 19×1 − 1) = 18,18 < 19 → 拒绝,809116:「第 {groupCode} 组座位数不足:19 座 × 1 辆,扣除 1 个司机座后可载客 18 人,少于该组最大乘车人数 19 人」。
    • 7 座 × 2 辆、该组最大日人数 13:passengerSeatCapacity = max(0, 7×2 − 2) = 12,12 < 13 → 拒绝,809116:「第 {groupCode} 组座位数不足:7 座 × 2 辆,扣除 2 个司机座后可载客 12 人,少于该组最大乘车人数 13 人」。
  • seats 与 count 同生同死规则不变:只填一个抛 809118(报文字面文本未变,仍是「第 {0} 组的座位数与车辆数必须同时填写,或同时留空」);两个都不填 = 存量形态,座位校验整体跳过。
  • 乐观锁 version 不一致抛 809102(不变);特殊诉求标签字典校验(809117)、逐字段约束均不变。

2. 用车需求汇总草稿 GET /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement/aggregate-draft

VO: GroupVehicleAggregateDraftRespVO

使用场景

团期详情「查看需求 → 团级正式用车需求」为空/未确认时,前端用它拉一份系统按子订单在团情况自动配出的推荐草稿(seats 取组内最大单车座位、count = ceil(最大日人数 / (seats − 1))),供运营参考后再决定是否提交。本次改动不影响这个推荐公式本身(GroupVehicleDraftAggregator.java:345,已用 git show a0973867f -- <该文件> 核对,本次合并只改了该文件的 javadoc 注释,公式代码未动),只影响 violations[] 数组里 809116 条目的判据与报文——它与保存端点共用同一份校验函数(collectFleetSpecViolations)。

入参

字段 位置 类型 必填 约束 说明
groupBatchId Path Long ✅ - 团期聚合主键

出参 Result<GroupVehicleAggregateDraftRespVO>

字段 类型 说明
groupBatchId String 团期聚合主键(Long 序列化为字符串)
currentStatus String 当前正式需求状态;未建过正式需求为 null
draft Object 结构同 GroupVehicleRequirementSaveReqVO;version 为当前生效版本号或 null;draft.groups[].groupId 恒为 null(草稿未落库)
droppedFleetItems Array 因非主车型/车型字典不可用被剔除的子订单车队行;字段 orderId/orderNo/vehicleType/seats/count/keptVehicleType/reason
staleHeadcountOrders Array 冻结人数与当前实际人数不一致的子订单;字段 orderId/orderNo/frozenHeadcount/liveHeadcount
paddedOrderDays Array 被自动补天的子订单;字段 orderId/orderNo/dates
violations Array 草稿自身触发的校验违规;恒非 null,无违规为空数组
violations[].code Integer 错误码,本次改动相关的是 809116
violations[].reason String 原因码,见「六.5」
violations[].detail String 渲染文案,与「三 → 1 → 错误响应」里同码条目的 message 逐字相同
violations[].groupCode / tripDate / orderId String / LocalDate / String 定位到具体分组/日期/子订单,均可为 null

请求示例

GET /v3/admin/order/group-batch/2102749115823919105/vehicle-requirement/aggregate-draft

响应示例

{
  "code": 200,
  "message": "成功",
  "data": {
    "groupBatchId": "2102749115823919105",
    "currentStatus": null,
    "draft": {
      "version": null,
      "remark": null,
      "groups": [
        {
          "groupId": null,
          "groupCode": "AC6BUS",
          "vehicleType": "bus",
          "serviceStartDate": "2027-03-24",
          "serviceEndDate": "2027-03-25",
          "seats": 20,
          "count": 2,
          "specialTags": [],
          "remark": null,
          "days": [
            { "tripDate": "2027-03-24", "headcount": 38, "memberOrderIds": ["2102749115559677953"] },
            { "tripDate": "2027-03-25", "headcount": 38, "memberOrderIds": ["2102749115559677953"] }
          ]
        }
      ]
    },
    "droppedFleetItems": [],
    "staleHeadcountOrders": [],
    "paddedOrderDays": [],
    "violations": []
  },
  "success": true
}

空数据 / 降级响应

  • 整团没有可汇总内容时,draft.groups 为空数组,droppedFleetItems/staleHeadcountOrders/paddedOrderDays/violations 均为空数组(javadoc 原文:恒非 null)。
  • 有需车户缺少可汇总的行程用车需求时报 809121(本次改动未涉及此判据)。
  • 车型字典不可用时报 809120(本次改动未涉及此判据)。
{ "code": 200, "message": "成功", "data": { "draft": { "groups": [] }, "droppedFleetItems": [], "staleHeadcountOrders": [], "paddedOrderDays": [], "violations": [] }, "success": true }

错误响应

{
  "code": 809121,
  "message": "团期 XXX 有 2 户缺少可汇总的行程用车需求,暂不能自动汇总:ORD001、ORD002",
  "success": false,
  "data": null
}

业务边界

  • 推荐公式 count = ceil(最大日人数 / (seats − 1)) 保证草稿自己生成的分组,扣司机座后的可载客座位数恒 ≥ 该组最大日人数,所以由这个端点直接产出的草稿正常情况下不会在自己的 violations[] 里出现 809116。
  • violations[] 里若确实出现 809116 条目(例如运营手工改过草稿后再调这个端点做二次校验),其 detail 文案与占位符结构(6 段)与「三 → 1 → 错误响应」逐字相同,前端复用同一份渲染/解析逻辑即可,不需要为本端点单独适配。
  • 本端点只读,不落库,多次调用互不影响、无并发/幂等问题。

四、契约约束与正确调用方式(接口类必写)

本节只写后端接受/拒绝 payload 的规则,不写 UI 渲染建议。

✅ 正确 / ❌ 错误 payload 对照(PUT .../vehicle-requirement 分组元素,seats/count/该组最大单日人数三者的关系)

场景 seats × count (seats−1) × count 最大单日人数 判定
✅ 明显够坐 35 34 20 放行(改前改后均放行)
✅ 恰好用完(边界) 40 38 38 放行,remainingPassengerSeats=0(20 座×2 辆/38 人,已用真实网关实测)
❌ 不扣司机座够坐、扣了不够(新增拒绝区间) 19 18 19 809116 拒绝——改前放行、改后拒绝
❌ 不扣司机座够坐、扣了不够(新增拒绝区间) 14 12 13 809116 拒绝——改前放行、改后拒绝(7 座×2 辆/13 人)
❌ 两版本均拒绝 10 9 15 809116 拒绝(改前改后均拒绝,不扣司机座也不够坐)

切换状态时的必要动作

seats/count 仍是「同填同空」的互斥对(不变):只提交其中一个会被 809118 拒绝;两个都不提交等价于存量形态,座位校验整体跳过。提交时不要依赖"隐藏输入框"的 UI 行为,后端只看 payload 里这两个字段是否同时为非 null。


五、数据库行为(涉及写操作时必写)

本次改动不涉及表结构变化,groups.seats/groups.count/groups_day.headcount 三列的落库口径与列值语义均未变——放行的提交,落库值仍是前端提交的原始 seats/count,后端不做任何扣减存储;变的只是"放不放行"这一步的判定发生在写库之前。

前端提交 改前落库结果 改后落库结果
19 座 × 1 辆、最大日人数 19(该组) 校验通过,group.seats=19, group.count=1 落库 809116 拒绝,整份提交零写入(不含该分组之外的其他改动)
20 座 × 2 辆、最大日人数 38(该组) 校验通过,group.seats=20, group.count=2 落库 校验通过(边界恰好用完),落库同值

零写入范围:PUT 语义是整份全量替换,任一分组触发 809116 会导致这次提交整体失败,不只是该分组,其余分组在本次提交里的改动也不会落库(与改前逻辑一致,不是本次新增行为)。


六、边界行为

  • 未登录 → 401(网关拦截,不变)。
  • groupBatchId 不存在 → 团期聚合层报错(不变,未在本次改动范围)。
  • 车队/字典服务降级 → 809120(vehicleType 字典不可用)判据与报文不变,仍与 809116 相互独立、互不影响。
  • 老数据兼容 → 存量分组的 seats/count 为 null 时 809116 判据整体跳过(不变);已有 seats/count 但未随本次改动重新保存的组,回显仍按旧值展示,可能带负的 remainingPassengerSeats(见「三 → 1 → 空数据 / 降级响应」)。
  • 生产环境 → 二期功能尚未在生产开放,本次改动的行为差异目前不会被任何生产流量触发。

六.5、枚举 / 数据字典(接口出现枚举时必写)

status(正式用车需求状态,GroupVehicleRequirementRespVO.status)

所属字段: GroupVehicleRequirementRespVO.status | 类型: String

值 中文 说明
DRAFT 草稿 PUT 保存成功后的默认状态;本次改动后,坐不下的组合会在到达这一步之前就被 809116 拒绝
CONFIRMED 已确认 整份确认后
DISPATCHED 已派车 fleet 已排车
DONE 已完成 车务完成
PENDING_RECONFIRM 待重新确认 确认后又被撤回/变更,需重新确认
CANCELLED 已取消 整团取消

本次改动未涉及状态机迁移逻辑本身,取值与含义均不变。

violations[].reason(GroupVehicleAggregateDraftRespVO.Violation.reason,本次改动相关的两个取值)

所属字段: GroupVehicleAggregateDraftRespVO.Violation.reason | 类型: String

值 中文 说明
GROUP_SEATS_INSUFFICIENT 座位数不足 对应 809116;本次改动后判据改为扣司机座
GROUP_SPEC_INCOMPLETE 车辆规格不完整 对应 809118;判据与报文字面文本均未变

六.6、修改前后对比(修改/删除类接口必写,新增跳过)

字段级对比

字段 改前 改后
809116 报文占位符数量 5 个({0}~{4}) 6 个({0}~{5}):{3} 从「总座位数(seats × count)」改为「扣除的司机座数(=车辆数)」;{4} 从「最大乘车人数」改为「可载客座位数」;「最大乘车人数」后移到新增的 {5}
809116 判据表达式 seats × count < 该组最大单日乘车人数 max(0, seats × count − count) < 该组最大单日乘车人数(即扣司机座后判断)
remainingPassengerSeats(响应字段) 计算公式含扣司机座,可能为负;负值组合仍能保存成功(拦截口径更宽松) 计算公式不变;但新提交里会算出负值的组合,已先被 809116 拒绝,不会写入库

行为级对比

行为 改前 改后
提交 seats × count == 该组最大单日人数(如 19 座×1 辆/19 人) 放行 809116 拒绝
提交 (seats−1) × count == 该组最大单日人数(如 20 座×2 辆/38 人) 放行 放行(余座为 0,恰好用完)
提交 (seats−1)×count < 该组最大单日人数 ≤ seats×count(如 19×1/19、7×2/13) 放行(不扣司机座时够坐) 809116 拒绝(扣司机座后不够坐,本次改动新增的拒绝区间)
拦截口径 vs 回显口径是否一致 刻意相差 1 个司机座/车,可能「保存成功但 remainingPassengerSeats 为负」 两口径共用同一份 VehicleSeatCalculator 计算,新提交不会再出现这种矛盾
存量分组(改动前已保存、字段未变) — 不主动重算,原样保留在库里;只有它下次出现在某次 PUT 的 groups 数组里才会被新口径重新校验
fleet 单车派车(AssignmentService) 已扣司机座 不变,本次改动是团级向它对齐
fleet 团级就绪检查黄牌(GroupDispatchReadinessService#seatShortageWarnings) 不扣司机座 不变,仍不扣司机座,跟踪于 #8294,不在本次改动范围内

六.7、影响评估(修改/删除类必写)

  • 是否破坏向后兼容: 部分是——请求体/响应体字段结构未变,但同一份 payload 在「恰好等于旧口径边界、不足新口径边界」的场景下,会从改前的 200 变成改后的 809116(809116 是 HTTP 200 下的业务失败,不是传输层错误)。
  • 前端是否必须同步上线: 视前端现有实现而定。若前端只是把 809116 的 message 整句原样 toast 展示,不改也能正常显示新文案,零改动。若前端曾按占位符位置/正则解析这句报文(例如截取「× {2} 辆」后面的数字单独展示),必须同步改成新的 6 段结构,否则会把 {3}(司机座数)或 {4}(可载客座位)错位显示。
  • 前端 workaround 清理点: 若前端为「刚好坐满」这类输入写过专门的“应该能保存”预期用例,需要把预期改成会撞 809116;remainingPassengerSeats 为负仍是合法信号(不应做 Math.max(0, x) clamp,也不应据此拦截提交,这条 22_8152 的既有结论继续有效),但不能再假设"负值一定对应一个刚保存成功的新组合"——新提交里的负值组合已经在保存时被拒绝,负值目前只可能来自尚未被下一次保存重新校验的存量分组。

七、不影响范围(显式声明, 帮前端/QA 缩小排查面)

  • 仅影响: PUT .../vehicle-requirement 的 809116 触发条件与报文;GET .../vehicle-requirement/aggregate-draft 响应体 violations[] 数组里 809116 条目的判据与报文(若出现)。
  • 零影响:
    • 两个接口的请求体/响应体字段结构(无新增、无删除字段)。
    • 错误码码值本身(仍是 809116/809118,未变);809117(特殊诉求标签字典)、809102(乐观锁)、809103809115、809119809122 等其余错误码的判据与报文。
    • GET /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement(读取回显):不触发任何校验,原样返回库里已落值。
    • GET .../requirement/vehicle-households、GET .../requirement-summary、GET .../requirement/confirm-check:均未改动。
    • aggregate-draft 自身的推荐车辆数公式 count = ceil(最大日人数 / (seats − 1))(GroupVehicleDraftAggregator.java:345):本次合并只改了该文件的 javadoc,公式代码本身未动。
    • fleet 单车派车口径(AssignmentService):本就扣司机座,不受影响。
    • fleet 团级就绪检查黄牌口径(GroupDispatchReadinessService#seatShortageWarnings):仍不扣司机座,未随本次改动对齐,另开 #8294 跟进。
    • 生产环境:二期功能尚未在生产开放,本条改动不影响任何生产流量。
    • 历史数据:存量分组的 seats/count/headcount 不做批量重算或迁移,见「六.6」。

八、测试环境已验证

真实网关实测(https://api.test.1814.love,hl-order-service-v3 dev-v3 @ a0973867f,2026-09-23 21:09:02 部署,deploy-status 读数 BEHIND=0 STATE=ok,两次 PUT 请求均晚于部署时刻):

PUT /v3/admin/order/group-batch/2102749115823919105/vehicle-requirement
  20 座 × 2 辆,headcount=40(两天)  → code=809116
  message=第 AC6BUS 组座位数不足:20 座 × 2 辆,扣除 2 个司机座后可载客 38 人,少于该组最大乘车人数 40 人 ✓
GET  同一团期同一端点回读              → code=200, data=null(校验在写库前抛出,零写入)✓

PUT /v3/admin/order/group-batch/2102749115823919105/vehicle-requirement
  同一分组,headcount 改为 38(两天)  → code=200, message=成功
  data.groups[0]: seats=20, count=2, totalSeatCount=40, maxHeadcount=38, remainingPassengerSeats=0 ✓
GET  同一团期同一端点回读              → code=200, requirementId=2102750063359135746, status=DRAFT, version=1 ✓

验证团期:groupBatchId=2102749115823919105(本轮自建夹具,未影响任何既有排期/团期)。两次 PUT 之间唯一变量是 headcount(40→38),groupCode/vehicleType/seats/count/日期范围完全相同,809116 与 200 的分野只能来自本次判据变更。

定向单测(mvn -o -pl hl-order-service-v3 -am test -Dtest='GroupVehicleRequirementSaveTest*,GroupVehicleDraftAggregatorTest*,...',起跑 2026-09-23 20:31:52):17 个外层类 / 148 用例,Failures/Errors/Skipped 全 0,BUILD SUCCESS;含 VehicleSeatCalculatorTest(6)、GroupVehicleRequirementSaveTest(25,含新旧边界两条判据)、GroupVehicleDraftAggregatorTest(19)与 9 个 @ArchTest 载体。仅改注释的返工提交后,定向复跑受影响 3 类:50/0/0。

存量影响核查(测试服只读 SELECT,2026-09-23 20:14:20):活跃团级用车需求 131 个,其中带完整规格(seats/count 均非空)的分组 3 组;按新口径重算,这 3 组全部仍满足要求(新口径可载客座位 415 人不等,均 ≥ 该组最大日人数 25 人),按新口径会被拒的活跃分组数为 0。生产环境二期尚未开放,无生产存量。

限定:本次真实网关实测只覆盖了「一辆 20 座大巴 × 2 辆」这一种具体座位组合的团级 PUT 入口;其它座位数组合、子订单级校验、aggregate-draft 端点自身,由上述单测覆盖(aggregate-draft 与保存端点共享同一份校验代码,但本次未针对该端点单独发起真实 HTTP 调用)。存量核查的分母只有 3 组带规格(团级用车规格字段是 #8152 刚上线的新字段,前端入口尚未普及),0 组的读数只说明此刻不会新拒任何已存活跃版本,对未来接入更多分组后是否仍为 0 没有分辨力。


十、相关文档

  • 关联 Issue: wx/HL#8278
  • 关联 PR: wx/HL#8296
  • 已知缺口(不在本次改动范围内): wx/HL#8294 —— fleet 团级就绪检查黄牌口径尚未对齐扣司机座
  • 订正声明(取代旧描述,不改动原文件):本条取代 changelogs-v2/2026-09/22_8152_团期用车需求补车辆规格与接送机汇总-修改接口-管理后台.md 中以下位置关于 809116 判据与文案的描述:
    • 第 36-38 行「⚠️ 关键变化」块(「拦截口径不扣司机位」「回显口径扣司机位」「20 座 × 1 辆 / 20 人能保存成功,而返回的 remainingPassengerSeats 是 -1」):描述的是本次改动前的行为,改动后 20×1/20 这组输入已改为 809116 拒绝,两口径不再矛盾。
    • 第 40、42、44 行「展示侧」「提交侧」「响应体里没有任何字段表示…」三段:其中「是否允许保存由后端 809116 判定(口径:seats × count 与该组最大乘车人数比较,不扣司机位)」这句已过时,判据已改为扣司机位。
    • 第 240 行错误响应示例 "第 BUS 组座位数不足:19 座 × 1 辆 = 19 座,少于该组最大乘车人数 20 人":这是旧模板(5 个占位符)的渲染结果,新模板见本条「三 → 1 → 错误响应」与「三 → 1 → 业务边界」。
    • 第 268 行业务边界「座位充足性判据 = seats × count < 该组最大单日乘车人数,不扣司机位」:判据已变,见本条「六.6」。
    • 第 354 行「20 座 × 1 辆载 20 人保存成功,余座 -1」:该结论已不成立,这组输入现在被拒绝。
    • 第 944 行行为级对比表格「提交坐不下的规格 | 接受(无此字段) | 809116 拒绝(判据不扣司机位)」一行,末列「判据不扣司机位」已过时。
    • 第 958-959 行「若此前把 remainingPassengerSeats 之类余座数做过 Math.max(0, x) 处理,必须撤掉」「不要新增『余座为负则禁止保存』的前端校验」:这两条建议本身依然有效,但其背景「后端放行的组合」已收窄——新提交里会被后端放行的组合,扣司机座后已经够坐,不会再出现负值;负值目前只可能来自尚未被下一次保存重新校验的存量分组。

关联 / 联系人

链接

联系人

  • 后端负责人: @wx