---
schema: "hl-changelog/v2"
ticket: "frontend"
title: "派单 Step2「下一步·发送给司机」置灰 / 日格恒显 1/3：完成度只认前端会话内的候选证据戳，已定稿豁免因绑死 readOnly 而永不生效"
consumer: "admin"
change_type: "前端缺陷"
author: "wx(GIT)"
backend_status: "not_required"
gateway_status: "not_required"
frontend_status: "implemented"
frontend_owner: "mmg"
frontend_ref: "bce7fbed"
target_release: ""
verified_at: "2026-08-11"
status_note: "前端已实现(2026-08-11,mmg,bce7fbed),按 wx 拍板口径 A+B 分步:①方案A 降级候选证据硬门,isDailyVehiclePlanCellComplete 车/司机/金额结构齐全即 return true,validateDailyVehiclePlan 的 requireCandidateEvidence 默认 false(显式开启才软校验);②方案B 修豁免通道,markPreservedFinalizedCell/isUnchangedFinalizedPreservedCell 去 readOnly||locked 绑定只认 planFinalized===true+签名未变,判定式两处三元改无条件调用,已定稿可编辑格未改动即完成、改动失效回资源判定。第七节同源缺陷(新增槽位误传 excludeAssignmentId)实证主链路已被 #5788 返工#2 修好(createFleetDraftSlotSelection 无 assignmentId→undefined 不传),契约实测截图来自部署包滞后于本地修复,不重复改。daily-vehicle-plan.spec 候选证据拆「默认不硬拦/显式软校验」+新增已定稿可编辑豁免用例,useAssignFlow.spec 候选证据两 case 改「不再硬拦批量提交」,assign-modal-title.spec 证据失效改「清空但不再卡 selectionReady」。fleet/board 442 全绿,checkpoint 精确文件集全过。可选增强(独立灰色软提示 UI)未做,完成度与按钮均不读证据,属增强非修复必需。"
updated_at: "2026-08-11"
base: "dev-v3"
---

# 派单 Step2「下一步」置灰 / 日格恒显 1/3（#5849 前端部分）

> **页面**：车务管理 → 派单看板 → 订单派车弹窗 → Step2「排车」
> **后端**：本条**零改动**。下面所有结论来自测试服真实 API、真实 DB 与**线上部署包**（`/var/www/hl-admin/assets/`，mtime 2026-08-11 18:06，比本地 hl-ui HEAD 新）三向核实。

---

## 一、现象

订单 **26-3698**（08/17→08/19，成人 10），车辆槽位 1：

- 三天**全部「已规划用车」、全部勾「用车」**，车 `蒙A-E5555 丰田埃尔法`、司机 `阿木古愣`、日历价与派车价均 ¥2500 全填
- 8月18日 挂橙标：「2026-08-18 车辆槽位1 尚未通过当前日期、车辆和司机的候选校验」
- 顶部「1 个槽位 · **1/3 个日格已完成**」
- 底部「已完成 **0/1** 个最终方案槽位」
- 「下一步 · 发送给司机（1 辆）」**置灰**

用户原话：**「3 日的行程都有数据，为啥完成 1/3」**。

---

## 二、后端已彻底排除

`GET /admin/fleet/board/orders/2086270138171994114` 返回的三个日格**逐字段对称**，只有 `assignmentId` / `serviceDate` / `pickup*` 不同：

```json
{
  "serviceDate": "2026-08-18",
  "fleetItemIndex": 0,
  "assignmentSlotId": "344659955019812864",
  "assignmentId": "345501044920422400",
  "assignmentGroupId": "2086270138708844545",
  "planFinalized": true,
  "planState": "USED",
  "used": true,
  "readOnly": false,
  "readOnlyReason": null,
  "pickupParticipant": false,
  "pickupRequired": false,
  "vehicleId": "2064998142394183681",
  "vehiclePlate": "蒙A-E5555",
  "vehicleModel": "丰田埃尔法",
  "driverId": "2065272145633591298",
  "driverName": "阿木古愣",
  "calendarPrice": "2500.00",
  "assignmentPrice": "2500.00",
  "priceSource": "CALENDAR",
  "priceAdjustmentReason": null,
  "assignmentStatus": "holding"
}
```

`POST /admin/fleet/assignments/candidates` 全组合实测——**车与司机、单日与整段（8/17→8/19）、三种 `excludeAssignmentId` 传法（组锚点 / 该日行真实 id / 不传）一律**：

```
车   available=true  selectable=true  availabilityReasonCode=AVAILABLE  conflicts=[]
司机 available=true  selectable=true  availabilityReasonCode=AVAILABLE  conflicts=[]
```

DB 侧：跨订单全库核查该车该司机 08/17~08/19 **零外单占用**；车 `service_status=ACTIVE` 未维保；司机 `season=active` 未休假、且是该车 primary driver（不触发跨常驻确认）；价格日历 08-15~08-22 每天 2500 AVAILABLE 不缺价。

**Step2 → Step3 全程零 HTTP**：`useAssignFlow.js` 约 :685-701 的 `onStep2Next` 只做 `step.value = 3`。

---

## 三、根因

### 3.1 判定式逐行代入真实数据

`utils/daily-vehicle-plan.js` 约 :443-466（部署包 `daily-vehicle-plan-CQiA9Iru.js` 逐字一致）：

| # | 代码 | 代入真实 cell | 结果 |
|---|---|---|---|
| 1 | `if (planState === UNPLANNED && !used) return false` | `USED` / `used=true` | 不早退 |
| 2 | `const preservedFinalized = (readOnly \| locked) ? isUnchangedFinalizedPreservedCell(cell) : false` | `readOnly=false`，`locked` 后端不下发 | **`false`（三元走 else，`isUnchangedFinalizedPreservedCell` 根本没被调用）** |
| 3 | `if ((readOnly \| locked) && !preservedFinalized) return false` | 条件为 false | 跳过 |
| 4 | `if (!used) return true` | `used=true` | 继续 |
| 5 | `if (!vehicleId \|\| !driverId) return false` | 两者都有 | 继续 |
| 6 | `if (!isValidAssignmentPrice(assignmentPrice)) return false` | `"2500.00"` 合法 | 继续 |
| 7 | `if (preservedFinalized) return true` | `false` | **不提前通过** |
| 8 | `if (needsPriceReason && !reason) return false` | `2500.00 === 2500.00` 不算改价 | 继续 |
| 9 | `if (requiresCrossResidentConfirmation && !confirmCrossResident) return false` | 后端不下发该字段 → `undefined` | 继续 |
| 10 | `return preservedFinalized \|\| dailyVehiclePlanCandidateEvidenceMatches(cell)` | `false \|\| evidence?` | **← 唯一决定因素** |

**这三格的完成与否，100% 只取决于 `_candidateEvidence`。**

### 3.2 `_candidateEvidence` 是纯前端会话内存字段，服务端回显的格子恒 null

后端 `DailyVehiclePlanVO` 没有这个字段（见上面响应），前端 `createDailyVehiclePlan` 也从不合成、只做透传（约 :184-186）。

| 来源 | `_candidateEvidence` |
|---|---|
| 用户当场在选车抽屉里选车/选司机 | 写入（`AssignModal.vue` 约 :2122-2127 批量、:2477 单格） |
| **从服务端快照重建日格** | **硬写 `null`**（`utils/canonical-assignment-snapshot.js` 约 :134） |
| 新建槽位空白格 | `null`（`AssignModal.vue` 约 :3560） |

### 3.3 🔴 唯一豁免 `preservedFinalized` 因为绑死 `readOnly` 而永不生效

```js
// :66-70
export function markPreservedFinalizedCell(cell) {
  if (!(cell.readOnly || cell.locked) || cell.planFinalized !== true) return cell   // ← 直接原样返回
  if (cell._preservedPlanSignature) return cell
  return { ...cell, _preservedPlanSignature: preservedPlanSignature(cell) }
}

// :72-79
function isUnchangedFinalizedPreservedCell(cell = {}) {
  return Boolean(
    (cell.readOnly || cell.locked) &&        // ← 又要求一次
    cell.planFinalized === true &&
    cell._preservedPlanSignature &&
    cell._preservedPlanSignature === preservedPlanSignature(cell)
  )
}
```

后端对这三格下发的是 **`planFinalized: true` + `readOnly: false`**，即「**已定稿、但仍可编辑**」。这个组合三道都过不去：

1. `markPreservedFinalizedCell` 第一行就 `return cell`，**`_preservedPlanSignature` 从来没被写上**；
2. 即便写上了，`isUnchangedFinalizedPreservedCell` 仍要求 `readOnly || locked`；
3. 而且 3.1 第 2 步的三元决定了这个函数**压根不会被调用**。

**→ 对「已定稿但可编辑」的日格，豁免通道彻底是死的**，只能回落到候选证据。

> **更正一条早先的说法**：先前判断「#5851 换版即作废定稿把 `planFinalized` 置成 false 从而关闭豁免」——**不成立**。接口实测 `planFinalized` 为 `true`。豁免关闭的原因是上面的 `readOnly` 绑定，与 #5851 无关。

### 3.4 为什么偏偏是「1/3」、且只有一天挂橙标

两条机制叠加，**不是「中间那天特殊」**：

1. **只有 1 格能拿到证据**：弹窗初始化自动激活「第一个未完成的用车格」（`AssignModal.vue` 约 :3536-3541；`dailyPlan` 按日期升序，`daily-vehicle-plan.js` 约 :201-205）→ 激活 **8/17**，抽屉对该单日拉候选并**只回写这一格**证据（约 :2418-2481）。8/18、8/19 **从未被激活，永远拿不到证据**。
2. **两天都不合格，只显示第一条**：`validateDailyVehiclePlan` 返回**第一条**错误；能定位到具体格时 `dailyPlanGlobalValidationError` 返回空串（约 :1820-1836），改由矩阵按 `serviceDate` + 槽位号渲染**单行**橙标（`DailyVehiclePlanMatrix.vue` 约 :296-301）。所以只有 8/18 挂标，**8/19 同样不合格只是没显示**。另一单表现为 8/21 同理。

**排除项**：「接机/参与」勾选**不是**原因——`isDailyVehiclePlanCellComplete` 通篇不读 `pickupParticipant`。

### 3.5 按钮与两个读数的完整链路

```js
// AssignModalFooter.vue 约 :107
:disabled="!selectionReady || submitting"

// AssignModal.vue 约 :1837-1846
selectionReady = batchMode
  ? selectedSlots.length > 0 && !selectionBlockedReason && !dailyPlanValidationError
  : ...

// 「N/M 个日格已完成」 约 :1526
dailyPlan.filter(isDailyVehiclePlanCellComplete).length

// 「已完成 N/M 个最终方案槽位」 约 :2416
slot.selectionReady = cells.every(isDailyVehiclePlanCellComplete)

// dailyPlanValidationError 约 :1808 → validateDailyVehiclePlan(...)
// 其中约 :571-577 抛出那句橙标：
if (requireCandidateEvidence && !preservedFinalized
    && !dailyVehiclePlanCandidateEvidenceMatches(cell)) {
  return `${label}尚未通过当前日期、车辆和司机的候选校验`
}
```

### 3.6 ⚠️「统一选择车辆/司机 → 应用到槽位全部日期」**不能**当绕过手段（wx 实测无效）

该路径确实会给整槽用车格批量写证据（约 :2102-2128），但它自己还有三道前置门：

1. `applySlotResource` 要求 `selVehicle && selDriver` 都已在抽屉内选中，否则 `message.warning('请先选择车辆和司机')` 直接返回（约 :2045-2050）；
2. `applySlotResourceSelection` 开头要求 `candidateSelectionReady`，重查一次仍不 ready 就 `return`（约 :2064-2080）；
3. `targetCells` 只取 `cell.used && !cell.readOnly && !cell.locked`（约 :2087-2092），空集直接返回。

**不要把它写进用户手册当 workaround**，必须从判定逻辑本身修。

---

## 四、建议改法

**核心认识：`_candidateEvidence` 是对后端既有保证的重复设卡。** 提交时后端会在锁内重新校验资源可用性、档期冲突、车辆维保、司机休假、日期窗，真有问题会返回明确错误码（605001/605003/605037/605038/605062）。前端不该要求车务为「本来就派好、且没改过」的日格重新点一遍选车抽屉。

### 方案 A（最快，2 行，直接满足 wx 口径）

| # | 位置 | 现在 | 改为 |
|---|---|---|---|
| 1 | `daily-vehicle-plan.js` 约 :484 | `requireCandidateEvidence = true` | `false` |
| 2 | `daily-vehicle-plan.js` 约 :465 | `return preservedFinalized \|\| dailyVehiclePlanCandidateEvidenceMatches(normalized)` | `return true` |

**必须同改**：只改 1 → 按钮能点但计数仍显 1/3；只改 2 → 计数对了但按钮仍被 `dailyPlanValidationError` 卡住。

### 方案 B（更保守，保留「改动过要重验」的语义）

1. **修好豁免通道**：把 `markPreservedFinalizedCell` 与 `isUnchangedFinalizedPreservedCell` 的 `readOnly || locked` 前置去掉，只要 `planFinalized === true` 且签名未变即视为「已定稿未改动」；同时把 `isDailyVehiclePlanCellComplete` 第 2 步的三元改为**无条件**调用 `isUnchangedFinalizedPreservedCell(normalized)`。
   → 后端已定稿、用户没动过的格子天然完成；用户一改车/司机/价格，签名变化自动失效，仍需重验。
2. **从服务端快照重建日格时补齐 `_candidateEvidence`**（`serviceDate` / `vehicleId` / `driverId` / `confirmCrossResident` 直接取该行的值）——把「后端已存在这条派车行」本身当作证据。
3. 即使证据缺失也**不要硬置灰**，降级为灰色软提示，由后端做最终裁决。

### 建议

**先上方案 A 解阻塞**（wx 现在卡着），再按方案 B 把语义修正确。两者不冲突。

### 可选增强（与 #5810 / #5824「只提示不门禁」同构）

新增独立 computed 调 `validateDailyVehiclePlan(..., { requireCandidateEvidence: true })`，渲染成**灰色软提示**、**不接入 `selectionReady`**——保留「这天还没重新验过」的信息，但不卡流程。

### 回归

同步调整 `__tests__/assign-modal-title.spec.js`（约 1726 / 1744 / 1988 / 2069 / 2208 / 2236 行）与 `utils/__tests__/daily-vehicle-plan.spec.js` 的期望值。

---

## 五、目标行为（wx 2026-08-11 拍板）

> 只要**槽位里有司机、有车辆，且日期是对的**，就可以进行下一步。**定制师的需求变了，但车务可以不改派车**；和需求数对不上也可以。

与 #5810「换版槽位人工制」、#5824「车型/数量不符只提示不做门禁」一脉相承。

| 类别 | 项 | 判定方 | 现状 | 应然 |
|---|---|---|---|---|
| **硬** | 同车/同司机同日期被**别单**占用 | 后端 605001/605003 + 锁内重校验 | 硬 | 硬 ✅ |
| **硬** | 派车日期越出当前需求窗 | 后端 605062（只看日期集合，不含车型/数量/座位/人数） | 硬 | 硬 ✅ |
| **硬** | 车辆维保/停用 | 后端 605037 | 硬 | 硬 ✅ |
| **硬** | 司机休假/待激活 | 后端 605038 | 硬 | 硬 ✅ |
| **硬** | 同日重复车/司机 | 前后端都有 | 硬 | 硬 ✅ |
| **硬** | 用车格缺车/缺司机 | 前后端都有 | 硬 | 硬 ✅ |
| 软 | 车型不匹配 | 后端已降级 | 软 | 软 ✅ |
| 软 | 座位不足 / 槽位数 vs 需求数 | 后端不拦 | 软 | 软 ✅ |
| **软** | **日格「候选证据」完成度** | **前端独有** | **硬** | ❌ **降级为提示** |

> 提醒：**车型不匹配不影响 `selectable`**。后端 `selectable = available && conflicts 全部非阻断`，`requirementMatched` / `seatsEnough` 均不参与。请勿把车型不匹配纳入按钮启用条件。

---

## 六、后端侧：本条 0 改动，放开后不会再被拒

| 守卫 | 结论 |
|---|---|
| Step2 → Step3 | **无任何后端调用** |
| `batchCreate` 契约 | 明文「可少派、多派…车型/数量/人数与需求不符不拦截」；`strictSeats` 三处硬编码 `false` |
| 605062 日期越窗 | 判定式只看日期集合归属 |
| `hasInvalidDispatchPlanGeneration` | 本单 `generation=NULL` / `finalized=0` → false，不拦 |
| `isRequirementFullyAssignedRows` | 只 return false 从不抛，用于快照发布与订单车控 DONE 判定，**非提交门禁** |
| `assertAtomicGroupReady` | Step4 确认执行门禁，与 Step2 无关 |
| 「每个保留槽位必须覆盖全部服务日」(100001) | 要求每槽×每日一条 item，`used=false` 空格合法；前端本就按满矩阵提交，天然满足 |

---

## 七、⚠️ 同源的第二个前端缺陷（独立问题，本次修完仍需处理）

测试服 `hl-fleet-service.log` 17:40:39~17:41:20 抓到 **8 条**同款 WARN：

```
候选查询排除派单与请求槽位上下文不一致，已忽略排除继续查询:
  reqFleetItemIndex=2  excludedFleetItemIndex=0
  excludedAssignmentId=2086270138708844545  excludedStatus=holding
```

**新增槽位（index=2）时，前端仍把 slot0 的 `assignmentId` 当 `excludeAssignmentId` 传。** 后端判定槽位上下文不一致后**静默放弃排除**（不报错、如实展示占用），于是本单自己的占用被算成阻断冲突 → 该车/司机在新槽位下 `selectable=false`。实测：`fleetItemIndex=1` 或 `2` 时三天全部 `selectable=false`，冲突体正是本单自己。

**影响**：新增槽位**永远拿不到候选证据**——与本条是同一个死结的两端。

**前端改法**：新增槽位时不要沿用其它槽位的 `assignmentId`；该槽没有既有派车行就**不要传** `excludeAssignmentId`。

> 后端侧会加固（候选入参支持按槽位排除，不再靠反查单行推断槽位身份），届时另发接口类 changelog。

---

## 八、关联

- 后端工单 #5849
- `selectable` 字段口径：`11_5850_候选接口显式返回selectable-修改接口-管理后台.md`
- 换版后车型陈旧快照（候选列表误标「需求不匹配」）：#5871，修复后另发
