From b5a72ce0ec589a009e851ee19642a38f94645616 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 09:50:41 +0800 Subject: [PATCH] feat(platform): filter vehicle archive gaps --- .../api/internal/platform/handler_test.go | 3 ++ .../apps/api/internal/platform/mock_store.go | 40 ++++++++++++++++ .../apps/api/internal/platform/model.go | 46 +++++++++++-------- .../api/internal/platform/mysql_queries.go | 29 +++++++++++- .../api/internal/platform/production_store.go | 18 +++++++- .../internal/platform/query_builders_test.go | 19 +++++++- .../apps/web/src/api/types.ts | 8 ++++ .../apps/web/src/domain/appRoute.test.ts | 5 +- .../apps/web/src/domain/appRoute.ts | 1 + .../apps/web/src/pages/Vehicles.tsx | 26 ++++++++++- .../apps/web/src/test/App.test.tsx | 5 ++ 11 files changed, 174 insertions(+), 26 deletions(-) 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 d2f72006..125b9fe8 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -208,6 +208,9 @@ func TestHandlerVehicleCoverageSummary(t *testing.T) { if len(body.Data.MissingSources) == 0 { t.Fatalf("coverage summary should expose missing source counts, got %+v", body.Data) } + if len(body.Data.ArchiveMissingFields) == 0 { + t.Fatalf("coverage summary should expose archive missing-field 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 b0e0b514..7f1f2c42 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -159,6 +159,7 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values return VehicleCoverageSummary{}, err } summary := VehicleCoverageSummary{TotalVehicles: coverage.Total} + missingFieldCounts := map[string]int{} for _, row := range coverage.Items { if row.Online { summary.OnlineVehicles++ @@ -178,6 +179,9 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values if vehicleArchiveIncomplete(row) { summary.ArchiveIncompleteVehicles++ } + for _, field := range vehicleArchiveMissingFields(row) { + missingFieldCounts[field]++ + } } for _, protocol := range canonicalVehicleProtocols { missing := 0 @@ -188,6 +192,11 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values } summary.MissingSources = append(summary.MissingSources, MissingSourceStat{Protocol: protocol, Count: missing}) } + summary.ArchiveMissingFields = buildArchiveMissingFieldStats( + missingFieldCounts["plate"], + missingFieldCounts["phone"], + missingFieldCounts["oem"], + ) return summary, nil } @@ -210,6 +219,7 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu "identity_required": {Status: "identity_required", Title: "身份未绑定"}, } protocolCounts := map[string]*ProtocolStat{} + missingFieldCounts := map[string]int{} for _, row := range coverage.Items { if row.BindingStatus == "bound" { summary.BoundVehicles++ @@ -217,6 +227,9 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu if vehicleArchiveIncomplete(row) { summary.ArchiveIncompleteVehicles++ } + for _, field := range vehicleArchiveMissingFields(row) { + missingFieldCounts[field]++ + } if row.Online { summary.OnlineVehicles++ } @@ -277,6 +290,11 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu } summary.MissingSources = append(summary.MissingSources, MissingSourceStat{Protocol: protocol, Count: missing}) } + summary.ArchiveMissingFields = buildArchiveMissingFieldStats( + missingFieldCounts["plate"], + missingFieldCounts["phone"], + missingFieldCounts["oem"], + ) sort.Slice(summary.ServiceStatuses, func(i, j int) bool { return summary.ServiceStatuses[i].Status < summary.ServiceStatuses[j].Status }) return summary, nil } @@ -288,6 +306,28 @@ func vehicleArchiveIncomplete(row VehicleCoverageRow) bool { strings.TrimSpace(row.OEM) == "" } +func vehicleArchiveMissingFields(row VehicleCoverageRow) []string { + fields := []string{} + if strings.TrimSpace(row.Plate) == "" { + fields = append(fields, "plate") + } + if strings.TrimSpace(row.Phone) == "" { + fields = append(fields, "phone") + } + if strings.TrimSpace(row.OEM) == "" { + fields = append(fields, "oem") + } + return fields +} + +func buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM int) []ArchiveMissingFieldStat { + return []ArchiveMissingFieldStat{ + {Field: "plate", Title: "缺车牌", Count: missingPlate}, + {Field: "phone", Title: "缺手机号", Count: missingPhone}, + {Field: "oem", Title: "缺OEM", Count: missingOEM}, + } +} + func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { rows := m.locations if vin := strings.TrimSpace(query.Get("vin")); vin != "" { diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index e92fd0b8..0270fbaa 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -67,14 +67,15 @@ 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"` - ArchiveIncompleteVehicles int `json:"archiveIncompleteVehicles"` - MissingSources []MissingSourceStat `json:"missingSources"` + TotalVehicles int `json:"totalVehicles"` + OnlineVehicles int `json:"onlineVehicles"` + SingleSourceVehicles int `json:"singleSourceVehicles"` + MultiSourceVehicles int `json:"multiSourceVehicles"` + NoDataVehicles int `json:"noDataVehicles"` + UnboundVehicles int `json:"unboundVehicles"` + ArchiveIncompleteVehicles int `json:"archiveIncompleteVehicles"` + MissingSources []MissingSourceStat `json:"missingSources"` + ArchiveMissingFields []ArchiveMissingFieldStat `json:"archiveMissingFields"` } type VehicleIdentityResolution struct { @@ -144,17 +145,18 @@ type VehicleServiceOverview struct { } type VehicleServiceSummary struct { - TotalVehicles int `json:"totalVehicles"` - BoundVehicles int `json:"boundVehicles"` - OnlineVehicles int `json:"onlineVehicles"` - SingleSourceVehicles int `json:"singleSourceVehicles"` - MultiSourceVehicles int `json:"multiSourceVehicles"` - NoDataVehicles int `json:"noDataVehicles"` - IdentityRequiredVehicles int `json:"identityRequiredVehicles"` - ArchiveIncompleteVehicles int `json:"archiveIncompleteVehicles"` - ServiceStatuses []ServiceStatusStat `json:"serviceStatuses"` - Protocols []ProtocolStat `json:"protocols"` - MissingSources []MissingSourceStat `json:"missingSources"` + TotalVehicles int `json:"totalVehicles"` + BoundVehicles int `json:"boundVehicles"` + OnlineVehicles int `json:"onlineVehicles"` + SingleSourceVehicles int `json:"singleSourceVehicles"` + MultiSourceVehicles int `json:"multiSourceVehicles"` + NoDataVehicles int `json:"noDataVehicles"` + IdentityRequiredVehicles int `json:"identityRequiredVehicles"` + ArchiveIncompleteVehicles int `json:"archiveIncompleteVehicles"` + ServiceStatuses []ServiceStatusStat `json:"serviceStatuses"` + Protocols []ProtocolStat `json:"protocols"` + MissingSources []MissingSourceStat `json:"missingSources"` + ArchiveMissingFields []ArchiveMissingFieldStat `json:"archiveMissingFields"` } type VehicleServiceStatus struct { @@ -171,6 +173,12 @@ type MissingSourceStat struct { Count int `json:"count"` } +type ArchiveMissingFieldStat struct { + Field string `json:"field"` + Title string `json:"title"` + Count int `json:"count"` +} + type VehicleSourceStatus struct { Protocol string `json:"protocol"` Online bool `json:"online"` 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 fabec789..caa0a78e 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -111,6 +111,9 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { case "incomplete": having = append(having, "(v.vin IS NULL OR v.vin = '' OR COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL OR NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL OR NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL)") } + if predicate := archiveMissingFieldPredicate(query.Get("archiveMissing")); predicate != "" { + having = append(having, predicate) + } 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") @@ -209,6 +212,9 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery { case "incomplete": having = append(having, "(v.vin IS NULL OR v.vin = '' OR COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL OR NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL OR NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL)") } + if predicate := archiveMissingFieldPredicate(query.Get("archiveMissing")); predicate != "" { + having = append(having, predicate) + } 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") @@ -243,7 +249,10 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery { `AND COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NOT NULL ` + `AND NULLIF(MAX(NULLIF(b.phone, '')), '') IS NOT NULL ` + `AND NULLIF(MAX(NULLIF(b.oem, '')), '') IS NOT NULL ` + - `THEN 1 ELSE 0 END AS archive_complete ` + + `THEN 1 ELSE 0 END AS archive_complete, ` + + `CASE WHEN COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL THEN 1 ELSE 0 END AS missing_plate, ` + + `CASE WHEN NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL THEN 1 ELSE 0 END AS missing_phone, ` + + `CASE WHEN NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL THEN 1 ELSE 0 END AS missing_oem ` + `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 ` + @@ -256,12 +265,28 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery { `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, ` + - `COALESCE(SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END), 0) AS archive_incomplete_vehicles ` + + `COALESCE(SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END), 0) AS archive_incomplete_vehicles, ` + + `COALESCE(SUM(CASE WHEN missing_plate = 1 THEN 1 ELSE 0 END), 0) AS missing_plate_vehicles, ` + + `COALESCE(SUM(CASE WHEN missing_phone = 1 THEN 1 ELSE 0 END), 0) AS missing_phone_vehicles, ` + + `COALESCE(SUM(CASE WHEN missing_oem = 1 THEN 1 ELSE 0 END), 0) AS missing_oem_vehicles ` + `FROM (` + groupSQL + `) vehicle_coverage_summary`, Args: args, } } +func archiveMissingFieldPredicate(field string) string { + switch strings.TrimSpace(field) { + case "plate": + return "COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL" + case "phone": + return "NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL" + case "oem": + return "NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL" + default: + return "" + } +} + func buildRealtimeLocationSQL(query url.Values) SQLQuery { limit := parsePositive(query.Get("limit"), 20) offset := parsePositive(query.Get("offset"), 0) 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 469c91df..aa298572 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -126,6 +126,9 @@ COALESCE(SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END), 0) AS multi_source_v 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 identity_required_vehicles, COALESCE(SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END), 0) AS archive_incomplete_vehicles, +COALESCE(SUM(CASE WHEN missing_plate = 1 THEN 1 ELSE 0 END), 0) AS missing_plate_vehicles, +COALESCE(SUM(CASE WHEN missing_phone = 1 THEN 1 ELSE 0 END), 0) AS missing_phone_vehicles, +COALESCE(SUM(CASE WHEN missing_oem = 1 THEN 1 ELSE 0 END), 0) AS missing_oem_vehicles, COALESCE(SUM(CASE WHEN bound = 1 AND source_count = `+canonicalSourceCount+` AND online_source_count = source_count THEN 1 ELSE 0 END), 0) AS healthy_vehicles, COALESCE(SUM(CASE WHEN bound = 1 AND source_count > 0 AND online_source_count > 0 AND (online_source_count < source_count OR source_count < `+canonicalSourceCount+`) THEN 1 ELSE 0 END), 0) AS degraded_vehicles, COALESCE(SUM(CASE WHEN bound = 1 AND source_count > 0 AND online_source_count = 0 THEN 1 ELSE 0 END), 0) AS offline_vehicles @@ -138,7 +141,10 @@ CASE WHEN v.vin IS NOT NULL AND v.vin <> '' AND COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NOT NULL AND NULLIF(MAX(NULLIF(b.phone, '')), '') IS NOT NULL AND NULLIF(MAX(NULLIF(b.oem, '')), '') IS NOT NULL -THEN 1 ELSE 0 END AS archive_complete +THEN 1 ELSE 0 END AS archive_complete, +CASE WHEN COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL THEN 1 ELSE 0 END AS missing_plate, +CASE WHEN NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL THEN 1 ELSE 0 END AS missing_phone, +CASE WHEN NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL THEN 1 ELSE 0 END AS missing_oem FROM ( SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> '' UNION @@ -154,6 +160,7 @@ GROUP BY v.vin defer rows.Close() var summary VehicleServiceSummary var healthy, degraded, offline int + var missingPlate, missingPhone, missingOEM int if rows.Next() { if err := rows.Scan( &summary.TotalVehicles, @@ -164,6 +171,9 @@ GROUP BY v.vin &summary.NoDataVehicles, &summary.IdentityRequiredVehicles, &summary.ArchiveIncompleteVehicles, + &missingPlate, + &missingPhone, + &missingOEM, &healthy, °raded, &offline, @@ -181,6 +191,7 @@ GROUP BY v.vin {Status: "no_data", Title: "暂无数据来源", Count: summary.NoDataVehicles}, {Status: "identity_required", Title: "身份未绑定", Count: summary.IdentityRequiredVehicles}, } + summary.ArchiveMissingFields = buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM) protocols, err := s.protocolStats(ctx) if err != nil { return VehicleServiceSummary{}, err @@ -317,6 +328,7 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values) func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) { built := buildVehicleCoverageSummarySQL(query) var summary VehicleCoverageSummary + var missingPlate, missingPhone, missingOEM int if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan( &summary.TotalVehicles, &summary.OnlineVehicles, @@ -325,9 +337,13 @@ func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url. &summary.NoDataVehicles, &summary.UnboundVehicles, &summary.ArchiveIncompleteVehicles, + &missingPlate, + &missingPhone, + &missingOEM, ); err != nil { return VehicleCoverageSummary{}, err } + summary.ArchiveMissingFields = buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM) missingSources, err := s.coverageMissingSourceStats(ctx, query) if 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 2ba79fb5..316d3361 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 @@ -133,8 +133,22 @@ func TestBuildVehicleCoverageSQLFiltersArchiveStatus(t *testing.T) { } } +func TestBuildVehicleCoverageSQLFiltersArchiveMissingField(t *testing.T) { + query := url.Values{"archiveMissing": {"phone"}, "limit": {"8"}} + built := buildVehicleCoverageSQL(query) + for _, want := range []string{ + "HAVING", + "NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL", + "vehicle_coverage_count", + } { + if !strings.Contains(built.Text+built.CountText, want) { + t.Fatalf("archive missing-field 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"}} + query := url.Values{"keyword": {"粤A"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "serviceStatus": {"healthy"}, "archiveMissing": {"oem"}} built := buildVehicleCoverageSummarySQL(query) for _, want := range []string{ "vehicle_realtime_snapshot", @@ -144,8 +158,11 @@ func TestBuildVehicleCoverageSummarySQL(t *testing.T) { "SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END)", "SUM(CASE WHEN source_count = 0 THEN 1 ELSE 0 END)", "SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END)", + "SUM(CASE WHEN missing_phone = 1 THEN 1 ELSE 0 END)", + "SUM(CASE WHEN missing_oem = 1 THEN 1 ELSE 0 END)", "HAVING", "COUNT(DISTINCT s.protocol) > 1", + "NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL", } { if !strings.Contains(built.Text, want) { t.Fatalf("summary SQL missing %q: %s", want, built.Text) diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 358403f8..8741dab8 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -72,6 +72,7 @@ export interface VehicleCoverageSummary { unboundVehicles: number; archiveIncompleteVehicles: number; missingSources: MissingSourceStat[]; + archiveMissingFields: ArchiveMissingFieldStat[]; } export interface VehicleIdentityResolution { @@ -152,6 +153,7 @@ export interface VehicleServiceSummary { serviceStatuses: ServiceStatusStat[]; protocols: ProtocolStat[]; missingSources: MissingSourceStat[]; + archiveMissingFields: ArchiveMissingFieldStat[]; } export interface MissingSourceStat { @@ -159,6 +161,12 @@ export interface MissingSourceStat { count: number; } +export interface ArchiveMissingFieldStat { + field: string; + title: string; + count: number; +} + export interface VehicleServiceStatus { status: string; severity: string; diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts index bebe15de..c52ea507 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts @@ -23,7 +23,7 @@ describe('parseAppHash', () => { }); test('parses vehicle list filters from hash query', () => { - expect(parseAppHash('#/vehicles?coverage=multi&serviceStatus=degraded&online=online&bindingStatus=bound&archiveStatus=incomplete&missingProtocol=YUTONG_MQTT')).toEqual({ + expect(parseAppHash('#/vehicles?coverage=multi&serviceStatus=degraded&online=online&bindingStatus=bound&archiveStatus=incomplete&archiveMissing=phone&missingProtocol=YUTONG_MQTT')).toEqual({ page: 'vehicles', filters: { coverage: 'multi', @@ -31,6 +31,7 @@ describe('parseAppHash', () => { online: 'online', bindingStatus: 'bound', archiveStatus: 'incomplete', + archiveMissing: 'phone', missingProtocol: 'YUTONG_MQTT' } }); @@ -62,7 +63,7 @@ describe('buildAppHash', () => { }); test('builds shareable vehicle list hash with filters', () => { - expect(buildAppHash({ page: 'vehicles', filters: { coverage: 'multi', serviceStatus: 'degraded', archiveStatus: 'incomplete', missingProtocol: 'YUTONG_MQTT' } })).toBe('#/vehicles?coverage=multi&serviceStatus=degraded&archiveStatus=incomplete&missingProtocol=YUTONG_MQTT'); + expect(buildAppHash({ page: 'vehicles', filters: { coverage: 'multi', serviceStatus: 'degraded', archiveStatus: 'incomplete', archiveMissing: 'phone', missingProtocol: 'YUTONG_MQTT' } })).toBe('#/vehicles?coverage=multi&serviceStatus=degraded&archiveStatus=incomplete&archiveMissing=phone&missingProtocol=YUTONG_MQTT'); }); test('builds shareable quality hash with filters', () => { diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.ts index f064e67b..95bb36bb 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.ts @@ -15,6 +15,7 @@ const filterKeys = [ 'online', 'bindingStatus', 'archiveStatus', + 'archiveMissing', 'missingProtocol', 'issueType', 'tab', diff --git a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx index f40264eb..79b93a4b 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -67,6 +67,11 @@ const archiveStatusLabel: Record = { complete: '完整', incomplete: '不完整' }; +const archiveMissingLabel: Record = { + plate: '缺车牌', + phone: '缺手机号', + oem: '缺OEM' +}; function sourceDiagnosisText(row: VehicleCoverageRow) { const parts = [sourceEvidenceText(row)]; @@ -157,6 +162,14 @@ export function Vehicles({ { label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } }, { label: '档案不完整', value: (summary?.archiveIncompleteVehicles ?? 0).toLocaleString(), filters: { archiveStatus: 'incomplete' } } ]; + for (const field of summary?.archiveMissingFields ?? []) { + if (field.count <= 0) continue; + items.push({ + label: field.title, + value: field.count.toLocaleString(), + filters: { archiveMissing: field.field } + }); + } for (const source of summary?.missingSources ?? []) { if (source.count <= 0) continue; items.push({ @@ -181,6 +194,10 @@ export function Vehicles({ if (archiveIncompleteCount > 0) { items.push({ label: '完善车辆档案', count: archiveIncompleteCount, filters: { archiveStatus: 'incomplete' }, color: 'orange' }); } + for (const field of summary?.archiveMissingFields ?? []) { + if (field.count <= 0) continue; + items.push({ label: `补齐${field.title}`, count: field.count, filters: { archiveMissing: field.field }, color: 'orange' }); + } for (const source of summary?.missingSources ?? []) { if (source.count <= 0) continue; items.push({ @@ -200,7 +217,8 @@ export function Vehicles({ filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : '', filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '', filters.bindingStatus ? `绑定:${bindingStatusLabel[filters.bindingStatus] ?? filters.bindingStatus}` : '', - filters.archiveStatus ? `档案:${archiveStatusLabel[filters.archiveStatus] ?? filters.archiveStatus}` : '' + filters.archiveStatus ? `档案:${archiveStatusLabel[filters.archiveStatus] ?? filters.archiveStatus}` : '', + filters.archiveMissing ? `档案缺项:${archiveMissingLabel[filters.archiveMissing] ?? filters.archiveMissing}` : '' ].filter(Boolean); const load = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { @@ -213,6 +231,7 @@ export function Vehicles({ if (values?.online) params.set('online', values.online); if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus); if (values?.archiveStatus) params.set('archiveStatus', values.archiveStatus); + if (values?.archiveMissing) params.set('archiveMissing', values.archiveMissing); if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus); const summaryParams = new URLSearchParams(params); summaryParams.delete('limit'); @@ -360,6 +379,11 @@ export function Vehicles({ 完整 不完整 + + 缺车牌 + 缺手机号 + 缺OEM + 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 26a2e51b..c003a420 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -594,6 +594,8 @@ test('shows vehicle service result summary on vehicle list filters', async () => multiSourceVehicles: 181, noDataVehicles: 4, unboundVehicles: 9, + archiveIncompleteVehicles: 12, + archiveMissingFields: [{ field: 'phone', title: '缺手机号', count: 7 }], missingSources: [{ protocol: 'YUTONG_MQTT', count: 181 }] }, traceId: 'trace-test', @@ -661,9 +663,12 @@ test('shows vehicle service result summary on vehicle list filters', async () => expect(screen.getByText('单源车辆')).toBeInTheDocument(); expect(screen.getByText('暂无来源车辆')).toBeInTheDocument(); expect(screen.getByText('待绑定')).toBeInTheDocument(); + expect(screen.getByText('档案不完整')).toBeInTheDocument(); + expect(screen.getByText('缺手机号')).toBeInTheDocument(); expect(screen.getByText('处置队列')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '维护身份绑定 9' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '确认平台转发 4' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '补齐缺手机号 7' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '补齐 YUTONG_MQTT 来源 181' })).toBeInTheDocument(); expect(screen.getByText('档案完整度')).toBeInTheDocument(); expect(screen.getByText('4/4')).toBeInTheDocument();