- Add 调度记录 modal: lists notifications by status, supports 标记已执行 (with after-mileage + notes) and 取消 for open records - Add CSV export of filtered suggestions (UTF-8 BOM for Excel); top candidate per row picked by same-region > can-qualify preference - Compute customer 7-day average alongside 30-day baseline in a single query; show trend indicator (up/down/flat) next to 客户日均 in list and detail card Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
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,
|
||
};
|
||
|
||
return { suggestions: filteredSuggestions, summary };
|
||
}
|