feat: integrate OneOS mileage APIs and release v1.1.10
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+129
-126
@@ -2,8 +2,8 @@ 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 { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js';
|
||||
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -64,6 +64,9 @@ interface MileageRow {
|
||||
daily_km: string;
|
||||
total_km: string | null;
|
||||
source: string;
|
||||
data_time: string | null;
|
||||
calculated_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface DailyMileageRow {
|
||||
@@ -72,6 +75,49 @@ interface DailyMileageRow {
|
||||
date: string;
|
||||
daily_km: string | number | null;
|
||||
source: string | null;
|
||||
data_time: string | null;
|
||||
calculated_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] {
|
||||
return rows.map(row => ({
|
||||
plate: row.plateNumber,
|
||||
vin: row.vin,
|
||||
daily_km: String(row.dailyMileageKm ?? 0),
|
||||
total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm),
|
||||
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
|
||||
data_time: row.dataTime,
|
||||
calculated_at: row.calculatedAt,
|
||||
updated_at: row.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
function previousDate(date: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
const value = new Date(Date.UTC(year, month - 1, day));
|
||||
value.setUTCDate(value.getUTCDate() - 1);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function shanghaiDate(): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
}
|
||||
|
||||
function dailyMileageMap(rows: OneOsDailyMileage[]): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (row.status !== 'NORMAL') continue;
|
||||
const km = Math.max(0, Number(row.dailyMileageKm) || 0);
|
||||
const existing = map.get(row.plateNumber) || 0;
|
||||
if (km > existing) map.set(row.plateNumber, km);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export interface RangeMileageResult {
|
||||
@@ -116,56 +162,10 @@ function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map<string, string[]
|
||||
return map;
|
||||
}
|
||||
|
||||
async function fetchBizTotalMileageMap(): Promise<Map<string, number>> {
|
||||
// v_vehicle_daily_stats.total_km 对 G7S 数据源常为 NULL(G7 只回传日增量),
|
||||
// 业务库 lingniu_prod.tab_mileage_assessment_vehicle.vehicle_total_mileage 是累加后的权威累计值,
|
||||
// 用它兜底保证 totalKm 汇总完整。
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT plate_number, vehicle_total_mileage FROM lingniu_prod.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>,
|
||||
targetNamesByPlate: Map<string, string[]>,
|
||||
): CachedVehicle[] {
|
||||
const mileageMap = new Map<string, MileageRow>();
|
||||
@@ -182,14 +182,17 @@ function mergeVehicles(
|
||||
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(plate);
|
||||
const bizTotal = bizTotalMap.get(plate);
|
||||
return {
|
||||
plate,
|
||||
vin: m?.vin || info.vin || '',
|
||||
dailyKm,
|
||||
totalKm: gpsTotal !== null ? gpsTotal : (latestPgTotal ?? bizTotal ?? null),
|
||||
// The OneOS daily mileage API does not currently return cumulative mileage.
|
||||
// Never backfill it from another mileage source outside the assessment page.
|
||||
totalKm: gpsTotal,
|
||||
source,
|
||||
dataTime: m?.data_time || null,
|
||||
calculatedAt: m?.calculated_at || null,
|
||||
updatedAt: m?.updated_at || null,
|
||||
isOnline: source !== 'NONE' && dailyKm > 0,
|
||||
isDataSynced: source !== 'NONE',
|
||||
customer: info.customer || null,
|
||||
@@ -212,44 +215,23 @@ export async function refreshMonitoringCache(): Promise<void> {
|
||||
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;
|
||||
})(),
|
||||
const date = shanghaiDate();
|
||||
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||
fetchOneOsDailyMileage(date),
|
||||
fetchOneOsDailyMileage(previousDate(date)),
|
||||
fetchVehicleInfoMap(),
|
||||
fetchTargetRows(),
|
||||
fetchBizTotalMileageMap(),
|
||||
fetchLatestPgTotalMileageMap(),
|
||||
]);
|
||||
|
||||
const mileageRows = toMileageRows(apiRows);
|
||||
const yesterdayMap = dailyMileageMap(yesterdayRows);
|
||||
|
||||
const targetPlatesMap = buildTargetPlatesMap(targetRows);
|
||||
const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
|
||||
const targetNames = Array.from(targetPlatesMap.keys());
|
||||
|
||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, bizTotalMap, latestPgTotalMap, targetNamesByPlate);
|
||||
const totalToday = vehicles.reduce((sum, v) => sum + v.dailyKm, 0);
|
||||
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, targetNamesByPlate);
|
||||
const totalToday = Math.round(vehicles.reduce((sum, v) => sum + v.dailyKm, 0));
|
||||
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
|
||||
|
||||
monitoringCache = {
|
||||
@@ -267,34 +249,20 @@ export async function refreshMonitoringCache(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
||||
const [mileageRows, yesterdayRows, infoMap, targetRows, 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 }[]),
|
||||
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||
fetchOneOsDailyMileage(dateStr),
|
||||
fetchOneOsDailyMileage(previousDate(dateStr)),
|
||||
fetchVehicleInfoMap(),
|
||||
fetchTargetRows(),
|
||||
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);
|
||||
}
|
||||
const mileageRows = toMileageRows(apiRows);
|
||||
const yesterdayMap = dailyMileageMap(yesterdayRows);
|
||||
|
||||
return mergeVehicles(
|
||||
mileageRows,
|
||||
infoMap,
|
||||
yesterdayMap,
|
||||
bizTotalMap,
|
||||
latestPgTotalMap,
|
||||
buildPlateTargetNamesMap(targetRows),
|
||||
);
|
||||
}
|
||||
@@ -315,31 +283,57 @@ function datesBetween(start: string, end: string): string[] {
|
||||
}
|
||||
|
||||
export async function queryRangeMileage(startDate: string, endDate: string): Promise<RangeMileageResult> {
|
||||
if (startDate === endDate) {
|
||||
const vehicles = (await queryDateMileage(startDate)).map(vehicle => ({
|
||||
...vehicle,
|
||||
dailyMileage: { [startDate]: vehicle.dailyKm },
|
||||
}));
|
||||
return {
|
||||
vehicles,
|
||||
dailyTotals: [{
|
||||
date: startDate,
|
||||
totalKm: Math.round(vehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)),
|
||||
}],
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
};
|
||||
}
|
||||
|
||||
const days = datesBetween(startDate, endDate);
|
||||
const [dailyRows, yesterdayRows, infoMap, targetRows, bizTotalMap, latestPgTotalMap] = await Promise.all([
|
||||
mileagePool.execute(
|
||||
`SELECT plate,
|
||||
DATE_FORMAT(stat_date, '%Y-%m-%d') AS date,
|
||||
vin,
|
||||
daily_km,
|
||||
source
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= ? AND stat_date <= ?
|
||||
ORDER BY stat_date, plate`,
|
||||
[startDate, endDate]
|
||||
).then(([r]) => r as DailyMileageRow[]),
|
||||
mileagePool.execute(
|
||||
'SELECT plate, daily_km FROM v_vehicle_daily_stats WHERE stat_date = DATE_SUB(?, INTERVAL 1 DAY)',
|
||||
[startDate]
|
||||
).then(([r]) => r as { plate: string; daily_km: string }[]),
|
||||
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||
fetchOneOsMileageDates(days),
|
||||
fetchOneOsDailyMileage(endDate),
|
||||
fetchOneOsDailyMileage(previousDate(startDate)),
|
||||
fetchVehicleInfoMap(),
|
||||
fetchTargetRows(),
|
||||
fetchBizTotalMileageMap(),
|
||||
fetchLatestPgTotalMileageMap(endDate),
|
||||
]);
|
||||
const dailyRows: DailyMileageRow[] = [];
|
||||
for (const [date, apiRows] of apiRowsByDate) {
|
||||
for (const row of apiRows) {
|
||||
dailyRows.push({
|
||||
plate: row.plateNumber,
|
||||
vin: row.vin,
|
||||
date,
|
||||
daily_km: row.dailyMileageKm,
|
||||
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
|
||||
data_time: row.dataTime,
|
||||
calculated_at: row.calculatedAt,
|
||||
updated_at: row.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const perVehicleDaily = new Map<string, Record<string, number>>();
|
||||
const perVehicleSum = new Map<string, { plate: string; vin: string; daily_km: string; total_km: null; source: string }>();
|
||||
const perVehicleSum = new Map<string, {
|
||||
plate: string;
|
||||
vin: string;
|
||||
daily_km: string;
|
||||
total_km: string | null;
|
||||
source: string;
|
||||
data_time: string | null;
|
||||
calculated_at: string | null;
|
||||
updated_at: string | null;
|
||||
}>();
|
||||
const dailyTotals = new Map<string, number>();
|
||||
const bestDailyRows = new Map<string, DailyMileageRow>();
|
||||
|
||||
@@ -371,22 +365,31 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
|
||||
daily_km: String((Number(existing?.daily_km) || 0) + km),
|
||||
total_km: null,
|
||||
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
|
||||
data_time: row.data_time || existing?.data_time || null,
|
||||
calculated_at: row.calculated_at || existing?.calculated_at || null,
|
||||
updated_at: row.updated_at || existing?.updated_at || null,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row]));
|
||||
for (const [plate, aggregate] of perVehicleSum) {
|
||||
const endDateRow = endDateMap.get(plate);
|
||||
if (!endDateRow) continue;
|
||||
perVehicleSum.set(plate, {
|
||||
...aggregate,
|
||||
vin: endDateRow.vin || aggregate.vin,
|
||||
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
|
||||
data_time: endDateRow.dataTime || aggregate.data_time,
|
||||
updated_at: endDateRow.updatedAt || aggregate.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
const yesterdayMap = dailyMileageMap(yesterdayRows);
|
||||
|
||||
const vehicles = mergeVehicles(
|
||||
Array.from(perVehicleSum.values()),
|
||||
infoMap,
|
||||
yesterdayMap,
|
||||
bizTotalMap,
|
||||
latestPgTotalMap,
|
||||
buildPlateTargetNamesMap(targetRows),
|
||||
).map(vehicle => {
|
||||
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
|
||||
@@ -397,7 +400,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
|
||||
|
||||
return {
|
||||
vehicles,
|
||||
dailyTotals: days.map(date => ({ date, totalKm: dailyTotals.get(date) || 0 })),
|
||||
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user