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
|
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 {
|
func (m *MockStore) vehicleByVIN(vin string) VehicleRow {
|
||||||
for _, vehicle := range m.vehicles {
|
for _, vehicle := range m.vehicles {
|
||||||
if vehicle.VIN == vin {
|
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
|
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) {
|
func (s *ProductionStore) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
|
||||||
built := buildRealtimeLocationSQL(query)
|
built := buildRealtimeLocationSQL(query)
|
||||||
total := 0
|
total := 0
|
||||||
@@ -685,3 +764,10 @@ func firstNonEmpty(values ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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)
|
OpsHealth(context.Context) (OpsHealth, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VehicleOverviewBatchStore interface {
|
||||||
|
VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error)
|
||||||
|
}
|
||||||
|
|
||||||
type RawFrameQuery struct {
|
type RawFrameQuery struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
VIN string `json:"vin"`
|
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) {
|
func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||||
keywords := normalizedKeywords(query.Keywords)
|
keywords, total, limit, offset := overviewBatchPage(query)
|
||||||
total := len(keywords)
|
if offset >= total {
|
||||||
if query.Offset < 0 {
|
return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: total, Limit: limit, Offset: offset}, nil
|
||||||
query.Offset = 0
|
|
||||||
}
|
}
|
||||||
if query.Limit <= 0 || query.Limit > 200 {
|
query.Keywords = keywords
|
||||||
query.Limit = 200
|
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 {
|
items := make([]VehicleServiceOverview, 0, len(keywords))
|
||||||
return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
for _, keyword := range keywords {
|
||||||
}
|
|
||||||
end := query.Offset + query.Limit
|
|
||||||
if end > total {
|
|
||||||
end = total
|
|
||||||
}
|
|
||||||
items := make([]VehicleServiceOverview, 0, end-query.Offset)
|
|
||||||
for _, keyword := range keywords[query.Offset:end] {
|
|
||||||
overview, err := s.VehicleServiceOverview(ctx, keyword, query.Protocol)
|
overview, err := s.VehicleServiceOverview(ctx, keyword, query.Protocol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Page[VehicleServiceOverview]{}, err
|
return Page[VehicleServiceOverview]{}, err
|
||||||
}
|
}
|
||||||
items = append(items, overview)
|
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) {
|
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
||||||
@@ -566,6 +572,27 @@ func normalizedKeywords(values []string) []string {
|
|||||||
return out
|
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 {
|
func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, history []HistoryLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []VehicleSourceStatus {
|
||||||
statusByProtocol := map[string]*VehicleSourceStatus{}
|
statusByProtocol := map[string]*VehicleSourceStatus{}
|
||||||
ensure := func(protocol 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