feat(platform): consolidate production vehicle data workflows

This commit is contained in:
lingniu
2026-07-15 23:26:29 +08:00
parent 6cddc0a43d
commit 3fabcf181a
59 changed files with 6849 additions and 3532 deletions

View File

@@ -1,30 +1,126 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import StatisticsPage from './StatisticsPage';
const mileageStatistics = vi.hoisted(() => vi.fn());
vi.mock('../../api/client', () => ({ api: { mileageStatistics } }));
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() }));
const exportMocks = vi.hoisted(() => ({ downloadMileageWorkbook: vi.fn() }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../domain/mileageExport', () => exportMocks);
afterEach(() => { cleanup(); mileageStatistics.mockReset(); });
afterEach(() => { cleanup(); window.localStorage.clear(); Object.values(mocks).forEach((mock) => mock.mockReset()); exportMocks.downloadMileageWorkbook.mockReset(); });
test('shows distinct period and latest fleet mileage metrics with exact daily evidence', async () => {
mileageStatistics.mockResolvedValue({
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 2, recordCount: 2, sourceCount: 2,
periodMileageKm: 193.3, fleetLatestMileageKm: 168723.9, averageMileagePerVin: 96.65, averageDailyMileageKm: 96.65,
trend: [{ date: '2026-07-14', mileageKm: 193.3, vehicles: 2 }],
ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 104.6, latestMileageKm: 119925, activeDays: 1 }],
function renderPage(initialEntry = '/statistics') {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[initialEntry]}><StatisticsPage /></MemoryRouter></QueryClientProvider>);
}
function prepareData() {
mocks.mileageStatistics.mockResolvedValue({
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2,
periodMileageKm: 193.3, fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65,
trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }],
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter><StatisticsPage /></MemoryRouter></QueryClientProvider>);
mocks.dailyMileage.mockResolvedValue({ items: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, source: 'GB32960' }
], total: 2, limit: 10000, offset: 0 });
mocks.vehicles.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocol: 'GB32960', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 });
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
}
test('renders one vehicle per row with dates as columns and a period total', async () => {
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
expect(await screen.findByText('车辆每日里程')).toBeInTheDocument();
expect(screen.getAllByText('区间总里程').length).toBeGreaterThan(1);
expect((await screen.findAllByText('193.3 km')).length).toBeGreaterThan(0);
expect(screen.getByText('168,723.9 km')).toBeInTheDocument();
expect(screen.getByText('按车辆与自然日去重')).toBeInTheDocument();
expect(screen.getByText('粤A12345')).toHaveAttribute('href', '/vehicles/LTEST000000000001');
expect(screen.getByRole('img', { name: '每日行驶里程趋势图' })).toBeInTheDocument();
await waitFor(() => expect(mileageStatistics).toHaveBeenCalledTimes(1));
expect(mileageStatistics.mock.calls[0][0].get('dateFrom')).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(screen.getAllByText('104.6 km').length).toBeGreaterThan(0);
expect(screen.getAllByText('88.7 km').length).toBeGreaterThan(0);
expect(screen.getAllByText('7/13').length).toBeGreaterThan(0);
expect(screen.getAllByText('7/14').length).toBeGreaterThan(0);
expect(screen.getAllByText('粤A12345').length).toBeGreaterThan(0);
expect(screen.queryByText('当日起始里程')).not.toBeInTheDocument();
expect(screen.queryByText('数据来源')).not.toBeInTheDocument();
expect(screen.queryByRole('img', { name: '每日行驶里程趋势图' })).not.toBeInTheDocument();
expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument();
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000');
expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT');
});
test('supports searching and selecting license plates before querying exact VINs', async () => {
prepareData();
renderPage();
const search = screen.getByRole('textbox', { name: '搜索车牌' });
fireEvent.focus(search);
fireEvent.change(search, { target: { value: '粤A12' } });
expect(await screen.findByRole('option', { name: /粤A12345/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('option', { name: /粤A12345/ }));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => {
const calls = mocks.mileageStatistics.mock.calls;
expect(calls[calls.length - 1]?.[0].get('vins')).toBe('LTEST000000000001');
});
expect(screen.getByTitle('LTEST000000000001')).toHaveTextContent('粤A12345');
});
test('lets users disable mileage sources and persists the source priority', async () => {
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument();
expect(screen.getByText('GPS 里程')).toBeInTheDocument();
expect(screen.getAllByText('仪表盘里程')).toHaveLength(2);
fireEvent.click(screen.getByRole('switch', { name: '禁用 国标 GB32960' }));
fireEvent.click(screen.getByRole('button', { name: '上移 宇通 MQTT' }));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => {
const calls = mocks.dailyMileage.mock.calls;
expect(calls[calls.length - 1]?.[0].get('protocols')).toBe('YUTONG_MQTT,JT808');
});
expect(window.localStorage.getItem('vehicle-platform:mileage-source-strategy')).toContain('YUTONG_MQTT');
expect(screen.getByText('来源优先级YUTONG_MQTT JT808')).toBeInTheDocument();
});
test('paginates all unique vehicles when no license plate is selected', async () => {
prepareData();
const firstPage = Array.from({ length: 20 }, (_, index) => ({ vin: `VIN${String(index + 1).padStart(14, '0')}`, plate: `粤A${String(index + 1).padStart(5, '0')}` }));
const secondPage = Array.from({ length: 12 }, (_, index) => ({ vin: `VIN${String(index + 21).padStart(14, '0')}`, plate: `粤A${String(index + 21).padStart(5, '0')}` }));
mocks.vehicleCoverage.mockImplementation((params: URLSearchParams) => Promise.resolve({
items: params.get('offset') === '20' ? secondPage : firstPage,
total: 32,
limit: 20,
offset: Number(params.get('offset') ?? 0)
}));
mocks.dailyMileage.mockResolvedValue({ items: [], total: 0, limit: 10000, offset: 0 });
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0);
expect(screen.getByText('第 1 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001'));
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
expect((await screen.findAllByText('粤A00021')).length).toBeGreaterThan(0);
expect(screen.getByText('第 2 / 2 页 · 共 32 辆 · 每页 20 辆')).toBeInTheDocument();
await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20'));
});
test('exports the full fleet mileage with one paginated query instead of VIN batches', async () => {
prepareData();
exportMocks.downloadMileageWorkbook.mockResolvedValue(undefined);
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
await waitFor(() => expect(screen.getByRole('button', { name: /导出 Excel/ })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ }));
await waitFor(() => expect(exportMocks.downloadMileageWorkbook).toHaveBeenCalledTimes(1));
const exportMileageCall = mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][0] as URLSearchParams;
expect(exportMileageCall.get('vins')).toBeNull();
expect(exportMileageCall.get('vehicleScope')).toBe('bound');
expect(exportMocks.downloadMileageWorkbook.mock.calls[0][0].vehicles).toHaveLength(1);
expect(screen.getByText(/已导出 1 辆车/)).toBeInTheDocument();
});