diff --git a/src/server/routes/scheduling/suggestions.ts b/src/server/routes/scheduling/suggestions.ts new file mode 100644 index 0000000..1aab875 --- /dev/null +++ b/src/server/routes/scheduling/suggestions.ts @@ -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(); + 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(); + 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) ---- + const customerAvgDailyMap = new Map(); + 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(); + 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(); + 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(); + 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(); + 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;