All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
476 lines
18 KiB
TypeScript
476 lines
18 KiB
TypeScript
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[] };
|
||
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] = 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[]),
|
||
]);
|
||
|
||
// 车辆关联信息 map
|
||
const infoMap = new Map<string, any>();
|
||
for (const row of infoRows) {
|
||
infoMap.set(row.plate, row);
|
||
}
|
||
|
||
// 去重:同一 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[];
|
||
|
||
monitoringCache = {
|
||
vehicles,
|
||
stats: { totalToday, totalAll, vehicleCount: vehicles.length },
|
||
filters: { departments, customers, plates, projects, entities, rentStatuses },
|
||
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, 2 * 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: [] }, 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 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[],
|
||
};
|
||
} 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 => v.department === dept);
|
||
if (customer) vehicles = vehicles.filter(v => 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 (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 — 某项目的车辆明细
|
||
app.get('/target/:id/vehicles', async (c) => {
|
||
const targetId = c.req.param('id');
|
||
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 result = rows.map((r: any) => ({
|
||
plateNumber: r.plate_number,
|
||
todayMileage: Number(r.today_mileage) || 0,
|
||
totalMileage: 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,
|
||
}));
|
||
|
||
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;
|