feat(platform): expose source consistency
This commit is contained in:
@@ -227,6 +227,32 @@ func TestHandlerVehicleServiceIncludesUnifiedOverview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleServiceIncludesSourceConsistency(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/vehicle-service?keyword=粤AG18312", 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 VehicleDetail `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 body.Data.SourceConsistency == nil {
|
||||
t.Fatalf("vehicle service should include sourceConsistency: %s", rec.Body.String())
|
||||
}
|
||||
consistency := body.Data.SourceConsistency
|
||||
if consistency.SourceCount != 2 || consistency.OnlineSourceCount != 1 || consistency.LocatedSourceCount != 2 {
|
||||
t.Fatalf("sourceConsistency should summarize source coverage, got %+v", consistency)
|
||||
}
|
||||
if consistency.MileageDeltaKm <= 0 || consistency.SourceTimeDeltaSeconds <= 0 {
|
||||
t.Fatalf("sourceConsistency should expose mileage and source time deltas, got %+v", consistency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleServiceOverviewEndpoint(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -85,21 +85,31 @@ type VehicleIdentityResolution struct {
|
||||
}
|
||||
|
||||
type VehicleDetail struct {
|
||||
VIN string `json:"vin"`
|
||||
LookupKey string `json:"lookupKey"`
|
||||
LookupResolved bool `json:"lookupResolved"`
|
||||
Resolution *VehicleIdentityResolution `json:"resolution,omitempty"`
|
||||
Identity *VehicleRow `json:"identity,omitempty"`
|
||||
RealtimeSummary *VehicleRealtimeRow `json:"realtimeSummary,omitempty"`
|
||||
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
|
||||
ServiceOverview *VehicleServiceOverview `json:"serviceOverview,omitempty"`
|
||||
Sources []string `json:"sources"`
|
||||
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||
Realtime []RealtimeLocationRow `json:"realtime"`
|
||||
History Page[HistoryLocationRow] `json:"history"`
|
||||
Raw Page[RawFrameRow] `json:"raw"`
|
||||
Mileage Page[DailyMileageRow] `json:"mileage"`
|
||||
Quality Page[QualityIssueRow] `json:"quality"`
|
||||
VIN string `json:"vin"`
|
||||
LookupKey string `json:"lookupKey"`
|
||||
LookupResolved bool `json:"lookupResolved"`
|
||||
Resolution *VehicleIdentityResolution `json:"resolution,omitempty"`
|
||||
Identity *VehicleRow `json:"identity,omitempty"`
|
||||
RealtimeSummary *VehicleRealtimeRow `json:"realtimeSummary,omitempty"`
|
||||
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
|
||||
ServiceOverview *VehicleServiceOverview `json:"serviceOverview,omitempty"`
|
||||
SourceConsistency *VehicleSourceConsistency `json:"sourceConsistency,omitempty"`
|
||||
Sources []string `json:"sources"`
|
||||
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||
Realtime []RealtimeLocationRow `json:"realtime"`
|
||||
History Page[HistoryLocationRow] `json:"history"`
|
||||
Raw Page[RawFrameRow] `json:"raw"`
|
||||
Mileage Page[DailyMileageRow] `json:"mileage"`
|
||||
Quality Page[QualityIssueRow] `json:"quality"`
|
||||
}
|
||||
|
||||
type VehicleSourceConsistency struct {
|
||||
SourceCount int `json:"sourceCount"`
|
||||
OnlineSourceCount int `json:"onlineSourceCount"`
|
||||
LocatedSourceCount int `json:"locatedSourceCount"`
|
||||
MileageDeltaKm float64 `json:"mileageDeltaKm"`
|
||||
SourceTimeDeltaSeconds float64 `json:"sourceTimeDeltaSeconds"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type VehicleServiceOverview struct {
|
||||
|
||||
@@ -2,10 +2,12 @@ package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
@@ -277,24 +279,120 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
sourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items)
|
||||
sourceStatus = s.enrichVehicleSourceStatus(ctx, queryVIN, sourceStatus)
|
||||
return VehicleDetail{
|
||||
VIN: resolvedVIN,
|
||||
LookupKey: keyword,
|
||||
LookupResolved: resolvedVIN != "",
|
||||
Resolution: &resolution,
|
||||
Identity: identity,
|
||||
RealtimeSummary: summary,
|
||||
ServiceStatus: buildVehicleServiceStatus(true, sourceStatus),
|
||||
ServiceOverview: buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, history, raw, mileage, quality),
|
||||
Sources: sourceNames(sourceStatus),
|
||||
SourceStatus: sourceStatus,
|
||||
Realtime: realtime.Items,
|
||||
History: history,
|
||||
Raw: raw,
|
||||
Mileage: mileage,
|
||||
Quality: quality,
|
||||
VIN: resolvedVIN,
|
||||
LookupKey: keyword,
|
||||
LookupResolved: resolvedVIN != "",
|
||||
Resolution: &resolution,
|
||||
Identity: identity,
|
||||
RealtimeSummary: summary,
|
||||
ServiceStatus: buildVehicleServiceStatus(true, sourceStatus),
|
||||
ServiceOverview: buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, history, raw, mileage, quality),
|
||||
SourceConsistency: buildVehicleSourceConsistency(sourceStatus, realtime.Items, protocol),
|
||||
Sources: sourceNames(sourceStatus),
|
||||
SourceStatus: sourceStatus,
|
||||
Realtime: realtime.Items,
|
||||
History: history,
|
||||
Raw: raw,
|
||||
Mileage: mileage,
|
||||
Quality: quality,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildVehicleSourceConsistency(statuses []VehicleSourceStatus, realtime []RealtimeLocationRow, protocol string) *VehicleSourceConsistency {
|
||||
consistency := &VehicleSourceConsistency{
|
||||
SourceCount: len(statuses),
|
||||
Scope: "all_sources",
|
||||
}
|
||||
if strings.TrimSpace(protocol) != "" {
|
||||
consistency.Scope = "single_source"
|
||||
}
|
||||
if consistency.SourceCount == 0 {
|
||||
seen := map[string]struct{}{}
|
||||
for _, row := range realtime {
|
||||
if strings.TrimSpace(row.Protocol) == "" {
|
||||
continue
|
||||
}
|
||||
seen[row.Protocol] = struct{}{}
|
||||
}
|
||||
consistency.SourceCount = len(seen)
|
||||
}
|
||||
for _, status := range statuses {
|
||||
if status.Online {
|
||||
consistency.OnlineSourceCount++
|
||||
}
|
||||
}
|
||||
var minMileage, maxMileage float64
|
||||
hasMileage := false
|
||||
var minTime, maxTime time.Time
|
||||
hasTime := false
|
||||
for _, row := range realtime {
|
||||
if row.Longitude != 0 || row.Latitude != 0 {
|
||||
consistency.LocatedSourceCount++
|
||||
}
|
||||
if row.TotalMileageKm > 0 {
|
||||
if !hasMileage {
|
||||
minMileage = row.TotalMileageKm
|
||||
maxMileage = row.TotalMileageKm
|
||||
hasMileage = true
|
||||
} else {
|
||||
minMileage = math.Min(minMileage, row.TotalMileageKm)
|
||||
maxMileage = math.Max(maxMileage, row.TotalMileageKm)
|
||||
}
|
||||
}
|
||||
if parsed, ok := parseVehicleServiceTime(row.LastSeen); ok {
|
||||
if !hasTime {
|
||||
minTime = parsed
|
||||
maxTime = parsed
|
||||
hasTime = true
|
||||
} else {
|
||||
if parsed.Before(minTime) {
|
||||
minTime = parsed
|
||||
}
|
||||
if parsed.After(maxTime) {
|
||||
maxTime = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, status := range statuses {
|
||||
if parsed, ok := parseVehicleServiceTime(status.LastSeen); ok {
|
||||
if !hasTime {
|
||||
minTime = parsed
|
||||
maxTime = parsed
|
||||
hasTime = true
|
||||
} else {
|
||||
if parsed.Before(minTime) {
|
||||
minTime = parsed
|
||||
}
|
||||
if parsed.After(maxTime) {
|
||||
maxTime = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasMileage {
|
||||
consistency.MileageDeltaKm = maxMileage - minMileage
|
||||
}
|
||||
if hasTime {
|
||||
consistency.SourceTimeDeltaSeconds = maxTime.Sub(minTime).Seconds()
|
||||
}
|
||||
return consistency
|
||||
}
|
||||
|
||||
func parseVehicleServiceTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05-07:00"}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func buildVehicleServiceOverview(vin string, lookupKey string, resolution *VehicleIdentityResolution, identity *VehicleRow, summary *VehicleRealtimeRow, statuses []VehicleSourceStatus, history Page[HistoryLocationRow], raw Page[RawFrameRow], mileage Page[DailyMileageRow], quality Page[QualityIssueRow]) *VehicleServiceOverview {
|
||||
overview := &VehicleServiceOverview{
|
||||
VIN: strings.TrimSpace(vin),
|
||||
|
||||
Reference in New Issue
Block a user