feat(platform): add filtered vehicle coverage summary
This commit is contained in:
@@ -29,6 +29,7 @@ func (h *Handler) routes() {
|
|||||||
h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles)
|
h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles)
|
||||||
h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve)
|
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", 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", h.handleVehicleDetail)
|
||||||
h.mux.HandleFunc("GET /api/vehicle-service/summary", h.handleVehicleServiceSummary)
|
h.mux.HandleFunc("GET /api/vehicle-service/summary", h.handleVehicleServiceSummary)
|
||||||
h.mux.HandleFunc("GET /api/vehicle-service/overview", h.handleVehicleServiceOverview)
|
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)
|
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) {
|
func (h *Handler) handleVehicleServiceSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := h.service.VehicleServiceSummary(r.Context())
|
data, err := h.service.VehicleServiceSummary(r.Context())
|
||||||
h.write(w, r, data, err)
|
h.write(w, r, data, err)
|
||||||
|
|||||||
@@ -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) {
|
func TestHandlerVehicleDetail(t *testing.T) {
|
||||||
handler := NewHandler(NewService(NewMockStore()))
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -141,6 +141,32 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
|||||||
return page(items, query), nil
|
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) {
|
func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) {
|
||||||
coverage, err := m.VehicleCoverage(ctx, url.Values{"limit": {"10000"}})
|
coverage, err := m.VehicleCoverage(ctx, url.Values{"limit": {"10000"}})
|
||||||
if err != nil {
|
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 {
|
func parsePositive(raw string, fallback int) int {
|
||||||
value, err := strconv.Atoi(raw)
|
value, err := strconv.Atoi(raw)
|
||||||
if err != nil || value < 0 {
|
if err != nil || value < 0 {
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ type VehicleCoverageRow struct {
|
|||||||
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
|
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 {
|
type VehicleIdentityResolution struct {
|
||||||
LookupKey string `json:"lookupKey"`
|
LookupKey string `json:"lookupKey"`
|
||||||
Resolved bool `json:"resolved"`
|
Resolved bool `json:"resolved"`
|
||||||
|
|||||||
@@ -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 {
|
func buildRealtimeLocationSQL(query url.Values) SQLQuery {
|
||||||
limit := parsePositive(query.Get("limit"), 20)
|
limit := parsePositive(query.Get("limit"), 20)
|
||||||
offset := parsePositive(query.Get("offset"), 0)
|
offset := parsePositive(query.Get("offset"), 0)
|
||||||
|
|||||||
@@ -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
|
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) {
|
func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||||
built := buildVehicleRealtimeSQL(query)
|
built := buildVehicleRealtimeSQL(query)
|
||||||
total := 0
|
total := 0
|
||||||
|
|||||||
@@ -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) {
|
func TestBuildRealtimeLocationSQL(t *testing.T) {
|
||||||
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "limit": {"10"}, "offset": {"20"}}
|
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "limit": {"10"}, "offset": {"20"}}
|
||||||
built := buildRealtimeLocationSQL(query)
|
built := buildRealtimeLocationSQL(query)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Store interface {
|
|||||||
DashboardSummary(context.Context) (DashboardSummary, error)
|
DashboardSummary(context.Context) (DashboardSummary, error)
|
||||||
Vehicles(context.Context, url.Values) (Page[VehicleRow], error)
|
Vehicles(context.Context, url.Values) (Page[VehicleRow], error)
|
||||||
VehicleCoverage(context.Context, url.Values) (Page[VehicleCoverageRow], error)
|
VehicleCoverage(context.Context, url.Values) (Page[VehicleCoverageRow], error)
|
||||||
|
VehicleCoverageSummary(context.Context, url.Values) (VehicleCoverageSummary, error)
|
||||||
VehicleServiceSummary(context.Context) (VehicleServiceSummary, error)
|
VehicleServiceSummary(context.Context) (VehicleServiceSummary, error)
|
||||||
VehicleRealtime(context.Context, url.Values) (Page[VehicleRealtimeRow], error)
|
VehicleRealtime(context.Context, url.Values) (Page[VehicleRealtimeRow], error)
|
||||||
RealtimeLocations(context.Context, url.Values) (Page[RealtimeLocationRow], 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)
|
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) {
|
func (s *Service) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) {
|
||||||
return s.store.VehicleServiceSummary(ctx)
|
return s.store.VehicleServiceSummary(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
RealtimeLocationRow,
|
RealtimeLocationRow,
|
||||||
VehicleRealtimeRow,
|
VehicleRealtimeRow,
|
||||||
VehicleCoverageRow,
|
VehicleCoverageRow,
|
||||||
|
VehicleCoverageSummary,
|
||||||
VehicleDetail,
|
VehicleDetail,
|
||||||
VehicleIdentityResolution,
|
VehicleIdentityResolution,
|
||||||
VehicleServiceOverview,
|
VehicleServiceOverview,
|
||||||
@@ -83,6 +84,7 @@ export const api = {
|
|||||||
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
|
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
|
||||||
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${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()}`),
|
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()}`),
|
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
|
||||||
vehicleServiceSummary: () => request<VehicleServiceSummary>('/api/vehicle-service/summary'),
|
vehicleServiceSummary: () => request<VehicleServiceSummary>('/api/vehicle-service/summary'),
|
||||||
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),
|
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ export interface VehicleCoverageRow {
|
|||||||
serviceStatus?: VehicleServiceStatus;
|
serviceStatus?: VehicleServiceStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VehicleCoverageSummary {
|
||||||
|
totalVehicles: number;
|
||||||
|
onlineVehicles: number;
|
||||||
|
singleSourceVehicles: number;
|
||||||
|
multiSourceVehicles: number;
|
||||||
|
unboundVehicles: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VehicleIdentityResolution {
|
export interface VehicleIdentityResolution {
|
||||||
lookupKey: string;
|
lookupKey: string;
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button, Card, Form, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
|
import { Button, Card, Form, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { VehicleCoverageRow } from '../api/types';
|
import type { VehicleCoverageRow, VehicleCoverageSummary } from '../api/types';
|
||||||
import { DataEmpty } from '../components/DataEmpty';
|
import { DataEmpty } from '../components/DataEmpty';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { StatusTag } from '../components/StatusTag';
|
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> }) {
|
export function Vehicles({ onOpenVehicle, initialFilters = {} }: { onOpenVehicle: (vin: string) => void; initialFilters?: Record<string, string> }) {
|
||||||
const [rows, setRows] = useState<VehicleCoverageRow[]>([]);
|
const [rows, setRows] = useState<VehicleCoverageRow[]>([]);
|
||||||
|
const [summary, setSummary] = useState<VehicleCoverageSummary | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
|
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
|
||||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
||||||
const resultSummary = useMemo(() => {
|
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 [
|
return [
|
||||||
{ label: '过滤车辆', value: pagination.total.toLocaleString() },
|
{ label: '过滤车辆', value: (summary?.totalVehicles ?? pagination.total).toLocaleString() },
|
||||||
{ label: '本页在线', value: onlineRows.toLocaleString() },
|
{ label: '在线车辆', value: (summary?.onlineVehicles ?? 0).toLocaleString() },
|
||||||
{ label: '本页多源', value: multiSourceRows.toLocaleString() },
|
{ label: '多源车辆', value: (summary?.multiSourceVehicles ?? 0).toLocaleString() },
|
||||||
{ label: '本页待绑定', value: unboundRows.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) => {
|
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -54,9 +52,13 @@ export function Vehicles({ onOpenVehicle, initialFilters = {} }: { onOpenVehicle
|
|||||||
if (values?.online) params.set('online', values.online);
|
if (values?.online) params.set('online', values.online);
|
||||||
if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
|
if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
|
||||||
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
|
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
|
||||||
api.vehicleCoverage(params)
|
const summaryParams = new URLSearchParams(params);
|
||||||
.then((nextPage) => {
|
summaryParams.delete('limit');
|
||||||
|
summaryParams.delete('offset');
|
||||||
|
Promise.all([api.vehicleCoverage(params), api.vehicleCoverageSummary(summaryParams)])
|
||||||
|
.then(([nextPage, nextSummary]) => {
|
||||||
setRows(nextPage.items);
|
setRows(nextPage.items);
|
||||||
|
setSummary(nextSummary);
|
||||||
setPagination({ currentPage: page, pageSize, total: nextPage.total });
|
setPagination({ currentPage: page, pageSize, total: nextPage.total });
|
||||||
})
|
})
|
||||||
.catch((error: Error) => Toast.error(error.message))
|
.catch((error: Error) => Toast.error(error.message))
|
||||||
|
|||||||
@@ -191,6 +191,22 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
|
|||||||
})
|
})
|
||||||
} as Response;
|
} 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')) {
|
if (path.includes('/api/vehicles/coverage')) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -238,10 +254,11 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('当前车辆结果')).toBeInTheDocument();
|
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('待绑定')).toBeInTheDocument();
|
||||||
expect(screen.getByText('VIN-MULTI-001')).toBeInTheDocument();
|
expect(screen.getByText('VIN-MULTI-001')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user