Files
ln-bi/src/server/routes/mileage.ts
kkfluous 8822ddf8ae
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: 车牌区域筛选、型号批次筛选、回到顶部修复、删除涨跌幅
- 新增车牌区域筛选(粤/沪/浙+数量),替代旧地区代码
- 新增型号批次筛选(从考核目标名称筛选车辆)
- 客户/部门增加"无值"选项筛选空值
- 修复回到顶部按钮在iOS上失效
- 删除KPI卡片涨跌幅百分比显示
- 全屏刷新按钮实际触发数据重新加载+加载动画
- 统计报表全屏刷新按钮修复

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:21:38 +08:00

553 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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: Number(s.today_total) || 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;