feat: 添加里程管理 API 路由(monitoring/targets/trend)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +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';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ const app = new Hono();
|
|||||||
|
|
||||||
app.use('/api/*', cors());
|
app.use('/api/*', cors());
|
||||||
app.route('/api/vehicles', vehiclesRouter);
|
app.route('/api/vehicles', vehiclesRouter);
|
||||||
|
app.route('/api/mileage', mileageRouter);
|
||||||
|
|
||||||
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() }));
|
||||||
|
|
||||||
|
|||||||
230
src/server/routes/mileage.ts
Normal file
230
src/server/routes/mileage.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
|
WHERE truck.is_deleted = 0 AND truck.is_operation = 1`;
|
||||||
|
|
||||||
|
// GET /monitoring — 实时监控数据
|
||||||
|
app.get('/monitoring', async (c) => {
|
||||||
|
try {
|
||||||
|
// 1. 从 hydrogen_energy 取最新日期的里程数据
|
||||||
|
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 c.json({ vehicles: [], updatedAt: new Date().toISOString() });
|
||||||
|
|
||||||
|
const [mileageRows] = await mileagePool.execute(
|
||||||
|
`SELECT plate, vin, daily_km, total_km, source
|
||||||
|
FROM v_vehicle_daily_stats
|
||||||
|
WHERE stat_date = ?`,
|
||||||
|
[latestDate]
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
// 对于同一 plate 可能有多条记录(不同 source),取 daily_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 从 lingniu_prod 取车辆关联信息
|
||||||
|
const [infoRows] = await pool.execute(VEHICLE_INFO_SQL) as any;
|
||||||
|
const infoMap = new Map<string, any>();
|
||||||
|
for (const row of infoRows) {
|
||||||
|
infoMap.set(row.plate, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 合并
|
||||||
|
const vehicles = 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ vehicles, updatedAt: new Date().toISOString() });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('monitoring error:', e);
|
||||||
|
return c.json({ vehicles: [], updatedAt: new Date().toISOString() }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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(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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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]
|
||||||
|
: '';
|
||||||
|
|
||||||
|
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,
|
||||||
|
period: `${startDate} ~ ${endDate}`,
|
||||||
|
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)
|
||||||
|
`;
|
||||||
|
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;
|
||||||
Reference in New Issue
Block a user