fix: distinguish track mileage evidence
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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时间窗完整');
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user