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) {
|
func TestHandlerVehicleServiceOverviewEndpoint(t *testing.T) {
|
||||||
handler := NewHandler(NewService(NewMockStore()))
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ type VehicleDetail struct {
|
|||||||
RealtimeSummary *VehicleRealtimeRow `json:"realtimeSummary,omitempty"`
|
RealtimeSummary *VehicleRealtimeRow `json:"realtimeSummary,omitempty"`
|
||||||
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
|
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
|
||||||
ServiceOverview *VehicleServiceOverview `json:"serviceOverview,omitempty"`
|
ServiceOverview *VehicleServiceOverview `json:"serviceOverview,omitempty"`
|
||||||
|
SourceConsistency *VehicleSourceConsistency `json:"sourceConsistency,omitempty"`
|
||||||
Sources []string `json:"sources"`
|
Sources []string `json:"sources"`
|
||||||
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||||
Realtime []RealtimeLocationRow `json:"realtime"`
|
Realtime []RealtimeLocationRow `json:"realtime"`
|
||||||
@@ -102,6 +103,15 @@ type VehicleDetail struct {
|
|||||||
Quality Page[QualityIssueRow] `json:"quality"`
|
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 {
|
type VehicleServiceOverview struct {
|
||||||
VIN string `json:"vin"`
|
VIN string `json:"vin"`
|
||||||
Plate string `json:"plate"`
|
Plate string `json:"plate"`
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package platform
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"math"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
@@ -285,6 +287,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
|||||||
RealtimeSummary: summary,
|
RealtimeSummary: summary,
|
||||||
ServiceStatus: buildVehicleServiceStatus(true, sourceStatus),
|
ServiceStatus: buildVehicleServiceStatus(true, sourceStatus),
|
||||||
ServiceOverview: buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, history, raw, mileage, quality),
|
ServiceOverview: buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, history, raw, mileage, quality),
|
||||||
|
SourceConsistency: buildVehicleSourceConsistency(sourceStatus, realtime.Items, protocol),
|
||||||
Sources: sourceNames(sourceStatus),
|
Sources: sourceNames(sourceStatus),
|
||||||
SourceStatus: sourceStatus,
|
SourceStatus: sourceStatus,
|
||||||
Realtime: realtime.Items,
|
Realtime: realtime.Items,
|
||||||
@@ -295,6 +298,101 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
|||||||
}, nil
|
}, 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 {
|
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{
|
overview := &VehicleServiceOverview{
|
||||||
VIN: strings.TrimSpace(vin),
|
VIN: strings.TrimSpace(vin),
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export interface VehicleDetail {
|
|||||||
realtimeSummary?: VehicleRealtimeRow;
|
realtimeSummary?: VehicleRealtimeRow;
|
||||||
serviceStatus?: VehicleServiceStatus;
|
serviceStatus?: VehicleServiceStatus;
|
||||||
serviceOverview?: VehicleServiceOverview;
|
serviceOverview?: VehicleServiceOverview;
|
||||||
|
sourceConsistency?: VehicleSourceConsistency;
|
||||||
sources: string[];
|
sources: string[];
|
||||||
sourceStatus: VehicleSourceStatus[];
|
sourceStatus: VehicleSourceStatus[];
|
||||||
realtime: RealtimeLocationRow[];
|
realtime: RealtimeLocationRow[];
|
||||||
@@ -99,6 +100,15 @@ export interface VehicleDetail {
|
|||||||
quality: Page<QualityIssueRow>;
|
quality: Page<QualityIssueRow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VehicleSourceConsistency {
|
||||||
|
sourceCount: number;
|
||||||
|
onlineSourceCount: number;
|
||||||
|
locatedSourceCount: number;
|
||||||
|
mileageDeltaKm: number;
|
||||||
|
sourceTimeDeltaSeconds: number;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VehicleServiceOverview {
|
export interface VehicleServiceOverview {
|
||||||
vin: string;
|
vin: string;
|
||||||
plate: string;
|
plate: string;
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ export function VehicleDetail({
|
|||||||
...(detail?.sourceStatus ?? []).map((source) => parseTimeMs(source.lastSeen))
|
...(detail?.sourceStatus ?? []).map((source) => parseTimeMs(source.lastSeen))
|
||||||
].filter(isFiniteNumber);
|
].filter(isFiniteNumber);
|
||||||
const sourceTimeDeltaSeconds = timeValues.length > 1 ? (Math.max(...timeValues) - Math.min(...timeValues)) / 1000 : undefined;
|
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 activeProtocol = query.protocol?.trim() ?? '';
|
||||||
const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合';
|
const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合';
|
||||||
const formKey = `${query.keyword}-${query.protocol ?? ''}`;
|
const formKey = `${query.keyword}-${query.protocol ?? ''}`;
|
||||||
@@ -322,11 +328,11 @@ export function VehicleDetail({
|
|||||||
<Descriptions
|
<Descriptions
|
||||||
row
|
row
|
||||||
data={[
|
data={[
|
||||||
{ key: '来源覆盖', value: sourceCount > 0 ? `${sourceCount} 个来源` : '-' },
|
{ key: '来源覆盖', value: consistencySourceCount > 0 ? `${consistencySourceCount} 个来源` : '-' },
|
||||||
{ key: '在线来源', value: sourceCount > 0 ? `${onlineSourceCount}/${sourceCount} 在线` : '-' },
|
{ key: '在线来源', value: consistencySourceCount > 0 ? `${consistencyOnlineSourceCount}/${consistencySourceCount} 在线` : '-' },
|
||||||
{ key: '位置覆盖', value: locatedSourceCount > 0 ? `${locatedSourceCount} 个来源有位置` : '暂无位置' },
|
{ key: '位置覆盖', value: consistencyLocatedSourceCount > 0 ? `${consistencyLocatedSourceCount} 个来源有位置` : '暂无位置' },
|
||||||
{ key: '里程差异', value: formatCompactNumber(mileageDelta, ' km') },
|
{ key: '里程差异', value: formatCompactNumber(consistencyMileageDelta, ' km') },
|
||||||
{ key: '来源时间差', value: formatSeconds(sourceTimeDeltaSeconds) },
|
{ key: '来源时间差', value: formatSeconds(consistencyTimeDeltaSeconds) },
|
||||||
{ key: '车辆服务视角', value: activeProtocol ? `当前只看 ${activeProtocol}` : '三类来源合并为同一车辆服务' }
|
{ key: '车辆服务视角', value: activeProtocol ? `当前只看 ${activeProtocol}` : '三类来源合并为同一车辆服务' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1762,6 +1762,14 @@ test('shows cross-source consistency for one vehicle service', async () => {
|
|||||||
totalMileageKm: 1000.5,
|
totalMileageKm: 1000.5,
|
||||||
lastSeen: '2026-07-03T20:12:10+08:00'
|
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'],
|
sources: ['GB32960', 'JT808', 'YUTONG_MQTT'],
|
||||||
sourceStatus: [
|
sourceStatus: [
|
||||||
{ protocol: 'GB32960', online: true, lastSeen: '2026-07-03T20:12:10+08:00', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true },
|
{ 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('3 个来源')).toBeInTheDocument();
|
||||||
expect(screen.getByText('2/3 在线')).toBeInTheDocument();
|
expect(screen.getByText('2/3 在线')).toBeInTheDocument();
|
||||||
expect(screen.getByText('3 个来源有位置')).toBeInTheDocument();
|
expect(screen.getByText('3 个来源有位置')).toBeInTheDocument();
|
||||||
expect(screen.getByText('0.3 km')).toBeInTheDocument();
|
expect(screen.getByText('0.4 km')).toBeInTheDocument();
|
||||||
expect(screen.getByText('30 秒')).toBeInTheDocument();
|
expect(screen.getByText('35 秒')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user