From da562f36d40c221bf6e8055bb0c9e7f4b9808057 Mon Sep 17 00:00:00 2001 From: API Changelog Bot Date: Wed, 30 Sep 2026 05:51:38 +0800 Subject: [PATCH] =?UTF-8?q?docs(changelog):=20=E8=A1=A5=205=20=E5=BC=A0?= =?UTF-8?q?=E5=B7=B2=E5=90=88=E5=B9=B6=E5=B7=A5=E5=8D=95=E7=9A=84=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=BA=A4=E6=8E=A5=E4=BB=B6=EF=BC=88#8559=20#8576=20#8?= =?UTF-8?q?577=20#8601=20#8603=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #8559 团期用车户数计入汇总读数不再随 kind 筛选变化 - #8576 团期配车提交与确认响应新增车辆规格提醒清单 - #8577 只提交接送机的户不再被判「未提交用车需求」 - #8601 逐户提交车务与打回的 kind 参数取消默认值,新增 809012 - #8603 派单确认响应删除恒空的接送机缺失日期字段 后端均已部署测试服(order-v3 / fleet @ d57498d381),网关实测通过。 Co-Authored-By: Claude Opus 5 (1M context) --- ...®¡入汇总读数不随kind筛选变化-修改接口-管理后台.md | 416 ++++++++++ ...确认响应新增车辆规格提醒清单-修改接口-管理后台.md | 623 +++++++++++++++ ...º的户不再被判未提交用车需求-修改接口-管理后台.md | 716 ++++++++++++++++++ ...¡与打回的kind参数取消默认值-修改接口-管理后台.md | 432 +++++++++++ ...ˆ 除恒空的接送机缺失日期字段-修改接口-管理后台.md | 430 +++++++++++ 5 files changed, 2617 insertions(+) create mode 100644 changelogs-v2/2026-09/30_8559_团期用车户数计入汇总读数不随kind筛选变化-修改接口-管理后台.md create mode 100644 changelogs-v2/2026-09/30_8576_团期配车提交与确认响应新增车辆规格提醒清单-修改接口-管理后台.md create mode 100644 changelogs-v2/2026-09/30_8577_只提交接送机的户不再被判未提交用车需求-修改接口-管理后台.md create mode 100644 changelogs-v2/2026-09/30_8601_逐户提交车务与打回的kind参数取消默认值-修改接口-管理后台.md create mode 100644 changelogs-v2/2026-09/30_8603_派单确认响应删除恒空的接送机缺失日期字段-修改接口-管理后台.md diff --git a/changelogs-v2/2026-09/30_8559_团期用车户数计入汇总读数不随kind筛选变化-修改接口-管理后台.md b/changelogs-v2/2026-09/30_8559_团期用车户数计入汇总读数不随kind筛选变化-修改接口-管理后台.md new file mode 100644 index 00000000..b6b2abdf --- /dev/null +++ b/changelogs-v2/2026-09/30_8559_团期用车户数计入汇总读数不随kind筛选变化-修改接口-管理后台.md @@ -0,0 +1,416 @@ +--- +schema: "hl-changelog/v2" +ticket: "8559" +title: "团期子订单用车需求记录:countedHouseholdCount / countedInSummary 不再随 kind 筛选归零" +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: "PR #8612 已 squash 合并 dev-v3(3ecf387979),hl-order-service-v3 dev-v3 分支已滚测试服。本条只改取值语义,字段名、字段个数、HTTP 形态全部不变。" +updated_at: "2026-09-30" +base: "dev-v3" +--- + +# hl-order-service-v3: 团期子订单用车需求记录的「计入汇总」读数不再随 kind 筛选归零 + +> **存放目录**: `changelogs-v2/2026-09/` +> **服务**: hl-order-service-v3 (端口 8086) +> **PR**: #8612 +> **Issue**: #8559 +> **日期**: 2026-09-30 +> **影响范围**: 团期「查看需求」Tab 用车板块逐户明细端点 `GET /v3/admin/order/group-batch/{groupBatchId}/requirement/vehicle-households` 的两个取值(顶层 `countedHouseholdCount`、每户 `households[].countedInSummary`) + +--- + +## ⚠️ 关键变化 + +- 🔴 **这是取值语义纠错,不是字段变更**:`countedHouseholdCount` 和 `households[].countedInSummary` 的**字段名、类型、位置全部没动**,变的是它们在带 `kind` 筛选时返回的**值**。 +- **改前**:带 `kind=TRANSFER` 调用时,`countedHouseholdCount` **恒为 0**,并且返回的每一户 `countedInSummary` **恒为 false**。原因是「这一户有没有被汇总计入座位」这个判据建在已经被 `kind` 截断过的需求行上——`kind=TRANSFER` 的结果集里不可能出现 TRAVEL 行,判据对每一户都落成 false。而团级汇总里那些户的座位是实实在在加进去的,同一页面两块数据互相矛盾。 +- **改后**:判据改成「这一户在**全部活跃需求行**里有没有 TRAVEL 行」,与本次 `kind` 筛选无关。三种调用(不传 `kind` / `kind=TRAVEL` / `kind=TRANSFER`)下,同一户的 `countedInSummary` 取值相同,`countedHouseholdCount` 读数相同。 +- **前端要做的事**:如果页面里有「`kind=TRANSFER` 时这个数恒为 0,所以隐藏/特判」这类兜底分支,**请删掉**——它现在会把正确的非零读数吞掉。另外不要再用「`countedInSummary` 全 false」去推断当前处于接送机筛选态。 +- **刻意没改**:卡片出不出现**仍然随 `kind` 变**(`kind=TRANSFER` 时只报了行程用车的户不出卡),这是 #8151 起的既有行为,本次不动。 +- **刻意没改**:`householdCount`(应报车户数)与 `vehicleRowCount`(需求行数)的口径,本次一个字都没动。 + +--- + +## 一、背景 + +团期「查看需求」Tab 的用车板块是上下两块:上面是团级汇总,下面是逐户明细。逐户明细支持按 `kind` 切换筛选(行程用车 / 接送机 / 全部)。`countedHouseholdCount` 回答的问题是「这个团有几户被汇总计入了座位」——它是用来跟上面那块汇总对账的,天然与「我现在正在看哪一类需求」无关。 + +| 维度 | 改前(`kind=TRANSFER`) | 改后(`kind=TRANSFER`) | +|------|------------------------|------------------------| +| `countedHouseholdCount` | 恒 `0` | 与不传 `kind` 时相同 | +| `households[].countedInSummary` | 每户恒 `false` | 与不传 `kind` 时逐户相同 | +| 卡片出现范围 | 随 `kind` 变 | 随 `kind` 变(未改) | +| 查库往返次数 | 1 次(IN 单值) | 1 次(IN 两值) | + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 团期子订单用车需求记录 | GET | `/v3/admin/order/group-batch/{groupBatchId}/requirement/vehicle-households` | 取值语义修正 | `countedHouseholdCount` 与 `households[].countedInSummary` 不再随 `kind` 筛选归零 | + +--- + +## 三、接口详情 + +### 1. 团期子订单用车需求记录 `GET /v3/admin/order/group-batch/{groupBatchId}/requirement/vehicle-households` + +**VO**: `GroupVehicleHouseholdsRespVO` + +#### 使用场景 + +团期详情「查看需求」Tab 的用车板块下半部分(逐户明细列表)。进入 Tab 时前端默认**不传** `kind`(两类都返);用户点「行程用车 / 接送机」切页签时带上 `kind`。本端点只读,无副作用。权限点 `group-batch:view`。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `groupBatchId` | Path | Long | ✅ | 团期主订单 ID | 团期不存在时抛团期未找到错误 | +| `kind` | Query | String | ❌ | `TRAVEL` / `TRANSFER`,不传或空白 = 两类都返 | 其它取值抛 809000「用车需求类别非法」。注意与提交侧「不传按 TRAVEL」的缺省刻意相反 | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `groupBatchId` | String | 团期主订单 ID(雪花,序列化为字符串) | +| `departDate` | String(`yyyy-MM-dd`) | 团期出发日,可为 null | +| `endDate` | String(`yyyy-MM-dd`) | 团期结束日,与团期详情同源,可为 null | +| `householdCount` | Integer | 应报车户数 = `households` 数组长度(含一份需求都没提交的空卡) | +| `vehicleRowCount` | Integer | 需求行数 = 各户 `requirements` 长度之和;**可以小于 `householdCount`**,不要当不变式用 | +| `countedHouseholdCount` | Integer | 🔴 被汇总计入座位的户数 = `households` 中 `countedInSummary=true` 的条数。**不随 `kind` 筛选变化**,三种筛选读数相同。与 `householdCount` 的差 = 只报接送机的户 + 未提交的户 | +| `households` | Array | 逐户卡片,按 `orderNo` 升序(`orderNo` 为空的排最后);单次最多 500 户 | +| `households[].orderId` | String | 子订单 ID(雪花,序列化为字符串) | +| `households[].orderNo` | String | 子订单号 | +| `households[].teamNo` | String | 团号,可为 null(不用订单号顶替) | +| `households[].customerName` | String | 客户姓名 | +| `households[].participantCount` | Integer | 出行人数 | +| `households[].consultantId` | String | 定制师 adminId,未指派为 null | +| `households[].consultantName` | String | 定制师姓名,未指派为 null | +| `households[].countedInSummary` | Boolean | 🔴 该户是否被团级汇总计入座位。**不随 `kind` 筛选变化**,同一户三种筛选下取值相同 | +| `households[].status` | String | 展示用需求状态;该户一份需求都没提交时为 null | +| `households[].statusName` | String | 状态中文名,`status` 为 null 时为 null | +| `households[].requirements` | Array | 该户活跃需求行,0~2 条;无行时为**空数组**不是 null | +| `households[].requirements[].requirementId` | String | 需求行 ID(雪花,序列化为字符串) | +| `households[].requirements[].kind` | String | `TRAVEL` / `TRANSFER` | +| `households[].requirements[].kindName` | String | 类别中文名 | +| `households[].requirements[].status` | String | `PENDING_REVIEW` / `PENDING` / `PROCESSING` / `DONE` | +| `households[].requirements[].statusName` | String | 状态中文名 | +| `households[].requirements[].fleet` | Array | 车型项:`vehicleType` / `vehicleTypeName` / `seats` / `count` | +| `households[].requirements[].specialTags` | Array | 特殊诉求标签:`code` / `name` | +| `households[].requirements[].remark` | String | 备注,≤500,可为 null | +| `households[].requirements[].serviceDates` | Array\ | 服务日期(`yyyy-MM-dd`) | +| `households[].requirements[].headcount` | Integer | 用车人数 | +| `households[].requirements[].totalSeatCount` | Integer | 总座位数 | +| `households[].requirements[].remainingPassengerSeats` | Integer | 余座(扣司机位后) | +| `households[].requirements[].pickupRequired` | Boolean | **仅 TRANSFER 行有值**,TRAVEL 行为 null | +| `households[].requirements[].dropoffRequired` | Boolean | **仅 TRANSFER 行有值**,TRAVEL 行为 null | +| `households[].requirements[].returnRemark` | String | 打回备注,可为 null | +| `households[].requirements[].returnedAt` | String(date-time) | 打回时刻,可为 null | + +#### 请求示例 + +```http +GET /v3/admin/order/group-batch/2101690789438570497/requirement/vehicle-households?kind=TRANSFER HTTP/1.1 +Host: +Authorization: Bearer +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "groupBatchId": "2101690789438570497", + "departDate": "2026-09-12", + "endDate": "2026-09-16", + "householdCount": 3, + "vehicleRowCount": 1, + "countedHouseholdCount": 2, + "households": [ + { + "orderId": "2102000000000000001", + "orderNo": "HL20260912100000001", + "teamNo": "26-0480", + "customerName": "陈昊", + "participantCount": 4, + "consultantId": "10001", + "consultantName": "李四", + "countedInSummary": true, + "status": "PENDING", + "statusName": "待车队配", + "requirements": [ + { + "requirementId": "2103000000000000011", + "kind": "TRANSFER", + "kindName": "接送机", + "status": "PENDING", + "statusName": "待车队配", + "fleet": [ + { "vehicleType": "suv", "vehicleTypeName": "SUV", "seats": 7, "count": 1 } + ], + "specialTags": [ + { "code": "child_seat", "name": "儿童安全座椅" } + ], + "remark": null, + "serviceDates": ["2026-09-12"], + "headcount": 4, + "totalSeatCount": 7, + "remainingPassengerSeats": 2, + "pickupRequired": true, + "dropoffRequired": false, + "returnRemark": null, + "returnedAt": null + } + ] + }, + { + "orderId": "2102000000000000002", + "orderNo": "HL20260912100000002", + "teamNo": "26-0480", + "customerName": "王琳", + "participantCount": 2, + "consultantId": "10001", + "consultantName": "李四", + "countedInSummary": true, + "status": null, + "statusName": null, + "requirements": [] + }, + { + "orderId": "2102000000000000003", + "orderNo": "HL20260912100000003", + "teamNo": null, + "customerName": "赵敏", + "participantCount": 3, + "consultantId": null, + "consultantName": null, + "countedInSummary": false, + "status": null, + "statusName": null, + "requirements": [] + } + ] + } +} +``` + +> 上例即改后行为:`kind=TRANSFER` 筛选下仍然有 `countedHouseholdCount=2`(前两户各有一条活跃 TRAVEL 行,被团级汇总计入了座位),只有第三户没报行程用车所以是 `false`。改前这三户的 `countedInSummary` 会全是 `false`、`countedHouseholdCount` 会是 `0`。 + +#### 空数据 / 降级响应 + +团期下没有在团子订单(全部退团 / 取消)时,三个计数全 `0`、`households` 为**空数组**(不是 null): + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "groupBatchId": "2101690789438570497", + "departDate": "2026-09-12", + "endDate": "2026-09-16", + "householdCount": 0, + "vehicleRowCount": 0, + "countedHouseholdCount": 0, + "households": [] + } +} +``` + +单次返回的户数上限为 500 户,超过时**截断**返回前 500 条并在服务端留 warn,不报错——页面仍可用。截断后 `householdCount` / `vehicleRowCount` / `countedHouseholdCount` 都按截断后的列表重算,三者与 `households` 数组始终自洽。 + +#### 错误响应 + +`kind` 传了 `TRAVEL` / `TRANSFER` 以外的值: + +```json +{ + "code": 809000, + "message": "用车需求类别非法:BUS", + "success": false, + "data": null +} +``` + +#### 业务边界 + +- **鉴权**:需要权限点 `group-batch:view`;未登录由网关拦截返 401。 +- **只读**:本端点零写入,重复调用无副作用,可安全轮询。 +- **户范围**:在团 = 仅排除 CANCELLED。退团 / 取消的户不出现在列表里。 +- **只取活跃行**:被打回的需求行已失活,**不在本列表内**(与用房侧「列出打回户」的行为刻意不同)。因此一户被打回后在这里表现为 `requirements: []` 的空卡,而不是灰条。 +- **卡片范围仍随 `kind` 变**:卡片集合 = 「应报车的户」∪「本次筛选命中需求行的户」。这一条本次未改。 +- **`countedInSummary` 不随 `kind` 变**:它读的是该户在**全部**活跃行里有没有 TRAVEL 行。 +- **`pickupRequired` / `dropoffRequired` 只在 TRANSFER 行有值**,TRAVEL 行恒 null,不要用 `false` 去区分。 +- **雪花 ID 一律是字符串**:`groupBatchId` / `orderId` / `consultantId` / `requirementId` 都以字符串下发,JS 直接当数字用会被静默截断。 + +--- + +## 四、契约约束与正确调用方式 + +> 本节只写后端接受 / 拒绝请求的规则,不写 UI 渲染建议。 + +### ✅ 正确 / ❌ 错误调用对照 + +| 场景 | 请求 | +|------|------| +| ✅ 进 Tab 默认拉全部 | `GET .../vehicle-households`(不带 `kind`) | +| ✅ 切到行程用车页签 | `GET .../vehicle-households?kind=TRAVEL` | +| ✅ 切到接送机页签 | `GET .../vehicle-households?kind=TRANSFER` | +| ✅ 显式传空值 | `GET .../vehicle-households?kind=`(空白按「两类都返」处理) | +| ❌ 传车型当类别 | `GET .../vehicle-households?kind=bus` → 809000 | +| ❌ 传小写类别 | `GET .../vehicle-households?kind=transfer` → 809000 | + +### 切换筛选时的必要动作 + +切换 `kind` 时,**顶部「计入汇总 N 户」这类读数不需要跟着置灰或隐藏**——它在三种筛选下是同一个数。如果前端此前为了绕开恒 0 做过「接送机页签下不展示该数」的兜底,现在应当删掉,否则接送机页签会永远看不到这个已经正确的读数。 + +--- + +## 五、数据库行为 + +本端点是 **GET 只读**,零写入:不建行、不改行、不软删、不产生任何 outbox / MQ 事件。 + +本次改动只调整了服务端的**读法**(原先按 `kind` 下推到查询条件,现在恒查两类再在内存里按 `kind` 分流),SQL 往返次数未变(同一条 IN 查询,`requirement_kind` 的 IN 列表从 1 个值放宽到 2 个值)。 + +--- + +## 六、边界行为 + +- 未登录 → 401(网关拦截)。 +- 权限点缺失 → 权限校验失败,不返回数据。 +- 团期 ID 不存在 → 抛团期未找到业务错误,HTTP 200 + 业务错误码。 +- 团期下无在团子订单 → 三个计数 0 + `households: []`,不报错。 +- 户数超 500 → 截断到前 500 条,三个计数按截断后重算,HTTP 200。 +- 在团订单 ID 存在但订单行缺失(脏数据)→ 跳过该户并在服务端留 warn,整页仍可打开。 +- 需求行 `requirement_kind` 为空的脏行 → 显式跳过,不进任何计数。 +- 老数据兼容:历史需求行缺 `seats` / `count` 等字段时对应位为 null,不异常。 + +--- + +## 六.5、枚举 / 数据字典 + +### kind(用车需求类别,`VehicleRequirementKind`) + +**所属字段**: 请求 Query `kind`、响应 `households[].requirements[].kind` | **类型**: `String` + +| 值 | 中文 | 说明 | +|----|------|------| +| `TRAVEL` | 行程用车 | 团期行程期间的用车需求;`countedInSummary` 的判据只认这一类 | +| `TRANSFER` | 接送机 | 接送机 / 接送站需求;`pickupRequired` / `dropoffRequired` 只在这一类上有值 | + +请求侧不传或传空白 = 两类都返(不是「默认 TRAVEL」)。 + +### status(需求行状态) + +**所属字段**: `households[].status`、`households[].requirements[].status` | **类型**: `String` + +| 值 | 中文 | 说明 | +|----|------|------| +| `PENDING_REVIEW` | 待审核 | 定制师已提交,等团期管理员下发车务 | +| `PENDING` | 待车队配 | 已下发车务,等车队配车 | +| `PROCESSING` | 配车中 | 车务处理中 | +| `DONE` | 配车完成 | 已完成 | + +户级 `status` 为 null 表示该户一份活跃需求都没有(空卡)。 + +--- + +## 六.6、修改前后对比 + +### 字段级对比 + +| 字段 | 改前 | 改后 | +|------|------|------| +| `countedHouseholdCount` | 字段存在,类型 Integer;`kind=TRANSFER` 时恒 `0` | 字段、类型不变;三种筛选读数相同 | +| `households[].countedInSummary` | 字段存在,类型 Boolean;`kind=TRANSFER` 时恒 `false` | 字段、类型不变;同一户三种筛选取值相同 | +| 其余全部字段 | — | 未变(无新增、无删除、无改名、无类型变化) | + +### 行为级对比 + +| 行为 | 改前 | 改后 | +|------|------|------| +| 不传 `kind` 时的两个读数 | 正确 | 正确(未变) | +| `kind=TRAVEL` 时的两个读数 | 正确 | 正确(未变) | +| `kind=TRANSFER` 时的两个读数 | `countedHouseholdCount=0`、每户 `countedInSummary=false` | 与不传 `kind` 时一致 | +| 「计入汇总」的判据数据源 | 已被 `kind` 截断的需求行 | 该户的全部活跃需求行 | +| 卡片是否随 `kind` 变 | 变 | 变(未改) | +| `householdCount` / `vehicleRowCount` | — | 未变 | + +--- + +## 六.7、影响评估 + +- **是否破坏向后兼容**: 否(无字段增删改名;只是 `kind=TRANSFER` 时的取值由恒 0 / 恒 false 变为真实值) +- **前端是否必须同步上线**: 否(不改也能正常渲染,只是接送机页签下该数从「永远 0」变成真实值) +- **前端 workaround 清理点**: 若页面里有「`kind=TRANSFER` 时 `countedHouseholdCount` 恒 0,所以隐藏该数 / 走另一套算法 / 用 `countedInSummary` 全 false 判断当前筛选态」这类兜底分支,**删掉它们**——保留会吞掉正确读数或做出错误的筛选态判断。 + +--- + +## 七、不影响范围 + +- **仅影响**: `GET /v3/admin/order/group-batch/{groupBatchId}/requirement/vehicle-households` 的 `countedHouseholdCount` 与 `households[].countedInSummary` 两个取值。 +- **零影响**: + - 团级用车汇总端点(本次改的是明细侧读法,汇总侧一个字没动) + - 团期正式用车需求的存 / 读 / 撤回 / 免车四个端点 + - 用房侧 `requirement/hotel-households` + - 单户用车需求的提交、下发、打回链路 + - 团期配车(fleet 侧)任何端点 + - 历史数据:本次只改读法,不做任何数据迁移 + +--- + +## 八、测试环境已验证 + +- **代码事实**(对 `origin/dev-v3` 逐一查证): + - 合并提交 `3ecf387979`(PR #8612 squash 合并进 `dev-v3`)。 + - `GroupBatchVehicleHouseholdService#households` 查库改为恒取 `TRAVEL` + `TRANSFER` 两类,另建 `travelOrderIds` 集合承载「计入汇总」判据;`buildHousehold` 签名增加 `boolean countedInSummary` 形参,替代原先在被截断的 `rows` 上做的 `anyMatch`。 + - `GroupVehicleHouseholdsRespVO#countedHouseholdCount` 与 `HouseholdItem#countedInSummary` 的 `@ApiModelProperty` 已同步写明「不随 kind 筛选变化,三种筛选读数相同」。 + - 卡片集合仍取自按 `kind` 过滤后的 `rowsByOrder`(既有行为,未改)。 +- **部署**:`hl-order-service-v3` 的 `dev-v3` 分支已滚到测试服,本端点走管理端网关 `/v3/admin/**` 既有通配路由,无新增路由。 + +``` +GET /v3/admin/order/group-batch/{groupBatchId}/requirement/vehicle-households → 200 ✓ +GET /v3/admin/order/group-batch/{groupBatchId}/requirement/vehicle-households?kind=TRAVEL → 200 ✓ +GET /v3/admin/order/group-batch/{groupBatchId}/requirement/vehicle-households?kind=TRANSFER → 200 + countedHouseholdCount 与前两次一致 ✓ +``` + +--- + +## 九、相关历史 PR + +| PR | Issue | 说明 | 是否仍有效 | +|----|-------|------|------------| +| — | #8151 | 首次提供本端点(逐户明细 + `kind` 筛选) | ✅ 有效 | +| — | #8195 | 缺陷 2:`householdCount` 改为「应报车户数」含空卡;缺陷 5:`endDate` 与团期详情同源 | ✅ 有效 | +| **本 PR #8612** | **#8559** | 「计入汇总」判据改为不受 `kind` 截断 | ✅ 最新 | + +--- + +## 十、相关文档 + +- 关联 Issue: [wx/HL#8559](https://git.1814.love:8443/wx/HL/issues/8559) +- 关联 PR: [wx/HL#8612](https://git.1814.love:8443/wx/HL/pulls/8612) + +## 关联 / 联系人 + +### 链接 + +- **Issue**: [#8559](https://git.1814.love:8443/wx/HL/issues/8559) +- **PR**: [#8612](https://git.1814.love:8443/wx/HL/pulls/8612) +- **Merge commit**: [3ecf387979](https://git.1814.love:8443/wx/HL/commit/3ecf387979) + +### 联系人 + +- **后端负责人**: @wx diff --git a/changelogs-v2/2026-09/30_8576_团期配车提交与确认响应新增车辆规格提醒清单-修改接口-管理后台.md b/changelogs-v2/2026-09/30_8576_团期配车提交与确认响应新增车辆规格提醒清单-修改接口-管理后台.md new file mode 100644 index 00000000..68e74814 --- /dev/null +++ b/changelogs-v2/2026-09/30_8576_团期配车提交与确认响应新增车辆规格提醒清单-修改接口-管理后台.md @@ -0,0 +1,623 @@ +--- +schema: "hl-changelog/v2" +ticket: "8576" +title: "团期配车提交 / 确认响应新增 specWarnings 车辆规格提醒清单(非错误)" +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: "PR #8602 已 squash 合并 dev-v3(cfefe04a83),hl-fleet-service dev-v3 分支已滚测试服。纯新增字段,两个端点的既有字段、错误码、HTTP 形态全部未变。" +updated_at: "2026-09-30" +base: "dev-v3" +--- + +# hl-fleet-service: 团期配车提交 / 确认响应新增 `specWarnings` 车辆规格提醒清单 + +> **存放目录**: `changelogs-v2/2026-09/` +> **服务**: hl-fleet-service (端口 8089) +> **PR**: #8602 +> **Issue**: #8576 +> **日期**: 2026-09-30 +> **影响范围**: 团期配车写口两个端点 `POST /admin/fleet/group-dispatch/batches/{groupBatchId}/reconfigure` 与 `POST /admin/fleet/group-dispatch/batches/{groupBatchId}/confirm` 的响应体各新增一个 `specWarnings` 数组 + +--- + +## ⚠️ 关键变化 + +- 两个端点的响应体**各新增一个字段** `specWarnings`(`List`)。**没有删字段、没有改名、没有改类型**,既有字段与错误码一个都没动。 +- 🔴 **`specWarnings` 不是错误**:它出现在 **HTTP 200 + 业务成功**的响应里。提交照常成功、确认照常成功、`coverage.satisfied` 照常按「排没排满」给结论。它回答的是另一个问题——**排上的那辆车,是不是这个分组当初要的那一类、那么多座**。前端不要把它当失败处理,也不要因为它非空就回滚本地状态。 +- 之前团期配车这条路**完全不读车辆实体的车型与座位**:声明 16 座大巴、实际派进 5 座 SUV,一路能确认到终态且零信号。本次补的就是这个信号。 +- 提醒分两类,**字段分两组、互不相干**,前端按 `code` 分支取值即可(另一组字段在各自提醒里恒为 `null`): + - `WARN_VEHICLE_TYPE_MISMATCH`(**车级**):点名到某一辆车,带 `vehicleId` / `vehiclePlate` / `declaredVehicleType` / `actualVehicleType`。 + - `WARN_GROUP_SEATS_BELOW_SPEC`(**组级**,一个分组最多一条):点名到组和不达标的服务日,带 `declaredSeats` / `declaredVehicleCount` / `declaredSeatTotal` / `actualSeatTotal` / `shortageDates`。 +- 两端的作用域不同,别混读: + - **reconfigure**:只覆盖**本次提交新增或就地改过**的「分组 + 车辆」组合,不含本次没动的存活行(否则一次只改司机的提交会把历史遗留的不符行一起刷出来)。 + - **confirm**:只覆盖**本次由「已派车」推进为「已确认」**的那些行。所以**重复确认(幂等重放)时恒为空列表**——那一次没有任何行被推进,清单的分母是空的。 +- **无提醒时是空数组 `[]`,不会是 `null`**,可直接 `v-for`。 + +--- + +## 一、背景 + +团期配车的「声明」来自正式团级用车需求的乘车分组(组码、车型、单车座位数 `seats`、每日车辆数 `vehicleCount`),「实际」来自车辆档案(车型 `typeKey`、座位数)。改前这两侧从来没被比对过,派错车型 / 座位不够在整条链路上零信号。 + +本次做成**提醒而不是硬拒**有两条已定口径的原因: + +| 维度 | 为什么不做成错误码 | +|------|-------------------| +| 座位不足 | 就绪判定里它已经是 wx 在 #7444 D9 拍板的「只提醒」档(`GroupDispatchReadinessService.WARN_SEAT_SHORTAGE`),硬拒会与该定案冲突 | +| 车型不符 | 两侧处在同一字典的不同归一层级,且两侧都合法地存在取不到值的行(车辆大类行缺失 / 存量需求的历史自由文本),硬拒会把现在能正常干活的分派拦下来 | + +与既有的 `GroupDispatchReadinessItemVO` 分工不同:那一份比的是「已扣司机座的可载客数 vs 该日实际用车人数」,回答「坐不坐得下」;本份比的是「名义座位合计 vs 需求方声明的计划容量 `seats × vehicleCount`」,回答「派的车是不是按计划来的」。右值不同源,不是重复。 + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 整团逐日配车提交 | POST | `/admin/fleet/group-dispatch/batches/{groupBatchId}/reconfigure` | 响应新增字段 | 新增 `specWarnings`,覆盖本次新增或就地改过的组+车组合 | +| 2 | 确认整团配车 | POST | `/admin/fleet/group-dispatch/batches/{groupBatchId}/confirm` | 响应新增字段 | 新增 `specWarnings`,覆盖本次被推进为「已确认」的行;重复确认恒为空 | + +--- + +## 三、接口详情 + +### 1. 整团逐日配车提交 `POST /admin/fleet/group-dispatch/batches/{groupBatchId}/reconfigure` + +**VO**: `GroupDispatchReconfigureReqVO → GroupDispatchReconfigureRespVO` + +#### 使用场景 + +团期配车页点「提交」时调用,按乘车分组提交整团逐日配车计划,服务端与现状差量比对(多删少补,旧记录软删留痕)。权限点 `fleet:group-dispatch:write`。本次改动只在响应里多加一个提醒清单,提交本身的行为与校验一条都没变。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `groupBatchId` | Path | Long | ✅ | - | 团期主订单 ID | +| `requirementId` | Body | Long | ✅ | `@NotNull` | 正式团级用车需求 ID,必须等于基线当前活跃需求,落后抛 602005 | +| `requirementVersion` | Body | Integer | ✅ | `@NotNull` | 正式团级用车需求版本,同上 | +| `clearAll` | Body | Boolean | ❌ | - | 显式整团清零标志;为 `true` 时 `demands` 只当待清日用,不会写入任何配车行 | +| `reconfigureWindowToken` | Body | String | ❌ | 团期已过资源准备阶段时必填 | 受控重开窗口令牌 | +| `survivorPolicy` | Body | String | ❌ | `clearAll=true` 且存在 active 共用关系时必填 | 幸存共用派单处置策略 | +| `demands` | Body | Array | ❌ | `@Valid`;`clearAll=false` 时必填 | 逐日配车需求列表 | +| `demands[].tripDate` | Body | String(`yyyy-MM-dd`) | ✅ | `@NotNull` | 行程日期 | +| `demands[].assignments` | Body | Array | ✅ | `@Valid` | 当日排车项列表 | +| `demands[].assignments[].groupId` | Body | String | ✅ | `@NotBlank`,`@Size(max=64)` | 乘车分组键(= 需求侧 `group_code`),空值返 HTTP 业务 400「乘车分组不能为空」 | +| `demands[].assignments[].vehicleId` | Body | Long | ✅ | `@NotNull` | 派出车辆 ID | +| `demands[].assignments[].driverId` | Body | Long | ❌ | - | 派出司机 ID;可空 = 仅排车未排司机 | +| `demands[].assignments[].remark` | Body | String | ❌ | `@Size(max=200)` | 备注 | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `groupBatchId` | String | 团期主订单 ID(雪花,字符串) | +| `requirementId` | String | 正式团级用车需求 ID(雪花,字符串) | +| `requirementVersion` | Integer | 正式团级用车需求版本 | +| `planVersion` | Integer/Long | 团期计划版本 | +| `addedCount` | Integer | 新增派车记录数 | +| `removedCount` | Integer | 软删派车记录数 | +| `keptCount` | Integer | 保留未变派车记录数 | +| `updatedCount` | Integer | 就地更新派车记录数 | +| `aliveCount` | Integer | 存活派车记录总数 | +| `addedDispatchIds` | Array\ | 新增派车记录主键列表(雪花,字符串) | +| `idempotentShortCircuit` | Boolean | 本次是否被计划去重短路;`true` 是**幂等成功**,不是失败 | +| `coverage` | Object | 按乘车分组的覆盖明细,见下 | +| `coverage.groups[]` | Array | 每组:`groupCode` / `vehicleType` / `requiredDates` / `coveredDates` / `missingDates` / `outOfRangeDates` / `satisfied` | +| `coverage.missingGroupCodes` | Array\ | 整组未提交的组码 | +| `coverage.wholeBatchSatisfied` | Boolean | 全团行程日整体覆盖是否成立 | +| `legacyGroupRowCount` | Integer | 无分组键的历史派车行数(非错误,仅留痕) | +| `releasedShareGroupIds` | Array\ | 本次连带解除的共用关系 ID 清单 | +| `keptSourceIds` | Array\ | 保留占用的 claim 来源 ID 清单 | +| `releasedSourceIds` | Array\ | 占用已被真正释放的派单 ID 清单 | +| `pendingReassignSourceIds` | Array\ | 待人工改派的派单 ID 清单(占用已释放,当前无车) | +| `ignoredDemandDays` | Array\ | 因 `clearAll=true` 未被写入的行程日清单;`clearAll=false` 时为空列表 | +| `specWarnings` | Array | 🆕 所派车辆与分组声明不符的提醒清单(**非错误,不影响提交成败**);无提醒为空数组 | +| `specWarnings[].code` | String | `WARN_VEHICLE_TYPE_MISMATCH` / `WARN_GROUP_SEATS_BELOW_SPEC` | +| `specWarnings[].message` | String | 中文描述,已点名到组与车牌 / 服务日,可直接展示 | +| `specWarnings[].groupCode` | String | 相关乘车分组码;**两类提醒都有值** | +| `specWarnings[].vehicleId` | String | 相关车辆 ID(雪花,字符串);**仅 `WARN_VEHICLE_TYPE_MISMATCH`** | +| `specWarnings[].vehiclePlate` | String | 相关车牌;车辆档案未录车牌时为 null(`message` 里已退回 `ID=车辆ID`) | +| `specWarnings[].declaredVehicleType` | String | 分组声明车型(归一后的规范大类 key,如 `bus`);**仅 `WARN_VEHICLE_TYPE_MISMATCH`** | +| `specWarnings[].actualVehicleType` | String | 车辆实际车型(归一后的规范大类 key,如 `suv`);**仅 `WARN_VEHICLE_TYPE_MISMATCH`** | +| `specWarnings[].declaredSeats` | Integer | 分组声明的**单车**座位数(含司机座);**仅 `WARN_GROUP_SEATS_BELOW_SPEC`** | +| `specWarnings[].declaredVehicleCount` | Integer | 分组声明的**每日**车辆数;**仅 `WARN_GROUP_SEATS_BELOW_SPEC`** | +| `specWarnings[].declaredSeatTotal` | Integer | 每日总容量 = `declaredSeats × declaredVehicleCount`,**后端算好回传,前端不要自己乘**;**仅 `WARN_GROUP_SEATS_BELOW_SPEC`** | +| `specWarnings[].actualSeatTotal` | Integer | 不达标服务日里**最低**那一天的实际座位合计;**仅 `WARN_GROUP_SEATS_BELOW_SPEC`** | +| `specWarnings[].shortageDates` | Array\ | 实际座位合计低于声明总容量的服务日,升序;**仅 `WARN_GROUP_SEATS_BELOW_SPEC`** | + +#### 请求示例 + +```json +{ + "requirementId": 5501, + "requirementVersion": 3, + "clearAll": false, + "demands": [ + { + "tripDate": "2026-09-13", + "assignments": [ + { "groupId": "BUS", "vehicleId": 1001, "driverId": 2001, "remark": "AA 团 7 座商务" } + ] + }, + { + "tripDate": "2026-09-14", + "assignments": [ + { "groupId": "BUS", "vehicleId": 1001, "driverId": 2001, "remark": null } + ] + } + ] +} +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "groupBatchId": "8801", + "requirementId": "5501", + "requirementVersion": 3, + "planVersion": 7, + "addedCount": 2, + "removedCount": 0, + "keptCount": 3, + "updatedCount": 0, + "aliveCount": 5, + "addedDispatchIds": ["9001", "9002"], + "idempotentShortCircuit": false, + "coverage": { + "groups": [ + { + "groupCode": "BUS", + "vehicleType": "bus", + "requiredDates": ["2026-09-13", "2026-09-14"], + "coveredDates": ["2026-09-13", "2026-09-14"], + "missingDates": [], + "outOfRangeDates": [], + "satisfied": true + } + ], + "missingGroupCodes": [], + "wholeBatchSatisfied": true + }, + "legacyGroupRowCount": 0, + "releasedShareGroupIds": [], + "keptSourceIds": [], + "releasedSourceIds": [], + "pendingReassignSourceIds": [], + "ignoredDemandDays": [], + "specWarnings": [ + { + "code": "WARN_VEHICLE_TYPE_MISMATCH", + "message": "分组 BUS 声明车型 大巴客车,所派车辆 蒙P318A 实际为 SUV", + "groupCode": "BUS", + "vehicleId": "1001", + "vehiclePlate": "蒙P318A", + "declaredVehicleType": "bus", + "actualVehicleType": "suv", + "declaredSeats": null, + "declaredVehicleCount": null, + "declaredSeatTotal": null, + "actualSeatTotal": null, + "shortageDates": null + }, + { + "code": "WARN_GROUP_SEATS_BELOW_SPEC", + "message": "分组 BUS 声明每日总容量 16 座(单车 16 座 × 1 辆),实际座位合计最低仅 5 座,涉及 2 个服务日:[2026-09-13, 2026-09-14]", + "groupCode": "BUS", + "vehicleId": null, + "vehiclePlate": null, + "declaredVehicleType": null, + "actualVehicleType": null, + "declaredSeats": 16, + "declaredVehicleCount": 1, + "declaredSeatTotal": 16, + "actualSeatTotal": 5, + "shortageDates": ["2026-09-13", "2026-09-14"] + } + ] + } +} +``` + +#### 空数据 / 降级响应 + +无提醒时 `specWarnings` 是**空数组**,不是 `null`: + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "groupBatchId": "8801", + "addedCount": 0, + "removedCount": 0, + "keptCount": 5, + "updatedCount": 0, + "aliveCount": 5, + "idempotentShortCircuit": true, + "specWarnings": [] + } +} +``` + +**降级(fail-open)规则** —— 下列情况提醒**不产出**,`specWarnings` 少一条或为空,这是设计行为不是丢数据: + +- 分组不在基线的权威分组清单里 → 该组整组跳过。 +- 车辆在本次的车辆档案快照里取不到 → 该车跳过。 +- 声明侧或实际侧任一方的车型归一不出规范 key(车辆大类行缺失 / 存量需求的历史自由文本)→ 不报车型不符。 +- 分组的 `seats` 或 `vehicleCount` 为 null 或 ≤ 0 → 不做容量判定。 +- 某个(分组 + 服务日)格里**只要有一辆车**的座位数取不到或 ≤ 0 → **整格不判**(不把缺值折成 0,折 0 会产出一个不存在的缺口)。 + +#### 错误响应 + +既有错误码一条都没变。示例(分组不在本团需求内): + +```json +{ + "code": 602001, + "message": "分组 VAN 不在本团正式用车需求的分组清单内", + "success": false, + "data": null +} +``` + +完整错误码:602000(排车项缺分组,服务层兜底;admin 口由入参校验先拦下返 400「乘车分组不能为空」)/ 602001 分组不在本团需求内 / 602002 整组未排车 / 602003 该组服务日未排满 / 602004 该组排了本组服务范围外的日期 / 602005 需求身份或版本已变 / 602006 需求状态不允许 / 602009 取不到权威分组清单 / 600003 重复行程日 / 600004 单日排车为空 / 600005 缺车辆 ID / 600006 车辆被占 / 600007 司机被占 / 600008 并发修改 / 600009 基线不可用 / 600010 团期状态不可配 / 600011 全团服务日未覆盖满 / 605037 车辆维保或停用不可派 / 605038 司机休假或待激活不可派 / 605006 司机已黑名单 / 605013 司机非在册赛季不可派单。 + +#### 业务边界 + +- **鉴权**:权限点 `fleet:group-dispatch:write`(与读口 `fleet:group-dispatch:view` 分开);未登录由网关拦截返 401。 +- **`specWarnings` 非错误**:HTTP 200 + `success=true` 的响应里出现,提交已成功落库。不要据此回滚本地状态或阻断后续动作。 +- **作用域**:只覆盖**本次提交新增或就地改过**的「分组 + 车辆」组合;本次没动的存活行不重判(一次只改司机的提交不会把历史遗留的不符行刷出来)。 +- **两类提醒字段分组互斥**:按 `code` 分支取值,另一组字段恒 `null`。 +- **`declaredSeatTotal` 由后端算好**:口径(含不含司机座、按不按日)只有一份权威,前端不要复算。 +- **`actualSeatTotal` 是最低值不是明细**:它与 `declaredSeatTotal` 一起答完「最坏差多少」,不需要拿 `shortageDates` 反查每一天。 +- **防重提交与幂等是两件事**:10 秒内对同一份计划重复提交会被防重窗口**拒绝**(返「团期配车重配处理中,请勿重复提交」);窗口之外重复提交同一份计划会正常受理并返回 `idempotentShortCircuit=true`,**那是成功**。 +- **`clearAll=true` 时必须读 `ignoredDemandDays`**:否则「清完并按新计划重排」与「只清空」在响应里长得一模一样(两者 `addedCount` 都是 0)。 +- **雪花 ID 一律是字符串**:`groupBatchId` / `requirementId` / `addedDispatchIds[]` / `specWarnings[].vehicleId` 等都以字符串下发。 + +--- + +### 2. 确认整团配车 `POST /admin/fleet/group-dispatch/batches/{groupBatchId}/confirm` + +**VO**: `GroupDispatchConfirmReqVO → GroupDispatchConfirmRespVO` + +#### 使用场景 + +团期配车页点「确认」时调用,把该团全部「已派车」的配车行转为「已确认」,并登记一条把正式用车需求推进到「已发车务」的异步回写意图。权限点与提交写口同一个 `fleet:group-dispatch:write`。本次改动只在响应里多加一个提醒清单。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `groupBatchId` | Path | Long | ✅ | - | 团期主订单 ID | +| `requirementId` | Body | Long | ✅ | `@NotNull` | 正式团级用车需求 ID | +| `requirementVersion` | Body | Integer | ✅ | `@NotNull` | 正式团级用车需求版本 | +| `remark` | Body | String | ❌ | `@Size(max=200)` | 确认备注,**仅留痕**,不写入配车行 | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `groupBatchId` | String | 团期主订单 ID(雪花,字符串) | +| `confirmedCount` | Integer | 本次由「已派车」转为「已确认」的配车行数;**重复确认为 0,属幂等成功** | +| `alreadyConfirmedCount` | Integer | 确认前就已是「已确认」的配车行数(重复确认时全部落在这里) | +| `requirementId` | String | 本次确认所依据的正式团级用车需求 ID(雪花,字符串) | +| `requirementVersion` | Integer | 本次确认所依据的需求版本 | +| `planVersion` | Integer/Long | 当前团期计划版本(确认不改计划,故不递增) | +| `requirementAdvanceIntent` | String | 已登记的需求回写意图方向,恒为 `CONFIRMED_TO_DISPATCHED` | +| `coverage` | Object | 按乘车分组的覆盖明细(确认前对库里现存配车行重判一次的结果),结构同 reconfigure | +| `legacyGroupRowCount` | Integer | 本团存活派车行里没有乘车分组键的历史行数;非零时 602008 的缺口很可能正是它们造成的 | +| `specWarnings` | Array | 🆕 本次被推进为「已确认」的行里,所派车辆与分组声明不符的提醒清单(**非错误,确认已成功**);无提醒为空数组 | +| `specWarnings[].code` | String | `WARN_VEHICLE_TYPE_MISMATCH` / `WARN_GROUP_SEATS_BELOW_SPEC` | +| `specWarnings[].message` | String | 中文描述,已点名到组与车牌 / 服务日,可直接展示 | +| `specWarnings[].groupCode` | String | 相关乘车分组码;两类提醒都有值 | +| `specWarnings[].vehicleId` | String | 相关车辆 ID(雪花,字符串);仅 `WARN_VEHICLE_TYPE_MISMATCH` | +| `specWarnings[].vehiclePlate` | String | 相关车牌;未录车牌时为 null | +| `specWarnings[].declaredVehicleType` | String | 分组声明车型(规范 key);仅 `WARN_VEHICLE_TYPE_MISMATCH` | +| `specWarnings[].actualVehicleType` | String | 车辆实际车型(规范 key);仅 `WARN_VEHICLE_TYPE_MISMATCH` | +| `specWarnings[].declaredSeats` | Integer | 分组声明单车座位数;仅 `WARN_GROUP_SEATS_BELOW_SPEC` | +| `specWarnings[].declaredVehicleCount` | Integer | 分组声明每日车辆数;仅 `WARN_GROUP_SEATS_BELOW_SPEC` | +| `specWarnings[].declaredSeatTotal` | Integer | 每日总座位数(后端算好);仅 `WARN_GROUP_SEATS_BELOW_SPEC` | +| `specWarnings[].actualSeatTotal` | Integer | 不达标日中的最低实际座位合计;仅 `WARN_GROUP_SEATS_BELOW_SPEC` | +| `specWarnings[].shortageDates` | Array\ | 不达标的服务日(升序);仅 `WARN_GROUP_SEATS_BELOW_SPEC` | + +#### 请求示例 + +```json +{ + "requirementId": 5501, + "requirementVersion": 3, + "remark": "与地接确认车辆无误" +} +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "groupBatchId": "8801", + "confirmedCount": 8, + "alreadyConfirmedCount": 0, + "requirementId": "5501", + "requirementVersion": 3, + "planVersion": 7, + "requirementAdvanceIntent": "CONFIRMED_TO_DISPATCHED", + "coverage": { + "groups": [ + { + "groupCode": "BUS", + "vehicleType": "bus", + "requiredDates": ["2026-09-13", "2026-09-14"], + "coveredDates": ["2026-09-13", "2026-09-14"], + "missingDates": [], + "outOfRangeDates": [], + "satisfied": true + } + ], + "missingGroupCodes": [], + "wholeBatchSatisfied": true + }, + "legacyGroupRowCount": 0, + "specWarnings": [ + { + "code": "WARN_VEHICLE_TYPE_MISMATCH", + "message": "分组 BUS 声明车型 大巴客车,所派车辆 蒙P318A 实际为 SUV", + "groupCode": "BUS", + "vehicleId": "1001", + "vehiclePlate": "蒙P318A", + "declaredVehicleType": "bus", + "actualVehicleType": "suv", + "declaredSeats": null, + "declaredVehicleCount": null, + "declaredSeatTotal": null, + "actualSeatTotal": null, + "shortageDates": null + } + ] + } +} +``` + +#### 空数据 / 降级响应 + +**重复确认(幂等重放)**:`confirmedCount=0`、`alreadyConfirmedCount=N`、`specWarnings` 恒为**空数组**(本次没有任何行被推进,清单的分母是空的)。HTTP 仍是 200,**这是成功不是失败**: + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "groupBatchId": "8801", + "confirmedCount": 0, + "alreadyConfirmedCount": 8, + "requirementId": "5501", + "requirementVersion": 3, + "planVersion": 7, + "requirementAdvanceIntent": "CONFIRMED_TO_DISPATCHED", + "legacyGroupRowCount": 0, + "specWarnings": [] + } +} +``` + +**降级(fail-open)规则**与 reconfigure 端点逐条相同:分组不在基线 / 车辆取不到 / 任一侧车型归一不出规范 key / `seats` 或 `vehicleCount` 为 null 或 ≤0 / 某(组+日)格里有一辆车座位取不到 → 对应提醒不产出。 + +#### 错误响应 + +既有错误码一条都没变。示例(现存配车对当前需求仍不完整): + +```json +{ + "code": 602008, + "message": "现存配车对当前需求仍不完整:分组 BUS 缺 2026-09-15", + "success": false, + "data": null +} +``` + +完整错误码:602007 本团无可确认的配车行 / 602008 现存配车对当前需求仍不完整 / 602005 需求身份或版本已变 / 602006 需求状态不允许 / 602009 取不到权威分组清单 / 600008 并发修改 / 600009 基线不可用 / 605037 车辆维保或停用不可派 / 605038 司机休假或待激活不可派 / 605006 司机已黑名单 / 605013 司机非在册赛季不可派单。 + +#### 业务边界 + +- **鉴权**:权限点 `fleet:group-dispatch:write`(与提交写口同一个);未登录由网关拦截返 401。 +- **`specWarnings` 非错误**:确认已经成功。它是终态前的最后一次复核——重配与确认之间车辆档案可能被改过,也可能有行绕过重配直接进来。 +- **作用域**:只覆盖**本次由「已派车」推进为「已确认」**的那些行;已是「已确认」的行不重判。 +- **重复确认时恒为空列表**:不要把「第二次点确认没有提醒」理解成「问题已经消失」。 +- **本端点没有防重提交时间窗**:连点多少次都是 `confirmedCount=0 / alreadyConfirmedCount=N` 这个形态,不会出现「请勿重复提交」这类错误码;同团的并发调用由服务端串行化。 +- **异步回写**:响应成功只代表车务侧已确认并已把回写意图可靠登记,正式用车需求的状态可能稍后才变成「已发车务」,需求页需自行刷新。 +- **回写会推进需求版本但不会让重复确认变成错误**:首次确认成功后正式用车需求被推进一版(status 转 DISPATCHED),此时本端点跳过需求版本与状态的严格校验,仍返回 200 + 两个计数。 +- **不校验团期是否可配**:那道门禁管的是「还能不能改车」,确认不改车。 +- **雪花 ID 一律是字符串**。 + +--- + +## 四、契约约束与正确调用方式 + +> 本节只写后端接受 / 拒绝 payload 的规则,不写 UI 渲染建议。 + +### ✅ 正确 / ❌ 错误 payload 对照 + +| 场景 | payload | +|------|---------| +| ✅ 正常提交两天配车 | `{ "requirementId": 5501, "requirementVersion": 3, "clearAll": false, "demands": [ { "tripDate": "2026-09-13", "assignments": [ { "groupId": "BUS", "vehicleId": 1001 } ] } ] }` | +| ✅ 整团清零 | `{ "requirementId": 5501, "requirementVersion": 3, "clearAll": true, "demands": [] }` | +| ✅ 确认(带留痕备注) | `{ "requirementId": 5501, "requirementVersion": 3, "remark": "与地接确认车辆无误" }` | +| ✅ 确认(不带备注) | `{ "requirementId": 5501, "requirementVersion": 3 }` | +| ❌ 排车项缺分组键 | `{ ..., "assignments": [ { "vehicleId": 1001 } ] }` → 400「乘车分组不能为空」 | +| ❌ 缺需求版本 | `{ "requirementId": 5501, "demands": [...] }` → 400「正式团级用车需求版本不能为空」 | +| ❌ 确认备注超 200 字 | `{ ..., "remark": "<201 字>" }` → 400「确认备注长度不能超过 200」 | + +### 处理 `specWarnings` 的必要动作 + +- 两个端点的成功分支里都要读 `specWarnings`:非空时就地展示(`message` 已经是完整中文句子,可直接渲染),**不要**把它接到错误处理分支上。 +- 按 `code` 分支取字段,不要对全部字段做非空假设——另一类提醒的字段组恒为 `null`。 +- `declaredSeatTotal` 直接用后端回传的值,不要用 `declaredSeats × declaredVehicleCount` 自己算。 +- reconfigure 的 `specWarnings` 只反映本次动过的行:`specWarnings` 为空**不等于**全团没有不符行,只等于「本次动的这些行没有不符」。 + +--- + +## 五、数据库行为 + +两个端点都是写端点,但**本次改动零写入变化**——`specWarnings` 完全由内存中的比对产出(`GroupDispatchVehicleSpecInspector` 是纯静态、无 IO),不新建表、不加列、不落任何提醒记录。 + +| 前端提交 | 配车行的写入 | 提醒的持久化 | +|----------|--------------|--------------| +| `reconfigure` 差量提交 | 多删少补,旧记录软删留痕(本次未变) | **不落库**,仅随本次响应下发 | +| `reconfigure` `clearAll=true` | 清空存活行,`demands` 不写入 | **不落库** | +| `confirm` 首次确认 | 「已派车」行 CAS 推进为「已确认」,登记回写意图(本次未变) | **不落库** | +| `confirm` 重复确认 | 一个字段都不动 | **不落库**,且恒为空数组 | + +因此**刷新页面或重新拉取不会再拿到同一批提醒**——提醒是本次动作的返回值,不是可查询的状态。 + +--- + +## 六、边界行为 + +- 未登录 → 401(网关拦截)。 +- 权限点 `fleet:group-dispatch:write` 缺失 → 权限校验失败。 +- 团期不存在 / 基线不可用 → 600009。 +- 需求身份或版本落后 → 602005(fail-closed,不接受「反正车没变」)。 +- 10 秒内重复提交同一份 reconfigure 计划 → 被防重窗口拒绝,提示「团期配车重配处理中,请勿重复提交」。 +- 窗口外重复提交同一份计划 → 200 + `idempotentShortCircuit=true`(成功)。 +- 重复 confirm → 200 + `confirmedCount=0`、`specWarnings=[]`(成功)。 +- 车辆档案未录车牌 → `specWarnings[].vehiclePlate` 为 null,但 `message` 里退回 `ID=车辆ID`,不留空白。 +- 老数据兼容:历史派车行没有乘车分组键时不计入任何组的覆盖,计入 `legacyGroupRowCount`,也不进 `specWarnings`。 + +--- + +## 六.5、枚举 / 数据字典 + +### code(车辆规格提醒项代码) + +**所属字段**: `specWarnings[].code` | **类型**: `String` + +| 值 | 中文 | 说明 | +|----|------|------| +| `WARN_VEHICLE_TYPE_MISMATCH` | 车型与分组声明不符 | **车级**提醒。两侧车型各自归一成规范大类 key 后不相等时产出。带 `vehicleId` / `vehiclePlate` / `declaredVehicleType` / `actualVehicleType`;其余字段为 null | +| `WARN_GROUP_SEATS_BELOW_SPEC` | 分组座位低于声明容量 | **组级**提醒,一个分组最多一条。判据:`该组该日实际座位合计 < seats × vehicleCount`。带 `declaredSeats` / `declaredVehicleCount` / `declaredSeatTotal` / `actualSeatTotal` / `shortageDates`;其余字段为 null | + +### declaredVehicleType / actualVehicleType(归一后的车型规范大类 key) + +**所属字段**: `specWarnings[].declaredVehicleType`、`specWarnings[].actualVehicleType` | **类型**: `String` + +取值是车型字典归一后的**规范大类 key**(如 `bus` / `suv`),**不是**车辆档案里的原值——车辆档案侧存的是开集原值(例如测试环境 SUV 大类的 `type_key` 实际是 `suv2`),后端归一后才比。前端如需展示中文名,用 `message` 里已经拼好的中文,不要自己拿 key 去查字典。 + +### requirementAdvanceIntent(需求回写意图方向) + +**所属字段**: `GroupDispatchConfirmRespVO.requirementAdvanceIntent` | **类型**: `String` + +| 值 | 中文 | 说明 | +|----|------|------| +| `CONFIRMED_TO_DISPATCHED` | 已确认 → 已发车务 | 当前**恒为此值**;表示确认成功后还有一步异步回写,需求列表页的状态可能稍后才变 | + +--- + +## 六.6、修改前后对比 + +### 字段级对比 + +| 字段 | 改前 | 改后 | +|------|------|------| +| `GroupDispatchReconfigureRespVO.specWarnings` | 不存在 | 🆕 `List`,无提醒为空数组 | +| `GroupDispatchConfirmRespVO.specWarnings` | 不存在 | 🆕 `List`,无提醒为空数组 | +| 两个响应体的其余全部字段 | — | 未变(无删除、无改名、无类型变化) | +| 两个请求体 | — | 未变(一个字段都没动) | +| 两个端点的错误码集合 | — | 未变 | + +### 行为级对比 + +| 行为 | 改前 | 改后 | +|------|------|------| +| 派错车型(声明大巴、实际 SUV) | 全链路零信号,一路能确认到终态 | 提交与确认的响应里各出一条 `WARN_VEHICLE_TYPE_MISMATCH` | +| 某服务日实际座位合计低于声明容量 | 全链路零信号 | 出一条 `WARN_GROUP_SEATS_BELOW_SPEC`,带最低值与不达标日清单 | +| 提交 / 确认的成败判定 | 按覆盖与资源可派性 | 未变——`specWarnings` 不参与成败判定 | +| 重复确认 | `confirmedCount=0`、`alreadyConfirmedCount=N` | 未变,额外 `specWarnings=[]` | +| 只改司机的提交 | — | 不会把历史遗留的不符行刷出来(作用域限本次动过的组+车组合) | + +--- + +## 六.7、影响评估 + +- **是否破坏向后兼容**: 否(纯新增字段,既有字段与错误码零变化;老前端忽略新字段即可正常工作) +- **前端是否必须同步上线**: 否(不读新字段不会报错,只是拿不到提醒) +- **前端 workaround 清理点**: 无(此前没有任何前端侧的车型 / 座位比对,不存在需要撤掉的本地实现) + +--- + +## 七、不影响范围 + +- **仅影响**: `POST /admin/fleet/group-dispatch/batches/{groupBatchId}/reconfigure` 与 `POST /admin/fleet/group-dispatch/batches/{groupBatchId}/confirm` 两个响应体各新增一个数组字段。 +- **零影响**: + - 团期配车所有读口(概览、就绪判定、矩阵、看板) + - `GroupDispatchReadinessItemVO` 的座位就绪判定(右值不同源,本次未动) + - 单车派单、改派、取消链路 + - order-v3 侧的正式团级用车需求存 / 读 / 撤回 / 免车 + - 车辆档案、司机档案的任何端点 + - 历史数据:提醒不落库,不做任何数据迁移 + +--- + +## 八、测试环境已验证 + +- **代码事实**(对 `origin/dev-v3` 逐一查证): + - 合并提交 `cfefe04a83`(PR #8602 squash 合并进 `dev-v3`)。 + - 新增 VO `hl-fleet-service/.../dispatch/vo/GroupDispatchSpecWarningRespVO.java`(12 个字段)与对位 Feign DTO `GroupDispatchSpecWarningDTO`。 + - 新增纯静态无 IO 的 `GroupDispatchVehicleSpecInspector`;两条提醒的消息拼装、fail-open 跳过条件、`seatTotalOrNull` 的「一辆车取不到座位就整格不判」逻辑均已逐行核对。 + - `GroupDispatchReconfigureRespVO` 与 `GroupDispatchConfirmRespVO` 各新增 `specWarnings` 字段,javadoc 分别写明作用域(本次新增/就地改过 vs 本次被推进)与「重复确认恒为空列表」。 +- **部署**:`hl-fleet-service` 的 `dev-v3` 分支已滚到测试服,两个端点走管理端网关 `/admin/fleet/**` 既有路由,无新增路由。 + +``` +POST /admin/fleet/group-dispatch/batches/{groupBatchId}/reconfigure → 200 + data.specWarnings 存在(无提醒时为 [])✓ +POST /admin/fleet/group-dispatch/batches/{groupBatchId}/confirm → 200 + data.specWarnings 存在(无提醒时为 [])✓ +POST .../confirm 重复调用 → 200 + confirmedCount=0 + specWarnings=[] ✓ +``` + +--- + +## 九、相关历史 PR + +| PR | Issue | 说明 | 是否仍有效 | +|----|-------|------|------------| +| — | #7442 | 团期配车写口(reconfigure / confirm)首次落地 | ✅ 有效 | +| — | #7444 D9 | wx 拍板座位不足在就绪判定里只提醒不硬拒 | ✅ 有效(本单沿用该口径) | +| — | #8195 | 需求侧车型归一后回写规范 key | ✅ 有效(本单的比对依赖它) | +| — | #8528 | reconfigure / confirm 资源可派性硬校验(605037/605038/605006/605013) | ✅ 有效 | +| **本 PR #8602** | **#8576** | 两个写口响应新增 `specWarnings` | ✅ 最新 | + +--- + +## 十、相关文档 + +- 关联 Issue: [wx/HL#8576](https://git.1814.love:8443/wx/HL/issues/8576) +- 关联 PR: [wx/HL#8602](https://git.1814.love:8443/wx/HL/pulls/8602) + +## 关联 / 联系人 + +### 链接 + +- **Issue**: [#8576](https://git.1814.love:8443/wx/HL/issues/8576) +- **PR**: [#8602](https://git.1814.love:8443/wx/HL/pulls/8602) +- **Merge commit**: [cfefe04a83](https://git.1814.love:8443/wx/HL/commit/cfefe04a83) + +### 联系人 + +- **后端负责人**: @wx diff --git a/changelogs-v2/2026-09/30_8577_只提交接送机的户不再被判未提交用车需求-修改接口-管理后台.md b/changelogs-v2/2026-09/30_8577_只提交接送机的户不再被判未提交用车需求-修改接口-管理后台.md new file mode 100644 index 00000000..80d34d71 --- /dev/null +++ b/changelogs-v2/2026-09/30_8577_只提交接送机的户不再被判未提交用车需求-修改接口-管理后台.md @@ -0,0 +1,716 @@ +--- +schema: "hl-changelog/v2" +ticket: "8577" +title: "只提交了接送机的户不再被判「未提交用车需求」,809121/809122/809123 触发条件收窄且文案改写" +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: "PR #8600 已 squash 合并 dev-v3(5b7074e691),hl-order-service-v3 dev-v3 分支已滚测试服。三个端点的请求体、响应体字段与错误码号全部未变,变的是 809121/809122/809123 的触发条件(收窄)与消息文案(去掉「行程」二字)。" +updated_at: "2026-09-30" +base: "dev-v3" +--- + +# hl-order-service-v3: 只提交了接送机的户不再被判「未提交用车需求」 + +> **存放目录**: `changelogs-v2/2026-09/` +> **服务**: hl-order-service-v3 (端口 8086) +> **PR**: #8600 +> **Issue**: #8577 +> **日期**: 2026-09-30 +> **影响范围**: 团期需求管理 Tab 的三个端点(保存正式用车需求 / 自动汇总草稿 / 整体确认预检)里 809121、809122、809123 的触发条件与消息文案 + +--- + +## ⚠️ 关键变化 + +- 🔴 **判据从「有没有提交行程用车(TRAVEL)」收窄为「两类用车需求(TRAVEL / TRANSFER)是不是一条都没有」**。改前:某户只提交了接送机需求,团级保存、自动汇总、确认预检都把它当成「一条都没交」,整团被 809123 / 809121 / 809122 卡住,而这户其实已经明确表达过「只要接送机、不要行程车」,运营**没有任何干净出路**(唯一逃生舱是整团 waive 免车,那会把真需要行程车的户一起免掉)。改后:这户算已提交,三处一律放行。 +- **三个错误码的码值没变、字段没变**,变的是**什么时候抛**(收窄)与**消息文案**(三条都去掉了「行程」二字): + - `809121` `团期 {0} 有 {1} 户缺少可汇总的行程用车需求…` → `…缺少可汇总的用车需求…` + - `809122` `该户尚未提交行程用车需求,请先让定制师提交后再整团提交车务` → `该户尚未提交用车需求,…` + - `809123` `{0}有 {1} 户尚未提交行程用车需求,暂不能保存正式用车需求:{2}` → `…尚未提交用车需求,…` + - 🔴 **前端凡是对这三条报文做过关键词匹配 / 字符串包含判断的地方必须改**(`行程用车需求` 这个子串在三条里都没了)。正确做法是按 `code` 分支,不要匹配 `message` 文本。 +- 🔴 **809109「逐日覆盖」一个字都没改,仍然只认 TRAVEL**。这是刻意的:本次分离的是「户级提交判定」与「行程覆盖判定」两件事,合并会把墙从 809123 挪到 809109,症状一模一样只是换个码。所以——**只提交接送机的户不再被判未提交,也不要求被任何乘车分组覆盖**;它结构上就在团级乘车分组之外,走逐户派车。 +- `GroupVehicleDraftAggregator` 的缺失原因文案 `未提交行程用车需求` → `未提交用车需求`。它出现在 809121 报文的逐户清单里(`「户标识:原因」`,顿号分隔),前端若展示过这个字符串同样受影响。 +- 「豁免户」`exemptHouseholds` 的语义边界也随之明确:**只提交了接送机的户既不进未提交名单、也不进豁免名单**——豁免解释的是「没提交的户为什么不拦」,而它本来就提交过。 + +--- + +## 一、背景 + +一户在团期里的用车需求有两类活跃行,互不替代: + +| 类别 | 含义 | 派车路径 | +|------|------|----------| +| `TRAVEL` | 团期行程用车 | 汇总进团级乘车分组,整团逐日配车 | +| `TRANSFER` | 接送机 | 逐户派车,**结构上不进团级乘车分组** | + +改前的三处判定都只查 `TRAVEL`。于是「只要接送机、不要行程车」这种完全合法的在团户(与 #7972 (A) 对 809114 的定案同源)被读成「什么都没交」。团级保存直接 809123 整份拒绝、自动汇总 809121 整团出不来草稿、确认预检 809122 逐户挂红——**运营改不动、催不动(该户定制师已经交过了)、也绕不过去**。 + +本次把判定拆成两个集合(`travelSubmittedOrderIds` / `anySubmittedOrderIds`),单源仍只有一份,在 `GroupVehicleRequirementService#classifyVehicleSubmission`(原名 `classifyTravelSubmission`),保存、预检、自动汇总三处共用: + +- **户级「交了没有」** → 用 `anySubmittedOrderIds`(两类任一即算交了)→ 管 809121 / 809122 / 809123; +- **行程逐日覆盖** → 仍用 `travelSubmittedOrderIds`(只认 TRAVEL)→ 管 809109。 + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 保存团期正式用车需求(全量替换) | PUT | `/v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement` | 错误码触发条件收窄 + 文案改写 | 809123 不再对「只提交接送机」的户触发;报文去掉「行程」 | +| 2 | 自动汇总正式用车需求草稿 | GET | `/v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement/aggregate-draft` | 错误码触发条件收窄 + 文案改写 | 809121 同上;缺失原因文案同步改写 | +| 3 | 整体确认需求缺失预检 | GET | `/v3/admin/order/group-batch/{groupBatchId}/requirement/confirm-check` | 缺失项触发条件收窄 + 文案改写 | `HOUSEHOLD_REQUIREMENT_NOT_SUBMITTED`(809122)同上 | + +--- + +## 三、接口详情 + +### 1. 保存团期正式用车需求(全量替换) `PUT /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement` + +**VO**: `GroupVehicleRequirementSaveReqVO → GroupVehicleRequirementRespVO` + +#### 使用场景 + +团期需求管理 Tab 的「正式用车需求」编辑弹窗点保存时调用,**整份全量替换**(未出现在本次提交里的分组会被移出当前版本)。权限点 `group-batch:demand:confirm`。本次改动只让 809123 少抛一类情况、并改了它的报文,请求体与响应体一个字段都没动。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `groupBatchId` | Path | Long | ✅ | - | 团期 ID | +| `version` | Body | Integer | ❌ | 乐观锁 | **首次保存传 null**,后续必须回传上次 GET / PUT 拿到的值;不一致抛 809102 | +| `remark` | Body | String | ❌ | `@Size(max=500)` | 整份需求备注 | +| `groups` | Body | Array | ✅ | `@NotNull`(**不是** `@NotEmpty`)、`@Valid` | 全部乘车分组;空数组是合法提交(有需车户时由 809103 拦),整团免车请改走 `waive` 端点 | +| `groups[].groupId` | Body | Long | ❌ | - | 既有分组主键;**新增分组传 null**。带上它 = 声明「就是库里那一组」,此时 `groupCode` 不得变更(改名抛 809104) | +| `groups[].groupCode` | Body | String | ✅ | `@NotBlank`,`@Size(max=32)` | 分组键,直接作为车费 `alloc_group` | +| `groups[].vehicleType` | Body | String | ✅ | `@NotBlank`,`@Size(max=64)` | 车型大类编码,**不是自由文本**;取值权威见 `GET /internal/fleet/vehicle-types/category-names`,不在字典内抛 809119 | +| `groups[].serviceStartDate` | Body | String(`yyyy-MM-dd`) | ✅ | `@NotNull` | 本组服务开始日 | +| `groups[].serviceEndDate` | Body | String(`yyyy-MM-dd`) | ✅ | `@NotNull` | 本组服务结束日(须不早于开始日) | +| `groups[].seats` | Body | Integer | ❌ | `@Min(1)` | 该组单车座位数;**刻意非必填**(存量分组没有该值),与 `count` 必须同填或同空(809118),且须在该车型可选档位内(809124) | +| `groups[].count` | Body | Integer | ❌ | `@Min(1)` | 该组车辆数量;同上 | +| `groups[].specialTags` | Body | Array\ | ❌ | 值须在字典 `vehicle_special_demand` 内 | 特殊诉求标签编码数组;含字典外编码整份拒绝(809117) | +| `groups[].remark` | Body | String | ❌ | `@Size(max=500)` | 该组备注 | +| `groups[].days` | Body | Array | ✅ | `@NotEmpty`,`@Valid` | 逐日用车人数与成员,**不能用单值人数代替** | +| `groups[].days[].tripDate` | Body | String(`yyyy-MM-dd`) | ✅ | `@NotNull` | 团期行程日,须落在本组服务日范围内且不缺日(809105 / 809106) | +| `groups[].days[].headcount` | Body | Integer | ✅ | `@NotNull`,`@Min(1)` | 该组该日**乘车人数**(不是户数);小于当日成员户数抛 809110 | +| `groups[].days[].memberOrderIds` | Body | Array\ | ✅ | `@NotEmpty` | 该组该日实际乘车的子订单集合,须全属本团在团户(809107),同一户同一日只能属一个分组(809108) | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `requirementId` | String | 正式用车需求 ID(雪花,字符串) | +| `groupBatchId` | String | 团期 ID(雪花,字符串) | +| `status` | String | 需求状态 | +| `version` | Integer | 乐观锁版本,下次保存必须回传 | +| `remark` | String | 整份需求备注 | +| `confirmedBy` | String | 确认人 | +| `confirmedAt` | String(datetime) | 确认时间 | +| `planRefreshState` | String | 配车刷新状态(只读投影) | +| `planRefreshReplayCount` | Integer | 配车刷新重投次数 | +| `blockedStage` | String | 被卡住的阶段 | +| `planRefreshStalled` | Boolean | 配车刷新是否已停滞 | +| `planRefreshStalledReason` | String | 停滞原因 | +| `planRefreshTimeoutAt` | String(datetime) | 刷新超时时刻 | +| `planRefreshReplayExhausted` | Boolean | 重投次数是否已用尽 | +| `groups` | Array | 乘车分组回显 | +| `groups[].groupId` / `groupCode` / `vehicleType` / `vehicleTypeName` | String | 分组主键(字符串)、分组键、车型大类编码、车型中文名(按归一 key 取) | +| `groups[].serviceStartDate` / `serviceEndDate` | String(`yyyy-MM-dd`) | 本组服务日范围 | +| `groups[].seats` / `count` / `totalSeatCount` / `maxHeadcount` / `remainingPassengerSeats` | Integer | 单车座位数 / 车辆数 / 总座位 / 最大日人数 / 剩余可载客座位 | +| `groups[].specialTags[]` | Array | `code` + `name`(中文名后端下发,前端不自己映射) | +| `groups[].remark` | String | 该组备注 | +| `groups[].days[]` | Array | `tripDate` / `headcount` / `memberOrderIds`(字符串数组) / `memberOrderCount` | +| `exemptHouseholds` | Array | 豁免户(在团需车、两类需求都没有活跃行、但定制师**提交不了**的户);🔴 **只提交了接送机的户不在这里**——它已提交 | +| `exemptHouseholds[].orderId` | String | 子订单 ID(雪花,字符串) | +| `exemptHouseholds[].teamNo` | String | 团号 | +| `exemptHouseholds[].orderNo` | String | 子订单号 | +| `exemptHouseholds[].reason` | String | `ORDER_NOT_CUSTOMIZING` / `REQUIREMENT_FROZEN` | +| `exemptHouseholds[].reasonName` | String | 豁免原因中文名(后端下发,前端不自己映射) | + +#### 请求示例 + +```json +{ + "version": 3, + "remark": "9/13 起换大巴", + "groups": [ + { + "groupId": null, + "groupCode": "BUS", + "vehicleType": "bus", + "serviceStartDate": "2026-09-12", + "serviceEndDate": "2026-09-16", + "seats": 19, + "count": 1, + "specialTags": ["CHILD_SEAT"], + "remark": "含高速费", + "days": [ + { + "tripDate": "2026-09-12", + "headcount": 9, + "memberOrderIds": [2099459272533323777, 2099459272533323778] + } + ] + } + ] +} +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "requirementId": "2099459272533400001", + "groupBatchId": "2099459272533000001", + "status": "DRAFT", + "version": 4, + "remark": "9/13 起换大巴", + "confirmedBy": null, + "confirmedAt": null, + "planRefreshState": null, + "planRefreshStalled": false, + "groups": [ + { + "groupId": "1867000000009", + "groupCode": "BUS", + "vehicleType": "bus", + "vehicleTypeName": "大巴客车", + "serviceStartDate": "2026-09-12", + "serviceEndDate": "2026-09-16", + "seats": 19, + "count": 1, + "totalSeatCount": 19, + "maxHeadcount": 9, + "remainingPassengerSeats": 9, + "specialTags": [{ "code": "CHILD_SEAT", "name": "儿童座椅" }], + "remark": "含高速费", + "days": [ + { + "tripDate": "2026-09-12", + "headcount": 9, + "memberOrderIds": ["2099459272533323777", "2099459272533323778"], + "memberOrderCount": 2 + } + ] + } + ], + "exemptHouseholds": [] + } +} +``` + +#### 空数据 / 降级响应 + +该团期**只有接送机户、没有任何行程用车户**时,提交零分组不再被 809123 拦(本次改动的直接效果);若团里确实还有需车户,零分组仍由 809103 拦下。`exemptHouseholds` 为空时是**空数组**不是 `null`: + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "requirementId": "2099459272533400001", + "groupBatchId": "2099459272533000001", + "status": "DRAFT", + "version": 1, + "groups": [], + "exemptHouseholds": [] + } +} +``` + +#### 错误响应 + +809123(触发条件已收窄、文案已改写;`{0}` 是团期人话标识,`{2}` 按团号列户、无团号回落订单号、都缺时为「某子订单」,顿号分隔): + +```json +{ + "code": 809123, + "message": "团期「第3期 10月8日出发团」有 2 户尚未提交用车需求,暂不能保存正式用车需求:26-0480、26-0481", + "success": false, + "data": null +} +``` + +其余错误码一条都没变:809100 团期尚未形成正式用车需求 / 809101 状态不允许 / 809102 已被他人修改(乐观锁) / 809103 有需车户却零分组 / 809104 分组重复或试图改名 / 809105 逐日行不在本组服务日范围内或重复 / 809106 缺逐日用车人数 / 809107 成员不属于本团期 / 809108 同一户同一日属多个分组 / 809109 该子订单的某日没有被任何乘车分组覆盖(**仍只认 TRAVEL**) / 809110 用车人数小于当日成员户数 / 809111 团期状态不允许编辑 / 809115 已声明整团免车需先 withdraw / 809116 座位不足 / 809117 特殊诉求标签不在字典内 / 809118 座位数与车辆数须同填或同空 / 809119 车型不在车型字典内 / 809120 车型字典暂不可用 / 809124 座位数不在该车型可选档位内。 + +#### 业务边界 + +- **鉴权**:权限点 `group-batch:demand:confirm`(与整体确认、按户打回、受控重开同码——它们动的是同一个 Tab 里的同一份数据);未登录由网关拦截返 401。 +- **全量替换语义**:未出现在本次提交里的分组会被移出当前版本,不是增量补丁。 +- **🔴 判据变化只在户级**:「这户交了没有」看两类任一;「行程逐日覆盖」(809109)仍只看 TRAVEL,没变。 +- **只提交接送机的户**:不再被 809123 拦、**也不要求被任何乘车分组覆盖**,且**不出现在 `exemptHouseholds` 里**。 +- **豁免户不阻断**:`ORDER_NOT_CUSTOMIZING` / `REQUIREMENT_FROZEN` 两类户不进 809123、不参与 809109,但必须在页面上提示出来(后端已逐户带原因下发)。 +- **错误码文案是可变的**:`message` 只用于展示,判定一律按 `code`。 +- **乐观锁只挡同一瞬间的并发写**:挡不住「A 读了 v3 去改、B 也读了 v3 改完先提交」这种跨请求覆盖。 +- **雪花 ID 一律是字符串**(`requirementId` / `groupBatchId` / `memberOrderIds[]` / `exemptHouseholds[].orderId`)。 + +--- + +### 2. 自动汇总正式用车需求草稿 `GET /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement/aggregate-draft` + +**VO**: `无请求体 → GroupVehicleAggregateDraftRespVO` + +#### 使用场景 + +编辑弹窗点「自动汇总」时调用,按各子订单的活跃 TRAVEL 需求汇总出一份团级草稿,**只读零写入**,返回的 `draft` 可原样 PUT 给上面那个保存端点。权限点与编辑弹窗取数口同码 `group-batch:demand:confirm`。本次改动只让 809121 少抛一类情况并改了它的报文与缺失原因文案。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `groupBatchId` | Path | Long | ✅ | - | 团期 ID | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `groupBatchId` | String | 团期 ID(雪花,字符串) | +| `currentStatus` | String | 当前正式需求状态 | +| `draft` | Object | 汇总出的草稿,结构**与保存端点的请求体逐字段相同**,可原样 PUT | +| `droppedFleetItems` | Array | 多车型户被丢弃的车型项:`orderId` / `teamNo` / `orderNo` / `vehicleType` / `seats` / `count` / `keptVehicleType` / `reason`(`VEHICLE_TYPE_NOT_IN_DICT` 等) | +| `staleHeadcountOrders` | Array | 冻结人数与实时人数不一致的户:`orderId` / `teamNo` / `orderNo` / `frozenHeadcount` / `liveHeadcount` | +| `paddedOrderDays` | Array | 为覆盖出发~返回而补进分组的日期:`orderId` / `teamNo` / `orderNo` / `dates[]` | +| `seatOptionAdjusted` | Array | 座位档被兜底调整的组/户:`groupCode` / `orderId` / `teamNo` / `orderNo` / `vehicleType` / `originalSeats` / `adoptedSeats` / `seatOptions[]` / `reason` | +| `violations` | Array | 草稿已先跑过与保存同一份逐日校验的结果:`code`(对应 809xxx) / `reason` / `detail` / `groupCode` / `tripDate` / `orderId` / `teamNo` | +| `exemptHouseholds` | Array | 豁免户(结构同上一个端点);🔴 **只提交了接送机的户不在这里,也不在草稿里** | + +#### 请求示例 + +```http +GET /v3/admin/order/group-batch/2099459272533000001/vehicle-requirement/aggregate-draft +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "groupBatchId": "2099459272533000001", + "currentStatus": "DRAFT", + "draft": { + "version": 3, + "remark": null, + "groups": [ + { + "groupId": null, + "groupCode": "BUS", + "vehicleType": "bus", + "serviceStartDate": "2026-09-12", + "serviceEndDate": "2026-09-16", + "seats": 19, + "count": 1, + "specialTags": [], + "remark": null, + "days": [ + { + "tripDate": "2026-09-12", + "headcount": 9, + "memberOrderIds": [2099459272533323777] + } + ] + } + ] + }, + "droppedFleetItems": [], + "staleHeadcountOrders": [], + "paddedOrderDays": [], + "seatOptionAdjusted": [], + "violations": [], + "exemptHouseholds": [] + } +} +``` + +#### 空数据 / 降级响应 + +团里只有接送机户、没有任何可汇总的行程用车户时,草稿分组为空数组而**不再抛 809121**(本次改动的直接效果): + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "groupBatchId": "2099459272533000001", + "currentStatus": "DRAFT", + "draft": { "version": null, "remark": null, "groups": [] }, + "droppedFleetItems": [], + "staleHeadcountOrders": [], + "paddedOrderDays": [], + "seatOptionAdjusted": [], + "violations": [], + "exemptHouseholds": [] + } +} +``` + +车型字典取不到时不静默降级,抛 809120 让运营重试(避免把一整份草稿的车型全判成非法)。 + +#### 错误响应 + +809121(触发条件已收窄、文案已改写;`{0}` 是团期名标识,`{2}` 是「户标识:原因」顿号分隔的清单,原因文案里的 `未提交行程用车需求` 已改为 `未提交用车需求`): + +```json +{ + "code": 809121, + "message": "团期 「第3期 10月8日出发团」 有 1 户缺少可汇总的用车需求,暂不能自动汇总:26-0480:未提交用车需求", + "success": false, + "data": null +} +``` + +其余错误码未变:809120 车队车型字典暂不可用 / 809111 团期状态不允许 / 809100 团期尚未形成正式用车需求(视链路)。 + +#### 业务边界 + +- **鉴权**:权限点 `group-batch:demand:confirm`;未登录由网关拦截返 401。 +- **⛔ 本端点零写入**,可安全重复调用;`draft` 是「按现有子订单需求草稿长什么样」,**不保证保存一定能过**——预跑的校验结果在 `violations`。 +- **收窄后的 809121 判据**:需车户「两类用车需求一条都没有」才算信息缺失;只提交接送机的户不算缺少,**也不会出现在草稿里**(团车草稿只汇总 TRAVEL,它本就没有位置)。 +- **缺失原因文案已改**:`未提交行程用车需求` → `未提交用车需求`(另有 `车型均不在车型字典内`、服务日推不出、人数为 0 三类未变)。 +- **诊断字段必须展示**:`droppedFleetItems` / `staleHeadcountOrders` / `paddedOrderDays` / `seatOptionAdjusted` 都是「草稿与用户预期可能不一致」的位置,静默吞掉会让运营看到一份自己没想要的草稿。 +- **雪花 ID 一律是字符串**。 + +--- + +### 3. 整体确认需求缺失预检 `GET /v3/admin/order/group-batch/{groupBatchId}/requirement/confirm-check` + +**VO**: `无请求体 → GroupBatchRequirementCheckRespVO` + +#### 使用场景 + +「查看需求」Tab 进入时与点「确认」前调用,据 `ready` 置灰确认按钮、据 `missing` / `vehicleMissing` 展示缺哪几户。**只读无副作用**。权限点 `group-batch:demand:confirm`。本次改动只让缺失项 `HOUSEHOLD_REQUIREMENT_NOT_SUBMITTED`(809122)少产出一类情况并改了它的报文。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `groupBatchId` | Path | Long | ✅ | - | 团期 ID | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `groupBatchId` | String | 团期 ID(雪花,字符串) | +| `batchStatus` / `batchStatusName` | String | 团期状态编码与中文名 | +| `ready` | Boolean | 是否可以整体确认(置灰按钮用) | +| `missing` | Array | 房侧缺失户:`orderId` / `teamNo` / `orderNo` / `customerName` / `consultantId` / `consultantName` / `reason` / `reasonName` / `dayNumber` / `segmentIndex` / `expectedNights` / `actualNights` | +| `checkedResourceTypes` | Array\ | 恒为 `["HOTEL","VEHICLE"]`;文案已更新为「车侧逐户查**用车需求行**是否提交(#8577 起行程用车与接送机任一有即算已提交)」 | +| `vehicleWaived` | Boolean | 是否已声明整团免车 | +| `vehicleMissing` | Array | 车侧缺失项,见下 | +| `vehicleMissing[].reason` | String | `GROUP_REQUIREMENT_NOT_FOUND` / `GROUP_REQUIREMENT_STATUS_INVALID` / `NO_GROUP` / `GROUP_CODE_INVALID` / `DAY_OUT_OF_GROUP_RANGE` / `DAY_GAP_IN_GROUP_RANGE` / `MEMBER_FOREIGN_ORDER` / `MEMBER_DUPLICATE_DAY` / `ORDER_DAY_UNCOVERED` / `HEADCOUNT_LESS_THAN_MEMBERS` / **`HOUSEHOLD_REQUIREMENT_NOT_SUBMITTED`** / `MEMBER_GROUP_MISMATCH` / `TRANSFER_SERVICE_DATES_NOT_BACKFILLED` / `TRANSFER_WINDOW_INCOMPLETE` | +| `vehicleMissing[].groupCode` | String | 涉及的乘车分组编码;无分组维度时 null | +| `vehicleMissing[].tripDate` | String(`yyyy-MM-dd`) | 涉及的日期;无日期维度时 null | +| `vehicleMissing[].orderId` | String | 涉及的子订单 ID(雪花,字符串);无订单维度时 null | +| `vehicleMissing[].teamNo` / `orderNo` | String | 团号 / 子订单号快照 | +| `vehicleMissing[].detail` | String | 人话描述,**与整团确认时抛出的错误报文逐字相同**,可直接展示 | +| `vehicleExemptHouseholds` | Array | 车侧豁免户(结构同前两个端点);🔴 **只提交了接送机的户不在这里** | +| `groupVehicleRequirementId` | String | 团级正式用车需求 ID(雪花,字符串) | +| `groupVehicleRequirementStatus` | String | 团级正式用车需求状态 | +| `groupVehicleRequirementVersion` | Integer | 团级正式用车需求版本 | +| `transferSubmitEnabled` | Boolean | 接送机提交灰度开关当前状态 | +| `transferDeclaredWithoutRequirement` | Array | 声明了接送机却没有活跃 TRANSFER 行的户:`orderId` / `teamNo` / `orderNo` / `customerName` / `consultantId` / `consultantName` / `pickupRequired` / `dropoffRequired` / `pickupRemark` | + +#### 请求示例 + +```http +GET /v3/admin/order/group-batch/2099459272533000001/requirement/confirm-check +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "groupBatchId": "2099459272533000001", + "batchStatus": "RESOURCE_PREPARING", + "batchStatusName": "资源准备中", + "ready": false, + "missing": [], + "checkedResourceTypes": ["HOTEL", "VEHICLE"], + "vehicleWaived": false, + "vehicleMissing": [ + { + "reason": "HOUSEHOLD_REQUIREMENT_NOT_SUBMITTED", + "groupCode": null, + "tripDate": null, + "orderId": "2099459272533323779", + "teamNo": "26-0482", + "orderNo": "HL2606010003", + "detail": "该户尚未提交用车需求,请先让定制师提交后再整团提交车务" + } + ], + "vehicleExemptHouseholds": [], + "groupVehicleRequirementId": "2099459272533400001", + "groupVehicleRequirementStatus": "DRAFT", + "groupVehicleRequirementVersion": 4, + "transferSubmitEnabled": true, + "transferDeclaredWithoutRequirement": [] + } +} +``` + +#### 空数据 / 降级响应 + +全部就绪时 `ready=true`,三个清单都是**空数组**不是 `null`: + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "groupBatchId": "2099459272533000001", + "batchStatus": "RESOURCE_PREPARING", + "batchStatusName": "资源准备中", + "ready": true, + "missing": [], + "checkedResourceTypes": ["HOTEL", "VEHICLE"], + "vehicleWaived": false, + "vehicleMissing": [], + "vehicleExemptHouseholds": [], + "transferDeclaredWithoutRequirement": [] + } +} +``` + +#### 错误响应 + +本端点是只读预检,把缺失**列成清单**而不是抛码;仍可能出现的错误只有权限与团期不存在两类: + +```json +{ + "code": 403, + "message": "无权限执行该操作", + "success": false, + "data": null +} +``` + +#### 业务边界 + +- **鉴权**:权限点 `group-batch:demand:confirm`;未登录由网关拦截返 401。 +- **⛔ 只读无副作用**,可随页面进入反复调用。 +- **它是缺失明细的唯一来源**:整团确认失败时抛出的 589533 只带汇总户数,逐户明细只能从本端点取。 +- **收窄后的 `HOUSEHOLD_REQUIREMENT_NOT_SUBMITTED` 判据**:在团需车户**两类用车需求都没提交**才产出;只提交接送机的户不产出,**也不进 `vehicleExemptHouseholds`**。 +- **`detail` 与错误报文逐字相同**:所以它也跟着改了文案(`行程用车需求` → `用车需求`),前端不要做子串匹配。 +- **`ORDER_DAY_UNCOVERED`(809109)仍只认 TRAVEL**:只提交接送机的户不会因为「没被任何乘车分组覆盖」出现在这里。 +- **`transferDeclaredWithoutRequirement` 里两个 flag 都为 false 是合法组合**:该户的声明落在 `direction` 为空或不在 ARRIVAL/DEPARTURE 两值内的批次上,仍确实声明了接送机,前端照常展示、不要过滤掉。 +- **雪花 ID 一律是字符串**。 + +--- + +## 四、契约约束与正确调用方式 + +> 本节只写后端接受 / 拒绝 payload 的规则,不写 UI 渲染建议。 + +### ✅ 正确 / ❌ 错误 payload 对照(保存端点) + +| 场景 | payload | +|------|---------| +| ✅ 首次保存(无版本) | `{ "version": null, "groups": [ { "groupCode": "BUS", "vehicleType": "bus", "serviceStartDate": "2026-09-12", "serviceEndDate": "2026-09-16", "days": [ { "tripDate": "2026-09-12", "headcount": 9, "memberOrderIds": [2099459272533323777] } ] } ] }` | +| ✅ 改既有组(带 groupId,groupCode 不变) | `{ "version": 3, "groups": [ { "groupId": 1867000000009, "groupCode": "BUS", ... } ] }` | +| ✅ 座位与车辆数同空(存量分组) | `{ ..., "seats": null, "count": null }` | +| ✅ 团里只有接送机户 → 提交零分组 | `{ "version": null, "groups": [] }` → 200(改前该团常因某户「只交了接送机」撞 809123) | +| ❌ `groups` 传 null | `{ "version": 3, "groups": null }` → 400「乘车分组列表不能为 null(整团免车请改用 waive 端点)」 | +| ❌ 带 groupId 却改了 groupCode | `{ "groupId": 1867000000009, "groupCode": "BUS2", ... }` → 809104 | +| ❌ 只填 seats 不填 count | `{ "seats": 19, "count": null }` → 809118 | +| ❌ 车型填自由文本 | `{ "vehicleType": "35座大巴" }` → 809119 | + +### 前端必须做的一处改动 + +- 🔴 **凡是对 809121 / 809122 / 809123 的 `message`(或预检 `vehicleMissing[].detail`)做过字符串包含判断的地方,一律改成按 `code` / `reason` 分支**。三条报文里的 `行程用车需求` 已改为 `用车需求`,旧的子串匹配会静默失配(不报错,只是那条分支再也不进)。 +- 其余全部字段、校验规则、请求格式不变,不需要任何别的适配。 + +--- + +## 五、数据库行为 + +只有保存端点(PUT)是写端点,本次改动**没有任何表结构或写入语义变化**——变的是写之前那道户级阻断的判据。 + +| 场景 | 改前 | 改后 | +|------|------|------| +| 某户只有活跃 TRANSFER 行,团级 PUT 提交 | 809123 整份拒绝,**零写入** | 正常落库(该户不需要被任何分组覆盖) | +| 某户两类都没有活跃行且提交得了 | 809123 整份拒绝,零写入 | 未变,仍 809123 零写入 | +| 某户两类都没有活跃行但提交不了(豁免户) | 不阻断,列入 `exemptHouseholds` | 未变 | +| 正常提交 | 全量替换:本次未出现的分组移出当前版本、版本号 +1 | 未变 | + +**失败零写入**:809123 抛在乐观锁比对与分组改名守卫之后、任何写入之前,整份拒绝不留半份数据。 + +自动汇总(GET)与确认预检(GET)两个端点**零写入**,本次未改变这一点。 + +--- + +## 六、边界行为 + +- 未登录 → 401(网关拦截)。 +- 权限点 `group-batch:demand:confirm` 缺失 → 403。 +- 团期尚未形成正式用车需求 → 809100(报文用「该团期」,不带雪花 id)。 +- 正式需求已被他人修改 → 809102,带提交版本与当前版本。 +- 团期已过配置阶段 → 809111。 +- 已声明整团免车又提交分组 → 809115(需先 withdraw 回草稿)。 +- 车队车型字典不可用 → 809120(不静默降级,让运营重试)。 +- 老数据兼容:存量分组没有 `seats` / `count`,编辑时原样回传 null 不会 400;库里被 `V20260924_402` 归一过的车型可正常回显,归一认不出的历史自由文本原样保留,但**再提交一次仍会被 809119 拒**——编辑态请把字典外的当前值显式标出提示重选,不要渲染成空。 +- 推不出服务日的户(`departDate` / `returnDate` 任一为空)跳过 809109 覆盖判定(已知盲区,不是遗漏)。 + +--- + +## 六.5、枚举 / 数据字典 + +### reason(车侧缺失项原因码) + +**所属字段**: `GroupBatchRequirementCheckRespVO.vehicleMissing[].reason` | **类型**: `String` + +| 值 | 中文 | 说明 | +|----|------|------| +| `GROUP_REQUIREMENT_NOT_FOUND` | 团级正式需求未形成 | 对应 809100 | +| `GROUP_REQUIREMENT_STATUS_INVALID` | 团级正式需求状态不允许 | 对应 809101 | +| `NO_GROUP` | 有需车户却零分组 | 对应 809103 | +| `GROUP_CODE_INVALID` | 分组编码重复或改名 | 对应 809104 | +| `DAY_OUT_OF_GROUP_RANGE` | 逐日行不在本组服务日范围内 | 对应 809105 | +| `DAY_GAP_IN_GROUP_RANGE` | 本组服务日范围内缺日 | 对应 809106 | +| `MEMBER_FOREIGN_ORDER` | 成员不属于本团期 | 对应 809107 | +| `MEMBER_DUPLICATE_DAY` | 同一户同一日属多个分组 | 对应 809108 | +| `ORDER_DAY_UNCOVERED` | 该户某日未被任何分组覆盖 | 对应 809109;🔴 **仍只认 TRAVEL,本次未改** | +| `HEADCOUNT_LESS_THAN_MEMBERS` | 用车人数小于当日成员户数 | 对应 809110 | +| `HOUSEHOLD_REQUIREMENT_NOT_SUBMITTED` | 该户尚未提交用车需求 | 对应 809122;🔴 **#8577 收窄:行程用车与接送机任一有即不报**。只带 `orderId` / `orderNo`,处置是催该户定制师提交 | +| `MEMBER_GROUP_MISMATCH` | 该户车型与覆盖它的分组车型不符 | 对应 809125;排在户级未提交之后(交都没交的户没有车型可比) | +| `TRANSFER_SERVICE_DATES_NOT_BACKFILLED` | 待放行的接送机需求未回填服务日 | 对应 809007,只带 `orderId` | +| `TRANSFER_WINDOW_INCOMPLETE` | 接送机需求窗没盖住大交通派生日期 | 对应 809126,带 `orderId` / `orderNo` 与首个越窗日期 | + +### reason(团级用车需求豁免户原因码) + +**所属字段**: `exemptHouseholds[].reason`、`vehicleExemptHouseholds[].reason` | **类型**: `String` + +| 值 | 中文 | 说明 | +|----|------|------| +| `ORDER_NOT_CUSTOMIZING` | 订单不在定制中 | 定制师提交会被 582017 拒,所以该户不算「没交」 | +| `REQUIREMENT_FROZEN` | 团期已过资源准备、需求已冻结且该户未被打回 | 定制师提交会被 589536 拒 | + +🔴 **只提交了接送机的户不属于任何一档**——它已提交,既不进未提交名单也不进豁免名单。 + +### 用车需求类别(判定用,不直接出现在本次三个响应的字段里) + +| 值 | 中文 | 在本次判定中的角色 | +|----|------|-------------------| +| `TRAVEL` | 团期行程用车 | 汇总进团级乘车分组;**809109 逐日覆盖只认它** | +| `TRANSFER` | 接送机 | 走逐户派车、结构上在团级乘车分组之外;#8577 起它也算「已提交用车需求」,参与 809121 / 809122 / 809123 的判定 | + +--- + +## 六.6、修改前后对比 + +### 字段级对比 + +| 字段 | 改前 | 改后 | +|------|------|------| +| 三个端点的全部请求字段 | — | 未变(一个都没动) | +| 三个端点的全部响应字段 | — | 未变(无新增、无删除、无改名、无类型变化) | +| `checkedResourceTypes` 的字段说明文案 | 「车侧逐户查**行程**用车需求行是否提交」 | 「车侧逐户查用车需求行是否提交(#8577 起行程用车与接送机任一有即算已提交)」 | +| `vehicleMissing[].reason` 的取值集合 | 14 个 | 未变(仍 14 个,只是其中一个的触发条件收窄) | +| `exemptHouseholds` 的成员判据 | 在团需车 ∧ 无 active TRAVEL ∧ 提交不了 | 在团需车 ∧ **两类都无 active 行** ∧ 提交不了 | + +### 行为级对比 + +| 行为 | 改前 | 改后 | +|------|------|------| +| 某户只提交了接送机,团级 PUT 保存 | 809123 整份拒绝,运营无干净出路 | 正常保存 | +| 某户只提交了接送机,点自动汇总 | 809121 整团出不来草稿 | 正常出草稿(该户不进草稿,也不进缺失清单) | +| 某户只提交了接送机,进确认预检 | 该户挂 `HOUSEHOLD_REQUIREMENT_NOT_SUBMITTED`,`ready=false` | 不产出该缺失项 | +| 某户只提交了接送机,是否要求被乘车分组覆盖 | 会走到 809109 | 不要求(它结构上在团级分组之外) | +| 809121 报文 | `团期 {0} 有 {1} 户缺少可汇总的**行程**用车需求…` | `…缺少可汇总的用车需求…` | +| 809122 报文 | `该户尚未提交**行程**用车需求,…` | `该户尚未提交用车需求,…` | +| 809123 报文 | `{0}有 {1} 户尚未提交**行程**用车需求,…` | `{0}有 {1} 户尚未提交用车需求,…` | +| 汇总缺失原因文案 | `未提交行程用车需求` | `未提交用车需求` | +| 809109 逐日覆盖的判据 | 只认 TRAVEL | 未变,仍只认 TRAVEL | +| 两类都没提交的户 | 三处照旧阻断 | 未变 | + +--- + +## 六.7、影响评估 + +- **是否破坏向后兼容**: 否(无字段增删改;只是三个错误码少抛一类情况、报文文案改写) +- **前端是否必须同步上线**: 否;但**若前端对这三条报文做过字符串包含判断,必须改**(改成按 `code` / `reason` 分支),否则那条分支会静默失配 +- **前端 workaround 清理点**: 若为绕开「纯接送机户卡住整团」在页面上加过提示、屏蔽过确认按钮、或引导过运营去整团免车,可以撤掉 + +--- + +## 七、不影响范围 + +- **仅影响**: 团期需求管理 Tab 的三个端点里 809121 / 809122 / 809123 的触发条件与报文文案。 +- **零影响**: + - 809109 逐日覆盖判定(仍只认 TRAVEL) + - 整体确认端点 `POST .../requirement/confirm` 自身的确认逻辑与响应字段 + - 受控重开、整份撤回、整团免车、按户打回四个端点 + - 接送机批量确认 `POST .../requirement/transfer/batch-confirm` + - 户级用车需求的提交 / 编辑 / 打回链路 + - 车务侧(hl-fleet-service)的配车、派单、就绪判定 + - 历史数据:不做任何迁移,存量团期下次调用时按新判据生效 + +--- + +## 八、测试环境已验证 + +- **代码事实**(对 `origin/dev-v3` 逐一查证): + - 合并提交 `5b7074e691`(PR #8600 squash 合并进 `dev-v3`),17 文件 / +559 −137。 + - `GroupVehicleRequirementErrorCode` 三条 `IErrorCode.of` 的字面量 diff 已逐字核对(809121 / 809122 / 809123 各去掉「行程」二字),码值与常量名未变。 + - `GroupVehicleDraftAggregator.MISSING_NOT_SUBMITTED` 由 `未提交行程用车需求` 改为 `未提交用车需求`;`Household` record 新增 `boolean transferSubmitted` 位,判缺失处改为 `household.needsVehicle() && !household.transferSubmitted()`。 + - `classifyTravelSubmission` 更名为 `classifyVehicleSubmission`,`VehicleSubmission` 内 `travelSubmittedOrderIds` 与 `anySubmittedOrderIds` 是两个分开的字段——809109 用前者、三码用后者,合并会把墙挪到 809109。 + - 三个端点的 Controller 签名、`@RequestBody` VO、响应 VO 字段清单逐一核对,确认零字段变化。 + - 回归钉在 `GroupVehicleRequirementValidateTest#save_frozenRejectedButTransferSubmitted_noLongerThrows809123` 等用例上(本 PR 新增 / 改写测试 6 个文件、+400 余行)。 +- **部署**:`hl-order-service-v3` 的 `dev-v3` 分支已滚到测试服,三个端点走管理端网关 `/v3/admin/order/**` 既有路由,无新增路由。 + +``` +PUT /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement → 200 ✓(纯接送机户不再触发 809123) +GET /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement/aggregate-draft → 200 ✓(纯接送机户不再触发 809121) +GET /v3/admin/order/group-batch/{groupBatchId}/requirement/confirm-check → 200 ✓(不再产出 HOUSEHOLD_REQUIREMENT_NOT_SUBMITTED) +``` + +--- + +## 九、相关历史 PR + +| PR | Issue | 说明 | 是否仍有效 | +|----|-------|------|------------| +| — | #7441 | 团期正式用车需求首次落地(809100-809115 段) | ✅ 有效 | +| — | #8219 | 有户未提交时阻断团级 PUT,新开 809123 与豁免户机制 | ✅ 有效(本单在其基础上收窄判据) | +| — | #8220 | 自动汇总草稿端点与 809121 | ✅ 有效 | +| — | #8249 | 预检加户级 809122 | ✅ 有效 | +| — | #8306 | 报文按团号列户、不出现雪花 id | ✅ 有效 | +| **本 PR #8600** | **#8577** | 户级提交判定与行程覆盖判定分离,三码收窄 + 文案改写 | ✅ 最新 | + +--- + +## 十、相关文档 + +- 关联 Issue: [wx/HL#8577](https://git.1814.love:8443/wx/HL/issues/8577) +- 关联 PR: [wx/HL#8600](https://git.1814.love:8443/wx/HL/pulls/8600) + +## 关联 / 联系人 + +### 链接 + +- **Issue**: [#8577](https://git.1814.love:8443/wx/HL/issues/8577) +- **PR**: [#8600](https://git.1814.love:8443/wx/HL/pulls/8600) +- **Merge commit**: [5b7074e691](https://git.1814.love:8443/wx/HL/commit/5b7074e691) + +### 联系人 + +- **后端负责人**: @wx diff --git a/changelogs-v2/2026-09/30_8601_逐户提交车务与打回的kind参数取消默认值-修改接口-管理后台.md b/changelogs-v2/2026-09/30_8601_逐户提交车务与打回的kind参数取消默认值-修改接口-管理后台.md new file mode 100644 index 00000000..0e3d3e57 --- /dev/null +++ b/changelogs-v2/2026-09/30_8601_逐户提交车务与打回的kind参数取消默认值-修改接口-管理后台.md @@ -0,0 +1,432 @@ +--- +schema: "hl-changelog/v2" +ticket: "8601" +title: "逐户提交车务 / 打回的 kind 参数取消默认值 TRAVEL,两类活跃需求并存时必须显式指定(新错误码 809012)" +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: "PR #8610 已 squash 合并 dev-v3(a2ba628ecb),hl-order-service-v3 dev-v3 分支已滚测试服。这是需要前端改调用代码的变更:两个端点的 kind 查询参数从 defaultValue=TRAVEL 改成无默认值,纯接送机户由此可用,两类并存且不传 kind 时新抛 809012。" +updated_at: "2026-09-30" +base: "dev-v3" +--- + +# hl-order-service-v3: 逐户提交车务 / 打回的 `kind` 参数取消默认值 `TRAVEL` + +> **存放目录**: `changelogs-v2/2026-09/` +> **服务**: hl-order-service-v3 (端口 8086) +> **PR**: #8610 +> **Issue**: #8601 +> **日期**: 2026-09-30 +> **影响范围**: 团期需求管理 Tab 的逐户「提交车务」与「打回定制师」两个按钮所调的端点,其 `kind` 查询参数语义 + +--- + +## ⚠️ 关键变化 + +- 🔴 **这是需要前端改调用代码的变更**:两个端点的 `kind` 查询参数由 `@RequestParam(defaultValue = "TRAVEL")` 改为 `@RequestParam(required = false)`,**没有默认值了**。 +- **前端以前可以怎么写**:不传 `kind`,后端按 `TRAVEL` 处理。**现在的实际行为**:不传 `kind` 时后端按该户**活跃用车需求的类别数**自动解析—— + - **恰好 1 类** → 就用那一类(🆕 **纯接送机户从此可用**:旧默认值会去找一条根本不存在的 TRAVEL 行,导致这类户在这两个入口走不通流程); + - **0 类** → 与改前一致,由既有分支抛 582031「订单无有效需求行」; + - **≥2 类并存** → 🆕 抛新错误码 **809012**,拒绝猜测。 +- 🔴 **809012 是新增错误码**,前端必须接住:`订单 {0} 同时存在 {1} 两类活跃用车需求,请显式指定要操作的类别(kind=TRAVEL 行程用车 / kind=TRANSFER 接送机)`。撞到它的正确处置是**带上 `kind` 重发**(由用户选,或由页面上下文决定),不是重试。 +- **前端要做的事**:这两个按钮所在的位置本来就知道自己在操作哪一类需求(页面上就是按 TRAVEL / TRANSFER 分开展示的),**一律显式带上 `kind`** 即可,带了就不会撞 809012。传了值的行为与改前逐字相同(含非法值仍由 809000 拒)。 +- **请求体、响应体、权限、HTTP 形态全部未变**:两个端点仍是 `Result`,`dispatchRemark` 仍选填、`returnRemark` 仍必填。 + +--- + +## 一、背景 + +一户订单的用车需求按类别分行,两类可以同时活跃: + +| 类别 | 含义 | +|------|------| +| `TRAVEL` | 团期行程用车 | +| `TRANSFER` | 接送机 | + +这两个端点都按 `kind` **精确定位一行**再迁移状态、写备注。`defaultValue = "TRAVEL"` 让「调用方没说要动哪一类」与「调用方明确要动 TRAVEL」在服务层**完全同形**——两类并存而调用方没传 `kind` 时,接口返 200、改掉 TRAVEL 行,而操作者想动的 TRANSFER 行三个字段一个都没变,**响应上没有任何可区分的信号**。这是静默错写。 + +同一个默认值还造成第二个缺陷:只有 TRANSFER 活跃行的户,旧逻辑会去找一条不存在的 TRAVEL 行,拿到 582031,**这类户在这两个入口根本用不了**。 + +解析只写在服务层一处(`RequirementService#resolveVehicleRequirementKind`),Controller 不做兜底,避免两层各写一份「空了怎么办」而日后分叉。类别数与后续取行**同源**:数的是 `selectAllActiveByOrderId`,而它的实现就是对 `selectLatestByOrderId(orderId, kind)` 按枚举逐类别循环,所以「数出几类」与「按那一类取到哪行」用的是同一个筛选条件,不可能分叉。 + +> 与结算侧 809008 有意不同:那边只有 TRAVEL 才自动解析、单独一条 TRANSFER 也拒绝(手录车费的省略更可能是漏选归属);本处是需求状态机写口,单 TRANSFER 户只有这一条活跃需求,拒绝它等于让这类户走不通流程。 + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 团期管理员提交车务 | POST | `/v3/admin/order/{id}/vehicle-requirement/dispatch` | 查询参数取消默认值 + 新增错误码 | `kind` 不再默认 TRAVEL;两类并存且不传抛 809012 | +| 2 | 团期管理员打回定制师(车需求) | POST | `/v3/admin/order/{id}/vehicle-requirement/reject` | 查询参数取消默认值 + 新增错误码 | 同上 | + +--- + +## 三、接口详情 + +### 1. 团期管理员提交车务 `POST /v3/admin/order/{id}/vehicle-requirement/dispatch` + +**VO**: `DispatchReqVO → Result` + +#### 使用场景 + +团期需求管理 Tab 里对某一户点「提交车务」时调用,把该户指定类别的用车需求从 `PENDING_REVIEW` 推进到 `PENDING` 并写入提交备注(提供给车队人员查看)。**仅团期子订单可用**。权限点 `group-batch:demand:confirm`。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `id` | Path | Long | ✅ | - | 子订单 ID | +| `kind` | Query | String | ❌ | 取值 `TRAVEL` / `TRANSFER` | 🔴 **改动点**:不再有默认值 `TRAVEL`。不传时按该户活跃需求类别自动解析(恰好 1 类用那一类;0 类抛 582031;≥2 类抛 809012)。**建议一律显式传**。非法值仍抛 809000 | +| `dispatchRemark` | Body | String | ❌ | `@Size(max=500)` | 提交备注,提供给车队的审核意见;上限对齐库列宽 `VARCHAR(500)` | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `code` | Integer | 200 = 成功 | +| `message` | String | `成功` | +| `success` | Boolean | `true` | +| `data` | null | **本端点无业务数据返回**(`Result`),成功即以 `code=200` 为准,不要读 `data` | + +#### 请求示例 + +```http +POST /v3/admin/order/2099459272533323777/vehicle-requirement/dispatch?kind=TRANSFER +Content-Type: application/json + +{ "dispatchRemark": "需求已确认,请尽快派接送机车辆" } +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": null +} +``` + +#### 空数据 / 降级响应 + +本端点恒无业务数据,成功时 `data` 恒为 `null`——这是正常成功形态,不是空数据降级: + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": null +} +``` + +该户没有任何活跃用车需求行时不降级、不静默成功,直接抛 582031(下面的错误响应)。 + +#### 错误响应 + +🆕 809012(本次新增;`{0}` = 订单 ID,`{1}` = 两类名以 ` / ` 连接): + +```json +{ + "code": 809012, + "message": "订单 2099459272533323777 同时存在 TRAVEL / TRANSFER 两类活跃用车需求,请显式指定要操作的类别(kind=TRAVEL 行程用车 / kind=TRANSFER 接送机)", + "success": false, + "data": null +} +``` + +其余错误码未变: + +| 码 | 报文 | 触发 | +|----|------|------| +| 809000 | `用车需求类别非法:{0}` | `kind` 传了 TRAVEL / TRANSFER 之外的值 | +| 809007 | `接送机需求 {0} 的服务日期尚未回填,无法下发车务` | 解析到 TRANSFER 但该行 `service_dates` 为 NULL 或空数组 | +| 582031 | `订单无有效需求行` | 该户按解析出的类别取不到活跃行(含「一条都没有」) | +| 582083 | `需求状态不允许此操作,请检查当前状态` | 非团期子订单,或最新需求不在 `PENDING_REVIEW` | + +#### 业务边界 + +- **鉴权**:权限点 `group-batch:demand:confirm`(Controller 入口执行,走 user-service Feign);未登录由网关拦截返 401。 +- **仅团期子订单**:非团期单先报 582083,`kind` 解析排在这道守卫**之后**——所以非团期单的报错与改前逐字相同,不会变成 809012。 +- **解析只在不传 `kind` 时发生**:传了值就原样使用,包括非法值(仍由 809000 拒),本次改动不改变既有的非法值行为。 +- **🔴 809012 不是可重试错误**:同一请求重发多少次都是同一个码。处置是**带上 `kind` 重发**。 +- **纯接送机户现在可用**:只有一条活跃 TRANSFER 行时不传 `kind` 会被解析成 TRANSFER(改前拿 582031)。 +- **状态机守卫未变**:`PENDING_REVIEW → PENDING`,同时写 `dispatch_remark`、车控置 `PENDING`、CAS 退流程。 +- **失败零写入**:809012 抛在取行之前、任何写入之前;809007 抛在服务日校验处,同样不落写。 + +--- + +### 2. 团期管理员打回定制师(车需求) `POST /v3/admin/order/{id}/vehicle-requirement/reject` + +**VO**: `RejectReqVO → Result` + +#### 使用场景 + +团期需求管理 Tab 里对某一户点「打回」时调用,把该户指定类别的用车需求退回定制师重提(`PENDING_REVIEW` / `PENDING` → `REJECTED_TO_CONSULTANT`),写入打回备注,并**同时清掉团级 `requirement_confirmed` 标记 + 写团级时间线**(否则会出现「该户未提交、整团已确认」的矛盾态)。权限点 `group-batch:demand:confirm`。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `id` | Path | Long | ✅ | - | 子订单 ID | +| `kind` | Query | String | ❌ | 取值 `TRAVEL` / `TRANSFER` | 🔴 **改动点**:不再有默认值 `TRAVEL`,规则与 dispatch 端点逐条相同。**建议一律显式传** | +| `returnRemark` | Body | String | ✅ | `@NotBlank`,`@Size(max=500)` | 打回备注;为空返 400「打回/驳回备注不能为空」,超长返 400「打回/驳回备注不能超过 500 字」。定制师重新提交时会创建新需求 | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `code` | Integer | 200 = 成功 | +| `message` | String | `成功` | +| `success` | Boolean | `true` | +| `data` | null | **本端点无业务数据返回**(`Result`) | + +#### 请求示例 + +```http +POST /v3/admin/order/2099459272533323777/vehicle-requirement/reject?kind=TRAVEL +Content-Type: application/json + +{ "returnRemark": "行程日与团期不符,请定制师重新确认用车日期" } +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": null +} +``` + +#### 空数据 / 降级响应 + +本端点恒无业务数据,成功时 `data` 恒为 `null`: + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": null +} +``` + +打回既要写需求行、又要清团级标记并写团级时间线,是跨聚合编排且与整团确认共用团级锁——**不存在「只做了一半」的降级形态**,要么整套生效要么整体回滚。 + +#### 错误响应 + +🆕 809012(本次新增,与 dispatch 端点同码同文案): + +```json +{ + "code": 809012, + "message": "订单 2099459272533323777 同时存在 TRAVEL / TRANSFER 两类活跃用车需求,请显式指定要操作的类别(kind=TRAVEL 行程用车 / kind=TRANSFER 接送机)", + "success": false, + "data": null +} +``` + +其余错误码未变: + +| 码 | 报文 | 触发 | +|----|------|------| +| 809000 | `用车需求类别非法:{0}` | `kind` 传了非法值 | +| 582031 | `订单无有效需求行` | 按解析出的类别取不到活跃行 | +| 582083 | `需求状态不允许此操作,请检查当前状态` | 非团期子订单,或最新需求不在 `PENDING_REVIEW` / `PENDING` | +| 589535 | `子订单 {0} 已分房,请先由房务调整配房后再打回` | 该户仍有活跃派单/配房占用 | +| 400 | `打回/驳回备注不能为空` | `returnRemark` 空 | + +#### 业务边界 + +- **鉴权**:权限点 `group-batch:demand:confirm`;未登录由网关拦截返 401。 +- **🔴 解析排在占用探测之前**:`kind` 先解析、再用解析结果去做「有没有活跃占用」的探测——两处必须看同一个类别,否则探测与实写会错位。 +- **只对车需求解析**:同一条服务方法也承接房需求打回(`resourceType=HOTEL`),`kind` 对它无意义、不触发解析,**酒店打回不会被车侧的两类并存误伤**。 +- **副作用是跨聚合的**:除需求行外还会清团级 `requirement_confirmed` 并写团级时间线 `BATCH_REQUIREMENT_REJECT`,与批量打回落同一套副作用。 +- **打回后需求要重提**:定制师重新提交会创建**新的需求行**,不是在原行上改。 +- **🔴 809012 不是可重试错误**:处置是带上 `kind` 重发。 +- **失败零写入**:809012 抛在探测与实写之前。 + +--- + +## 四、契约约束与正确调用方式 + +> 本节只写后端接受 / 拒绝请求的规则,不写 UI 渲染建议。 + +### ✅ 正确 / ❌ 错误 调用对照 + +| 场景 | 请求 | 结果 | +|------|------|------| +| ✅ 显式指定行程用车(**推荐写法**) | `POST /v3/admin/order/{id}/vehicle-requirement/dispatch?kind=TRAVEL` + `{ "dispatchRemark": "..." }` | 200 | +| ✅ 显式指定接送机(**推荐写法**) | `POST /v3/admin/order/{id}/vehicle-requirement/dispatch?kind=TRANSFER` + `{ "dispatchRemark": "..." }` | 200 | +| ✅ 不传 kind,该户只有一类活跃需求 | `POST /v3/admin/order/{id}/vehicle-requirement/dispatch` | 200,按那一类处理(纯接送机户从此可用) | +| ✅ 打回(备注必填) | `POST /v3/admin/order/{id}/vehicle-requirement/reject?kind=TRAVEL` + `{ "returnRemark": "请重新确认用车日期" }` | 200 | +| ❌ 不传 kind,该户两类活跃需求并存 | `POST /v3/admin/order/{id}/vehicle-requirement/reject` | **809012**,必须带 kind 重发 | +| ❌ kind 传非法值 | `?kind=travel2` | 809000 | +| ❌ 打回不带备注 | `{ "returnRemark": "" }` | 400「打回/驳回备注不能为空」 | +| ❌ 备注超 500 字 | `{ "returnRemark": "<501 字>" }` | 400「打回/驳回备注不能超过 500 字」 | + +### 前端必须做的改动 + +1. **两个端点的调用一律显式带上 `kind`**。页面上这两个按钮本来就分挂在 TRAVEL / TRANSFER 两块需求下,取值是现成的;带上之后永远不会撞 809012。 +2. **接住 809012**:若某处确实拿不到类别,撞到 809012 时要提示用户选择类别并**带上 `kind` 重发**,不要做自动重试(同一请求重发永远同码)。 +3. **不要再依赖「不传 = TRAVEL」这个隐含约定**——它已经不成立了。 + +--- + +## 五、数据库行为 + +本次改动**没有任何表结构变化**,变的是「写哪一行」的定位规则。 + +| 前端调用 | 该户活跃需求 | 改前写入 | 改后写入 | +|----------|--------------|----------|----------| +| 不传 `kind` | 只有 TRAVEL | TRAVEL 行 | TRAVEL 行(未变) | +| 不传 `kind` | 只有 TRANSFER | ❌ 取不到 TRAVEL 行 → 582031,零写入 | ✅ **TRANSFER 行** | +| 不传 `kind` | TRAVEL + TRANSFER 并存 | ❌ **静默写 TRAVEL 行**(返 200,操作者要动的那行三个字段全不变) | ✅ **809012 拒绝,零写入** | +| 不传 `kind` | 一条活跃行都没有 | 582031,零写入 | 582031,零写入(未变) | +| 传 `kind=TRAVEL` | 任意 | TRAVEL 行 | TRAVEL 行(未变) | +| 传 `kind=TRANSFER` | 任意 | TRANSFER 行 | TRANSFER 行(未变) | + +写入内容本身未变: + +- **dispatch**:`order_vehicle_requirement` 该行 `status` `PENDING_REVIEW → PENDING`、写 `dispatch_remark`(≤500)、车控置 `PENDING`、CAS 退流程。 +- **reject**:该行 `status → REJECTED_TO_CONSULTANT`、写 `return_remark`(≤500);同事务清团级 `requirement_confirmed`、写团级时间线 `BATCH_REQUIREMENT_REJECT`。 + +**失败零写入**:809012 抛在解析阶段(dispatch 里排在团期守卫之后、取行之前;reject 里排在占用探测之前),任何一条数据都不会落。 + +--- + +## 六、边界行为 + +- 未登录 → 401(网关拦截)。 +- 权限点 `group-batch:demand:confirm` 缺失 → 403。 +- 非团期子订单 → 582083(这道守卫排在 `kind` 解析之前,报错与改前逐字相同)。 +- 需求状态不在允许的源状态集合 → 582083。 +- 该户按解析出的类别取不到活跃行 → 582031。 +- 解析到 TRANSFER 但服务日未回填 → 809007(dispatch 端点,失败关闭不放行)。 +- 打回时该户仍有活跃占用 → 589535。 +- `kind` 传非法值 → 809000(与改前一致,本次未改变非法值行为)。 +- 老数据兼容:不改表、不迁移;存量订单下次调用时按新解析规则生效。 + +--- + +## 六.5、枚举 / 数据字典 + +### kind(用车需求类别) + +**所属字段**: 两个端点的查询参数 `kind` | **类型**: `String` + +| 值 | 中文 | 说明 | +|----|------|------| +| `TRAVEL` | 团期行程用车 | 汇总进团级乘车分组、整团逐日配车的那一类 | +| `TRANSFER` | 接送机 | 逐户派车的那一类;服务日由大交通派生,未回填时 dispatch 抛 809007 | +| (不传) | — | 🔴 **不再等价于 `TRAVEL`**。按该户活跃需求类别数解析:1 类用那一类 / 0 类抛 582031 / ≥2 类抛 809012 | +| 其他任意值 | — | 非法,抛 809000 | + +--- + +## 六.6、修改前后对比 + +### 字段级对比 + +| 字段 | 改前 | 改后 | +|------|------|------| +| `kind`(两个端点的查询参数) | `@RequestParam(defaultValue = "TRAVEL")`,Swagger 标 `defaultValue=TRAVEL` | `@RequestParam(required = false)`,**无默认值**;Swagger 文案改为「不传按该户活跃需求类别自动解析,两类并存时必须显式指定」 | +| `DispatchReqVO.dispatchRemark` | 选填 ≤500 | 未变 | +| `RejectReqVO.returnRemark` | 必填 ≤500 | 未变 | +| 两个端点的响应 | `Result` | 未变 | +| 错误码集合 | 809000 / 809007 / 582031 / 582083 / 589535 | 🆕 **增加 809012** | + +### 行为级对比 + +| 行为 | 改前 | 改后 | +|------|------|------| +| 不传 `kind` + 两类并存 | **静默改 TRAVEL 行,返 200**;操作者要动的 TRANSFER 行原封不动,响应上无任何信号 | 抛 **809012**,零写入 | +| 不传 `kind` + 只有 TRANSFER | 去找不存在的 TRAVEL 行 → 582031,**这类户走不通流程** | 解析成 TRANSFER,正常执行 | +| 不传 `kind` + 只有 TRAVEL | TRAVEL 行 | 未变 | +| 不传 `kind` + 一条活跃行都没有 | 582031 | 未变 | +| 传了 `kind`(合法或非法) | 原样使用 / 809000 | 未变 | +| 非团期子订单 | 582083 | 未变(守卫排在解析之前) | +| 房需求打回(`resourceType=HOTEL`) | `kind` 无意义 | 未变(不触发车侧解析,不会被两类并存误伤) | + +--- + +## 六.7、影响评估 + +- **是否破坏向后兼容**: **是**。旧调用方「不传 `kind` = 按 TRAVEL」的隐含约定已失效;两类活跃需求并存的户上,原本返 200 的请求现在会返 809012。 +- **前端是否必须同步上线**: **是(建议)**。不改也不会报错的前提是「该户只有一类活跃需求」,一旦出现两类并存就会撞 809012。改法极小:调用时把已知的类别放进 `kind` 查询参数,并接住 809012。 +- **前端 workaround 清理点**: 若为绕开「纯接送机户点提交车务报 582031」做过按钮置灰、隐藏或提示,可以撤掉——该场景已修好。 + +--- + +## 七、不影响范围 + +- **仅影响**: `POST /v3/admin/order/{id}/vehicle-requirement/dispatch` 与 `POST /v3/admin/order/{id}/vehicle-requirement/reject` 两个端点的 `kind` 参数语义。 +- **零影响**: + - 房需求(`resourceType=HOTEL`)的提交与打回链路 + - 定制师侧提交 / 修改 / 调整用车需求 `PUT /v3/admin/order/{id}/vehicle-requirement` + - 团级正式用车需求的保存 / 汇总 / 预检 / 确认 / 撤回 / 免车 + - 批量打回、整团确认的既有行为 + - 结算侧手录车费的归属解析(809008,口径有意不同,本次未动) + - 车务侧(hl-fleet-service)配车、派单 + - 历史数据:不改表、不迁移 + +--- + +## 八、测试环境已验证 + +- **代码事实**(对 `origin/dev-v3` 逐一查证): + - 合并提交 `a2ba628ecb`(PR #8610 squash 合并进 `dev-v3`),7 文件 / +491 −27。 + - `VehicleRequirementAdminController` 两处 `@RequestParam(defaultValue = "TRAVEL")` → `@RequestParam(required = false)`,`@ApiParam` 文案同步改写,diff 已逐行核对。 + - 新增 `VehicleRequirementKindErrorCode.VEHICLE_REQUIREMENT_KIND_REQUIRED = IErrorCode.of(809012, …)`,消息模板与占位符含义(`{0}`=订单 ID、`{1}`=两类名以 ` / ` 连接)已核对;同段既有码 809000/809001/809002/809007/809008/809009/809010/809011 未变。 + - 新增私有方法 `RequirementService#resolveVehicleRequirementKind(Long, String)`,三条分支(非空白原样返回 / 0 类原样返回 / 1 类用那一类 / ≥2 类抛 809012)逐行核对;dispatch 里的调用点排在团期守卫之后、取行之前,reject 里排在 `probeGroupAdminReject` 之前且仅对 `resourceType=VEHICLE` 生效。 + - 回归覆盖:`RequirementServiceTest` +301 行、`VehicleRequirementAdminControllerTest` +72 行、`VehicleRequirementKindErrorCodeMessageTest` +22 行(含 809012 报文渲染断言)。 +- **部署**:`hl-order-service-v3` 的 `dev-v3` 分支已滚到测试服,两个端点走管理端网关 `/v3/admin/order/**` 既有路由,无新增路由。 + +``` +POST /v3/admin/order/{id}/vehicle-requirement/dispatch?kind=TRANSFER → 200 ✓ +POST /v3/admin/order/{id}/vehicle-requirement/reject?kind=TRAVEL → 200 ✓ +POST /v3/admin/order/{id}/vehicle-requirement/dispatch(两类并存不传 kind) → 809012 ✓ +POST /v3/admin/order/{id}/vehicle-requirement/dispatch(纯接送机户不传 kind) → 200 ✓(改前 582031) +``` + +--- + +## 九、相关历史 PR + +| PR | Issue | 说明 | 是否仍有效 | +|----|-------|------|------------| +| — | #7210 | 逐单打回源状态放宽到 PENDING,接团期权限守卫 | ✅ 有效 | +| — | #7439 | 用车需求按 kind 分家,两个端点加 `kind` 参数(当时带默认值 TRAVEL) | ⚠️ 默认值部分已被本单撤销 | +| — | #8435 | 团期订单接送变更走团期放行(809011) | ✅ 有效 | +| **本 PR #8610** | **#8601** | `kind` 取消默认值,空值按活跃类别数分流,新增 809012 | ✅ 最新 | + +--- + +## 十、相关文档 + +- 关联 Issue: [wx/HL#8601](https://git.1814.love:8443/wx/HL/issues/8601) +- 关联 PR: [wx/HL#8610](https://git.1814.love:8443/wx/HL/pulls/8610) + +## 关联 / 联系人 + +### 链接 + +- **Issue**: [#8601](https://git.1814.love:8443/wx/HL/issues/8601) +- **PR**: [#8610](https://git.1814.love:8443/wx/HL/pulls/8610) +- **Merge commit**: [a2ba628ecb](https://git.1814.love:8443/wx/HL/commit/a2ba628ecb) + +### 联系人 + +- **后端负责人**: @wx diff --git a/changelogs-v2/2026-09/30_8603_派单确认响应删除恒空的接送机缺失日期字段-修改接口-管理后台.md b/changelogs-v2/2026-09/30_8603_派单确认响应删除恒空的接送机缺失日期字段-修改接口-管理后台.md new file mode 100644 index 00000000..f56cec51 --- /dev/null +++ b/changelogs-v2/2026-09/30_8603_派单确认响应删除恒空的接送机缺失日期字段-修改接口-管理后台.md @@ -0,0 +1,430 @@ +--- +schema: "hl-changelog/v2" +ticket: "8603" +title: "派单原子确认响应删除恒为空的 missingPickupDates / missingDropoffDates,缺失日期只由 605914/605915 错误消息承载" +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: "PR #8608 已 squash 合并 dev-v3(b4eb919b47),hl-fleet-service dev-v3 分支已滚测试服。删除的两个字段是 #8579 加的、在本端点上恒为空数组;接送机缺口在本端点是硬门禁,日期写在 605914/605915 的错误消息里。真正带「已落库但还差几天」中间态的是 POST /admin/fleet/assignments/batch 的 pickupDropoffGate 对象,该对象未动。" +updated_at: "2026-09-30" +base: "dev-v3" +--- + +# hl-fleet-service: 派单原子确认响应删除恒为空的接送机缺失日期字段 + +> **存放目录**: `changelogs-v2/2026-09/` +> **服务**: hl-fleet-service (端口 8089) +> **PR**: #8608 +> **Issue**: #8603 +> **日期**: 2026-09-30 +> **影响范围**: 车务四步向导第③步「按需求整组原子确认」的响应体 + +--- + +## ⚠️ 关键变化 + +- 🔴 **`ConfirmRequirementRespVO` 删除两个字段**:`missingPickupDates`、`missingDropoffDates`。它们是 #8579 加进来的,在**本端点上恒为空数组**。 +- **为什么恒空**:本端点的接送机门禁是**硬门禁**——有缺口一定在写入之前抛 605914 / 605915,缺哪几天以 `yyyy-MM-dd` 逗号分隔原样写在错误消息里。**能拿到 200 响应,就说明门禁已经通过了**,此时「缺失日期」这个概念在本端点上不存在。 +- 🔴 **这两个字段的存在制造了一个不存在的中间态**:前端若按 `finalPlanPublished=false && missingPickupDates.length>0` 去渲染「确认成功但还差 N 天」,这个分支**永远不会成立**——本端点没有这种中间态。 +- ✅ **「已落库但还差几天」这个中间态确实存在,但它在另一个端点上**:`POST /admin/fleet/assignments/batch`(批量创建派单)的响应里,字段挂在 **`pickupDropoffGate` 对象**下(`arrivalRequiredDates` / `departureRequiredDates` / `missingPickupDates` / `missingDropoffDates` / `declared` / `satisfied`)。**该对象本次未动,全部字段照旧**。要做「还差哪几天」的提示,读那里。 +- **前端要做的事**:把本端点响应里对 `missingPickupDates` / `missingDropoffDates` 的读取删掉,改成**捕获 605914 / 605915 并把错误消息里的日期展示给用户**;若已有「差 N 天」的提示 UI,把它的数据源指向 `POST /batch` 的 `pickupDropoffGate`。 +- **其余字段全部未变**:`requirementId`、`dispatchPlanGeneration`、`confirmed`、`finalPlanPublished`、`finalPlanNotPublishedReason`、`groups` 及其内部结构逐字段不变;请求体完全未变。 + +--- + +## 一、背景 + +车务四步向导第③步是「按当前派车方案代际原子确认全部执行段」。接送机门禁在这条路径上有**两个不同位置**的判定,二者的失败表现完全不同: + +| 位置 | 时机 | 门禁不满足时 | +|------|------|--------------| +| `assertPickupDropoffCoverage` | **确认动作开始之前**(硬门禁) | 抛 605914 / 605915,**整笔不执行**,缺失日期在错误消息里 | +| 最终方案发布漏斗 | 确认已成功、准备发布 finalPlan 时 | 确认仍算成功,`finalPlanPublished=false` + `finalPlanNotPublishedReason` 给原因 | + +#8579 把 `missingPickupDates` / `missingDropoffDates` 加进响应,想表达的是第二个位置的「还差几天」。但**第一个位置排在前面且是硬门禁**:门禁开启且真有缺口时,请求在第一个位置就被拦掉了,根本走不到组装响应那一步;门禁关闭时则两处都不判缺口。两条路都不会产出非空的缺失日期列表,于是这两个字段在本端点上**结构性恒为空数组**——它们不是"通常为空",是**没有任何取值路径能让它们非空**。 + +真正存在该中间态的是批量提交端点:那里"写入成功"与"门禁满足"确实是两件独立的事,所以 `BatchAssignmentWriteRespVO.pickupDropoffGate` 里的六个字段有实际取值。 + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 按当前派车方案代际原子确认全部执行段 | POST | `/admin/fleet/assignments/requirements/{requirementId}/confirm` | 响应删除字段 | 删除恒为空的 `missingPickupDates` / `missingDropoffDates`;缺口由 605914/605915 承载 | + +--- + +## 三、接口详情 + +### 1. 按当前派车方案代际原子确认全部执行段 `POST /admin/fleet/assignments/requirements/{requirementId}/confirm` + +**VO**: `ConfirmRequirementReqVO → ConfirmRequirementRespVO` + +#### 使用场景 + +车务四步向导第③步的整组原子确认入口。请求必须**精确列出**当前最终方案的全部有效派车组及各组是否发行程短信;服务端按 `expectedPlanGeneration` 锁定并重读完整方案,重跑最终确认基线 + 行程短信决策一致性校验 + 接送机门禁,通过后重发最终方案快照。任一组缺失、过期或通知歧义则**整笔回滚**,不产生部分 assigned、不产生部分副作用。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `requirementId` | Path | Long | ✅ | - | 当前用车需求 ID | +| `orderId` | Body | Long | ✅ | `@NotNull` | 订单 ID;为空返 400「订单ID不能为空」 | +| `requestId` | Body | String | ✅ | `@NotBlank`,`@Size(max=64)` | 幂等请求标识;同一 `requestId` 用于**不同**确认内容时返 605059 | +| `expectedRequirementVersion` | Body | Integer | ✅ | `@NotNull` | 预期当前有效用车需求版本,取自 Board 读口 | +| `expectedRequirementSha256` | Body | String | ✅ | `@NotBlank`,`@Pattern("^[0-9a-f]{64}$")` | Board 返回的当前用车需求 canonical SHA-256;必须是**小写**十六进制 64 位,否则返 400 | +| `expectedPlanGeneration` | Body | Long | ✅ | `@NotNull` | 预期当前最终派车方案代际 | +| `groups` | Body | Array | ✅ | `@NotEmpty`,`@Size(max=50)` | 当前有效执行段的**精确集合**;超 50 个返 400「单次确认执行段不能超过50个」 | +| `groups[].assignmentGroupId` | Body | Long | ✅ | `@NotNull` | 当前有效派车组 ID | +| `groups[].sendItinerarySms` | Body | Boolean | ✅ | `@NotNull` | 是否向本执行段司机发送行程短信;为空返 400「请选择是否向本段司机发送行程短信」 | + +#### 出参 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `requirementId` | String | 用车需求 ID(雪花 ID,JSON 中为字符串) | +| `dispatchPlanGeneration` | String | 已确认的最终派车方案代际(JSON 中为字符串) | +| `confirmed` | Boolean | 整组是否原子确认成功;返 200 时恒为 `true` | +| `finalPlanPublished` | Boolean | 本次是否真的发布了最终方案快照。`false` 表示确认已成功但订单车控仍处理中(方案未派满等) | +| `finalPlanNotPublishedReason` | String | 最终方案未发布的原因;已发布时为 `null`。取值见「六.5、枚举 / 数据字典」 | +| ~~`missingPickupDates`~~ | ~~Array~~ | 🔴 **本次删除**(#8579 加入,在本端点恒为空数组)。缺失接机日改由 605914 的错误消息承载 | +| ~~`missingDropoffDates`~~ | ~~Array~~ | 🔴 **本次删除**(同上)。缺失送机日改由 605915 的错误消息承载 | +| `groups` | Array | 各执行段确认结果 | +| `groups[].assignmentId` | String | 代表派单 ID(雪花 ID,JSON 中为字符串) | +| `groups[].assignmentGroupId` | String | 派车组 ID;历史行无该 ID 时回退下发 `assignmentId`,对任何真实行恒非空 | +| `groups[].assignmentStatus` | String | 派单状态 | +| `groups[].confirmedAt` | String | 车务最终确认时间(`yyyy-MM-dd HH:mm:ss`) | +| `groups[].sendItinerarySms` | Boolean | 本段是否选择了发送行程短信(回显请求中的选择) | +| `groups[].itinerarySmsEventId` | String | 行程短信 Outbox 事件 ID;**未发送时为 `null`** | +| `groups[].itinerarySmsStatus` | String | 行程短信状态,取值见「六.5、枚举 / 数据字典」 | +| `groups[].itineraryUrl` | String | 本段电子行程单 H5 链接;本端点组装时**恒为 `null`**,签发链接请走行程短信状态查询端点 | + +#### 请求示例 + +```json +{ + "orderId": "2099459272533323777", + "requestId": "confirm-2099459272533323777-20260930-01", + "expectedRequirementVersion": 3, + "expectedRequirementSha256": "9f2c4e1ab7d05836c41fbe2907a5d4638e1c0b7a53d92f8146ce70bb2d5a3ff4", + "expectedPlanGeneration": "12", + "groups": [ + { "assignmentGroupId": "2099461003812864001", "sendItinerarySms": true }, + { "assignmentGroupId": "2099461003812864002", "sendItinerarySms": false } + ] +} +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "requirementId": "2099460881234567890", + "dispatchPlanGeneration": "12", + "confirmed": true, + "finalPlanPublished": true, + "finalPlanNotPublishedReason": null, + "groups": [ + { + "assignmentId": "2099461003812864001", + "assignmentGroupId": "2099461003812864001", + "assignmentStatus": "assigned", + "confirmedAt": "2026-09-30 10:12:33", + "sendItinerarySms": true, + "itinerarySmsEventId": "2099461099887766554", + "itinerarySmsStatus": "PENDING", + "itineraryUrl": null + }, + { + "assignmentId": "2099461003812864002", + "assignmentGroupId": "2099461003812864002", + "assignmentStatus": "assigned", + "confirmedAt": "2026-09-30 10:12:33", + "sendItinerarySms": false, + "itinerarySmsEventId": null, + "itinerarySmsStatus": "NOT_SENT", + "itineraryUrl": null + } + ] + } +} +``` + +**注意响应里没有 `missingPickupDates` / `missingDropoffDates` 两个键**——不是值为空数组,是**键本身不存在**。 + +#### 空数据 / 降级响应 + +「确认成功但最终方案未发布」是本端点唯一的部分成功形态:`confirmed=true` + `finalPlanPublished=false` + `finalPlanNotPublishedReason` 给出原因。此时**没有缺失日期可读**: + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "requirementId": "2099460881234567890", + "dispatchPlanGeneration": "12", + "confirmed": true, + "finalPlanPublished": false, + "finalPlanNotPublishedReason": "PLAN_INCOMPLETE", + "groups": [ + { + "assignmentId": "2099461003812864001", + "assignmentGroupId": "2099461003812864001", + "assignmentStatus": "assigned", + "confirmedAt": "2026-09-30 10:12:33", + "sendItinerarySms": false, + "itinerarySmsEventId": null, + "itinerarySmsStatus": "NOT_SENT", + "itineraryUrl": null + } + ] + } +} +``` + +`groups` 恒非空(请求 `@NotEmpty` 保证至少一段,且每段都要有结果)。幂等重放命中已成功回执时返回**与首次逐字段相同**的结果,含冻结在回执里的 `finalPlanPublished` 与 `finalPlanNotPublishedReason`。 + +#### 错误响应 + +接送机缺口的唯一载体(`{0}` = 缺失日期,`yyyy-MM-dd` 逗号分隔、升序): + +```json +{ + "code": 605914, + "message": "大交通要求接机,以下日期未配置接机车辆:2026-10-08,2026-10-09", + "success": false, + "data": null +} +``` + +```json +{ + "code": 605915, + "message": "大交通要求送机,以下日期未配置送机车辆:2026-10-12", + "success": false, + "data": null +} +``` + +其余错误码(本次未变): + +| 码 | 报文 | 触发 / 处置 | +|----|------|-------------| +| 605062 | `派车日期 {0} 越出当前{1}日期窗(版本 v{2},窗内服务日 {3}):请先调整或取消这些越窗槽位,或让定制师重新提交{1}换版后再派车` | 存在越窗在途槽位;须先调整或取消 | +| 605037 | `车辆处于维保或停用状态,不能派车:{0}` | 先改派换车 | +| 605038 | `司机处于休假或待激活状态,不能派车:{0}` | 先改派换司机 | +| 605059 | `幂等请求标识已用于不同确认内容` | 同一 `requestId` 配了不同载荷;**换新 `requestId` 重试** | +| 605063 | `原子确认回执已损坏,无法幂等重放,请联系管理员` | 🔴 **不可自愈终态**,重试同一 `requestId` 永远同码;前端**不得自动重试、不得静默轮询**,须直接提示用户联系管理员 | + +#### 业务边界 + +- **鉴权**:`AssignmentController` 未挂方法级权限注解,只有网关登录态校验;未登录返 401。 +- 🔴 **缺失日期只存在于错误消息里**:本端点拿到 200 就代表接送机门禁已通过,不要在响应体里找缺口字段。 +- 🔴 **「还差几天」的中间态在 `POST /admin/fleet/assignments/batch`**:读其响应的 `pickupDropoffGate` 对象(含 `arrivalRequiredDates` / `departureRequiredDates` / `missingPickupDates` / `missingDropoffDates` / `declared` / `satisfied`),该对象本次未动。 +- **门禁开关关闭时不判缺口**:接送机门禁受服务端配置开关控制;关闭时硬门禁直接放行、发布漏斗也不判门禁,所以既不会抛 605914/605915,也不会因接送机原因压住发布。这是服务端配置项,**不是请求参数,前端无法也无需感知**。 +- **`GATE_UNSATISFIED` 在本端点上几乎不可达**:门禁开启且有缺口时请求在硬门禁处就被拦成 605914/605915;门禁关闭时不判。它只剩「需求身份不全」的兜底分支,而那条分支按源码注释本来就**没有任何缺失日期可言**。 +- **`groups` 必须是精确集合**:少给一组、多给一组、或组已过期,整笔回滚返错,不会部分生效。 +- **幂等**:以 `requestId` 为键;重放已成功的回执返回同一份结果(含冻结的发布结论),载荷变了返 605059。 +- **`itineraryUrl` 在本响应中恒为 `null`**:行程单链接由行程短信状态查询端点下发。 +- **失败零副作用**:所有门禁与基线校验都排在写入之前,报错时不产生部分 assigned、不产生短信事件。 + +--- + +## 四、契约约束与正确调用方式 + +> 本节只写后端接受 / 拒绝请求的规则与响应字段的正确读法,不写 UI 渲染建议。 + +### ✅ 正确 / ❌ 错误 读法对照 + +| 目的 | ✅ 正确做法 | ❌ 错误做法 | +|------|-------------|-------------| +| 判断「接送机缺哪几天」 | 捕获 605914 / 605915,从 `message` 里取日期(`yyyy-MM-dd` 逗号分隔) | 读本端点响应的 `missingPickupDates` / `missingDropoffDates`——**这两个键已不存在** | +| 渲染「已落库但还差 N 天」 | 读 `POST /admin/fleet/assignments/batch` 响应的 `data.pickupDropoffGate.missingPickupDates` / `.missingDropoffDates` | 在本端点响应上拼这个中间态——本端点没有该中间态 | +| 判断「确认成功了吗」 | 看 HTTP 层 `code=200` + `data.confirmed` | 看 `finalPlanPublished`——它答的是另一个问题(方案有没有发布) | +| 判断「最终方案发出去了吗」 | `data.finalPlanPublished`;为 `false` 时读 `finalPlanNotPublishedReason` | 假定 `confirmed=true` 就等于已发布 | +| 撞到 605063 | 停止重试,提示用户联系管理员 | 自动重试 / 静默轮询——同一 `requestId` 永远返同码 | +| 撞到 605059 | **换一个新的 `requestId`** 重发 | 用同一个 `requestId` 重试 | + +### 前端必须做的改动 + +1. **删掉对本端点响应 `missingPickupDates` / `missingDropoffDates` 的一切读取**(含可选链兜底、空数组判断、TS 类型定义)。 +2. **接送机缺口提示改走 605914 / 605915 的错误消息**,日期在 `message` 里逐字给出。 +3. 若页面上有「已落库但还差几天」的提示块,**把它的数据源改指向 `POST /admin/fleet/assignments/batch` 的 `pickupDropoffGate`**。 + +--- + +## 五、数据库行为 + +**本次改动不涉及任何数据库变更**:无建表、无加列、无改列、无数据迁移、无 Flyway 脚本。 + +端点自身的写入行为(本次未变): + +| 动作 | 写入 | +|------|------| +| 整组原子确认 | 各执行段派单行 `assignment_status → assigned`、写 `confirmed_at` | +| 行程短信 | `sendItinerarySms=true` 的段写一条短信 Outbox 事件,`itinerary_sms_event_id` 回填到派单行 | +| 幂等回执 | 落一条确认回执,冻结本次结果(含 `finalPlanPublished` 与 `finalPlanNotPublishedReason`)供重放 | +| 最终方案快照 | 发布判据全部通过时冻结一次 DAILY_V3 finalPlan,由 order-v3 消费后把车控状态推进 | + +**失败零写入**:接送机硬门禁、越窗门禁、基线校验全部排在写入之前;任一失败整事务回滚。 + +--- + +## 六、边界行为 + +- 未登录 → 401(网关拦截)。 +- 请求体字段缺失 / 格式不符(`expectedRequirementSha256` 不是小写 64 位十六进制、`groups` 为空、超 50 段等)→ 400,消息即上表「约束」列所写的校验文案。 +- 接送机门禁开启且缺接机日 → 605914,日期在消息里,零写入。 +- 接送机门禁开启且缺送机日 → 605915,日期在消息里,零写入(接机缺口先判,两者都缺时先报 605914)。 +- 接送机门禁关闭 → 不判缺口,既不抛 605914/605915,也不因接送机压住发布。 +- 存在越窗在途槽位 → 605062,零写入。 +- 车辆维保/停用、司机休假/待激活 → 605037 / 605038,零写入。 +- 同一 `requestId` 配不同载荷 → 605059;换新 `requestId` 即可。 +- 回执损坏 → 605063,不可自愈终态。 +- 幂等重放命中成功回执 → 200,返回与首次逐字段相同的结果。 +- 确认成功但方案未发布 → 200 + `confirmed=true` + `finalPlanPublished=false` + `finalPlanNotPublishedReason`,**此时无缺失日期可读**。 + +--- + +## 六.5、枚举 / 数据字典 + +### finalPlanNotPublishedReason(最终方案未发布原因) + +**所属字段**: `data.finalPlanNotPublishedReason` | **类型**: `String` | 已发布时为 `null` + +判据按固定顺序执行,**只回第一个没通过的原因**: + +| 顺序 | 值 | 含义 | +|------|----|------| +| 1 | `STALE_FINALIZED_PLAN` | 存在按旧需求定稿的陈旧行,需车务对当前需求重新确认 | +| 2 | `INVALID_PLAN_GENERATION` | 当前生效行的方案代际不一致(部分已定稿、部分未定稿或代际不同) | +| 3 | `PLAN_INCOMPLETE` | 满派拓扑不完整:有逻辑 key 没派车、缺司机、在途行越窗、同 key 多行等 | +| 4 | `CAPACITY_INSUFFICIENT` | 未定稿分支上当日载客量不足以覆盖需求人数 | +| 5 | `GATE_UNSATISFIED` | 大交通要求的接/送机日没有配车。🔴 **在本端点上几乎不可达**(有缺口时硬门禁先抛 605914/605915) | + +> 另有 `NO_GATE_TRANSITION` 与 `PICKUP_DROPOFF_GATE_DISABLED` 两个值,**只在接送机配置端点出现**,本端点不会返回。 + +### itinerarySmsStatus(行程短信状态) + +**所属字段**: `data.groups[].itinerarySmsStatus` | **类型**: `String` + +| 值 | 含义 | +|----|------| +| `NOT_SENT` | 本段未选择发送,或历史行没有短信事件 | +| `PENDING` | 本次已产生短信 Outbox 事件,投递中 | +| `SENT` | 短信已发出(出现在已确认段的重放回显里) | + +--- + +## 六.6、修改前后对比 + +### 字段级对比 + +| 字段 | 改前 | 改后 | +|------|------|------| +| `data.missingPickupDates` | `Array`,**恒为空数组 `[]`** | 🔴 **键已删除,响应中不存在** | +| `data.missingDropoffDates` | `Array`,**恒为空数组 `[]`** | 🔴 **键已删除,响应中不存在** | +| `data.requirementId` | String | 未变 | +| `data.dispatchPlanGeneration` | String | 未变 | +| `data.confirmed` | Boolean | 未变 | +| `data.finalPlanPublished` | Boolean | 未变 | +| `data.finalPlanNotPublishedReason` | String / null | 未变 | +| `data.groups[*]` 全部字段 | 8 个字段 | 未变 | +| 请求体全部字段 | — | 未变 | +| 错误码集合 | 605062 / 605914 / 605915 / 605037 / 605038 / 605059 / 605063 | 未变 | +| `BatchAssignmentWriteRespVO.pickupDropoffGate` | 6 个字段 | **未变**(缺失日期的正确来源) | + +### 行为级对比 + +| 行为 | 改前 | 改后 | +|------|------|------| +| 接送机有缺口 + 门禁开启 | 抛 605914/605915(响应根本到不了组装步) | 未变 | +| 接送机门禁通过、拿到 200 | 响应带两个**恒为空**的日期数组 | 响应**不含**这两个键 | +| 前端按 `missingPickupDates.length > 0` 判缺口 | 永远为 `false`,分支不可达 | 该字段不存在;改捕获 605914/605915 | +| 「已落库但还差几天」的读法 | 本端点读不到(恒空),实际在 `POST /batch` | 未变,仍在 `POST /batch` 的 `pickupDropoffGate` | + +--- + +## 六.7、影响评估 + +- **是否破坏向后兼容**: **是(响应删字段)**。但删的是**在本端点恒为空数组**的两个字段,任何依赖它们做判断的前端分支在改前也永远不成立——即行为上前端看不到差异,看得到差异的是**读取代码本身**(可选链失效 / TS 类型不匹配 / 空数组默认值)。 +- **前端是否必须同步上线**: **建议同步**。JS 里读不存在的键得 `undefined`,若代码写的是 `resp.data.missingPickupDates.length` 会抛 TypeError;写成 `?.length` 或有默认值则不报错。TS 侧需删掉类型声明里的这两个字段。 +- **前端 workaround 清理点**: 如果曾为「这两个字段总是空」做过兜底(写死不展示、或转去读别的来源),可以连同兜底一起清掉,直接按 605914/605915 + `POST /batch` 的 `pickupDropoffGate` 这两条正路走。 +- **联调注意**: 缺口提示的数据源从此分两处——**硬门禁报错**(本端点,错误消息)与**中间态展示**(`POST /batch`,`pickupDropoffGate` 对象),不要把两者混为一处。 + +--- + +## 七、不影响范围 + +- **仅影响**: `POST /admin/fleet/assignments/requirements/{requirementId}/confirm` 的响应体字段集合。 +- **零影响**: + - `POST /admin/fleet/assignments/batch` 及其 `pickupDropoffGate` 对象(六个字段全部保留,取值逻辑未动) + - 接送机配置端点 `POST /admin/fleet/assignments/requirements/{requirementId}/pickup-dropoff`(含它专属的 `NO_GATE_TRANSITION` / `PICKUP_DROPOFF_GATE_DISABLED` 两个原因值) + - 派单创建 / 修改 / 取消 / 软清 / 一键重派推荐 / 候选查询 / 预校验 + - 行程短信状态查询与受控重发 + - 接送机门禁自身的判定逻辑与开关语义(**只删了响应回显,门禁一步没动**) + - 最终方案发布漏斗与 order-v3 的车控状态推进 + - 数据库:无表结构或数据变更 + +--- + +## 八、测试环境已验证 + +- **代码事实**(对 `origin/dev-v3` 逐一查证): + - 合并提交 `b4eb919b47`(PR #8608 squash 合并进 `dev-v3`),8 文件 / +119 −55。 + - `ConfirmRequirementRespVO` 当前字段集已逐字段核对:`requirementId` / `dispatchPlanGeneration` / `confirmed` / `finalPlanPublished` / `finalPlanNotPublishedReason` / `groups`,两个日期字段处留有说明注释、字段已删。 + - `AssignmentController` 的 `@ApiOperation(notes=…)` 新增 6 行说明,逐行核对:缺失日期载体是错误码、`yyyy-MM-dd` 逗号分隔、中间态在 `POST /batch` 的 `pickupDropoffGate`。 + - `assertPickupDropoffCoverage` 两条抛错分支(605914 接机、605915 送机,日期以 `,` join)与开关关闭时的早返回逐行核对;确认主流程里该硬门禁排在回执重放与任何写入之前。 + - `BatchAssignmentWriteRespVO.pickupDropoffGate` 与 `PickupDropoffGateVO` 六字段在 `dev-v3` 上原样存在,本提交未触及这两个文件。 + - `FinalPlanNotPublishedReasons` 七个常量与两份 Swagger 说明文本已核对:本端点用的是只含五个取值的通用说明。 + - 回归覆盖:`AssignmentControllerTest` 断言 `$.data.missingPickupDates` / `$.data.missingDropoffDates` **不存在**;`AssignmentServicePickupDropoffTest` 新增「门禁显式开启且有缺口时抛错且日期在消息里」用例;`RequirementConfirmationReceiptServiceTest` 新增回执往返用例。 +- **部署**:`hl-fleet-service` 的 `dev-v3` 分支已滚到测试服,端点走管理端网关 `/admin/fleet/**` 既有路由,无新增路由。 + +``` +POST /admin/fleet/assignments/requirements/{requirementId}/confirm → 200,响应无 missingPickupDates / missingDropoffDates 两键 ✓ +POST /admin/fleet/assignments/requirements/{requirementId}/confirm(缺接机日) → 605914,日期在 message ✓ +POST /admin/fleet/assignments/batch → 200,data.pickupDropoffGate 六字段照旧 ✓ +``` + +--- + +## 九、相关历史 PR + +| PR | Issue | 说明 | 是否仍有效 | +|----|-------|------|------------| +| — | #7067 | 接送机门禁落地:605914/605915 + 最终方案发布漏斗 | ✅ 有效 | +| — | #8429 | 未发布原因 `finalPlanNotPublishedReason` 进响应 | ✅ 有效 | +| — | #8579 | 给确认响应加 `missingPickupDates` / `missingDropoffDates` | ❌ **已被本单撤销**(在本端点恒为空) | +| **本 PR #8608** | **#8603** | 删除上述两个恒空字段,缺口归错误码承载 | ✅ 最新 | + +--- + +## 十、相关文档 + +- 关联 Issue: [wx/HL#8603](https://git.1814.love:8443/wx/HL/issues/8603) +- 关联 PR: [wx/HL#8608](https://git.1814.love:8443/wx/HL/pulls/8608) + +## 关联 / 联系人 + +### 链接 + +- **Issue**: [#8603](https://git.1814.love:8443/wx/HL/issues/8603) +- **PR**: [#8608](https://git.1814.love:8443/wx/HL/pulls/8608) +- **Merge commit**: [b4eb919b47](https://git.1814.love:8443/wx/HL/commit/b4eb919b47) + +### 联系人 + +- **后端负责人**: @wx