feat(platform): summarize mileage queries

This commit is contained in:
lingniu
2026-07-03 23:21:51 +08:00
parent a4e9a8afb8
commit 4ca1bdb8fe
11 changed files with 179 additions and 13 deletions

View File

@@ -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)

View File

@@ -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"},

View File

@@ -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) {

View File

@@ -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"`

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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)
}

View File

@@ -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<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`),
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
opsHealth: () => request<OpsHealth>('/api/ops/health')

View File

@@ -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;

View File

@@ -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<string, string>, 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<DailyMileageRow[]>([]);
const [summary, setSummary] = useState<MileageSummary>(emptySummary);
const [loading, setLoading] = useState(true);
const [summaryLoading, setSummaryLoading] = useState(true);
const [filters, setFilters] = useState<Record<string, string>>({});
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
const loadSummary = (values: Record<string, string> = filters) => {
setSummaryLoading(true);
api.mileageSummary(mileageParams(values))
.then(setSummary)
.catch((error: Error) => Toast.error(error.message))
.finally(() => setSummaryLoading(false));
};
const load = (values: Record<string, string> = 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() {
<Form layout="horizontal" onSubmit={(values) => {
const nextFilters = values as Record<string, string>;
setFilters(nextFilters);
loadSummary(nextFilters);
load(nextFilters, 1, pagination.pageSize);
}}>
<Form.Input field="vin" label="关键词" placeholder="VIN / 车牌" style={{ width: 260 }} />
<Form.Input field="vin" label="关键词" placeholder="VIN / 车牌 / 手机号 / OEM" style={{ width: 260 }} />
<Form.Select field="protocol" label="协议" placeholder="全部协议" style={{ width: 180 }}>
<Select.Option value="GB32960">GB32960</Select.Option>
<Select.Option value="JT808">JT808</Select.Option>
@@ -51,11 +81,25 @@ export function Mileage() {
<Button htmlType="submit" theme="solid" type="primary"></Button>
<Button onClick={() => {
setFilters({});
loadSummary({});
load({}, 1, pagination.pageSize);
}}></Button>
</Space>
</Form>
</Card>
<div className="vp-kpi-grid" style={{ marginTop: 16 }}>
{[
{ 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) => (
<Card key={item.label} bordered loading={summaryLoading}>
<div className="vp-kpi-value">{item.value}</div>
<div className="vp-kpi-label">{item.label}</div>
</Card>
))}
</div>
<Card bordered style={{ marginTop: 16 }}>
<Table
loading={loading}