---
schema: "hl-changelog/v2"
ticket: "5299"
title: "车务矩阵车辆司机预选与常驻标记"
consumer: "admin"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "implemented"
frontend_owner: "hl-admin"
frontend_ref: "45c713a8b30b13c9b57d102cfcce82885744a7e9"
target_release: ""
verified_at: "2026-07-28T14:38:26+08:00"
status_note: "hl-admin 已修复 FleetAssignModal 递归更新、补齐车辆预选/常驻三态与车辆行实际司机汇总；最终提交 45c713a8 已在 origin/v2.1 可达，checkpoint 通过"
updated_at: "2026-07-28"
base: "dev-v3"
---

# Fleet：矩阵车辆司机预选与常驻标记

> **服务**：`hl-fleet-service`
> **Issue**：#5299
> **影响页面**：管理后台车务管理 → 矩阵派单
> **兼容性**：仅新增响应字段，旧客户端可继续忽略

## 问题与目标

当前矩阵车辆行虽然已有常驻司机姓名，但缺少常驻司机稳定 ID；订单甘特条也没有直接返回该派车组的实际车辆、司机及常驻关系。页面因此无法稳定完成以下行为：

- 从具体车辆上下文发起派单时自动带出车辆和可派常驻司机；
- 行头展示车辆常驻司机；
- 已排订单条块展示实际执行司机，并区分常驻/临时司机。

本次后端补齐稳定读契约。前端不得按姓名判断常驻关系，也不得复制相邻订单的临时司机作为新派单默认值。

## 2026-07-28 前端运行态阻断：`FleetAssignModal` 递归更新

### 现场结论

前端实现提交 `bfcfafc69335f289071fe3e6dce74bacb636c55d` 已进入 `v2.1`，但当前测试页面打开逐日派车弹窗并加载车辆、司机候选后稳定出现：

```text
Maximum recursive updates exceeded in component <FleetAssignModal>
```

浏览器 Console 同时记录多次 `unhandledrejection`；Network 中多条 `POST /admin/fleet/assignments/candidates` 均返回 HTTP 200，但车辆、司机区域持续停留在 loading，无法进入下一步。因此本次不是候选接口、日期冲突或后端状态机错误，而是前端实现后的响应式自反馈回归。`frontend_status: "implemented"` 仅表示代码已存在，不代表已发布或页面闭环。

### 高可信自反馈链

当前 `AssignModal.vue` 的深度 watcher 同时观察 `candidateEvidence`、`candidateSelectionReady`、`autoSelectedResidentDriverSource` 等候选派生状态，并在回调 `syncActiveSlotSelection()` 中无条件重建 `dailyPlan`、回写当前槽位。`dailyPlan` 又参与计算其他槽位排除 ID、候选可选态和新的 `candidateEvidence`；即使业务值没有变化，新数组/对象身份仍会再次触发同一 watcher，形成“观察候选派生值 → 回写逐日方案 → 候选派生值重新计算 → 再次回写”的闭环。

前端修复必须同时满足：

1. `syncActiveSlotSelection()` 先比较当前日格与待写 payload；语义完全相同时直接返回，不创建新 `dailyPlan`/槽位对象。
2. watcher 使用稳定原始值或稳定 fingerprint，不深度监听会被自身回写间接失效的派生对象；候选响应、用户选择和槽位同步应有单向边界。
3. `updateDailyVehiclePlanCell()` 或等价更新器在无真实字段变化时返回原引用，禁止仅因对象重建触发后续 effect。
4. 保留 #5283 的稳定 `initializationIdentity` 与 `requestSeq` 旧响应隔离；不得退回监听 `props.order` 对象身份或通过删除并发保护掩盖循环。
5. 正常打开弹窗只允许一次初始候选请求；需要自动常驻司机二次校验时最多再请求一次。状态稳定后不得继续请求，loading 必须收敛。

### 前端回归验收

- [ ] 打开订单逐日派车弹窗并取得候选成功响应后，Console 不再出现 `Maximum recursive updates` 或相关 `unhandledrejection`。
- [ ] 候选请求数量有明确上界；自动常驻司机场景最多“初始查询 + 携两侧 ID 二次校验”，不存在持续请求。
- [ ] 车辆、司机列表结束 loading，接口返回的分页总数与页面一致，可正常进入下一步。
- [ ] 已选车辆、司机、逐日费用和跨常驻确认写回一次后保持稳定，多轮 `nextTick` 不再重建相同 `dailyPlan`。
- [ ] 相同业务身份的 SSE/列表对象替换仍保留候选与草稿；真实订单、需求、槽位或模式变化时才重新初始化。
- [ ] 新增真实挂载 `FleetAssignModal` 的回归测试，模拟候选成功和自动常驻二次校验，断言无未处理 Promise、请求次数有界且草稿稳定；仅做静态源码断言不足以验收。

## 变更接口

### `GET /admin/fleet/matrix/grid`

`data.vehicles[]` 新增：

| 字段 | 类型 | 空值规则 | 说明 |
|---|---|---|---|
| `primaryDriverId` | `String(Long)` | 无常驻司机为 `null` | 车辆主档的权威常驻司机 ID |

既有 `primaryDriverName`、`primaryDriverPhone` 继续返回；手机号保持脱敏。三个字段共同用于行头展示，常驻判断以 ID 为准。

`data.vehicles[].assignments[]` 新增：

| 字段 | 类型 | 空值规则 | 说明 |
|---|---|---|---|
| `vehicleId` | `String(Long)` | 未派为空 | 该甘特条对应派车组的实际车辆 ID |
| `vehiclePlate` | `String` | 未派为空 | 实际车牌 |
| `driverId` | `String(Long)` | 未派为空 | 该派车组实际司机 ID |
| `driverName` | `String` | 未派为空 | 该派车组实际司机姓名 |
| `residentMatch` | `Boolean` | 无实际司机为 `null` | `true`=实际司机是该车常驻司机；`false`=实际司机存在但不是该车常驻司机或车辆无常驻 |

矩阵继续排除取消切片；同一派车组存在取消日缺口时按有效连续服务段拆成多个甘特条，不得把外包络日期当作司机持续占用。

## 前端必须调整

### 1. 从矩阵车辆上下文发起派单

1. 将所在行 `vehicles[].id` 直接作为当前选择车辆，并在候选请求中传 `selectedVehicleId`。
2. 候选接口 `POST /admin/fleet/assignments/candidates` 会返回既有字段 `selectedVehicleResidentDriver`。
3. 仅当该快照存在且 `available=true` 时，才把其司机 ID 作为默认司机。
4. 无常驻司机、常驻司机冲突或不可派时，车辆仍保持预选，司机保持“待选择”，并展示候选返回的不可用原因。
5. 不得取前后相邻订单的实际司机作为新订单默认司机。

### 2. 矩阵司机展示

#### 2.1 车辆行司机汇总数据源

后端契约已经足够，前端不得新增接口或按车牌反查司机：

- 常驻司机取当前车辆行 `vehicles[].primaryDriverId`、`primaryDriverName`；常驻关系以 ID 为准。
- 订单实际司机只取同一车辆行当前响应中的 `vehicles[].assignments[].driverId`、`driverName`；这些是当前矩阵月份和筛选条件已加载的真实派车段。
- `assignments[]` 后端已按月内 `startDay` 升序返回。前端按响应数组顺序扫描，以司机首次出现的位置作为其他司机的稳定展示顺序，不按姓名另行排序，也不混入上一月份、上一筛选条件、未派窗口、候选列表或 `drivers` 页面数据。
- `residentMatch` 继续用于订单条块的常驻/临时三态标记；车辆行汇总去重使用稳定 `driverId`，不得只按姓名猜测同一人。

#### 2.2 汇总与展示规则

1. 若 `primaryDriverId` 非空，先把常驻司机放在结果第一项，显示 `primaryDriverName（常驻）`。ID 存在但姓名异常为空时使用 `姓名未标注（常驻）`，不得输出空白项。
2. 随后按 `assignments[]` 当前顺序遍历实际司机：`driverId` 或去空后的 `driverName` 为空则跳过；相同 `driverId` 只保留第一次出现。
3. 订单实际司机与 `primaryDriverId` 相同时，不再追加普通姓名，只保留第一项带“（常驻）”标记的展示。
4. 其他实际司机按首次出现顺序追加，使用中文逗号 `，` 连接。不得把同一司机跨多个订单或拆分派车段重复展示。
5. 无常驻司机但存在订单实际司机时，直接展示实际司机汇总，**不得只显示“无常驻司机”**。
6. 只有常驻司机和订单实际司机都不存在时，才显示“无常驻司机”。若行宽不足允许视觉省略，但必须通过 `title`、tooltip 或等价交互查看完整汇总，不得静默丢失司机。

展示样例：

| 常驻司机 | 当前行订单司机（按首次出现顺序） | 车辆行展示 |
|---|---|---|
| 张三 | 张三、李四、王五、李四、赵六 | `张三（常驻），李四，王五，赵六` |
| 无 | 李四、王五、李四 | `李四，王五` |
| 张三 | 张三、张三 | `张三（常驻）` |
| 无 | 空 | `无常驻司机` |

#### 2.3 订单条块保持既有语义

已排订单条块继续展示本条 `driverName`：

- `residentMatch=true`：标记“常驻”。
- `residentMatch=false`：标记“临时”。
- `residentMatch=null`：显示“待派司机”。

不要从 `drivers` 页面列表或姓名/车牌文本反推常驻关系；所有 Long ID 继续按字符串比较，禁止转为 JS `Number`。

#### 2.4 前端验收清单

- [ ] 截图中车辆无常驻司机但订单已有实际司机时，车辆行显示订单实际司机姓名，不再只显示“无常驻司机”。
- [ ] 常驻司机与多名订单司机并存时，展示严格为 `张三（常驻），李四，王五，赵六`，常驻第一且只出现一次。
- [ ] 同一实际司机出现在多个订单或多个有效派车段时只展示一次；空 ID、空姓名和未派条块不产生空白分隔项。
- [ ] 其他司机顺序跟随当前 `assignments[]` 首次出现顺序；切换月份、车队、车型或状态筛选后按新响应重新计算，不残留旧司机。
- [ ] 订单条块的 `driverName/residentMatch` 常驻、临时、待派展示不回归。
- [ ] 补充纯汇总函数或 `VehicleGantt` 组件测试，至少覆盖上表四组样例、Long ID 字符串去重和筛选响应替换。
- [ ] 真实页面复验行宽溢出场景可查看完整司机列表，Console 无异常，且不增加司机列表或候选接口请求。

在本节完成并通过真实矩阵页面复验前，`frontend_status` 保持 `claimed`，不得流转为 `implemented/released/verified`。

### 3. 同步完成 #5292 页面改造

用户截图仍出现“收取车费日期”，说明当前测试页面仍在使用旧构建或旧逻辑。新页面必须继续执行 #5292：

- 删除“收取车费日期”及旧免费日期提交逻辑；
- 使用 `dailyVehiclePlan` 渲染“服务日期 × 稳定车辆槽位”；
- 保存时只提交 `dailyPlan`，不得同时提交旧 `items`、`chargeableServiceDates`、`vehicleFeeWaiverReason` 或 `confirmAllServiceDatesFree`。

## 响应示例

```json
{
  "id": "9007199254740993",
  "plate": "蒙A-88888",
  "primaryDriverId": "9007199254740994",
  "primaryDriverName": "王师傅",
  "primaryDriverPhone": "138****1234",
  "assignments": [
    {
      "id": "9007199254740995",
      "assignmentGroupId": "9007199254740996",
      "vehicleId": "9007199254740993",
      "vehiclePlate": "蒙A-88888",
      "driverId": "9007199254740994",
      "driverName": "王师傅",
      "residentMatch": true
    }
  ]
}
```

所有 Long ID 仍按 JSON 字符串处理，禁止转为 JS `Number`。

## 不影响范围

- 不修改派单状态机、车辆/司机占用、保险、对账或常驻关系写入。
- 不自动选择不可派司机，不绕过候选接口与最终派单锁内校验。
- 不修改 `hl-ui` 仓库；前端消费状态独立流转。

## 验证证据

- 后端 PR [wx/HL#5300](https://git.1814.love:8443/wx/HL/pulls/5300) 已 squash 合并至 `dev-v3`，合并提交 `fca8cedc6`。
- `hl-fleet-service` 已滚动部署测试环境，8087/8187 双实例健康。
- 定向矩阵测试：43 tests，0 failures，0 errors，0 skipped。
- Fleet reactor verify：2456 tests，0 failures，0 errors，1 skipped；模块 Spotless、Jar、JaCoCo 成功。
- 真实测试网关 `GET /admin/fleet/matrix/grid?year=2026&month=8&season=active`：HTTP/code 200；返回 19 辆车、38 个派车段，新增车辆/司机字段完整，Long ID 均为字符串，`residentMatch` 三态合法，常驻司机手机号全部脱敏，无业务写入。
- 网关证据：`D:/work2/HL-v3/.tmp/5299-gateway.json`，SHA-256 `d94ee78fbe52cc193a28e5bf182e1353d2eca9d87824b1c2eb526c62a4fe645d`。
- OpenAPI/oasdiff：项目尚未配置可复现 Swagger2→OAS3 与 oasdiff，状态为 `not_configured`；使用源码字段对比、Controller 序列化测试及真实网关响应作为人工回退证据，未冒充工具通过。

当前状态：后端已部署、网关已验证；前端仅有可达的部分实现 `bfcfafc6`，运行态复验及车辆行司机汇总均未通过，因此 source 状态保持 `claimed`；修复并通过上述页面验收前不得流转为 `implemented`、`released` 或 `verified`。

关联：#5299、#5292、#5283。
