diff --git a/src/modules/scheduling/SuggestionDetail.tsx b/src/modules/scheduling/SuggestionDetail.tsx index 1eb8e09..c2f046d 100644 --- a/src/modules/scheduling/SuggestionDetail.tsx +++ b/src/modules/scheduling/SuggestionDetail.tsx @@ -121,7 +121,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
{isRescue - ? '以下车辆里程已充足,可调给当前客户,将此车换走给高里程客户冲刺' + ? '以下车辆快达标,换到当前客户处利用剩余天数即可冲线' : '以下车辆里程缺口大,换到该高里程客户处可加速达标' }
diff --git a/src/server/routes/scheduling/algorithm.ts b/src/server/routes/scheduling/algorithm.ts index 815bf53..e43669e 100644 --- a/src/server/routes/scheduling/algorithm.ts +++ b/src/server/routes/scheduling/algorithm.ts @@ -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}`,