feat(monitor): retain authorized vehicles without locations

This commit is contained in:
lingniu
2026-07-16 16:04:23 +08:00
parent 4688abadef
commit c224907fad
12 changed files with 352 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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