diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go
index d774cf76..c04ad339 100644
--- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go
+++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go
@@ -869,6 +869,55 @@ func TestHandlerVehicleRealtimeFiltersServiceStatus(t *testing.T) {
}
}
+func TestHandlerVehicleRealtimeKeepsBoundVehiclesWithoutLocation(t *testing.T) {
+ handler := NewHandler(NewService(NewMockStore()))
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles?status=no_location&limit=10", nil)
+ handler.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Data struct {
+ Items []VehicleRealtimeRow `json:"items"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
+ }
+ if len(body.Data.Items) == 0 {
+ t.Fatalf("no-location filter should retain bound vehicles: %s", rec.Body.String())
+ }
+ for _, item := range body.Data.Items {
+ if item.LocationAvailable {
+ t.Fatalf("no-location result must not contain a located vehicle: %+v", item)
+ }
+ }
+}
+
+func TestHandlerVehicleRealtimeOfflineFilterExcludesNeverReportedVehicles(t *testing.T) {
+ handler := NewHandler(NewService(NewMockStore()))
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles?online=offline&limit=10", nil)
+ handler.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Data struct {
+ Items []VehicleRealtimeRow `json:"items"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
+ }
+ for _, item := range body.Data.Items {
+ if item.SourceCount == 0 {
+ t.Fatalf("offline means a known source stopped reporting; never-reported vehicles belong to no-location: %+v", item)
+ }
+ }
+}
+
func TestHandlerVehicleRealtimeAcceptsKeyword(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
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 e8e9a0be..99d8e9bd 100644
--- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go
+++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go
@@ -485,30 +485,48 @@ func buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM int) [
func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
rows := m.locations
- if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
+ keywords := vehicleSearchKeywords(query)
+ matchesIdentity := func(vin, plate, phone, oem string) bool {
+ if len(keywords) == 0 {
+ return true
+ }
batch := strings.TrimSpace(query.Get("keywords")) != ""
- rows = keep(rows, func(row RealtimeLocationRow) bool {
- vehicle := m.vehicleByVIN(row.VIN)
- if batch {
- for _, keyword := range keywords {
- if strings.EqualFold(row.VIN, keyword) || strings.EqualFold(row.Plate, keyword) || strings.EqualFold(vehicle.Plate, keyword) {
- return true
- }
- }
- return false
- }
- haystack := strings.ToLower(row.VIN + row.Plate + vehicle.Plate + vehicle.Phone + vehicle.OEM)
+ if batch {
for _, keyword := range keywords {
- if strings.Contains(haystack, strings.ToLower(keyword)) {
+ if strings.EqualFold(vin, keyword) || strings.EqualFold(plate, keyword) {
return true
}
}
return false
+ }
+ haystack := strings.ToLower(vin + plate + phone + oem)
+ for _, keyword := range keywords {
+ if strings.Contains(haystack, strings.ToLower(keyword)) {
+ return true
+ }
+ }
+ return false
+ }
+ if len(keywords) > 0 {
+ rows = keep(rows, func(row RealtimeLocationRow) bool {
+ vehicle := m.vehicleByVIN(row.VIN)
+ return matchesIdentity(row.VIN, firstNonEmpty(row.Plate, vehicle.Plate), vehicle.Phone, vehicle.OEM)
})
}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
rows = keep(rows, func(row RealtimeLocationRow) bool { return row.Protocol == protocol })
}
+ scopeVINs := splitCSV(query.Get("scopeVins"))
+ scope := make(map[string]struct{}, len(scopeVINs))
+ for _, vin := range scopeVINs {
+ scope[vin] = struct{}{}
+ }
+ if len(scope) > 0 {
+ rows = keep(rows, func(row RealtimeLocationRow) bool {
+ _, allowed := scope[row.VIN]
+ return allowed
+ })
+ }
byVIN := map[string]*VehicleRealtimeRow{}
for _, location := range rows {
current := byVIN[location.VIN]
@@ -533,10 +551,14 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
}
if location.LastSeen >= current.LastSeen {
current.PrimaryProtocol = location.Protocol
+ current.LocationAvailable = validRealtimeCoordinate(location.Longitude, location.Latitude)
current.Longitude = location.Longitude
current.Latitude = location.Latitude
+ current.SpeedAvailable = true
current.SpeedKmh = location.SpeedKmh
+ current.SOCAvailable = location.Protocol == "GB32960" || location.Protocol == "YUTONG_MQTT"
current.SOCPercent = location.SOCPercent
+ current.MileageAvailable = true
current.TotalMileageKm = location.TotalMileageKm
current.LastSeen = location.LastSeen
}
@@ -545,6 +567,28 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
current.OnlineSourceCount++
}
}
+ if strings.TrimSpace(query.Get("protocol")) == "" {
+ for _, vehicle := range m.vehicles {
+ if _, exists := byVIN[vehicle.VIN]; exists {
+ continue
+ }
+ if len(scope) > 0 {
+ if _, allowed := scope[vehicle.VIN]; !allowed {
+ continue
+ }
+ }
+ if !matchesIdentity(vehicle.VIN, vehicle.Plate, vehicle.Phone, vehicle.OEM) {
+ continue
+ }
+ byVIN[vehicle.VIN] = &VehicleRealtimeRow{
+ VIN: vehicle.VIN,
+ Plate: vehicle.Plate,
+ Phone: vehicle.Phone,
+ OEM: vehicle.OEM,
+ BindingStatus: "bound",
+ }
+ }
+ }
items := make([]VehicleRealtimeRow, 0, len(byVIN))
for _, row := range byVIN {
sort.Strings(row.Protocols)
@@ -562,12 +606,14 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
continue
}
case "offline":
- if row.Online {
+ if row.Online || row.SourceCount == 0 {
continue
}
}
if status := strings.TrimSpace(query.Get("status")); status != "" && !matchesMonitorStatus(*row, status) {
- continue
+ if status != "no_location" || row.LocationAvailable {
+ continue
+ }
}
if !keepServiceStatus(row.ServiceStatus, query.Get("serviceStatus")) {
continue
@@ -649,10 +695,14 @@ func (m *MockStore) realtimeSummaryForVIN(vin string, protocol string) *VehicleR
}
if location.LastSeen >= summary.LastSeen {
summary.PrimaryProtocol = location.Protocol
+ summary.LocationAvailable = validRealtimeCoordinate(location.Longitude, location.Latitude)
summary.Longitude = location.Longitude
summary.Latitude = location.Latitude
+ summary.SpeedAvailable = true
summary.SpeedKmh = location.SpeedKmh
+ summary.SOCAvailable = location.Protocol == "GB32960" || location.Protocol == "YUTONG_MQTT"
summary.SOCPercent = location.SOCPercent
+ summary.MileageAvailable = true
summary.TotalMileageKm = location.TotalMileageKm
summary.LastSeen = location.LastSeen
}
diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go
index 7ac2ef23..635b14b6 100644
--- a/vehicle-data-platform/apps/api/internal/platform/model.go
+++ b/vehicle-data-platform/apps/api/internal/platform/model.go
@@ -9,6 +9,8 @@ type Page[T any] struct {
type MonitorSummary struct {
TotalVehicles int `json:"totalVehicles"`
+ LocationVehicles int `json:"locationVehicles"`
+ NoLocationVehicles int `json:"noLocationVehicles"`
OnlineVehicles int `json:"onlineVehicles"`
OfflineVehicles int `json:"offlineVehicles"`
DrivingVehicles int `json:"drivingVehicles"`
@@ -1009,28 +1011,34 @@ type RealtimeLocationRow struct {
}
type VehicleRealtimeRow struct {
- VIN string `json:"vin"`
- Plate string `json:"plate"`
- Phone string `json:"phone"`
- OEM string `json:"oem"`
- Protocols []string `json:"protocols"`
- SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
- SourceCount int `json:"sourceCount"`
- OnlineSourceCount int `json:"onlineSourceCount"`
- Online bool `json:"online"`
- 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"`
- SOCPercent float64 `json:"socPercent"`
- TotalMileageKm float64 `json:"totalMileageKm"`
- LastSeen string `json:"lastSeen"`
- ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
+ VIN string `json:"vin"`
+ Plate string `json:"plate"`
+ Phone string `json:"phone"`
+ OEM string `json:"oem"`
+ Protocols []string `json:"protocols"`
+ SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
+ SourceCount int `json:"sourceCount"`
+ OnlineSourceCount int `json:"onlineSourceCount"`
+ Online bool `json:"online"`
+ 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"`
+ LocationAvailable bool `json:"locationAvailable"`
+ Longitude float64 `json:"longitude"`
+ Latitude float64 `json:"latitude"`
+ SpeedAvailable bool `json:"speedAvailable"`
+ SpeedKmh float64 `json:"speedKmh"`
+ SOCAvailable bool `json:"socAvailable"`
+ SOCPercent float64 `json:"socPercent"`
+ MileageAvailable bool `json:"mileageAvailable"`
+ TotalMileageKm float64 `json:"totalMileageKm"`
+ TodayMileageAvailable bool `json:"todayMileageAvailable"`
+ TodayMileageKm float64 `json:"todayMileageKm"`
+ LastSeen string `json:"lastSeen"`
+ ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
}
type HistoryLocationRow struct {
diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go
index 7fba5f81..387da106 100644
--- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go
+++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go
@@ -328,8 +328,8 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
offset := parsePositive(query.Get("offset"), 0)
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{}
- where := []string{"l.vin IS NOT NULL", "l.vin <> ''"}
- where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
+ where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
+ where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins"))
having := []string{}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "l.protocol = ?")
@@ -338,7 +338,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
if strings.TrimSpace(query.Get("keywords")) != "" {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(keywords)), ",")
- where = append(where, "(l.vin IN ("+placeholders+") OR l.plate IN ("+placeholders+") OR b.plate IN ("+placeholders+"))")
+ where = append(where, "(v.vin IN ("+placeholders+") OR l.plate IN ("+placeholders+") OR v.plate IN ("+placeholders+"))")
for range 3 {
for _, keyword := range keywords {
args = append(args, keyword)
@@ -347,7 +347,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
} else {
keyword := keywords[0]
like := "%" + keyword + "%"
- where = append(where, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
+ where = append(where, "(v.vin LIKE ? OR l.plate LIKE ? OR v.plate LIKE ? OR v.phone LIKE ? OR v.oem LIKE ?)")
args = append(args, like, like, like, like, like)
}
}
@@ -355,9 +355,19 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
case "online":
having = append(having, "COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0")
case "offline":
- having = append(having, "COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0")
+ having = append(having,
+ "COUNT(DISTINCT l.protocol) > 0",
+ "COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0",
+ )
}
- primarySpeed := "CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY l.updated_at DESC, l.protocol ASC), ',', 1) AS DECIMAL(18,6))"
+ orderExpr := `CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE) THEN 0 ELSE 1 END ASC, ` +
+ `CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 10 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`
+ primarySpeed := "CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY " + orderExpr + "), ',', 1) AS DECIMAL(18,6))"
+ primaryLocationAvailable := `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(` +
+ `CASE WHEN l.vin IS NOT NULL AND l.longitude BETWEEN -180 AND 180 AND l.latitude BETWEEN -90 AND 90 ` +
+ `AND NOT (l.longitude = 0 AND l.latitude = 0) THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0)`
switch strings.TrimSpace(query.Get("status")) {
case "driving":
having = append(having,
@@ -369,29 +379,31 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0",
primarySpeed+" <= 3",
)
+ case "no_location":
+ having = append(having, primaryLocationAvailable+" = 0")
}
switch strings.TrimSpace(query.Get("serviceStatus")) {
case "healthy":
having = append(having,
- "MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1",
+ "MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 1",
"COUNT(DISTINCT l.protocol) = "+canonicalSourceCount,
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = COUNT(DISTINCT l.protocol)",
)
case "degraded":
having = append(having,
- "MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1",
+ "MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 1",
"COUNT(DISTINCT l.protocol) > 0",
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0",
"(COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) < COUNT(DISTINCT l.protocol) OR COUNT(DISTINCT l.protocol) < "+canonicalSourceCount+")",
)
case "offline":
having = append(having,
- "MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1",
+ "MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 1",
"COUNT(DISTINCT l.protocol) > 0",
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0",
)
case "identity_required":
- having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 0")
+ having = append(having, "MAX(CASE WHEN v.binding_status = 'bound' THEN 1 ELSE 0 END) = 0")
}
countArgs := append([]any(nil), args...)
args = append(args, limit, offset)
@@ -399,45 +411,62 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
if len(having) > 0 {
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
}
- groupSQL := `FROM vehicle_realtime_location l ` +
- `LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin ` +
- `LEFT JOIN vehicle_realtime_snapshot s ON s.vin = l.vin AND s.protocol = l.protocol ` +
- `WHERE ` + strings.Join(where, " AND ") + ` ` +
- `GROUP BY l.vin, b.plate, b.phone, b.oem ` +
+ populationSQL := `(` +
+ `SELECT b0.vin, COALESCE(MAX(NULLIF(b0.plate, '')), '') AS plate, ` +
+ `COALESCE(MAX(NULLIF(b0.phone, '')), '') AS phone, COALESCE(MAX(NULLIF(b0.oem, '')), '') AS oem, 'bound' AS binding_status ` +
+ `FROM vehicle_identity_binding b0 WHERE b0.vin IS NOT NULL AND b0.vin <> '' GROUP BY b0.vin ` +
+ `UNION ALL ` +
+ `SELECT l0.vin, COALESCE(MAX(NULLIF(l0.plate, '')), '') AS plate, '' AS phone, '' AS oem, 'unbound' AS binding_status ` +
+ `FROM vehicle_realtime_location l0 LEFT JOIN vehicle_identity_binding b1 ON b1.vin = l0.vin ` +
+ `WHERE l0.vin IS NOT NULL AND l0.vin <> '' AND b1.vin IS NULL GROUP BY l0.vin` +
+ `) v`
+ baseGroupSQL := `FROM ` + populationSQL + ` ` +
+ `LEFT JOIN vehicle_realtime_location l ON l.vin = v.vin ` +
+ `LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin AND s.protocol = l.protocol `
+ groupSuffixSQL := `WHERE ` + strings.Join(where, " AND ") + ` ` +
+ `GROUP BY v.vin, v.plate, v.phone, v.oem, v.binding_status ` +
havingSQL
+ groupSQL := baseGroupSQL +
+ `LEFT JOIN (` +
+ `SELECT vin, protocol, MAX(daily_mileage_km) AS daily_mileage_km FROM vehicle_daily_mileage ` +
+ `WHERE stat_date = CURDATE() GROUP BY vin, protocol` +
+ `) m ON m.vin = v.vin AND m.protocol = l.protocol ` +
+ groupSuffixSQL
// 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 10 MINUTE) THEN 0 ELSE 1 END ASC, ` +
- `CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 10 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, ` +
- `COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
+ Text: `SELECT v.vin, ` +
+ `COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') AS plate, ` +
+ `COALESCE(v.phone, '') AS phone, COALESCE(v.oem, '') AS oem, ` +
`COALESCE(GROUP_CONCAT(DISTINCT l.protocol ORDER BY l.protocol SEPARATOR ','), '') AS protocols, ` +
`COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END ORDER BY l.protocol SEPARATOR ','), '') AS online_protocols, ` +
`COUNT(DISTINCT l.protocol) AS source_count, ` +
`COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) AS online_source_count, ` +
`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, ` +
+ `v.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, ` +
+ primaryLocationAvailable + ` AS location_available, ` +
`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(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.speed_kmh IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS speed_available, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` +
+ `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.protocol IN ('GB32960','YUTONG_MQTT') AND l.soc_percent IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS soc_available, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` +
+ `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.total_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS mileage_available, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` +
+ `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN m.daily_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS today_mileage_available, ` +
+ `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.daily_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS today_mileage_km, ` +
`COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
`CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.access_report_interval_ms, -1) ORDER BY ` + orderExpr + `), ',', 1) AS SIGNED) AS report_interval_ms ` +
groupSQL +
- `ORDER BY MAX(l.updated_at) DESC, l.vin ASC LIMIT ? OFFSET ?`,
+ `ORDER BY MAX(l.updated_at) IS NULL ASC, MAX(l.updated_at) DESC, v.vin ASC LIMIT ? OFFSET ?`,
Args: args,
- CountText: `SELECT COUNT(*) FROM (SELECT l.vin ` + groupSQL + `) vehicle_realtime_count`,
+ CountText: `SELECT COUNT(*) FROM (SELECT v.vin ` + baseGroupSQL + groupSuffixSQL + `) vehicle_realtime_count`,
CountArgs: countArgs,
}
}
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 befc0ff8..64f9cf86 100644
--- a/vehicle-data-platform/apps/api/internal/platform/production_store.go
+++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go
@@ -403,14 +403,25 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
var longitude, latitude, speed, soc, mileage string
var conflictDistance sql.NullString
var locationConflict int
+ var locationAvailable int
+ var speedAvailable int
+ var socAvailable int
+ var mileageAvailable int
+ var todayMileageAvailable 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, &row.LocationSource, &locationConflict, &conflictDistance, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen, &reportIntervalMs); err != nil {
+ var todayMileage string
+ 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, &locationAvailable, &longitude, &latitude, &speedAvailable, &speed, &socAvailable, &soc, &mileageAvailable, &mileage, &todayMileageAvailable, &todayMileage, &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
+ row.LocationAvailable = locationAvailable == 1
+ row.SpeedAvailable = speedAvailable == 1
+ row.SOCAvailable = socAvailable == 1
+ row.MileageAvailable = mileageAvailable == 1
+ row.TodayMileageAvailable = todayMileageAvailable == 1
if conflictDistance.Valid {
value := parseFloatString(conflictDistance.String)
if value >= 0 {
@@ -423,6 +434,7 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
row.SpeedKmh = parseFloatString(speed)
row.SOCPercent = parseFloatString(soc)
row.TotalMileageKm = parseFloatString(mileage)
+ row.TodayMileageKm = parseFloatString(todayMileage)
if reportIntervalMs > 0 {
row.ReportIntervalMs = &reportIntervalMs
}
diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go
index f1627de7..7dbbd8b3 100644
--- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go
+++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go
@@ -200,12 +200,12 @@ func TestBuildRealtimeLocationSQL(t *testing.T) {
func TestBuildVehicleRealtimeSQL(t *testing.T) {
query := url.Values{"vin": {"粤A"}, "protocol": {"JT808"}, "online": {"online"}, "limit": {"10"}, "offset": {"20"}}
built := buildVehicleRealtimeSQL(query)
- for _, want := range []string{"vehicle_realtime_location", "vehicle_identity_binding", "vehicle_realtime_snapshot", "access_report_interval_ms", "GROUP BY l.vin", "GROUP_CONCAT(DISTINCT l.protocol", "online_source_count", "vehicle_realtime_count"} {
+ for _, want := range []string{"vehicle_realtime_location", "vehicle_identity_binding", "vehicle_realtime_snapshot", "vehicle_daily_mileage", "access_report_interval_ms", "GROUP BY v.vin", "GROUP_CONCAT(DISTINCT l.protocol", "online_source_count", "location_available", "soc_available", "today_mileage_available", "vehicle_realtime_count"} {
if !strings.Contains(built.Text+built.CountText, want) {
t.Fatalf("SQL missing %q: %s / %s", want, built.Text, built.CountText)
}
}
- if !strings.Contains(built.Text, "ORDER BY MAX(l.updated_at) DESC, l.vin ASC") {
+ if !strings.Contains(built.Text, "ORDER BY MAX(l.updated_at) IS NULL ASC, MAX(l.updated_at) DESC, v.vin ASC") {
t.Fatalf("SQL should keep vehicle-level stable pagination order: %s", built.Text)
}
for _, want := range []string{
@@ -233,7 +233,7 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) {
func TestBuildVehicleRealtimeSQLFiltersMultipleVehicleKeywords(t *testing.T) {
built := buildVehicleRealtimeSQL(url.Values{"keywords": {"粤AG18312, 川AHTWO1, 粤AG18312"}, "limit": {"10"}})
for _, text := range []string{built.Text, built.CountText} {
- if !strings.Contains(text, "l.vin IN (?,?)") || !strings.Contains(text, "l.plate IN (?,?)") || !strings.Contains(text, "b.plate IN (?,?)") {
+ if !strings.Contains(text, "v.vin IN (?,?)") || !strings.Contains(text, "l.plate IN (?,?)") || !strings.Contains(text, "v.plate IN (?,?)") {
t.Fatalf("batch vehicle search must use exact identity sets in rows and count: %s", text)
}
if strings.Contains(text, "LIKE") {
@@ -292,6 +292,20 @@ func TestBuildVehicleRealtimeSQLFiltersMotionStatusBeforePagination(t *testing.T
}
}
+func TestBuildVehicleRealtimeSQLFiltersVehiclesWithoutRealtimeLocation(t *testing.T) {
+ built := buildVehicleRealtimeSQL(url.Values{"status": {"no_location"}, "limit": {"200"}})
+ for _, text := range []string{built.Text, built.CountText} {
+ for _, want := range []string{"vehicle_identity_binding b0", "LEFT JOIN vehicle_realtime_location l ON l.vin = v.vin", "= 0"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("no-location filter must retain identity population and filter after the left join, missing %q: %s", want, text)
+ }
+ }
+ }
+ if !strings.Contains(built.Text, "AS location_available") {
+ t.Fatalf("realtime row must expose explicit location availability: %s", built.Text)
+ }
+}
+
func TestBuildDailyMileageSQL(t *testing.T) {
query := url.Values{"vin": {"VIN001"}, "protocol": {"JT808"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
built := buildDailyMileageSQL(query)
diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go
index 0ad97685..fc01c40e 100644
--- a/vehicle-data-platform/apps/api/internal/platform/service.go
+++ b/vehicle-data-platform/apps/api/internal/platform/service.go
@@ -411,6 +411,10 @@ func (bounds monitorBounds) contains(longitude, latitude float64) bool {
return longitude >= bounds.minLongitude && longitude <= bounds.maxLongitude && latitude >= bounds.minLatitude && latitude <= bounds.maxLatitude
}
+func validRealtimeCoordinate(longitude, latitude float64) bool {
+ return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && (longitude != 0 || latitude != 0)
+}
+
func monitorVehicleStatus(row VehicleRealtimeRow) string {
if strings.TrimSpace(row.LastSeen) == "" {
return "unknown"
@@ -480,6 +484,9 @@ func vehicleSearchKeywords(query url.Values) []string {
func matchesMonitorStatus(row VehicleRealtimeRow, requested string) bool {
requested = strings.TrimSpace(requested)
+ if requested == "no_location" {
+ return !row.LocationAvailable
+ }
return requested == "" || monitorVehicleStatus(row) == requested || requested == "online" && row.Online
}
@@ -522,6 +529,11 @@ func (s *Service) buildMonitorSummary(ctx context.Context, query url.Values, veh
continue
}
result.TotalVehicles++
+ if vehicle.LocationAvailable {
+ result.LocationVehicles++
+ } else {
+ result.NoLocationVehicles++
+ }
if _, active := activeAlertVINs[vehicle.VIN]; active {
result.AlertVehicles++
}
@@ -607,10 +619,10 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
if !matchesMonitorStatus(vehicle, query.Get("status")) {
continue
}
- result.Total++
- if vehicle.Longitude < 73 || vehicle.Longitude > 135 || vehicle.Latitude < 18 || vehicle.Latitude > 54 {
+ if !vehicle.LocationAvailable || vehicle.Longitude < 73 || vehicle.Longitude > 135 || vehicle.Latitude < 18 || vehicle.Latitude > 54 {
continue
}
+ result.Total++
if hasBounds && !bounds.contains(vehicle.Longitude, vehicle.Latitude) {
continue
}
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 2962ceab..dbd51d58 100644
--- a/vehicle-data-platform/apps/api/internal/platform/service_test.go
+++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go
@@ -433,9 +433,9 @@ func TestMonitorWorkspaceSharesOneRealtimeSnapshot(t *testing.T) {
func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testing.T) {
reportIntervalMs := int64(30000)
vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{
- {VIN: "NEAR-1", Plate: "粤A00001", Online: true, Longitude: 113.260, Latitude: 23.130, ReportIntervalMs: &reportIntervalMs},
- {VIN: "NEAR-2", Plate: "粤A00002", Online: true, Longitude: 113.265, Latitude: 23.135},
- {VIN: "SINGLE", Plate: "粤A00003", Online: true, Longitude: 113.600, Latitude: 23.500},
+ {VIN: "NEAR-1", Plate: "粤A00001", Online: true, LocationAvailable: true, Longitude: 113.260, Latitude: 23.130, ReportIntervalMs: &reportIntervalMs},
+ {VIN: "NEAR-2", Plate: "粤A00002", Online: true, LocationAvailable: true, Longitude: 113.265, Latitude: 23.135},
+ {VIN: "SINGLE", Plate: "粤A00003", Online: true, LocationAvailable: true, Longitude: 113.600, Latitude: 23.500},
}, Total: 3, Limit: 3}
mixed, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"10"}})
@@ -475,7 +475,7 @@ func syntheticMonitorVehicles(count int) Page[VehicleRealtimeRow] {
for index := range items {
items[index] = VehicleRealtimeRow{
VIN: "SYNTH" + strconv.Itoa(index), Online: true, SpeedKmh: float64(index % 90),
- Longitude: 73 + float64((index/100)%200)*0.3, Latitude: 18 + float64(index%100)*0.3,
+ LocationAvailable: true, Longitude: 73 + float64((index/100)%200)*0.3, Latitude: 18 + float64(index%100)*0.3,
LastSeen: "2026-07-14T08:00:00+08:00",
}
}
diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts
index 745e1e6a..a26d0fd6 100644
--- a/vehicle-data-platform/apps/web/src/api/types.ts
+++ b/vehicle-data-platform/apps/web/src/api/types.ts
@@ -60,6 +60,8 @@ export interface CustomerUserInput {
export interface MonitorSummary {
totalVehicles: number;
+ locationVehicles?: number;
+ noLocationVehicles?: number;
onlineVehicles: number;
offlineVehicles: number;
drivingVehicles: number;
@@ -593,11 +595,17 @@ export interface VehicleRealtimeRow {
locationSource?: string;
locationConflict?: boolean;
conflictDistanceM?: number;
+ locationAvailable?: boolean;
longitude: number;
latitude: number;
+ speedAvailable?: boolean;
speedKmh: number;
+ socAvailable?: boolean;
socPercent: number;
+ mileageAvailable?: boolean;
totalMileageKm: number;
+ todayMileageAvailable?: boolean;
+ todayMileageKm?: number;
lastSeen: string;
reportIntervalMs?: number;
}
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx
index 6af85090..cecd3a1c 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx
@@ -9,11 +9,13 @@ import { ROUTER_FUTURE } from '../routing/routerConfig';
const vehicles = [{
vin: 'LTEST000000000001', plate: '粤A12345', protocols: ['JT808'], primaryProtocol: 'JT808',
- longitude: 113.26, latitude: 23.13, speedKmh: 42, socPercent: 80, totalMileageKm: 1234,
+ locationAvailable: true, longitude: 113.26, latitude: 23.13, speedAvailable: true, speedKmh: 42,
+ socAvailable: false, socPercent: 0, mileageAvailable: true, totalMileageKm: 1234, todayMileageAvailable: true, todayMileageKm: 18.6,
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
}, {
vin: 'LTEST000000000002', plate: '粤B67890', protocols: ['JT808'], primaryProtocol: 'JT808',
- longitude: 113.28, latitude: 23.15, speedKmh: 0, socPercent: 72, totalMileageKm: 2234,
+ locationAvailable: true, longitude: 113.28, latitude: 23.15, speedAvailable: true, speedKmh: 0,
+ socAvailable: false, socPercent: 0, mileageAvailable: true, totalMileageKm: 2234, todayMileageAvailable: false, todayMileageKm: 0,
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
}] as VehicleRealtimeRow[];
const monitorMap = { clusters: [], points: [], total: 2 };
@@ -58,7 +60,7 @@ vi.mock('../hooks/useMonitorData', () => ({
useMonitorData: (...args: unknown[]) => {
monitorDataArgsSpy(...args);
return {
- summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
+ summary: { data: { totalVehicles: 2, locationVehicles: 2, noLocationVehicles: 0, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } },
vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags },
map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData },
selectedVehicle: { data: { items: [] } }
@@ -95,7 +97,8 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(firstVehicle).not.toHaveClass('is-selected');
expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
- expect(screen.getByText('有实时位置')).toBeInTheDocument();
+ expect(screen.getByText('车辆总数')).toBeInTheDocument();
+ expect(screen.getAllByText('无实时位置').length).toBeGreaterThanOrEqual(2);
expect(screen.getByRole('status', { name: '搜索和筛选条件修改后自动生效' })).toHaveTextContent('实时筛选');
expect(screen.queryByRole('button', { name: '筛选' })).not.toBeInTheDocument();
expect(screen.queryByText('接入车辆')).not.toBeInTheDocument();
@@ -106,6 +109,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
expect(screen.getByRole('button', { name: '取消选择车辆' })).toBeInTheDocument();
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', 'LTEST000000000001');
expect(vehicleCardArgsSpy).toHaveBeenLastCalledWith('LTEST000000000001', vehicles[0], true);
+ expect(screen.getByText('SOC').parentElement).toHaveTextContent('SOC—');
const mapRendersAfterSelection = fleetMapRenderSpy.mock.calls.length;
const cardCallsAfterSelection = vehicleCardArgsSpy.mock.calls.length;
@@ -140,15 +144,17 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
const view = render();
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
- expect(screen.getByText('速度、里程和坐标实时刷新,文字地址按需解析')).toBeInTheDocument();
+ expect(screen.getByText('覆盖全部授权车辆;实时字段缺失时显示“—”,文字地址按需解析')).toBeInTheDocument();
expect(await screen.findAllByText('粤A12345')).toHaveLength(1);
expect(screen.getAllByText('42')).toHaveLength(1);
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
+ expect(screen.getAllByText(/18\.6/)).toHaveLength(1);
expect(screen.getAllByText(/113\.260000/)).toHaveLength(1);
expect(reverseGeocode).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '解析粤A12345位置' }));
expect(await screen.findByText('广东省广州市天河区测试路')).toBeInTheDocument();
+ expect(screen.getByText(/解析于 \d{2}:\d{2}/)).toBeInTheDocument();
expect(reverseGeocode).toHaveBeenCalledTimes(1);
expect(queryClient.getQueryCache().findAll({ queryKey: ['monitor', 'list-address'] })).toHaveLength(1);
expect(screen.queryByText('综合状态')).not.toBeInTheDocument();
@@ -160,6 +166,41 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
view.unmount();
});
+test('keeps authorized vehicles without realtime coordinates in the list without geocoding them', async () => {
+ const row = {
+ ...vehicles[0],
+ vin: 'LNOLOCATION0000001',
+ plate: '粤A无定位',
+ protocols: [],
+ primaryProtocol: '',
+ locationAvailable: false,
+ longitude: 0,
+ latitude: 0,
+ speedAvailable: false,
+ speedKmh: 0,
+ mileageAvailable: false,
+ totalMileageKm: 0,
+ todayMileageAvailable: false,
+ todayMileageKm: 0,
+ lastSeen: '',
+ online: false,
+ sourceCount: 0,
+ onlineSourceCount: 0
+ } satisfies VehicleRealtimeRow;
+ vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });
+ const reverseGeocode = vi.spyOn(api, 'reverseGeocode');
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /列表/ }));
+
+ expect(await screen.findByText('粤A无定位')).toBeInTheDocument();
+ expect(screen.getByText('暂无实时位置')).toBeInTheDocument();
+ expect(screen.getByText('从未上报')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /解析粤A无定位位置/ })).not.toBeInTheDocument();
+ expect(reverseGeocode).not.toHaveBeenCalled();
+});
+
test('pauses selected-vehicle polling in list mode and resumes it when returning to the map', () => {
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
index b008886c..965fac57 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx
@@ -11,7 +11,7 @@ import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQ
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
-const statuses = ['', 'online', 'offline', 'driving', 'idle'];
+const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
const EMPTY_VEHICLES: VehicleRealtimeRow[] = [];
const MOBILE_MONITOR_QUERY = '(max-width: 760px)';
@@ -49,8 +49,27 @@ function useMobileMonitorLayout() {
type AddressCoordinate = { longitude: number; latitude: number; key: string };
+function hasRealtimeLocation(vehicle: VehicleRealtimeRow) {
+ if (vehicle.locationAvailable != null) return vehicle.locationAvailable;
+ return Number.isFinite(vehicle.longitude) && Number.isFinite(vehicle.latitude)
+ && Math.abs(vehicle.longitude) <= 180 && Math.abs(vehicle.latitude) <= 90
+ && (vehicle.longitude !== 0 || vehicle.latitude !== 0);
+}
+
+function hasRealtimeSpeed(vehicle: VehicleRealtimeRow) {
+ return vehicle.speedAvailable ?? hasRealtimeLocation(vehicle);
+}
+
+function hasRealtimeMileage(vehicle: VehicleRealtimeRow) {
+ return vehicle.mileageAvailable ?? hasRealtimeLocation(vehicle);
+}
+
+function hasRealtimeSOC(vehicle: VehicleRealtimeRow) {
+ return vehicle.socAvailable ?? (vehicle.primaryProtocol === 'GB32960' || vehicle.primaryProtocol === 'YUTONG_MQTT');
+}
+
function addressCoordinate(vehicle: VehicleRealtimeRow): AddressCoordinate | undefined {
- if (!Number.isFinite(vehicle.longitude) || !Number.isFinite(vehicle.latitude)
+ if (!hasRealtimeLocation(vehicle) || !Number.isFinite(vehicle.longitude) || !Number.isFinite(vehicle.latitude)
|| Math.abs(vehicle.longitude) > 180 || Math.abs(vehicle.latitude) > 90
|| (vehicle.longitude === 0 && vehicle.latitude === 0)) return undefined;
const longitude = Number(vehicle.longitude.toFixed(4));
@@ -75,29 +94,31 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic
});
const moved = Boolean(current && requested && current.key !== requested.key);
- if (!current) return 无有效坐标;
+ if (!current) return 暂无实时位置;
if (!requested) return ;
if (addressQuery.isFetching && !addressQuery.data) return 解析中;
if (addressQuery.isError) return ;
- return
{addressQuery.data?.formattedAddress || '暂无地址'}{moved ? : null}
;
+ return {addressQuery.data?.formattedAddress || '暂无地址'}{addressQuery.dataUpdatedAt ? `解析于 ${new Date(addressQuery.dataUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })}` : ''}{moved ? : null}
;
});
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
const mobile = useMobileMonitorLayout();
return
-
- {!mobile ? | 车辆 | 速度 | 总里程 | 经纬度 | 地理位置 |
+
+ {!mobile ? | 车辆 | 推荐来源 | 速度 | 里程 | 经纬度 | 地理位置 | 最新上报 |
{rows.map((row) =>
|
- {formatNumber(row.speedKmh, 1)}km/h |
- {formatNumber(row.totalMileageKm, 1)}km |
- {row.longitude.toFixed(6)} {row.latitude.toFixed(6)} |
+ {row.primaryProtocol || '—'} |
+ {hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}{hasRealtimeSpeed(row) ? km/h : null} |
+ 总{hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}今日{row.todayMileageAvailable ? `${formatNumber(row.todayMileageKm ?? 0, 1)} km` : '—'} |
+ {hasRealtimeLocation(row) ? {row.longitude.toFixed(6)} {row.latitude.toFixed(6)} : —} |
|
+ {row.lastSeen ? relativeFreshness(row.lastSeen) : '从未上报'}{row.lastSeen || '—'} |
)}
{loading ?
正在更新车辆实时数据…
: null}{!loading && !rows.length ?
: null}
: null}
{mobile ? {rows.map((row) =>
-
- - 总里程
- {formatNumber(row.totalMileageKm, 1)} km
- 经纬度
{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}- 地理位置
-
+
+ - 总里程
- {hasRealtimeMileage(row) ? `${formatNumber(row.totalMileageKm, 1)} km` : '—'}
- 今日里程
- {row.todayMileageAvailable ? `${formatNumber(row.todayMileageKm ?? 0, 1)} km` : '—'}
- 经纬度
- {hasRealtimeLocation(row) ?
{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)} : '—'} - 最新上报
- {row.lastSeen ? relativeFreshness(row.lastSeen) : '从未上报'}
- 地理位置
+
)}{loading ?
正在更新车辆实时数据…
: null}{!loading && !rows.length ?
: null}
: null}
;
@@ -199,9 +220,9 @@ function VehicleDetailCard({
实时状态
-
速度{formatNumber(vehicle.speedKmh, 1)}km/h
-
SOC{formatNumber(vehicle.socPercent, 1)}%
-
总里程{formatNumber(vehicle.totalMileageKm, 1)}km
+
速度{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}
+
SOC{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}{hasRealtimeSOC(vehicle) ? '%' : ''}
+
总里程{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}{hasRealtimeMileage(vehicle) ? 'km' : ''}
今日里程{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}{dailyMileage == null ? '' : 'km'}
状态{statusLabel(status)}
当前告警{formatNumber(activeAlerts?.total ?? 0)}条
@@ -212,8 +233,8 @@ function VehicleDetailCard({
- 时间
- {vehicle.lastSeen || '暂无'}
- 新鲜度
- {relativeFreshness(vehicle.lastSeen)}
- - 坐标
- {vehicle.longitude.toFixed(6)}, {vehicle.latitude.toFixed(6)}
- - 位置
- {address?.formattedAddress || '位置解析中'}
+ - 坐标
- {hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}
+ - 位置
- {hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}
- 告警状态
- {latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}
@@ -328,7 +349,7 @@ export default function MonitorPage() {
{protocols.map((item) =>
)}
实时筛选
@@ -339,7 +360,8 @@ export default function MonitorPage() {
{[
- ['有实时位置', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
+ ['车辆总数', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
+ ['无实时位置', formatNumber(summary.data?.noLocationVehicles ?? 0), 'missing'],
['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],
diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css
index 2b7f540b..58f91c2e 100644
--- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css
+++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css
@@ -123,7 +123,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-monitor-mode button.is-active { background: #fff; color: var(--v2-blue); box-shadow: 0 1px 4px rgba(21,32,51,.12); font-weight: 700; }
.v2-monitor-mobile-entry { height: 36px; border: 1px solid #dce4ef; background: #fff; }
-.v2-kpis { display: grid; grid-template-columns: repeat(7, minmax(90px, 1fr)); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
+.v2-kpis { display: grid; grid-template-columns: repeat(8, minmax(86px, 1fr)); border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: #fff; box-shadow: var(--v2-shadow); }
.v2-kpi { position: relative; min-width: 0; padding: 10px 14px; }
.v2-kpi + .v2-kpi::before { position: absolute; inset: 12px auto 12px 0; width: 1px; background: var(--v2-border); content: ""; }
.v2-kpi small { display: block; color: var(--v2-muted); font-size: 11px; }
@@ -131,7 +131,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-kpi.is-online strong, .v2-kpi.is-idle strong { color: var(--v2-green); }
.v2-kpi.is-driving strong, .v2-kpi.is-today strong { color: var(--v2-blue); }
.v2-kpi.is-alert strong { color: var(--v2-red); }
-.v2-kpi.is-offline strong { color: #7b8798; }
+.v2-kpi.is-offline strong, .v2-kpi.is-missing strong { color: #7b8798; }
.v2-monitor-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr); grid-template-rows: minmax(0, 1fr); overflow: hidden; border: 1px solid #dce4ee; border-radius: 8px; background: #fff; box-shadow: 0 4px 16px rgba(21,32,51,.04); }
.v2-monitor-workspace.is-detail-open { grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr) clamp(300px, 17vw, 360px); }
@@ -166,9 +166,9 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-monitor-table-panel > header strong { font-size: 14px; }
.v2-monitor-table-panel > header span, .v2-monitor-table-panel > header > b { color: var(--v2-muted); font-size: 11px; font-weight: 500; }
.v2-monitor-table-scroll { position: relative; min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
-.v2-monitor-table-scroll table { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
+.v2-monitor-table-scroll table { width: 100%; min-width: 1080px; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
.v2-monitor-table-scroll th { position: sticky; top: 0; z-index: 3; height: 42px; border-bottom: 1px solid #dce4ee; background: #f7f9fc; padding: 0 14px; color: #607086; font-size: 11px; font-weight: 650; text-align: left; }
-.v2-monitor-table-scroll th:nth-child(1) { width: 180px; }.v2-monitor-table-scroll th:nth-child(2) { width: 92px; }.v2-monitor-table-scroll th:nth-child(3) { width: 128px; }.v2-monitor-table-scroll th:nth-child(4) { width: 164px; }.v2-monitor-table-scroll th:nth-child(5) { width: auto; }
+.v2-monitor-table-scroll th:nth-child(1) { width: 180px; }.v2-monitor-table-scroll th:nth-child(2) { width: 116px; }.v2-monitor-table-scroll th:nth-child(3) { width: 92px; }.v2-monitor-table-scroll th:nth-child(4) { width: 154px; }.v2-monitor-table-scroll th:nth-child(5) { width: 150px; }.v2-monitor-table-scroll th:nth-child(6) { width: auto; }.v2-monitor-table-scroll th:nth-child(7) { width: 148px; }
.v2-monitor-table-scroll td { height: 62px; border-bottom: 1px solid #edf1f6; padding: 7px 14px; vertical-align: middle; }
.v2-monitor-table-scroll tbody tr { transition: background .14s ease; }
.v2-monitor-table-scroll tbody tr:hover { background: #f8fbff; }
@@ -178,11 +178,18 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-monitor-live-value { display: inline !important; color: #213044; font-size: 14px !important; font-variant-numeric: tabular-nums; }
.v2-monitor-live-unit { margin-left: 4px; color: #8b98aa; font-size: 9px; }
.v2-monitor-coordinate { color: #52637a; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 10px; font-variant-numeric: tabular-nums; line-height: 1.55; }
+.v2-monitor-source-badge { display: inline-flex !important; max-width: 100%; align-items: center; overflow: hidden; border: 1px solid #dbe5f1; border-radius: 999px; background: #f7faff; padding: 4px 8px; color: #42648f; font-size: 9px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
+.v2-monitor-mileage, .v2-monitor-last-seen { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
+.v2-monitor-mileage > span { display: grid; min-width: 0; grid-template-columns: 26px minmax(0,1fr); align-items: center; gap: 4px; }
+.v2-monitor-mileage small { color: #8996a8; font-size: 8px; }.v2-monitor-mileage strong { overflow: hidden; color: #34445a; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.v2-monitor-last-seen strong { color: #46566c; font-size: 10px; }.v2-monitor-last-seen span { overflow: hidden; color: #8a97a9; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.v2-monitor-unavailable { color: #a2acb9; }
.v2-monitor-address-action { display: inline-flex; height: 28px; align-items: center; gap: 5px; border: 1px solid #d5e0ee; border-radius: 6px; background: #fff; padding: 0 9px; color: #3568a9; cursor: pointer; font-size: 10px; font-weight: 650; transition: border-color .14s ease, background .14s ease; }
.v2-monitor-address-action:hover { border-color: #9bb8dd; background: #f4f8fe; }.v2-monitor-address-action.is-error { border-color: #f0c9c9; color: #b74b4b; }
.v2-monitor-address-empty, .v2-monitor-address-loading { display: inline-flex; align-items: center; gap: 6px; color: #929dac; font-size: 10px; }
.v2-monitor-address-result { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; gap: 4px; }
.v2-monitor-address-result > span { display: block; width: 100%; overflow: hidden; color: #47576c; font-size: 10px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
+.v2-monitor-address-result > small { color: #98a3b2; font-size: 8px; }
.v2-monitor-address-result > button { border: 0; background: transparent; padding: 0; color: #c27a13; cursor: pointer; font-size: 9px; }
.v2-monitor-source { min-width: 0; border-left: 2px solid #d6dee9; padding-left: 8px; }
.v2-monitor-source.is-online { border-color: #1fb573; }.v2-monitor-source.is-offline, .v2-monitor-source.is-pending, .v2-monitor-source.is-unknown { border-color: #f59e0b; }.v2-monitor-source.is-not-required { opacity: .62; }