diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 47613a3c..2d17c41f 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -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)) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index d97699fc..8fffa94c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -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() diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index 514f5093..6c318eb1 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -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++ } diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 84882b87..7a11303a 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -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"` } diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index 07b48a24..f229a614 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -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, diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 0376762d..588110fa 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -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 diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index cfec833b..b9f548c4 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -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", } { diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 06035eae..c9269c3f 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -65,6 +65,7 @@ export interface VehicleCoverageSummary { onlineVehicles: number; singleSourceVehicles: number; multiSourceVehicles: number; + noDataVehicles: number; unboundVehicles: number; } diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index 5a83147a..57acc58d 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -17,6 +17,7 @@ const serviceStatusColor: Record = healthy: 'green', degraded: 'orange', offline: 'red', + no_data: 'orange', identity_required: 'orange' }; @@ -24,6 +25,7 @@ const serviceStatusTitle: Record = { healthy: '服务正常', degraded: '部分来源离线', offline: '车辆离线', + no_data: '暂无数据来源', identity_required: '身份未绑定' }; @@ -104,6 +106,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on { label: '在线车辆', value: formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles), filters: { online: 'online' } }, { label: '单源车辆', value: formatCount(serviceSummary?.singleSourceVehicles), filters: { coverage: 'single' } }, { label: '多源车辆', value: formatCount(serviceSummary?.multiSourceVehicles), filters: { coverage: 'multi' } }, + { label: '暂无来源车辆', value: formatCount(serviceSummary?.noDataVehicles), filters: { serviceStatus: 'no_data' } }, { label: '身份未绑定', value: formatCount(serviceSummary?.identityRequiredVehicles), filters: { serviceStatus: 'identity_required' } } ]; @@ -239,6 +242,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on 服务正常 部分来源离线 车辆离线 + 暂无数据来源 身份未绑定 diff --git a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx index 9ee68fa6..e9e98227 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -52,6 +52,7 @@ export function Vehicles({ { label: '在线车辆', value: (summary?.onlineVehicles ?? 0).toLocaleString(), filters: { online: 'online' } }, { label: '单源车辆', value: (summary?.singleSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'single' } }, { label: '多源车辆', value: (summary?.multiSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'multi' } }, + { label: '暂无来源车辆', value: (summary?.noDataVehicles ?? 0).toLocaleString(), filters: { serviceStatus: 'no_data' } }, { label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } } ]; return items; @@ -144,6 +145,7 @@ export function Vehicles({ 服务正常 部分来源离线 车辆离线 + 暂无数据来源 身份未绑定 diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 21cd2d00..51afb0c0 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -121,6 +121,8 @@ test('dashboard renders vehicle service summary metrics', async () => { expect(screen.getByText('391')).toBeInTheDocument(); expect(screen.getByText('多源车辆')).toBeInTheDocument(); expect(screen.getByText('181')).toBeInTheDocument(); + expect(screen.getByText('暂无来源车辆')).toBeInTheDocument(); + expect(screen.getByText('461')).toBeInTheDocument(); expect(screen.getByText('身份未绑定')).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', undefined); }); @@ -171,12 +173,12 @@ test('opens vehicle list filtered by service summary KPI', async () => { render(); - fireEvent.click(await screen.findByRole('button', { name: /单源车辆/ })); + fireEvent.click(await screen.findByRole('button', { name: /暂无来源车辆/ })); await waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&coverage=single'), undefined); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&serviceStatus=no_data'), undefined); }); - expect(window.location.hash).toBe('#/vehicles?coverage=single'); + expect(window.location.hash).toBe('#/vehicles?serviceStatus=no_data'); }); test('shows vehicle service result summary on vehicle list filters', async () => { @@ -261,6 +263,7 @@ test('shows vehicle service result summary on vehicle list filters', async () => expect(screen.getByText('73')).toBeInTheDocument(); expect(screen.getByText('在线车辆')).toBeInTheDocument(); expect(screen.getByText('单源车辆')).toBeInTheDocument(); + expect(screen.getByText('暂无来源车辆')).toBeInTheDocument(); expect(screen.getByText('待绑定')).toBeInTheDocument(); expect(screen.getByText('VIN-MULTI-001')).toBeInTheDocument(); expect(screen.getAllByText('来源证据').length).toBeGreaterThanOrEqual(1); diff --git a/vehicle-data-platform/docs/api-contract.md b/vehicle-data-platform/docs/api-contract.md index 7d7be3e4..741d740c 100644 --- a/vehicle-data-platform/docs/api-contract.md +++ b/vehicle-data-platform/docs/api-contract.md @@ -94,6 +94,8 @@ GET /api/vehicles/coverage?keyword=粤AG18312&serviceStatus=degraded&limit=20&of Returns VIN-level source coverage rows for the vehicle service list. Each row includes the canonical vehicle-level `serviceStatus` so frontend, exports, and external integrations share the same health definition. `serviceStatus` accepts `healthy`, `degraded`, `offline`, and `identity_required`. +Coverage summary also exposes `noDataVehicles`, so UI can show vehicles that exist in identity binding but have no GB32960, JT808, or Yutong MQTT source evidence. `/api/vehicles/coverage?serviceStatus=no_data` returns those bound vehicles for follow-up source onboarding. + ### History Locations ```http diff --git a/vehicle-data-platform/docs/product-spec.md b/vehicle-data-platform/docs/product-spec.md index 03c6dfab..4c25c7ef 100644 --- a/vehicle-data-platform/docs/product-spec.md +++ b/vehicle-data-platform/docs/product-spec.md @@ -28,6 +28,8 @@ Vehicle service lists and dashboard previews should label protocol coverage as ` Dashboard and vehicle-list summary cards should expose both `单源车辆` and `多源车辆`. Single-source vehicles are operationally important because they cannot be cross-checked across sources, so they must be one-click filter targets rather than hidden behind the generic coverage filter. +`暂无来源车辆` is also a first-class governance entry. It represents bound vehicles that have no current GB32960, JT808, or Yutong MQTT source evidence, and should route directly to the `no_data` vehicle service filter. + ## Interaction Rules - Tables are the default data surface.