diff --git a/changelogs-v2/2026-09/27_8429_车务最终实派方案发布判据统一与未发布原因字段-修改接口-管理后台.md b/changelogs-v2/2026-09/27_8429_车务最终实派方案发布判据统一与未发布原因字段-修改接口-管理后台.md new file mode 100644 index 00000000..df84c50f --- /dev/null +++ b/changelogs-v2/2026-09/27_8429_车务最终实派方案发布判据统一与未发布原因字段-修改接口-管理后台.md @@ -0,0 +1,770 @@ +--- +schema: "hl-changelog/v2" +ticket: "8429" +title: "车务最终实派方案发布判据统一(批量派车 batch 补上满派与陈旧定稿守卫,已完结行越窗豁免)、三个写口新增未发布原因字段、602202 按声明车数放宽" +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 #8451 已合入 dev-v3(合并提交 267ab6906)。TEST 环境 hl-fleet-service 267ab6906 于 2026-09-27 19:40:15 部署,hl-gateway 71def6dc5 同期在线;2026-09-27 19:58-20:27 在该组合上网关实测 batch/pd/confirm 三写口(见「八、测试环境已验证」):越窗已完结行 batch 与 pd 答案从一真一假变为都真;602202 改按声明车数动态判定(count=2 时第 3 辆拒绝、降为 count=1 后第 2 辆即拒);三个接口响应均带 finalPlanNotPublishedReason 字段。" +updated_at: "2026-09-27" +base: "dev-v3" +--- + +# fleet: 车务最终实派方案发布判据统一 + 未发布原因字段 + +**服务**: hl-fleet-service(端口 8161/8261) +**PR**: `#8451`(已合入 `dev-v3`,合并提交 `267ab6906`) +**Issue**: #8429 +**日期**: 2026-09-27 +**影响范围**: 管理后台车务看板的批量派车、接送机配置、需求确认三个接口;接送机派车对同日同方向多辆车的上限校验 + +--- + +## ⚠️ 关键变化 + +🔴 **三个写口统一判据。** 改前:batch 只看接送机门禁(满足即发布),pd 与 confirm 还要过满派拓扑与陈旧定稿两道守卫。改后:三个接口都走完整判据(陈旧定稿 → 方案代际 → 满派拓扑 → 门禁)。结果:同一份派单数据走 batch 和 pd 可能得到不同答案的情况消失;batch 在「方案不完整」时会从原来的 `finalPlanPublished=true` 变成 `false` + `PLAN_INCOMPLETE`。 + +🟡 **新字段 `finalPlanNotPublishedReason`。** 三个接口(batch、pd、confirm)响应都新增这个字段。发布时为 null;不发布时回「第一个没通过的判据」原因,供前端对运营给出有针对性的提示。取值共五个(通用)+ 两个(pd 专属)。 + +🟡 **已完结行越窗豁免。** 改大交通或换版后,已完结日落在新服务日之外。改前会让方案判「不完整」永久发不出去;改后只对在途行检查窗口,已完结行豁免。 + +🟢 **602202 按声明车数放宽。** TRANSFER 需求下同日同方向标「参与」的派车行数上限从「1」改为「声明车数」(count)。当 count 缺失或 ≤0 时按 1。code 不变,文案扩展(含日期、方向、标记条数、派车行 ID 列表、声明车数)。TRAVEL 需求不受影响。 + +--- + +## 一、背景 + +**问题现象**(测试服 2026-09-27 实测):同一批派单数据,pd 配齐接送机后响应 `finalPlanPublished=false`,同一条 batch(无新增行)7 秒后响应 `true`。根因是三个写口走不同判据——batch 只过门禁漏斗,pd 还要过满派守卫,当方案含「越窗已完结行」时满派判定会卡住。 + +**两个上游缺口**: +1. #5810 在「最终确认基线」与「方案代际」两处给已完结行豁免,满派拓扑这一处漏了; +2. #7067 统一了发布入口但 batch 路径返工时没有带上满派与陈旧定稿的守卫。 + +**业务定案**(wx 2026-09-27): +- TRANSFER 的 count = 同日同方向实际要派的车数。 +- count 不是发布门禁;实派即权威,派少照样发。 +- 602202 以 count 为上限,不再「超过 1 就拒」。 + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 批量派车 | POST | `/admin/fleet/assignments/batch` | 修改 | 判据改完整(新增满派与陈旧定稿检查);新增 finalPlanNotPublishedReason;已完结行越窗豁免 | +| 2 | 接送机配置 | PUT | `/admin/fleet/assignments/pickup-dropoff-config` | 修改 | 新增 finalPlanNotPublishedReason;602202 上限按声明车数;已完结行越窗豁免 | +| 3 | 需求确认 | POST | `/admin/fleet/assignments/requirements/{requirementId}/confirm` | 修改 | 新增 finalPlanNotPublishedReason;已完结行越窗豁免 | + +--- + +## 三、接口详情 + +### 1. 批量派车 `POST /admin/fleet/assignments/batch` + +**VO**: `BatchCreateAssignmentReqVO` → `BatchAssignmentWriteRespVO` + +#### 使用场景 + +车务第②步:提交逐日最终方案。**改后走完整发布判据,方案不完整时会从原来的 true 变成 false。** + +#### 入参字段表 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| orderId | Body | Long | ✅ | 订单 ID(雪花) | **不变** | +| orderNo | Body | String | ❌ | - | **不变**:订单号冗余 | +| requirementId | Body | Long | ✅ | 用车需求 ID | **不变**:当前生效用车需求 ID | +| kind | Body | String | ❌ | `TRANSFER` / `TRAVEL` | **不变,与本单无关**:不传按 `TRAVEL` 解析(见下方业务边界「kind 省略」) | +| startDate | Body | String | ✅ | yyyy-MM-dd | **不变**(此前遗漏未列):用车开始日期;TRANSFER 请传接送机服务日最早一天 | +| endDate | Body | String | ✅ | yyyy-MM-dd | **不变**(此前遗漏未列):用车结束日期 | +| pickupAt | Body | String | ❌ | - | **不变**:接客地 | +| dropoffAt | Body | String | ❌ | - | **不变**:送客地 | +| headcount | Body | Integer | ❌ | - | **不变**:乘客人数 | +| confirmNoVehicleServiceDates | Body | Boolean | ❌ | - | **不变**:逐日计划未覆盖全部服务日(存在不配车日)时的显式二次确认 | +| sendItinerarySms | Body | Boolean | ❌ | 不传按 false | **不变**:是否向本批各车师傅发送行程短信,整批统一决策 | +| skipCityJunctionException | Body | Boolean | ❌ | - | **不变**:跳过城市衔接例外 | +| fromEntry | Body | String | ❌ | - | **不变**:操作来源 | +| requestId | Body | String | ✅ | 长度 ≤64 | **改**:此前误写为非必填,源码 `@NotBlank`,批次级幂等请求标识不能为空 | +| dailyPlan | Body | List<DailyPlanItem> | ✅ | 最多 4000 项 | **不变**:按行程日的完整配车列表,需求日期窗内未出现的服务日视为该日不配车 | +| dailyPlan[].serviceDate | Body | String | ✅ | yyyy-MM-dd | **不变** | +| dailyPlan[].vehicleId | Body | Long | ✅ | 车辆 ID | **不变** | +| dailyPlan[].driverId | Body | Long | ✅ | 司机 ID | **不变** | +| dailyPlan[].assignmentPrice | Body | BigDecimal | ❌ | ≥0.00,整数位≤10 小数位≤2 | **不变**:本车当天实际价格,未传按车型价格日历参考价兜底 | +| dailyPlan[].priceAdjustmentReason | Body | String | ❌ | 长度 ≤256 | **不变**:实际价格与参考价不一致时的调整原因 | +| dailyPlan[].confirmCrossResident | Body | Boolean | ❌ | - | **不变**:跨常驻车显式确认 | + +#### 出参字段表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| assignments | List<Item> | **不变**:本次提交生效的派单结果;`Item.fleetItemIndex` 去槽位化(#7067)后恒为 `null`(字段保留兼容),`Item.assignment` 是完整 `AssignmentWriteRespVO`(见响应示例,关键字段 `id`【不是 `assignmentId`】、`assignmentStatus`【提交即派定,恒 `"assigned"` 小写】) | +| finalPlanPublished | Boolean | **语义不变,取值可能改变**:true = 本次发布了最终方案;false 时见下一字段 | +| finalPlanNotPublishedReason | String | **新增**。发布时为 null;不发布时取值见下表。返回第一个没通过的判据原因 | +| pickupDropoffGate | PickupDropoffGateVO | **不变**:含 `arrivalRequiredDates`/`departureRequiredDates`/`missingPickupDates`/`missingDropoffDates`/`declared`/`satisfied`(见响应示例) | +| failedFleetItemIndex | Integer | **不变** | +| dailyDifferences | List<...> | **不变**:成功时为 `null`(不是空数组),仅最终派定失败时回逐日基线差异 | + +**finalPlanNotPublishedReason 取值**(五个值,按判据顺序,只返回首个未通过的): + +| 取值 | 触发条件 | +|------|----------| +| `STALE_FINALIZED_PLAN` | 存在按旧需求定稿的陈旧行,需车务对当前需求重新确认 | +| `INVALID_PLAN_GENERATION` | 方案代际不一致(部分行已定稿、部分未定稿或代际不同) | +| `PLAN_INCOMPLETE` | 满派拓扑不完整:有逻辑 key 没派车、缺司机、在途行越窗、同 key 多行等 | +| `CAPACITY_INSUFFICIENT` | 未定稿分支当日载客量不足覆盖需求人数(仅未定稿分支出现) | +| `GATE_UNSATISFIED` | 大交通要求的接/送机日没有配车 | + +#### 请求示例 + +实测请求(2026-09-27 20:00:24,测试专用车务账号,见 `wf/8429/ac2/result.md` AC-2 第一步): + +```json +{ + "orderId": 2104161383220457474, + "requirementId": 2104178895018459138, + "kind": "TRANSFER", + "startDate": "2027-08-15", + "endDate": "2027-08-30", + "headcount": 2, + "requestId": "fleetqa-ac2_step1_batch-xxxxxxxxxx", + "dailyPlan": [ + { + "serviceDate": "2027-08-15", + "vehicleId": 2104029270659780610, + "driverId": 2104029157140910081, + "assignmentPrice": "300.00", + "priceAdjustmentReason": "按订单约定价格" + }, + { + "serviceDate": "2027-08-30", + "vehicleId": 2104029270659780610, + "driverId": 2104029157140910081, + "assignmentPrice": "300.00", + "priceAdjustmentReason": "按订单约定价格" + } + ] +} +``` + +#### 响应示例 + +提交新行、门禁未配齐(不发布)——实测原文(`wf/8429/ac2/out/batch_ac4_assign3.json`,AC-4 三车同时新建): + +```json +{ + "code": 200, + "message": "成功", + "data": { + "assignments": [ + { + "fleetItemIndex": null, + "assignment": { + "id": "2104183651266863105", + "assignmentGroupId": "362573467578667009", + "assignmentSlotId": "362573467578667009", + "assignmentStatus": "assigned", + "stageCode": "assigned", + "stageLabel": "已派车", + "currentStep": 3, + "skippedStepCodes": [], + "protocolPrice": "300.00", + "vehicleFeeAutoTotal": "0.00", + "vehicleFeeAutoComplete": false, + "vehicleFeeTotal": "300.00", + "vehicleFeeSource": "MANUAL", + "vehicleFeeAdjustmentReason": "按订单约定价格", + "dailyVehicleFees": null, + "holdSentAt": null, + "confirmedAt": "2026-09-27 20:17:31", + "sideEffects": { + "vehicleStatusUpdated": "busy", + "driverStatusUpdated": "busy", + "reconPrepRowsCreated": 0, + "reconPrepMarkedCanceled": null + }, + "sendItinerarySms": false, + "itinerarySmsEventId": null, + "itinerarySmsStatus": "NOT_SENT", + "dailyDifferences": null + } + } + ], + "finalPlanPublished": false, + "finalPlanNotPublishedReason": "GATE_UNSATISFIED", + "pickupDropoffGate": { + "arrivalRequiredDates": ["2027-08-30", "2027-09-05"], + "departureRequiredDates": ["2027-08-09"], + "missingPickupDates": ["2027-08-30", "2027-09-05"], + "missingDropoffDates": [], + "declared": true, + "satisfied": false + }, + "failedFleetItemIndex": null, + "dailyDifferences": null + }, + "traceId": null, + "success": true +} +``` + +> 上例响应只返回 1 条 `assignments` 记录,即便当次提交/相关联的行更多——`assignments[]` 只包含本次调用**新建**的派车行(与库内既有行 diff 后的 `itemsToCreate`),已存在且未变化的行不会重复出现。**不要用 `assignments` 数组长度判断本次提交是否被完整接受**,应以 `finalPlanPublished` / `finalPlanNotPublishedReason` 为准。 + +原样重放、已发布(本次未产生新建行)——实测原文(`wf/8429/ac2/out/batch_ac2_step3_batch_replay.json`): + +```json +{ + "code": 200, + "message": "成功", + "data": { + "assignments": [], + "finalPlanPublished": true, + "finalPlanNotPublishedReason": null, + "pickupDropoffGate": { + "arrivalRequiredDates": ["2027-08-15", "2027-08-30"], + "departureRequiredDates": ["2027-08-09"], + "missingPickupDates": [], + "missingDropoffDates": [], + "declared": true, + "satisfied": true + }, + "failedFleetItemIndex": null, + "dailyDifferences": null + }, + "traceId": null, + "success": true +} +``` + +#### 空数据 / 降级响应 + +派单成功但方案不完整时(缺接/送机日、缺司机等)返回 `finalPlanPublished=false` + 原因(无空态)。 + +网关降级无额外变化。 + +#### 错误响应 + +```json +{ + "code": 605062, + "message": "派车日期不在用车需求服务范围内", + "data": null, + "success": false +} +``` + +| 错误码 | 触发条件 | +|---|---| +| 605062 | 提交项的服务日期越出当前需求日期窗(**不变**);既有的窗外在途行不再报错,仅新提交项受限 | +| 605912 | 派车快照缺少需求车型:所选车辆无车型大类且型号名无法归一(**不变**) | +| 605905 | 需求版本过期:锁内重读的当前生效需求 ID/版本与请求不一致(**不变**);`kind` 省略解析出的需求与传入 `requirementId` 不一致时也走这条 | +| 100001 | 参数非法,含「已完结日改派」(该日已完结不可改派)与请求携带已移除旧字段两类场景(**不变**) | + +#### 业务边界 + +- **满派拓扑缺口场景**:需求下某些必须配车的服务日本次未提交任何 `dailyPlan` 行覆盖(而非"某行漏填司机"——`dailyPlan[].driverId` 本身必填,无法提交出一条缺司机的行),门禁满足时改前发布、改后不发布(`PLAN_INCOMPLETE`)。运营需按原因提示补齐缺失服务日的派车行。 +- **已完结行**:越窗已完结行不再卡发布,该行在其他维度(车、司机、形态)仍需满足完整性条件。 +- **空方案**(dailyPlan 为空):只过门禁判定,满派守卫不适用。 +- **重复调用**(requestId 相同):按幂等复用,返回相同结果。 +- **`assignments` 只回新建行**:数组只包含本次调用新建的派车行,已存在且未变化的行不重复返回;判断提交是否被接受用 `finalPlanPublished`/`finalPlanNotPublishedReason`,不要用数组长度或内容对比。 +- **旧字段一律 400**:`items`/`chargeableServiceDates`/`vehicleFeeWaiverReason`/`confirmAllServiceDatesFree`/`holdMode`(顶层)、`dailyPlan[].fleetItemIndex`/`used`/`pickupParticipant`(逐日项)已移除,请求体携带任一个即触发 Bean Validation 400(不进入业务判据)。 +- **该接口重置本次覆盖服务日的接送标记**:`batch` 落库时会把本次 `dailyPlan` 覆盖到的每个服务日的 pickup/dropoff 参与标记重置,即使该日车辆/司机未变——先调 `batch` 再调 `pickup-dropoff-config` 的调用顺序下,`batch` 覆盖到的日期需要重新配置接送标记,不能假设旧标记还在。 +- **`kind` 省略时的解析行为**(`#8453`,纯 TRANSFER 订单既有缺陷,非本次改动引入):`kind` 不是必填字段,省略时按 `TRAVEL` 解析当前需求(`RequirementKindResolver.resolveForMutation`,`AssignmentService.java:8088`),与「接送机配置」接口共用同一个解析器(`AssignmentService.java:1228`)。纯 TRANSFER 订单(无 TRAVEL 需求)省略 `kind` 时会解析不到当前需求;`requirementId` 传了值但与解析结果不一致会触发 605905(需求版本过期)。该缺陷已在「接送机配置」接口实测复现(见该接口业务边界的日志对照),本接口调用同一个解析器,结构上同样受影响,但本轮未针对 `batch` 单独造数复现——TRANSFER 场景请始终显式传 `kind: "TRANSFER"`,跟踪见 `#8453`。 + +--- + +### 2. 接送机配置 `PUT /admin/fleet/assignments/pickup-dropoff-config` + +**VO**: `PickupDropoffConfigReqVO` → `PickupDropoffConfigRespVO` + +#### 使用场景 + +车务第③步:配置派车行是否参与接机/送机。改后新增未发布原因字段,602202 上限按声明车数。**本接口仍保留「跃迁才发布」规则(门禁从不满足→满足时发布),且已完结行越窗不再阻止发布。** + +#### 入参字段表 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| orderId | Body | Long | ✅ | 订单 ID | **不变** | +| requirementId | Body | Long | ✅ | 用车需求 ID | **不变**:当前生效用车需求 ID | +| kind | Body | String | ❌ | `TRANSFER` / `TRAVEL` | **改,与本单无关**:不传按 `TRAVEL` 解析(见下方业务边界「kind 省略」,纯 TRANSFER 订单请务必显式传) | +| requestId | Body | String | ✅ | 长度 ≤64 | **改**:此前误写为非必填,源码 `@NotBlank` | +| items | Body | List<ConfigItem> | ✅ | 最多 4000 项 | **不变**:要标记接送机参与的派车行集合;该订单+当前需求下未出现在 `items` 中的生效派车行两个方向标志一律归零(全量覆盖语义) | +| items[].assignmentId | Body | Long | ✅ | 须是当前需求下的生效派车行 | **不变** | +| items[].pickupParticipant | Body | Boolean | ✅ | - | **不变**:当日该车是否参与 ARRIVAL 接机 | +| items[].dropoffParticipant | Body | Boolean | ✅ | 与 pickupParticipant 不能同为 false | **不变**:当日该车是否参与 DEPARTURE 送机;同一行两个标志不能同为 false(不参与的行不要出现在 `items` 里,否则 400) | + +#### 出参字段表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| finalPlanPublished | Boolean | **不变**:是否已发布最终方案 | +| finalPlanNotPublishedReason | String | **新增**。七个值(五个通用 + 两个本接口专属),发布时为 null | +| requirementReopened | Boolean | **不变**:是否已把订单拉回处理中 | +| reopenBlockedReason | String | **不变**:拉回失败的原因 | +| pickupDropoffGate | PickupDropoffGateVO | **不变**:当前门禁状态 | + +**finalPlanNotPublishedReason 取值**(七个值): + +| 取值 | 出现场景 | +|------|---------| +| 前五个 | 同 batch(陈旧定稿、代际不一致、拓扑不完整、载客量不足、门禁不满足) | +| `NO_GATE_TRANSITION` | **本接口专属**:本次配置前后门禁没有从不满足→满足 | +| `PICKUP_DROPOFF_GATE_DISABLED` | **本接口专属**:接送机门禁开关关闭或无当前生效需求,本接口退回纯写口 | + +#### 请求示例 + +实测请求(2026-09-27 20:02:18,测试专用车务账号,见 `wf/8429/ac2/result.md` AC-2 第二步、一次性提交两行): + +```json +{ + "orderId": 2104161383220457474, + "requirementId": 2104178895018459138, + "kind": "TRANSFER", + "requestId": "fleetqa-pd-ac2_step2_pd-xxxxxxxxxx", + "items": [ + { + "assignmentId": 2104166776231403521, + "pickupParticipant": true, + "dropoffParticipant": false + }, + { + "assignmentId": 2104179344782008321, + "pickupParticipant": true, + "dropoffParticipant": false + } + ] +} +``` + +#### 响应示例 + +门禁尚未满足(部分补标,仍缺 08-15)——实测原文(`wf/8429/ac2/out/pd_ac2_step2_pd.json`): + +```json +{ + "code": 200, + "message": "成功", + "data": { + "finalPlanPublished": false, + "finalPlanNotPublishedReason": "NO_GATE_TRANSITION", + "requirementReopened": false, + "reopenBlockedReason": null, + "pickupDropoffGate": { + "arrivalRequiredDates": ["2027-08-15", "2027-08-30"], + "departureRequiredDates": ["2027-08-09"], + "missingPickupDates": ["2027-08-15"], + "missingDropoffDates": [], + "declared": true, + "satisfied": false + } + }, + "traceId": null, + "success": true +} +``` + +门禁已满足但无新跃迁(重复提交与已生效配置相同的标记)——实测原文(`wf/8429/ac2/out/pd_ac4_reset_v2.json`): + +```json +{ + "code": 200, + "message": "成功", + "data": { + "finalPlanPublished": false, + "finalPlanNotPublishedReason": "NO_GATE_TRANSITION", + "requirementReopened": false, + "reopenBlockedReason": null, + "pickupDropoffGate": { + "arrivalRequiredDates": ["2027-08-15", "2027-08-30"], + "departureRequiredDates": ["2027-08-09"], + "missingPickupDates": [], + "missingDropoffDates": [], + "declared": true, + "satisfied": true + } + }, + "traceId": null, + "success": true +} +``` + +> 首次把门禁从「不满足」配到「满足」时(即上面「请求示例」一次性提交两行的那次调用)响应形状与上两例相同,字段取值为 `finalPlanPublished=true`、`finalPlanNotPublishedReason=null`、`pickupDropoffGate.satisfied=true`、`missingPickupDates`/`missingDropoffDates` 均为空数组(实测记录见 `wf/8429/ac2/result.md` AC-2 第二步,本节不重复整段 JSON)。 + +#### 空数据 / 降级响应 + +无额外空态。门禁不满足或未跃迁时 `finalPlanPublished=false` + 原因;拉回失败时 `requirementReopened=true` + 拉回失败原因。 + +#### 错误响应 + +实测原文(`wf/8429/ac2/out/pd_ac4_pd3.json`,声明车数=2 时追加第 3 辆): + +```json +{ + "code": 602202, + "message": "2027-09-05 的 ARRIVAL 接机 已有 3 条派车行标记参与(派车行 2104183651266863105,2104183651463995394,2104183651883425793), 超过接送机需求声明的 2 辆", + "data": null, + "traceId": null, + "success": false +} +``` + +换版把声明车数降为 1 后,同样 2 辆车即触发(`wf/8429/ac2/out/pd_ac4_pd2_after_reduce.json`): + +```json +{ + "code": 602202, + "message": "2027-09-05 的 ARRIVAL 接机 已有 2 条派车行标记参与(派车行 2104183651266863105,2104183651463995394), 超过接送机需求声明的 1 辆", + "data": null, + "traceId": null, + "success": false +} +``` + +| 错误码 | 触发条件 | +|---|---| +| 602202 | **语义改**:TRANSFER 需求,同日同方向标「参与」的派车行数 **超过声明车数**(改前是固定「超过 1」,现改按 `RequirementSnapshot.fleet[].count` 动态判定);失败时整批原子回滚(实测复核:失败调用前后 SQL 标记值完全一致);文案含日期、方向、标记条数、派车行 ID 列表、声明车数(见上两例) | +| 605905 | 需求版本过期:锁内重读的当前生效需求 ID 或版本与写命令不一致(**不变**) | +| 605913 | 接送机配置无效:`items[].assignmentId` 对应的派单行不存在、不属于当前需求,或已是终态(已取消/无车无司机)(**不变**) | + +#### 业务边界 + +- **602202 的「标记参与」判据**:跳过 CANCELED 与 EXCEPTION 行,COMPLETED 照计(已完结日重新配置时它仍代表现状)。 +- **TRAVEL 需求不受 602202 约束**(TRANSFER-only),同日多行参与接机仍合法。 +- **count 缺失或 ≤0 时**:602202 上限按 1 计(与改前行为等价)。 +- **改前配置数据里已完结日**:越窗不再阻止发布;该行仍需满足其他完整性条件(有车有司机、形态合法等)。 +- **`items` 是全量覆盖语义**:请求里未列出的行,其 pickup/dropoff 标记会被隐式重置为 `false`;只想改动部分行时也必须把其余仍需保留标记的行一并带上,否则会被静默清零(实测 `wf/8429/ac2/result.md` 第 85 行记录同一现象)。 +- **`kind` 省略时的解析行为**(`#8453`,纯 TRANSFER 订单实测确认的既有缺陷,非本次改动引入):不传 `kind` 时 `RequirementKindResolver.resolveForMutation` 按 `TRAVEL` 解析当前生效需求;若该订单没有 TRAVEL 需求,解析结果为 `currentRequirement=null`,依赖它的校验(含 602202)会被静默跳过而不报错,接口返回**看似正常的 200**且 `finalPlanNotPublishedReason=PICKUP_DROPOFF_GATE_DISABLED`——该原因值本意是"这条需求 kind 不启用接送机门禁",用在这里具有误导性(实际是 kind 解析失配,不是真的门禁禁用)。实测对照(同一份 3 行 `items`、同一账号重放): + - 漏传 `kind`(`wf/8429/ac2/run_ac4_pd3.log` 20:19:37):`http=200 code=200 finalPlanPublished=False reason=PICKUP_DROPOFF_GATE_DISABLED`,602202 判定被跳过。 + - 补传 `kind=TRANSFER`(`wf/8429/ac2/run_ac4_pd3b.log` 20:23:21):同一份 `items` 立即 `http=200 code=602202`,报文与上方错误响应一致。 + + **纯 TRANSFER 订单调用本接口必须显式传 `kind=TRANSFER`**,否则会静默绕过声明车数上限校验且不产生任何报错信号。该问题已登记为独立缺陷跟踪 `#8453`;在该单修复上线前,前端调用侧须将 `kind` 作为纯 TRANSFER 场景的强制字段随每次请求携带。 + +--- + +### 3. 需求确认 `POST /admin/fleet/assignments/requirements/{requirementId}/confirm` + +**VO**: `ConfirmRequirementReqVO` → `ConfirmRequirementRespVO` + +#### 使用场景 + +车务最终确认需求(第④步)。改后新增未发布原因字段,已完结行越窗不再阻止发布。 + +#### 入参字段表 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| requirementId | Path | Long | ✅ | 用车需求 ID | **不变** | +| orderId | Body | Long | ✅ | `@NotNull` | 订单 ID | +| requestId | Body | String | ✅ | `@NotBlank` `@Size(max=64)` | 幂等请求标识 | +| expectedRequirementVersion | Body | Integer | ✅ | `@NotNull` | 预期当前有效用车需求版本 | +| expectedRequirementSha256 | Body | String | ✅ | `@NotBlank` `@Pattern(^[0-9a-f]{64}$)` | Board 返回的当前用车需求 canonical SHA-256 | +| expectedPlanGeneration | Body | Long | ✅ | `@NotNull` | 预期当前最终派车方案代际 | +| groups | Body | List<GroupDecisionVO> | ✅ | `@NotEmpty` `@Size(max=50)` | 当前有效执行段**精确集合**及各段行程短信选择;漏传/多传任一执行段触发 605056(见下方错误响应与业务边界) | +| groups[].assignmentGroupId | Body | Long | ✅ | `@NotNull` | 当前有效派车组 ID | +| groups[].sendItinerarySms | Body | Boolean | ✅ | `@NotNull` | 是否向本执行段司机发送行程短信 | + +#### 出参字段表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| requirementId | Long | **不变**:用车需求 ID | +| dispatchPlanGeneration | Long | **不变**:已确认的最终派车方案代际 | +| confirmed | Boolean | **不变**:整组是否原子确认成功 | +| finalPlanPublished | Boolean | **不变**:本次是否发布了最终方案;`false` 表示确认已成功但订单车控仍为处理中(接送机未配齐或方案未派满),需按 605914/605915 提示补齐后重试 | +| finalPlanNotPublishedReason | String | **新增**(#8429)。五个通用值,发布时为 `null`(本接口不含 pd 专属的两个值) | +| groups | List<GroupResultVO> | **不变**:各执行段确认结果 | +| groups[].assignmentId | Long | 代表派单 ID | +| groups[].assignmentGroupId | Long | 派车组 ID;历史行无 `assignmentGroupId` 时回退下发 `assignmentId`,对任何真实行恒非空 | +| groups[].assignmentStatus | String | 派单状态;实测恒为小写 `assigned`(#5827 提交即派定后,确认阶段不再有其他取值) | +| groups[].confirmedAt | LocalDateTime | 车务最终确认时间;实测序列化为 `"yyyy-MM-dd HH:mm:ss"`(无 `T` 分隔符,见下方响应示例) | +| groups[].sendItinerarySms | Boolean | 是否选择发送本段行程短信 | +| groups[].itinerarySmsEventId | Long | 行程短信 Outbox 事件 ID;未发送为 `null` | +| groups[].itinerarySmsStatus | String | 行程短信状态 | +| groups[].itineraryUrl | String | 本段电子行程单 H5 链接;签发不可用时为 `null` | + +**finalPlanNotPublishedReason 取值**(五个值,同 batch): + +| 取值 | 触发条件 | +|------|----------| +| `STALE_FINALIZED_PLAN` | 存在按旧需求定稿的陈旧行 | +| `INVALID_PLAN_GENERATION` | 方案代际不一致 | +| `PLAN_INCOMPLETE` | 满派拓扑不完整 | +| `CAPACITY_INSUFFICIENT` | 未定稿分支载客量不足 | +| `GATE_UNSATISFIED` | 大交通要求的接/送机日未配车 | + +#### 请求示例 + +实测原文(`wf/8429/ac2/out/confirm_req_body.json`,AC-5,groups 精确传当前 2 个 assigned 执行段): + +```json +{ + "orderId": 2104161383220457474, + "requestId": "fleetqa-ac5-confirm-8337937430", + "expectedRequirementVersion": 4, + "expectedRequirementSha256": "24a2233c35565f31d14ecaaba4cf47abc56c592b5a0bb6b78f927ba4a949ca95", + "expectedPlanGeneration": 362569745205170176, + "groups": [ + { + "assignmentGroupId": 362556592543109120, + "sendItinerarySms": false + }, + { + "assignmentGroupId": 362569161085423616, + "sendItinerarySms": false + } + ] +} +``` + +#### 响应示例 + +确认成功且方案发布——实测原文(`wf/8429/ac2/out/confirm_resp.json`): + +```json +{ + "code": 200, + "message": "成功", + "data": { + "requirementId": "2104178895018459138", + "dispatchPlanGeneration": "362569745205170176", + "confirmed": true, + "finalPlanPublished": true, + "finalPlanNotPublishedReason": null, + "groups": [ + { + "assignmentId": "2104166776231403521", + "assignmentGroupId": "362556592543109120", + "assignmentStatus": "assigned", + "confirmedAt": "2026-09-27 19:10:28", + "sendItinerarySms": false, + "itinerarySmsEventId": null, + "itinerarySmsStatus": "NOT_SENT", + "itineraryUrl": null + }, + { + "assignmentId": "2104179344782008321", + "assignmentGroupId": "362569161085423616", + "assignmentStatus": "assigned", + "confirmedAt": "2026-09-27 20:00:24", + "sendItinerarySms": false, + "itinerarySmsEventId": null, + "itinerarySmsStatus": "NOT_SENT", + "itineraryUrl": null + } + ] + }, + "traceId": null, + "success": true +} +``` + +> 确认成功但未发布(`confirmed=true, finalPlanPublished=false, finalPlanNotPublishedReason="GATE_UNSATISFIED"` 等五值之一)的响应形状与上例相同,只是这三个字段取值不同——本轮测试未构造出该分支的原始抓包,字段定义见上方出参表与源码 `ConfirmRequirementRespVO.java`,取值语义见「finalPlanNotPublishedReason 取值」表。 + +#### 空数据 / 降级响应 + +无额外空态。确认成功但未发布时返回 `confirmed=true` + `finalPlanPublished=false` + 原因。 + +#### 错误响应 + +按日志重建(`wf/8429/ac2/ac2.log:48`,字段形状与本文档其余错误响应一致的 `Result` 包装): + +```json +{ + "code": 605056, + "message": "执行段集合已变化,请刷新后重试", + "data": null, + "traceId": null, + "success": false +} +``` + +| 错误码 | 触发条件 | +|---|---| +| 605056 | `groups` 未精确等于当前有效执行段集合(多传/少传任一均拒绝,非 602202/#8429 引入,**不变**但实测新确认;文档 `@ApiOperation` 未列出此码,属既有文档缺口)—— 实测:首次误传全部 6 个历史执行段(含已 completed)报本码,改传当前 2 个 assigned 执行段后成功 | +| 605914 | 大交通要求接机的日期未配置接机车辆(**不变**) | +| 605915 | 大交通要求送机的日期未配置送机车辆(**不变**) | +| 605062 | 存在派车日期越出当前需求日期窗的在途槽位行(**不变**) | +| 605037 / 605038 | 车辆维保或停用 / 司机休假或待激活,不可派(**不变**) | +| 605059 | 同一 `requestId` 用于了不同的确认内容,须换新 `requestId` 重试(**不变**) | +| 605063 | 原子确认回执已损坏,无法幂等重放;该码不可自愈,前端不得自动重试或静默轮询,须提示用户联系管理员(**不变**) | + +#### 业务边界 + +- **确认与发布分离**:确认本身与最终方案发布是两件事,`confirmed=true` 不代表 `finalPlanPublished=true`;前端以两个字段组合判断下一步。 +- **已完结行越窗**:不再拒绝确认;确认成功但未发布时按原因(通常是门禁不满足或方案不完整)回到前面的步骤补齐。 +- **行程短信与电子行程单**:只在 `finalPlanPublished=true` 时发送(不变)。 +- **`groups` 必须是精确集合,不是增量**:提交前须先取当前全部有效执行段(如通过看板/详情接口)再据其构造 `groups`,多传已完结的历史执行段或少传遗漏均触发 605056;本次实测已验证该约束真实存在(见上方错误响应)。 +- **605063 终态**:命中该码时前端应停止对同一 `requestId` 的自动重试,引导人工介入,重试不会改变结果。 + +--- + +## 四、契约约束与正确调用方式 + +### 判断发布结果 + +对所有三个接口,推荐判断顺序: + +``` +1. 若 finalPlanPublished = true + → 最终方案已发布,订单车控推进到 DONE(不变) +2. 若 finalPlanPublished = false 且 reason = GATE_UNSATISFIED + → 接送机未配齐,回到第③步配接送机 +3. 若 reason = PLAN_INCOMPLETE + → 派车方案不完整(缺车、缺司机等),回到第②步调整派车 +4. 若 reason = STALE_FINALIZED_PLAN 或 INVALID_PLAN_GENERATION + → 需重新确认需求(第④步 confirm),或方案代际变化需重新派车 +5. 若 reason = NO_GATE_TRANSITION(仅 pd) + → 本次未触发新的跃迁,重复保存或调整门禁再试 +``` + +### 前端不需要做的 + +- ❌ 按 `finalPlanPublished=false` 推断"哪里出了问题",必须用 `finalPlanNotPublishedReason` 字段精确判断。 +- ❌ 拦截错误码后自行决定前端跳转(跳转策略参考上面的判断顺序,但最权威的来源是后端原因字段)。 + +--- + +## 五、数据库行为 + +无 DDL。改动全在判据(读侧)与新字段(响应序列化)。 + +| 对象 | 改前 | 改后 | +|------|------|------| +| 派车行(已完结) | 越窗时满派判据返 false,方案永不发布 | 越窗时 dateValid 豁免,其他条件仍检查 | +| 派车行(在途) | 越窗时满派判据返 false | 不变 | +| 最终方案快照 | 发布条件只看门禁(batch 路径),或门禁+满派(pd/confirm 路径) | 三条路径统一走完整判据 | +| finalPlanNotPublishedReason 响应字段 | 无 | **新增**;发布时为 null;不发布时为首个未通过的原因 | +| 602202 上限 | 1(TRANSFER-only) | max(1, 需求声明车数)(TRANSFER-only) | + +--- + +## 六、边界行为 + +### 能否从 true 变成 false + +- **能**(方案不完整场景):改前 batch 可能因只看门禁而返 true,改后补上满派检查可能返 false。前端需捕获这个变化。 +- **缓解**:原因字段让前端精确提示运营下一步;越窗已完结行豁免缓解了大部分场景。 + +### 已完结行的豁免形式 + +- **只豁免 `dateValid`**(日期窗口检查);不豁免其他条件(有车有司机、形态合法、每个 key 恰一条)。 +- **只豁免 COMPLETED 行**;HOLDING / ASSIGNED 越窗照旧拒。 +- **不影响其他判据**(陈旧定稿、方案代际、载客量、门禁都不变)。 + +### 版本联动 + +本单改动不涉及其他服务。下游 order-v3 对 finalPlanPublished 的消费逻辑不变(true 时更新车控为 DONE,false 时保持原状)。 + +--- + +## 六.6、修改前后对比 + +### 字段级对比 + +| 字段 | 改前 | 改后 | +|------|------|------| +| batch / pd / confirm `finalPlanNotPublishedReason` | 无 | **新增**;发布时 null,不发布时为首个未通过判据的原因(五个通用值或两个 pd 专属值) | +| pd `finalPlanPublished`(已完结行越窗场景) | false | true(已完结行越窗不再阻止) | +| batch `finalPlanPublished`(必须配车的服务日本次未提交 `dailyPlan` 行覆盖) | true | false + `PLAN_INCOMPLETE`(补上满派检查) | +| 602202 上限 | 1(TRANSFER-only) | max(1, 需求声明车数)(TRANSFER-only) | + +### 行为级对比 + +| 行为 | 改前 | 改后 | +|------|------|------| +| 三个接口发布判据 | batch 只过门禁;pd/confirm 过满派+陈旧定稿+门禁 | 三者走同一判据:陈旧定稿→代际→满派→门禁 | +| 越窗已完结行 | 满派判据返 false,方案永不发布 | 豁免 dateValid;其他条件仍检查 | +| batch 存在必须配车服务日未提交 `dailyPlan` 行覆盖 | 门禁满足时发布(true) | 满派检查拦住,不发布(false + PLAN_INCOMPLETE) | +| 602202 文案(TRANSFER) | 「同一天同一方向只能有一条」 | 「{日期} 的 {方向} 已有 {已标条数} 条…超过接送机需求声明的 {声明车数} 辆」 | + +## 六.7、影响评估 + +- **是否破坏向后兼容**: 否。所有接口路径、入参、出参结构不变;错误码与文案(602202 除外)不变。只是三个接口新增一个字段,且 batch 的发布结果在「方案不完整」时取值会改变。 +- **前端是否必须同步上线**: 否。不改前端时: + - 管理员的调用流程与改前一致(只是 batch 时 false 需要按新原因字段判断下一步,而原来的 false 本身就很少出现); + - 新字段为 null 时前端可以忽略(安全字段,只在不发布时有值)。 +- **前端 workaround 清理点**: + - 如果前端有"batch 返 false 就兜底改 pd"之类的旧补偿逻辑,需以新原因字段为准判断是「接送机未配」还是「方案不完整」,而不是盲目走第③步。 + - 602202 的提示文案可从「不能多配」改为「不能超过声明车数」(文案已自解释)。 + - 已完结行越窗的"方案卡死"问题随之消失,如有针对性的降级/提示可撤。 + +--- + +## 七、不影响范围 + +- 其他接口:fleet 其他调用方、order-v3、mp 端、网关路由全不受影响。 +- 操作链路:第①②③④步的入参、错误码(除 602202 上限外)、取消/删除操作一概不变。 +- 数据结构:派车行、最终方案快照、需求实体的存储结构不变。 +- 已发布的快照:改前发的最终方案快照不重放,不在版本号上变化。 + +--- + +## 八、测试环境已验证 + +**环境**:TEST,hl-fleet-service `267ab6906` 于 2026-09-27 19:40:15 部署,hl-gateway `71def6dc5` 同期在线(`deploy-status` 于 20:00 前后与 20:25:46 两次核对读数一致,全程无重新部署)。以下均为 2026-09-27 19:58-20:27 网关实测(测试专用车务账号,role=车务),原始请求/响应/SQL/日志见 `wf/8429/ac2/result.md` 及同目录 `out/*.json`。 + +### AC-2:越窗已完结行不再卡发布,batch 与 pd 答案一致 —— 已验证 通过 + +三步链路(同一需求 `requirementId=2104178895018459138`,换版后 serviceDates 含新日期 2027-08-30): +1. **batch** 提交 [(08-15,车), (08-30,车)]:`finalPlanPublished=false, finalPlanNotPublishedReason=GATE_UNSATISFIED`;fleet 日志同步打出「接送机未配齐,暂不发布最终方案快照」。 +2. **pd** 一次性补标 08-15+08-30 两日接机:`finalPlanPublished=true, finalPlanNotPublishedReason=null`,`pickupDropoffGate.satisfied=true`;此后日志不再出现「未配齐」提示。 +3. **原样重放 batch**(20:02:43,未提交新行):仍 `finalPlanPublished=true, finalPlanNotPublishedReason=null`。 + +SQL 复核(20:02:43 之后):该需求名下 6 行全部 `dispatch_plan_finalized=1`,含越窗已完结行(08-03/08-04,`plan_finalized_requirement_id` 已指向当前需求)——越窗已完结行豁免、仍计入当前需求判定,符合预期。batch 与 pd 对同一份数据不再出现「一真一假」。 + +### AC-4:602202 改按声明车数动态判定 —— 已验证 通过 + +声明车数=2 时(需求 `2104183379455049729`,3 行落库 assigned):2 辆标接机 `http=200 code=200`;追加第 3 辆 → `http=200 code=602202`,文案含日期(2027-09-05)、方向(ARRIVAL)、3 个派车行 ID、声明车数「2」;失败调用前后 SQL 标记值完全一致,确认整批原子回滚。换版把声明车数降为 1(需求 `2104185762864115714`)后,同样 2 辆车即触发 602202,文案里的声明车数同步变为「1」。两级阈值均实测复现,判据已从固定值改为动态读需求声明车数。 + +### AC-5:三写口统一暴露 finalPlanNotPublishedReason —— 已验证 通过 + +- **batch**(即 AC-2 第一步):字段值 `GATE_UNSATISFIED`。 +- **pd**(AC-2 第二步同配置换新 requestId 重放,20:09:26):字段值 `NO_GATE_TRANSITION`(门禁已满足,重复提交不构成新的跃迁,不重新发布)。 +- **confirm**:`groups` 精确传当前 2 个 assigned 执行段后,响应 `data` 中存在 `finalPlanNotPublishedReason` 键,值为 `null`(已发布状态),见「三、接口详情」需求确认小节的响应示例。 + +三个写口的响应体均带该字段,取值随各自判据结果变化,字段本身在「发布」与「未发布」两种结果下都稳定出现。 + +### 已知边界 + +- **旧回执重放**(二期未上生产,仅测试服):本版本前生成的 confirm 回执若重放,可能出现 `finalPlanPublished=false` 而原因为 `null`。前端以 `finalPlanPublished` 为准,不要用「原因是否为空」反推是否已发布。 +- **count 缺失**:按 1 计,与改前等价。 +- **confirm 的 `groups` 必须精确等于当前有效执行段集合**:本轮实测中途误传过全部历史执行段(含已 completed),触发 605056,详见「三、接口详情」需求确认小节的错误响应。 + +--- + +## 九、相关历史 PR + +| PR | Issue | 说明 | 是否仍有效 | +|----|-------|------|-----------| +| — | #7067 | 统一发布入口,batch 补充门禁漏斗 | ⚠️ batch 返工时漏了满派与陈旧定稿守卫,本单补齐 | +| — | #5810 | 已完结行随平移与豁免(基线、代际) | ⚠️ 满派拓扑这一处漏了,本单补齐 | +| — | #7443 | 602202 首次引入(上限 1) | ⚠️ 上限被本单改为声明车数 | +| **本 PR #8451** | **#8429** | 三个写口统一判据、已完结行越窗豁免、602202 按声明车数、新增未发布原因字段 | ✅ 最新 | + +--- + +## 十、相关文档 + +- **Issue**: [#8429](https://git.1814.love/wx/HL/issues/8429) +- **PR**: [#8451](https://git.1814.love/wx/HL/pulls/8451) +- **上游缺口**: [#5810](https://git.1814.love/wx/HL/issues/5810)(已完结行口径)、[#7067](https://git.1814.love/wx/HL/issues/7067)(发布统一)、[#7443](https://git.1814.love/wx/HL/issues/7443)(602202 来源) +- **本轮实测新确认的既有缺陷**: [#8453](https://git.1814.love/wx/HL/issues/8453)(接送机配置 `kind` 省略时绕过 605905/602202/门禁判据,纯 TRANSFER 订单需显式传 `kind`;非本单引入,详见「三、接口详情」批量派车与接送机配置两节的业务边界) + +## 关联 / 联系人 + +### 链接 + +- **Issue**: [#8429](https://git.1814.love/wx/HL/issues/8429) +- **PR**: [#8451](https://git.1814.love/wx/HL/pulls/8451) +- **Merge commit**: [267ab6906](https://git.1814.love/wx/HL/commit/267ab6906) + +### 联系人 + +- **后端负责人**: @wx