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/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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user