hl-api-changelog/changelogs-v2/2026-08/11_frontend_派单Step2下一步置灰与日格未完成误判-前端缺陷-管理后台.md
2026-08-11 18:51:03 +08:00

16 KiB

schema, ticket, title, consumer, change_type, author, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
schema ticket title consumer change_type author backend_status gateway_status frontend_status frontend_owner frontend_ref target_release verified_at status_note updated_at base
hl-changelog/v2 frontend 派单 Step2「下一步·发送给司机」置灰 / 日格恒显 1/3完成度只认前端会话内的候选证据戳,已定稿豁免因绑死 readOnly 而永不生效 admin 前端缺陷 wx(GIT) not_required not_required pending mmg 对应后端工单 #5849。后端已用真实 API + DB 双向排除(车与司机、单日与整段、三种 excludeAssignmentId 传法,全部 available=true / selectable=true / conflicts=[])。根因在前端 isDailyVehiclePlanCellComplete完成度最终只取决于会话内存字段 _candidateEvidence,而服务端回显的日格该字段恒 null;唯一豁免 preservedFinalized 额外要求 readOnly||locked,后端下发的是 readOnly:false + planFinalized:true,故豁免分支永不进入。结论已用测试服部署包核实,非本地陈旧源码。注先前版本称『豁免被 #5851 换版作废定稿关闭』有误,接口实测 planFinalized 为 true,与 #5851 无关,已更正。 2026-08-11 dev-v3

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

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


一、现象

订单 26-369808/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* 不同:

{
  "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/1708/19 零外单占用;车 service_status=ACTIVE 未维保;司机 season=active 未休假、且是该车 primary driver不触发跨常驻确认;价格日历 08-1508-22 每天 2500 AVAILABLE 不缺价。

Step2 → Step3 全程零 HTTPuseAssignFlow.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=falselocked 后端不下发 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 单格)
从服务端快照重建日格 硬写 nullutils/canonical-assignment-snapshot.js 约 :134
新建槽位空白格 nullAssignModal.vue 约 :3560

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

// :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 从而关闭豁免」——不成立。接口实测 planFinalizedtrue。豁免关闭的原因是上面的 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 按钮与两个读数的完整链路

// 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. 修好豁免通道:把 markPreservedFinalizedCellisUnchangedFinalizedPreservedCellreadOnly || locked 前置去掉,只要 planFinalized === true 且签名未变即视为「已定稿未改动」;同时把 isDailyVehiclePlanCellComplete 第 2 步的三元改为无条件调用 isUnchangedFinalizedPreservedCell(normalized)。 → 后端已定稿、用户没动过的格子天然完成;用户一改车/司机/价格,签名变化自动失效,仍需重验。
  2. 从服务端快照重建日格时补齐 _candidateEvidenceserviceDate / 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 的 assignmentIdexcludeAssignmentId 传。 后端判定槽位上下文不一致后静默放弃排除(不报错、如实展示占用),于是本单自己的占用被算成阻断冲突 → 该车/司机在新槽位下 selectable=false。实测:fleetItemIndex=12 时三天全部 selectable=false,冲突体正是本单自己。

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

前端改法:新增槽位时不要沿用其它槽位的 assignmentId;该槽没有既有派车行就不要传 excludeAssignmentId

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


八、关联

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