import { Hono } from 'hono'; import pool from '../../db.js'; import { getCache } from './cache.js'; import { fetchOneOsDailyMileage } from './oneos-api.js'; import { fetchVehicleInfoByPlates } from './vehicle-info.js'; import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js'; const app = new Hono(); app.get('/', async (c) => { try { const [targets] = await pool.execute( 'SELECT * FROM lingniu_prod.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 lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0 GROUP BY target_id `) as [any[], unknown]; const statsMap = new Map(); for (const s of vehicleStats) statsMap.set(s.target_id, s); const [firstYearRows] = await pool.execute(` SELECT v.target_id, COUNT(*) as first_year_total, SUM(t.annual_mileage_per_vehicle) as first_year_target, SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) as first_year_completed, SUM(GREATEST(t.annual_mileage_per_vehicle - v.current_mileage, 0)) as first_year_remaining, SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle)) / NULLIF(SUM(t.annual_mileage_per_vehicle), 0) as first_year_completion_rate, SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle THEN 1 ELSE 0 END) as first_year_qualified_count, SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * 0.5 THEN 1 ELSE 0 END) as first_year_half_qualified_count, DATE_FORMAT(MIN(v.assessment_start_date), '%Y-%m-%d') as first_year_start_date, DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL 1 YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as first_year_end_date FROM lingniu_prod.tab_mileage_assessment_vehicle v JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0 WHERE v.is_deleted = 0 GROUP BY v.target_id `) as [any[], unknown]; const firstYearMap = new Map(); for (const s of firstYearRows) firstYearMap.set(s.target_id, s); const [yearlyRows] = await pool.execute(` SELECT v.target_id, y.year_number, COUNT(*) as vehicle_count, SUM(t.annual_mileage_per_vehicle * y.year_number) as target_mileage, SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) as completed_mileage, SUM(GREATEST(t.annual_mileage_per_vehicle * y.year_number - v.current_mileage, 0)) as remaining_mileage, SUM(LEAST(v.current_mileage, t.annual_mileage_per_vehicle * y.year_number)) / NULLIF(SUM(t.annual_mileage_per_vehicle * y.year_number), 0) as completion_rate, SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number THEN 1 ELSE 0 END) as qualified_count, SUM(CASE WHEN v.current_mileage >= t.annual_mileage_per_vehicle * y.year_number * 0.5 THEN 1 ELSE 0 END) as half_qualified_count, DATE_FORMAT(MIN(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR)), '%Y-%m-%d') as start_date, DATE_FORMAT(MAX(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY)), '%Y-%m-%d') as end_date FROM lingniu_prod.tab_mileage_assessment_vehicle v JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0 JOIN ( SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 ) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number) WHERE v.is_deleted = 0 GROUP BY v.target_id, y.year_number ORDER BY v.target_id, y.year_number `) as [any[], unknown]; const yearlyMap = new Map(); for (const row of yearlyRows) { const list = yearlyMap.get(row.target_id) || []; list.push(row); yearlyMap.set(row.target_id, list); } const [yearlyPeriodRows] = await pool.execute(` SELECT v.target_id, y.year_number, DATE_FORMAT(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number - 1 YEAR), '%Y-%m-%d') as start_date, DATE_FORMAT(DATE_SUB(DATE_ADD(v.assessment_start_date, INTERVAL y.year_number YEAR), INTERVAL 1 DAY), '%Y-%m-%d') as end_date, COUNT(*) as cnt FROM lingniu_prod.tab_mileage_assessment_vehicle v JOIN lingniu_prod.tab_mileage_assessment_target t ON t.id = v.target_id AND t.is_deleted = 0 JOIN ( SELECT 1 as year_number UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 ) y ON y.year_number <= LEAST(t.assessment_years, v.current_year_number) WHERE v.is_deleted = 0 GROUP BY v.target_id, y.year_number, v.assessment_start_date ORDER BY v.target_id, y.year_number, v.assessment_start_date `) as [any[], unknown]; const yearlyPeriodsMap = new Map(); for (const row of yearlyPeriodRows) { const key = `${row.target_id}-${row.year_number}`; const list = yearlyPeriodsMap.get(key) || []; list.push(`${row.start_date} ~ ${row.end_date} (${row.cnt}台)`); yearlyPeriodsMap.set(key, list); } 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 lingniu_prod.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(); 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(); 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 lingniu_prod.tab_mileage_assessment_vehicle WHERE is_deleted = 0' ) as [{ target_id: number; plate_number: string }[], unknown]; const targetIdPlatesMap = new Map(); 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 fy = firstYearMap.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 firstYearEnd = fy.first_year_end_date ? new Date(fy.first_year_end_date) : now; const firstYearDaysLeft = Math.max(0, Math.ceil((firstYearEnd.getTime() - now.getTime()) / 86400000)); const firstYearRemaining = Number(fy.first_year_remaining) || 0; const firstYearVehicleCount = Number(fy.first_year_total) || 0; const firstYearQualifiedCount = Number(fy.first_year_qualified_count) || 0; const yearlyAssessments = (yearlyMap.get(t.id) || []).map((row: any) => { const vehicleCount = Number(row.vehicle_count) || 0; const qualifiedCount = Number(row.qualified_count) || 0; const remainingMileage = Number(row.remaining_mileage) || 0; const endDate = row.end_date ? new Date(row.end_date) : now; const assessmentDaysLeft = Math.max(0, Math.ceil((endDate.getTime() - now.getTime()) / 86400000)); const yearNumber = Number(row.year_number) || 0; return { yearNumber, label: `第${yearNumber}年`, vehicleCount, target: Number(row.target_mileage) || 0, completed: Number(row.completed_mileage) || 0, remaining: remainingMileage, completionRate: (Number(row.completion_rate) || 0) * 100, qualifiedCount, qualifiedRate: vehicleCount > 0 ? (qualifiedCount / vehicleCount) * 100 : 0, halfQualifiedCount: Number(row.half_qualified_count) || 0, daysLeft: assessmentDaysLeft, dailyTarget: assessmentDaysLeft > 0 ? Math.round((remainingMileage / assessmentDaysLeft) * 10) / 10 : 0, startDate: row.start_date || null, endDate: row.end_date || null, periods: yearlyPeriodsMap.get(`${row.target_id}-${row.year_number}`) || [], }; }); 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: Math.round((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, firstYearVehicleCount, firstYearTarget: Number(fy.first_year_target) || 0, firstYearCompleted: Number(fy.first_year_completed) || 0, firstYearRemaining, firstYearCompletionRate: (Number(fy.first_year_completion_rate) || 0) * 100, firstYearQualifiedCount, firstYearQualifiedRate: firstYearVehicleCount > 0 ? (firstYearQualifiedCount / firstYearVehicleCount) * 100 : 0, firstYearHalfQualifiedCount: Number(fy.first_year_half_qualified_count) || 0, firstYearDaysLeft, firstYearDailyTarget: firstYearDaysLeft > 0 ? Math.round((firstYearRemaining / firstYearDaysLeft) * 10) / 10 : 0, firstYearStartDate: fy.first_year_start_date || null, firstYearEndDate: fy.first_year_end_date || null, yearlyAssessments, }; }); 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 lingniu_prod.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(); if (date && plates.length > 0) { const mileageRows = await fetchOneOsDailyMileage(date, plates); for (const m of mileageRows) { const existing = dateMileageMap.get(m.plateNumber); const dailyKm = m.status === 'NORMAL' ? (Number(m.dailyMileageKm) || 0) : 0; if (!existing || dailyKm > existing.dailyKm) { dateMileageMap.set(m.plateNumber, { dailyKm, totalKm: m.totalMileageKm, isOnline: m.status === 'NORMAL' && 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, }; }); const user = (c as any).get('user') as import('../../auth/types.js').AuthUser | undefined; const filtered = user ? filterByPermission(result, user) : result; return c.json(maskCustomerNames(filtered)); } catch (e: unknown) { console.error('target vehicles error:', e); return c.json([], 500); } }); export default app;