From b127b22a4b289ec6f294e6ba59be76eca860b05c Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 09:45:03 +0800 Subject: [PATCH] feat(platform): surface incomplete vehicle archives --- .../api/internal/platform/handler_test.go | 3 ++ .../apps/api/internal/platform/mock_store.go | 13 +++++++ .../apps/api/internal/platform/model.go | 36 ++++++++++--------- .../api/internal/platform/mysql_queries.go | 10 ++++-- .../api/internal/platform/production_store.go | 10 +++++- .../internal/platform/query_builders_test.go | 1 + .../apps/web/src/api/types.ts | 2 ++ .../apps/web/src/pages/Dashboard.tsx | 11 +++++- .../apps/web/src/pages/Vehicles.tsx | 7 +++- .../apps/web/src/test/App.test.tsx | 9 +++-- 10 files changed, 78 insertions(+), 24 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 645c036a..d2f72006 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -463,6 +463,9 @@ func TestHandlerVehicleServiceSummaryEndpoint(t *testing.T) { if body.Data.MultiSourceVehicles < 1 || body.Data.SingleSourceVehicles < 1 { t.Fatalf("summary should expose source coverage distribution, got %+v", body.Data) } + if body.Data.ArchiveIncompleteVehicles < 1 { + t.Fatalf("summary should expose incomplete archive vehicles, got %+v", body.Data) + } if len(body.Data.ServiceStatuses) == 0 || len(body.Data.Protocols) == 0 { t.Fatalf("summary should expose service status and protocol distributions, got %+v", body.Data) } 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 bb896106..b0e0b514 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -175,6 +175,9 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values if row.BindingStatus != "bound" { summary.UnboundVehicles++ } + if vehicleArchiveIncomplete(row) { + summary.ArchiveIncompleteVehicles++ + } } for _, protocol := range canonicalVehicleProtocols { missing := 0 @@ -211,6 +214,9 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu if row.BindingStatus == "bound" { summary.BoundVehicles++ } + if vehicleArchiveIncomplete(row) { + summary.ArchiveIncompleteVehicles++ + } if row.Online { summary.OnlineVehicles++ } @@ -275,6 +281,13 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu return summary, nil } +func vehicleArchiveIncomplete(row VehicleCoverageRow) bool { + return strings.TrimSpace(row.VIN) == "" || + strings.TrimSpace(row.Plate) == "" || + strings.TrimSpace(row.Phone) == "" || + strings.TrimSpace(row.OEM) == "" +} + 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 7f5d3c39..e92fd0b8 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -67,13 +67,14 @@ 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"` - 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"` } type VehicleIdentityResolution struct { @@ -143,16 +144,17 @@ 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"` - 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"` } type VehicleServiceStatus struct { 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 2c319c39..fabec789 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -238,7 +238,12 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery { 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 ` + + `MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) AS bound, ` + + `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 ` + `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 ` + @@ -250,7 +255,8 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery { `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 ` + + `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 ` + `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 12395b73..469c91df 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -125,6 +125,7 @@ COALESCE(SUM(CASE WHEN source_count = 1 THEN 1 ELSE 0 END), 0) AS single_source_ 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 identity_required_vehicles, +COALESCE(SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END), 0) AS archive_incomplete_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 @@ -132,7 +133,12 @@ FROM ( SELECT v.vin, MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) AS bound, 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 +COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, +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 FROM ( SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> '' UNION @@ -157,6 +163,7 @@ GROUP BY v.vin &summary.MultiSourceVehicles, &summary.NoDataVehicles, &summary.IdentityRequiredVehicles, + &summary.ArchiveIncompleteVehicles, &healthy, °raded, &offline, @@ -317,6 +324,7 @@ func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url. &summary.MultiSourceVehicles, &summary.NoDataVehicles, &summary.UnboundVehicles, + &summary.ArchiveIncompleteVehicles, ); 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 86c90bca..2ba79fb5 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 @@ -143,6 +143,7 @@ func TestBuildVehicleCoverageSummarySQL(t *testing.T) { "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)", + "SUM(CASE WHEN archive_complete = 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 932b7497..358403f8 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -70,6 +70,7 @@ export interface VehicleCoverageSummary { multiSourceVehicles: number; noDataVehicles: number; unboundVehicles: number; + archiveIncompleteVehicles: number; missingSources: MissingSourceStat[]; } @@ -147,6 +148,7 @@ export interface VehicleServiceSummary { multiSourceVehicles: number; noDataVehicles: number; identityRequiredVehicles: number; + archiveIncompleteVehicles: number; serviceStatuses: ServiceStatusStat[]; protocols: ProtocolStat[]; missingSources: MissingSourceStat[]; diff --git a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx index 1e17f722..6708c99e 100644 --- a/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Dashboard.tsx @@ -160,7 +160,8 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on { 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' } } + { label: '身份未绑定', value: formatCount(serviceSummary?.identityRequiredVehicles), filters: { serviceStatus: 'identity_required' } }, + { label: '档案不完整', value: formatCount(serviceSummary?.archiveIncompleteVehicles), filters: { archiveStatus: 'incomplete' } } ]; const missingSourceCounts = new Map((serviceSummary?.missingSources ?? []).map((item) => [item.protocol, item.count])); const serviceActionQueue = useMemo; detail: string }>>(() => { @@ -181,6 +182,14 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on detail: '已有数据但无法稳定归并到 VIN,会影响车辆服务聚合。' }); } + if ((serviceSummary?.archiveIncompleteVehicles ?? 0) > 0) { + items.push({ + label: '完善车辆档案', + count: serviceSummary?.archiveIncompleteVehicles ?? 0, + filters: { archiveStatus: 'incomplete' }, + detail: '车辆缺少车牌、手机号或 OEM 等基础档案,影响后续运营查询和治理。' + }); + } for (const source of serviceSummary?.missingSources ?? []) { if (source.count <= 0) continue; items.push({ diff --git a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx index 9e971f74..f40264eb 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -154,7 +154,8 @@ export function Vehicles({ { 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' } } + { label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } }, + { label: '档案不完整', value: (summary?.archiveIncompleteVehicles ?? 0).toLocaleString(), filters: { archiveStatus: 'incomplete' } } ]; for (const source of summary?.missingSources ?? []) { if (source.count <= 0) continue; @@ -176,6 +177,10 @@ export function Vehicles({ if (noDataCount > 0) { items.push({ label: '确认平台转发', count: noDataCount, filters: { serviceStatus: 'no_data' }, color: 'orange' }); } + const archiveIncompleteCount = summary?.archiveIncompleteVehicles ?? 0; + if (archiveIncompleteCount > 0) { + items.push({ label: '完善车辆档案', count: archiveIncompleteCount, filters: { archiveStatus: 'incomplete' }, color: 'orange' }); + } for (const source of summary?.missingSources ?? []) { if (source.count <= 0) continue; items.push({ 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 21725554..26a2e51b 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -92,6 +92,7 @@ test('dashboard renders vehicle service summary metrics', async () => { multiSourceVehicles: 181, noDataVehicles: 461, identityRequiredVehicles: 9, + archiveIncompleteVehicles: 366, serviceStatuses: [ { status: 'healthy', title: '服务正常', count: 161 }, { status: 'degraded', title: '来源不完整', count: 41 } @@ -128,6 +129,8 @@ test('dashboard renders vehicle service summary metrics', async () => { expect(screen.getByText('暂无来源车辆')).toBeInTheDocument(); expect(screen.getByText('461')).toBeInTheDocument(); expect(screen.getByText('身份未绑定')).toBeInTheDocument(); + expect(screen.getByText('档案不完整')).toBeInTheDocument(); + expect(screen.getByText('366')).toBeInTheDocument(); expect(screen.getByText('来源证据在线分布')).toBeInTheDocument(); expect(screen.getByText('YUTONG_MQTT')).toBeInTheDocument(); expect(screen.getByText('0%')).toBeInTheDocument(); @@ -252,6 +255,7 @@ test('dashboard shows vehicle service action queue', async () => { multiSourceVehicles: 181, noDataVehicles: 461, identityRequiredVehicles: 9, + archiveIncompleteVehicles: 366, serviceStatuses: [], protocols: [], missingSources: [{ protocol: 'YUTONG_MQTT', count: 983 }] @@ -276,11 +280,12 @@ test('dashboard shows vehicle service action queue', async () => { expect(await screen.findByText('车辆服务处置队列')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '确认平台转发 461' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '维护身份绑定 9' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '完善车辆档案 366' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '补齐 YUTONG_MQTT 来源 983' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '确认平台转发 461' })); + fireEvent.click(screen.getByRole('button', { name: '完善车辆档案 366' })); await waitFor(() => { - expect(window.location.hash).toBe('#/vehicles?serviceStatus=no_data'); + expect(window.location.hash).toBe('#/vehicles?archiveStatus=incomplete'); }); });