All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
视图 v_vehicle_daily_stats.total_km 仅当日有 TBOX 记录才有值, 否则原样为 NULL。新增 fetchLatestPgTotalMileageMap 在应用层按车牌 取最近一条非空 total_mileage(queryDateMileage 限定 dates <= 查询日, 保证历史日不取未来值),插入 gpsTotal → latestPgTotal → bizTotal 的 回退链,让 totalKm 显示连续。 MySQL 5.7 无窗口函数,用 INNER JOIN + GROUP BY MAX(dates) 取每车最近一条; 本地 dev 实测 1004 辆车 cache 刷新 ~16s,不再有 SQL 解析错。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
import pool from '../../db.js';
|
||
import mileagePool from '../../mileage-db.js';
|
||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const regionMap: Record<string, string> = JSON.parse(
|
||
readFileSync(join(__dirname, 'region-map.json'), 'utf8')
|
||
);
|
||
const REGION_ORDER = ['华东区域', '华南区域', '西南区域', '西北区域', '华北区域', '华中区域', '东北区域'];
|
||
|
||
let monitoringCache: MonitoringCache | null = null;
|
||
|
||
export function getCache(): MonitoringCache | null {
|
||
return monitoringCache;
|
||
}
|
||
|
||
const DEPT_ORDER = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||
|
||
function sortDepartments(departments: string[]): string[] {
|
||
return departments.sort((a, b) => {
|
||
const ai = DEPT_ORDER.findIndex(d => a.includes(d));
|
||
const bi = DEPT_ORDER.findIndex(d => b.includes(d));
|
||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||
});
|
||
}
|
||
|
||
function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): MonitoringFilters {
|
||
const departments = sortDepartments(
|
||
Array.from(new Set(vehicles.map(v => v.department).filter((d): d is string => d !== null)))
|
||
);
|
||
const customers = Array.from(new Set(vehicles.map(v => v.customer).filter((c): c is string => c !== null)));
|
||
const plates = vehicles.map(v => v.plate);
|
||
const projects = Array.from(new Set(vehicles.map(v => v.project).filter((p): p is string => p !== null)));
|
||
const entities = Array.from(new Set(vehicles.map(v => v.entity).filter((e): e is string => e !== null)));
|
||
const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter((r): r is string => r !== null)));
|
||
|
||
const prefixCount = new Map<string, number>();
|
||
for (const v of vehicles) {
|
||
const p = v.plate.charAt(0);
|
||
prefixCount.set(p, (prefixCount.get(p) || 0) + 1);
|
||
}
|
||
const platePrefixes: PlatePrefix[] = Array.from(prefixCount.entries())
|
||
.map(([prefix, count]) => ({ prefix, count }))
|
||
.sort((a, b) => b.count - a.count);
|
||
|
||
const regionSet = new Set(vehicles.map(v => v.region).filter((r): r is string => r !== null));
|
||
const regions = Array.from(regionSet).sort((a, b) => {
|
||
const ai = REGION_ORDER.indexOf(a);
|
||
const bi = REGION_ORDER.indexOf(b);
|
||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||
});
|
||
|
||
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions };
|
||
}
|
||
|
||
interface MileageRow {
|
||
plate: string;
|
||
vin: string;
|
||
daily_km: string;
|
||
total_km: string | null;
|
||
source: string;
|
||
}
|
||
|
||
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
|
||
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULL(G7 只回传日增量),
|
||
// 业务库 tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
||
// 用它兜底保证 totalKm 汇总完整。
|
||
const [rows] = await pool.execute(
|
||
'SELECT plate_number, vehicle_total_mileage FROM tab_mileage_assessment_vehicle WHERE is_deleted = 0'
|
||
) as [{ plate_number: string; vehicle_total_mileage: string | number | null }[], unknown];
|
||
const map = new Map<string, number>();
|
||
for (const r of rows) {
|
||
const km = Number(r.vehicle_total_mileage);
|
||
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
async function fetchLatestPgTotalMileageMap(asOf?: string): Promise<Map<string, number>> {
|
||
// 当日 ln_vehicle_day_total_pg 无记录或 total_mileage 为 NULL 时,
|
||
// 回填该车 dates <= asOf 的最近一条非空 total_mileage(÷1000 转 km),
|
||
// 让视图 total_km 为 NULL 的车也能显示历史累计。
|
||
// MySQL 5.7 无窗口函数,用 GROUP BY MAX(dates) + JOIN 取每车最近一条。
|
||
const sql = `
|
||
SELECT t.plate_number, t.total_mileage
|
||
FROM ln_vehicle_day_total_pg t
|
||
INNER JOIN (
|
||
SELECT plate_number, MAX(dates) AS max_dates
|
||
FROM ln_vehicle_day_total_pg
|
||
WHERE total_mileage IS NOT NULL
|
||
${asOf ? 'AND dates <= ?' : ''}
|
||
GROUP BY plate_number
|
||
) m ON m.plate_number = t.plate_number AND m.max_dates = t.dates
|
||
WHERE t.total_mileage IS NOT NULL`;
|
||
const params = asOf ? [asOf] : [];
|
||
const [rows] = await mileagePool.execute(sql, params) as [
|
||
{ plate_number: string; total_mileage: string | number | null }[],
|
||
unknown,
|
||
];
|
||
const map = new Map<string, number>();
|
||
for (const r of rows) {
|
||
const km = Number(r.total_mileage) / 1000;
|
||
if (Number.isFinite(km) && km > 0) map.set(r.plate_number, km);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function mergeVehicles(
|
||
mileageRows: MileageRow[],
|
||
infoMap: Map<string, VehicleInfoRow>,
|
||
yesterdayMap: Map<string, number>,
|
||
bizTotalMap: Map<string, number>,
|
||
latestPgTotalMap: Map<string, number>,
|
||
): CachedVehicle[] {
|
||
const mileageMap = new Map<string, MileageRow>();
|
||
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);
|
||
}
|
||
}
|
||
|
||
return Array.from(mileageMap.values()).map(m => {
|
||
const info = infoMap.get(m.plate);
|
||
const dailyKm = Number(m.daily_km) || 0;
|
||
const source = m.source || 'NONE';
|
||
const gpsTotal = m.total_km !== null ? Number(m.total_km) : null;
|
||
const latestPgTotal = latestPgTotalMap.get(m.plate);
|
||
const bizTotal = bizTotalMap.get(m.plate);
|
||
return {
|
||
plate: m.plate,
|
||
vin: m.vin,
|
||
dailyKm,
|
||
totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null),
|
||
source,
|
||
isOnline: source !== 'NONE' && dailyKm > 0,
|
||
isDataSynced: source !== 'NONE',
|
||
customer: info?.customer || null,
|
||
department: info?.department || null,
|
||
manager: info?.manager || null,
|
||
managerId: info?.manager_id || null,
|
||
rentStatus: info?.rent_status || null,
|
||
entity: info?.entity || null,
|
||
project: info?.project || null,
|
||
region: regionMap[m.plate] || null,
|
||
yesterdayKm: yesterdayMap.get(m.plate) || 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
export async function refreshMonitoringCache(): Promise<void> {
|
||
try {
|
||
console.log('[mileage] refreshing monitoring cache...');
|
||
const start = Date.now();
|
||
|
||
const [mileageRows, yesterdayMap, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||
(async () => {
|
||
const [dateRows] = await mileagePool.execute(
|
||
'SELECT MAX(stat_date) as latest FROM v_vehicle_daily_stats'
|
||
) as [{ latest: string | null }[], unknown];
|
||
const latestDate = dateRows[0]?.latest;
|
||
if (!latestDate) return [];
|
||
const [rows] = await mileagePool.execute(
|
||
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
||
[latestDate]
|
||
) as [MileageRow[], unknown];
|
||
return rows;
|
||
})(),
|
||
(async () => {
|
||
const [rows] = await mileagePool.execute(
|
||
`SELECT plate, daily_km FROM v_vehicle_daily_stats
|
||
WHERE stat_date = DATE_SUB((SELECT MAX(stat_date) FROM v_vehicle_daily_stats), INTERVAL 1 DAY)`
|
||
) as [{ plate: string; daily_km: string }[], unknown];
|
||
const map = new Map<string, number>();
|
||
for (const r of rows) {
|
||
const km = Number(r.daily_km) || 0;
|
||
const existing = map.get(r.plate) || 0;
|
||
if (km > existing) map.set(r.plate, km);
|
||
}
|
||
return map;
|
||
})(),
|
||
fetchVehicleInfoMap(),
|
||
pool.execute(
|
||
`SELECT t.id, t.target_name, v.plate_number
|
||
FROM tab_mileage_assessment_target t
|
||
JOIN tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0
|
||
WHERE t.is_deleted = 0`
|
||
).then(([rows]) => rows as { id: number; target_name: string; plate_number: string }[]),
|
||
fetchBizTotalMileageMap(),
|
||
fetchLatestPgTotalMileageMap(),
|
||
]);
|
||
|
||
const targetPlatesMap = new Map<string, Set<string>>();
|
||
for (const r of targetRows) {
|
||
const set = targetPlatesMap.get(r.target_name) || new Set();
|
||
set.add(r.plate_number);
|
||
targetPlatesMap.set(r.target_name, set);
|
||
}
|
||
const targetNames = Array.from(targetPlatesMap.keys());
|
||
|
||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
|
||
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
||
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
||
|
||
monitoringCache = {
|
||
vehicles,
|
||
stats: { totalToday, totalAll, vehicleCount: vehicles.length },
|
||
filters: buildFilters(vehicles, targetNames),
|
||
targetPlatesMap,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
console.log(`[mileage] cache refreshed: ${vehicles.length} vehicles in ${Date.now() - start}ms`);
|
||
} catch (e: unknown) {
|
||
console.error('[mileage] cache refresh error:', e);
|
||
}
|
||
}
|
||
|
||
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
||
const [mileageRows, yesterdayRows, infoMap, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||
mileagePool.execute(
|
||
'SELECT plate, vin, daily_km, total_km, source FROM v_vehicle_daily_stats WHERE stat_date = ?',
|
||
[dateStr]
|
||
).then(([r]) => r as MileageRow[]),
|
||
mileagePool.execute(
|
||
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
|
||
[dateStr]
|
||
).then(([r]) => r as { plate: string; daily_km: string }[]),
|
||
fetchVehicleInfoMap(),
|
||
fetchBizTotalMileageMap(),
|
||
fetchLatestPgTotalMileageMap(dateStr),
|
||
]);
|
||
|
||
const yesterdayMap = new Map<string, number>();
|
||
for (const r of yesterdayRows) {
|
||
const km = Number(r.daily_km) || 0;
|
||
const existing = yesterdayMap.get(r.plate) || 0;
|
||
if (km > existing) yesterdayMap.set(r.plate, km);
|
||
}
|
||
|
||
return mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap);
|
||
}
|
||
|
||
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
||
return buildFilters(vehicles, monitoringCache?.filters.targetNames || []);
|
||
}
|