fix: distinguish track mileage evidence

This commit is contained in:
lingniu
2026-07-19 12:32:01 +08:00
parent f825c9f417
commit 4e4f383988
10 changed files with 63 additions and 32 deletions

View File

@@ -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,
})
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;

View File

@@ -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');

View File

@@ -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<number[] | undefined> = 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')}`;
}

View File

@@ -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时间窗完整');

View File

@@ -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 <div className="v2-track-overview-panel">
<section className="v2-track-overview-section">
<WorkspacePanelHeader variant="compact" title="行程概览" meta={<Tag color={track.sampled ? 'orange' : 'green'} type="light" size="small">{track.sampled ? '地图已抽稀' : '完整点集'}</Tag>} />
<div className="v2-track-overview-card-body"><Descriptions className="v2-track-overview-descriptions" align="left" size="small" data={[
{ key: '开始时间', value: dateTime(track.summary.startTime) },
{ key: '结束时间', value: dateTime(track.summary.endTime) },
{ key: '行驶里程', value: `${number(track.summary.distanceKm)} km` },
{ key: distanceLabel, value: `${number(track.summary.distanceKm)} km` },
{ key: '行驶 / 停车', value: `${formatDuration(track.summary.movingSeconds)} / ${formatDuration(track.summary.stoppedSeconds)}` },
{ key: '平均 / 最高速度', value: `${number(track.summary.averageSpeedKmh)} / ${number(track.summary.maximumSpeedKmh)} km/h` },
{ key: '停车 / 分段', value: `${track.summary.stopCount} / ${track.summary.segmentCount}` }
@@ -501,7 +503,7 @@ export default function TrackPage() {
<span><small></small><strong>{number(current?.speedKmh ?? 0)}<em> km/h</em></strong></span>
<span><small></small><strong>{direction(current?.directionDeg)}</strong></span>
<span><small>SOC</small><strong>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}</strong></span>
<span><small></small><strong>{number(current?.totalMileageKm ?? 0)}<em> km</em></strong></span>
<span><small></small><strong>{trackPointMileageAvailable(current) ? number(current?.totalMileageKm ?? 0) : '—'}{trackPointMileageAvailable(current) ? <em> km</em> : null}</strong></span>
</div>
<div className={`v2-track-current-evidence v2-track-current-coverage${track.coverage.complete ? '' : ' is-warning'}`}>
<ProtocolTag className="v2-track-protocol-tag" protocol={current?.protocol} compact />