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:
kkfluous
2026-04-16 23:36:38 +08:00
parent 335282a2c3
commit 31716c6547
6 changed files with 250 additions and 240 deletions

View File

@@ -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>();

View File

@@ -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> </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> </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>
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>
{/* 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>
{sameRegion.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{displayCandidates.map(c => { {sameRegion.map(c => renderCandidate(c))}
const sent = sentPlates.has(c.plateNumber);
return (
<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> </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 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="space-y-2">
{crossRegion.map(c => renderCandidate(c))}
</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>

View File

@@ -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[];
}

View File

@@ -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 = {

View File

@@ -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';

View 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;
}