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:
@@ -161,6 +161,13 @@ export default function SchedulingModule() {
|
|||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
const handleNotifySuccess = useCallback(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
// Close detail modal if selected suggestion is filtered out or no longer exists
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSuggestion || !data) return;
|
||||||
|
const stillExists = data.suggestions.some(s => s.id === selectedSuggestion.id);
|
||||||
|
if (!stillExists) setSelectedSuggestion(null);
|
||||||
|
}, [data, selectedSuggestion]);
|
||||||
|
|
||||||
const filterOptions = useMemo(() => {
|
const filterOptions = useMemo(() => {
|
||||||
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
|
if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] };
|
||||||
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
|
const r = new Set<string>(), t = new Set<string>(), c = new Set<string>(), d = new Set<string>(), m = new Set<string>();
|
||||||
|
|||||||
@@ -42,22 +42,87 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
return [...set].sort();
|
return [...set].sort();
|
||||||
}, [s.candidates]);
|
}, [s.candidates]);
|
||||||
|
|
||||||
// Filtered + sorted candidates
|
// Filtered + sorted candidates, grouped by region
|
||||||
const displayCandidates = useMemo(() => {
|
const { sameRegion, crossRegion } = useMemo(() => {
|
||||||
let list = s.candidates;
|
let list = s.candidates;
|
||||||
if (batchFilter.size > 0) list = list.filter(c => c.targetName != null && batchFilter.has(c.targetName));
|
if (batchFilter.size > 0) list = list.filter(c => c.targetName != null && batchFilter.has(c.targetName));
|
||||||
return [...list].sort((a, b) => {
|
const sorted = [...list].sort((a, b) => {
|
||||||
const va = sortKey === 'predicted' ? a.predictedAfterSwap : a.totalMileage;
|
const va = sortKey === 'predicted' ? a.predictedAfterSwap : a.totalMileage;
|
||||||
const vb = sortKey === 'predicted' ? b.predictedAfterSwap : b.totalMileage;
|
const vb = sortKey === 'predicted' ? b.predictedAfterSwap : b.totalMileage;
|
||||||
return sortDir === 'desc' ? vb - va : va - vb;
|
return sortDir === 'desc' ? vb - va : va - vb;
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
sameRegion: sorted.filter(c => c.isSameRegion),
|
||||||
|
crossRegion: sorted.filter(c => !c.isSameRegion),
|
||||||
|
};
|
||||||
}, [s.candidates, batchFilter, sortKey, sortDir]);
|
}, [s.candidates, batchFilter, sortKey, sortDir]);
|
||||||
|
|
||||||
|
const displayCount = sameRegion.length + crossRegion.length;
|
||||||
|
|
||||||
const toggleSort = (key: SortKey) => {
|
const toggleSort = (key: SortKey) => {
|
||||||
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
|
if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); }
|
||||||
else { setSortKey(key); setSortDir('desc'); }
|
else { setSortKey(key); setSortDir('desc'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderCandidate = (c: CandidateVehicle) => {
|
||||||
|
const sent = sentPlates.has(c.plateNumber);
|
||||||
|
return (
|
||||||
|
<div key={c.plateNumber} className="rounded-xl border border-slate-200 overflow-hidden bg-white">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
|
||||||
|
<span className={`text-[9px] px-1.5 py-0.5 rounded flex items-center gap-0.5 ${c.isSameRegion ? 'bg-slate-100 text-slate-500' : 'bg-amber-50 text-amber-600'}`}>
|
||||||
|
<MapPin size={9} />{c.region}{!c.isSameRegion && ' · 跨区'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-400">{c.vehicleType}</span>
|
||||||
|
<span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span>
|
||||||
|
<span className="text-[9px] text-slate-400">剩余{c.daysLeft}天</span>
|
||||||
|
</div>
|
||||||
|
{c.canQualifyAfterSwap ? (
|
||||||
|
<span className="text-[9px] font-bold text-emerald-600 flex items-center gap-0.5 bg-emerald-50 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||||
|
<CheckCircle size={10} /> 可达标
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[9px] font-bold text-amber-500 flex items-center gap-0.5 bg-amber-50 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||||
|
<AlertTriangle size={10} /> 需关注
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
|
||||||
|
<div className="flex-1 py-1.5 px-2 text-center">
|
||||||
|
<div className="text-slate-400">当前</div>
|
||||||
|
<div className="font-bold text-slate-700">{fmtKm(c.totalMileage)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 py-1.5 px-2 text-center">
|
||||||
|
<div className="text-slate-400">替换后预计</div>
|
||||||
|
<div className={`font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>{fmtKm(c.predictedAfterSwap)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 py-1.5 px-2 text-center">
|
||||||
|
<div className="text-blue-400">考核</div>
|
||||||
|
<div className="font-bold text-blue-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-3 pb-2.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewCandidate(c)}
|
||||||
|
disabled={sent}
|
||||||
|
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
|
||||||
|
sent
|
||||||
|
? 'bg-emerald-50 text-emerald-600'
|
||||||
|
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sent ? <><CheckCircle size={12} /> 已通知</> : <>查看替换方案 <ArrowRight size={12} /></>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-end sm:items-center justify-center" onClick={onClose}>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -130,41 +195,25 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reason — structured lines */}
|
{/* Reason — structured lines */}
|
||||||
<div className="px-4 py-2.5 border-b border-slate-100 bg-slate-50/60 space-y-1">
|
<div className="px-4 py-2.5 border-b border-slate-100 bg-slate-50/60">
|
||||||
{s.reason.split('\n').map((line, i) => {
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||||
const isConclusion = line.startsWith('!!');
|
{s.reason.lines.map((line, i) => (
|
||||||
const text = isConclusion ? line.slice(2) : line;
|
<div key={i} className="flex items-center justify-between text-[11px]">
|
||||||
if (isConclusion) {
|
<span className="text-slate-500">{line.label}</span>
|
||||||
return (
|
<span className="text-slate-700 font-medium">{line.value}</span>
|
||||||
<div key={i} className="mt-1.5 pt-1.5 border-t border-slate-200">
|
|
||||||
<span className="text-xs font-bold text-rose-600">{text}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Split by | for two-column layout
|
|
||||||
if (text.includes('|')) {
|
|
||||||
const parts = text.split('|').map(p => p.trim());
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex items-center justify-between text-[11px] py-0.5">
|
|
||||||
<span className="text-slate-600">{parts[0]}</span>
|
|
||||||
<span className="text-slate-600">{parts[1]}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex items-center gap-1.5 text-[11px] py-0.5">
|
|
||||||
<span className="w-1 h-1 rounded-full bg-slate-300 flex-shrink-0" />
|
|
||||||
<span className="text-slate-600">{text}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
|
<div className="mt-2 pt-2 border-t border-slate-200">
|
||||||
|
<span className="text-xs font-bold text-rose-600">{s.reason.conclusion}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Candidates */}
|
{/* Candidates */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<span className="text-xs font-bold text-slate-700">当前区域可替换在库车辆</span>
|
<span className="text-xs font-bold text-slate-700">可替换在库车辆</span>
|
||||||
<span className="text-[10px] text-slate-400">{displayCandidates.length}/{s.candidates.length} 辆</span>
|
<span className="text-[10px] text-slate-400">{displayCount}/{s.candidates.length} 辆</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter + Sort controls */}
|
{/* Filter + Sort controls */}
|
||||||
@@ -222,67 +271,28 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{sameRegion.length > 0 && (
|
||||||
{displayCandidates.map(c => {
|
<div className="space-y-2">
|
||||||
const sent = sentPlates.has(c.plateNumber);
|
{sameRegion.map(c => renderCandidate(c))}
|
||||||
return (
|
</div>
|
||||||
<div key={c.plateNumber} className="rounded-xl border border-slate-200 overflow-hidden bg-white">
|
)}
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{c.plateNumber}</Blur></span>
|
|
||||||
<span className="text-[9px] text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded flex items-center gap-0.5"><MapPin size={9} />{c.region}</span>
|
|
||||||
<span className="text-[9px] text-slate-400">{c.vehicleType}</span>
|
|
||||||
<span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span>
|
|
||||||
<span className="text-[9px] text-slate-400">剩余{c.daysLeft}天</span>
|
|
||||||
</div>
|
|
||||||
{c.canQualifyAfterSwap ? (
|
|
||||||
<span className="text-[9px] font-bold text-emerald-600 flex items-center gap-0.5 bg-emerald-50 px-1.5 py-0.5 rounded">
|
|
||||||
<CheckCircle size={10} /> 可达标
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[9px] font-bold text-amber-500 flex items-center gap-0.5 bg-amber-50 px-1.5 py-0.5 rounded">
|
|
||||||
<AlertTriangle size={10} /> 需关注
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metrics */}
|
{crossRegion.length > 0 && (
|
||||||
<div className="px-3 pb-2">
|
<>
|
||||||
<div className="flex text-[10px] bg-slate-50 rounded-lg overflow-hidden divide-x divide-slate-200">
|
<div className="flex items-center gap-2 my-3">
|
||||||
<div className="flex-1 py-1.5 px-2 text-center">
|
<div className="flex-1 h-px bg-slate-200" />
|
||||||
<div className="text-slate-400">当前</div>
|
<span className="text-[10px] text-slate-400 font-medium">跨区候选 · {crossRegion.length} 辆</span>
|
||||||
<div className="font-bold text-slate-700">{fmtKm(c.totalMileage)}</div>
|
<div className="flex-1 h-px bg-slate-200" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 py-1.5 px-2 text-center">
|
<div className="space-y-2">
|
||||||
<div className="text-slate-400">替换后预计</div>
|
{crossRegion.map(c => renderCandidate(c))}
|
||||||
<div className={`font-bold ${c.canQualifyAfterSwap ? 'text-emerald-600' : 'text-amber-600'}`}>{fmtKm(c.predictedAfterSwap)}</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<div className="flex-1 py-1.5 px-2 text-center">
|
)}
|
||||||
<div className="text-blue-400">考核</div>
|
|
||||||
<div className="font-bold text-blue-700">{c.yearTarget ? fmtKm(c.yearTarget) : '-'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action */}
|
{displayCount === 0 && (
|
||||||
<div className="px-3 pb-2.5">
|
<div className="py-8 text-center text-xs text-slate-400">当前筛选条件下无可替换车辆</div>
|
||||||
<button
|
)}
|
||||||
onClick={() => setPreviewCandidate(c)}
|
|
||||||
disabled={sent}
|
|
||||||
className={`w-full flex items-center justify-center gap-1.5 text-[11px] font-bold py-2 rounded-lg transition-all cursor-pointer ${
|
|
||||||
sent
|
|
||||||
? 'bg-emerald-50 text-emerald-600'
|
|
||||||
: 'bg-slate-800 hover:bg-slate-900 text-white active:scale-[0.98] shadow-sm'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sent ? <><CheckCircle size={12} /> 已通知</> : <>查看替换方案 <ArrowRight size={12} /></>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +1,11 @@
|
|||||||
export interface SchedulingVehicleInfo {
|
export type {
|
||||||
plateNumber: string;
|
SchedulingVehicleInfo,
|
||||||
targetId: number;
|
CandidateVehicle,
|
||||||
targetName: string;
|
SchedulingSuggestion,
|
||||||
vehicleType: string;
|
SchedulingSummary,
|
||||||
totalMileage: number;
|
SchedulingTargetOption,
|
||||||
currentYearMileage: number;
|
SchedulingResponse,
|
||||||
completionRate: number;
|
NotifyRequest,
|
||||||
yearTarget: number;
|
ReasonLine,
|
||||||
region: string;
|
ReasonBlock,
|
||||||
province: string;
|
} from '../../shared/scheduling/types';
|
||||||
customer: string | null;
|
|
||||||
department: string | null;
|
|
||||||
manager: string | null;
|
|
||||||
customerAvgDaily: number;
|
|
||||||
predictedYearEnd: number;
|
|
||||||
daysLeft: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
EnrichedVehicle, InventoryVehicle, SchedulingSuggestion,
|
EnrichedVehicle, InventoryVehicle, SchedulingSuggestion,
|
||||||
CandidateVehicle, VehicleClassification, SchedulingSummary,
|
CandidateVehicle, VehicleClassification, SchedulingSummary,
|
||||||
|
ReasonBlock,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
function fmtKmSimple(v: number): string {
|
function fmtKmSimple(v: number): string {
|
||||||
@@ -93,7 +94,6 @@ export function generateSuggestions(
|
|||||||
const candidates: CandidateVehicle[] = inventoryVehicles
|
const candidates: CandidateVehicle[] = inventoryVehicles
|
||||||
.filter((inv) => {
|
.filter((inv) => {
|
||||||
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
||||||
if (inv.region !== vehicle.region) return false;
|
|
||||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||||
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
|
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -101,7 +101,6 @@ export function generateSuggestions(
|
|||||||
.map((inv) => {
|
.map((inv) => {
|
||||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||||
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
||||||
// Use candidate's own daysLeft for prediction
|
|
||||||
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
||||||
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
||||||
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
||||||
@@ -119,22 +118,30 @@ export function generateSuggestions(
|
|||||||
mileageGap,
|
mileageGap,
|
||||||
predictedAfterSwap,
|
predictedAfterSwap,
|
||||||
canQualifyAfterSwap,
|
canQualifyAfterSwap,
|
||||||
|
isSameRegion: inv.region === vehicle.region,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.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)
|
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
|
||||||
return a.canQualifyAfterSwap ? -1 : 1;
|
return a.canQualifyAfterSwap ? -1 : 1;
|
||||||
// 2. Among qualifiable: smallest gap first (easiest to finish)
|
// 3. Smallest gap (closest to target)
|
||||||
// Among non-qualifiable: smallest gap first (closest to target)
|
|
||||||
return a.mileageGap - b.mileageGap;
|
return a.mileageGap - b.mileageGap;
|
||||||
})
|
})
|
||||||
;
|
;
|
||||||
|
|
||||||
const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage);
|
const gap = Math.max(0, vehicle.yearTarget - vehicle.currentYearMileage);
|
||||||
const dailyReq = vehicle.daysLeft > 0 ? Math.round(gap / vehicle.daysLeft) : 0;
|
const dailyReq = vehicle.daysLeft > 0 ? Math.round(gap / vehicle.daysLeft) : 0;
|
||||||
const predictedTotal = Math.round(vehicle.currentYearMileage + vehicle.customerAvgDaily * vehicle.daysLeft);
|
const reason: ReasonBlock = {
|
||||||
const reason = `客户日均 ${Math.round(vehicle.customerAvgDaily)} km | 考核周期剩余 ${vehicle.daysLeft} 天 · 日均需 ${fmtKmSimple(dailyReq)} km\n!!预估无法达标,需替换`;
|
lines: [
|
||||||
|
{ label: '客户日均', value: `${Math.round(vehicle.customerAvgDaily)} km` },
|
||||||
|
{ label: '考核剩余', value: `${vehicle.daysLeft} 天` },
|
||||||
|
{ label: '日均需', value: `${fmtKmSimple(dailyReq)} km` },
|
||||||
|
],
|
||||||
|
conclusion: '预估无法达标,需替换',
|
||||||
|
};
|
||||||
|
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
id: `hopeless-${vehicle.plateNumber}`,
|
id: `hopeless-${vehicle.plateNumber}`,
|
||||||
@@ -156,8 +163,6 @@ export function generateSuggestions(
|
|||||||
const candidates: CandidateVehicle[] = inventoryVehicles
|
const candidates: CandidateVehicle[] = inventoryVehicles
|
||||||
.filter((inv) => {
|
.filter((inv) => {
|
||||||
if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false;
|
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;
|
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||||
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
|
if (effectiveTarget > 0 && inv.totalMileage >= effectiveTarget) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -165,7 +170,6 @@ export function generateSuggestions(
|
|||||||
.map((inv) => {
|
.map((inv) => {
|
||||||
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
|
||||||
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
|
||||||
// Use candidate's own daysLeft for prediction
|
|
||||||
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
|
||||||
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
|
||||||
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
|
||||||
@@ -183,17 +187,18 @@ export function generateSuggestions(
|
|||||||
mileageGap,
|
mileageGap,
|
||||||
predictedAfterSwap,
|
predictedAfterSwap,
|
||||||
canQualifyAfterSwap,
|
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) => {
|
.sort((a, b) => {
|
||||||
// 1. canQualifyAfterSwap first
|
// 1. Same-region first
|
||||||
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
|
if (a.isSameRegion !== b.isSameRegion) return a.isSameRegion ? -1 : 1;
|
||||||
return a.canQualifyAfterSwap ? -1 : 1;
|
// 2. Biggest gap first (most value from the swap)
|
||||||
// 2. Among qualifiable: biggest gap first (most value from the swap)
|
|
||||||
return b.mileageGap - a.mileageGap;
|
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
|
// 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 yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0;
|
||||||
const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft;
|
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({
|
suggestions.push({
|
||||||
id: `qualified-${vehicle.plateNumber}`,
|
id: `qualified-${vehicle.plateNumber}`,
|
||||||
@@ -222,10 +235,11 @@ export function generateSuggestions(
|
|||||||
return a.priority === 'high' ? -1 : 1;
|
return a.priority === 'high' ? -1 : 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
// estimatedGain: count suggestions where at least one candidate canQualifyAfterSwap,
|
// estimatedGain uses strict definition: count suggestions that have at least
|
||||||
// plus rescue_hopeless suggestions (each rescued car can potentially qualify at a new customer)
|
// 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) =>
|
const estimatedGain = filteredSuggestions.filter((s) =>
|
||||||
s.candidates.some((c) => c.canQualifyAfterSwap) || s.type === 'rescue_hopeless',
|
s.candidates.some((c) => c.canQualifyAfterSwap),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const summary: SchedulingSummary = {
|
const summary: SchedulingSummary = {
|
||||||
|
|||||||
@@ -1,71 +1,18 @@
|
|||||||
export interface SchedulingVehicleInfo {
|
export type {
|
||||||
plateNumber: string;
|
SchedulingVehicleInfo,
|
||||||
targetId: number;
|
CandidateVehicle,
|
||||||
targetName: string;
|
SchedulingSuggestion,
|
||||||
vehicleType: string;
|
SchedulingSummary,
|
||||||
totalMileage: number;
|
SchedulingTargetOption,
|
||||||
currentYearMileage: number;
|
SchedulingResponse,
|
||||||
completionRate: number; // 本年完成率 currentYearMileage / yearTarget
|
NotifyRequest,
|
||||||
yearTarget: number;
|
ReasonLine,
|
||||||
region: string;
|
ReasonBlock,
|
||||||
province: string;
|
} from '../../../shared/scheduling/types.js';
|
||||||
customer: string | null;
|
|
||||||
department: string | null;
|
|
||||||
manager: string | null;
|
|
||||||
customerAvgDaily: number;
|
|
||||||
predictedYearEnd: number;
|
|
||||||
daysLeft: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CandidateVehicle {
|
// ---------------------------------------------------------------------------
|
||||||
plateNumber: string;
|
// Server-only types
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VehicleClassification = 'qualified' | 'hopeless' | 'normal';
|
export type VehicleClassification = 'qualified' | 'hopeless' | 'normal';
|
||||||
|
|
||||||
|
|||||||
83
src/shared/scheduling/types.ts
Normal file
83
src/shared/scheduling/types.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Shared scheduling types — used by both client (modules/scheduling) and server
|
||||||
|
// (server/routes/scheduling). Keep server-only types (EnrichedVehicle etc.) in
|
||||||
|
// server/routes/scheduling/types.ts.
|
||||||
|
|
||||||
|
export interface SchedulingVehicleInfo {
|
||||||
|
plateNumber: string;
|
||||||
|
targetId: number;
|
||||||
|
targetName: string;
|
||||||
|
vehicleType: string;
|
||||||
|
totalMileage: number;
|
||||||
|
currentYearMileage: number;
|
||||||
|
completionRate: number;
|
||||||
|
yearTarget: number;
|
||||||
|
region: string;
|
||||||
|
province: string;
|
||||||
|
customer: string | null;
|
||||||
|
department: string | null;
|
||||||
|
manager: string | null;
|
||||||
|
customerAvgDaily: number;
|
||||||
|
predictedYearEnd: number;
|
||||||
|
daysLeft: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
isSameRegion: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReasonLine {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReasonBlock {
|
||||||
|
lines: ReasonLine[];
|
||||||
|
conclusion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulingSuggestion {
|
||||||
|
id: string;
|
||||||
|
priority: 'high' | 'medium';
|
||||||
|
type: 'replace_qualified' | 'rescue_hopeless';
|
||||||
|
currentVehicle: SchedulingVehicleInfo;
|
||||||
|
candidates: CandidateVehicle[];
|
||||||
|
reason: ReasonBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user