refactor(scheduling): shared types, structured reason, cross-region candidates
- Extract shared types to src/shared/scheduling/types.ts (client/server both re-export)
- Convert SchedulingSuggestion.reason from string to structured { lines, conclusion }
- Remove hard region filter; algorithm keeps cross-region candidates with isSameRegion flag
- SuggestionDetail renders same-region vs cross-region sections with a divider
- Close detail modal when selected suggestion no longer exists in data
- Unify estimatedGain definition (strict canQualifyAfterSwap) between algorithm and API layers
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
EnrichedVehicle, InventoryVehicle, SchedulingSuggestion,
|
||||
CandidateVehicle, VehicleClassification, SchedulingSummary,
|
||||
ReasonBlock,
|
||||
} from './types.js';
|
||||
|
||||
function fmtKmSimple(v: number): string {
|
||||
@@ -93,7 +94,6 @@ export function generateSuggestions(
|
||||
const candidates: CandidateVehicle[] = inventoryVehicles
|
||||
.filter((inv) => {
|
||||
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
||||
if (inv.region !== vehicle.region) return false;
|
||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
|
||||
return true;
|
||||
@@ -101,7 +101,6 @@ export function generateSuggestions(
|
||||
.map((inv) => {
|
||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
||||
// Use candidate's own daysLeft for prediction
|
||||
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
||||
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
||||
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
||||
@@ -119,22 +118,30 @@ export function generateSuggestions(
|
||||
mileageGap,
|
||||
predictedAfterSwap,
|
||||
canQualifyAfterSwap,
|
||||
isSameRegion: inv.region === vehicle.region,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// 1. Prefer "can qualify after swap" first
|
||||
// 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;
|
||||
// 2. Among qualifiable: smallest gap first (easiest to finish)
|
||||
// Among non-qualifiable: smallest gap first (closest to target)
|
||||
// 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 predictedTotal = Math.round(vehicle.currentYearMileage + vehicle.customerAvgDaily * vehicle.daysLeft);
|
||||
const reason = `客户日均 ${Math.round(vehicle.customerAvgDaily)} km | 考核周期剩余 ${vehicle.daysLeft} 天 · 日均需 ${fmtKmSimple(dailyReq)} km\n!!预估无法达标,需替换`;
|
||||
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}`,
|
||||
@@ -156,8 +163,6 @@ export function generateSuggestions(
|
||||
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;
|
||||
@@ -165,7 +170,6 @@ export function generateSuggestions(
|
||||
.map((inv) => {
|
||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
||||
// Use candidate's own daysLeft for prediction
|
||||
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
||||
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
||||
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
||||
@@ -183,17 +187,18 @@ export function generateSuggestions(
|
||||
mileageGap,
|
||||
predictedAfterSwap,
|
||||
canQualifyAfterSwap,
|
||||
isSameRegion: inv.region === vehicle.region,
|
||||
};
|
||||
})
|
||||
// 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. canQualifyAfterSwap first
|
||||
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
|
||||
return a.canQualifyAfterSwap ? -1 : 1;
|
||||
// 2. Among qualifiable: biggest gap first (most value from the swap)
|
||||
// 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;
|
||||
})
|
||||
// Only keep candidates that can actually qualify at this customer
|
||||
.filter(c => c.canQualifyAfterSwap)
|
||||
;
|
||||
|
||||
// Skip if no candidate can reach target — swap would be pointless
|
||||
@@ -201,7 +206,15 @@ export function generateSuggestions(
|
||||
|
||||
const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
|
||||
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
|
||||
const reason = `客户日均 ${Math.round(vehicle.customerAvgDaily)} km\n已完成考核(完成率 ${yearRate}%)\n考核周期剩余 ${vehicle.daysLeft} 天,可为新车贡献约 ${fmtKmSimple(Math.round(canAddKm))} km\n!!已达标,建议换上未达标车辆`;
|
||||
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}`,
|
||||
@@ -222,10 +235,11 @@ export function generateSuggestions(
|
||||
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)
|
||||
// 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) || s.type === 'rescue_hopeless',
|
||||
s.candidates.some((c) => c.canQualifyAfterSwap),
|
||||
).length;
|
||||
|
||||
const summary: SchedulingSummary = {
|
||||
|
||||
@@ -1,71 +1,18 @@
|
||||
export interface SchedulingVehicleInfo {
|
||||
plateNumber: string;
|
||||
targetId: number;
|
||||
targetName: string;
|
||||
vehicleType: string;
|
||||
totalMileage: number;
|
||||
currentYearMileage: number;
|
||||
completionRate: number; // 本年完成率 currentYearMileage / yearTarget
|
||||
yearTarget: number;
|
||||
region: string;
|
||||
province: string;
|
||||
customer: string | null;
|
||||
department: string | null;
|
||||
manager: string | null;
|
||||
customerAvgDaily: number;
|
||||
predictedYearEnd: number;
|
||||
daysLeft: number;
|
||||
}
|
||||
export type {
|
||||
SchedulingVehicleInfo,
|
||||
CandidateVehicle,
|
||||
SchedulingSuggestion,
|
||||
SchedulingSummary,
|
||||
SchedulingTargetOption,
|
||||
SchedulingResponse,
|
||||
NotifyRequest,
|
||||
ReasonLine,
|
||||
ReasonBlock,
|
||||
} from '../../../shared/scheduling/types.js';
|
||||
|
||||
export interface CandidateVehicle {
|
||||
plateNumber: string;
|
||||
targetId: number | null;
|
||||
targetName: string | null;
|
||||
vehicleType: string;
|
||||
totalMileage: number;
|
||||
completionRate: number;
|
||||
yearTarget: number | null;
|
||||
daysLeft: number;
|
||||
region: string;
|
||||
province: string;
|
||||
mileageGap: number;
|
||||
predictedAfterSwap: number;
|
||||
canQualifyAfterSwap: boolean;
|
||||
}
|
||||
|
||||
export interface SchedulingSuggestion {
|
||||
id: string;
|
||||
priority: 'high' | 'medium';
|
||||
type: 'replace_qualified' | 'rescue_hopeless';
|
||||
currentVehicle: SchedulingVehicleInfo;
|
||||
candidates: CandidateVehicle[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface SchedulingSummary {
|
||||
qualifiedCount: number;
|
||||
hopelessCount: number;
|
||||
suggestionCount: number;
|
||||
estimatedGain: number;
|
||||
}
|
||||
|
||||
export interface SchedulingTargetOption {
|
||||
id: number;
|
||||
name: string;
|
||||
vehicleCount: number;
|
||||
}
|
||||
|
||||
export interface SchedulingResponse {
|
||||
summary: SchedulingSummary;
|
||||
suggestions: SchedulingSuggestion[];
|
||||
targets: SchedulingTargetOption[];
|
||||
}
|
||||
|
||||
export interface NotifyRequest {
|
||||
suggestionId: string;
|
||||
currentPlate: string;
|
||||
candidatePlate: string;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server-only types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type VehicleClassification = 'qualified' | 'hopeless' | 'normal';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user