import { Hono } from 'hono'; import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from './cache.js'; import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js'; import type { AuthUser } from '../../auth/types.js'; import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js'; import { DEFAULT_MILEAGE_SOURCE_PRIORITY, parseMileageSourcePriority, protocolsForSourcePriority, } from './source-policy.js'; import { isMonitoringCacheEligible, isMonitoringSnapshotScope, shanghaiDate, } from './monitoring-cache-policy.js'; import { getManualMonitoringSnapshot, storeManualMonitoringSnapshot, } from './manual-snapshot.js'; import { sumMileageKm } from './precision.js'; const app = new Hono(); const EMPTY_RESPONSE: MonitoringResponse = { vehicles: [], stats: { totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }, filters: { departments: [], customers: [], plates: [], projects: [], entities: [], rentStatuses: [], platePrefixes: [], targetNames: [], regions: [], brands: [] }, total: 0, page: 1, totalPages: 1, updatedAt: new Date().toISOString(), sourcePriority: [...DEFAULT_MILEAGE_SOURCE_PRIORITY], }; function applyFilters(vehicles: CachedVehicle[], params: { search: string; dept: string; customer: string; project: string; entity: string; rentStatus: string; plate: string; platePrefix: string; targetNames: string[]; region: string; mileageMin: string; mileageMax: string; brands: string[]; }): CachedVehicle[] { let result = vehicles; if (params.search) { const q = params.search.toLowerCase(); result = result.filter(v => v.plate.toLowerCase().includes(q) || v.vin.toLowerCase().includes(q) || (v.customer || '').toLowerCase().includes(q) || (v.project || '').toLowerCase().includes(q) ); } if (params.dept) result = result.filter(v => params.dept === '__EMPTY__' ? !v.department : v.department === params.dept); if (params.customer) result = result.filter(v => params.customer === '__EMPTY__' ? !v.customer : (v.customer || '').toLowerCase().includes(params.customer.toLowerCase())); if (params.project) result = result.filter(v => v.project === params.project); if (params.entity) result = result.filter(v => v.entity === params.entity); if (params.rentStatus) result = result.filter(v => v.rentStatus === params.rentStatus); if (params.plate) { const wanted = new Set(params.plate.split(',').map(s => s.trim()).filter(Boolean)); if (wanted.size > 0) result = result.filter(v => wanted.has(v.plate)); } if (params.platePrefix) result = result.filter(v => v.plate.startsWith(params.platePrefix)); if (params.region) result = result.filter(v => v.region === params.region); if (params.targetNames.length > 0) { const selectedTargets = new Set(params.targetNames); result = result.filter(v => v.targetNames.some(targetName => selectedTargets.has(targetName))); } if (params.brands.length > 0) { const selectedBrands = new Set(params.brands); result = result.filter(v => v.brand != null && selectedBrands.has(v.brand)); } if (params.mileageMin) result = result.filter(v => v.dailyKm >= Number(params.mileageMin)); if (params.mileageMax) result = result.filter(v => v.dailyKm <= Number(params.mileageMax)); return result; } function parseTargetNames(reqUrl: string): string[] { const params = new URL(reqUrl).searchParams; const raw = [ ...params.getAll('targetName'), ...params.getAll('targetNames'), ]; const names = raw.flatMap(item => item.split(',')) .map(item => item.trim()) .filter(Boolean); return Array.from(new Set(names)); } function parseYmd(value: string): Date | null { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) return null; const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); date.setHours(0, 0, 0, 0); return Number.isFinite(date.getTime()) ? date : null; } function fmtYmd(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } function normalizeRange(startQuery: string, endQuery: string): { start: string; end: string } | null { if (!startQuery && !endQuery) return null; const start = parseYmd(startQuery || endQuery); const end = parseYmd(endQuery || startQuery); if (!start || !end) return null; const a = start <= end ? start : end; let b = start <= end ? end : start; const span = Math.round((b.getTime() - a.getTime()) / 86400000) + 1; if (span > 366) { b = new Date(a); b.setDate(a.getDate() + 365); } return { start: fmtYmd(a), end: fmtYmd(b) }; } app.get('/', async (c) => { const requestedSortBy = c.req.query('sortBy'); const sortBy = requestedSortBy === 'total' || requestedSortBy === 'statisticTime' ? requestedSortBy : 'today'; const sortOrder = c.req.query('sortOrder') === 'asc' ? 'asc' : 'desc'; const limit = Number(c.req.query('limit')) || 50; const page = Number(c.req.query('page')) || 1; const date = c.req.query('date') || ''; const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || ''); const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority')); const protocolPriority = protocolsForSourcePriority(sourcePriority); const force = c.req.query('force') === '1'; const filterParams = { search: c.req.query('search') || '', dept: c.req.query('dept') || '', customer: c.req.query('customer') || '', project: c.req.query('project') || '', entity: c.req.query('entity') || '', rentStatus: c.req.query('rentStatus') || '', plate: c.req.query('plate') || '', platePrefix: c.req.query('platePrefix') || '', targetNames: parseTargetNames(c.req.url), region: c.req.query('region') || '', mileageMin: c.req.query('mileageMin') || '', mileageMax: c.req.query('mileageMax') || '', brands: c.req.queries('brand') || [], }; let allVehicles: CachedVehicle[]; let filters: MonitoringFilters; let rangeDailyTotals: { date: string; totalKm: number }[] | undefined; let dateRange: { start: string; end: string } | undefined; let dataUpdatedAt: string | undefined; let cacheStatus: 'hit' | 'manual-hit' | 'refresh' | 'miss' | 'bypass' = 'bypass'; if (range) { const cache = getCache(); const today = shanghaiDate(); const snapshotScope = isMonitoringSnapshotScope({ startDate: range.start, endDate: range.end, today, sourcePriority, }); const cacheEligible = isMonitoringCacheEligible({ startDate: range.start, endDate: range.end, today, sourcePriority, force, }); const manualSnapshot = cacheEligible ? getManualMonitoringSnapshot(range.start, range.end) : null; if (manualSnapshot) { cacheStatus = 'manual-hit'; allVehicles = manualSnapshot.result.vehicles; rangeDailyTotals = manualSnapshot.result.dailyTotals; dateRange = { start: manualSnapshot.result.start, end: manualSnapshot.result.end }; dataUpdatedAt = manualSnapshot.updatedAt; filters = buildDateFilters(allVehicles); } else if (cacheEligible && cache) { cacheStatus = 'hit'; allVehicles = cache.vehicles.map(vehicle => ({ ...vehicle, dailyMileage: { [range.start]: vehicle.dailyKm }, dailySourceProtocols: { [range.start]: vehicle.sourceProtocol }, })); rangeDailyTotals = [{ date: range.start, totalKm: Math.round(allVehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)), }]; dateRange = { ...range }; dataUpdatedAt = cache.updatedAt; filters = cache.filters; } else { cacheStatus = cacheEligible ? 'miss' : 'bypass'; try { const result = await queryRangeMileage(range.start, range.end, protocolPriority); allVehicles = result.vehicles; rangeDailyTotals = result.dailyTotals; dateRange = { start: result.start, end: result.end }; filters = buildDateFilters(allVehicles); if (force && snapshotScope) { const stored = storeManualMonitoringSnapshot(result); cacheStatus = 'refresh'; dataUpdatedAt = stored.updatedAt; } } catch (e: unknown) { console.error('monitoring range query error:', e); return c.json(EMPTY_RESPONSE, 500); } } } else if (date) { try { allVehicles = await queryDateMileage(date, protocolPriority); filters = buildDateFilters(allVehicles); } catch (e: unknown) { console.error('monitoring date query error:', e); return c.json(EMPTY_RESPONSE, 500); } } else { const cache = getCache(); if (!cache) return c.json(EMPTY_RESPONSE); allVehicles = cache.vehicles; filters = cache.filters; } // 权限过滤 const user = (c as any).get('user') as AuthUser | undefined; if (user) { allVehicles = filterByPermission(allVehicles, user); filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围 } // 区域级联:选中运营区域时,下游筛选选项(车牌等)只展示该区域车辆 if (filterParams.region) { const regionScope = allVehicles.filter(v => v.region === filterParams.region); filters = buildDateFilters(regionScope); } const filtered = applyFilters(allVehicles, filterParams); if (rangeDailyTotals && filtered.length !== allVehicles.length) { rangeDailyTotals = rangeDailyTotals.map(item => ({ ...item, totalKm: Math.round(filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0)), })); } const stats = { totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)), totalAll: sumMileageKm(filtered.map(v => v.totalKm || 0)), vehicleCount: filtered.length, yesterdayTotal: sumMileageKm(filtered.map(v => v.yesterdayKm)), }; const sorted = [...filtered].sort((a, b) => { if (sortBy === 'statisticTime') { const rawTimeA = a.dataTime || a.updatedAt || a.calculatedAt; const rawTimeB = b.dataTime || b.updatedAt || b.calculatedAt; const parsedTimeA = rawTimeA ? Date.parse(rawTimeA) : Number.NaN; const parsedTimeB = rawTimeB ? Date.parse(rawTimeB) : Number.NaN; const timeA = Number.isFinite(parsedTimeA) ? parsedTimeA : null; const timeB = Number.isFinite(parsedTimeB) ? parsedTimeB : null; // Missing/invalid statistic times always stay at the end for both directions. if (timeA == null && timeB == null) return a.plate.localeCompare(b.plate, 'zh-CN'); if (timeA == null) return 1; if (timeB == null) return -1; const timeDiff = sortOrder === 'desc' ? timeB - timeA : timeA - timeB; return timeDiff || a.plate.localeCompare(b.plate, 'zh-CN'); } const valA = sortBy === 'today' ? a.dailyKm : (a.totalKm || 0); const valB = sortBy === 'today' ? b.dailyKm : (b.totalKm || 0); const mileageDiff = sortOrder === 'desc' ? valB - valA : valA - valB; return mileageDiff || a.plate.localeCompare(b.plate, 'zh-CN'); }); const offset = (page - 1) * limit; const paged = sorted.slice(offset, offset + limit); const total = filtered.length; c.header('X-Mileage-Cache', cacheStatus); return c.json({ vehicles: maskCustomerNames(paged), stats, filters, rangeDailyTotals, dateRange, total, page, totalPages: Math.ceil(total / limit), updatedAt: dataUpdatedAt || dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(), sourcePriority, }); }); export default app;