diff --git a/src/server/index.ts b/src/server/index.ts index b40eb5f..833adfd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,6 +4,7 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import dotenv from 'dotenv'; import vehiclesRouter from './routes/vehicles.js'; +import mileageRouter from './routes/mileage.js'; dotenv.config(); @@ -11,6 +12,7 @@ const app = new Hono(); app.use('/api/*', cors()); app.route('/api/vehicles', vehiclesRouter); +app.route('/api/mileage', mileageRouter); app.get('/api/health', (c) => c.json({ status: 'ok', time: new Date().toISOString() })); diff --git a/src/server/routes/mileage.ts b/src/server/routes/mileage.ts new file mode 100644 index 0000000..ef58383 --- /dev/null +++ b/src/server/routes/mileage.ts @@ -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(); + 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(); + 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(); + 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;