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,
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ function applyFilters(vehicles: CachedVehicle[], params: {
|
||||
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)
|
||||
);
|
||||
@@ -168,12 +169,12 @@ app.get('/', async (c) => {
|
||||
if (rangeDailyTotals && filtered.length !== allVehicles.length) {
|
||||
rangeDailyTotals = rangeDailyTotals.map(item => ({
|
||||
...item,
|
||||
totalKm: filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0),
|
||||
totalKm: Math.round(filtered.reduce((sum, vehicle) => sum + (vehicle.dailyMileage?.[item.date] || 0), 0)),
|
||||
}));
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalToday: filtered.reduce((sum, v) => sum + v.dailyKm, 0),
|
||||
totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)),
|
||||
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0),
|
||||
vehicleCount: filtered.length,
|
||||
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0),
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const ENDPOINT = '/api/v1/vehicles/mileage/query';
|
||||
const RANGE_ENDPOINT = '/api/v1/vehicles/mileage/range/query';
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
const CURRENT_DAY_TTL_MS = 0;
|
||||
const HISTORICAL_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const MAX_CACHE_DATES = 400;
|
||||
const RANGE_PAGE_SIZE = 5000;
|
||||
|
||||
export interface OneOsDailyMileage {
|
||||
vin: string;
|
||||
plateNumber: string;
|
||||
date: string;
|
||||
dailyMileageKm: number | null;
|
||||
totalMileageKm: number | null;
|
||||
status: 'NORMAL' | 'NO_DATA';
|
||||
dataTime: string | null;
|
||||
calculatedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
interface OneOsMileageResponse {
|
||||
code?: string;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
interface OneOsMileageRangeResponse extends OneOsMileageResponse {
|
||||
snapshotId?: string;
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt: number;
|
||||
rows: OneOsDailyMileage[];
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inflight = new Map<string, Promise<OneOsDailyMileage[]>>();
|
||||
const rangeInflight = new Map<string, Promise<Map<string, OneOsDailyMileage[]>>>();
|
||||
|
||||
function apiConfig(): { baseUrl: string; apiKey: string; timeoutMs: number } {
|
||||
const baseUrl = (process.env.ONEOS_MILEAGE_API_BASE_URL || '').replace(/\/+$/, '');
|
||||
const apiKey = process.env.ONEOS_MILEAGE_API_KEY || '';
|
||||
const timeoutMs = Number(process.env.ONEOS_MILEAGE_API_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
|
||||
if (!baseUrl) throw new Error('ONEOS_MILEAGE_API_BASE_URL is not configured');
|
||||
if (!apiKey) throw new Error('ONEOS_MILEAGE_API_KEY is not configured');
|
||||
return { baseUrl, apiKey, timeoutMs };
|
||||
}
|
||||
|
||||
function shanghaiDate(): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
}
|
||||
|
||||
function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage[] {
|
||||
if (!Array.isArray(value)) throw new Error('OneOS mileage API returned a non-array data field');
|
||||
|
||||
const best = new Map<string, OneOsDailyMileage>();
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const vin = typeof row.vin === 'string' ? row.vin.trim() : '';
|
||||
const plateNumber = typeof row.plateNumber === 'string' ? row.plateNumber.trim() : '';
|
||||
const date = typeof row.date === 'string' ? row.date : requestedDate;
|
||||
const status = row.status === 'NORMAL' ? 'NORMAL' : 'NO_DATA';
|
||||
const rawKm = row.dailyMileageKm;
|
||||
const numericKm = rawKm === null || rawKm === undefined ? null : Number(rawKm);
|
||||
const dailyMileageKm = status === 'NORMAL' && numericKm !== null && Number.isFinite(numericKm)
|
||||
? Math.max(0, numericKm)
|
||||
: null;
|
||||
const rawTotalKm = row.totalMileageKm;
|
||||
const numericTotalKm = rawTotalKm === null || rawTotalKm === undefined ? null : Number(rawTotalKm);
|
||||
const totalMileageKm = status === 'NORMAL' && numericTotalKm !== null && Number.isFinite(numericTotalKm)
|
||||
? Math.max(0, numericTotalKm)
|
||||
: null;
|
||||
const stringField = (...names: string[]): string | null => {
|
||||
for (const name of names) {
|
||||
const field = row[name];
|
||||
if (typeof field === 'string' && field.trim()) return field.trim();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
// dataTime is the preferred contract. Aliases keep the BI forward-compatible
|
||||
// while the Open API rolls out the formal field name.
|
||||
const dataTime = stringField('dataTime', 'statisticTime', 'recordTime');
|
||||
const calculatedAt = stringField('calculatedAt', 'calculationTime');
|
||||
const updatedAt = stringField('updatedAt');
|
||||
if (!plateNumber || date !== requestedDate) continue;
|
||||
|
||||
const normalized: OneOsDailyMileage = {
|
||||
vin,
|
||||
plateNumber,
|
||||
date,
|
||||
dailyMileageKm,
|
||||
totalMileageKm,
|
||||
status,
|
||||
dataTime,
|
||||
calculatedAt,
|
||||
updatedAt,
|
||||
};
|
||||
const existing = best.get(plateNumber);
|
||||
if (!existing || (dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) {
|
||||
best.set(plateNumber, normalized);
|
||||
}
|
||||
}
|
||||
return Array.from(best.values());
|
||||
}
|
||||
|
||||
function normalizeRangeRows(value: unknown, startDate: string, endDate: string): Map<string, OneOsDailyMileage[]> {
|
||||
if (!Array.isArray(value)) throw new Error('OneOS mileage range API returned a non-array data field');
|
||||
const rawByDate = new Map<string, unknown[]>();
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const date = (item as Record<string, unknown>).date;
|
||||
if (typeof date !== 'string' || date < startDate || date > endDate) continue;
|
||||
const rows = rawByDate.get(date) || [];
|
||||
rows.push(item);
|
||||
rawByDate.set(date, rows);
|
||||
}
|
||||
const result = new Map<string, OneOsDailyMileage[]>();
|
||||
for (const [date, rows] of rawByDate) result.set(date, normalizeRows(rows, date));
|
||||
return result;
|
||||
}
|
||||
|
||||
async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
|
||||
const { baseUrl, apiKey, timeoutMs } = apiConfig();
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${ENDPOINT}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Intentionally omit plateNumbers: the API then returns every vehicle
|
||||
// authorized for this application on the requested natural day.
|
||||
body: JSON.stringify({ date }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as OneOsMileageResponse | null;
|
||||
if (!response.ok || payload?.code !== 'SUCCESS') {
|
||||
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
|
||||
const error = new Error(
|
||||
`OneOS mileage API failed: HTTP ${response.status}, code=${payload?.code || 'UNKNOWN'}${trace}`,
|
||||
);
|
||||
if (response.status < 500 || attempt === 2) throw error;
|
||||
lastError = error;
|
||||
} else {
|
||||
return normalizeRows(payload.data, date);
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const nonRetryable = error instanceof Error && /HTTP (400|401|403)/.test(error.message);
|
||||
if (nonRetryable || attempt === 2) throw error;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt));
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error('OneOS mileage API request failed');
|
||||
}
|
||||
|
||||
async function requestRange(startDate: string, endDate: string): Promise<Map<string, OneOsDailyMileage[]>> {
|
||||
const { baseUrl, apiKey, timeoutMs } = apiConfig();
|
||||
const allRows: unknown[] = [];
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
let snapshotId: string | null = null;
|
||||
|
||||
for (let page = 0; page < 10_000; page += 1) {
|
||||
let payload: OneOsMileageRangeResponse | null = null;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
startDate,
|
||||
endDate,
|
||||
pageSize: RANGE_PAGE_SIZE,
|
||||
};
|
||||
if (cursor) body.cursor = cursor;
|
||||
const response = await fetch(`${baseUrl}${RANGE_ENDPOINT}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
payload = await response.json().catch(() => null) as OneOsMileageRangeResponse | null;
|
||||
if (!response.ok || payload?.code !== 'SUCCESS') {
|
||||
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
|
||||
const error = new Error(
|
||||
`OneOS mileage range API failed: HTTP ${response.status}, code=${payload?.code || 'UNKNOWN'}${trace}`,
|
||||
);
|
||||
if (response.status < 500 || attempt === 2) throw error;
|
||||
lastError = error;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const nonRetryable = error instanceof Error && /HTTP (400|401|403)/.test(error.message);
|
||||
if (nonRetryable || attempt === 2) throw error;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt));
|
||||
}
|
||||
|
||||
if (!payload || payload.code !== 'SUCCESS') {
|
||||
throw lastError instanceof Error ? lastError : new Error('OneOS mileage range API request failed');
|
||||
}
|
||||
if (!Array.isArray(payload.data)) throw new Error('OneOS mileage range API returned a non-array data field');
|
||||
if (snapshotId && payload.snapshotId !== snapshotId) {
|
||||
throw new Error('OneOS mileage range API snapshot changed during pagination');
|
||||
}
|
||||
snapshotId = payload.snapshotId || snapshotId;
|
||||
allRows.push(...payload.data);
|
||||
const nextCursor = payload.nextCursor || null;
|
||||
if (!nextCursor) return normalizeRangeRows(allRows, startDate, endDate);
|
||||
if (seenCursors.has(nextCursor)) throw new Error('OneOS mileage range API returned a repeated cursor');
|
||||
seenCursors.add(nextCursor);
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
throw new Error('OneOS mileage range API exceeded the pagination safety limit');
|
||||
}
|
||||
|
||||
function trimCache(): void {
|
||||
while (cache.size > MAX_CACHE_DATES) {
|
||||
const oldestKey = cache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOneOsDailyMileage(
|
||||
date: string,
|
||||
plateNumbers?: string[],
|
||||
): Promise<OneOsDailyMileage[]> {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`);
|
||||
|
||||
const hit = cache.get(date);
|
||||
let rows: OneOsDailyMileage[];
|
||||
if (hit && hit.expiresAt > Date.now()) {
|
||||
rows = hit.rows;
|
||||
} else {
|
||||
let pending = inflight.get(date);
|
||||
if (!pending) {
|
||||
pending = requestDate(date).then(result => {
|
||||
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
|
||||
cache.delete(date);
|
||||
if (ttl > 0) {
|
||||
cache.set(date, { rows: result, expiresAt: Date.now() + ttl });
|
||||
trimCache();
|
||||
}
|
||||
return result;
|
||||
}).finally(() => inflight.delete(date));
|
||||
inflight.set(date, pending);
|
||||
}
|
||||
rows = await pending;
|
||||
}
|
||||
|
||||
if (!plateNumbers?.length) return rows;
|
||||
const selected = new Set(plateNumbers.map(plate => plate.trim()).filter(Boolean));
|
||||
return rows.filter(row => selected.has(row.plateNumber));
|
||||
}
|
||||
|
||||
export async function fetchOneOsMileageDates(
|
||||
dates: string[],
|
||||
): Promise<Map<string, OneOsDailyMileage[]>> {
|
||||
const result = new Map<string, OneOsDailyMileage[]>();
|
||||
const uniqueDates = Array.from(new Set(dates)).sort();
|
||||
if (uniqueDates.length === 0) return result;
|
||||
for (const date of uniqueDates) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`);
|
||||
result.set(date, []);
|
||||
}
|
||||
|
||||
const groups: string[][] = [];
|
||||
for (const date of uniqueDates) {
|
||||
const current = groups[groups.length - 1];
|
||||
if (!current) {
|
||||
groups.push([date]);
|
||||
continue;
|
||||
}
|
||||
const previous = new Date(`${current[current.length - 1]}T00:00:00Z`);
|
||||
previous.setUTCDate(previous.getUTCDate() + 1);
|
||||
if (previous.toISOString().slice(0, 10) === date && current.length < 366) current.push(date);
|
||||
else groups.push([date]);
|
||||
}
|
||||
|
||||
await Promise.all(groups.map(async group => {
|
||||
const startDate = group[0];
|
||||
const endDate = group[group.length - 1];
|
||||
const key = `${startDate}:${endDate}`;
|
||||
let pending = rangeInflight.get(key);
|
||||
if (!pending) {
|
||||
pending = requestRange(startDate, endDate).finally(() => rangeInflight.delete(key));
|
||||
rangeInflight.set(key, pending);
|
||||
}
|
||||
const rowsByDate = await pending;
|
||||
for (const date of group) result.set(date, rowsByDate.get(date) || []);
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
|
||||
export function clearOneOsMileageCache(): void {
|
||||
cache.clear();
|
||||
rangeInflight.clear();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { getCache } from './cache.js';
|
||||
import { fetchOneOsDailyMileage } from './oneos-api.js';
|
||||
import { fetchVehicleInfoByPlates } from './vehicle-info.js';
|
||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||
|
||||
@@ -202,7 +202,7 @@ app.get('/', async (c) => {
|
||||
annualMileagePerVehicle: Number(t.annual_mileage_per_vehicle),
|
||||
assessmentYears: t.assessment_years,
|
||||
periods,
|
||||
todayTotal: (targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0),
|
||||
todayTotal: Math.round((targetIdPlatesMap.get(t.id) || []).reduce((sum, plate) => sum + (cacheVehicleMap.get(plate) || 0), 0)),
|
||||
cumulativeTotal: Number(s.cumulative_total) || 0,
|
||||
avgCompletion: (Number(s.avg_completion) || 0) * 100,
|
||||
qualifiedCount: Number(s.qualified_count) || 0,
|
||||
@@ -256,20 +256,15 @@ app.get('/:id/vehicles', async (c) => {
|
||||
|
||||
const dateMileageMap = new Map<string, { dailyKm: number; totalKm: number | null; isOnline: boolean }>();
|
||||
if (date && plates.length > 0) {
|
||||
const [mileageRows] = await mileagePool.execute(
|
||||
`SELECT plate, daily_km, total_km, source FROM v_vehicle_daily_stats
|
||||
WHERE stat_date = ? AND plate IN (${plates.map(() => '?').join(',')})`,
|
||||
[date, ...plates]
|
||||
) as [any[], unknown];
|
||||
const mileageRows = await fetchOneOsDailyMileage(date, plates);
|
||||
for (const m of mileageRows) {
|
||||
const existing = dateMileageMap.get(m.plate);
|
||||
const dailyKm = Number(m.daily_km) || 0;
|
||||
const existing = dateMileageMap.get(m.plateNumber);
|
||||
const dailyKm = m.status === 'NORMAL' ? (Number(m.dailyMileageKm) || 0) : 0;
|
||||
if (!existing || dailyKm > existing.dailyKm) {
|
||||
const source = m.source || 'NONE';
|
||||
dateMileageMap.set(m.plate, {
|
||||
dateMileageMap.set(m.plateNumber, {
|
||||
dailyKm,
|
||||
totalKm: m.total_km !== null ? Number(m.total_km) : null,
|
||||
isOnline: source !== 'NONE' && dailyKm > 0,
|
||||
totalKm: m.totalMileageKm,
|
||||
isOnline: m.status === 'NORMAL' && dailyKm > 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { fetchOneOsMileageDates } from './oneos-api.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', async (c) => {
|
||||
const targetId = c.req.query('targetId');
|
||||
const days = Number(c.req.query('days')) || 7;
|
||||
const days = Math.min(Math.max(Number(c.req.query('days')) || 7, 1), 366);
|
||||
|
||||
try {
|
||||
let plates: string[] = [];
|
||||
@@ -19,27 +19,23 @@ app.get('/', async (c) => {
|
||||
if (plates.length === 0) return c.json([]);
|
||||
}
|
||||
|
||||
// 单车日里程负值视为脏数据(里程表回滚 / 换 GPS 设备),不纳入统计
|
||||
let sql = `
|
||||
SELECT DATE_FORMAT(stat_date, '%m-%d') as date,
|
||||
SUM(IF(daily_km < 0, 0, daily_km)) as mileage
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND stat_date < CURDATE()
|
||||
`;
|
||||
const params: (string | number)[] = [days];
|
||||
|
||||
if (plates.length > 0) {
|
||||
sql += ` AND plate IN (${plates.map(() => '?').join(',')})`;
|
||||
params.push(...plates);
|
||||
const today = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dates: string[] = [];
|
||||
for (let offset = days; offset >= 1; offset -= 1) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() - offset);
|
||||
dates.push(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
sql += ' GROUP BY stat_date ORDER BY stat_date';
|
||||
|
||||
const [rows] = await mileagePool.execute(sql, params) as [any[], unknown];
|
||||
|
||||
return c.json(rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
mileage: Math.round(Number(r.mileage) || 0),
|
||||
const rowsByDate = await fetchOneOsMileageDates(dates);
|
||||
const selectedPlates = plates.length > 0 ? new Set(plates) : null;
|
||||
return c.json(dates.map(date => ({
|
||||
date: date.slice(5),
|
||||
mileage: Math.round((rowsByDate.get(date) || []).reduce((sum, row) => {
|
||||
if (selectedPlates && !selectedPlates.has(row.plateNumber)) return sum;
|
||||
return sum + (row.status === 'NORMAL' ? (row.dailyMileageKm || 0) : 0);
|
||||
}, 0)),
|
||||
})));
|
||||
} catch (e: unknown) {
|
||||
console.error('trend error:', e);
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface CachedVehicle {
|
||||
dailyMileage?: Record<string, number>;
|
||||
totalKm: number | null;
|
||||
source: string;
|
||||
dataTime: string | null;
|
||||
calculatedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
isOnline: boolean;
|
||||
isDataSynced: boolean;
|
||||
customer: string | null;
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { Hono } from 'hono';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { fetchOneOsMileageDates } from './oneos-api.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
interface DayRow {
|
||||
date: string;
|
||||
daily_km: string | number | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
function fmt(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
@@ -58,35 +52,24 @@ app.get('/:plate/recent', async (c) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await mileagePool.execute(
|
||||
`SELECT DATE_FORMAT(stat_date, '%Y-%m-%d') AS date, daily_km, source
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE plate = ? AND stat_date >= ? AND stat_date <= ?
|
||||
ORDER BY stat_date`,
|
||||
[plate, fmt(start), fmt(end)]
|
||||
) as [DayRow[], unknown];
|
||||
|
||||
// 同一 plate 同一天可能有多个数据源,取最大 daily_km
|
||||
const map = new Map<string, { dailyKm: number; source: string }>();
|
||||
for (const r of rows) {
|
||||
const km = Number(r.daily_km) || 0;
|
||||
const src = r.source || 'NONE';
|
||||
const existing = map.get(r.date);
|
||||
if (!existing || km > existing.dailyKm) {
|
||||
map.set(r.date, { dailyKm: km, source: src });
|
||||
}
|
||||
const dates: string[] = [];
|
||||
const dateCursor = new Date(start);
|
||||
while (dateCursor <= end) {
|
||||
dates.push(fmt(dateCursor));
|
||||
dateCursor.setDate(dateCursor.getDate() + 1);
|
||||
}
|
||||
const rowsByDate = await fetchOneOsMileageDates(dates);
|
||||
|
||||
// 补全:从 start 到 end 每天一条
|
||||
const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = [];
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
const key = fmt(cursor);
|
||||
const hit = map.get(key);
|
||||
const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate);
|
||||
result.push({
|
||||
date: key,
|
||||
dailyKm: hit?.dailyKm ?? 0,
|
||||
isDataSynced: !!hit && hit.source !== 'NONE',
|
||||
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
|
||||
isDataSynced: hit?.status === 'NORMAL',
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import mileagePool from '../../mileage-db.js';
|
||||
import { fetchVehicleInfoMap } from '../mileage/vehicle-info.js';
|
||||
import { fetchOneOsMileageDates } from '../mileage/oneos-api.js';
|
||||
import { mapRegion } from '../vehicles.js';
|
||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||
import { classifyVehicle, generateSuggestions } from './algorithm.js';
|
||||
@@ -42,6 +42,24 @@ function classifyVehicleType(typeName: string, _modelRaw: string): string {
|
||||
return t || '其他';
|
||||
}
|
||||
|
||||
function recentCompletedDates(count: number): string[] {
|
||||
const today = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date());
|
||||
const [year, month, day] = today.split('-').map(Number);
|
||||
const base = new Date(Date.UTC(year, month - 1, day));
|
||||
const dates: string[] = [];
|
||||
for (let offset = count; offset >= 1; offset -= 1) {
|
||||
const date = new Date(base);
|
||||
date.setUTCDate(base.getUTCDate() - offset);
|
||||
dates.push(date.toISOString().slice(0, 10));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,29 +132,33 @@ app.get('/', async (c) => {
|
||||
// ---- Collect all plates for Query 6 ----
|
||||
const allPlates = assessmentRows.map((r: any) => r.plate_number as string);
|
||||
|
||||
// ---- Query 6: Customer daily avg (from mileage DB) — 30d baseline + 7d recent ----
|
||||
// ---- Query 6: Customer daily avg (OneOS only) — 30d baseline + 7d recent ----
|
||||
const customerAvgDailyMap = new Map<string, number>();
|
||||
const customerAvgDaily7dMap = new Map<string, number>();
|
||||
if (allPlates.length > 0) {
|
||||
const placeholders = allPlates.map(() => '?').join(',');
|
||||
// Single query returning both windows per plate.
|
||||
const [dailyRows] = await mileagePool.execute(
|
||||
`SELECT plate,
|
||||
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN daily_km END) AS avg_30d,
|
||||
AVG(CASE WHEN stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN daily_km END) AS avg_7d
|
||||
FROM v_vehicle_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
AND stat_date < CURDATE()
|
||||
AND plate IN (${placeholders})
|
||||
GROUP BY plate`,
|
||||
allPlates,
|
||||
) as [any[], unknown];
|
||||
|
||||
const dates = recentCompletedDates(30);
|
||||
const sevenDayDates = new Set(dates.slice(-7));
|
||||
const allowedPlates = new Set(allPlates);
|
||||
const rowsByDate = await fetchOneOsMileageDates(dates);
|
||||
const aggregates = new Map<string, { sum30: number; count30: number; sum7: number; count7: number }>();
|
||||
for (const [date, rows] of rowsByDate) {
|
||||
for (const row of rows) {
|
||||
if (!allowedPlates.has(row.plateNumber) || row.status !== 'NORMAL' || row.dailyMileageKm === null) continue;
|
||||
const current = aggregates.get(row.plateNumber) || { sum30: 0, count30: 0, sum7: 0, count7: 0 };
|
||||
current.sum30 += row.dailyMileageKm;
|
||||
current.count30 += 1;
|
||||
if (sevenDayDates.has(date)) {
|
||||
current.sum7 += row.dailyMileageKm;
|
||||
current.count7 += 1;
|
||||
}
|
||||
aggregates.set(row.plateNumber, current);
|
||||
}
|
||||
}
|
||||
const plateAvg30Map = new Map<string, number>();
|
||||
const plateAvg7Map = new Map<string, number>();
|
||||
for (const row of dailyRows) {
|
||||
if (row.avg_30d !== null) plateAvg30Map.set(row.plate, Number(row.avg_30d));
|
||||
if (row.avg_7d !== null) plateAvg7Map.set(row.plate, Number(row.avg_7d));
|
||||
for (const [plate, values] of aggregates) {
|
||||
if (values.count30 > 0) plateAvg30Map.set(plate, values.sum30 / values.count30);
|
||||
if (values.count7 > 0) plateAvg7Map.set(plate, values.sum7 / values.count7);
|
||||
}
|
||||
|
||||
const customerPlates30 = new Map<string, number[]>();
|
||||
|
||||
Reference in New Issue
Block a user