From 31716c65479630ec872f601c75b5c03e4964c6a5 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Thu, 16 Apr 2026 23:36:38 +0800 Subject: [PATCH] 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 --- src/modules/scheduling/SchedulingModule.tsx | 7 + src/modules/scheduling/SuggestionDetail.tsx | 192 ++++++++++---------- src/modules/scheduling/types.ts | 73 ++------ src/server/routes/scheduling/algorithm.ts | 54 ++++-- src/server/routes/scheduling/types.ts | 81 ++------- src/shared/scheduling/types.ts | 83 +++++++++ 6 files changed, 250 insertions(+), 240 deletions(-) create mode 100644 src/shared/scheduling/types.ts diff --git a/src/modules/scheduling/SchedulingModule.tsx b/src/modules/scheduling/SchedulingModule.tsx index 51bede3..8763814 100644 --- a/src/modules/scheduling/SchedulingModule.tsx +++ b/src/modules/scheduling/SchedulingModule.tsx @@ -161,6 +161,13 @@ export default function SchedulingModule() { useEffect(() => { 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(() => { if (!data) return { regions: [], vehicleTypes: [], customers: [], departments: [], managers: [] }; const r = new Set(), t = new Set(), c = new Set(), d = new Set(), m = new Set(); diff --git a/src/modules/scheduling/SuggestionDetail.tsx b/src/modules/scheduling/SuggestionDetail.tsx index f4b544b..2033a5f 100644 --- a/src/modules/scheduling/SuggestionDetail.tsx +++ b/src/modules/scheduling/SuggestionDetail.tsx @@ -42,22 +42,87 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce return [...set].sort(); }, [s.candidates]); - // Filtered + sorted candidates - const displayCandidates = useMemo(() => { + // Filtered + sorted candidates, grouped by region + const { sameRegion, crossRegion } = useMemo(() => { let list = s.candidates; 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 vb = sortKey === 'predicted' ? b.predictedAfterSwap : b.totalMileage; 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]); + const displayCount = sameRegion.length + crossRegion.length; + const toggleSort = (key: SortKey) => { if (sortKey === key) { setSortDir(d => d === 'desc' ? 'asc' : 'desc'); } else { setSortKey(key); setSortDir('desc'); } }; + const renderCandidate = (c: CandidateVehicle) => { + const sent = sentPlates.has(c.plateNumber); + return ( +
+
+
+ {c.plateNumber} + + {c.region}{!c.isSameRegion && ' · 跨区'} + + {c.vehicleType} + {c.targetName || '库存'} + 剩余{c.daysLeft}天 +
+ {c.canQualifyAfterSwap ? ( + + 可达标 + + ) : ( + + 需关注 + + )} +
+ +
+
+
+
当前
+
{fmtKm(c.totalMileage)}
+
+
+
替换后预计
+
{fmtKm(c.predictedAfterSwap)}
+
+
+
考核
+
{c.yearTarget ? fmtKm(c.yearTarget) : '-'}
+
+
+
+ +
+ +
+
+ ); + }; + return (
{/* Reason — structured lines */} -
- {s.reason.split('\n').map((line, i) => { - const isConclusion = line.startsWith('!!'); - const text = isConclusion ? line.slice(2) : line; - if (isConclusion) { - return ( -
- {text} -
- ); - } - // Split by | for two-column layout - if (text.includes('|')) { - const parts = text.split('|').map(p => p.trim()); - return ( -
- {parts[0]} - {parts[1]} -
- ); - } - return ( -
- - {text} +
+
+ {s.reason.lines.map((line, i) => ( +
+ {line.label} + {line.value}
- ); - })} + ))} +
+
+ {s.reason.conclusion} +
{/* Candidates */}
- 当前区域可替换在库车辆 - {displayCandidates.length}/{s.candidates.length} 辆 + 可替换在库车辆 + {displayCount}/{s.candidates.length} 辆
{/* Filter + Sort controls */} @@ -222,67 +271,28 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
-
- {displayCandidates.map(c => { - const sent = sentPlates.has(c.plateNumber); - return ( -
- {/* Header */} -
-
- {c.plateNumber} - {c.region} - {c.vehicleType} - {c.targetName || '库存'} - 剩余{c.daysLeft}天 -
- {c.canQualifyAfterSwap ? ( - - 可达标 - - ) : ( - - 需关注 - - )} -
+ {sameRegion.length > 0 && ( +
+ {sameRegion.map(c => renderCandidate(c))} +
+ )} - {/* Metrics */} -
-
-
-
当前
-
{fmtKm(c.totalMileage)}
-
-
-
替换后预计
-
{fmtKm(c.predictedAfterSwap)}
-
-
-
考核
-
{c.yearTarget ? fmtKm(c.yearTarget) : '-'}
-
-
-
+ {crossRegion.length > 0 && ( + <> +
+
+ 跨区候选 · {crossRegion.length} 辆 +
+
+
+ {crossRegion.map(c => renderCandidate(c))} +
+ + )} - {/* Action */} -
- -
-
- ); - })} -
+ {displayCount === 0 && ( +
当前筛选条件下无可替换车辆
+ )}
diff --git a/src/modules/scheduling/types.ts b/src/modules/scheduling/types.ts index d7a0c18..20b8d35 100644 --- a/src/modules/scheduling/types.ts +++ b/src/modules/scheduling/types.ts @@ -1,62 +1,11 @@ -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; -} - -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 type { + SchedulingVehicleInfo, + CandidateVehicle, + SchedulingSuggestion, + SchedulingSummary, + SchedulingTargetOption, + SchedulingResponse, + NotifyRequest, + ReasonLine, + ReasonBlock, +} from '../../shared/scheduling/types'; diff --git a/src/server/routes/scheduling/algorithm.ts b/src/server/routes/scheduling/algorithm.ts index 5dea032..c70c027 100644 --- a/src/server/routes/scheduling/algorithm.ts +++ b/src/server/routes/scheduling/algorithm.ts @@ -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 = { diff --git a/src/server/routes/scheduling/types.ts b/src/server/routes/scheduling/types.ts index 65a998b..b817f5a 100644 --- a/src/server/routes/scheduling/types.ts +++ b/src/server/routes/scheduling/types.ts @@ -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'; diff --git a/src/shared/scheduling/types.ts b/src/shared/scheduling/types.ts new file mode 100644 index 0000000..676fa99 --- /dev/null +++ b/src/shared/scheduling/types.ts @@ -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; +}