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:
@@ -121,7 +121,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-slate-400 mb-2.5">
|
<div className="text-[10px] text-slate-400 mb-2.5">
|
||||||
{isRescue
|
{isRescue
|
||||||
? '以下车辆里程已充足,可调给当前客户,将此车换走给高里程客户冲刺'
|
? '以下车辆快达标,换到当前客户处利用剩余天数即可冲线'
|
||||||
: '以下车辆里程缺口大,换到该高里程客户处可加速达标'
|
: '以下车辆里程缺口大,换到该高里程客户处可加速达标'
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,26 +76,31 @@ export function generateSuggestions(
|
|||||||
const suggestions: SchedulingSuggestion[] = [];
|
const suggestions: SchedulingSuggestion[] = [];
|
||||||
|
|
||||||
// --- rescue_hopeless (high priority) ---
|
// --- rescue_hopeless (high priority) ---
|
||||||
// For this scenario: take the hopeless car away to a high-mileage customer,
|
// Take the hopeless car away → give to high-mileage customer to sprint.
|
||||||
// and give the low-mileage customer a replacement from inventory.
|
// Replace with an inventory car that is CLOSE to qualifying — the low-mileage
|
||||||
// Exclude near-qualified candidates (completionRate >= 80%) — no point swapping
|
// customer's remaining driving days can push it over the finish line.
|
||||||
// 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.
|
// 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) {
|
for (const vehicle of hopeless) {
|
||||||
|
const customerCanAdd = vehicle.customerAvgDaily * vehicle.daysLeft;
|
||||||
|
|
||||||
const candidates: CandidateVehicle[] = inventoryVehicles
|
const candidates: CandidateVehicle[] = inventoryVehicles
|
||||||
.filter((inv) => {
|
.filter((inv) => {
|
||||||
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
||||||
if (inv.region !== vehicle.region) 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;
|
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;
|
return true;
|
||||||
})
|
})
|
||||||
.map((inv) => {
|
.map((inv) => {
|
||||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||||
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
||||||
const predictedAfterSwap =
|
const predictedAfterSwap = inv.totalMileage + customerCanAdd;
|
||||||
inv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft;
|
|
||||||
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
||||||
return {
|
return {
|
||||||
plateNumber: inv.plateNumber,
|
plateNumber: inv.plateNumber,
|
||||||
@@ -113,15 +118,20 @@ export function generateSuggestions(
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Prefer biggest gap first — these benefit most from any mileage
|
// 1. Prefer "can qualify after swap" first
|
||||||
return b.mileageGap - a.mileageGap;
|
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);
|
.slice(0, 5);
|
||||||
|
|
||||||
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
|
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
|
||||||
const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage);
|
const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage);
|
||||||
const reason = `该车在客户「${vehicle.customer}」处日均仅 ${Math.round(vehicle.customerAvgDaily)} km,本年完成率 ${yearRate}%,还差 ${fmtKmSimple(gap)} km 达标,按当前速度年底无法完成。`
|
const canAddKm = Math.round(customerCanAdd);
|
||||||
+ `\n建议:将此车调配给高里程客户冲刺达标,同时从库存调一辆已达标的车给当前客户。`;
|
const reason = `该车在客户「${vehicle.customer}」处日均仅 ${Math.round(vehicle.customerAvgDaily)} km,完成率 ${yearRate}%,还差 ${fmtKmSimple(gap)} km,年底无法达标。`
|
||||||
|
+ `\n建议:将此车换走给高里程客户冲刺,换上一辆快达标的车——该客户剩余 ${vehicle.daysLeft} 天还能跑约 ${fmtKmSimple(canAddKm)} km,足以帮缺口小的车冲线。`;
|
||||||
|
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
id: `hopeless-${vehicle.plateNumber}`,
|
id: `hopeless-${vehicle.plateNumber}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user