From 4ca1bdb8feee5b906ce933b7a44a8ecd63bdd8c6 Mon Sep 17 00:00:00 2001 From: lingniu Date: Fri, 3 Jul 2026 23:21:51 +0800 Subject: [PATCH] feat(platform): summarize mileage queries --- .../apps/api/internal/platform/handler.go | 6 ++ .../api/internal/platform/handler_test.go | 1 + .../apps/api/internal/platform/mock_store.go | 38 +++++++++++- .../apps/api/internal/platform/model.go | 8 +++ .../api/internal/platform/mysql_queries.go | 32 +++++++++- .../api/internal/platform/production_store.go | 12 ++++ .../internal/platform/query_builders_test.go | 20 ++++++- .../apps/api/internal/platform/service.go | 5 ++ .../apps/web/src/api/client.ts | 2 + .../apps/web/src/api/types.ts | 8 +++ .../apps/web/src/pages/Mileage.tsx | 60 ++++++++++++++++--- 11 files changed, 179 insertions(+), 13 deletions(-) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 937f9a06..3721970b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -34,6 +34,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("GET /api/history/locations", h.handleHistoryLocations) h.mux.HandleFunc("GET /api/history/raw-frames", h.handleRawFramesGet) h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost) + h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary) h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage) h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues) h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth) @@ -111,6 +112,11 @@ func (h *Handler) handleDailyMileage(w http.ResponseWriter, r *http.Request) { h.write(w, r, data, err) } +func (h *Handler) handleMileageSummary(w http.ResponseWriter, r *http.Request) { + data, err := h.service.MileageSummary(r.Context(), r.URL.Query()) + h.write(w, r, data, err) +} + func (h *Handler) handleQualityIssues(w http.ResponseWriter, r *http.Request) { data, err := h.service.QualityIssues(r.Context(), r.URL.Query()) h.write(w, r, data, err) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index c1b1e66d..caacf728 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -111,6 +111,7 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) { }{ {"/api/history/locations?limit=10", "totalMileageKm"}, {"/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true", "plate"}, + {"/api/mileage/summary?limit=10", "totalMileageKm"}, {"/api/mileage/daily?limit=10", "dailyMileageKm"}, {"/api/quality/issues?limit=10", "issueType"}, {"/api/ops/health", "linkHealth"}, diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index 5849dc08..c11a49af 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -231,11 +231,47 @@ func (m *MockStore) RawFrames(_ context.Context, query RawFrameQuery) (Page[RawF } func (m *MockStore) DailyMileage(_ context.Context, query url.Values) (Page[DailyMileageRow], error) { + rows := m.dailyMileageRows(query) + return page(rows, query), nil +} + +func (m *MockStore) MileageSummary(_ context.Context, query url.Values) (MileageSummary, error) { + rows := m.dailyMileageRows(query) + vehicles := map[string]struct{}{} + sources := map[string]struct{}{} + total := 0.0 + for _, row := range rows { + vehicles[row.VIN] = struct{}{} + sources[row.Source] = struct{}{} + total += row.DailyMileageKm + } + summary := MileageSummary{ + VehicleCount: len(vehicles), + RecordCount: len(rows), + SourceCount: len(sources), + TotalMileageKm: total, + } + if summary.VehicleCount > 0 { + summary.AverageMileagePerVIN = summary.TotalMileageKm / float64(summary.VehicleCount) + } + return summary, nil +} + +func (m *MockStore) dailyMileageRows(query url.Values) []DailyMileageRow { rows := []DailyMileageRow{ {VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Date: "2026-07-03", StartMileageKm: 48710.2, EndMileageKm: 48798.9, DailyMileageKm: 88.7, Source: "JT808"}, {VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Date: "2026-07-03", StartMileageKm: 119820.4, EndMileageKm: 119925, DailyMileageKm: 104.6, Source: "GB32960"}, } - return page(rows, query), nil + if vin := strings.TrimSpace(query.Get("vin")); vin != "" { + keyword := strings.ToLower(vin) + rows = keep(rows, func(row DailyMileageRow) bool { + return strings.Contains(strings.ToLower(row.VIN+row.Plate), keyword) + }) + } + if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { + rows = keep(rows, func(row DailyMileageRow) bool { return row.Source == protocol }) + } + return rows } func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[QualityIssueRow], error) { diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 8986a57b..d5deb0c9 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -142,6 +142,14 @@ type DailyMileageRow struct { AnomalySeverity string `json:"anomalySeverity,omitempty"` } +type MileageSummary struct { + VehicleCount int `json:"vehicleCount"` + RecordCount int `json:"recordCount"` + SourceCount int `json:"sourceCount"` + TotalMileageKm float64 `json:"totalMileageKm"` + AverageMileagePerVIN float64 `json:"averageMileagePerVin"` +} + type QualityIssueRow struct { VIN string `json:"vin"` Plate string `json:"plate"` diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index 76560e2e..77c58f7e 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -198,9 +198,9 @@ func buildDailyMileageSQL(query url.Values) SQLQuery { args := []any{} where := []string{"1 = 1"} if vin := strings.TrimSpace(query.Get("vin")); vin != "" { - where = append(where, "(m.vin LIKE ? OR b.plate LIKE ?)") + where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)") like := "%" + vin + "%" - args = append(args, like, like) + args = append(args, like, like, like, like) } if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { where = append(where, "m.protocol = ?") @@ -227,6 +227,34 @@ func buildDailyMileageSQL(query url.Values) SQLQuery { } } +func buildMileageSummarySQL(query url.Values) SQLQuery { + args := []any{} + where := []string{"1 = 1"} + if vin := strings.TrimSpace(query.Get("vin")); vin != "" { + where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)") + like := "%" + vin + "%" + args = append(args, like, like, like, like) + } + if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { + where = append(where, "m.protocol = ?") + args = append(args, protocol) + } + if dateFrom := strings.TrimSpace(query.Get("dateFrom")); dateFrom != "" { + where = append(where, "m.stat_date >= ?") + args = append(args, dateFrom) + } + if dateTo := strings.TrimSpace(query.Get("dateTo")); dateTo != "" { + where = append(where, "m.stat_date <= ?") + args = append(args, dateTo) + } + fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ") + return SQLQuery{ + Text: `SELECT COUNT(DISTINCT m.vin) AS vehicle_count, COUNT(*) AS record_count, ` + + `COUNT(DISTINCT m.protocol) AS source_count, COALESCE(SUM(m.daily_mileage_km), 0) AS total_mileage_km ` + fromSQL, + Args: args, + } +} + func buildLimitOffset(query url.Values) (int, int) { return parsePositive(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0) } diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 9f992f12..2598cf36 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -417,6 +417,18 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P return Page[RawFrameRow]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil } +func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) { + built := buildMileageSummarySQL(query) + var summary MileageSummary + if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(&summary.VehicleCount, &summary.RecordCount, &summary.SourceCount, &summary.TotalMileageKm); err != nil { + return MileageSummary{}, err + } + if summary.VehicleCount > 0 { + summary.AverageMileagePerVIN = summary.TotalMileageKm / float64(summary.VehicleCount) + } + return summary, nil +} + func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) { built := buildDailyMileageSQL(query) total := 0 diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index c5d4d426..0691a481 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -98,14 +98,30 @@ func TestBuildDailyMileageSQL(t *testing.T) { if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") { t.Fatalf("count SQL = %s", built.CountText) } - if len(built.Args) != 7 || built.Args[0] != "%VIN001%" || built.Args[2] != "JT808" || built.Args[5] != 20 || built.Args[6] != 0 { + if len(built.Args) != 9 || built.Args[0] != "%VIN001%" || built.Args[4] != "JT808" || built.Args[7] != 20 || built.Args[8] != 0 { t.Fatalf("args = %#v", built.Args) } - if len(built.CountArgs) != 5 || built.CountArgs[0] != "%VIN001%" || built.CountArgs[2] != "JT808" { + if len(built.CountArgs) != 7 || built.CountArgs[0] != "%VIN001%" || built.CountArgs[4] != "JT808" { t.Fatalf("count args = %#v", built.CountArgs) } } +func TestBuildMileageSummarySQL(t *testing.T) { + query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}} + built := buildMileageSummarySQL(query) + for _, want := range []string{"COUNT(DISTINCT m.vin)", "COUNT(DISTINCT m.protocol)", "SUM(m.daily_mileage_km)", "vehicle_daily_mileage", "vehicle_identity_binding"} { + if !strings.Contains(built.Text, want) { + t.Fatalf("SQL missing %q: %s", want, built.Text) + } + } + if strings.Contains(built.Text, "LIMIT") || strings.Contains(built.Text, "OFFSET") { + t.Fatalf("summary SQL should not paginate: %s", built.Text) + } + if len(built.Args) != 7 || built.Args[0] != "%粤A%" || built.Args[4] != "GB32960" { + t.Fatalf("args = %#v", built.Args) + } +} + func TestBuildRawFrameSQL(t *testing.T) { built := buildRawFrameSQL("lingniu_vehicle_ts", RawFrameQuery{ Protocol: "GB32960", diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 64d96c57..41141201 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -16,6 +16,7 @@ type Store interface { HistoryLocations(context.Context, url.Values) (Page[HistoryLocationRow], error) HistoryLocationsFromTDengine(context.Context, url.Values) (Page[HistoryLocationRow], error) RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error) + MileageSummary(context.Context, url.Values) (MileageSummary, error) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error) OpsHealth(context.Context) (OpsHealth, error) @@ -185,6 +186,10 @@ func (s *Service) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawF return s.store.RawFrames(ctx, query) } +func (s *Service) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) { + return s.store.MileageSummary(ctx, query) +} + func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) { return s.store.DailyMileage(ctx, query) } diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 6464fd99..77def776 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -3,6 +3,7 @@ import type { DailyMileageRow, DashboardSummary, HistoryLocationRow, + MileageSummary, OpsHealth, Page, QualityIssueRow, @@ -32,6 +33,7 @@ export const api = { realtimeLocations: (params = new URLSearchParams()) => request>(`/api/realtime/locations?${params.toString()}`), historyLocations: (params = new URLSearchParams()) => request>(`/api/history/locations?${params.toString()}`), rawFrames: (params = new URLSearchParams()) => request>(`/api/history/raw-frames?${params.toString()}`), + mileageSummary: (params = new URLSearchParams()) => request(`/api/mileage/summary?${params.toString()}`), dailyMileage: (params = new URLSearchParams()) => request>(`/api/mileage/daily?${params.toString()}`), qualityIssues: (params = new URLSearchParams()) => request>(`/api/quality/issues?${params.toString()}`), opsHealth: () => request('/api/ops/health') diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 82f6f3d6..3a2d6ffb 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -132,6 +132,14 @@ export interface DailyMileageRow { anomalySeverity?: string; } +export interface MileageSummary { + vehicleCount: number; + recordCount: number; + sourceCount: number; + totalMileageKm: number; + averageMileagePerVin: number; +} + export interface QualityIssueRow { vin: string; plate: string; diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index 204db8a9..08078708 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -1,23 +1,51 @@ import { Button, Card, Form, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; -import type { DailyMileageRow } from '../api/types'; +import type { DailyMileageRow, MileageSummary } from '../api/types'; import { PageHeader } from '../components/PageHeader'; +const emptySummary: MileageSummary = { + vehicleCount: 0, + recordCount: 0, + sourceCount: 0, + totalMileageKm: 0, + averageMileagePerVin: 0 +}; + +function mileageParams(values: Record, pageSize?: number, offset?: number) { + const params = new URLSearchParams(); + if (pageSize != null) params.set('limit', String(pageSize)); + if (offset != null) params.set('offset', String(offset)); + if (values?.vin) params.set('vin', values.vin); + if (values?.protocol) params.set('protocol', values.protocol); + if (values?.dateFrom) params.set('dateFrom', values.dateFrom); + if (values?.dateTo) params.set('dateTo', values.dateTo); + return params; +} + +function formatKm(value: number) { + return Number.isFinite(value) ? value.toLocaleString(undefined, { maximumFractionDigits: 1 }) : '0'; +} + export function Mileage() { const [rows, setRows] = useState([]); + const [summary, setSummary] = useState(emptySummary); const [loading, setLoading] = useState(true); + const [summaryLoading, setSummaryLoading] = useState(true); const [filters, setFilters] = useState>({}); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); + const loadSummary = (values: Record = filters) => { + setSummaryLoading(true); + api.mileageSummary(mileageParams(values)) + .then(setSummary) + .catch((error: Error) => Toast.error(error.message)) + .finally(() => setSummaryLoading(false)); + }; + const load = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { setLoading(true); - const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) }); - if (values?.vin) params.set('vin', values.vin); - if (values?.protocol) params.set('protocol', values.protocol); - if (values?.dateFrom) params.set('dateFrom', values.dateFrom); - if (values?.dateTo) params.set('dateTo', values.dateTo); - api.dailyMileage(params) + api.dailyMileage(mileageParams(values, pageSize, (page - 1) * pageSize)) .then((nextPage) => { setRows(nextPage.items); setPagination({ currentPage: page, pageSize, total: nextPage.total }); @@ -27,6 +55,7 @@ export function Mileage() { }; useEffect(() => { + loadSummary({}); load({}, 1, pagination.pageSize); }, []); @@ -37,9 +66,10 @@ export function Mileage() {
{ const nextFilters = values as Record; setFilters(nextFilters); + loadSummary(nextFilters); load(nextFilters, 1, pagination.pageSize); }}> - + GB32960 JT808 @@ -51,11 +81,25 @@ export function Mileage() { +
+ {[ + { label: '车辆数', value: summary.vehicleCount.toLocaleString() }, + { label: '累计里程 km', value: formatKm(summary.totalMileageKm) }, + { label: '单车平均 km', value: formatKm(summary.averageMileagePerVin) }, + { label: '来源 / 记录', value: `${summary.sourceCount}/${summary.recordCount.toLocaleString()}` } + ].map((item) => ( + +
{item.value}
+
{item.label}
+
+ ))} +