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 { fetchActiveNotificationMap, fetchRecentInterventionCount } from './notify.js'; import type { EnrichedVehicle, InventoryVehicle, SchedulingResponse, SchedulingSummary } from './types.js'; import type { AuthUser } from '../../auth/types.js'; // --------------------------------------------------------------------------- // Helper: vehicle type classification // --------------------------------------------------------------------------- /** * Infer vehicle type from target name when truck table has no match. * e.g. "交投190辆4.5T冷链车" → "4.5T冷链", "羚牛100辆18T" → "18T" */ function inferTypeFromTargetName(targetName: string): string { const t = targetName || ''; if (t.includes('冷链')) return '4.5T冷链'; if (t.includes('普货') || (t.includes('4.5') && !t.includes('冷链'))) return '4.5T普货'; if (t.includes('18T') || t.includes('18t')) return '18T'; if (t.includes('49') || t.includes('牵引')) return '49T'; if (t.includes('挂车')) return '挂车'; return '其他'; } /** * 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 || '其他'; } // --------------------------------------------------------------------------- // 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(); 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 ---- // Include soft-deleted trucks: many assessment vehicles have is_deleted=1 in tab_truck // but are still active in the assessment. We need their type info. 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_operation = 1 `) as [any[], unknown]; const truckTypeMap = new Map(); 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(); 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) — 30d baseline + 7d recent ---- const customerAvgDailyMap = new Map(); const customerAvgDaily7dMap = new Map(); if (allPlates.length > 0) { const placeholders = allPlates.map(() => '?').join(','); // Single query returning both windows per plate. const [dailyRows] = await mileagePool.execute( `SELECT plate, AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d, AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d 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]; const plateAvg30Map = new Map(); const plateAvg7Map = new Map(); for (const row of dailyRows) { if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d)); if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d)); } const customerPlates30 = new Map(); const customerPlates7 = new Map(); for (const plate of allPlates) { const info = vehicleInfoMap.get(plate); const customer = info?.customer || '未知客户'; if (!customerPlates30.has(customer)) customerPlates30.set(customer, []); if (!customerPlates7.has(customer)) customerPlates7.set(customer, []); const v30 = plateAvg30Map.get(plate); const v7 = plateAvg7Map.get(plate); if (v30 !== undefined) customerPlates30.get(customer)!.push(v30); if (v7 !== undefined) customerPlates7.get(customer)!.push(v7); } for (const [customer, avgs] of customerPlates30) { if (avgs.length > 0) customerAvgDailyMap.set(customer, avgs.reduce((s, v) => s + v, 0) / avgs.length); } for (const [customer, avgs] of customerPlates7) { if (avgs.length > 0) customerAvgDaily7dMap.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(); 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); // Only include vehicles that are actively rented/operated (租赁 or 自营) const rentStatus = info?.rent_status || ''; if (rentStatus !== '租赁' && rentStatus !== '自营') continue; const loc = locationMap.get(plate); const truckType = truckTypeMap.get(plate); const province = loc?.province || ''; const city = loc?.city || ''; const region = mapRegion(province, city); // Determine vehicle type: prefer truck table, fallback to target name let vehicleType = '其他'; if (truckType) { vehicleType = classifyVehicleType(truckType.typeName, truckType.modelRaw); } else { // Fallback: infer from target name (e.g. "交投190辆4.5T冷链车" → "4.5T冷链") vehicleType = inferTypeFromTargetName(target.targetName); } 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 customerAvgDaily7d = customerAvgDaily7dMap.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, currentYearMileage, yearTarget, predictedYearEnd); 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, department: info?.department || null, manager: info?.manager || null, customerAvgDaily, customerAvgDaily7d, 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); // 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({ plateNumber: plate, vehicleType, region, province, totalMileage: assessment ? Number(assessment.vehicle_total_mileage) || 0 : 0, daysLeft: invDaysLeft, 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); // ---- Attach notification status to candidates ---- const notificationMap = await fetchActiveNotificationMap(); for (const s of suggestions) { for (const c of s.candidates) { const key = `${s.id}::${c.plateNumber}`; const notif = notificationMap.get(key); if (notif) { c.notificationId = notif.id; c.notificationStatus = notif.status; } } } // ---- 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(); 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, })); // Recalculate summary based on permission-filtered results const filteredQualified = masked.filter((s: any) => s.type === 'replace_qualified').length; const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length; const recentInterventionCount = await fetchRecentInterventionCount(); const filteredSummary: SchedulingSummary = { qualifiedCount: filteredQualified, hopelessCount: filteredHopeless, suggestionCount: masked.length, estimatedGain: masked.filter((s: any) => s.candidates?.some((c: any) => c.canQualifyAfterSwap) ).length, recentInterventionCount, }; const response: SchedulingResponse = { summary: filteredSummary, 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, recentInterventionCount: 0 }, suggestions: [], targets: [], } satisfies SchedulingResponse, 500, ); } }); export default app;