From 460c9906e1f5f954eaca953b1bffbe1a41357d53 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Thu, 16 Apr 2026 20:21:50 +0800 Subject: [PATCH] feat(scheduling): add algorithm pure functions and export mapRegion Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/routes/scheduling/algorithm.ts | 185 ++++++++++++++++++++++ src/server/routes/vehicles.ts | 2 +- 2 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 src/server/routes/scheduling/algorithm.ts diff --git a/src/server/routes/scheduling/algorithm.ts b/src/server/routes/scheduling/algorithm.ts new file mode 100644 index 0000000..5e83505 --- /dev/null +++ b/src/server/routes/scheduling/algorithm.ts @@ -0,0 +1,185 @@ +import type { + EnrichedVehicle, InventoryVehicle, SchedulingSuggestion, + CandidateVehicle, VehicleClassification, SchedulingSummary, +} from './types.js'; + +// --------------------------------------------------------------------------- +// 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 { + return { + plateNumber: v.plateNumber, + targetId: v.targetId, + targetName: v.targetName, + vehicleType: v.vehicleType, + totalMileage: v.totalMileage, + completionRate: v.completionRate, + yearTarget: v.yearTarget, + region: v.region, + province: v.province, + customer: v.customer, + 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) --- + for (const vehicle of hopeless) { + const candidates: CandidateVehicle[] = inventoryVehicles + .filter( + (inv) => + isTypeCompatible(vehicle.vehicleType, inv.vehicleType) && + inv.region === vehicle.region && + inv.completionRate >= 0.8, + ) + .map((inv) => { + const mileageGap = (inv.yearTarget ?? 0) - inv.totalMileage; + const predictedAfterSwap = + inv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft; + const canQualifyAfterSwap = + inv.yearTarget != null && predictedAfterSwap >= inv.yearTarget; + return { + plateNumber: inv.plateNumber, + targetId: inv.targetId, + targetName: inv.targetName, + vehicleType: inv.vehicleType, + totalMileage: inv.totalMileage, + completionRate: inv.completionRate, + yearTarget: inv.yearTarget, + region: inv.region, + province: inv.province, + mileageGap, + predictedAfterSwap, + canQualifyAfterSwap, + }; + }) + .sort((a, b) => { + if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap) + return a.canQualifyAfterSwap ? -1 : 1; + return b.completionRate - a.completionRate; + }) + .slice(0, 5); + + const reason = `${vehicle.customer}日均里程仅 ${Math.round(vehicle.customerAvgDaily)} KM,该车达标概率 ${Math.round((vehicle.predictedYearEnd / vehicle.yearTarget) * 100)}%,建议替换为已达标车辆,将此车调配给高里程客户。`; + + suggestions.push({ + id: `hopeless-${vehicle.plateNumber}`, + priority: 'high', + type: 'rescue_hopeless', + currentVehicle: toVehicleInfo(vehicle), + candidates, + reason, + }); + } + + // --- replace_qualified (medium priority) --- + for (const vehicle of qualified) { + if (vehicle.customerAvgDaily <= vehicle.dailyRequiredMileage) continue; + + const candidates: CandidateVehicle[] = inventoryVehicles + .filter( + (inv) => + isTypeCompatible(vehicle.vehicleType, inv.vehicleType) && + inv.region === vehicle.region, + ) + .map((inv) => { + const mileageGap = (inv.yearTarget ?? 0) - inv.totalMileage; + const predictedAfterSwap = + inv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft; + const canQualifyAfterSwap = + inv.yearTarget != null && predictedAfterSwap >= inv.yearTarget; + return { + plateNumber: inv.plateNumber, + targetId: inv.targetId, + targetName: inv.targetName, + vehicleType: inv.vehicleType, + totalMileage: inv.totalMileage, + completionRate: inv.completionRate, + yearTarget: inv.yearTarget, + region: inv.region, + province: inv.province, + mileageGap, + predictedAfterSwap, + canQualifyAfterSwap, + }; + }) + .sort((a, b) => { + if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap) + return a.canQualifyAfterSwap ? -1 : 1; + return b.mileageGap - a.mileageGap; + }) + .slice(0, 5); + + const reason = `${vehicle.customer}日均里程 ${Math.round(vehicle.customerAvgDaily)} KM(高里程),该车已达标(完成率 ${Math.round(vehicle.completionRate * 100)}%),建议换上里程缺口大的车辆以加速达标。`; + + suggestions.push({ + id: `qualified-${vehicle.plateNumber}`, + priority: 'medium', + type: 'replace_qualified', + currentVehicle: toVehicleInfo(vehicle), + candidates, + reason, + }); + } + + // Sort: high priority first + suggestions.sort((a, b) => { + if (a.priority === b.priority) return 0; + return a.priority === 'high' ? -1 : 1; + }); + + const estimatedGain = suggestions.filter((s) => + s.candidates.some((c) => c.canQualifyAfterSwap), + ).length; + + const summary: SchedulingSummary = { + qualifiedCount: qualified.length, + hopelessCount: hopeless.length, + suggestionCount: suggestions.length, + estimatedGain, + }; + + return { suggestions, summary }; +} diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index 9788d9e..748819f 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -91,7 +91,7 @@ WHERE truck.is_deleted = 0 const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const; const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const; -function mapRegion(province: string | null, city: string | null): string { +export function mapRegion(province: string | null, city: string | null): string { if (!province && !city) return '其他'; const loc = (city || province || '').trim(); if (loc.includes('嘉兴') || loc.includes('浙江') || loc.includes('上海') || loc.includes('江苏')) return '嘉兴';