fix(scheduling): use each candidate's own daysLeft for prediction
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Different assessment targets have different end dates. Previously all
candidates used the current vehicle's daysLeft, causing wrong predictions.

Now each inventory vehicle computes its own daysLeft from its assessment
target's current_year_assessment_end_date. predictedAfterSwap uses the
candidate's own daysLeft instead of the current vehicle's.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-16 22:58:47 +08:00
parent a52a77f3a2
commit aa9a29fed8
5 changed files with 22 additions and 8 deletions

View File

@@ -229,7 +229,7 @@ export default function SuggestionDetail({ suggestion: s, onClose, onNotifySucce
<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-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-400">{c.vehicleType}</span>
<span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span> <span className="text-[9px] text-slate-300">{c.targetName || '库存'}</span>
<span className="text-[9px] text-slate-400">{v.daysLeft}</span> <span className="text-[9px] text-slate-400">{c.daysLeft}</span>
</div> </div>
{c.canQualifyAfterSwap ? ( {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"> <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">

View File

@@ -25,6 +25,7 @@ export interface CandidateVehicle {
totalMileage: number; totalMileage: number;
completionRate: number; completionRate: number;
yearTarget: number | null; yearTarget: number | null;
daysLeft: number;
region: string; region: string;
province: string; province: string;
mileageGap: number; mileageGap: number;

View File

@@ -90,13 +90,10 @@ export function generateSuggestions(
// Among those, prefer the one with the smallest gap (easiest to finish). // Among those, prefer the one with the smallest gap (easiest to finish).
// Exclude already-qualified (>= 100%) — no value in swapping those. // Exclude already-qualified (>= 100%) — no value in swapping those.
for (const vehicle of hopeless) { for (const vehicle of hopeless) {
const customerCanAdd = vehicle.customerAvgDaily * vehicle.daysLeft;
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; if (inv.region !== vehicle.region) return false;
// Exclude already fully qualified
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;
@@ -104,7 +101,9 @@ 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);
const predictedAfterSwap = inv.totalMileage + customerCanAdd; // Use candidate's own daysLeft for prediction
const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget; const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return { return {
plateNumber: inv.plateNumber, plateNumber: inv.plateNumber,
@@ -114,6 +113,7 @@ export function generateSuggestions(
totalMileage: inv.totalMileage, totalMileage: inv.totalMileage,
completionRate: inv.completionRate, completionRate: inv.completionRate,
yearTarget: inv.yearTarget ?? vehicle.yearTarget, yearTarget: inv.yearTarget ?? vehicle.yearTarget,
daysLeft: inv.daysLeft,
region: inv.region, region: inv.region,
province: inv.province, province: inv.province,
mileageGap, mileageGap,
@@ -133,7 +133,7 @@ export function generateSuggestions(
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 + customerCanAdd); 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 = `客户日均 ${Math.round(vehicle.customerAvgDaily)} km | 考核周期剩余 ${vehicle.daysLeft} 天 · 日均需 ${fmtKmSimple(dailyReq)} km\n!!预估无法达标,需替换`;
suggestions.push({ suggestions.push({
@@ -165,8 +165,9 @@ 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);
const predictedAfterSwap = // Use candidate's own daysLeft for prediction
inv.totalMileage + vehicle.customerAvgDaily * vehicle.daysLeft; const candidateCanAdd = vehicle.customerAvgDaily * inv.daysLeft;
const predictedAfterSwap = inv.totalMileage + candidateCanAdd;
const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget; const canQualifyAfterSwap = predictedAfterSwap >= effectiveTarget;
return { return {
plateNumber: inv.plateNumber, plateNumber: inv.plateNumber,
@@ -176,6 +177,7 @@ export function generateSuggestions(
totalMileage: inv.totalMileage, totalMileage: inv.totalMileage,
completionRate: inv.completionRate, completionRate: inv.completionRate,
yearTarget: inv.yearTarget ?? vehicle.yearTarget, yearTarget: inv.yearTarget ?? vehicle.yearTarget,
daysLeft: inv.daysLeft,
region: inv.region, region: inv.region,
province: inv.province, province: inv.province,
mileageGap, mileageGap,

View File

@@ -250,12 +250,21 @@ app.get('/', async (c) => {
// Cross-reference with assessment data // Cross-reference with assessment data
const assessment = assessmentByPlate.get(plate); const assessment = assessmentByPlate.get(plate);
// Compute this vehicle's own daysLeft from its assessment end date
let invDaysLeft = 0;
if (assessment?.current_year_assessment_end_date) {
const endDate = new Date(assessment.current_year_assessment_end_date);
invDaysLeft = Math.max(1, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
} else {
invDaysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
}
inventoryVehicles.push({ inventoryVehicles.push({
plateNumber: plate, plateNumber: plate,
vehicleType, vehicleType,
region, region,
province, province,
totalMileage: assessment ? Number(assessment.vehicle_total_mileage) || 0 : 0, totalMileage: assessment ? Number(assessment.vehicle_total_mileage) || 0 : 0,
daysLeft: invDaysLeft,
targetId: assessment ? (assessment.target_id as number) : null, targetId: assessment ? (assessment.target_id as number) : null,
targetName: assessment ? (targetMap.get(assessment.target_id)?.targetName ?? null) : null, targetName: assessment ? (targetMap.get(assessment.target_id)?.targetName ?? null) : null,
yearTarget: assessment ? Number(assessment.current_year_mileage_task) || null : null, yearTarget: assessment ? Number(assessment.current_year_mileage_task) || null : null,

View File

@@ -25,6 +25,7 @@ export interface CandidateVehicle {
totalMileage: number; totalMileage: number;
completionRate: number; completionRate: number;
yearTarget: number | null; yearTarget: number | null;
daysLeft: number;
region: string; region: string;
province: string; province: string;
mileageGap: number; mileageGap: number;
@@ -97,6 +98,7 @@ export interface InventoryVehicle {
region: string; region: string;
province: string; province: string;
totalMileage: number; totalMileage: number;
daysLeft: number;
targetId: number | null; targetId: number | null;
targetName: string | null; targetName: string | null;
yearTarget: number | null; yearTarget: number | null;