From 3f29bed5fa85dafce871959688a7ee154f204e71 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Thu, 23 Jul 2026 12:19:11 +0800 Subject: [PATCH] feat: integrate OneOS mileage APIs and release v1.1.10 --- docker-compose.yml | 5 + docs/oneos-mileage-api-requirements.md | 129 ++++++++ package-lock.json | 4 +- package.json | 2 +- src/modules/mileage/MonitoringView.tsx | 305 ++++++++++++------- src/modules/mileage/types.ts | 3 + src/server/routes/mileage/cache.ts | 255 ++++++++-------- src/server/routes/mileage/monitoring.ts | 5 +- src/server/routes/mileage/oneos-api.ts | 321 ++++++++++++++++++++ src/server/routes/mileage/targets.ts | 21 +- src/server/routes/mileage/trend.ts | 38 ++- src/server/routes/mileage/types.ts | 3 + src/server/routes/mileage/vehicle-recent.ts | 37 +-- src/server/routes/scheduling/suggestions.ts | 60 ++-- 14 files changed, 863 insertions(+), 325 deletions(-) create mode 100644 docs/oneos-mileage-api-requirements.md create mode 100644 src/server/routes/mileage/oneos-api.ts diff --git a/docker-compose.yml b/docker-compose.yml index ca52561..6f235dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,11 @@ services: MILEAGE_DB_USER: "bi_reader_02" MILEAGE_DB_PASSWORD: "bi_reader_02_Pass" MILEAGE_DB_NAME: "hydrogen_energy" + # ECS production accesses OneOS over the private network. Configure the key + # as a Portainer stack environment variable; never commit it to this file. + ONEOS_MILEAGE_API_BASE_URL: "${ONEOS_MILEAGE_API_BASE_URL:-http://172.17.111.55:20310}" + ONEOS_MILEAGE_API_KEY: "${ONEOS_MILEAGE_API_KEY}" + ONEOS_MILEAGE_API_TIMEOUT_MS: "${ONEOS_MILEAGE_API_TIMEOUT_MS:-20000}" SERVER_PORT: "8111" EXTERNAL_API_BASE: "https://lnh2e.com" JWT_SECRET: "ln-bi-jwt-prod-k8s9m2x7" diff --git a/docs/oneos-mileage-api-requirements.md b/docs/oneos-mileage-api-requirements.md new file mode 100644 index 0000000..f3909b9 --- /dev/null +++ b/docs/oneos-mileage-api-requirements.md @@ -0,0 +1,129 @@ +# OneOS 车辆里程接口需求 + +## 开发原则 + +1. 新增区间查询接口;车辆里程汇总能力在现有 + `POST /api/v1/vehicles/mileage/query` 上兼容扩展。 +2. 现有接口的路径、鉴权、已有请求参数、已有响应字段、字段语义和默认行为必须完全兼容。 +3. 公网 `https://open.d.lnoneos.com` 与 ECS 内网 `http://172.17.111.55:20310` 使用相同路径、鉴权和数据口径。 +4. 鉴权继续使用 `Authorization: Bearer APP_KEY`。 +5. 日期采用 `YYYY-MM-DD`,时区统一为 `Asia/Shanghai`,里程单位统一为 km。 + +## 1. 车辆区间日里程批量查询 + +```http +POST /api/v1/vehicles/mileage/range/query +``` + +请求: + +```json +{ + "startDate": "2026-07-01", + "endDate": "2026-07-23", + "plateNumbers": ["沪A00001", "沪A00002"], + "cursor": null, + "pageSize": 5000 +} +``` + +规则: + +- `startDate`、`endDate` 必填,最长支持 366 天; +- `plateNumbers` 可省略,省略时返回授权范围内全部车辆; +- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`; +- 每辆车每天返回一条记录; +- 无数据返回 `status: "NO_DATA"` 且 `dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"` 且 `dailyMileageKm: 0`。 + +响应: + +```json +{ + "code": "SUCCESS", + "message": "success", + "data": [ + { + "vin": "LMRK...", + "plateNumber": "沪A00001", + "date": "2026-07-01", + "dailyMileageKm": 182.437, + "dataTime": "2026-07-01T23:58:45+08:00", + "updatedAt": "2026-07-02T05:10:00+08:00", + "status": "NORMAL" + } + ], + "snapshotId": "mileage-20260723-001", + "nextCursor": null, + "traceId": "..." +} +``` + +## 2. 兼容扩展现有单日车辆里程接口 + +用于一次获取指定日期每辆车的日里程、累计里程和数据时间。 + +```http +POST /api/v1/vehicles/mileage/query +``` + +请求: + +```json +{ + "date": "2026-07-23", + "plateNumbers": ["沪A00001", "沪A00002"] +} +``` + +规则: + +- `date` 和 `plateNumbers` 沿用现有接口规则; +- `plateNumbers` 省略时返回授权范围内全部车辆; +- 接口默认在现有车辆结果中返回 `totalMileageKm`、`dataTime` 和 `updatedAt`; +- 只增加响应字段,不删除或修改任何现有字段及其语义; +- 历史日期返回该自然日最终有效结果; +- 查询当天返回当前最新结果; +- `dataTime` 必须是本次统计实际采用的最后一条车辆源数据时间,不能使用接口请求时间或响应时间代替; +- 无数据时相关里程字段返回 `null`,不能使用 0 代替; +- 接口应支持当前应用授权范围内的全部车辆。 + +响应: + +```json +{ + "code": "SUCCESS", + "message": "success", + "data": [ + { + "vin": "LMRK...", + "plateNumber": "沪A00001", + "date": "2026-07-23", + "dailyMileageKm": 182.437, + "totalMileageKm": 12345.678, + "dataTime": "2026-07-23T10:35:42+08:00", + "updatedAt": "2026-07-23T10:35:46+08:00", + "status": "NORMAL" + } + ], + "traceId": "..." +} +``` + +现有响应外层结构及已有字段保持不变,调用方无需增加任何请求参数。 + +## 通用要求 + +- `dataTime`、`updatedAt` 使用带 `+08:00` 的 ISO 8601 格式; +- 每个响应返回 `traceId`; +- 接口返回值保留原始精度; +- 负里程不得作为正常数据返回; +- 公网和 ECS 内网使用同一 AppKey 时,授权范围和查询结果保持一致; +- 区间接口及现有接口的汇总扩展不得影响现有调用方和默认行为。 + +## 验收 + +1. 现有 `/api/v1/vehicles/mileage/query` 请求方式保持不变,回归测试全部通过。 +2. 区间接口不传车牌可完整查询全部授权车辆,分页无重复、无漏行。 +3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。 +4. 正确区分无数据与真实零里程。 +5. `dataTime` 能反映每辆车实际数据的新鲜度。 diff --git a/package-lock.json b/package-lock.json index 1f05926..1599f0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ln-bi", - "version": "1.1.7", + "version": "1.1.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.1.7", + "version": "1.1.10", "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", "@hono/node-server": "^1.13.0", diff --git a/package.json b/package.json index b8912fe..36f3881 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ln-bi", "private": true, - "version": "1.1.9", + "version": "1.1.10", "type": "module", "scripts": { "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index a91fcfc..af4402c 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -4,6 +4,7 @@ import { Truck, Filter, ChevronDown, Maximize2, Minimize2, RotateCcw, ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays, + RefreshCw, } from 'lucide-react'; import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine, XAxis } from 'recharts'; import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types'; @@ -25,6 +26,72 @@ function defaultMileageDate(): string { return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; } +function parseOneOsTime(value: string | null | undefined): number | null { + if (!value) return null; + let normalized = value.trim().replace(' ', 'T'); + if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) normalized += '+08:00'; + const timestamp = Date.parse(normalized); + return Number.isFinite(timestamp) ? timestamp : null; +} + +function formatAbsoluteTime(value: string | null | undefined): string | null { + const timestamp = parseOneOsTime(value); + if (timestamp === null) return null; + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(new Date(timestamp)); +} + +function vehicleStatisticTime(vehicle: MonitoringVehicle, nowMs: number): { + label: string; + title: string; + color: string; +} { + const primaryTime = vehicle.dataTime || vehicle.updatedAt || vehicle.calculatedAt; + const timestamp = parseOneOsTime(primaryTime); + if (timestamp === null) { + return { + label: vehicle.isDataSynced ? '统计时间待接口' : '无有效数据时间', + title: 'OneOS 当前未返回车辆级数据时间', + color: vehicle.isDataSynced ? 'text-amber-500' : 'text-slate-500', + }; + } + + const deltaSeconds = Math.floor((nowMs - timestamp) / 1000); + let label: string; + if (deltaSeconds < -60) label = '时间异常'; + else if (deltaSeconds < 5) label = '刚刚'; + else if (deltaSeconds < 60) label = `${deltaSeconds}秒前`; + else if (deltaSeconds < 3600) label = `${Math.floor(deltaSeconds / 60)}分钟前`; + else if (deltaSeconds < 86400) label = `${Math.floor(deltaSeconds / 3600)}小时前`; + else label = `${Math.floor(deltaSeconds / 86400)}天前`; + + const absoluteDataTime = formatAbsoluteTime(vehicle.dataTime); + const absoluteCalculatedAt = formatAbsoluteTime(vehicle.calculatedAt); + const absoluteUpdatedAt = formatAbsoluteTime(vehicle.updatedAt); + const title = [ + absoluteDataTime ? `数据时间:${absoluteDataTime}` : null, + absoluteCalculatedAt ? `计算时间:${absoluteCalculatedAt}` : null, + absoluteUpdatedAt ? `更新时间:${absoluteUpdatedAt}` : null, + ].filter(Boolean).join('\n'); + const ageSeconds = Math.max(0, deltaSeconds); + const color = ageSeconds <= 300 + ? 'text-emerald-500' + : ageSeconds <= 3600 + ? 'text-blue-500' + : ageSeconds <= 86400 + ? 'text-amber-500' + : 'text-rose-500'; + return { label, title, color }; +} + function normalizeRangeLabel(start: string, end: string): string { if (!start && !end) return '最新数据'; if (start && end && start === end) return start; @@ -35,12 +102,15 @@ function fmtDate(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } -type RangePreset = 'today' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15'; +type RangePreset = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth' | 'last7' | 'last15'; function getRangePreset(preset: RangePreset): { start: string; end: string } { const end = new Date(`${defaultMileageDate()}T00:00:00`); const start = new Date(end); - if (preset === 'thisWeek') { + if (preset === 'yesterday') { + start.setDate(start.getDate() - 1); + end.setDate(end.getDate() - 1); + } else if (preset === 'thisWeek') { const day = start.getDay() || 7; start.setDate(start.getDate() - day + 1); } else if (preset === 'thisMonth') { @@ -295,9 +365,17 @@ export default function MonitoringView() { const [hasMore, setHasMore] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [pageLoading, setPageLoading] = useState(true); + const [manualRefreshing, setManualRefreshing] = useState(false); + const [lastRefreshedAt, setLastRefreshedAt] = useState(null); const [showBackToTop, setShowBackToTop] = useState(false); + const [relativeNow, setRelativeNow] = useState(() => Date.now()); const PAGE_SIZE = 50; + useEffect(() => { + const timer = setInterval(() => setRelativeNow(Date.now()), 5000); + return () => clearInterval(timer); + }, []); + const departments = filterOptions.departments; const plateNumbers = filterOptions.plates; const rangeLabel = normalizeRangeLabel(effectiveRange.start, effectiveRange.end); @@ -305,10 +383,6 @@ export default function MonitoringView() { const averageDailyKm = rangeDailyTotals.length > 0 ? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length : 0; - const topLoadedVehicle = useMemo( - () => vehicles.reduce((best, vehicle) => (!best || vehicle.dailyKm > best.dailyKm ? vehicle : best), null), - [vehicles], - ); const applyRangePreset = useCallback((preset: RangePreset) => { const range = getRangePreset(preset); setRangeStart(range.start); @@ -322,9 +396,9 @@ export default function MonitoringView() { }, [filterTargetNames]); // 加载首页数据 - const loadFirstPage = useCallback(() => { - setPageLoading(true); - fetchMonitoring({ + const loadFirstPage = useCallback((showPageLoading = true) => { + if (showPageLoading) setPageLoading(true); + return fetchMonitoring({ sortBy, sortOrder, limit: PAGE_SIZE, @@ -353,9 +427,22 @@ export default function MonitoringView() { setTotal(d.total); setPage(1); setHasMore(d.page < d.totalPages); - }).catch(() => {}).finally(() => setPageLoading(false)); + setLastRefreshedAt(new Date()); + }).catch(() => {}).finally(() => { + if (showPageLoading) setPageLoading(false); + }); }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]); + const handleManualRefresh = useCallback(async () => { + if (manualRefreshing) return; + setManualRefreshing(true); + try { + await loadFirstPage(false); + } finally { + setManualRefreshing(false); + } + }, [loadFirstPage, manualRefreshing]); + // 加载更多 const loadMore = useCallback(() => { if (loadingMore || !hasMore) return; @@ -437,7 +524,7 @@ export default function MonitoringView() { // 每分钟自动刷新 useEffect(() => { - const timer = setInterval(loadFirstPage, 60 * 1000); + const timer = setInterval(() => { void loadFirstPage(false); }, 60 * 1000); return () => clearInterval(timer); }, [loadFirstPage]); @@ -757,12 +844,18 @@ export default function MonitoringView() { {fullscreenVehicles.map((v) => { const highMileageAlert = isHighMileageAlert(v); + const statisticTime = vehicleStatisticTime(v, relativeNow); return (
- {v.plate} + +
{v.plate}
+
+ {statisticTime.label} +
+ {v.customer || '-'} {v.brand || '-'} {v.rentStatus || '-'} @@ -788,89 +881,61 @@ export default function MonitoringView() { )} - {/* Ultra Compact Header - Two Rows */} -
- {/* Top Row: Title & Sort */} -
-
-
-
+
+
+
+
+
-

里程看板

- -
-
- - 数据监控 • 每15分钟更新 +
+ + 数据监控 • OneOS 实时查询
-
-
- - +
+ + +
-
-
- {/* Bottom Row: 外部三选 (批次型号 / 运营区域 / 车牌多选) + 详情筛选 */}
-
- - - +
+ + +
- @@ -879,6 +944,7 @@ export default function MonitoringView() {
{([ ['today', '今天'], + ['yesterday', '昨天'], ['thisWeek', '本周'], ['thisMonth', '本月'], ['last15', '近15天'], @@ -890,19 +956,13 @@ export default function MonitoringView() { key={preset} type="button" onClick={() => applyRangePreset(preset)} - className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${ - active - ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm' - : 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600' - }`} + className={`shrink-0 rounded-lg border px-2.5 py-1.5 text-[10px] font-black transition-all ${active ? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm' : 'border-slate-100 bg-slate-50 text-slate-500 hover:border-blue-100 hover:bg-blue-50 hover:text-blue-600'}`} > {label} ); })} - - {rangeLabel} - + {rangeLabel}
@@ -937,6 +997,7 @@ export default function MonitoringView() {
{([ ['today', '今天'], + ['yesterday', '昨天'], ['thisWeek', '本周'], ['thisMonth', '本月'], ['last7', '近7天'], @@ -1150,15 +1211,16 @@ export default function MonitoringView() { setRangeStart(today); setRangeEnd(today); }; return ( -
+
+ 已选条件 {tags.map((tag, i) => ( - + {tag.label} ))}
); @@ -1167,19 +1229,19 @@ export default function MonitoringView() { {/* Sticky header: KPI + 清单标题 */}
-
+
{sortBy === 'today' ? (isRangeMode ? '区间' : '当日') : '累计'}总里程
{pageLoading ?
: <>{Math.round(sortBy === 'today' ? stats.totalToday : stats.totalAll).toLocaleString()} km}
{rangeLabel}
-
+
平均单车
{pageLoading ?
: (stats.vehicleCount > 0 ? (sortBy === 'today' ? stats.totalToday : stats.totalAll) / stats.vehicleCount : 0).toFixed(0)}
km/台
-
+
监控台数
{pageLoading ?
: stats.vehicleCount}
@@ -1223,42 +1285,35 @@ export default function MonitoringView() {
) : ( -
-
+
+
单日概览
{rangeLabel} · 单位 km
-
-
当前列表最高
- {topLoadedVehicle ? ( -
- {topLoadedVehicle.plate} - - {topLoadedVehicle.dailyKm.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} km - -
- ) : ( -
-
- )} -
-
+
当日总计
-
{Math.round(stats.totalToday).toLocaleString()}
+
{Math.round(stats.totalToday).toLocaleString()}
-
+
日均单车
-
{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}
+
{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}
)}
-
+
车辆详情清单 {total} 条
+
+ 车牌 / 数据状态 + 客户 / 归属 + {isRangeMode ? '区间里程' : '当日里程'} + 累计里程 +
{/* Vehicle List */} @@ -1287,15 +1342,16 @@ export default function MonitoringView() {
{filteredVehicles.map((v) => { const highMileageAlert = isHighMileageAlert(v); + const statisticTime = vehicleStatisticTime(v, relativeNow); return ( setDetailVehicle(v)} > -
+
@@ -1309,13 +1365,34 @@ export default function MonitoringView() { {v.isOnline ? '在线' : '离线'}
-
+
+ {statisticTime.label} +
+
{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''} {v.customer || '-'}
-
+
+
{v.customer || '-'}
+
+ {[v.rentStatus, v.department?.replace('业务', ''), v.project].filter(Boolean).join(' · ') || '暂无归属信息'} +
+
+
+
+ {Math.max(0, v.dailyKm || 0).toLocaleString()} + km +
+
+
+
+ {v.totalKm != null ? v.totalKm.toLocaleString() : '-'} + km +
+
+
{!v.isDataSynced && v.totalKm == null && (
diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index a1078c2..38eb9b3 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -5,6 +5,9 @@ export interface MonitoringVehicle { dailyMileage?: Record; totalKm: number | null; source: string; + dataTime: string | null; + calculatedAt: string | null; + updatedAt: string | null; isOnline: boolean; isDataSynced: boolean; customer: string | null; diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts index 2c44163..eb337af 100644 --- a/src/server/routes/mileage/cache.ts +++ b/src/server/routes/mileage/cache.ts @@ -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 { + const map = new Map(); + 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> { - // 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(); - 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> { - // 当日 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(); - 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, yesterdayMap: Map, - bizTotalMap: Map, - latestPgTotalMap: Map, targetNamesByPlate: Map, ): CachedVehicle[] { const mileageMap = new Map(); @@ -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 { 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(); - 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 { } export async function queryDateMileage(dateStr: string): Promise { - 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(); - 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 { + 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>(); - const perVehicleSum = new Map(); + const perVehicleSum = new Map(); const dailyTotals = new Map(); const bestDailyRows = new Map(); @@ -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(); - 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, }; diff --git a/src/server/routes/mileage/monitoring.ts b/src/server/routes/mileage/monitoring.ts index 750c6ce..085dc3c 100644 --- a/src/server/routes/mileage/monitoring.ts +++ b/src/server/routes/mileage/monitoring.ts @@ -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), diff --git a/src/server/routes/mileage/oneos-api.ts b/src/server/routes/mileage/oneos-api.ts new file mode 100644 index 0000000..8158ebd --- /dev/null +++ b/src/server/routes/mileage/oneos-api.ts @@ -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(); +const inflight = new Map>(); +const rangeInflight = new Map>>(); + +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(); + for (const item of value) { + if (!item || typeof item !== 'object') continue; + const row = item as Record; + 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 { + if (!Array.isArray(value)) throw new Error('OneOS mileage range API returned a non-array data field'); + const rawByDate = new Map(); + for (const item of value) { + if (!item || typeof item !== 'object') continue; + const date = (item as Record).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(); + for (const [date, rows] of rawByDate) result.set(date, normalizeRows(rows, date)); + return result; +} + +async function requestDate(date: string): Promise { + 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> { + const { baseUrl, apiKey, timeoutMs } = apiConfig(); + const allRows: unknown[] = []; + const seenCursors = new Set(); + 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 = { + 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 { + 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> { + const result = new Map(); + 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(); +} diff --git a/src/server/routes/mileage/targets.ts b/src/server/routes/mileage/targets.ts index 638712d..3fcd94b 100644 --- a/src/server/routes/mileage/targets.ts +++ b/src/server/routes/mileage/targets.ts @@ -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(); 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, }); } } diff --git a/src/server/routes/mileage/trend.ts b/src/server/routes/mileage/trend.ts index 1114355..22f22f9 100644 --- a/src/server/routes/mileage/trend.ts +++ b/src/server/routes/mileage/trend.ts @@ -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); diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts index b450622..87daa65 100644 --- a/src/server/routes/mileage/types.ts +++ b/src/server/routes/mileage/types.ts @@ -6,6 +6,9 @@ export interface CachedVehicle { dailyMileage?: Record; totalKm: number | null; source: string; + dataTime: string | null; + calculatedAt: string | null; + updatedAt: string | null; isOnline: boolean; isDataSynced: boolean; customer: string | null; diff --git a/src/server/routes/mileage/vehicle-recent.ts b/src/server/routes/mileage/vehicle-recent.ts index 2f5a223..22f72a5 100644 --- a/src/server/routes/mileage/vehicle-recent.ts +++ b/src/server/routes/mileage/vehicle-recent.ts @@ -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(); - 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); } diff --git a/src/server/routes/scheduling/suggestions.ts b/src/server/routes/scheduling/suggestions.ts index 1b6e0d6..86bab07 100644 --- a/src/server/routes/scheduling/suggestions.ts +++ b/src/server/routes/scheduling/suggestions.ts @@ -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(); const customerAvgDaily7dMap = new Map(); 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(); + 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(); const plateAvg7Map = new Map(); - 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();