Files
ln-bi/src/server/routes/mileage/monitoring.ts
T

297 lines
11 KiB
TypeScript

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;