fix(mileage): skip missing daily mileage in interval totals
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -4,11 +4,8 @@ import { dirname, join } from 'node:path';
|
||||
import pool from '../../db.js';
|
||||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||||
import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js';
|
||||
import {
|
||||
sourceCategoryFromProtocol,
|
||||
type MileageSourceCategory,
|
||||
type OneOsProtocol,
|
||||
} from './source-policy.js';
|
||||
import type { OneOsProtocol } from './source-policy.js';
|
||||
import { buildRangeMileage, type RangeMileageResult } from './range-model.js';
|
||||
import {
|
||||
buildMonitoringFilters,
|
||||
buildPlateTargetNamesMap,
|
||||
@@ -18,8 +15,6 @@ import {
|
||||
mileageDatesBetween,
|
||||
previousMileageDate,
|
||||
toMileageRows,
|
||||
type DailyMileageRow,
|
||||
type MileageRow,
|
||||
type TargetRow,
|
||||
} from './cache-model.js';
|
||||
import type { CachedVehicle, MonitoringCache, MonitoringFilters } from './types.js';
|
||||
@@ -43,13 +38,6 @@ function shanghaiDate(): string {
|
||||
}).format(new Date());
|
||||
}
|
||||
|
||||
export interface RangeMileageResult {
|
||||
vehicles: CachedVehicle[];
|
||||
dailyTotals: { date: string; totalKm: number }[];
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
async function fetchTargetRows(): Promise<TargetRow[]> {
|
||||
return pool.execute(
|
||||
`SELECT t.id, t.target_name, v.plate_number
|
||||
@@ -131,23 +119,6 @@ export async function queryRangeMileage(
|
||||
endDate: string,
|
||||
protocolPriority?: OneOsProtocol[],
|
||||
): Promise<RangeMileageResult> {
|
||||
if (startDate === endDate) {
|
||||
const vehicles = (await queryDateMileage(startDate, protocolPriority)).map(vehicle => ({
|
||||
...vehicle,
|
||||
dailyMileage: { [startDate]: vehicle.dailyKm },
|
||||
dailySourceProtocols: { [startDate]: vehicle.sourceProtocol },
|
||||
}));
|
||||
return {
|
||||
vehicles,
|
||||
dailyTotals: [{
|
||||
date: startDate,
|
||||
totalKm: Math.round(vehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)),
|
||||
}],
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
};
|
||||
}
|
||||
|
||||
const days = mileageDatesBetween(startDate, endDate);
|
||||
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||
fetchOneOsMileageDates(days, undefined, protocolPriority),
|
||||
@@ -156,122 +127,7 @@ export async function queryRangeMileage(
|
||||
fetchVehicleInfoMap(),
|
||||
fetchTargetRows(),
|
||||
]);
|
||||
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 !== 'NO_DATA' ? 'ONEOS_API' : 'NONE',
|
||||
mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null,
|
||||
source_protocol: row.sourceProtocol,
|
||||
data_time: row.dataTime,
|
||||
calculated_at: row.calculatedAt,
|
||||
updated_at: row.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const perVehicleDaily = new Map<string, Record<string, number>>();
|
||||
const perVehicleDailySources = new Map<string, Record<string, OneOsProtocol | null>>();
|
||||
const perVehicleSourceCategories = new Map<string, Set<MileageSourceCategory>>();
|
||||
const perVehicleSum = new Map<string, MileageRow>();
|
||||
const dailyTotals = new Map<string, number>();
|
||||
const bestDailyRows = new Map<string, DailyMileageRow>();
|
||||
|
||||
for (const day of days) dailyTotals.set(day, 0);
|
||||
|
||||
for (const row of dailyRows) {
|
||||
const key = `${row.plate}\u0000${row.date}`;
|
||||
const km = Math.max(0, Number(row.daily_km) || 0);
|
||||
const existing = bestDailyRows.get(key);
|
||||
if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) {
|
||||
bestDailyRows.set(key, row);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of bestDailyRows.values()) {
|
||||
const km = Math.max(0, Number(row.daily_km) || 0);
|
||||
const date = row.date;
|
||||
const plate = row.plate;
|
||||
dailyTotals.set(date, (dailyTotals.get(date) || 0) + km);
|
||||
|
||||
const daily = perVehicleDaily.get(plate) || {};
|
||||
daily[date] = km;
|
||||
perVehicleDaily.set(plate, daily);
|
||||
const dailySources = perVehicleDailySources.get(plate) || {};
|
||||
dailySources[date] = row.source_protocol;
|
||||
perVehicleDailySources.set(plate, dailySources);
|
||||
const category = sourceCategoryFromProtocol(row.source_protocol);
|
||||
if (category) {
|
||||
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
|
||||
categories.add(category);
|
||||
perVehicleSourceCategories.set(plate, categories);
|
||||
}
|
||||
|
||||
const existing = perVehicleSum.get(plate);
|
||||
perVehicleSum.set(plate, {
|
||||
plate,
|
||||
vin: existing?.vin || row.vin || '',
|
||||
daily_km: String((Number(existing?.daily_km) || 0) + km),
|
||||
total_km: null,
|
||||
mileage_anomaly: existing?.mileage_anomaly || row.mileage_anomaly || null,
|
||||
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
|
||||
source_protocol: row.source_protocol || existing?.source_protocol || null,
|
||||
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 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),
|
||||
source_protocol: endDateRow.sourceProtocol || aggregate.source_protocol,
|
||||
data_time: endDateRow.dataTime || aggregate.data_time,
|
||||
updated_at: endDateRow.updatedAt || aggregate.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
const yesterdayMap = dailyMileageMap(yesterdayRows);
|
||||
|
||||
const vehicles = mergeMonitoringVehicles(
|
||||
Array.from(perVehicleSum.values()),
|
||||
infoMap,
|
||||
yesterdayMap,
|
||||
buildPlateTargetNamesMap(targetRows),
|
||||
regionMap,
|
||||
).map(vehicle => {
|
||||
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
|
||||
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
|
||||
const categories = perVehicleSourceCategories.get(vehicle.plate);
|
||||
const completedDailyMileage: Record<string, number> = {};
|
||||
const completedDailySources: Record<string, OneOsProtocol | null> = {};
|
||||
for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0;
|
||||
for (const day of days) completedDailySources[day] = dailySources[day] || null;
|
||||
return {
|
||||
...vehicle,
|
||||
dailyMileage: completedDailyMileage,
|
||||
dailySourceProtocols: completedDailySources,
|
||||
sourceCategory: categories && categories.size > 1
|
||||
? 'MIXED' as const
|
||||
: categories?.values().next().value || vehicle.sourceCategory,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
vehicles,
|
||||
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
};
|
||||
return buildRangeMileage(startDate, endDate, apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows, regionMap);
|
||||
}
|
||||
|
||||
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface OneOsDailyMileage {
|
||||
sourceProtocol: OneOsProtocol | null;
|
||||
}
|
||||
|
||||
/** Missing or unusable daily mileage must not contribute to interval statistics. */
|
||||
export function validDailyMileage(row: OneOsDailyMileage | undefined): number | null {
|
||||
const km = row?.dailyMileageKm;
|
||||
return row?.status === 'NORMAL' && km != null && Number.isFinite(km) && km >= 0 ? km : null;
|
||||
}
|
||||
|
||||
const MILEAGE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export function assertMileageDate(date: string): void {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildRangeMileage } from './range-model.js';
|
||||
import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js';
|
||||
import type { VehicleInfoRow } from './types.js';
|
||||
|
||||
const info: VehicleInfoRow = {
|
||||
plate: '粤A06619F', vin: 'test', customer: null, department: null, manager: null,
|
||||
manager_id: null, rent_status: null, entity: null, project: null,
|
||||
inventory_location: null, brand_label: null,
|
||||
};
|
||||
const row = (date: string, km: number | null, status: OneOsDailyMileage['status'] = 'NORMAL'): OneOsDailyMileage => ({
|
||||
plateNumber: info.plate, vin: 'test', date, dailyMileageKm: km, status,
|
||||
totalMileageKm: 64413.6, dataTime: null, calculatedAt: null, updatedAt: null,
|
||||
sourceProtocol: 'GB32960',
|
||||
});
|
||||
function aggregate(rows: OneOsDailyMileage[], start = '2026-10-09', end = '2026-10-12') {
|
||||
const byDate = new Map<string, OneOsDailyMileage[]>();
|
||||
for (const item of rows) byDate.set(item.date, [...(byDate.get(item.date) || []), item]);
|
||||
return buildRangeMileage(start, end, byDate, rows.filter(item => item.date === end), [], new Map([[info.plate, info]]), [], {});
|
||||
}
|
||||
|
||||
test('缺失或异常的一天不覆盖其余日期合计,明细保留空值', () => {
|
||||
for (const status of ['NO_DATA', 'DATA_ANOMALY', 'NORMAL'] as const) {
|
||||
const result = aggregate([
|
||||
row('2026-10-09', 1067), row('2026-10-10', null, status),
|
||||
row('2026-10-11', 2212), row('2026-10-12', 2138),
|
||||
]);
|
||||
const vehicle = result.vehicles[0];
|
||||
assert.equal(vehicle.dailyKm, 5417);
|
||||
assert.equal(vehicle.mileageAnomaly, null);
|
||||
assert.equal(vehicle.isDataSynced, true);
|
||||
assert.equal(vehicle.totalKm, 64413.6);
|
||||
assert.deepEqual(vehicle.dailyMileage, {
|
||||
'2026-10-09': 1067, '2026-10-10': null, '2026-10-11': 2212, '2026-10-12': 2138,
|
||||
});
|
||||
assert.equal(result.dailyTotals.reduce((sum, day) => sum + day.totalKm, 0), 5417);
|
||||
}
|
||||
});
|
||||
|
||||
test('整段无日里程保留缺失状态及累计里程,不能伪装为有效零里程', () => {
|
||||
const vehicle = aggregate([row('2026-10-12', null, 'DATA_ANOMALY')]).vehicles[0];
|
||||
assert.equal(vehicle.dailyKm, 0);
|
||||
assert.equal(vehicle.isDataSynced, false);
|
||||
assert.equal(vehicle.mileageAnomaly, null);
|
||||
assert.equal(vehicle.totalKm, 64413.6);
|
||||
assert.deepEqual(Object.values(vehicle.dailyMileage!), [null, null, null, null]);
|
||||
});
|
||||
|
||||
test('真实零里程是有效数据,缺失日期及重复无效记录不影响它', () => {
|
||||
const vehicle = aggregate([row('2026-10-09', null, 'NO_DATA'), row('2026-10-09', 0)]).vehicles[0];
|
||||
assert.equal(vehicle.isDataSynced, true);
|
||||
assert.equal(vehicle.dailyMileage?.['2026-10-09'], 0);
|
||||
assert.equal(vehicle.dailyMileage?.['2026-10-10'], null);
|
||||
});
|
||||
|
||||
test('单日区间同样跳过缺失数据', () => {
|
||||
const vehicle = aggregate([row('2026-10-12', null, 'DATA_ANOMALY')], '2026-10-12').vehicles[0];
|
||||
assert.equal(vehicle.mileageAnomaly, null);
|
||||
assert.equal(vehicle.isDataSynced, false);
|
||||
assert.deepEqual(vehicle.dailyMileage, { '2026-10-12': null });
|
||||
});
|
||||
|
||||
test('区间汇总和单车明细共用有效里程口径', () => {
|
||||
assert.equal(validDailyMileage(undefined), null);
|
||||
assert.equal(validDailyMileage(row('2026-10-12', null)), null);
|
||||
assert.equal(validDailyMileage(row('2026-10-12', 123, 'DATA_ANOMALY')), null);
|
||||
assert.equal(validDailyMileage(row('2026-10-12', NaN)), null);
|
||||
assert.equal(validDailyMileage(row('2026-10-12', 0)), 0);
|
||||
assert.equal(validDailyMileage(row('2026-10-12', 12.3)), 12.3);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { sourceCategoryFromProtocol, type MileageSourceCategory, type OneOsProtocol } from './source-policy.js';
|
||||
import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js';
|
||||
import {
|
||||
buildPlateTargetNamesMap, dailyMileageMap, mergeMonitoringVehicles, mileageDatesBetween,
|
||||
type DailyMileageRow, type MileageRow, type TargetRow,
|
||||
} from './cache-model.js';
|
||||
import type { CachedVehicle, VehicleInfoRow } from './types.js';
|
||||
|
||||
export interface RangeMileageResult {
|
||||
vehicles: CachedVehicle[];
|
||||
dailyTotals: { date: string; totalKm: number }[];
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export function buildRangeMileage(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
apiRowsByDate: Map<string, OneOsDailyMileage[]>,
|
||||
endDateRows: OneOsDailyMileage[],
|
||||
yesterdayRows: OneOsDailyMileage[],
|
||||
infoMap: Map<string, VehicleInfoRow>,
|
||||
targetRows: TargetRow[],
|
||||
regionMap: Record<string, string>,
|
||||
): RangeMileageResult {
|
||||
const days = mileageDatesBetween(startDate, 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: validDailyMileage(row),
|
||||
source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE',
|
||||
mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null,
|
||||
source_protocol: row.sourceProtocol,
|
||||
data_time: row.dataTime,
|
||||
calculated_at: row.calculatedAt,
|
||||
updated_at: row.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const perVehicleDaily = new Map<string, Record<string, number>>();
|
||||
const perVehicleDailySources = new Map<string, Record<string, OneOsProtocol | null>>();
|
||||
const perVehicleSourceCategories = new Map<string, Set<MileageSourceCategory>>();
|
||||
const perVehicleSum = new Map<string, MileageRow>();
|
||||
const dailyTotals = new Map<string, number>();
|
||||
const bestDailyRows = new Map<string, DailyMileageRow>();
|
||||
|
||||
for (const day of days) dailyTotals.set(day, 0);
|
||||
|
||||
for (const row of dailyRows) {
|
||||
if (row.daily_km == null) continue;
|
||||
const key = `${row.plate}\u0000${row.date}`;
|
||||
const km = Math.max(0, Number(row.daily_km) || 0);
|
||||
const existing = bestDailyRows.get(key);
|
||||
if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) {
|
||||
bestDailyRows.set(key, row);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of bestDailyRows.values()) {
|
||||
const km = Math.max(0, Number(row.daily_km) || 0);
|
||||
const date = row.date;
|
||||
const plate = row.plate;
|
||||
dailyTotals.set(date, (dailyTotals.get(date) || 0) + km);
|
||||
|
||||
const daily = perVehicleDaily.get(plate) || {};
|
||||
daily[date] = km;
|
||||
perVehicleDaily.set(plate, daily);
|
||||
const dailySources = perVehicleDailySources.get(plate) || {};
|
||||
dailySources[date] = row.source_protocol;
|
||||
perVehicleDailySources.set(plate, dailySources);
|
||||
const category = sourceCategoryFromProtocol(row.source_protocol);
|
||||
if (category) {
|
||||
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
|
||||
categories.add(category);
|
||||
perVehicleSourceCategories.set(plate, categories);
|
||||
}
|
||||
|
||||
const existing = perVehicleSum.get(plate);
|
||||
perVehicleSum.set(plate, {
|
||||
plate,
|
||||
vin: existing?.vin || row.vin || '',
|
||||
daily_km: String((Number(existing?.daily_km) || 0) + km),
|
||||
total_km: null,
|
||||
mileage_anomaly: null,
|
||||
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
|
||||
source_protocol: row.source_protocol || existing?.source_protocol || null,
|
||||
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 endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row]));
|
||||
// Preserve end-of-range odometer data even when every daily mileage is missing.
|
||||
for (const [plate, endDateRow] of endDateMap) {
|
||||
const aggregate = perVehicleSum.get(plate);
|
||||
perVehicleSum.set(plate, {
|
||||
plate,
|
||||
daily_km: aggregate?.daily_km || '0',
|
||||
source: aggregate?.source || 'NONE',
|
||||
mileage_anomaly: null,
|
||||
vin: endDateRow.vin || aggregate?.vin || '',
|
||||
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
|
||||
source_protocol: endDateRow.sourceProtocol || aggregate?.source_protocol || null,
|
||||
data_time: endDateRow.dataTime || aggregate?.data_time || null,
|
||||
calculated_at: endDateRow.calculatedAt || aggregate?.calculated_at || null,
|
||||
updated_at: endDateRow.updatedAt || aggregate?.updated_at || null,
|
||||
});
|
||||
}
|
||||
|
||||
const yesterdayMap = dailyMileageMap(yesterdayRows);
|
||||
|
||||
const vehicles = mergeMonitoringVehicles(
|
||||
Array.from(perVehicleSum.values()),
|
||||
infoMap,
|
||||
yesterdayMap,
|
||||
buildPlateTargetNamesMap(targetRows),
|
||||
regionMap,
|
||||
).map(vehicle => {
|
||||
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
|
||||
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
|
||||
const categories = perVehicleSourceCategories.get(vehicle.plate);
|
||||
const completedDailyMileage: Record<string, number | null> = {};
|
||||
const completedDailySources: Record<string, OneOsProtocol | null> = {};
|
||||
for (const day of days) completedDailyMileage[day] = dailyMileage[day] ?? null;
|
||||
for (const day of days) completedDailySources[day] = dailySources[day] || null;
|
||||
return {
|
||||
...vehicle,
|
||||
dailyMileage: completedDailyMileage,
|
||||
dailySourceProtocols: completedDailySources,
|
||||
sourceCategory: categories && categories.size > 1
|
||||
? 'MIXED' as const
|
||||
: categories?.values().next().value || vehicle.sourceCategory,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
vehicles,
|
||||
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export interface CachedVehicle {
|
||||
vin: string;
|
||||
dailyKm: number;
|
||||
mileageAnomaly?: string | null;
|
||||
dailyMileage?: Record<string, number>;
|
||||
dailyMileage?: Record<string, number | null>;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import { validDailyMileage } from './oneos-model.js';
|
||||
import { fetchOneOsMileageDates } from './oneos-api.js';
|
||||
import {
|
||||
parseMileageSourcePriority,
|
||||
@@ -81,8 +82,8 @@ app.get('/:plate/recent', async (c) => {
|
||||
const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate);
|
||||
result.push({
|
||||
date: key,
|
||||
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
|
||||
isDataSynced: hit?.status === 'NORMAL',
|
||||
dailyKm: validDailyMileage(hit) ?? 0,
|
||||
isDataSynced: validDailyMileage(hit) != null,
|
||||
sourceProtocol: hit?.sourceProtocol || null,
|
||||
sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user