Files
ln-bi/src/server/routes/scheduling/algorithm.ts
kkfluous 0785c78382 fix(scheduling): only show candidates that can actually qualify after swap
replace_qualified (换下):
- Exclude already-qualified inventory (totalMileage >= yearTarget, gap=0)
- Only keep candidates where canQualifyAfterSwap=true
- Skip suggestions with no qualifiable candidates (e.g., too few days left)
- Reason text now shows customer's remaining capacity: "日均 318km × 53天 ≈ 1.7万km"

Before: showed 粤AGP9738 (缺口 0, already at target) — pointless
After: shows 粤AGQ5808 (缺口 1.7万, 换后 3.0万, 可达标) — meaningful

All replace_qualified candidates now guaranteed canQualifyAfterSwap=true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:41:08 +08:00

236 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
EnrichedVehicle, InventoryVehicle, SchedulingSuggestion,
CandidateVehicle, VehicleClassification, SchedulingSummary,
} from './types.js';
function fmtKmSimple(v: number): string {
if (v >= 10000) return (v / 10000).toFixed(1) + '万';
return Math.round(v).toLocaleString();
}
// ---------------------------------------------------------------------------
// 1. Vehicle type compatibility
// ---------------------------------------------------------------------------
export function isTypeCompatible(sourceType: string, candidateType: string): boolean {
if (sourceType === candidateType) return true;
// Cold-chain 4.5T can replace plain-cargo 4.5T
if (candidateType === '4.5T冷链' && (sourceType === '4.5T冷链' || sourceType === '4.5T普货')) return true;
return false;
}
// ---------------------------------------------------------------------------
// 2. Vehicle classification
// ---------------------------------------------------------------------------
export function classifyVehicle(
currentYearIsQualified: boolean,
predictedYearEnd: number,
yearTarget: number,
): VehicleClassification {
if (currentYearIsQualified || predictedYearEnd / yearTarget >= 1.2) return 'qualified';
if (predictedYearEnd / yearTarget < 0.6) return 'hopeless';
return 'normal';
}
// ---------------------------------------------------------------------------
// 3. Helper convert EnrichedVehicle to SchedulingVehicleInfo shape
// ---------------------------------------------------------------------------
import type { SchedulingVehicleInfo } from './types.js';
export function toVehicleInfo(v: EnrichedVehicle): SchedulingVehicleInfo {
// Use current year completion rate instead of overall
const yearCompletionRate = v.yearTarget > 0 ? v.currentYearMileage / v.yearTarget : 0;
return {
plateNumber: v.plateNumber,
targetId: v.targetId,
targetName: v.targetName,
vehicleType: v.vehicleType,
totalMileage: v.totalMileage,
currentYearMileage: v.currentYearMileage,
completionRate: yearCompletionRate,
yearTarget: v.yearTarget,
region: v.region,
province: v.province,
customer: v.customer,
department: v.department,
manager: v.manager,
customerAvgDaily: v.customerAvgDaily,
predictedYearEnd: v.predictedYearEnd,
daysLeft: v.daysLeft,
};
}
// ---------------------------------------------------------------------------
// 4. Main algorithm generate scheduling suggestions
// ---------------------------------------------------------------------------
export function generateSuggestions(
vehicles: EnrichedVehicle[],
inventoryVehicles: InventoryVehicle[],
): { suggestions: SchedulingSuggestion[]; summary: SchedulingSummary } {
const qualified = vehicles.filter((v) => v.classification === 'qualified');
const hopeless = vehicles.filter((v) => v.classification === 'hopeless');
const suggestions: SchedulingSuggestion[] = [];
// --- rescue_hopeless (high priority) ---
// 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 already fully qualified
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
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 + customerCanAdd;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return {
plateNumber: inv.plateNumber,
targetId: inv.targetId,
targetName: inv.targetName,
vehicleType: inv.vehicleType,
totalMileage: inv.totalMileage,
completionRate: inv.completionRate,
yearTarget: inv.yearTarget ?? vehicle.yearTarget,
region: inv.region,
province: inv.province,
mileageGap,
predictedAfterSwap,
canQualifyAfterSwap,
};
})
.sort((a, b) => {
// 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 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}`,
priority: 'high',
type: 'rescue_hopeless',
currentVehicle: toVehicleInfo(vehicle),
candidates,
reason,
});
}
// --- replace_qualified (medium priority) ---
// Swap out the qualified car, swap in a car that NEEDS mileage.
// The high-mileage customer will drive it hard → helps it reach target.
// Exclude candidates already at target (gap <= 0) — swapping those in is pointless.
for (const vehicle of qualified) {
if (vehicle.customerAvgDaily <= vehicle.dailyRequiredMileage) continue;
const candidates: CandidateVehicle[] = inventoryVehicles
.filter((inv) => {
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
if (inv.region !== vehicle.region) return false;
// Must still need mileage — exclude already-qualified inventory
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
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 canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return {
plateNumber: inv.plateNumber,
targetId: inv.targetId,
targetName: inv.targetName,
vehicleType: inv.vehicleType,
totalMileage: inv.totalMileage,
completionRate: inv.completionRate,
yearTarget: inv.yearTarget ?? vehicle.yearTarget,
region: inv.region,
province: inv.province,
mileageGap,
predictedAfterSwap,
canQualifyAfterSwap,
};
})
.sort((a, b) => {
// 1. canQualifyAfterSwap first
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
return a.canQualifyAfterSwap ? -1 : 1;
// 2. Among qualifiable: biggest gap first (most value from the swap)
return b.mileageGap - a.mileageGap;
})
// Only keep candidates that can actually qualify at this customer
.filter(c => c.canQualifyAfterSwap)
.slice(0, 5);
// Skip if no candidate can reach target — swap would be pointless
if (candidates.length === 0) continue;
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
const reason = `该车在客户「${vehicle.customer}」处已达标(完成率 ${yearRate}%),客户日均 ${Math.round(vehicle.customerAvgDaily)} km × ${vehicle.daysLeft} 天 ≈ ${fmtKmSimple(canAddKm)} km。`
+ `\n建议换上里程未达标的车利用该客户的高日均帮新车快速冲线。`;
suggestions.push({
id: `qualified-${vehicle.plateNumber}`,
priority: 'medium',
type: 'replace_qualified',
currentVehicle: toVehicleInfo(vehicle),
candidates,
reason,
});
}
// Remove suggestions with no candidates
const filteredSuggestions = suggestions.filter((s) => s.candidates.length > 0);
// Sort: high priority first
filteredSuggestions.sort((a, b) => {
if (a.priority === b.priority) return 0;
return a.priority === 'high' ? -1 : 1;
});
// estimatedGain: count suggestions where at least one candidate canQualifyAfterSwap,
// plus rescue_hopeless suggestions (each rescued car can potentially qualify at a new customer)
const estimatedGain = filteredSuggestions.filter((s) =>
s.candidates.some((c) => c.canQualifyAfterSwap) || s.type === 'rescue_hopeless',
).length;
const summary: SchedulingSummary = {
qualifiedCount: qualified.length,
hopelessCount: hopeless.length,
suggestionCount: filteredSuggestions.length,
estimatedGain,
};
return { suggestions: filteredSuggestions, summary };
}