From 29f84099b2c46b2a385de4b599d678f836693d30 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 09:37:51 +0800 Subject: [PATCH] feat(platform): filter vehicles by archive status --- .../api/internal/platform/mysql_queries.go | 18 ++++++++++++++++++ .../internal/platform/query_builders_test.go | 16 ++++++++++++++++ .../apps/web/src/domain/appRoute.test.ts | 5 +++-- .../apps/web/src/domain/appRoute.ts | 1 + .../apps/web/src/pages/Vehicles.tsx | 13 ++++++++++++- .../apps/web/src/test/App.test.tsx | 6 +++++- 6 files changed, 55 insertions(+), 4 deletions(-) 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 ba925302..2c319c39 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -102,6 +102,15 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { case "unbound": having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0") } + switch strings.TrimSpace(query.Get("archiveStatus")) { + case "complete": + having = append(having, "v.vin IS NOT NULL AND v.vin <> ''") + having = append(having, "COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NOT NULL") + having = append(having, "NULLIF(MAX(NULLIF(b.phone, '')), '') IS NOT NULL") + having = append(having, "NULLIF(MAX(NULLIF(b.oem, '')), '') IS NOT NULL") + 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)") + } 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") @@ -191,6 +200,15 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery { case "unbound": having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0") } + switch strings.TrimSpace(query.Get("archiveStatus")) { + case "complete": + having = append(having, "v.vin IS NOT NULL AND v.vin <> ''") + having = append(having, "COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NOT NULL") + having = append(having, "NULLIF(MAX(NULLIF(b.phone, '')), '') IS NOT NULL") + having = append(having, "NULLIF(MAX(NULLIF(b.oem, '')), '') IS NOT NULL") + 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)") + } 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") 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 69f9b11f..86c90bca 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 @@ -117,6 +117,22 @@ func TestBuildVehicleCoverageSQLFiltersMissingProtocol(t *testing.T) { } } +func TestBuildVehicleCoverageSQLFiltersArchiveStatus(t *testing.T) { + query := url.Values{"archiveStatus": {"incomplete"}, "limit": {"8"}} + built := buildVehicleCoverageSQL(query) + for _, want := range []string{ + "HAVING", + "COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL", + "NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL", + "NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL", + "vehicle_coverage_count", + } { + if !strings.Contains(built.Text+built.CountText, want) { + t.Fatalf("archive status 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) 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 221e1e82..bebe15de 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts @@ -23,13 +23,14 @@ describe('parseAppHash', () => { }); test('parses vehicle list filters from hash query', () => { - expect(parseAppHash('#/vehicles?coverage=multi&serviceStatus=degraded&online=online&bindingStatus=bound&missingProtocol=YUTONG_MQTT')).toEqual({ + expect(parseAppHash('#/vehicles?coverage=multi&serviceStatus=degraded&online=online&bindingStatus=bound&archiveStatus=incomplete&missingProtocol=YUTONG_MQTT')).toEqual({ page: 'vehicles', filters: { coverage: 'multi', serviceStatus: 'degraded', online: 'online', bindingStatus: 'bound', + archiveStatus: 'incomplete', missingProtocol: 'YUTONG_MQTT' } }); @@ -61,7 +62,7 @@ describe('buildAppHash', () => { }); test('builds shareable vehicle list hash with filters', () => { - expect(buildAppHash({ page: 'vehicles', filters: { coverage: 'multi', serviceStatus: 'degraded', missingProtocol: 'YUTONG_MQTT' } })).toBe('#/vehicles?coverage=multi&serviceStatus=degraded&missingProtocol=YUTONG_MQTT'); + 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'); }); 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 a4fa9179..f064e67b 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.ts @@ -14,6 +14,7 @@ const filterKeys = [ 'serviceStatus', 'online', 'bindingStatus', + 'archiveStatus', '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 1d4c9468..9e971f74 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -63,6 +63,11 @@ const bindingStatusLabel: Record = { unbound: '未绑定' }; +const archiveStatusLabel: Record = { + complete: '完整', + incomplete: '不完整' +}; + function sourceDiagnosisText(row: VehicleCoverageRow) { const parts = [sourceEvidenceText(row)]; if ((row.missingProtocols ?? []).length > 0) { @@ -189,7 +194,8 @@ export function Vehicles({ filters.missingProtocol ? `缺失来源:${filters.missingProtocol}` : '', filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : '', filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '', - filters.bindingStatus ? `绑定:${bindingStatusLabel[filters.bindingStatus] ?? filters.bindingStatus}` : '' + filters.bindingStatus ? `绑定:${bindingStatusLabel[filters.bindingStatus] ?? filters.bindingStatus}` : '', + filters.archiveStatus ? `档案:${archiveStatusLabel[filters.archiveStatus] ?? filters.archiveStatus}` : '' ].filter(Boolean); const load = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { @@ -201,6 +207,7 @@ export function Vehicles({ if (values?.missingProtocol) params.set('missingProtocol', values.missingProtocol); 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?.serviceStatus) params.set('serviceStatus', values.serviceStatus); const summaryParams = new URLSearchParams(params); summaryParams.delete('limit'); @@ -344,6 +351,10 @@ 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 380819f9..21725554 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -806,7 +806,7 @@ test('filters vehicle list from action queue', async () => { }); test('shows and clears current vehicle service filters', async () => { - window.history.replaceState(null, '', '/#/vehicles?keyword=%E7%B2%A4A&protocol=JT808&coverage=multi&missingProtocol=YUTONG_MQTT&serviceStatus=degraded&online=online&bindingStatus=bound'); + window.history.replaceState(null, '', '/#/vehicles?keyword=%E7%B2%A4A&protocol=JT808&coverage=multi&missingProtocol=YUTONG_MQTT&serviceStatus=degraded&online=online&bindingStatus=bound&archiveStatus=incomplete'); const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const path = String(input); if (path.includes('/api/ops/health')) { @@ -866,6 +866,10 @@ test('shows and clears current vehicle service filters', async () => { expect(screen.getByText('服务状态:来源不完整')).toBeInTheDocument(); expect(screen.getByText('在线:在线')).toBeInTheDocument(); expect(screen.getByText('绑定:已绑定')).toBeInTheDocument(); + expect(screen.getByText('档案:不完整')).toBeInTheDocument(); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('archiveStatus=incomplete'), undefined); + }); fireEvent.click(screen.getByRole('button', { name: '清空筛选' })); await waitFor(() => {