feat(platform): add vehicle service overview

This commit is contained in:
lingniu
2026-07-04 02:49:06 +08:00
parent 131f59360c
commit 38e1bf422a
6 changed files with 267 additions and 12 deletions

View File

@@ -172,6 +172,38 @@ func TestHandlerVehicleServiceIncludesVehicleLevelStatus(t *testing.T) {
}
}
func TestHandlerVehicleServiceIncludesUnifiedOverview(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.ServiceOverview == nil {
t.Fatalf("vehicle service should include serviceOverview: %s", rec.Body.String())
}
overview := body.Data.ServiceOverview
if overview.VIN != "LB9A32A24R0LS1426" || overview.Plate != "粤AG18312" {
t.Fatalf("overview should expose canonical vehicle identity, got %+v", overview)
}
if overview.SourceCount != 2 || overview.OnlineSourceCount != 1 || overview.CoverageStatus != "partial" {
t.Fatalf("overview should summarize source coverage, got %+v", overview)
}
if overview.LastSeen == "" || overview.PrimaryProtocol == "" {
t.Fatalf("overview should expose latest service time and primary source, got %+v", overview)
}
if overview.RealtimeCount == 0 || overview.HistoryCount == 0 || overview.RawCount == 0 || overview.MileageCount == 0 {
t.Fatalf("overview should expose available data counts, got %+v", overview)
}
}
func TestHandlerVehicleDetailResolvesPlateToVIN(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()

View File

@@ -84,6 +84,7 @@ type VehicleDetail struct {
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"`
@@ -93,6 +94,24 @@ type VehicleDetail struct {
Quality Page[QualityIssueRow] `json:"quality"`
}
type VehicleServiceOverview struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
Phone string `json:"phone"`
OEM string `json:"oem"`
Protocols []string `json:"protocols"`
PrimaryProtocol string `json:"primaryProtocol"`
CoverageStatus string `json:"coverageStatus"`
SourceCount int `json:"sourceCount"`
OnlineSourceCount int `json:"onlineSourceCount"`
LastSeen string `json:"lastSeen"`
RealtimeCount int `json:"realtimeCount"`
HistoryCount int `json:"historyCount"`
RawCount int `json:"rawCount"`
MileageCount int `json:"mileageCount"`
QualityIssueCount int `json:"qualityIssueCount"`
}
type VehicleServiceStatus struct {
Status string `json:"status"`
Severity string `json:"severity"`

View File

@@ -115,18 +115,19 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
return VehicleDetail{}, err
}
return VehicleDetail{
VIN: "",
LookupKey: keyword,
LookupResolved: false,
Resolution: &resolution,
ServiceStatus: buildVehicleServiceStatus(false, nil),
Sources: []string{},
SourceStatus: []VehicleSourceStatus{},
Realtime: []RealtimeLocationRow{},
History: Page[HistoryLocationRow]{Items: []HistoryLocationRow{}, Limit: 20, Offset: 0},
Raw: Page[RawFrameRow]{Items: []RawFrameRow{}, Limit: 10, Offset: 0},
Mileage: Page[DailyMileageRow]{Items: []DailyMileageRow{}, Limit: 20, Offset: 0},
Quality: quality,
VIN: "",
LookupKey: keyword,
LookupResolved: false,
Resolution: &resolution,
ServiceStatus: buildVehicleServiceStatus(false, nil),
ServiceOverview: buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, quality),
Sources: []string{},
SourceStatus: []VehicleSourceStatus{},
Realtime: []RealtimeLocationRow{},
History: Page[HistoryLocationRow]{Items: []HistoryLocationRow{}, Limit: 20, Offset: 0},
Raw: Page[RawFrameRow]{Items: []RawFrameRow{}, Limit: 10, Offset: 0},
Mileage: Page[DailyMileageRow]{Items: []DailyMileageRow{}, Limit: 20, Offset: 0},
Quality: quality,
}, nil
}
@@ -179,6 +180,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
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,
@@ -189,6 +191,94 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
}, nil
}
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),
Plate: "",
Phone: "",
OEM: "",
Protocols: sourceNames(statuses),
CoverageStatus: "no_data",
SourceCount: len(statuses),
OnlineSourceCount: 0,
HistoryCount: pageCount(history.Total, len(history.Items)),
RawCount: pageCount(raw.Total, len(raw.Items)),
MileageCount: pageCount(mileage.Total, len(mileage.Items)),
QualityIssueCount: pageCount(quality.Total, len(quality.Items)),
}
if overview.VIN == "" && resolution != nil && resolution.Resolved {
overview.VIN = strings.TrimSpace(resolution.VIN)
}
if overview.VIN == "" {
overview.VIN = strings.TrimSpace(lookupKey)
}
if resolution != nil {
overview.Plate = resolution.Plate
overview.Phone = resolution.Phone
overview.OEM = resolution.OEM
if len(overview.Protocols) == 0 {
overview.Protocols = append([]string(nil), resolution.Protocols...)
overview.SourceCount = len(overview.Protocols)
}
overview.LastSeen = latestString(overview.LastSeen, resolution.LastSeen)
}
if identity != nil {
overview.Plate = firstNonEmpty(overview.Plate, identity.Plate)
overview.Phone = firstNonEmpty(overview.Phone, identity.Phone)
overview.OEM = firstNonEmpty(overview.OEM, identity.OEM)
overview.LastSeen = latestString(overview.LastSeen, identity.LastSeen)
}
if summary != nil {
overview.Plate = firstNonEmpty(overview.Plate, summary.Plate)
overview.Phone = firstNonEmpty(overview.Phone, summary.Phone)
overview.OEM = firstNonEmpty(overview.OEM, summary.OEM)
overview.PrimaryProtocol = summary.PrimaryProtocol
overview.LastSeen = latestString(overview.LastSeen, summary.LastSeen)
overview.RealtimeCount = pageCount(summary.SourceCount, len(summary.Protocols))
if len(statuses) == 0 {
overview.SourceCount = summary.SourceCount
overview.OnlineSourceCount = summary.OnlineSourceCount
}
if len(overview.Protocols) == 0 {
overview.Protocols = append([]string(nil), summary.Protocols...)
}
}
for _, status := range statuses {
if status.Online {
overview.OnlineSourceCount++
}
if overview.PrimaryProtocol == "" && status.Online {
overview.PrimaryProtocol = status.Protocol
}
overview.LastSeen = latestString(overview.LastSeen, status.LastSeen)
}
if overview.PrimaryProtocol == "" && len(overview.Protocols) > 0 {
overview.PrimaryProtocol = overview.Protocols[0]
}
overview.CoverageStatus = coverageStatus(overview.SourceCount, overview.OnlineSourceCount)
return overview
}
func pageCount(total int, itemCount int) int {
if total > 0 {
return total
}
return itemCount
}
func coverageStatus(sourceCount int, onlineSourceCount int) string {
if sourceCount <= 0 {
return "no_data"
}
if onlineSourceCount <= 0 {
return "offline"
}
if onlineSourceCount < sourceCount {
return "partial"
}
return "online"
}
func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
for index := range statuses {
protocol := statuses[index].Protocol