fix(scheduling): rescue candidates should be close-to-qualifying, not zero-mileage

For rescue_hopeless (换走) scenario, completely rethought candidate logic:

Before: showed biggest-gap candidates (0 mileage) → pointless, customer can't
  drive them to target
After: prioritize candidates where customer's remaining driving can push them
  over the target line (canQualifyAfterSwap), sorted by smallest gap first

Example: customer drives 178km/day × 57 days = ~1万km remaining.
- 粤AGR6869 (缺口 1990km) → 换后 3.8万, 可达标  (shown first)
- 浙FF58720 (缺口 6万km) → 换后 1万, 远不达标 (no longer shown first)

Also updated reason text to explain the math:
"该客户剩余57天还能跑约1万km,足以帮缺口小的车冲线"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-16 21:36:53 +08:00
parent 1d1f8901aa
commit afec75a1cc
2 changed files with 24 additions and 14 deletions

View File

@@ -121,7 +121,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
</div>
<div className="text-[10px] text-slate-400 mb-2.5">
{isRescue
? '以下车辆里程已充足,可调给当前客户,将此车换走给高里程客户冲刺'
? '以下车辆快达标,换到当前客户处利用剩余天数即可冲线'
: '以下车辆里程缺口大,换到该高里程客户处可加速达标'
}
</div>

View File

@@ -76,26 +76,31 @@ export function generateSuggestions(
const suggestions: SchedulingSuggestion[] = [];
// --- rescue_hopeless (high priority) ---
// For this scenario: take the hopeless car away to a high-mileage customer,
// and give the low-mileage customer a replacement from inventory.
// Exclude near-qualified candidates (completionRate >= 80%) — no point swapping
// in a car that's basically already at target.
// Instead, pick cars with BIG gaps: they benefit from any mileage, even at a low customer.
// Take the hopeless car away → give to high-mileage customer to sprint.
// Replace with an inventory car that is CLOSE to qualifying — the low-mileage
// customer's remaining driving days can push it over the finish line.
//
// Key insight: pick candidates where
// candidate.totalMileage + customer.avgDaily × daysLeft >= yearTarget
// i.e., the customer's daily driving is enough to finish the candidate's target.
// Among those, prefer the one with the smallest gap (easiest to finish).
// Exclude already-qualified (>= 100%) — no value in swapping those.
for (const vehicle of hopeless) {
const customerCanAdd = vehicle.customerAvgDaily * vehicle.daysLeft;
const candidates: CandidateVehicle[] = inventoryVehicles
.filter((inv) => {
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
if (inv.region !== vehicle.region) return false;
// Exclude near-qualified: yearTarget known and already >= 80% done
// Exclude already fully qualified
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
if (effectiveTarget > 0 && inv.totalMileage / effectiveTarget >= 0.8) return false;
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
return true;
})
.map((inv) => {
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
const predictedAfterSwap =
inv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft;
const predictedAfterSwap = inv.totalMileage + customerCanAdd;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return {
plateNumber: inv.plateNumber,
@@ -113,15 +118,20 @@ export function generateSuggestions(
};
})
.sort((a, b) => {
// Prefer biggest gap first — these benefit most from any mileage
return b.mileageGap - a.mileageGap;
// 1. Prefer "can qualify after swap" first
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
return a.canQualifyAfterSwap ? -1 : 1;
// 2. Among qualifiable: smallest gap first (easiest to finish)
// Among non-qualifiable: smallest gap first (closest to target)
return a.mileageGap - b.mileageGap;
})
.slice(0, 5);
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage);
const reason = `该车在客户「${vehicle.customer}」处日均仅 ${Math.round(vehicle.customerAvgDaily)} km本年完成率 ${yearRate}%,还差 ${fmtKmSimple(gap)} km 达标,按当前速度年底无法完成。`
+ `\n建议将此车调配给高里程客户冲刺达标同时从库存调一辆已达标的车给当前客户`;
const canAddKm = Math.round(customerCanAdd);
const reason = `该车在客户「${vehicle.customer}」处日均仅 ${Math.round(vehicle.customerAvgDaily)} km完成率 ${yearRate}%,还差 ${fmtKmSimple(gap)} km年底无法达标`
+ `\n建议将此车换走给高里程客户冲刺换上一辆快达标的车——该客户剩余 ${vehicle.daysLeft} 天还能跑约 ${fmtKmSimple(canAddKm)} km足以帮缺口小的车冲线。`;
suggestions.push({
id: `hopeless-${vehicle.plateNumber}`,