Compare commits

..

7 Commits

Author SHA1 Message Date
kkfluous
6dbd36dcd3 refactor: replace mileage monolith with modular route files
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:35:58 +08:00
kkfluous
7ec422c13a refactor: create targets route handler 2026-04-02 13:33:14 +08:00
kkfluous
890050ed55 refactor: create trend route handler 2026-04-02 13:33:05 +08:00
kkfluous
ac2a16e7b7 refactor: create monitoring route handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:32:32 +08:00
kkfluous
459b0400b4 refactor: extract monitoring cache module 2026-04-02 13:31:07 +08:00
kkfluous
1a169feaa6 refactor: extract vehicle-info query module 2026-04-02 13:30:19 +08:00
kkfluous
bc1e0ea32e refactor: extract mileage shared types 2026-04-02 13:30:02 +08:00
9 changed files with 665 additions and 570 deletions

View File

@@ -4,7 +4,7 @@ import { Hono } from 'hono';
import { cors } from 'hono/cors'; import { cors } from 'hono/cors';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import vehiclesRouter from './routes/vehicles.js'; import vehiclesRouter from './routes/vehicles.js';
import mileageRouter from './routes/mileage.js'; import mileageRouter from './routes/mileage/index.js';
dotenv.config(); dotenv.config();

View File

@@ -1,569 +0,0 @@
import { Hono } from 'hono';
import pool from '../db.js';
import mileagePool from '../mileage-db.js';
const app = new Hono();
// 车辆关联信息 SQL客户名、部门、经理
const VEHICLE_INFO_SQL = `SELECT
truck.plate_number AS plate,
cus.customer_name AS customer,
dep.dep_name AS department,
u.user_name AS manager,
dic_status.dic_name AS rent_status,
org_truck.org_name AS entity,
c.project_name AS project
FROM tab_truck truck
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status'
AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0
LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
// ========== 实时监控缓存每2分钟刷新 ==========
interface CachedVehicle {
plate: string;
vin: string;
dailyKm: number;
totalKm: number | null;
source: string;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;
department: string | null;
manager: string | null;
rentStatus: string | null;
entity: string | null;
project: string | null;
yesterdayKm: number;
}
interface MonitoringCache {
vehicles: CachedVehicle[];
stats: { totalToday: number; totalAll: number; vehicleCount: number };
filters: { departments: string[]; customers: string[]; plates: string[]; projects: string[]; entities: string[]; rentStatuses: string[]; platePrefixes: { prefix: string; count: number }[]; targetNames: string[] };
targetPlatesMap: Map<string, Set<string>>;
updatedAt: string;
}
let monitoringCache: MonitoringCache | null = null;
async function refreshMonitoringCache() {
try {
console.log('[mileage] refreshing monitoring cache...');
const start = Date.now();
// 并行查询两个数据库
const [mileageResult, yesterdayResult, infoRows, targetRows] = await Promise.all([
(async () => {
const [dateRows] = await mileagePool.execute(
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
) as any;
const latestDate = dateRows[0]?.latest;
if (!latestDate) return [];
const [rows] = await mileagePool.execute(
`SELECT plate, vin, daily_km, total_km, source
FROM v_vehicle_daily_stats WHERE stat_date = ?`,
[latestDate]
) as any;
return rows;
})(),
(async () => {
const [rows] = await mileagePool.execute(
`SELECT plate, daily_km FROM v_vehicle_daily_stats
WHERE stat_date = DATE_SUB((SELECT MAX(stat_date) FROM v_vehicle_daily_stats), INTERVAL 1 DAY)`
) as any;
const map = new Map<string, number>();
for (const r of rows) {
const existing = map.get(r.plate) || 0;
const km = Number(r.daily_km) || 0;
if (km > existing) map.set(r.plate, km);
}
return map;
})(),
pool.execute(VEHICLE_INFO_SQL).then(([rows]) => rows as any[]),
pool.execute(`SELECT t.id, t.target_name, v.plate_number FROM tab_mileage_assessment_target t
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`).then(([rows]) => rows as any[]),
]);
// 车辆关联信息 map
const infoMap = new Map<string, any>();
for (const row of infoRows) {
infoMap.set(row.plate, row);
}
// 型号批次→车牌映射
const targetNameMap = new Map<number, string>();
const targetPlatesMap = new Map<string, Set<string>>(); // targetName -> plates
for (const r of targetRows) {
targetNameMap.set(r.id, r.target_name);
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
const targetNames = Array.from(targetPlatesMap.keys());
// 去重:同一 plate 取 daily_km 最大的
const mileageMap = new Map<string, any>();
for (const row of mileageResult) {
const existing = mileageMap.get(row.plate);
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) {
mileageMap.set(row.plate, row);
}
}
// 合并
const vehicles: CachedVehicle[] = Array.from(mileageMap.values()).map((m: any) => {
const info = infoMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
const source = m.source || 'NONE';
return {
plate: m.plate,
vin: m.vin,
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
source,
isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE',
customer: info?.customer || null,
department: info?.department || null,
manager: info?.manager || null,
rentStatus: info?.rent_status || null,
entity: info?.entity || null,
project: info?.project || null,
yesterdayKm: yesterdayResult.get(m.plate) || 0,
};
});
// 预计算统计信息
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
// 预提取筛选选项
const deptOrder = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
const departments = (Array.from(new Set(vehicles.map(v => v.department).filter(Boolean))) as string[])
.sort((a, b) => {
const ai = deptOrder.findIndex(d => a.includes(d));
const bi = deptOrder.findIndex(d => b.includes(d));
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
const customers = Array.from(new Set(vehicles.map(v => v.customer).filter(Boolean))) as string[];
const plates = vehicles.map(v => v.plate);
const projects = Array.from(new Set(vehicles.map(v => v.project).filter(Boolean))) as string[];
const entities = Array.from(new Set(vehicles.map(v => v.entity).filter(Boolean))) as string[];
const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter(Boolean))) as string[];
const prefixCount = new Map<string, number>();
for (const v of vehicles) {
const p = v.plate.charAt(0);
prefixCount.set(p, (prefixCount.get(p) || 0) + 1);
}
const platePrefixes = Array.from(prefixCount.entries()).map(([prefix, count]) => ({ prefix, count })).sort((a, b) => b.count - a.count);
monitoringCache = {
vehicles,
stats: { totalToday, totalAll, vehicleCount: vehicles.length },
filters: { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames },
targetPlatesMap,
updatedAt: new Date().toISOString(),
};
console.log(`[mileage] cache refreshed: ${vehicles.length} vehicles in ${Date.now() - start}ms`);
} catch (e) {
console.error('[mileage] cache refresh error:', e);
}
}
// 启动时立即刷新之后每2分钟刷新
refreshMonitoringCache();
setInterval(refreshMonitoringCache, 60 * 1000);
// 查询指定日期的里程数据(非缓存)
async function queryDateMileage(dateStr: string): Promise<{ vehicles: CachedVehicle[] }> {
const [mileageRows, yesterdayRows, infoRows] = await Promise.all([
mileagePool.execute(`SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?`, [dateStr]).then(([r]) => r as any[]),
mileagePool.execute(`SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)`, [dateStr]).then(([r]) => r as any[]),
pool.execute(VEHICLE_INFO_SQL).then(([r]) => r as any[]),
]);
const infoMap = new Map<string, any>();
for (const row of infoRows) infoMap.set(row.plate, row);
const yesterdayMap = new Map<string, number>();
for (const r of yesterdayRows) {
const km = Number(r.daily_km) || 0;
const existing = yesterdayMap.get(r.plate) || 0;
if (km > existing) yesterdayMap.set(r.plate, km);
}
const mileageMap = new Map<string, any>();
for (const row of mileageRows) {
const existing = mileageMap.get(row.plate);
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) mileageMap.set(row.plate, row);
}
const vehicles: CachedVehicle[] = Array.from(mileageMap.values()).map((m: any) => {
const info = infoMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
const source = m.source || 'NONE';
return {
plate: m.plate, vin: m.vin, dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
source, isOnline: source !== 'NONE' && dailyKm > 0, isDataSynced: source !== 'NONE',
customer: info?.customer || null, department: info?.department || null,
manager: info?.manager || null, rentStatus: info?.rent_status || null,
entity: info?.entity || null, project: info?.project || null,
yesterdayKm: yesterdayMap.get(m.plate) || 0,
};
});
return { vehicles };
}
// GET /monitoring — 从缓存取数据(或指定日期实时查询),支持筛选/排序/分页
app.get('/monitoring', async (c) => {
const emptyResponse = { vehicles: [], stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }, filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] }, total: 0, page: 1, totalPages: 1, updatedAt: new Date().toISOString() };
const sortBy = c.req.query('sortBy') || 'today';
const sortOrder = c.req.query('sortOrder') || 'desc';
const limit = Number(c.req.query('limit')) || 50;
const page = Number(c.req.query('page')) || 1;
const search = c.req.query('search') || '';
const dept = c.req.query('dept') || '';
const customer = c.req.query('customer') || '';
const project = c.req.query('project') || '';
const entity = c.req.query('entity') || '';
const mileageMin = c.req.query('mileageMin') || '';
const mileageMax = c.req.query('mileageMax') || '';
const platePrefix = c.req.query('platePrefix') || '';
const targetName = c.req.query('targetName') || '';
const plate = c.req.query('plate') || '';
const rentStatus = c.req.query('rentStatus') || '';
const date = c.req.query('date') || '';
let allVehicles: CachedVehicle[];
let filters: MonitoringCache['filters'];
if (date) {
// 指定日期:实时查询
try {
const result = await queryDateMileage(date);
allVehicles = result.vehicles;
const deptOrder = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
filters = {
departments: (Array.from(new Set(allVehicles.map(v => v.department).filter(Boolean))) as string[]).sort((a, b) => {
const ai = deptOrder.findIndex(d => a.includes(d)); const bi = deptOrder.findIndex(d => b.includes(d));
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
}),
customers: Array.from(new Set(allVehicles.map(v => v.customer).filter(Boolean))) as string[],
plates: allVehicles.map(v => v.plate),
projects: Array.from(new Set(allVehicles.map(v => v.project).filter(Boolean))) as string[],
entities: Array.from(new Set(allVehicles.map(v => v.entity).filter(Boolean))) as string[],
rentStatuses: Array.from(new Set(allVehicles.map(v => v.rentStatus).filter(Boolean))) as string[],
platePrefixes: (() => { const m = new Map<string, number>(); for (const v of allVehicles) { const p = v.plate.charAt(0); m.set(p, (m.get(p) || 0) + 1); } return Array.from(m.entries()).map(([prefix, count]) => ({ prefix, count })).sort((a, b) => b.count - a.count); })(),
targetNames: monitoringCache?.filters.targetNames || [],
};
} catch (e) {
console.error('monitoring date query error:', e);
return c.json(emptyResponse, 500);
}
} else {
if (!monitoringCache) return c.json(emptyResponse);
allVehicles = monitoringCache.vehicles;
filters = monitoringCache.filters;
}
let vehicles = allVehicles;
// 筛选
if (search) {
const q = search.toLowerCase();
vehicles = vehicles.filter(v =>
v.plate.toLowerCase().includes(q) ||
(v.customer || '').toLowerCase().includes(q) ||
(v.project || '').toLowerCase().includes(q)
);
}
if (dept) vehicles = vehicles.filter(v => dept === '__EMPTY__' ? !v.department : v.department === dept);
if (customer) vehicles = vehicles.filter(v => customer === '__EMPTY__' ? !v.customer : v.customer === customer);
if (project) vehicles = vehicles.filter(v => v.project === project);
if (entity) vehicles = vehicles.filter(v => v.entity === entity);
if (rentStatus) vehicles = vehicles.filter(v => v.rentStatus === rentStatus);
if (plate) vehicles = vehicles.filter(v => v.plate === plate);
if (platePrefix) vehicles = vehicles.filter(v => v.plate.startsWith(platePrefix));
if (targetName && monitoringCache?.targetPlatesMap) {
const tPlates = monitoringCache.targetPlatesMap.get(targetName);
if (tPlates) vehicles = vehicles.filter(v => tPlates.has(v.plate));
else vehicles = [];
}
if (mileageMin) vehicles = vehicles.filter(v => v.dailyKm >= Number(mileageMin));
if (mileageMax) vehicles = vehicles.filter(v => v.dailyKm <= Number(mileageMax));
const total = vehicles.length;
// 基于筛选后的数据计算统计yesterdayTotal 也基于筛选后的车辆)
const filteredStats = {
totalToday: vehicles.reduce((sum, v) => sum + v.dailyKm, 0),
totalAll: vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0),
vehicleCount: vehicles.length,
yesterdayTotal: vehicles.reduce((sum, v) => sum + v.yesterdayKm, 0),
};
// 排序
vehicles = [...vehicles].sort((a, b) => {
const valA = sortBy === 'today' ? a.dailyKm : (a.totalKm || 0);
const valB = sortBy === 'today' ? b.dailyKm : (b.totalKm || 0);
return sortOrder === 'desc' ? valB - valA : valA - valB;
});
// 分页
const offset = (page - 1) * limit;
const paged = vehicles.slice(offset, offset + limit);
return c.json({
vehicles: paged,
stats: filteredStats,
filters,
total,
page,
totalPages: Math.ceil(total / limit),
updatedAt: date || monitoringCache?.updatedAt || new Date().toISOString(),
});
});
// GET /targets — 考核项目列表 + 汇总
app.get('/targets', async (c) => {
try {
const [targets] = await pool.execute(
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as any;
const [vehicleStats] = await pool.execute(`
SELECT
target_id,
COUNT(*) as total,
SUM(today_mileage) as today_total,
SUM(current_mileage) as cumulative_total,
AVG(current_year_completion_rate) as avg_completion,
SUM(CASE WHEN is_qualified = 1 THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN current_year_is_qualified = 1 THEN 1 ELSE 0 END) as year_qualified_count,
SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date
FROM tab_mileage_assessment_vehicle
WHERE is_deleted = 0
GROUP BY target_id
`) as any;
const statsMap = new Map<number, any>();
for (const s of vehicleStats) {
statsMap.set(s.target_id, s);
}
// 查询每个 target 的不同考核区间
const [periodRows] = await pool.execute(`
SELECT target_id,
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM tab_mileage_assessment_vehicle
WHERE is_deleted = 0
GROUP BY target_id, assessment_start_date, assessment_end_date
ORDER BY target_id, assessment_start_date
`) as any;
const periodsMap = new Map<number, string[]>();
for (const p of periodRows) {
const list = periodsMap.get(p.target_id) || [];
list.push(`${p.start_date} ~ ${p.end_date} (${p.cnt}台)`);
periodsMap.set(p.target_id, list);
}
// 使用监控缓存的里程数据(与里程看板一致)
const cacheVehicleMap = new Map<string, number>();
if (monitoringCache) {
for (const v of monitoringCache.vehicles) {
cacheVehicleMap.set(v.plate, Math.max(0, v.dailyKm || 0));
}
}
const [targetVehicleRows] = await pool.execute(
`SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0`
) as any;
const targetIdPlatesMap = new Map<number, string[]>();
for (const r of targetVehicleRows) {
const list = targetIdPlatesMap.get(r.target_id) || [];
list.push(r.plate_number);
targetIdPlatesMap.set(r.target_id, list);
}
const now = new Date();
const result = targets.map((t: any) => {
const s = statsMap.get(t.id) || {};
const currentYearTarget = Number(s.current_year_target) || 0;
const currentYearCompleted = Number(s.current_year_completed) || 0;
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
const dailyTarget = remaining / daysLeft;
const periods = periodsMap.get(t.id) || [];
if (periods.length === 0) {
const startDate = t.default_start_date
? new Date(t.default_start_date).toISOString().split('T')[0]
: '';
const endDate = t.default_end_date
? new Date(t.default_end_date).toISOString().split('T')[0]
: '';
if (startDate || endDate) periods.push(`${startDate} ~ ${endDate}`);
}
return {
id: t.id,
targetName: t.target_name,
vehicleCount: Number(s.total) || t.vehicle_count,
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
assessmentYears: t.assessment_years,
periods,
todayTotal: (targetIdPlatesMap.get(t.id) || []).reduce((sum: number, plate: string) => sum + (cacheVehicleMap.get(plate) || 0), 0),
cumulativeTotal: Number(s.cumulative_total) || 0,
avgCompletion: (Number(s.avg_completion) || 0) * 100,
qualifiedCount: Number(s.qualified_count) || 0,
yearQualifiedCount: Number(s.year_qualified_count) || 0,
halfQualifiedCount: Number(s.half_qualified_count) || 0,
currentYearTarget,
currentYearCompleted,
remaining,
daysLeft,
dailyTarget: Math.round(dailyTarget * 10) / 10,
};
});
return c.json(result);
} catch (e) {
console.error('targets error:', e);
return c.json([], 500);
}
});
// GET /target/:id/vehicles — 某项目的车辆明细(支持 ?date= 查询指定日期里程)
app.get('/target/:id/vehicles', async (c) => {
const targetId = c.req.param('id');
const date = c.req.query('date') || '';
try {
// 获取考核车辆基本信息
const [rows] = await pool.execute(
`SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage
FROM tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`,
[targetId]
) as any;
// 获取车辆关联信息(租赁状态、部门、客户)
const plates = rows.map((r: any) => r.plate_number);
const infoMap = new Map<string, any>();
if (plates.length > 0) {
const [infoRows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`,
plates
) as any;
for (const row of infoRows) {
infoMap.set(row.plate, row);
}
}
// 如果指定了日期,从里程数据库查询该日期的里程
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
if (date && plates.length > 0) {
const [mileageRows] = await mileagePool.execute(
`SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats
WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`,
[date, ...plates]
) as any;
for (const m of mileageRows) {
const existing = dateMileageMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
if (!existing || dailyKm > existing.dailyKm) {
const source = m.source || 'NONE';
dateMileageMap.set(m.plate, {
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
isOnline: source !== 'NONE' && dailyKm > 0,
});
}
}
}
const result = rows.map((r: any) => {
const info = infoMap.get(r.plate_number);
const dateMileage = date ? dateMileageMap.get(r.plate_number) : null;
return {
plateNumber: r.plate_number,
todayMileage: dateMileage ? dateMileage.dailyKm : (Number(r.today_mileage) || 0),
totalMileage: dateMileage?.totalKm ?? (Number(r.vehicle_total_mileage) || 0),
completionRate: Number(r.completion_rate) || 0,
isQualified: r.is_qualified === 1,
currentYearIsQualified: r.current_year_is_qualified === 1,
dailyRequiredMileage: Number(r.daily_required_mileage) || 0,
rentStatus: info?.rent_status || null,
department: info?.department || null,
customer: info?.customer || null,
isOnline: dateMileage ? dateMileage.isOnline : true,
};
});
return c.json(result);
} catch (e) {
console.error('target vehicles error:', e);
return c.json([], 500);
}
});
// GET /trend — 7天里程趋势
app.get('/trend', async (c) => {
const targetId = c.req.query('targetId');
const days = Number(c.req.query('days')) || 7;
try {
let plates: string[] = [];
if (targetId) {
const [vehicleRows] = await pool.execute(
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId]
) as any;
plates = vehicleRows.map((r: any) => r.plate_number);
if (plates.length === 0) return c.json([]);
}
let sql = `
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
`;
const params: any[] = [days];
if (plates.length > 0) {
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
params.push(...plates);
}
sql += ' GROUP BY stat_date ORDER BY stat_date';
const [rows] = await mileagePool.execute(sql, params) as any;
const result = rows.map((r: any) => ({
date: r.date,
mileage: Math.round(Number(r.mileage) || 0),
}));
return c.json(result);
} catch (e) {
console.error('trend error:', e);
return c.json([], 500);
}
});
export default app;

View File

@@ -0,0 +1,179 @@
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
let monitoringCache: MonitoringCache | null = null;
export function getCache(): MonitoringCache | null {
return monitoringCache;
}
const DEPT_ORDER = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
function sortDepartments(departments: string[]): string[] {
return departments.sort((a, b) => {
const ai = DEPT_ORDER.findIndex(d => a.includes(d));
const bi = DEPT_ORDER.findIndex(d => b.includes(d));
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
}
function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): MonitoringFilters {
const departments = sortDepartments(
Array.from(new Set(vehicles.map(v => v.department).filter((d): d is string => d !== null)))
);
const customers = Array.from(new Set(vehicles.map(v => v.customer).filter((c): c is string => c !== null)));
const plates = vehicles.map(v => v.plate);
const projects = Array.from(new Set(vehicles.map(v => v.project).filter((p): p is string => p !== null)));
const entities = Array.from(new Set(vehicles.map(v => v.entity).filter((e): e is string => e !== null)));
const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter((r): r is string => r !== null)));
const prefixCount = new Map<string, number>();
for (const v of vehicles) {
const p = v.plate.charAt(0);
prefixCount.set(p, (prefixCount.get(p) || 0) + 1);
}
const platePrefixes: PlatePrefix[] = Array.from(prefixCount.entries())
.map(([prefix, count]) => ({ prefix, count }))
.sort((a, b) => b.count - a.count);
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames };
}
interface MileageRow {
plate: string;
vin: string;
daily_km: string;
total_km: string | null;
source: string;
}
function mergeVehicles(
mileageRows: MileageRow[],
infoMap: Map<string, VehicleInfoRow>,
yesterdayMap: Map<string, number>,
): CachedVehicle[] {
const mileageMap = new Map<string, MileageRow>();
for (const row of mileageRows) {
const existing = mileageMap.get(row.plate);
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) {
mileageMap.set(row.plate, row);
}
}
return Array.from(mileageMap.values()).map(m => {
const info = infoMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
const source = m.source || 'NONE';
return {
plate: m.plate,
vin: m.vin,
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
source,
isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE',
customer: info?.customer || null,
department: info?.department || null,
manager: info?.manager || null,
rentStatus: info?.rent_status || null,
entity: info?.entity || null,
project: info?.project || null,
yesterdayKm: yesterdayMap.get(m.plate) || 0,
};
});
}
export async function refreshMonitoringCache(): Promise<void> {
try {
console.log('[mileage] refreshing monitoring cache...');
const start = Date.now();
const [mileageRows, yesterdayMap, infoMap, targetRows] = await Promise.all([
(async () => {
const [dateRows] = await mileagePool.execute(
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
) as [{ latest: string | null }[], unknown];
const latestDate = dateRows[0]?.latest;
if (!latestDate) return [];
const [rows] = await mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[latestDate]
) as [MileageRow[], unknown];
return rows;
})(),
(async () => {
const [rows] = await mileagePool.execute(
`SELECT plate, daily_km FROM v_vehicle_daily_stats
WHERE stat_date = DATE_SUB((SELECT MAX(stat_date) FROM v_vehicle_daily_stats), INTERVAL 1 DAY)`
) as [{ plate: string; daily_km: string }[], unknown];
const map = new Map<string, number>();
for (const r of rows) {
const km = Number(r.daily_km) || 0;
const existing = map.get(r.plate) || 0;
if (km > existing) map.set(r.plate, km);
}
return map;
})(),
fetchVehicleInfoMap(),
pool.execute(
`SELECT t.id, t.target_name, v.plate_number
FROM tab_mileage_assessment_target t
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
WHERE t.is_deleted = 0`
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
]);
const targetPlatesMap = new Map<string, Set<string>>();
for (const r of targetRows) {
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
const targetNames = Array.from(targetPlatesMap.keys());
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap);
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
monitoringCache = {
vehicles,
stats: { totalToday, totalAll, vehicleCount: vehicles.length },
filters: buildFilters(vehicles, targetNames),
targetPlatesMap,
updatedAt: new Date().toISOString(),
};
console.log(`[mileage] cache refreshed: ${vehicles.length} vehicles in ${Date.now() - start}ms`);
} catch (e: unknown) {
console.error('[mileage] cache refresh error:', e);
}
}
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
const [mileageRows, yesterdayRows, infoMap] = await Promise.all([
mileagePool.execute(
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
[dateStr]
).then(([r]) => r as MileageRow[]),
mileagePool.execute(
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
[dateStr]
).then(([r]) => r as { plate: string; daily_km: string }[]),
fetchVehicleInfoMap(),
]);
const yesterdayMap = new Map<string, number>();
for (const r of yesterdayRows) {
const km = Number(r.daily_km) || 0;
const existing = yesterdayMap.get(r.plate) || 0;
if (km > existing) yesterdayMap.set(r.plate, km);
}
return mergeVehicles(mileageRows, infoMap, yesterdayMap);
}
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
return buildFilters(vehicles, monitoringCache?.filters.targetNames || []);
}

View File

@@ -0,0 +1,18 @@
import { Hono } from 'hono';
import { refreshMonitoringCache } from './cache.js';
import monitoringRouter from './monitoring.js';
import targetsRouter from './targets.js';
import trendRouter from './trend.js';
const app = new Hono();
app.route('/monitoring', monitoringRouter);
app.route('/targets', targetsRouter);
app.route('/target', targetsRouter);
app.route('/trend', trendRouter);
// 启动时立即刷新缓存,之后每分钟刷新
refreshMonitoringCache();
setInterval(refreshMonitoringCache, 60 * 1000);
export default app;

View File

@@ -0,0 +1,119 @@
import { Hono } from 'hono';
import { getCache, queryDateMileage, buildDateFilters } from './cache.js';
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js';
const app = new Hono();
const EMPTY_RESPONSE: MonitoringResponse = {
vehicles: [],
stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 },
filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [] },
total: 0,
page: 1,
totalPages: 1,
updatedAt: new Date().toISOString(),
};
function applyFilters(vehicles: CachedVehicle[], params: {
search: string; dept: string; customer: string; project: string;
entity: string; rentStatus: string; plate: string; platePrefix: string;
targetName: string; mileageMin: string; mileageMax: string;
}): CachedVehicle[] {
let result = vehicles;
if (params.search) {
const q = params.search.toLowerCase();
result = result.filter(v =>
v.plate.toLowerCase().includes(q) ||
(v.customer || '').toLowerCase().includes(q) ||
(v.project || '').toLowerCase().includes(q)
);
}
if (params.dept) result = result.filter(v => params.dept === '__EMPTY__' ? !v.department : v.department === params.dept);
if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : v.customer === params.customer);
if (params.project) result = result.filter(v => v.project === params.project);
if (params.entity) result = result.filter(v => v.entity === params.entity);
if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus);
if (params.plate) result = result.filter(v => v.plate === params.plate);
if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix));
if (params.targetName) {
const cache = getCache();
const tPlates = cache?.targetPlatesMap.get(params.targetName);
result = tPlates ? result.filter(v => tPlates.has(v.plate)) : [];
}
if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin));
if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax));
return result;
}
app.get('/', async (c) => {
const sortBy = c.req.query('sortBy') || 'today';
const sortOrder = c.req.query('sortOrder') || 'desc';
const limit = Number(c.req.query('limit')) || 50;
const page = Number(c.req.query('page')) || 1;
const date = c.req.query('date') || '';
const filterParams = {
search: c.req.query('search') || '',
dept: c.req.query('dept') || '',
customer: c.req.query('customer') || '',
project: c.req.query('project') || '',
entity: c.req.query('entity') || '',
rentStatus: c.req.query('rentStatus') || '',
plate: c.req.query('plate') || '',
platePrefix: c.req.query('platePrefix') || '',
targetName: c.req.query('targetName') || '',
mileageMin: c.req.query('mileageMin') || '',
mileageMax: c.req.query('mileageMax') || '',
};
let allVehicles: CachedVehicle[];
let filters: MonitoringFilters;
if (date) {
try {
allVehicles = await queryDateMileage(date);
filters = buildDateFilters(allVehicles);
} catch (e: unknown) {
console.error('monitoring date query error:', e);
return c.json(EMPTY_RESPONSE, 500);
}
} else {
const cache = getCache();
if (!cache) return c.json(EMPTY_RESPONSE);
allVehicles = cache.vehicles;
filters = cache.filters;
}
const filtered = applyFilters(allVehicles, filterParams);
const stats = {
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
vehicleCount: filtered.length,
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
};
const sorted = [...filtered].sort((a, b) => {
const valA = sortBy === 'today' ? a.dailyKm : (a.totalKm || 0);
const valB = sortBy === 'today' ? b.dailyKm : (b.totalKm || 0);
return sortOrder === 'desc' ? valB - valA : valA - valB;
});
const offset = (page - 1) * limit;
const paged = sorted.slice(offset, offset + limit);
const total = filtered.length;
return c.json({
vehicles: paged,
stats,
filters,
total,
page,
totalPages: Math.ceil(total / limit),
updatedAt: date || getCache()?.updatedAt || new Date().toISOString(),
});
});
export default app;

View File

@@ -0,0 +1,180 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
import { getCache } from './cache.js';
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
const app = new Hono();
app.get('/', async (c) => {
try {
const [targets] = await pool.execute(
'SELECT * FROM tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id'
) as [any[], unknown];
const [vehicleStats] = await pool.execute(`
SELECT
target_id, COUNT(*) as total,
SUM(today_mileage) as today_total,
SUM(current_mileage) as cumulative_total,
AVG(current_year_completion_rate) as avg_completion,
SUM(CASE WHEN is_qualified = 1 THEN 1 ELSE 0 END) as qualified_count,
SUM(CASE WHEN current_year_is_qualified = 1 THEN 1 ELSE 0 END) as year_qualified_count,
SUM(CASE WHEN current_year_completion_rate >= 0.5 THEN 1 ELSE 0 END) as half_qualified_count,
SUM(current_year_mileage_task) as current_year_target,
SUM(current_year_mileage) as current_year_completed,
MAX(current_year_assessment_end_date) as year_end_date
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id
`) as [any[], unknown];
const statsMap = new Map<number, any>();
for (const s of vehicleStats) statsMap.set(s.target_id, s);
const [periodRows] = await pool.execute(`
SELECT target_id,
DATE_FORMAT(assessment_start_date, '%Y-%m-%d') as start_date,
DATE_FORMAT(assessment_end_date, '%Y-%m-%d') as end_date,
COUNT(*) as cnt
FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0
GROUP BY target_id, assessment_start_date, assessment_end_date
ORDER BY target_id, assessment_start_date
`) as [any[], unknown];
const periodsMap = new Map<number, string[]>();
for (const p of periodRows) {
const list = periodsMap.get(p.target_id) || [];
list.push(`${p.start_date} ~ ${p.end_date} (${p.cnt}台)`);
periodsMap.set(p.target_id, list);
}
const cache = getCache();
const cacheVehicleMap = new Map<string, number>();
if (cache) {
for (const v of cache.vehicles) {
cacheVehicleMap.set(v.plate, Math.max(0, v.dailyKm || 0));
}
}
const [targetVehicleRows] = await pool.execute(
'SELECT target_id, plate_number FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
) as [{ target_id: number; plate_number: string }[], unknown];
const targetIdPlatesMap = new Map<number, string[]>();
for (const r of targetVehicleRows) {
const list = targetIdPlatesMap.get(r.target_id) || [];
list.push(r.plate_number);
targetIdPlatesMap.set(r.target_id, list);
}
const now = new Date();
const result = targets.map((t: any) => {
const s = statsMap.get(t.id) || {};
const currentYearTarget = Number(s.current_year_target) || 0;
const currentYearCompleted = Number(s.current_year_completed) || 0;
const remaining = Math.max(0, currentYearTarget - currentYearCompleted);
const yearEnd = s.year_end_date ? new Date(s.year_end_date) : now;
const daysLeft = Math.max(1, Math.ceil((yearEnd.getTime() - now.getTime()) / 86400000));
const dailyTarget = remaining / daysLeft;
const periods = periodsMap.get(t.id) || [];
if (periods.length === 0) {
const startDate = t.default_start_date ? new Date(t.default_start_date).toISOString().split('T')[0] : '';
const endDate = t.default_end_date ? new Date(t.default_end_date).toISOString().split('T')[0] : '';
if (startDate || endDate) periods.push(`${startDate} ~ ${endDate}`);
}
return {
id: t.id,
targetName: t.target_name,
vehicleCount: Number(s.total) || t.vehicle_count,
totalMileagePerVehicle: Number(t.total_mileage_per_vehicle),
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
assessmentYears: t.assessment_years,
periods,
todayTotal: (targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0),
cumulativeTotal: Number(s.cumulative_total) || 0,
avgCompletion: (Number(s.avg_completion) || 0) * 100,
qualifiedCount: Number(s.qualified_count) || 0,
yearQualifiedCount: Number(s.year_qualified_count) || 0,
halfQualifiedCount: Number(s.half_qualified_count) || 0,
currentYearTarget,
currentYearCompleted,
remaining,
daysLeft,
dailyTarget: Math.round(dailyTarget * 10) / 10,
};
});
return c.json(result);
} catch (e: unknown) {
console.error('targets error:', e);
return c.json([], 500);
}
});
app.get('/:id/vehicles', async (c) => {
const targetId = c.req.param('id');
const date = c.req.query('date') || '';
try {
const [rows] = await pool.execute(
`SELECT plate_number, today_mileage, vehicle_total_mileage,
completion_rate, is_qualified, current_year_is_qualified,
daily_required_mileage
FROM tab_mileage_assessment_vehicle
WHERE target_id = ? AND is_deleted = 0
ORDER BY today_mileage DESC`,
[targetId]
) as [any[], unknown];
const plates: string[] = rows.map((r: any) => r.plate_number);
const infoMap = await fetchVehicleInfoByPlates(plates);
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
if (date && plates.length > 0) {
const [mileageRows] = await mileagePool.execute(
`SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats
WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`,
[date, ...plates]
) as [any[], unknown];
for (const m of mileageRows) {
const existing = dateMileageMap.get(m.plate);
const dailyKm = Number(m.daily_km) || 0;
if (!existing || dailyKm > existing.dailyKm) {
const source = m.source || 'NONE';
dateMileageMap.set(m.plate, {
dailyKm,
totalKm: m.total_km !== null ? Number(m.total_km) : null,
isOnline: source !== 'NONE' && dailyKm > 0,
});
}
}
}
const result = rows.map((r: any) => {
const info = infoMap.get(r.plate_number);
const dateMileage = date ? dateMileageMap.get(r.plate_number) : null;
return {
plateNumber: r.plate_number,
todayMileage: dateMileage ? dateMileage.dailyKm : (Number(r.today_mileage) || 0),
totalMileage: dateMileage?.totalKm ?? (Number(r.vehicle_total_mileage) || 0),
completionRate: Number(r.completion_rate) || 0,
isQualified: r.is_qualified === 1,
currentYearIsQualified: r.current_year_is_qualified === 1,
dailyRequiredMileage: Number(r.daily_required_mileage) || 0,
rentStatus: info?.rent_status || null,
department: info?.department || null,
customer: info?.customer || null,
isOnline: dateMileage ? dateMileage.isOnline : true,
};
});
return c.json(result);
} catch (e: unknown) {
console.error('target vehicles error:', e);
return c.json([], 500);
}
});
export default app;

View File

@@ -0,0 +1,48 @@
import { Hono } from 'hono';
import pool from '../../db.js';
import mileagePool from '../../mileage-db.js';
const app = new Hono();
app.get('/', async (c) => {
const targetId = c.req.query('targetId');
const days = Number(c.req.query('days')) || 7;
try {
let plates: string[] = [];
if (targetId) {
const [vehicleRows] = await pool.execute(
'SELECT plate_number FROM tab_mileage_assessment_vehicle WHERE target_id = ? AND is_deleted = 0',
[targetId]
) as [{ plate_number: string }[], unknown];
plates = vehicleRows.map(r => r.plate_number);
if (plates.length === 0) return c.json([]);
}
let sql = `
SELECT DATE_FORMAT(stat_date, '%m-%d') as date, SUM(daily_km) as mileage
FROM v_vehicle_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
`;
const params: (string | number)[] = [days];
if (plates.length > 0) {
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
params.push(...plates);
}
sql += ' GROUP BY stat_date ORDER BY stat_date';
const [rows] = await mileagePool.execute(sql, params) as [any[], unknown];
return c.json(rows.map((r: any) => ({
date: r.date,
mileage: Math.round(Number(r.mileage) || 0),
})));
} catch (e: unknown) {
console.error('trend error:', e);
return c.json([], 500);
}
});
export default app;

View File

@@ -0,0 +1,74 @@
/** 缓存中的单辆车数据 */
export interface CachedVehicle {
plate: string;
vin: string;
dailyKm: number;
totalKm: number | null;
source: string;
isOnline: boolean;
isDataSynced: boolean;
customer: string | null;
department: string | null;
manager: string | null;
rentStatus: string | null;
entity: string | null;
project: string | null;
yesterdayKm: number;
}
/** 车牌前缀统计 */
export interface PlatePrefix {
prefix: string;
count: number;
}
/** 筛选选项(前端下拉) */
export interface MonitoringFilters {
departments: string[];
customers: string[];
plates: string[];
projects: string[];
entities: string[];
rentStatuses: string[];
platePrefixes: PlatePrefix[];
targetNames: string[];
}
/** 监控缓存 */
export interface MonitoringCache {
vehicles: CachedVehicle[];
stats: { totalToday: number; totalAll: number; vehicleCount: number };
filters: MonitoringFilters;
targetPlatesMap: Map<string, Set<string>>;
updatedAt: string;
}
/** /monitoring 响应中的统计 */
export interface MonitoringStats {
totalToday: number;
totalAll: number;
vehicleCount: number;
yesterdayTotal: number;
}
/** /monitoring 完整响应 */
export interface MonitoringResponse {
vehicles: CachedVehicle[];
stats: MonitoringStats;
filters: MonitoringFilters;
total: number;
page: number;
totalPages: number;
updatedAt: string;
}
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
export interface VehicleInfoRow {
plate: string;
customer: string | null;
department: string | null;
manager: string | null;
rent_status: string | null;
entity: string | null;
project: string | null;
}

View File

@@ -0,0 +1,46 @@
import pool from '../../db.js';
import type { VehicleInfoRow } from './types.js';
/** 车辆关联信息 SQL客户名、部门、经理、租赁状态、主体、项目 */
export const VEHICLE_INFO_SQL = `SELECT
truck.plate_number AS plate,
cus.customer_name AS customer,
dep.dep_name AS department,
u.user_name AS manager,
dic_status.dic_name AS rent_status,
org_truck.org_name AS entity,
c.project_name AS project
FROM tab_truck truck
LEFT JOIN tab_truck_status_info si ON si.truck_id = truck.id AND si.is_deleted = 0
LEFT JOIN tab_contract c ON c.id = si.contract_id AND c.is_deleted = 0
LEFT JOIN tab_customer cus ON cus.id = c.customer_id AND cus.is_deleted = 0
LEFT JOIN tab_user u ON u.id = c.bd AND u.is_deleted = 0
LEFT JOIN tab_department dep ON dep.id = u.dep_id AND dep.is_deleted = 0
LEFT JOIN tab_dic dic_status ON dic_status.parent_code = 'dic_truck_rent_status'
AND dic_status.dic_code = truck.truck_rent_status AND dic_status.is_deleted = 0
LEFT JOIN tab_org org_truck ON org_truck.id = truck.org_id AND org_truck.is_deleted = 0
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
/** 查询所有车辆关联信息,返回 plate→info 的 Map */
export async function fetchVehicleInfoMap(): Promise<Map<string, VehicleInfoRow>> {
const [rows] = await pool.execute(VEHICLE_INFO_SQL) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>();
for (const row of rows) {
map.set(row.plate, row);
}
return map;
}
/** 查询指定车牌的关联信息 */
export async function fetchVehicleInfoByPlates(plates: string[]): Promise<Map<string, VehicleInfoRow>> {
if (plates.length === 0) return new Map();
const [rows] = await pool.execute(
`${VEHICLE_INFO_SQL} AND truck.plate_number IN (${plates.map(() => '?').join(',')})`,
plates
) as [VehicleInfoRow[], unknown];
const map = new Map<string, VehicleInfoRow>();
for (const row of rows) {
map.set(row.plate, row);
}
return map;
}