feat(platform): summarize mileage queries
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user