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}`,