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
-
车辆实时列表速度、里程和坐标实时刷新,文字地址按需解析
{total.toLocaleString('zh-CN')} 辆车辆
- {!mobile ?
+
车辆实时列表覆盖全部授权车辆;实时字段缺失时显示“—”,文字地址按需解析
{total.toLocaleString('zh-CN')} 辆车辆
+ {!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) =>
-
{row.plate || '未绑定车牌'}{row.vin}
{formatNumber(row.speedKmh, 1)}km/h
-
总里程
{formatNumber(row.totalMileageKm, 1)} km
经纬度
{row.longitude.toFixed(6)}, {row.latitude.toFixed(6)}
地理位置
-
+
{row.plate || '未绑定车牌'}{row.vin} · {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) : '从未上报'}
地理位置
+
)}{loading ?
正在更新车辆实时数据…
: null}{!loading && !rows.length ? : null}
: null}
第 {page} / {totalPages} 页
; @@ -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; }