fix(monitor): arbitrate conflicting location sources
This commit is contained in:
@@ -50,6 +50,8 @@ type MonitorMapPoint struct {
|
||||
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
|
||||
LocationSource string `json:"locationSource"`
|
||||
LocationConflict bool `json:"locationConflict"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -1019,6 +1021,9 @@ type VehicleRealtimeRow struct {
|
||||
BindingStatus string `json:"bindingStatus"`
|
||||
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
|
||||
PrimaryProtocol string `json:"primaryProtocol"`
|
||||
LocationSource string `json:"locationSource"`
|
||||
LocationConflict bool `json:"locationConflict"`
|
||||
ConflictDistanceM *float64 `json:"conflictDistanceM,omitempty"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
SpeedKmh float64 `json:"speedKmh"`
|
||||
|
||||
@@ -398,7 +398,14 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||
`GROUP BY l.vin, b.plate, b.phone, b.oem ` +
|
||||
havingSQL
|
||||
orderExpr := `l.updated_at DESC, l.protocol ASC`
|
||||
// Keep one authoritative coordinate source while all protocols are healthy.
|
||||
// Ordering only by updated_at makes the selected point flap whenever protocols
|
||||
// report at different cadences. When every source is stale, recency remains the
|
||||
// safest fallback so an old high-priority source cannot mask newer evidence.
|
||||
orderExpr := `CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 0 ELSE 1 END ASC, ` +
|
||||
`CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN CASE l.protocol ` +
|
||||
`WHEN 'GB32960' THEN 10 WHEN 'YUTONG_MQTT' THEN 20 WHEN 'JT808' THEN 30 ELSE 100 END ELSE 100 END ASC, ` +
|
||||
`l.updated_at DESC, l.protocol ASC`
|
||||
return SQLQuery{
|
||||
Text: `SELECT l.vin, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), b.plate, '') AS plate, ` +
|
||||
@@ -410,6 +417,9 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
`CASE WHEN COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` +
|
||||
`CASE WHEN MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1 THEN 'bound' ELSE 'unbound' END AS binding_status, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(l.protocol ORDER BY ` + orderExpr + `), ',', 1), '') AS primary_protocol, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(l.source_key, ''), CONCAT(l.protocol, ':canonical')) ORDER BY ` + orderExpr + `), ',', 1), '') AS location_source, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(l.location_conflict, 0) ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS location_conflict, ` +
|
||||
`SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(CAST(l.location_conflict_distance_m AS CHAR), '-1') ORDER BY ` + orderExpr + `), ',', 1) AS conflict_distance_m, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.longitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS longitude, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.latitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS latitude, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` +
|
||||
|
||||
@@ -401,13 +401,22 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
|
||||
var onlineProtocols string
|
||||
var online int
|
||||
var longitude, latitude, speed, soc, mileage string
|
||||
var conflictDistance sql.NullString
|
||||
var locationConflict int
|
||||
var reportIntervalMs int64
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen, &reportIntervalMs); err != nil {
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &row.LocationSource, &locationConflict, &conflictDistance, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen, &reportIntervalMs); err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
}
|
||||
row.Protocols = splitCSV(protocols)
|
||||
row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, splitCSV(onlineProtocols), row.LastSeen)
|
||||
row.Online = online == 1
|
||||
row.LocationConflict = locationConflict == 1
|
||||
if conflictDistance.Valid {
|
||||
value := parseFloatString(conflictDistance.String)
|
||||
if value >= 0 {
|
||||
row.ConflictDistanceM = &value
|
||||
}
|
||||
}
|
||||
row.ServiceStatus = buildRealtimeServiceStatus(row)
|
||||
row.Longitude = parseFloatString(longitude)
|
||||
row.Latitude = parseFloatString(latitude)
|
||||
|
||||
@@ -208,6 +208,20 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) {
|
||||
if !strings.Contains(built.Text, "ORDER BY MAX(l.updated_at) DESC, l.vin ASC") {
|
||||
t.Fatalf("SQL should keep vehicle-level stable pagination order: %s", built.Text)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"INTERVAL 2 MINUTE",
|
||||
"WHEN 'GB32960' THEN 10",
|
||||
"WHEN 'YUTONG_MQTT' THEN 20",
|
||||
"WHEN 'JT808' THEN 30",
|
||||
"ELSE 100 END ELSE 100 END ASC, l.updated_at DESC",
|
||||
"location_source",
|
||||
"location_conflict",
|
||||
"location_conflict_distance_m",
|
||||
} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("realtime source arbitration missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
if len(built.Args) != 8 || built.Args[0] != "JT808" || built.Args[1] != "%粤A%" || built.Args[6] != 10 || built.Args[7] != 20 {
|
||||
t.Fatalf("args = %#v", built.Args)
|
||||
}
|
||||
|
||||
@@ -607,6 +607,8 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
|
||||
Longitude: vehicle.Longitude, Latitude: vehicle.Latitude, SpeedKmh: vehicle.SpeedKmh,
|
||||
SOCPercent: vehicle.SOCPercent, TotalMileageKm: vehicle.TotalMileageKm, LastSeen: vehicle.LastSeen,
|
||||
ReportIntervalMs: vehicle.ReportIntervalMs,
|
||||
LocationSource: vehicle.LocationSource,
|
||||
LocationConflict: vehicle.LocationConflict,
|
||||
Status: monitorVehicleStatus(vehicle),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface MonitorMapPoint {
|
||||
totalMileageKm: number;
|
||||
lastSeen: string;
|
||||
reportIntervalMs?: number;
|
||||
locationSource?: string;
|
||||
locationConflict?: boolean;
|
||||
status: 'driving' | 'idle' | 'offline' | 'unknown';
|
||||
}
|
||||
|
||||
@@ -540,6 +542,9 @@ export interface VehicleRealtimeRow {
|
||||
bindingStatus: string;
|
||||
serviceStatus?: VehicleServiceStatus;
|
||||
primaryProtocol: string;
|
||||
locationSource?: string;
|
||||
locationConflict?: boolean;
|
||||
conflictDistanceM?: number;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
speedKmh: number;
|
||||
|
||||
@@ -48,6 +48,7 @@ export type AMapMassPoint = {
|
||||
style: number;
|
||||
id: string;
|
||||
label: string;
|
||||
sourceToken?: string;
|
||||
};
|
||||
|
||||
type AMapAddressComponent = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import type { MonitorMapResponse } from '../../api/types';
|
||||
import { wgs84ToGcj02, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap';
|
||||
import { advancePointMotions, FleetMap, interpolateMassPoints, massPointFingerprint, pointMoveDurationMs, pointMoveFrameMs } from './FleetMap';
|
||||
import { advancePointMotions, FleetMap, interpolateMassPoints, massPointFingerprint, movingPointIDs, pointMoveDurationMs, pointMoveFrameMs } from './FleetMap';
|
||||
|
||||
const setData = vi.fn<(data: AMapMassPoint[]) => void>();
|
||||
const setStyle = vi.fn();
|
||||
@@ -170,6 +170,15 @@ test('interpolates existing mass points at constant speed while keeping new poin
|
||||
expect(interpolateMassPoints(previous, target, 1)).toEqual(target);
|
||||
});
|
||||
|
||||
test('does not interpolate across an authoritative location source switch', () => {
|
||||
const previous: AMapMassPoint[] = [{ id: 'vehicle', label: 'A', style: 0, lnglat: [113.2, 23.1], sourceToken: 'JT808:terminal-a' }];
|
||||
const sameSource: AMapMassPoint[] = [{ id: 'vehicle', label: 'A', style: 0, lnglat: [113.3, 23.2], sourceToken: 'JT808:terminal-a' }];
|
||||
const switchedSource: AMapMassPoint[] = [{ id: 'vehicle', label: 'A', style: 0, lnglat: [113.4, 23.3], sourceToken: 'GB32960:canonical' }];
|
||||
|
||||
expect(movingPointIDs(previous, sameSource).has('vehicle')).toBe(true);
|
||||
expect(movingPointIDs(previous, switchedSource).has('vehicle')).toBe(false);
|
||||
});
|
||||
|
||||
test('uses each protocol report interval with bounded fallbacks for point motion', () => {
|
||||
expect(pointMoveDurationMs(10_000)).toBe(10_000);
|
||||
expect(pointMoveDurationMs(30_000)).toBe(30_000);
|
||||
|
||||
@@ -160,6 +160,7 @@ export function massPointFingerprint(
|
||||
fingerprint.add(point.status);
|
||||
fingerprint.add(point.plate || '');
|
||||
fingerprint.add(point.reportIntervalMs ?? '');
|
||||
fingerprint.add(point.locationSource || point.protocol);
|
||||
}
|
||||
} else {
|
||||
fingerprint.add(points.length);
|
||||
@@ -170,6 +171,7 @@ export function massPointFingerprint(
|
||||
fingerprint.add(vehicleStatus(point));
|
||||
fingerprint.add(point.plate || '');
|
||||
fingerprint.add(point.reportIntervalMs ?? '');
|
||||
fingerprint.add(point.locationSource || point.primaryProtocol);
|
||||
}
|
||||
}
|
||||
return fingerprint.value();
|
||||
@@ -225,12 +227,12 @@ export function pointMoveFrameMs(totalPoints: number, movingPoints: number) {
|
||||
return Math.round(Math.min(MAX_POINT_MOVE_FRAME_MS, MIN_POINT_MOVE_FRAME_MS + pressure * 120));
|
||||
}
|
||||
|
||||
function movingPointIDs(previous: AMapMassPoint[], target: AMapMassPoint[]) {
|
||||
const previousByID = new Map(previous.map((point) => [point.id, point.lnglat]));
|
||||
const moving = new Set<string>();
|
||||
for (const point of target) {
|
||||
const start = previousByID.get(point.id);
|
||||
if (start && (Math.abs(start[0] - point.lnglat[0]) >= POINT_MOVE_EPSILON || Math.abs(start[1] - point.lnglat[1]) >= POINT_MOVE_EPSILON)) moving.add(point.id);
|
||||
export function movingPointIDs(previous: AMapMassPoint[], target: AMapMassPoint[]) {
|
||||
const previousByID = new Map(previous.map((point) => [point.id, point]));
|
||||
const moving = new Set<string>();
|
||||
for (const point of target) {
|
||||
const start = previousByID.get(point.id);
|
||||
if (start && start.sourceToken === point.sourceToken && (Math.abs(start.lnglat[0] - point.lnglat[0]) >= POINT_MOVE_EPSILON || Math.abs(start.lnglat[1] - point.lnglat[1]) >= POINT_MOVE_EPSILON)) moving.add(point.id);
|
||||
}
|
||||
return moving;
|
||||
}
|
||||
@@ -486,9 +488,9 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
const clusterStyleIndexes = new Map(clusterCounts.map((count, index) => [count, COLORS.length + index]));
|
||||
const data: AMapMassPoint[] = monitorMode ? [
|
||||
...clusters.map((cluster) => ({ lnglat: wgs84ToGcj02(cluster.longitude, cluster.latitude), style: clusterStyleIndexes.get(cluster.count) ?? COLORS.length, id: cluster.id, label: `${cluster.count} 辆` })),
|
||||
...mapPoints.map((point) => ({ lnglat: wgs84ToGcj02(point.longitude, point.latitude), style: point.status === 'driving' ? 2 : point.status === 'idle' ? 0 : point.status === 'offline' ? 1 : 3, id: point.vin, label: point.plate || point.vin }))
|
||||
...mapPoints.map((point) => ({ lnglat: wgs84ToGcj02(point.longitude, point.latitude), style: point.status === 'driving' ? 2 : point.status === 'idle' ? 0 : point.status === 'offline' ? 1 : 3, id: point.vin, label: point.plate || point.vin, sourceToken: point.locationSource || point.protocol }))
|
||||
] : fallbackPoints.map((vehicle) => ({
|
||||
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin
|
||||
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin, sourceToken: vehicle.locationSource || vehicle.primaryProtocol
|
||||
}));
|
||||
const moveDurationByID = new Map((monitorMode ? mapPoints : fallbackPoints)
|
||||
.map((point) => [point.vin, pointMoveDurationMs(point.reportIntervalMs)]));
|
||||
|
||||
Reference in New Issue
Block a user