feat(scheduling): add suggestions route with data aggregation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-16 20:23:56 +08:00
parent 86d5bc8738
commit 4169e04a9c

View File

@@ -0,0 +1,295 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
import { mapRegion } from '../vehicles.js';
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
import { classifyVehicle, generateSuggestions } from './algorithm.js';
import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse } from './types.js';
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 || '其他';
}
// ---------------------------------------------------------------------------
// Route
// ---------------------------------------------------------------------------
const app = new Hono();
app.get('/', async (c) => {
try {
const targetIdParam = c.req.query('targetId');
const filterTargetId = targetIdParam ? Number(targetIdParam) : null;
// ---- Query 1: Assessment targets ----
const [targets] = await pool.execute(
'SELECT id, target_name, annual_mileage_per_vehicle FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id',
) as [any[], unknown];
const targetMap = new Map<number, { targetName: string; annualMileage: number }>();
for (const t of targets) {
targetMap.set(t.id, {
targetName: t.target_name,
annualMileage: Number(t.annual_mileage_per_vehicle) || 0,
});
}
// ---- Query 2: Assessment vehicles ----
const [assessmentRows] = await pool.execute(`
SELECT target_id, plate_number, today_mileage, vehicle_total_mileage,
current_mileage, current_year_mileage, current_year_mileage_task,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage, current_year_assessment_end_date
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
`) as [any[], unknown];
// ---- Query 3: Vehicle info (customer, dept, manager) ----
const vehicleInfoMap = await fetchVehicleInfoMap();
// ---- Query 4: Vehicle types from tab_truck ----
const [truckTypeRows] = await pool.execute(`
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
`) as [any[], unknown];
const truckTypeMap = new Map<string, { typeName: string; modelRaw: string }>();
for (const row of truckTypeRows) {
truckTypeMap.set(row.plate_number, {
typeName: row.type_name || '',
modelRaw: row.model_raw || '',
});
}
// ---- Query 5: Real-time location ----
const [locationRows] = await pool.execute(`
SELECT plate_number, province, city
FROM tab_truck_remote_sync_realtime_info
WHERE is_deleted = 0 AND plate_number IS NOT NULL
`) as [any[], unknown];
const locationMap = new Map<string, { province: string; city: string }>();
for (const row of locationRows) {
locationMap.set(row.plate_number, {
province: row.province || '',
city: row.city || '',
});
}
// ---- Collect all plates for Query 6 ----
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
// ---- Query 6: Customer daily avg (from mileage DB) ----
const customerAvgDailyMap = new Map<string, number>();
if (allPlates.length > 0) {
const placeholders = allPlates.map(() => '?').join(',');
const [dailyRows] = await mileagePool.execute(
`SELECT plate, AVG(daily_km) as avg_daily
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND stat_date < CURDATE()
AND plate IN (${placeholders})
GROUP BY plate`,
allPlates,
) as [any[], unknown];
// Build plate → avg_daily map
const plateAvgMap = new Map<string, number>();
for (const row of dailyRows) {
plateAvgMap.set(row.plate, Number(row.avg_daily) || 0);
}
// Aggregate per customer: average of all plates belonging to each customer
const customerPlates = new Map<string, number[]>();
for (const plate of allPlates) {
const info = vehicleInfoMap.get(plate);
const customer = info?.customer || '未知客户';
if (!customerPlates.has(customer)) customerPlates.set(customer, []);
const avg = plateAvgMap.get(plate);
if (avg !== undefined) customerPlates.get(customer)!.push(avg);
}
for (const [customer, avgs] of customerPlates) {
if (avgs.length > 0) {
customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length);
}
}
}
// ---- Query 7: Inventory vehicles (rent_status = 0) ----
const [inventoryTruckRows] = await pool.execute(`
SELECT truck.plate_number, dic_type.dic_name AS type_name, truck.model AS model_raw
FROM tab_truck truck
LEFT JOIN tab_dic dic_type ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model AND dic_type.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1
AND truck.truck_rent_status = 0
`) as [any[], unknown];
// ---- Build assessment vehicle lookup for inventory cross-reference ----
const assessmentByPlate = new Map<string, any>();
for (const row of assessmentRows) {
assessmentByPlate.set(row.plate_number, row);
}
// ---- Enrich assessment vehicles ----
const now = new Date();
const yearEnd = new Date(now.getFullYear(), 11, 31); // Dec 31
const enrichedVehicles: EnrichedVehicle[] = [];
for (const row of assessmentRows) {
const targetId = row.target_id as number;
if (filterTargetId !== null && targetId !== filterTargetId) continue;
const target = targetMap.get(targetId);
if (!target) continue;
const plate = row.plate_number as string;
const info = vehicleInfoMap.get(plate);
const loc = locationMap.get(plate);
const truckType = truckTypeMap.get(plate);
const province = loc?.province || '';
const city = loc?.city || '';
const region = mapRegion(province, city);
const vehicleType = truckType
? classifyVehicleType(truckType.typeName, truckType.modelRaw)
: '其他';
const endDate = row.current_year_assessment_end_date
? new Date(row.current_year_assessment_end_date)
: yearEnd;
const daysLeft = Math.max(1, Math.ceil((endDate.getTime() - now.getTime()) / 86400000));
const customer = info?.customer || null;
const customerAvgDaily = customerAvgDailyMap.get(customer || '未知客户') || 0;
const currentYearMileage = Number(row.current_year_mileage) || 0;
const yearTarget = Number(row.current_year_mileage_task) || 0;
const predictedYearEnd = currentYearMileage + customerAvgDaily * daysLeft;
const currentYearIsQualified = row.current_year_is_qualified === 1;
const classification = classifyVehicle(currentYearIsQualified, predictedYearEnd, yearTarget);
enrichedVehicles.push({
plateNumber: plate,
targetId,
targetName: target.targetName,
vehicleType,
totalMileage: Number(row.vehicle_total_mileage) || 0,
currentYearMileage,
completionRate: Number(row.completion_rate) || 0,
yearTarget,
isQualified: row.is_qualified === 1,
currentYearIsQualified,
dailyRequiredMileage: Number(row.daily_required_mileage) || 0,
region,
province,
customer,
customerAvgDaily,
predictedYearEnd,
daysLeft,
classification,
});
}
// ---- Build inventory vehicles ----
const inventoryVehicles: InventoryVehicle[] = [];
for (const row of inventoryTruckRows) {
const plate = row.plate_number as string;
const loc = locationMap.get(plate);
const province = loc?.province || '';
const city = loc?.city || '';
const region = mapRegion(province, city);
const vehicleType = classifyVehicleType(row.type_name || '', row.model_raw || '');
// Cross-reference with assessment data
const assessment = assessmentByPlate.get(plate);
inventoryVehicles.push({
plateNumber: plate,
vehicleType,
region,
province,
totalMileage: assessment ? Number(assessment.vehicle_total_mileage) || 0 : 0,
targetId: assessment ? (assessment.target_id as number) : null,
targetName: assessment ? (targetMap.get(assessment.target_id)?.targetName ?? null) : null,
yearTarget: assessment ? Number(assessment.current_year_mileage_task) || null : null,
completionRate: assessment ? Number(assessment.completion_rate) || 0 : 0,
});
}
// ---- Run algorithm ----
const { suggestions, summary } = generateSuggestions(enrichedVehicles, inventoryVehicles);
// ---- Permission filtering & customer name masking ----
const user = (c as any).get('user') as AuthUser | undefined;
// Attach department/manager info so filterByPermission can work
const suggestionsWithPermFields = suggestions.map((s) => {
const info = vehicleInfoMap.get(s.currentVehicle.plateNumber);
return {
...s,
department: info?.department || null,
departmentName: info?.department || null,
managerId: info?.manager_id || null,
};
});
const filtered = user
? filterByPermission(suggestionsWithPermFields, user)
: suggestionsWithPermFields;
// Mask customer names in suggestions
const masked = maskCustomerNames(
filtered.map((s) => {
// Strip permission-filtering fields from response
const { department, departmentName, managerId, ...rest } = s;
return rest;
}),
);
// ---- Build target options list for filter UI ----
const targetVehicleCounts = new Map<number, number>();
for (const v of enrichedVehicles) {
targetVehicleCounts.set(v.targetId, (targetVehicleCounts.get(v.targetId) || 0) + 1);
}
const targetOptions = targets.map((t: any) => ({
id: t.id as number,
name: t.target_name as string,
vehicleCount: targetVehicleCounts.get(t.id) || 0,
}));
const response: SchedulingResponse = {
summary,
suggestions: masked,
targets: targetOptions,
};
return c.json(response);
} catch (e: unknown) {
console.error('scheduling suggestions error:', e);
return c.json(
{
summary: { qualifiedCount: 0, hopelessCount: 0, suggestionCount: 0, estimatedGain: 0 },
suggestions: [],
targets: [],
} satisfies SchedulingResponse,
500,
);
}
});
export default app;