diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index d8164be7..5737b870 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -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() diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index e0f1bf76..c43c0358 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -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 { diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index f88c03af..153c167a 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -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), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 9b7a66d2..18ee8346 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -90,6 +90,7 @@ export interface VehicleDetail { realtimeSummary?: VehicleRealtimeRow; serviceStatus?: VehicleServiceStatus; serviceOverview?: VehicleServiceOverview; + sourceConsistency?: VehicleSourceConsistency; sources: string[]; sourceStatus: VehicleSourceStatus[]; realtime: RealtimeLocationRow[]; @@ -99,6 +100,15 @@ export interface VehicleDetail { quality: Page; } +export interface VehicleSourceConsistency { + sourceCount: number; + onlineSourceCount: number; + locatedSourceCount: number; + mileageDeltaKm: number; + sourceTimeDeltaSeconds: number; + scope: string; +} + export interface VehicleServiceOverview { vin: string; plate: string; diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index 37520a67..16fa6ce8 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -130,6 +130,12 @@ export function VehicleDetail({ ...(detail?.sourceStatus ?? []).map((source) => parseTimeMs(source.lastSeen)) ].filter(isFiniteNumber); const sourceTimeDeltaSeconds = timeValues.length > 1 ? (Math.max(...timeValues) - Math.min(...timeValues)) / 1000 : undefined; + const consistency = detail?.sourceConsistency; + const consistencySourceCount = consistency?.sourceCount ?? sourceCount; + const consistencyOnlineSourceCount = consistency?.onlineSourceCount ?? onlineSourceCount; + const consistencyLocatedSourceCount = consistency?.locatedSourceCount ?? locatedSourceCount; + const consistencyMileageDelta = consistency?.mileageDeltaKm ?? mileageDelta; + const consistencyTimeDeltaSeconds = consistency?.sourceTimeDeltaSeconds ?? sourceTimeDeltaSeconds; const activeProtocol = query.protocol?.trim() ?? ''; const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合'; const formKey = `${query.keyword}-${query.protocol ?? ''}`; @@ -322,11 +328,11 @@ export function VehicleDetail({ 0 ? `${sourceCount} 个来源` : '-' }, - { key: '在线来源', value: sourceCount > 0 ? `${onlineSourceCount}/${sourceCount} 在线` : '-' }, - { key: '位置覆盖', value: locatedSourceCount > 0 ? `${locatedSourceCount} 个来源有位置` : '暂无位置' }, - { key: '里程差异', value: formatCompactNumber(mileageDelta, ' km') }, - { key: '来源时间差', value: formatSeconds(sourceTimeDeltaSeconds) }, + { key: '来源覆盖', value: consistencySourceCount > 0 ? `${consistencySourceCount} 个来源` : '-' }, + { key: '在线来源', value: consistencySourceCount > 0 ? `${consistencyOnlineSourceCount}/${consistencySourceCount} 在线` : '-' }, + { key: '位置覆盖', value: consistencyLocatedSourceCount > 0 ? `${consistencyLocatedSourceCount} 个来源有位置` : '暂无位置' }, + { key: '里程差异', value: formatCompactNumber(consistencyMileageDelta, ' km') }, + { key: '来源时间差', value: formatSeconds(consistencyTimeDeltaSeconds) }, { key: '车辆服务视角', value: activeProtocol ? `当前只看 ${activeProtocol}` : '三类来源合并为同一车辆服务' } ]} /> diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index c3e9a49b..2bd4b87e 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -1762,6 +1762,14 @@ test('shows cross-source consistency for one vehicle service', async () => { totalMileageKm: 1000.5, lastSeen: '2026-07-03T20:12:10+08:00' }, + sourceConsistency: { + sourceCount: 3, + onlineSourceCount: 2, + locatedSourceCount: 3, + mileageDeltaKm: 0.4, + sourceTimeDeltaSeconds: 35, + scope: 'all_sources' + }, sources: ['GB32960', 'JT808', 'YUTONG_MQTT'], sourceStatus: [ { protocol: 'GB32960', online: true, lastSeen: '2026-07-03T20:12:10+08:00', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }, @@ -1801,6 +1809,6 @@ test('shows cross-source consistency for one vehicle service', async () => { expect(screen.getByText('3 个来源')).toBeInTheDocument(); expect(screen.getByText('2/3 在线')).toBeInTheDocument(); expect(screen.getByText('3 个来源有位置')).toBeInTheDocument(); - expect(screen.getByText('0.3 km')).toBeInTheDocument(); - expect(screen.getByText('30 秒')).toBeInTheDocument(); + expect(screen.getByText('0.4 km')).toBeInTheDocument(); + expect(screen.getByText('35 秒')).toBeInTheDocument(); });