import type { EnrichedVehicle, InventoryVehicle, SchedulingSuggestion, CandidateVehicle, VehicleClassification, SchedulingSummary, ReasonBlock, } 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, currentYearMileage: number, yearTarget: number, predictedYearEnd: number, ): VehicleClassification { // qualified: current year mileage already >= target (actually done, not just predicted) const actualRate = yearTarget > 0 ? currentYearMileage / yearTarget : 0; if (currentYearIsQualified || actualRate >= 1.0) return 'qualified'; // hopeless: even with remaining days, predicted < 60% of target if (yearTarget > 0 && 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, customerAvgDaily7d: v.customerAvgDaily7d, 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 candidates: CandidateVehicle[] = inventoryVehicles .filter((inv) => { if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false; 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 candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft; const predictedAfterSwap = inv.totalMileage + candidateCanAdd; 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, daysLeft: inv.daysLeft, region: inv.region, province: inv.province, mileageGap, predictedAfterSwap, canQualifyAfterSwap, isSameRegion: inv.region === vehicle.region, notificationId: null, notificationStatus: null, }; }) .sort((a, b) => { // 1. Same-region first (business rule: prefer same-region swaps) if (a.isSameRegion !== b.isSameRegion) return a.isSameRegion ? -1 : 1; // 2. Can-qualify next if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap) return a.canQualifyAfterSwap ? -1 : 1; // 3. Smallest gap (closest to target) return a.mileageGap - b.mileageGap; }) ; const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage); const dailyReq = vehicle.daysLeft > 0 ? Math.round(gap / vehicle.daysLeft) : 0; const reason: ReasonBlock = { lines: [ { label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` }, { label: '考核剩余', value: `${vehicle.daysLeft} 天` }, { label: '日均需', value: `${fmtKmSimple(dailyReq)} km` }, ], conclusion: '预估无法达标,需替换', }; 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; 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 candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft; const predictedAfterSwap = inv.totalMileage + candidateCanAdd; 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, daysLeft: inv.daysLeft, region: inv.region, province: inv.province, mileageGap, predictedAfterSwap, canQualifyAfterSwap, isSameRegion: inv.region === vehicle.region, notificationId: null, notificationStatus: null, }; }) // Only keep candidates that can actually qualify at this customer — // swapping in a car that still can't reach target wastes the high-mileage customer .filter(c => c.canQualifyAfterSwap) .sort((a, b) => { // 1. Same-region first if (a.isSameRegion !== b.isSameRegion) return a.isSameRegion ? -1 : 1; // 2. Biggest gap first (most value from the swap) return b.mileageGap - a.mileageGap; }) ; // 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: ReasonBlock = { lines: [ { label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` }, { label: '年度完成率', value: `${yearRate}%` }, { label: '考核剩余', value: `${vehicle.daysLeft} 天` }, { label: '可为新车贡献', value: `约 ${fmtKmSimple(Math.round(canAddKm))} km` }, ], conclusion: '已达标,建议换上未达标车辆', }; 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 uses strict definition: count suggestions that have at least // one candidate able to qualify after swap. The API layer recomputes this // post permission-filtering, so keep both sides consistent. const estimatedGain = filteredSuggestions.filter((s) => s.candidates.some((c) => c.canQualifyAfterSwap), ).length; const summary: SchedulingSummary = { qualifiedCount: qualified.length, hopelessCount: hopeless.length, suggestionCount: filteredSuggestions.length, estimatedGain, recentInterventionCount: 0, }; return { suggestions: filteredSuggestions, summary }; }