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:
295
src/server/routes/scheduling/suggestions.ts
Normal file
295
src/server/routes/scheduling/suggestions.ts
Normal 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;
|
||||
Reference in New Issue
Block a user