feat(platform): expose source slots in vehicle service
This commit is contained in:
@@ -96,6 +96,7 @@ func TestHandlerVehicleCoverage(t *testing.T) {
|
|||||||
Data Page[struct {
|
Data Page[struct {
|
||||||
VIN string `json:"vin"`
|
VIN string `json:"vin"`
|
||||||
MissingProtocols []string `json:"missingProtocols"`
|
MissingProtocols []string `json:"missingProtocols"`
|
||||||
|
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||||
SourceConsistency *VehicleSourceConsistency `json:"sourceConsistency"`
|
SourceConsistency *VehicleSourceConsistency `json:"sourceConsistency"`
|
||||||
}] `json:"data"`
|
}] `json:"data"`
|
||||||
}
|
}
|
||||||
@@ -111,6 +112,18 @@ func TestHandlerVehicleCoverage(t *testing.T) {
|
|||||||
if rawBody.Data.Items[0].SourceConsistency.Status != "degraded" || !containsString(rawBody.Data.Items[0].SourceConsistency.MissingProtocols, "YUTONG_MQTT") {
|
if rawBody.Data.Items[0].SourceConsistency.Status != "degraded" || !containsString(rawBody.Data.Items[0].SourceConsistency.MissingProtocols, "YUTONG_MQTT") {
|
||||||
t.Fatalf("coverage source consistency should diagnose missing source evidence, got %+v body=%s", rawBody.Data.Items[0].SourceConsistency, rec.Body.String())
|
t.Fatalf("coverage source consistency should diagnose missing source evidence, got %+v body=%s", rawBody.Data.Items[0].SourceConsistency, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
byProtocol := map[string]VehicleSourceStatus{}
|
||||||
|
for _, source := range rawBody.Data.Items[0].SourceStatus {
|
||||||
|
byProtocol[source.Protocol] = source
|
||||||
|
}
|
||||||
|
for _, protocol := range []string{"GB32960", "JT808", "YUTONG_MQTT"} {
|
||||||
|
if _, ok := byProtocol[protocol]; !ok {
|
||||||
|
t.Fatalf("coverage row should expose canonical source slot %s, got %+v body=%s", protocol, rawBody.Data.Items[0].SourceStatus, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !byProtocol["JT808"].Online || byProtocol["YUTONG_MQTT"].Online || byProtocol["YUTONG_MQTT"].HasRealtime {
|
||||||
|
t.Fatalf("coverage source slots should expose online and missing evidence, got %+v", byProtocol)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerVehicleCoverageFiltersServiceStatus(t *testing.T) {
|
func TestHandlerVehicleCoverageFiltersServiceStatus(t *testing.T) {
|
||||||
|
|||||||
@@ -133,6 +133,13 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
|||||||
items := make([]VehicleCoverageRow, 0, len(byVIN))
|
items := make([]VehicleCoverageRow, 0, len(byVIN))
|
||||||
for _, row := range byVIN {
|
for _, row := range byVIN {
|
||||||
row.MissingProtocols = missingCanonicalProtocols(row.Protocols)
|
row.MissingProtocols = missingCanonicalProtocols(row.Protocols)
|
||||||
|
onlineProtocols := make([]string, 0, row.OnlineSourceCount)
|
||||||
|
for _, vehicle := range vehicles {
|
||||||
|
if vehicle.VIN == row.VIN && vehicle.Online {
|
||||||
|
onlineProtocols = append(onlineProtocols, vehicle.Protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, onlineProtocols, row.LastSeen)
|
||||||
row.ServiceStatus = buildVehicleCoverageServiceStatus(*row)
|
row.ServiceStatus = buildVehicleCoverageServiceStatus(*row)
|
||||||
row.SourceConsistency = buildVehicleCoverageSourceConsistency(*row)
|
row.SourceConsistency = buildVehicleCoverageSourceConsistency(*row)
|
||||||
if keepCoverageRow(*row, query) {
|
if keepCoverageRow(*row, query) {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type VehicleCoverageRow struct {
|
|||||||
OEM string `json:"oem"`
|
OEM string `json:"oem"`
|
||||||
Protocols []string `json:"protocols"`
|
Protocols []string `json:"protocols"`
|
||||||
MissingProtocols []string `json:"missingProtocols"`
|
MissingProtocols []string `json:"missingProtocols"`
|
||||||
|
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||||
SourceCount int `json:"sourceCount"`
|
SourceCount int `json:"sourceCount"`
|
||||||
OnlineSourceCount int `json:"onlineSourceCount"`
|
OnlineSourceCount int `json:"onlineSourceCount"`
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
|||||||
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
|
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
|
||||||
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
|
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
|
||||||
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
|
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
|
||||||
|
`COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END ORDER BY s.protocol SEPARATOR ','), '') AS online_protocols, ` +
|
||||||
`COUNT(DISTINCT s.protocol) AS source_count, ` +
|
`COUNT(DISTINCT s.protocol) AS source_count, ` +
|
||||||
`COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` +
|
`COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` +
|
||||||
`CASE WHEN COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` +
|
`CASE WHEN COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` +
|
||||||
|
|||||||
@@ -264,12 +264,14 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values)
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var row VehicleCoverageRow
|
var row VehicleCoverageRow
|
||||||
var protocols string
|
var protocols string
|
||||||
|
var onlineProtocols string
|
||||||
var online int
|
var online int
|
||||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil {
|
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil {
|
||||||
return Page[VehicleCoverageRow]{}, err
|
return Page[VehicleCoverageRow]{}, err
|
||||||
}
|
}
|
||||||
row.Protocols = splitCSV(protocols)
|
row.Protocols = splitCSV(protocols)
|
||||||
row.MissingProtocols = missingCanonicalProtocols(row.Protocols)
|
row.MissingProtocols = missingCanonicalProtocols(row.Protocols)
|
||||||
|
row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, splitCSV(onlineProtocols), row.LastSeen)
|
||||||
row.Online = online == 1
|
row.Online = online == 1
|
||||||
row.ServiceStatus = buildVehicleCoverageServiceStatus(row)
|
row.ServiceStatus = buildVehicleCoverageServiceStatus(row)
|
||||||
row.SourceConsistency = buildVehicleCoverageSourceConsistency(row)
|
row.SourceConsistency = buildVehicleCoverageSourceConsistency(row)
|
||||||
|
|||||||
@@ -68,6 +68,23 @@ func missingCanonicalProtocols(protocols []string) []string {
|
|||||||
return missing
|
return missing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildVehicleCoverageSourceStatus(protocols []string, onlineProtocols []string, lastSeen string) []VehicleSourceStatus {
|
||||||
|
statuses := make([]VehicleSourceStatus, 0, len(protocols))
|
||||||
|
for _, protocol := range protocols {
|
||||||
|
protocol = strings.TrimSpace(protocol)
|
||||||
|
if protocol == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
statuses = append(statuses, VehicleSourceStatus{
|
||||||
|
Protocol: protocol,
|
||||||
|
Online: containsString(onlineProtocols, protocol),
|
||||||
|
LastSeen: lastSeen,
|
||||||
|
HasRealtime: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return completeVehicleSourceStatus(statuses, "")
|
||||||
|
}
|
||||||
|
|
||||||
func NewService(store Store) *Service {
|
func NewService(store Store) *Service {
|
||||||
return &Service{store: store}
|
return &Service{store: store}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface VehicleCoverageRow {
|
|||||||
oem: string;
|
oem: string;
|
||||||
protocols: string[];
|
protocols: string[];
|
||||||
missingProtocols: string[];
|
missingProtocols: string[];
|
||||||
|
sourceStatus: VehicleSourceStatus[];
|
||||||
sourceCount: number;
|
sourceCount: number;
|
||||||
onlineSourceCount: number;
|
onlineSourceCount: number;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ function sourceEvidenceText(row: VehicleCoverageRow) {
|
|||||||
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
|
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceStatusLabel(source: { online: boolean; hasRealtime: boolean; lastSeen: string }) {
|
||||||
|
if (source.online) {
|
||||||
|
return { text: '在线', color: 'green' as const };
|
||||||
|
}
|
||||||
|
if (source.hasRealtime || source.lastSeen) {
|
||||||
|
return { text: '离线', color: 'orange' as const };
|
||||||
|
}
|
||||||
|
return { text: '未接入', color: 'grey' as const };
|
||||||
|
}
|
||||||
|
|
||||||
function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record<string, string>) => void) {
|
function sourceConsistencyAction(row: VehicleCoverageRow, onFilter: (filters: Record<string, string>) => void) {
|
||||||
const consistency = row.sourceConsistency;
|
const consistency = row.sourceConsistency;
|
||||||
if (!consistency) {
|
if (!consistency) {
|
||||||
@@ -148,10 +158,13 @@ export function Vehicles({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '来源证据',
|
title: '来源证据',
|
||||||
width: 240,
|
width: 300,
|
||||||
render: (_: unknown, row: VehicleCoverageRow) => (
|
render: (_: unknown, row: VehicleCoverageRow) => (
|
||||||
<Space spacing={4} wrap>
|
<Space spacing={4} wrap>
|
||||||
{row.protocols.map((protocol) => <Tag key={protocol} color="blue">{protocol}</Tag>)}
|
{(row.sourceStatus?.length ? row.sourceStatus : row.protocols.map((protocol) => ({ protocol, online: false, hasRealtime: true, lastSeen: row.lastSeen }))).map((source) => {
|
||||||
|
const status = sourceStatusLabel(source);
|
||||||
|
return <Tag key={source.protocol} color={status.color}>{source.protocol} {status.text}</Tag>;
|
||||||
|
})}
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -289,6 +289,12 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
|
|||||||
phone: '13307795425',
|
phone: '13307795425',
|
||||||
oem: 'G7s',
|
oem: 'G7s',
|
||||||
protocols: ['GB32960', 'JT808'],
|
protocols: ['GB32960', 'JT808'],
|
||||||
|
missingProtocols: ['YUTONG_MQTT'],
|
||||||
|
sourceStatus: [
|
||||||
|
{ protocol: 'GB32960', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: false, hasRaw: false, hasMileage: false },
|
||||||
|
{ protocol: 'JT808', online: false, lastSeen: '2026-07-03 20:10:00', hasRealtime: true, hasHistory: false, hasRaw: false, hasMileage: false },
|
||||||
|
{ protocol: 'YUTONG_MQTT', online: false, lastSeen: '', hasRealtime: false, hasHistory: false, hasRaw: false, hasMileage: false }
|
||||||
|
],
|
||||||
sourceCount: 2,
|
sourceCount: 2,
|
||||||
onlineSourceCount: 1,
|
onlineSourceCount: 1,
|
||||||
online: true,
|
online: true,
|
||||||
@@ -334,6 +340,9 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
|
|||||||
expect(screen.getByText('待绑定')).toBeInTheDocument();
|
expect(screen.getByText('待绑定')).toBeInTheDocument();
|
||||||
expect(screen.getByText('VIN-MULTI-001')).toBeInTheDocument();
|
expect(screen.getByText('VIN-MULTI-001')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('来源证据').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('来源证据').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText('GB32960 在线')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('JT808 离线')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('YUTONG_MQTT 未接入')).toBeInTheDocument();
|
||||||
expect(screen.getByText('1/2 来源在线')).toBeInTheDocument();
|
expect(screen.getByText('1/2 来源在线')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user