feat(platform): add vehicle service overview
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface VehicleDetail {
|
||||
identity?: VehicleRow;
|
||||
realtimeSummary?: VehicleRealtimeRow;
|
||||
serviceStatus?: VehicleServiceStatus;
|
||||
serviceOverview?: VehicleServiceOverview;
|
||||
sources: string[];
|
||||
sourceStatus: VehicleSourceStatus[];
|
||||
realtime: RealtimeLocationRow[];
|
||||
@@ -90,6 +91,24 @@ export interface VehicleDetail {
|
||||
quality: Page<QualityIssueRow>;
|
||||
}
|
||||
|
||||
export interface VehicleServiceOverview {
|
||||
vin: string;
|
||||
plate: string;
|
||||
phone: string;
|
||||
oem: string;
|
||||
protocols: string[];
|
||||
primaryProtocol: string;
|
||||
coverageStatus: string;
|
||||
sourceCount: number;
|
||||
onlineSourceCount: number;
|
||||
lastSeen: string;
|
||||
realtimeCount: number;
|
||||
historyCount: number;
|
||||
rawCount: number;
|
||||
mileageCount: number;
|
||||
qualityIssueCount: number;
|
||||
}
|
||||
|
||||
export interface VehicleServiceStatus {
|
||||
status: string;
|
||||
severity: string;
|
||||
|
||||
@@ -19,6 +19,13 @@ const sourceCapabilities: Array<{ key: keyof Pick<VehicleSourceStatus, 'hasRealt
|
||||
{ key: 'hasMileage', label: '里程' }
|
||||
];
|
||||
|
||||
const coverageStatusText: Record<string, string> = {
|
||||
online: '全部在线',
|
||||
partial: '部分在线',
|
||||
offline: '全部离线',
|
||||
no_data: '暂无来源'
|
||||
};
|
||||
|
||||
export function VehicleDetail({
|
||||
vin,
|
||||
protocol,
|
||||
@@ -78,6 +85,7 @@ export function VehicleDetail({
|
||||
const summary = detail?.realtimeSummary;
|
||||
const latest = detail?.realtime?.[0];
|
||||
const resolution = detail?.resolution;
|
||||
const overview = detail?.serviceOverview;
|
||||
const resolvedVIN = resolution?.vin || detail?.vin || summary?.vin || identity?.vin || latest?.vin || query.keyword;
|
||||
const hasResolvedVIN = resolution?.resolved ?? detail?.lookupResolved ?? isLikelyVIN(resolvedVIN);
|
||||
const displayVIN = hasResolvedVIN ? resolvedVIN : '-';
|
||||
@@ -156,6 +164,24 @@ export function VehicleDetail({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{overview ? (
|
||||
<Card bordered title="车辆服务概览" style={{ marginTop: 16 }}>
|
||||
<Descriptions
|
||||
row
|
||||
data={[
|
||||
{ key: '服务车辆', value: [overview.plate, overview.vin].filter(Boolean).join(' / ') || '-' },
|
||||
{ key: '在线来源', value: `${overview.onlineSourceCount}/${overview.sourceCount}` },
|
||||
{ key: '覆盖状态', value: <Tag color={overview.coverageStatus === 'online' ? 'green' : overview.coverageStatus === 'partial' ? 'orange' : 'grey'}>{coverageStatusText[overview.coverageStatus] ?? overview.coverageStatus}</Tag> },
|
||||
{ key: '主来源', value: overview.primaryProtocol || '-' },
|
||||
{ key: '最后上报', value: overview.lastSeen || '-' },
|
||||
{ key: '数据规模', value: `历史 ${overview.historyCount} / RAW ${overview.rawCount} / 里程 ${overview.mileageCount}` },
|
||||
{ key: '实时来源', value: overview.realtimeCount },
|
||||
{ key: '质量问题', value: <Tag color={overview.qualityIssueCount > 0 ? 'orange' : 'green'}>{overview.qualityIssueCount}</Tag> }
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card bordered style={{ marginTop: 16 }}>
|
||||
<div className="vp-vehicle-summary">
|
||||
<Descriptions
|
||||
|
||||
@@ -1056,6 +1056,75 @@ test('switches vehicle detail to a source from the coverage cards', async () =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined);
|
||||
});
|
||||
|
||||
test('shows unified service overview on vehicle detail', async () => {
|
||||
window.history.replaceState(null, '', '/#/detail?keyword=VIN001');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path.includes('/api/vehicle-service')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
vin: 'VIN001',
|
||||
lookupKey: 'VIN001',
|
||||
lookupResolved: true,
|
||||
serviceOverview: {
|
||||
vin: 'VIN001',
|
||||
plate: '粤AG18312',
|
||||
phone: '13307795425',
|
||||
oem: 'G7s',
|
||||
protocols: ['GB32960', 'JT808', 'YUTONG_MQTT'],
|
||||
primaryProtocol: 'GB32960',
|
||||
coverageStatus: 'partial',
|
||||
sourceCount: 3,
|
||||
onlineSourceCount: 2,
|
||||
lastSeen: '2026-07-03 20:12:10',
|
||||
realtimeCount: 3,
|
||||
historyCount: 12,
|
||||
rawCount: 34,
|
||||
mileageCount: 7,
|
||||
qualityIssueCount: 1
|
||||
},
|
||||
serviceStatus: {
|
||||
status: 'degraded',
|
||||
severity: 'warning',
|
||||
title: '部分来源离线',
|
||||
detail: '2/3 个来源在线',
|
||||
sourceCount: 3,
|
||||
onlineSourceCount: 2
|
||||
},
|
||||
sources: ['GB32960', 'JT808', 'YUTONG_MQTT'],
|
||||
sourceStatus: [],
|
||||
realtime: [],
|
||||
history: { items: [], total: 12, limit: 20, offset: 0 },
|
||||
raw: { items: [], total: 34, limit: 10, offset: 0 },
|
||||
mileage: { items: [], total: 7, limit: 20, offset: 0 },
|
||||
quality: { items: [], total: 1, limit: 20, offset: 0 }
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: path.includes('/api/ops/health')
|
||||
? { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }
|
||||
: { items: [], total: 0, limit: 10, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('车辆服务概览')).toBeInTheDocument();
|
||||
expect(screen.getByText('2/3')).toBeInTheDocument();
|
||||
expect(screen.getByText('历史 12 / RAW 34 / 里程 7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens history with the currently selected vehicle detail source', async () => {
|
||||
window.history.replaceState(null, '', '/#/detail?keyword=VIN001');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
|
||||
Reference in New Issue
Block a user