From 4e4f383988242f7cc3ad2620f183aa9ddf10c408 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sun, 19 Jul 2026 12:32:01 +0800 Subject: [PATCH] fix: distinguish track mileage evidence --- .../apps/api/internal/platform/mock_store.go | 2 +- .../apps/api/internal/platform/model.go | 28 ++++++++++--------- .../api/internal/platform/production_store.go | 3 +- .../apps/api/internal/platform/service.go | 6 +++- .../api/internal/platform/service_test.go | 17 +++++++++-- .../apps/web/src/api/types.ts | 2 ++ .../apps/web/src/v2/domain/track.test.ts | 7 ++++- .../apps/web/src/v2/domain/track.ts | 6 +++- .../apps/web/src/v2/pages/TrackPage.test.tsx | 14 +++++----- .../apps/web/src/v2/pages/TrackPage.tsx | 10 ++++--- 10 files changed, 63 insertions(+), 32 deletions(-) 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 783982c8..c60a8f92 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -1036,7 +1036,7 @@ func (m *MockStore) HistoryLocationsFromTDengine(ctx context.Context, query url. VIN: row.VIN, Plate: row.Plate, Protocol: row.Protocol, Longitude: row.Longitude - float64(remaining)*0.0062, Latitude: row.Latitude - float64(remaining)*0.0021 + float64(index%3)*0.0008, - SpeedKmh: speed, TotalMileageKm: row.TotalMileageKm - float64(remaining)*1.35, + SpeedKmh: speed, TotalMileageKm: row.TotalMileageKm - float64(remaining)*1.35, MileageAvailable: true, DeviceTime: observedAt, ServerTime: observedAt, }) } diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index eb9de6b8..a67325bc 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -101,6 +101,7 @@ type TrackPlaybackSummary struct { StartTime string `json:"startTime"` EndTime string `json:"endTime"` DistanceKm float64 `json:"distanceKm"` + DistanceMethod string `json:"distanceMethod"` DurationSeconds int64 `json:"durationSeconds"` AverageSpeedKmh float64 `json:"averageSpeedKmh"` MaximumSpeedKmh float64 `json:"maximumSpeedKmh"` @@ -1304,19 +1305,20 @@ type VehicleRealtimeRow struct { } type HistoryLocationRow struct { - VIN string `json:"vin"` - Plate string `json:"plate"` - Protocol string `json:"protocol"` - Longitude float64 `json:"longitude"` - Latitude float64 `json:"latitude"` - SpeedKmh float64 `json:"speedKmh"` - SOCPercent float64 `json:"socPercent"` - SOCAvailable bool `json:"socAvailable"` - DirectionDeg *int64 `json:"directionDeg,omitempty"` - AlarmFlag *int64 `json:"alarmFlag,omitempty"` - TotalMileageKm float64 `json:"totalMileageKm"` - DeviceTime string `json:"deviceTime"` - ServerTime string `json:"serverTime"` + VIN string `json:"vin"` + Plate string `json:"plate"` + Protocol string `json:"protocol"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + SpeedKmh float64 `json:"speedKmh"` + SOCPercent float64 `json:"socPercent"` + SOCAvailable bool `json:"socAvailable"` + DirectionDeg *int64 `json:"directionDeg,omitempty"` + AlarmFlag *int64 `json:"alarmFlag,omitempty"` + TotalMileageKm float64 `json:"totalMileageKm"` + MileageAvailable bool `json:"mileageAvailable"` + DeviceTime string `json:"deviceTime"` + ServerTime string `json:"serverTime"` } type RawFrameRow struct { 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 457ca9c2..e3c25136 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -616,7 +616,7 @@ func (s *ProductionStore) HistoryLocations(ctx context.Context, query url.Values for _, row := range realtime.Items { items = append(items, HistoryLocationRow{ VIN: row.VIN, Plate: row.Plate, Protocol: row.Protocol, Longitude: row.Longitude, Latitude: row.Latitude, - SpeedKmh: row.SpeedKmh, SOCPercent: row.SOCPercent, SOCAvailable: row.SOCPercent > 0, TotalMileageKm: row.TotalMileageKm, DeviceTime: row.LastSeen, ServerTime: row.LastSeen, + SpeedKmh: row.SpeedKmh, SOCPercent: row.SOCPercent, SOCAvailable: row.SOCPercent > 0, TotalMileageKm: row.TotalMileageKm, MileageAvailable: row.TotalMileageKm > 0, DeviceTime: row.LastSeen, ServerTime: row.LastSeen, }) } return Page[HistoryLocationRow]{Items: items, Total: realtime.Total, Limit: realtime.Limit, Offset: realtime.Offset}, nil @@ -675,6 +675,7 @@ func (s *ProductionStore) HistoryLocationsFromTDengine(ctx context.Context, quer row.DirectionDeg = nullInt64Pointer(direction) row.AlarmFlag = nullInt64Pointer(alarm) row.TotalMileageKm = nullFloat64(mileage) + row.MileageAvailable = mileage.Valid row.DeviceTime = time.UnixMilli(ts).UTC().Format(time.RFC3339Nano) row.ServerTime = row.DeviceTime if receivedAt.Valid { diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 5a9d6438..655c58cc 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -3474,10 +3474,14 @@ func summarizeTrack(points []HistoryLocationRow) TrackPlaybackSummary { summary.AverageSpeedKmh = speedTotal / float64(speedCount) } mileageDistance := points[len(points)-1].TotalMileageKm - points[0].TotalMileageKm - if points[0].Protocol == points[len(points)-1].Protocol && mileageDistance >= 0 && mileageDistance < 10000 { + if points[0].Protocol == points[len(points)-1].Protocol && + points[0].MileageAvailable && points[len(points)-1].MileageAvailable && + mileageDistance >= 0 && mileageDistance < 10000 { summary.DistanceKm = mileageDistance + summary.DistanceMethod = "odometer" } else { summary.DistanceKm = coordinateDistance + summary.DistanceMethod = "coordinates" } start, startOK := parseVehicleServiceTime(summary.StartTime) end, endOK := parseVehicleServiceTime(summary.EndTime) diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index a0de848b..3988d777 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -377,15 +377,26 @@ func TestCompleteProtocolStatsIncludesCanonicalSlots(t *testing.T) { func TestSummarizeTrackUsesMileageAndChronology(t *testing.T) { points := []HistoryLocationRow{ - {DeviceTime: "2026-07-03 10:00:00", TotalMileageKm: 100, SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1}, - {DeviceTime: "2026-07-03 10:30:00", TotalMileageKm: 112.5, SpeedKmh: 50, Longitude: 113.2, Latitude: 23.2}, + {DeviceTime: "2026-07-03 10:00:00", TotalMileageKm: 100, MileageAvailable: true, SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1}, + {DeviceTime: "2026-07-03 10:30:00", TotalMileageKm: 112.5, MileageAvailable: true, SpeedKmh: 50, Longitude: 113.2, Latitude: 23.2}, } summary := summarizeTrack(points) - if summary.DistanceKm != 12.5 || summary.DurationSeconds != 1800 || summary.MaximumSpeedKmh != 50 { + if summary.DistanceKm != 12.5 || summary.DistanceMethod != "odometer" || summary.DurationSeconds != 1800 || summary.MaximumSpeedKmh != 50 { t.Fatalf("unexpected track summary: %+v", summary) } } +func TestSummarizeTrackUsesCoordinatesWhenProtocolMileageIsMissing(t *testing.T) { + points := []HistoryLocationRow{ + {Protocol: "JT808", DeviceTime: "2026-07-03 10:00:00", Longitude: 113.1, Latitude: 23.1}, + {Protocol: "JT808", DeviceTime: "2026-07-03 10:01:00", Longitude: 113.101, Latitude: 23.101}, + } + summary := summarizeTrack(points) + if summary.DistanceMethod != "coordinates" || summary.DistanceKm <= 0 { + t.Fatalf("missing protocol mileage must use an explicitly labelled coordinate distance: %+v", summary) + } +} + func TestTrackEventKeepsSynchronizedTelemetryEvidence(t *testing.T) { direction := int64(88) alarm := int64(2) diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index e7bb46a1..48686307 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -167,6 +167,7 @@ export interface TrackPlaybackSummary { startTime: string; endTime: string; distanceKm: number; + distanceMethod?: 'odometer' | 'coordinates' | string; durationSeconds: number; averageSpeedKmh: number; maximumSpeedKmh: number; @@ -748,6 +749,7 @@ export interface VehicleRealtimeRow { export interface HistoryLocationRow extends RealtimeLocationRow { socAvailable: boolean; + mileageAvailable?: boolean; directionDeg?: number; alarmFlag?: number; deviceTime: string; diff --git a/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts index dcd0ad6d..c1b4f186 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { TrackPlaybackResponse } from '../../api/types'; -import { buildTrackRailSelection, formatDuration, sampledEventIndex, trackAddressCoordinate, trackCsv, trackPlaybackInterval } from './track'; +import { buildTrackRailSelection, formatDuration, sampledEventIndex, trackAddressCoordinate, trackCsv, trackPlaybackInterval, trackPointMileageAvailable } from './track'; describe('track domain', () => { it('formats durations and maps original event indices to sampled points', () => { @@ -39,6 +39,11 @@ describe('track domain', () => { expect(csv).toContain('"粤A,001"'); }); + it('keeps missing protocol mileage distinct from a real zero', () => { + expect(trackPointMileageAvailable({ totalMileageKm: 123, mileageAvailable: true } as never)).toBe(true); + expect(trackPointMileageAvailable({ totalMileageKm: 0, mileageAvailable: false } as never)).toBe(false); + }); + it('coalesces GPS jitter into street-level address cells and rejects invalid coordinates', () => { expect(trackAddressCoordinate(113.123441, 23.123441)).toEqual({ longitude: 113.1234, latitude: 23.1234, key: '113.1234,23.1234' }); expect(trackAddressCoordinate(113.123449, 23.123449)?.key).toBe('113.1234,23.1234'); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/track.ts b/vehicle-data-platform/apps/web/src/v2/domain/track.ts index 9dd0ecd0..a0f1e486 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/track.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/track.ts @@ -38,6 +38,10 @@ export function trackPlaybackInterval(speed: number) { return Math.max(55, 300 / Math.max(0.5, speed)); } +export function trackPointMileageAvailable(point?: HistoryLocationRow) { + return Boolean(point && (point.mileageAvailable ?? point.totalMileageKm > 0)); +} + export function buildTrackRailSelection(track?: TrackPlaybackResponse) { const pointCount = track?.points.length ?? 0; const stopIndexesByPoint: Array = new Array(pointCount); @@ -65,7 +69,7 @@ function csvCell(value: unknown) { export function trackCsv(track: TrackPlaybackResponse) { const headers = ['VIN', '车牌', '协议', '设备时间', '服务时间', '经度', '纬度', '速度(km/h)', '总里程(km)']; - const rows = track.points.map((point) => [point.vin, point.plate, point.protocol, point.deviceTime, point.serverTime, point.longitude, point.latitude, point.speedKmh, point.totalMileageKm]); + const rows = track.points.map((point) => [point.vin, point.plate, point.protocol, point.deviceTime, point.serverTime, point.longitude, point.latitude, point.speedKmh, trackPointMileageAvailable(point) ? point.totalMileageKm : '']); return `\uFEFF${[headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\n')}`; } diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx index 2107c6ac..4e9b9918 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx @@ -50,9 +50,9 @@ vi.mock('@douyinfe/semi-ui', async (importOriginal) => { const track = { vin: 'LTEST000000000001', plate: '粤A12345', total: 3, truncated: false, sampled: false, asOf: '2026-07-15T08:00:00Z', points: [ - { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:00:00Z', serverTime: '2026-07-15T00:00:01Z', longitude: 113.1, latitude: 23.1, speedKmh: 0, totalMileageKm: 100, socPercent: 80, socAvailable: true }, - { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:10:00Z', serverTime: '2026-07-15T00:10:01Z', longitude: 113.2, latitude: 23.2, speedKmh: 35, totalMileageKm: 104, socPercent: 79, socAvailable: true }, - { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:20:00Z', serverTime: '2026-07-15T00:20:01Z', longitude: 113.3, latitude: 23.3, speedKmh: 10, totalMileageKm: 108, socPercent: 78, socAvailable: true } + { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:00:00Z', serverTime: '2026-07-15T00:00:01Z', longitude: 113.1, latitude: 23.1, speedKmh: 0, totalMileageKm: 100, mileageAvailable: true, socPercent: 80, socAvailable: true }, + { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:10:00Z', serverTime: '2026-07-15T00:10:01Z', longitude: 113.2, latitude: 23.2, speedKmh: 35, totalMileageKm: 104, mileageAvailable: true, socPercent: 79, socAvailable: true }, + { vin: 'LTEST000000000001', plate: '粤A12345', protocol: 'JT808', deviceTime: '2026-07-15T00:20:00Z', serverTime: '2026-07-15T00:20:01Z', longitude: 113.3, latitude: 23.3, speedKmh: 10, totalMileageKm: 108, mileageAvailable: true, socPercent: 78, socAvailable: true } ], events: [ { index: 0, sampledIndex: 0, type: 'start', title: '开始行驶', time: '2026-07-15T00:00:00Z', speedKmh: 0, longitude: 113.1, latitude: 23.1 }, @@ -62,7 +62,7 @@ const track = { sources: [{ protocol: 'JT808', pointCount: 3, startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z' }], segments: [{ index: 0, type: 'moving', title: '行驶', startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z', durationSeconds: 1200, distanceKm: 8, pointCount: 3, startIndex: 0, endIndex: 2, sampledStartIndex: 0, sampledEndIndex: 2 }], stops: [{ index: 0, startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:05:00Z', durationSeconds: 300, pointCount: 1, longitude: 113.1, latitude: 23.1, sampledIndex: 0, evidence: 'GPS 推断' }], - summary: { startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z', distanceKm: 8, durationSeconds: 1200, averageSpeedKmh: 24, maximumSpeedKmh: 35, pointCount: 3, movingSeconds: 900, stoppedSeconds: 300, stopCount: 1, segmentCount: 1 }, + summary: { startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T00:20:00Z', distanceKm: 8, distanceMethod: 'odometer', durationSeconds: 1200, averageSpeedKmh: 24, maximumSpeedKmh: 35, pointCount: 3, movingSeconds: 900, stoppedSeconds: 300, stopCount: 1, segmentCount: 1 }, coverage: { requestedStart: '', requestedEnd: '', actualStart: '', actualEnd: '', totalPoints: 3, fetchedPoints: 3, processedPoints: 3, returnedPoints: 3, complete: true, limitReasons: [], evidence: '时间窗完整' }, quality: { status: 'good', selectedProtocol: 'JT808', rawPoints: 3, validPoints: 3, alternateSourcePoints: 0, invalidCoordinatePoints: 0, duplicatePoints: 0, driftPoints: 0, sourceSwitches: 0, largeGapCount: 0, maximumGapSeconds: 0, evidence: '轨迹质量正常' } } as unknown as TrackPlaybackResponse; @@ -181,8 +181,8 @@ test('renders a map-first replay workspace and connects stop, event, and panel i expect(view.container.querySelector('.v2-track-rail-result')).toBeInTheDocument(); const tripSummary = screen.getByRole('list', { name: '轨迹行程摘要' }); expect(tripSummary.closest('.v2-track-result-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue'); - expect(Array.from(tripSummary.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['行程里程', '停留点', '事件点', '数据点']); - expect(tripSummary).toHaveTextContent('行程里程8 km00:20:00'); + expect(Array.from(tripSummary.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['协议总里程差', '停留点', '事件点', '数据点']); + expect(tripSummary).toHaveTextContent('协议总里程差8 km00:20:00'); expect(tripSummary).toHaveTextContent('停留点1超过 3 分钟'); expect(tripSummary).toHaveTextContent('事件点3轨迹事件'); expect(tripSummary).toHaveTextContent('数据点3时间窗完整'); @@ -285,7 +285,7 @@ test('uses the shared Semi editor SideSheet for mobile track criteria and eviden expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument(); expect(screen.getAllByText('07-15 00:00 – 07-15 23:59 · 自动来源')).toHaveLength(2); const mobileSummary = screen.getByRole('list', { name: '轨迹查询与明细摘要' }); - expect(mobileSummary).toHaveTextContent('行程里程8 km00:20:00'); + expect(mobileSummary).toHaveTextContent('协议总里程差8 km00:20:00'); expect(mobileSummary).toHaveTextContent('停留点1超过 3 分钟'); expect(mobileSummary).toHaveTextContent('事件点3轨迹事件'); expect(mobileSummary).toHaveTextContent('数据点3时间窗完整'); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx index 2e8914ea..b3ee6ba0 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -9,7 +9,7 @@ import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../../api/types'; import { protocolDisplayLabel } from '../domain/protocols'; -import { buildTrackRailSelection, downloadTrackCsv, formatDuration, sampledEventIndex, TRACK_ADDRESS_SETTLE_MS, trackAddressCoordinate, trackPlaybackInterval, type TrackAddressCoordinate } from '../domain/track'; +import { buildTrackRailSelection, downloadTrackCsv, formatDuration, sampledEventIndex, TRACK_ADDRESS_SETTLE_MS, trackAddressCoordinate, trackPlaybackInterval, trackPointMileageAvailable, type TrackAddressCoordinate } from '../domain/track'; import { TrackMap } from '../map/TrackMap'; import { InlineError } from '../shared/AsyncState'; import { MonitorReturnBar } from '../shared/MonitorReturnBar'; @@ -129,9 +129,10 @@ function mobileTrackSheetHeight(track: TrackPlaybackResponse | undefined, queryC } function trackSummaryItems(track?: TrackPlaybackResponse): WorkspaceQueueMetricRailItem[] { + const distanceLabel = track?.summary.distanceMethod === 'coordinates' ? 'GPS 轨迹累计' : track?.summary.distanceMethod === 'odometer' ? '协议总里程差' : '行程里程'; return [ { - label: '行程里程', + label: distanceLabel, value: track ? `${number(track.summary.distanceKm)} km` : '—', note: track ? formatDuration(track.summary.durationSeconds) : '等待查询', tone: 'primary', @@ -206,13 +207,14 @@ function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: } function OverviewPanel({ track }: { track: TrackPlaybackResponse }) { + const distanceLabel = track.summary.distanceMethod === 'coordinates' ? 'GPS 轨迹累计' : track.summary.distanceMethod === 'odometer' ? '协议总里程差' : '行驶里程'; return
{track.sampled ? '地图已抽稀' : '完整点集'}} />
速度{number(current?.speedKmh ?? 0)} km/h 方向{direction(current?.directionDeg)} SOC{current?.socAvailable ? `${number(current.socPercent)}%` : '—'} - 总里程{number(current?.totalMileageKm ?? 0)} km + 节点累计总里程{trackPointMileageAvailable(current) ? number(current?.totalMileageKm ?? 0) : '—'}{trackPointMileageAvailable(current) ? km : null}