feat(platform): add filtered vehicle coverage summary

This commit is contained in:
lingniu
2026-07-04 03:42:19 +08:00
parent 6683ce4df7
commit b7e8aae317
12 changed files with 228 additions and 14 deletions

View File

@@ -29,6 +29,7 @@ func (h *Handler) routes() {
h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles)
h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve)
h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage)
h.mux.HandleFunc("GET /api/vehicles/coverage/summary", h.handleVehicleCoverageSummary)
h.mux.HandleFunc("GET /api/vehicle-service", h.handleVehicleDetail)
h.mux.HandleFunc("GET /api/vehicle-service/summary", h.handleVehicleServiceSummary)
h.mux.HandleFunc("GET /api/vehicle-service/overview", h.handleVehicleServiceOverview)
@@ -72,6 +73,11 @@ func (h *Handler) handleVehicleCoverage(w http.ResponseWriter, r *http.Request)
h.write(w, r, data, err)
}
func (h *Handler) handleVehicleCoverageSummary(w http.ResponseWriter, r *http.Request) {
data, err := h.service.VehicleCoverageSummary(r.Context(), r.URL.Query())
h.write(w, r, data, err)
}
func (h *Handler) handleVehicleServiceSummary(w http.ResponseWriter, r *http.Request) {
data, err := h.service.VehicleServiceSummary(r.Context())
h.write(w, r, data, err)

View File

@@ -109,6 +109,28 @@ func TestHandlerVehicleCoverageFiltersServiceStatus(t *testing.T) {
}
}
func TestHandlerVehicleCoverageSummary(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage/summary?coverage=multi", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data VehicleCoverageSummary `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
}
if body.Data.TotalVehicles != 1 || body.Data.MultiSourceVehicles != 1 {
t.Fatalf("coverage summary should honor filters, got %+v body=%s", body.Data, rec.Body.String())
}
if body.Data.OnlineVehicles != 1 || body.Data.UnboundVehicles != 0 {
t.Fatalf("coverage summary should expose filtered online and binding totals, got %+v", body.Data)
}
}
func TestHandlerVehicleDetail(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()

View File

@@ -141,6 +141,32 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
return page(items, query), nil
}
func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
allQuery := copyValues(query)
allQuery.Set("limit", "100000")
allQuery.Set("offset", "0")
coverage, err := m.VehicleCoverage(ctx, allQuery)
if err != nil {
return VehicleCoverageSummary{}, err
}
summary := VehicleCoverageSummary{TotalVehicles: coverage.Total}
for _, row := range coverage.Items {
if row.Online {
summary.OnlineVehicles++
}
if row.SourceCount == 1 {
summary.SingleSourceVehicles++
}
if row.SourceCount > 1 {
summary.MultiSourceVehicles++
}
if row.BindingStatus != "bound" {
summary.UnboundVehicles++
}
}
return summary, nil
}
func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) {
coverage, err := m.VehicleCoverage(ctx, url.Values{"limit": {"10000"}})
if err != nil {
@@ -658,6 +684,14 @@ func keepServiceStatus(status *VehicleServiceStatus, raw string) bool {
}
}
func copyValues(values url.Values) url.Values {
next := url.Values{}
for key, items := range values {
next[key] = append([]string(nil), items...)
}
return next
}
func parsePositive(raw string, fallback int) int {
value, err := strconv.Atoi(raw)
if err != nil || value < 0 {

View File

@@ -63,6 +63,14 @@ type VehicleCoverageRow struct {
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
}
type VehicleCoverageSummary struct {
TotalVehicles int `json:"totalVehicles"`
OnlineVehicles int `json:"onlineVehicles"`
SingleSourceVehicles int `json:"singleSourceVehicles"`
MultiSourceVehicles int `json:"multiSourceVehicles"`
UnboundVehicles int `json:"unboundVehicles"`
}
type VehicleIdentityResolution struct {
LookupKey string `json:"lookupKey"`
Resolved bool `json:"resolved"`

View File

@@ -142,6 +142,77 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
}
}
func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
args := []any{}
where := []string{"s.vin IS NOT NULL", "s.vin <> ''"}
having := []string{}
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
where = append(where, "(s.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
like := "%" + keyword + "%"
args = append(args, like, like, like, like, like, like)
}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "s.protocol = ?")
args = append(args, protocol)
}
switch strings.TrimSpace(query.Get("coverage")) {
case "single":
having = append(having, "COUNT(DISTINCT s.protocol) = 1")
case "multi":
having = append(having, "COUNT(DISTINCT s.protocol) > 1")
}
switch strings.TrimSpace(query.Get("online")) {
case "online":
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0")
case "offline":
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0")
}
switch strings.TrimSpace(query.Get("bindingStatus")) {
case "bound":
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
case "unbound":
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
}
switch strings.TrimSpace(query.Get("serviceStatus")) {
case "identity_required":
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
case "offline":
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0")
case "degraded":
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0")
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) < COUNT(DISTINCT s.protocol)")
case "healthy":
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = COUNT(DISTINCT s.protocol)")
}
havingSQL := ""
if len(having) > 0 {
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
}
groupSQL := `SELECT s.vin, ` +
`COUNT(DISTINCT s.protocol) AS source_count, ` +
`COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` +
`MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) AS bound ` +
`FROM vehicle_realtime_snapshot s ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` +
`WHERE ` + strings.Join(where, " AND ") + ` ` +
`GROUP BY s.vin ` + havingSQL
return SQLQuery{
Text: `SELECT COUNT(*) AS total_vehicles, ` +
`COALESCE(SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END), 0) AS online_vehicles, ` +
`COALESCE(SUM(CASE WHEN source_count = 1 THEN 1 ELSE 0 END), 0) AS single_source_vehicles, ` +
`COALESCE(SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END), 0) AS multi_source_vehicles, ` +
`COALESCE(SUM(CASE WHEN bound = 0 THEN 1 ELSE 0 END), 0) AS unbound_vehicles ` +
`FROM (` + groupSQL + `) vehicle_coverage_summary`,
Args: args,
}
}
func buildRealtimeLocationSQL(query url.Values) SQLQuery {
limit := parsePositive(query.Get("limit"), 20)
offset := parsePositive(query.Get("offset"), 0)

View File

@@ -256,6 +256,21 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values)
return Page[VehicleCoverageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
}
func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
built := buildVehicleCoverageSummarySQL(query)
var summary VehicleCoverageSummary
if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(
&summary.TotalVehicles,
&summary.OnlineVehicles,
&summary.SingleSourceVehicles,
&summary.MultiSourceVehicles,
&summary.UnboundVehicles,
); err != nil {
return VehicleCoverageSummary{}, err
}
return summary, nil
}
func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
built := buildVehicleRealtimeSQL(query)
total := 0

View File

@@ -83,6 +83,30 @@ func TestBuildVehicleCoverageSQLFiltersServiceStatus(t *testing.T) {
}
}
func TestBuildVehicleCoverageSummarySQL(t *testing.T) {
query := url.Values{"keyword": {"粤A"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "serviceStatus": {"healthy"}}
built := buildVehicleCoverageSummarySQL(query)
for _, want := range []string{
"vehicle_realtime_snapshot",
"vehicle_identity_binding",
"vehicle_coverage_summary",
"SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END)",
"SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END)",
"HAVING",
"COUNT(DISTINCT s.protocol) > 1",
} {
if !strings.Contains(built.Text, want) {
t.Fatalf("summary 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) != 6 || built.Args[0] != "%粤A%" {
t.Fatalf("args = %#v", built.Args)
}
}
func TestBuildRealtimeLocationSQL(t *testing.T) {
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "limit": {"10"}, "offset": {"20"}}
built := buildRealtimeLocationSQL(query)

View File

@@ -12,6 +12,7 @@ type Store interface {
DashboardSummary(context.Context) (DashboardSummary, error)
Vehicles(context.Context, url.Values) (Page[VehicleRow], error)
VehicleCoverage(context.Context, url.Values) (Page[VehicleCoverageRow], error)
VehicleCoverageSummary(context.Context, url.Values) (VehicleCoverageSummary, error)
VehicleServiceSummary(context.Context) (VehicleServiceSummary, error)
VehicleRealtime(context.Context, url.Values) (Page[VehicleRealtimeRow], error)
RealtimeLocations(context.Context, url.Values) (Page[RealtimeLocationRow], error)
@@ -89,6 +90,10 @@ func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[V
return s.store.VehicleCoverage(ctx, query)
}
func (s *Service) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
return s.store.VehicleCoverageSummary(ctx, query)
}
func (s *Service) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) {
return s.store.VehicleServiceSummary(ctx)
}

View File

@@ -12,6 +12,7 @@ import type {
RealtimeLocationRow,
VehicleRealtimeRow,
VehicleCoverageRow,
VehicleCoverageSummary,
VehicleDetail,
VehicleIdentityResolution,
VehicleServiceOverview,
@@ -83,6 +84,7 @@ export const api = {
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
vehicleCoverage: (params = new URLSearchParams()) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`),
vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`),
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
vehicleServiceSummary: () => request<VehicleServiceSummary>('/api/vehicle-service/summary'),
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),

View File

@@ -60,6 +60,14 @@ export interface VehicleCoverageRow {
serviceStatus?: VehicleServiceStatus;
}
export interface VehicleCoverageSummary {
totalVehicles: number;
onlineVehicles: number;
singleSourceVehicles: number;
multiSourceVehicles: number;
unboundVehicles: number;
}
export interface VehicleIdentityResolution {
lookupKey: string;
resolved: boolean;

View File

@@ -1,7 +1,7 @@
import { Button, Card, Form, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api/client';
import type { VehicleCoverageRow } from '../api/types';
import type { VehicleCoverageRow, VehicleCoverageSummary } from '../api/types';
import { DataEmpty } from '../components/DataEmpty';
import { PageHeader } from '../components/PageHeader';
import { StatusTag } from '../components/StatusTag';
@@ -30,20 +30,18 @@ function vehicleServiceStatus(row: VehicleCoverageRow) {
export function Vehicles({ onOpenVehicle, initialFilters = {} }: { onOpenVehicle: (vin: string) => void; initialFilters?: Record<string, string> }) {
const [rows, setRows] = useState<VehicleCoverageRow[]>([]);
const [summary, setSummary] = useState<VehicleCoverageSummary | null>(null);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
const resultSummary = useMemo(() => {
const onlineRows = rows.filter((row) => row.online).length;
const multiSourceRows = rows.filter((row) => row.sourceCount > 1).length;
const unboundRows = rows.filter((row) => row.bindingStatus !== 'bound').length;
return [
{ label: '过滤车辆', value: pagination.total.toLocaleString() },
{ label: '本页在线', value: onlineRows.toLocaleString() },
{ label: '本页多源', value: multiSourceRows.toLocaleString() },
{ label: '本页待绑定', value: unboundRows.toLocaleString() }
{ label: '过滤车辆', value: (summary?.totalVehicles ?? pagination.total).toLocaleString() },
{ label: '在线车辆', value: (summary?.onlineVehicles ?? 0).toLocaleString() },
{ label: '多源车辆', value: (summary?.multiSourceVehicles ?? 0).toLocaleString() },
{ label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString() }
];
}, [pagination.total, rows]);
}, [pagination.total, summary]);
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
setLoading(true);
@@ -54,9 +52,13 @@ export function Vehicles({ onOpenVehicle, initialFilters = {} }: { onOpenVehicle
if (values?.online) params.set('online', values.online);
if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
api.vehicleCoverage(params)
.then((nextPage) => {
const summaryParams = new URLSearchParams(params);
summaryParams.delete('limit');
summaryParams.delete('offset');
Promise.all([api.vehicleCoverage(params), api.vehicleCoverageSummary(summaryParams)])
.then(([nextPage, nextSummary]) => {
setRows(nextPage.items);
setSummary(nextSummary);
setPagination({ currentPage: page, pageSize, total: nextPage.total });
})
.catch((error: Error) => Toast.error(error.message))

View File

@@ -191,6 +191,22 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
})
} as Response;
}
if (path.includes('/api/vehicles/coverage/summary')) {
return {
ok: true,
json: async () => ({
data: {
totalVehicles: 181,
onlineVehicles: 73,
singleSourceVehicles: 0,
multiSourceVehicles: 181,
unboundVehicles: 9
},
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
}
if (path.includes('/api/vehicles/coverage')) {
return {
ok: true,
@@ -238,10 +254,11 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
render(<App />);
expect(await screen.findByText('当前车辆结果')).toBeInTheDocument();
expect(screen.getByText('181')).toBeInTheDocument();
expect(screen.getAllByText('181').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('过滤车辆')).toBeInTheDocument();
expect(screen.getByText('本页在线')).toBeInTheDocument();
expect(screen.getByText('本页多源')).toBeInTheDocument();
expect(screen.getByText('73')).toBeInTheDocument();
expect(screen.getByText('在线车辆')).toBeInTheDocument();
expect(screen.getByText('待绑定')).toBeInTheDocument();
expect(screen.getByText('VIN-MULTI-001')).toBeInTheDocument();
});