fix(scheduling): fix vehicle type classification and algorithm candidate matching

- classifyVehicleType now parses dic_type.dic_name (e.g. "4.5吨冷链车") instead of raw model code
- Remove overly strict completionRate >= 0.8 filter for hopeless candidates
- Use vehicle's yearTarget as fallback when inventory has no assessment target
- Filter out suggestions with no candidates (not actionable)
- estimatedGain counts rescue_hopeless suggestions as potential gains

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-16 20:31:44 +08:00
parent db5ca2e686
commit 253cc2f2c0
13 changed files with 2110 additions and 28 deletions

View File

@@ -71,15 +71,14 @@ export function generateSuggestions(
.filter(
(inv) =>
isTypeCompatible(vehicle.vehicleType, inv.vehicleType) &&
inv.region === vehicle.region &&
inv.completionRate >= 0.8,
inv.region === vehicle.region,
)
.map((inv) => {
const mileageGap = (inv.yearTarget ?? 0) - inv.totalMileage;
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
const predictedAfterSwap =
inv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft;
const canQualifyAfterSwap =
inv.yearTarget != null && predictedAfterSwap >= inv.yearTarget;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return {
plateNumber: inv.plateNumber,
targetId: inv.targetId,
@@ -87,7 +86,7 @@ export function generateSuggestions(
vehicleType: inv.vehicleType,
totalMileage: inv.totalMileage,
completionRate: inv.completionRate,
yearTarget: inv.yearTarget,
yearTarget: inv.yearTarget ?? vehicle.yearTarget,
region: inv.region,
province: inv.province,
mileageGap,
@@ -98,6 +97,7 @@ export function generateSuggestions(
.sort((a, b) => {
if (a.canQualifyAfterSwap !== b.canQualifyAfterSwap)
return a.canQualifyAfterSwap ? -1 : 1;
// For hopeless: prefer already-qualified inventory, then highest completion
return b.completionRate - a.completionRate;
})
.slice(0, 5);
@@ -125,11 +125,11 @@ export function generateSuggestions(
inv.region === vehicle.region,
)
.map((inv) => {
const mileageGap = (inv.yearTarget ?? 0) - inv.totalMileage;
const effectiveTarget = inv.yearTarget ?? vehicle.yearTarget;
const mileageGap = Math.max(0, effectiveTarget - inv.totalMileage);
const predictedAfterSwap =
inv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft;
const canQualifyAfterSwap =
inv.yearTarget != null && predictedAfterSwap >= inv.yearTarget;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return {
plateNumber: inv.plateNumber,
targetId: inv.targetId,
@@ -137,7 +137,7 @@ export function generateSuggestions(
vehicleType: inv.vehicleType,
totalMileage: inv.totalMileage,
completionRate: inv.completionRate,
yearTarget: inv.yearTarget,
yearTarget: inv.yearTarget ?? vehicle.yearTarget,
region: inv.region,
province: inv.province,
mileageGap,
@@ -164,22 +164,27 @@ export function generateSuggestions(
});
}
// Remove suggestions with no candidates
const filteredSuggestions = suggestions.filter((s) => s.candidates.length > 0);
// Sort: high priority first
suggestions.sort((a, b) => {
filteredSuggestions.sort((a, b) => {
if (a.priority === b.priority) return 0;
return a.priority === 'high' ? -1 : 1;
});
const estimatedGain = suggestions.filter((s) =>
s.candidates.some((c) => c.canQualifyAfterSwap),
// estimatedGain: count suggestions where at least one candidate canQualifyAfterSwap,
// plus rescue_hopeless suggestions (each rescued car can potentially qualify at a new customer)
const estimatedGain = filteredSuggestions.filter((s) =>
s.candidates.some((c) => c.canQualifyAfterSwap) || s.type === 'rescue_hopeless',
).length;
const summary: SchedulingSummary = {
qualifiedCount: qualified.length,
hopelessCount: hopeless.length,
suggestionCount: suggestions.length,
suggestionCount: filteredSuggestions.length,
estimatedGain,
};
return { suggestions, summary };
return { suggestions: filteredSuggestions, summary };
}

View File

@@ -12,13 +12,18 @@ import type { AuthUser } from '../../auth/types.js';
// Helper: vehicle type classification
// ---------------------------------------------------------------------------
function classifyVehicleType(type: string, model: string): string {
if (type === '4.5T' && model.includes('冷链')) return '4.5T冷链';
if (type === '4.5T') return '4.5T普货';
if (type === '18T') return '18T';
if (type === '49T') return '49T';
if (type === '挂车' || model.includes('挂车')) return '挂车';
return type || '其他';
/**
* Classify vehicle type from dic_type.dic_name (e.g. "4.5冷链车", "4.5吨货车", "18吨双飞翼货车").
* The typeName is the full label from the dictionary, modelRaw is the numeric dic_code.
*/
function classifyVehicleType(typeName: string, _modelRaw: string): string {
const t = (typeName || '').trim();
if (t.includes('4.5') && t.includes('冷链')) return '4.5T冷链';
if (t.includes('4.5')) return '4.5T普货';
if (t.includes('18')) return '18T';
if (t.includes('49') || t.includes('牵引')) return '49T';
if (t.includes('挂车')) return '挂车';
return t || '其他';
}
// ---------------------------------------------------------------------------