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 16ca9ee6..b3dedf88 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -185,6 +185,9 @@ func TestHandlerVehicleCoverageSummary(t *testing.T) { if body.Data.OnlineVehicles != 1 || body.Data.UnboundVehicles != 0 { t.Fatalf("coverage summary should expose filtered online and binding totals, got %+v", body.Data) } + if len(body.Data.MissingSources) == 0 { + t.Fatalf("coverage summary should expose missing source counts, got %+v", body.Data) + } } func TestSplitCSVEmptyReturnsEmptySlice(t *testing.T) { 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 782a21a0..b6902c20 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -168,6 +168,15 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values summary.UnboundVehicles++ } } + for _, protocol := range canonicalVehicleProtocols { + missing := 0 + for _, row := range coverage.Items { + if !containsString(row.Protocols, protocol) { + missing++ + } + } + summary.MissingSources = append(summary.MissingSources, MissingSourceStat{Protocol: protocol, Count: missing}) + } return summary, nil } diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index a009f8fe..fd2733b1 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -65,12 +65,13 @@ type VehicleCoverageRow struct { } type VehicleCoverageSummary struct { - TotalVehicles int `json:"totalVehicles"` - OnlineVehicles int `json:"onlineVehicles"` - SingleSourceVehicles int `json:"singleSourceVehicles"` - MultiSourceVehicles int `json:"multiSourceVehicles"` - NoDataVehicles int `json:"noDataVehicles"` - UnboundVehicles int `json:"unboundVehicles"` + TotalVehicles int `json:"totalVehicles"` + OnlineVehicles int `json:"onlineVehicles"` + SingleSourceVehicles int `json:"singleSourceVehicles"` + MultiSourceVehicles int `json:"multiSourceVehicles"` + NoDataVehicles int `json:"noDataVehicles"` + UnboundVehicles int `json:"unboundVehicles"` + MissingSources []MissingSourceStat `json:"missingSources"` } type VehicleIdentityResolution struct { 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 8a024fcd..67c5c097 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -297,9 +297,30 @@ func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url. ); err != nil { return VehicleCoverageSummary{}, err } + missingSources, err := s.coverageMissingSourceStats(ctx, query) + if err != nil { + return VehicleCoverageSummary{}, err + } + summary.MissingSources = missingSources return summary, nil } +func (s *ProductionStore) coverageMissingSourceStats(ctx context.Context, query url.Values) ([]MissingSourceStat, error) { + out := make([]MissingSourceStat, 0, len(canonicalVehicleProtocols)) + for _, protocol := range canonicalVehicleProtocols { + next := copyValues(query) + next.Set("missingProtocol", protocol) + next.Set("limit", "1") + next.Set("offset", "0") + page, err := s.VehicleCoverage(ctx, next) + if err != nil { + return nil, err + } + out = append(out, MissingSourceStat{Protocol: protocol, Count: page.Total}) + } + return out, nil +} + func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { built := buildVehicleRealtimeSQL(query) total := 0 diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 9fd7580e..17cf645d 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -68,6 +68,7 @@ export interface VehicleCoverageSummary { multiSourceVehicles: number; noDataVehicles: number; unboundVehicles: number; + missingSources: MissingSourceStat[]; } export interface VehicleIdentityResolution { diff --git a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx index a47b1543..b2d65883 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -65,6 +65,14 @@ export function Vehicles({ { label: '暂无来源车辆', value: (summary?.noDataVehicles ?? 0).toLocaleString(), filters: { serviceStatus: 'no_data' } }, { label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } } ]; + for (const source of summary?.missingSources ?? []) { + if (source.count <= 0) continue; + items.push({ + label: `缺 ${source.protocol}`, + value: source.count.toLocaleString(), + filters: { missingProtocol: source.protocol } + }); + } return items; }, [pagination.total, summary]); 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 619ad9b7..7421d00c 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -427,7 +427,8 @@ test('filters vehicle list by missing source evidence', async () => { singleSourceVehicles: 0, multiSourceVehicles: 1, noDataVehicles: 0, - unboundVehicles: 0 + unboundVehicles: 0, + missingSources: [{ protocol: 'YUTONG_MQTT', count: 1 }] }, traceId: 'trace-test', timestamp: 1783094400000 @@ -477,6 +478,8 @@ test('filters vehicle list by missing source evidence', async () => { expect(screen.getAllByText('缺失来源').length).toBeGreaterThanOrEqual(2); }); expect(screen.getAllByText('缺 YUTONG_MQTT').length).toBeGreaterThanOrEqual(1); + fireEvent.click(screen.getByRole('button', { name: '缺 YUTONG_MQTT 1' })); + expect(window.location.hash).toBe('#/vehicles?missingProtocol=YUTONG_MQTT'); fireEvent.click(screen.getByRole('button', { name: '缺 YUTONG_MQTT' })); expect(window.location.hash).toBe('#/vehicles?missingProtocol=YUTONG_MQTT'); await waitFor(() => {