feat(monitor): retain authorized vehicles without locations
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user