docs(changelog): #8429 车务最终实派方案发布判据统一 + 未发布原因字段 finalPlanNotPublishedReason
changelog-filename-gate / validate (push) Failing after 2s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
这个提交包含在:
API Changelog Bot
2026-09-27 21:42:38 +08:00
共同撰写人 Claude Opus 5.5
父节点 2f02fbb3a3
当前提交 96b920881c
@@ -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&lt;DailyPlanItem&gt; | ✅ | 最多 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&lt;Item&gt; | **不变**:本次提交生效的派单结果;`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&lt;...&gt; | **不变**:成功时为 `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&lt;ConfigItem&gt; | ✅ | 最多 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&lt;GroupDecisionVO&gt; | ✅ | `@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&lt;GroupResultVO&gt; | **不变**:各执行段确认结果 |
| 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