fix(mileage): bound daily date ranges

This commit is contained in:
lingniu
2026-07-16 08:44:00 +08:00
parent 91b5d76657
commit 99d9dcca1b
5 changed files with 72 additions and 13 deletions

View File

@@ -419,6 +419,21 @@ func TestNormalizeMileageStatisticsWindow(t *testing.T) {
}
}
func TestNormalizeDailyMileageWindowBoundsOnlyDateFilteredQueries(t *testing.T) {
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.Local)
unfiltered, err := normalizeDailyMileageWindow(url.Values{"limit": {"20"}}, now)
if err != nil || unfiltered.Get("dateFrom") != "" || unfiltered.Get("dateTo") != "" {
t.Fatalf("unfiltered daily mileage must stay compatible: %v, err=%v", unfiltered, err)
}
toOnly, err := normalizeDailyMileageWindow(url.Values{"dateTo": {"2026-07-14"}}, now)
if err != nil || toOnly.Get("dateFrom") != "2026-06-15" {
t.Fatalf("dateTo-only daily mileage should receive a bounded default start: %v, err=%v", toOnly, err)
}
if _, err := normalizeDailyMileageWindow(url.Values{"dateFrom": {"2025-01-01"}, "dateTo": {"2026-07-14"}}, now); err == nil {
t.Fatal("date-filtered daily mileage over 366 days must be rejected")
}
}
func TestNormalizeMileageVINSelection(t *testing.T) {
normalized, err := normalizeMileageVINSelection(url.Values{"vins": {" VIN001,VIN002,VIN001 "}})
if err != nil || normalized.Get("vins") != "VIN001,VIN002" {

View File

@@ -3177,6 +3177,10 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
if err != nil {
return Page[DailyMileageRow]{}, err
}
resolvedQuery, err = normalizeDailyMileageWindow(resolvedQuery, time.Now())
if err != nil {
return Page[DailyMileageRow]{}, err
}
return s.store.DailyMileage(ctx, resolvedQuery)
}
@@ -3243,6 +3247,13 @@ func normalizeMileageStatisticsWindow(query url.Values, now time.Time) (url.Valu
return next, nil
}
func normalizeDailyMileageWindow(query url.Values, now time.Time) (url.Values, error) {
if strings.TrimSpace(query.Get("dateFrom")) == "" && strings.TrimSpace(query.Get("dateTo")) == "" {
return cloneValues(query), nil
}
return normalizeMileageStatisticsWindow(query, now)
}
func (s *Service) OnlineStatisticsSummary(ctx context.Context, query url.Values) (OnlineStatisticsSummary, error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {

View File

@@ -56,6 +56,17 @@ test('renders only the desktop matrix with dates as columns and a period total',
expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT');
});
test('rejects overlong mileage ranges before querying or rendering a huge matrix', async () => {
prepareData();
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2025-01-01&dateTo=2026-07-14');
expect(await screen.findByRole('alert')).toHaveTextContent('单次最多查询 366 个自然日');
expect(mocks.mileageStatistics).not.toHaveBeenCalled();
expect(mocks.dailyMileage).not.toHaveBeenCalled();
expect(view.container.querySelectorAll('.v2-mileage-table th.is-date')).toHaveLength(0);
expect(screen.getByRole('button', { name: '导出 Excel' })).toBeDisabled();
});
test('removes the previous mileage scope while a new date range is loading', async () => {
prepareData();
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');

View File

@@ -16,6 +16,7 @@ const PAGE_SIZE = 20;
const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
const EXPORT_VIN_BATCH_SIZE = 50;
const MOBILE_MILEAGE_QUERY = '(max-width: 680px)';
const MAX_MILEAGE_RANGE_DAYS = 366;
type VehicleOption = Pick<VehicleRow, 'vin' | 'plate'>;
type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
@@ -46,9 +47,17 @@ function formatKm(value?: number) {
}
function inclusiveDays(dateFrom: string, dateTo: string) {
const from = Date.parse(`${dateFrom}T00:00:00`);
const to = Date.parse(`${dateTo}T00:00:00`);
return Number.isFinite(from) && Number.isFinite(to) ? Math.max(1, Math.round((to - from) / DAY) + 1) : 0;
const from = Date.parse(`${dateFrom}T00:00:00Z`);
const to = Date.parse(`${dateTo}T00:00:00Z`);
return Number.isFinite(from) && Number.isFinite(to) && to >= from ? Math.round((to - from) / DAY) + 1 : 0;
}
function mileageDateRangeError(criteria: Pick<Criteria, 'dateFrom' | 'dateTo'>) {
if (!criteria.dateFrom || !criteria.dateTo) return '请选择完整的开始和结束日期';
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
if (!days) return '结束日期不能早于开始日期';
if (days > MAX_MILEAGE_RANGE_DAYS) return `单次最多查询 ${MAX_MILEAGE_RANGE_DAYS} 个自然日,请缩小日期范围`;
return '';
}
function mileageParams(criteria: Criteria, offset = 0) {
@@ -219,9 +228,12 @@ function useMobileMileageLayout() {
function rangeDates(dateFrom: string, dateTo: string) {
const dates: string[] = [];
const cursor = new Date(`${dateFrom}T00:00:00`);
const end = new Date(`${dateTo}T00:00:00`);
while (cursor <= end) { dates.push(localDate(cursor)); cursor.setDate(cursor.getDate() + 1); }
const cursor = new Date(`${dateFrom}T00:00:00Z`);
const end = new Date(`${dateTo}T00:00:00Z`);
while (cursor <= end && dates.length < MAX_MILEAGE_RANGE_DAYS) {
dates.push(cursor.toISOString().slice(0, 10));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return dates;
}
@@ -257,6 +269,7 @@ export default function StatisticsPage() {
const [isExporting, setIsExporting] = useState(false);
const [exportFeedback, setExportFeedback] = useState('');
const [exportProgress, setExportProgress] = useState<ExportProgress>();
const [validationError, setValidationError] = useState('');
const exportControllerRef = useRef<AbortController | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
@@ -267,6 +280,7 @@ export default function StatisticsPage() {
};
}, []);
const hasVehicles = criteria.vehicles.length > 0;
const criteriaError = mileageDateRangeError(criteria);
const fleetParams = useMemo(() => new URLSearchParams({ limit: String(PAGE_SIZE), offset: String((page - 1) * PAGE_SIZE), bindingStatus: 'bound' }), [page]);
const fleetVehicles = useQuery({
queryKey: ['mileage-fleet-page', fleetParams.toString()],
@@ -282,9 +296,9 @@ export default function StatisticsPage() {
const statisticsScope = statisticsParams.toString();
const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]);
const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]);
const statistics = useQuery({ queryKey: ['mileage-statistics', statisticsScope], queryFn: ({ signal }) => api.mileageStatistics(statisticsParams, signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime });
const mileage = useQuery<Page<DailyMileageRow>>({ queryKey: ['daily-mileage-query', statisticsScope, rowsParams.toString()], queryFn: ({ signal }) => api.dailyMileage(rowsParams, signal), enabled: displayVehicles.length > 0, staleTime: 60_000, gcTime: QUERY_MEMORY.highVolumeGcTime, placeholderData: retainPreviousPageWithinScope<Page<DailyMileageRow>>(statisticsScope, 2) });
const dates = useMemo(() => rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo]);
const statistics = useQuery({ queryKey: ['mileage-statistics', statisticsScope], queryFn: ({ signal }) => api.mileageStatistics(statisticsParams, signal), enabled: !criteriaError, staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime });
const mileage = useQuery<Page<DailyMileageRow>>({ queryKey: ['daily-mileage-query', statisticsScope, rowsParams.toString()], queryFn: ({ signal }) => api.dailyMileage(rowsParams, signal), enabled: !criteriaError && displayVehicles.length > 0, staleTime: 60_000, gcTime: QUERY_MEMORY.highVolumeGcTime, placeholderData: retainPreviousPageWithinScope<Page<DailyMileageRow>>(statisticsScope, 2) });
const dates = useMemo(() => criteriaError ? [] : rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo, criteriaError]);
const mileageByVin = useMemo(() => {
const index = new Map<string, { plate: string; days: Map<string, number>; sources: Map<string, string> }>();
for (const row of mileage.data?.items ?? []) {
@@ -310,8 +324,14 @@ export default function StatisticsPage() {
}), [displayVehicles, mileageByVin, rankingByVin]);
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE));
const submit = (event: FormEvent) => { event.preventDefault(); setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true }); };
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); };
const submit = (event: FormEvent) => {
event.preventDefault();
const error = mileageDateRangeError(draft);
setValidationError(error);
if (error) return;
setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(mileageParams(draft, -1), { replace: true });
};
const setDays = (days: number) => { const range = defaultWindow(days); const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(mileageParams(next, -1), { replace: true }); };
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
const resultsLoading = fleetVehicles.isLoading || mileage.isLoading || mileage.isPlaceholderData;
const exportPercent = exportProgress?.total
@@ -319,7 +339,7 @@ export default function StatisticsPage() {
: undefined;
const exportExcel = async () => {
if (exportControllerRef.current || !totalVehicles) return;
if (exportControllerRef.current || !totalVehicles || criteriaError) return;
const controller = new AbortController();
exportControllerRef.current = controller;
const reportProgress = (progress: ExportProgress) => {
@@ -406,11 +426,12 @@ export default function StatisticsPage() {
<SourceStrategy value={draft.sources} onChange={(sources) => setDraft((current) => ({ ...current, sources }))} />
<div className="v2-mileage-ranges"><span></span><button type="button" onClick={() => setDays(7)}> 7 </button><button type="button" onClick={() => setDays(30)}> 30 </button><button type="button" onClick={() => setDays(90)}> 90 </button></div>
</form>
{validationError || criteriaError ? <p className="v2-mileage-validation" role="alert">{validationError || criteriaError}</p> : null}
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
</section>
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
<section className="v2-mileage-results">
<header><div><strong></strong><span>{criteria.dateFrom} {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles}`} · {dates.length} </em><button type="button" aria-label={isExporting ? '取消导出' : '导出 Excel'} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles}>{isExporting ? <IconClose /> : <IconDownload />}{isExporting ? '取消导出' : '导出 Excel'}</button></div></header>
<header><div><strong></strong><span>{criteria.dateFrom} {criteria.dateTo}</span></div><div className="v2-mileage-result-actions"><em>{hasVehicles ? `${totalVehicles} 辆车` : `当前 ${displayVehicles.length} 辆 / 共 ${totalVehicles}`} · {dates.length} </em><button type="button" aria-label={isExporting ? '取消导出' : '导出 Excel'} onClick={isExporting ? cancelExport : exportExcel} disabled={!totalVehicles || Boolean(criteriaError)}>{isExporting ? <IconClose /> : <IconDownload />}{isExporting ? '取消导出' : '导出 Excel'}</button></div></header>
{exportProgress ? <div className="v2-mileage-export-progress" role="progressbar" aria-label={exportProgress.label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={exportPercent}>
<span><strong>{exportProgress.label}</strong><small>{exportPercent == null ? '处理中' : `${exportPercent}%`}</small></span>
<i className={exportPercent == null ? 'is-indeterminate' : ''}><b style={exportPercent == null ? undefined : { width: `${exportPercent}%` }} /></i>

View File

@@ -993,6 +993,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-mileage-filter > label > input { width: 100%; height: 40px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; padding: 0 11px; color: #253248; outline: 0; font-family: inherit; font-size: 13px; transition: border-color .16s ease, box-shadow .16s ease; }
.v2-mileage-filter > label > input:focus, .v2-mileage-multiselect:focus-within { border-color: #6d9ff1; box-shadow: 0 0 0 3px rgba(37,99,235,.08); }
.v2-mileage-filter > .v2-primary-button { height: 40px; margin-top: 18px; border-radius: 7px; padding: 0 22px; box-shadow: 0 4px 10px rgba(37,99,235,.16); font-size: 12px; }
.v2-mileage-validation { margin: -5px 20px 14px; border: 1px solid #f1c5c8; border-radius: 7px; background: #fff7f7; padding: 9px 11px; color: #b54046; font-size: 11px; line-height: 1.5; }
.v2-mileage-vehicle-field { position: relative; }
.v2-mileage-vehicle-field > small { color: #96a1b0; font-size: 9px; font-weight: 400; }
.v2-mileage-multiselect { position: relative; display: flex; min-height: 40px; align-items: flex-start; gap: 8px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; padding: 6px 10px; color: #8a98aa; transition: border-color .16s ease, box-shadow .16s ease; }