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>
236 lines
9.9 KiB
TypeScript
236 lines
9.9 KiB
TypeScript
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 };
|
||
}
|