feat(platform): batch vehicle service overview lookup
This commit is contained in:
@@ -216,6 +216,91 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
|
||||
return page(items, query), nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||
items := make([]VehicleServiceOverview, 0, len(query.Keywords))
|
||||
for _, keyword := range normalizedKeywords(query.Keywords) {
|
||||
vehicles := m.vehiclesForKeyword(keyword, query.Protocol)
|
||||
resolution := buildVehicleIdentityResolution(keyword, vehicles)
|
||||
identity := resolveVehicleIdentity(keyword, vehicles)
|
||||
resolvedVIN := ""
|
||||
if identity != nil && strings.TrimSpace(identity.VIN) != "" {
|
||||
resolvedVIN = identity.VIN
|
||||
} else if resolution.Resolved {
|
||||
resolvedVIN = resolution.VIN
|
||||
}
|
||||
if resolvedVIN == "" {
|
||||
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
|
||||
continue
|
||||
}
|
||||
summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol)
|
||||
sourceStatus := vehicleSourceStatus(vehicles, nil, nil, nil, nil)
|
||||
items = append(items, *buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
|
||||
}
|
||||
return Page[VehicleServiceOverview]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) vehiclesForKeyword(keyword string, protocol string) []VehicleRow {
|
||||
keyword = strings.ToLower(strings.TrimSpace(keyword))
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
out := make([]VehicleRow, 0)
|
||||
for _, vehicle := range m.vehicles {
|
||||
if protocol != "" && vehicle.Protocol != protocol {
|
||||
continue
|
||||
}
|
||||
haystack := strings.ToLower(vehicle.VIN + vehicle.Plate + vehicle.Phone + vehicle.OEM)
|
||||
if keyword == "" || strings.Contains(haystack, keyword) {
|
||||
out = append(out, vehicle)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *MockStore) realtimeSummaryForVIN(vin string, protocol string) *VehicleRealtimeRow {
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
var summary *VehicleRealtimeRow
|
||||
for _, location := range m.locations {
|
||||
if location.VIN != vin {
|
||||
continue
|
||||
}
|
||||
if protocol != "" && location.Protocol != protocol {
|
||||
continue
|
||||
}
|
||||
if summary == nil {
|
||||
vehicle := m.vehicleByVIN(location.VIN)
|
||||
summary = &VehicleRealtimeRow{
|
||||
VIN: location.VIN,
|
||||
Plate: firstNonEmpty(location.Plate, vehicle.Plate),
|
||||
Phone: vehicle.Phone,
|
||||
OEM: vehicle.OEM,
|
||||
BindingStatus: "bound",
|
||||
LastSeen: location.LastSeen,
|
||||
}
|
||||
}
|
||||
if !containsString(summary.Protocols, location.Protocol) {
|
||||
summary.Protocols = append(summary.Protocols, location.Protocol)
|
||||
summary.SourceCount = len(summary.Protocols)
|
||||
}
|
||||
if location.LastSeen >= summary.LastSeen {
|
||||
summary.PrimaryProtocol = location.Protocol
|
||||
summary.Longitude = location.Longitude
|
||||
summary.Latitude = location.Latitude
|
||||
summary.SpeedKmh = location.SpeedKmh
|
||||
summary.SOCPercent = location.SOCPercent
|
||||
summary.TotalMileageKm = location.TotalMileageKm
|
||||
summary.LastSeen = location.LastSeen
|
||||
}
|
||||
if location.LastSeen >= "2026-07-03 20:11:00" {
|
||||
summary.Online = true
|
||||
summary.OnlineSourceCount++
|
||||
}
|
||||
}
|
||||
if summary != nil {
|
||||
sort.Strings(summary.Protocols)
|
||||
summary.ServiceStatus = buildRealtimeServiceStatus(*summary)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (m *MockStore) vehicleByVIN(vin string) VehicleRow {
|
||||
for _, vehicle := range m.vehicles {
|
||||
if vehicle.VIN == vin {
|
||||
|
||||
@@ -228,6 +228,85 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
|
||||
return Page[VehicleRealtimeRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||
keywords := normalizedKeywords(query.Keywords)
|
||||
if len(keywords) == 0 {
|
||||
return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: 0, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
placeholders := repeatPlaceholders(len(keywords))
|
||||
protocolJoin := ""
|
||||
args := make([]any, 0, len(keywords)*5+1)
|
||||
for i := 0; i < 3; i++ {
|
||||
for _, keyword := range keywords {
|
||||
args = append(args, keyword)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
for _, keyword := range keywords {
|
||||
args = append(args, keyword)
|
||||
}
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Protocol); protocol != "" {
|
||||
protocolJoin = " AND s.protocol = ? "
|
||||
args = append(args, protocol)
|
||||
}
|
||||
sqlText := `SELECT i.vin, COALESCE(MAX(NULLIF(i.plate, '')), '') AS plate, ` +
|
||||
`COALESCE(MAX(NULLIF(i.phone, '')), '') AS phone, COALESCE(MAX(NULLIF(i.oem, '')), '') AS oem, ` +
|
||||
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
|
||||
`COUNT(DISTINCT s.protocol) AS source_count, ` +
|
||||
`COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` +
|
||||
`COALESCE(DATE_FORMAT(MAX(s.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(s.protocol ORDER BY s.updated_at DESC, s.protocol ASC), ',', 1), '') AS primary_protocol ` +
|
||||
`FROM (` +
|
||||
`SELECT b.vin, b.plate, b.phone, b.oem FROM vehicle_identity_binding b ` +
|
||||
`WHERE b.vin IN (` + placeholders + `) OR b.plate IN (` + placeholders + `) OR b.phone IN (` + placeholders + `) ` +
|
||||
`UNION ` +
|
||||
`SELECT s.vin, COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem ` +
|
||||
`FROM vehicle_realtime_snapshot s LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` +
|
||||
`WHERE s.vin IN (` + placeholders + `) OR s.plate IN (` + placeholders + `) ` +
|
||||
`GROUP BY s.vin, b.plate, b.phone, b.oem` +
|
||||
`) i LEFT JOIN vehicle_realtime_snapshot s ON s.vin = i.vin ` + protocolJoin +
|
||||
`WHERE i.vin IS NOT NULL AND i.vin <> '' GROUP BY i.vin`
|
||||
rows, err := s.db.QueryContext(ctx, sqlText, args...)
|
||||
if err != nil {
|
||||
return Page[VehicleServiceOverview]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
byKey := map[string]*VehicleServiceOverview{}
|
||||
for rows.Next() {
|
||||
var overview VehicleServiceOverview
|
||||
var protocols string
|
||||
if err := rows.Scan(&overview.VIN, &overview.Plate, &overview.Phone, &overview.OEM, &protocols, &overview.SourceCount, &overview.OnlineSourceCount, &overview.LastSeen, &overview.PrimaryProtocol); err != nil {
|
||||
return Page[VehicleServiceOverview]{}, err
|
||||
}
|
||||
overview.Protocols = splitCSV(protocols)
|
||||
if overview.PrimaryProtocol == "" && len(overview.Protocols) > 0 {
|
||||
overview.PrimaryProtocol = overview.Protocols[0]
|
||||
}
|
||||
overview.RealtimeCount = overview.SourceCount
|
||||
overview.CoverageStatus = coverageStatus(overview.SourceCount, overview.OnlineSourceCount)
|
||||
overview.ServiceStatus = buildVehicleServiceStatus(strings.TrimSpace(overview.VIN) != "", statusesForOverview(&overview))
|
||||
for _, key := range []string{overview.VIN, overview.Plate, overview.Phone} {
|
||||
if trimmed := strings.ToLower(strings.TrimSpace(key)); trimmed != "" {
|
||||
byKey[trimmed] = &overview
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Page[VehicleServiceOverview]{}, err
|
||||
}
|
||||
items := make([]VehicleServiceOverview, 0, len(keywords))
|
||||
for _, keyword := range keywords {
|
||||
if overview := byKey[strings.ToLower(strings.TrimSpace(keyword))]; overview != nil {
|
||||
items = append(items, *overview)
|
||||
continue
|
||||
}
|
||||
resolution := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}}
|
||||
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
|
||||
}
|
||||
return Page[VehicleServiceOverview]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
|
||||
built := buildRealtimeLocationSQL(query)
|
||||
total := 0
|
||||
@@ -685,3 +764,10 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func repeatPlaceholders(count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(strings.Repeat("?,", count), ",")
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ type Store interface {
|
||||
OpsHealth(context.Context) (OpsHealth, error)
|
||||
}
|
||||
|
||||
type VehicleOverviewBatchStore interface {
|
||||
VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error)
|
||||
}
|
||||
|
||||
type RawFrameQuery struct {
|
||||
Protocol string `json:"protocol"`
|
||||
VIN string `json:"vin"`
|
||||
@@ -133,30 +137,32 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr
|
||||
}
|
||||
|
||||
func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||
keywords := normalizedKeywords(query.Keywords)
|
||||
total := len(keywords)
|
||||
if query.Offset < 0 {
|
||||
query.Offset = 0
|
||||
keywords, total, limit, offset := overviewBatchPage(query)
|
||||
if offset >= total {
|
||||
return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
if query.Limit <= 0 || query.Limit > 200 {
|
||||
query.Limit = 200
|
||||
query.Keywords = keywords
|
||||
query.Limit = limit
|
||||
query.Offset = offset
|
||||
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
|
||||
page, err := batchStore.VehicleServiceOverviews(ctx, query)
|
||||
if err != nil {
|
||||
return Page[VehicleServiceOverview]{}, err
|
||||
}
|
||||
page.Total = total
|
||||
page.Limit = limit
|
||||
page.Offset = offset
|
||||
return page, nil
|
||||
}
|
||||
if query.Offset >= total {
|
||||
return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
end := query.Offset + query.Limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
items := make([]VehicleServiceOverview, 0, end-query.Offset)
|
||||
for _, keyword := range keywords[query.Offset:end] {
|
||||
items := make([]VehicleServiceOverview, 0, len(keywords))
|
||||
for _, keyword := range keywords {
|
||||
overview, err := s.VehicleServiceOverview(ctx, keyword, query.Protocol)
|
||||
if err != nil {
|
||||
return Page[VehicleServiceOverview]{}, err
|
||||
}
|
||||
items = append(items, overview)
|
||||
}
|
||||
return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
|
||||
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
||||
@@ -566,6 +572,27 @@ func normalizedKeywords(values []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func overviewBatchPage(query VehicleOverviewBatchQuery) ([]string, int, int, int) {
|
||||
keywords := normalizedKeywords(query.Keywords)
|
||||
total := len(keywords)
|
||||
limit := query.Limit
|
||||
offset := query.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
if offset >= total {
|
||||
return []string{}, total, limit, offset
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return keywords[offset:end], total, limit, offset
|
||||
}
|
||||
|
||||
func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, history []HistoryLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []VehicleSourceStatus {
|
||||
statusByProtocol := map[string]*VehicleSourceStatus{}
|
||||
ensure := func(protocol string) *VehicleSourceStatus {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type countingStore struct {
|
||||
*MockStore
|
||||
vehiclesCalls int
|
||||
vehicleRealtimeCalls int
|
||||
}
|
||||
|
||||
func newCountingStore() *countingStore {
|
||||
return &countingStore{MockStore: NewMockStore()}
|
||||
}
|
||||
|
||||
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
||||
s.vehiclesCalls++
|
||||
return s.MockStore.Vehicles(ctx, query)
|
||||
}
|
||||
|
||||
func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||
s.vehicleRealtimeCalls++
|
||||
return s.MockStore.VehicleRealtime(ctx, query)
|
||||
}
|
||||
|
||||
func TestVehicleServiceOverviewsUsesBatchDataPath(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
service := NewService(store)
|
||||
page, err := service.VehicleServiceOverviews(context.Background(), VehicleOverviewBatchQuery{
|
||||
Keywords: []string{"粤AG18312", "LMRKH9AC2R1004087"},
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VehicleServiceOverviews returned error: %v", err)
|
||||
}
|
||||
if page.Total != 2 || len(page.Items) != 2 {
|
||||
t.Fatalf("expected two overview rows, got %+v", page)
|
||||
}
|
||||
if store.vehiclesCalls > 1 || store.vehicleRealtimeCalls > 1 {
|
||||
t.Fatalf("batch overview should avoid per-keyword store calls, vehicles=%d realtime=%d", store.vehiclesCalls, store.vehicleRealtimeCalls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user