feat(platform): expose no-data vehicles

This commit is contained in:
lingniu
2026-07-04 04:25:29 +08:00
parent 7f9d327f38
commit 40397efa4a
13 changed files with 79 additions and 19 deletions

View File

@@ -201,7 +201,7 @@ func traceID(r *http.Request) string {
func splitCSV(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
return []string{}
}
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))

View File

@@ -131,6 +131,16 @@ func TestHandlerVehicleCoverageSummary(t *testing.T) {
}
}
func TestSplitCSVEmptyReturnsEmptySlice(t *testing.T) {
values := splitCSV("")
if values == nil {
t.Fatalf("empty CSV should encode as [] instead of JSON null")
}
if len(values) != 0 {
t.Fatalf("empty CSV should have no values, got %#v", values)
}
}
func TestHandlerVehicleDetail(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()

View File

@@ -160,6 +160,9 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values
if row.SourceCount > 1 {
summary.MultiSourceVehicles++
}
if row.SourceCount == 0 {
summary.NoDataVehicles++
}
if row.BindingStatus != "bound" {
summary.UnboundVehicles++
}

View File

@@ -68,6 +68,7 @@ type VehicleCoverageSummary struct {
OnlineVehicles int `json:"onlineVehicles"`
SingleSourceVehicles int `json:"singleSourceVehicles"`
MultiSourceVehicles int `json:"multiSourceVehicles"`
NoDataVehicles int `json:"noDataVehicles"`
UnboundVehicles int `json:"unboundVehicles"`
}

View File

@@ -67,10 +67,10 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
limit := parsePositive(query.Get("limit"), 20)
offset := parsePositive(query.Get("offset"), 0)
args := []any{}
where := []string{"s.vin IS NOT NULL", "s.vin <> ''"}
where := []string{"v.vin IS NOT NULL", "v.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 ?)")
where = append(where, "(v.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)
}
@@ -99,6 +99,9 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
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 "no_data":
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")
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")
@@ -119,13 +122,16 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
if len(having) > 0 {
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
}
groupSQL := `FROM vehicle_realtime_snapshot s ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` +
vehicleSetSQL := `SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> '' ` +
`UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> ''`
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
`WHERE ` + strings.Join(where, " AND ") + ` ` +
`GROUP BY s.vin, b.plate, b.phone, b.oem, b.vin ` +
`GROUP BY v.vin, b.plate, b.phone, b.oem, b.vin ` +
havingSQL
return SQLQuery{
Text: `SELECT s.vin, ` +
Text: `SELECT v.vin, ` +
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
@@ -135,19 +141,19 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
`COALESCE(DATE_FORMAT(MAX(s.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
`CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 'bound' ELSE 'unbound' END AS binding_status ` +
groupSQL +
`ORDER BY MAX(s.updated_at) DESC, s.vin ASC LIMIT ? OFFSET ?`,
`ORDER BY MAX(s.updated_at) DESC, v.vin ASC LIMIT ? OFFSET ?`,
Args: args,
CountText: `SELECT COUNT(*) FROM (SELECT s.vin ` + groupSQL + `) vehicle_coverage_count`,
CountText: `SELECT COUNT(*) FROM (SELECT v.vin ` + groupSQL + `) vehicle_coverage_count`,
CountArgs: countArgs,
}
}
func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
args := []any{}
where := []string{"s.vin IS NOT NULL", "s.vin <> ''"}
where := []string{"v.vin IS NOT NULL", "v.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 ?)")
where = append(where, "(v.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)
}
@@ -176,6 +182,9 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
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 "no_data":
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")
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")
@@ -194,19 +203,23 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
if len(having) > 0 {
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
}
groupSQL := `SELECT s.vin, ` +
vehicleSetSQL := `SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> '' ` +
`UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> ''`
groupSQL := `SELECT v.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 ` +
`FROM (` + vehicleSetSQL + `) v ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
`WHERE ` + strings.Join(where, " AND ") + ` ` +
`GROUP BY s.vin ` + havingSQL
`GROUP BY v.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 source_count = 0 THEN 1 ELSE 0 END), 0) AS no_data_vehicles, ` +
`COALESCE(SUM(CASE WHEN bound = 0 THEN 1 ELSE 0 END), 0) AS unbound_vehicles ` +
`FROM (` + groupSQL + `) vehicle_coverage_summary`,
Args: args,

View File

@@ -264,6 +264,7 @@ func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url.
&summary.OnlineVehicles,
&summary.SingleSourceVehicles,
&summary.MultiSourceVehicles,
&summary.NoDataVehicles,
&summary.UnboundVehicles,
); err != nil {
return VehicleCoverageSummary{}, err

View File

@@ -46,7 +46,7 @@ func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) {
func TestBuildVehicleCoverageSQL(t *testing.T) {
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}}
built := buildVehicleCoverageSQL(query)
for _, want := range []string{"GROUP BY s.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, s.vin ASC"} {
for _, want := range []string{"GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("SQL missing %q: %s", want, built.Text)
}
@@ -83,6 +83,23 @@ func TestBuildVehicleCoverageSQLFiltersServiceStatus(t *testing.T) {
}
}
func TestBuildVehicleCoverageSQLIncludesNoDataVehicles(t *testing.T) {
query := url.Values{"serviceStatus": {"no_data"}, "limit": {"8"}}
built := buildVehicleCoverageSQL(query)
for _, want := range []string{
"vehicle_identity_binding",
"UNION",
"vehicle_realtime_snapshot",
"LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin",
"COUNT(DISTINCT s.protocol) = 0",
"MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1",
} {
if !strings.Contains(built.Text+built.CountText, want) {
t.Fatalf("no-data coverage SQL missing %q: %s / %s", want, built.Text, built.CountText)
}
}
}
func TestBuildVehicleCoverageSummarySQL(t *testing.T) {
query := url.Values{"keyword": {"粤A"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "serviceStatus": {"healthy"}}
built := buildVehicleCoverageSummarySQL(query)
@@ -92,6 +109,7 @@ func TestBuildVehicleCoverageSummarySQL(t *testing.T) {
"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)",
"SUM(CASE WHEN source_count = 0 THEN 1 ELSE 0 END)",
"HAVING",
"COUNT(DISTINCT s.protocol) > 1",
} {