297 lines
11 KiB
TypeScript
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;
|