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

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

View File

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