feat(platform): expose no-data vehicles
This commit is contained in:
@@ -201,7 +201,7 @@ func traceID(r *http.Request) string {
|
|||||||
|
|
||||||
func splitCSV(value string) []string {
|
func splitCSV(value string) []string {
|
||||||
if strings.TrimSpace(value) == "" {
|
if strings.TrimSpace(value) == "" {
|
||||||
return nil
|
return []string{}
|
||||||
}
|
}
|
||||||
parts := strings.Split(value, ",")
|
parts := strings.Split(value, ",")
|
||||||
out := make([]string, 0, len(parts))
|
out := make([]string, 0, len(parts))
|
||||||
|
|||||||
@@ -131,6 +131,16 @@ func TestHandlerVehicleCoverageSummary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSplitCSVEmptyReturnsEmptySlice(t *testing.T) {
|
||||||
|
values := splitCSV("")
|
||||||
|
if values == nil {
|
||||||
|
t.Fatalf("empty CSV should encode as [] instead of JSON null")
|
||||||
|
}
|
||||||
|
if len(values) != 0 {
|
||||||
|
t.Fatalf("empty CSV should have no values, got %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandlerVehicleDetail(t *testing.T) {
|
func TestHandlerVehicleDetail(t *testing.T) {
|
||||||
handler := NewHandler(NewService(NewMockStore()))
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values
|
|||||||
if row.SourceCount > 1 {
|
if row.SourceCount > 1 {
|
||||||
summary.MultiSourceVehicles++
|
summary.MultiSourceVehicles++
|
||||||
}
|
}
|
||||||
|
if row.SourceCount == 0 {
|
||||||
|
summary.NoDataVehicles++
|
||||||
|
}
|
||||||
if row.BindingStatus != "bound" {
|
if row.BindingStatus != "bound" {
|
||||||
summary.UnboundVehicles++
|
summary.UnboundVehicles++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ type VehicleCoverageSummary struct {
|
|||||||
OnlineVehicles int `json:"onlineVehicles"`
|
OnlineVehicles int `json:"onlineVehicles"`
|
||||||
SingleSourceVehicles int `json:"singleSourceVehicles"`
|
SingleSourceVehicles int `json:"singleSourceVehicles"`
|
||||||
MultiSourceVehicles int `json:"multiSourceVehicles"`
|
MultiSourceVehicles int `json:"multiSourceVehicles"`
|
||||||
|
NoDataVehicles int `json:"noDataVehicles"`
|
||||||
UnboundVehicles int `json:"unboundVehicles"`
|
UnboundVehicles int `json:"unboundVehicles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
|||||||
limit := parsePositive(query.Get("limit"), 20)
|
limit := parsePositive(query.Get("limit"), 20)
|
||||||
offset := parsePositive(query.Get("offset"), 0)
|
offset := parsePositive(query.Get("offset"), 0)
|
||||||
args := []any{}
|
args := []any{}
|
||||||
where := []string{"s.vin IS NOT NULL", "s.vin <> ''"}
|
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
||||||
having := []string{}
|
having := []string{}
|
||||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||||
where = append(where, "(s.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||||
like := "%" + keyword + "%"
|
like := "%" + keyword + "%"
|
||||||
args = append(args, like, like, like, like, like, like)
|
args = append(args, like, like, like, like, like, like)
|
||||||
}
|
}
|
||||||
@@ -99,6 +99,9 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
|||||||
switch strings.TrimSpace(query.Get("serviceStatus")) {
|
switch strings.TrimSpace(query.Get("serviceStatus")) {
|
||||||
case "identity_required":
|
case "identity_required":
|
||||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
|
||||||
|
case "no_data":
|
||||||
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
||||||
|
having = append(having, "COUNT(DISTINCT s.protocol) = 0")
|
||||||
case "offline":
|
case "offline":
|
||||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
||||||
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
|
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
|
||||||
@@ -119,13 +122,16 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
|||||||
if len(having) > 0 {
|
if len(having) > 0 {
|
||||||
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
|
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
|
||||||
}
|
}
|
||||||
groupSQL := `FROM vehicle_realtime_snapshot s ` +
|
vehicleSetSQL := `SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> '' ` +
|
||||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` +
|
`UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> ''`
|
||||||
|
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
|
||||||
|
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
||||||
|
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
||||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||||
`GROUP BY s.vin, b.plate, b.phone, b.oem, b.vin ` +
|
`GROUP BY v.vin, b.plate, b.phone, b.oem, b.vin ` +
|
||||||
havingSQL
|
havingSQL
|
||||||
return SQLQuery{
|
return SQLQuery{
|
||||||
Text: `SELECT s.vin, ` +
|
Text: `SELECT v.vin, ` +
|
||||||
`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, ` +
|
||||||
@@ -135,19 +141,19 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
|||||||
`COALESCE(DATE_FORMAT(MAX(s.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
`COALESCE(DATE_FORMAT(MAX(s.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
||||||
`CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 'bound' ELSE 'unbound' END AS binding_status ` +
|
`CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 'bound' ELSE 'unbound' END AS binding_status ` +
|
||||||
groupSQL +
|
groupSQL +
|
||||||
`ORDER BY MAX(s.updated_at) DESC, s.vin ASC LIMIT ? OFFSET ?`,
|
`ORDER BY MAX(s.updated_at) DESC, v.vin ASC LIMIT ? OFFSET ?`,
|
||||||
Args: args,
|
Args: args,
|
||||||
CountText: `SELECT COUNT(*) FROM (SELECT s.vin ` + groupSQL + `) vehicle_coverage_count`,
|
CountText: `SELECT COUNT(*) FROM (SELECT v.vin ` + groupSQL + `) vehicle_coverage_count`,
|
||||||
CountArgs: countArgs,
|
CountArgs: countArgs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||||
args := []any{}
|
args := []any{}
|
||||||
where := []string{"s.vin IS NOT NULL", "s.vin <> ''"}
|
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
||||||
having := []string{}
|
having := []string{}
|
||||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||||
where = append(where, "(s.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||||
like := "%" + keyword + "%"
|
like := "%" + keyword + "%"
|
||||||
args = append(args, like, like, like, like, like, like)
|
args = append(args, like, like, like, like, like, like)
|
||||||
}
|
}
|
||||||
@@ -176,6 +182,9 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
|||||||
switch strings.TrimSpace(query.Get("serviceStatus")) {
|
switch strings.TrimSpace(query.Get("serviceStatus")) {
|
||||||
case "identity_required":
|
case "identity_required":
|
||||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
|
||||||
|
case "no_data":
|
||||||
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
||||||
|
having = append(having, "COUNT(DISTINCT s.protocol) = 0")
|
||||||
case "offline":
|
case "offline":
|
||||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
||||||
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
|
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
|
||||||
@@ -194,19 +203,23 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
|||||||
if len(having) > 0 {
|
if len(having) > 0 {
|
||||||
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
|
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
|
||||||
}
|
}
|
||||||
groupSQL := `SELECT s.vin, ` +
|
vehicleSetSQL := `SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> '' ` +
|
||||||
|
`UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> ''`
|
||||||
|
groupSQL := `SELECT v.vin, ` +
|
||||||
`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, ` +
|
||||||
`MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) AS bound ` +
|
`MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) AS bound ` +
|
||||||
`FROM vehicle_realtime_snapshot s ` +
|
`FROM (` + vehicleSetSQL + `) v ` +
|
||||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` +
|
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
||||||
|
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
||||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||||
`GROUP BY s.vin ` + havingSQL
|
`GROUP BY v.vin ` + havingSQL
|
||||||
return SQLQuery{
|
return SQLQuery{
|
||||||
Text: `SELECT COUNT(*) AS total_vehicles, ` +
|
Text: `SELECT COUNT(*) AS total_vehicles, ` +
|
||||||
`COALESCE(SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END), 0) AS online_vehicles, ` +
|
`COALESCE(SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END), 0) AS online_vehicles, ` +
|
||||||
`COALESCE(SUM(CASE WHEN source_count = 1 THEN 1 ELSE 0 END), 0) AS single_source_vehicles, ` +
|
`COALESCE(SUM(CASE WHEN source_count = 1 THEN 1 ELSE 0 END), 0) AS single_source_vehicles, ` +
|
||||||
`COALESCE(SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END), 0) AS multi_source_vehicles, ` +
|
`COALESCE(SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END), 0) AS multi_source_vehicles, ` +
|
||||||
|
`COALESCE(SUM(CASE WHEN source_count = 0 THEN 1 ELSE 0 END), 0) AS no_data_vehicles, ` +
|
||||||
`COALESCE(SUM(CASE WHEN bound = 0 THEN 1 ELSE 0 END), 0) AS unbound_vehicles ` +
|
`COALESCE(SUM(CASE WHEN bound = 0 THEN 1 ELSE 0 END), 0) AS unbound_vehicles ` +
|
||||||
`FROM (` + groupSQL + `) vehicle_coverage_summary`,
|
`FROM (` + groupSQL + `) vehicle_coverage_summary`,
|
||||||
Args: args,
|
Args: args,
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url.
|
|||||||
&summary.OnlineVehicles,
|
&summary.OnlineVehicles,
|
||||||
&summary.SingleSourceVehicles,
|
&summary.SingleSourceVehicles,
|
||||||
&summary.MultiSourceVehicles,
|
&summary.MultiSourceVehicles,
|
||||||
|
&summary.NoDataVehicles,
|
||||||
&summary.UnboundVehicles,
|
&summary.UnboundVehicles,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return VehicleCoverageSummary{}, err
|
return VehicleCoverageSummary{}, err
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) {
|
|||||||
func TestBuildVehicleCoverageSQL(t *testing.T) {
|
func TestBuildVehicleCoverageSQL(t *testing.T) {
|
||||||
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}}
|
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}}
|
||||||
built := buildVehicleCoverageSQL(query)
|
built := buildVehicleCoverageSQL(query)
|
||||||
for _, want := range []string{"GROUP BY s.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, s.vin ASC"} {
|
for _, want := range []string{"GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} {
|
||||||
if !strings.Contains(built.Text, want) {
|
if !strings.Contains(built.Text, want) {
|
||||||
t.Fatalf("SQL missing %q: %s", want, built.Text)
|
t.Fatalf("SQL missing %q: %s", want, built.Text)
|
||||||
}
|
}
|
||||||
@@ -83,6 +83,23 @@ func TestBuildVehicleCoverageSQLFiltersServiceStatus(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildVehicleCoverageSQLIncludesNoDataVehicles(t *testing.T) {
|
||||||
|
query := url.Values{"serviceStatus": {"no_data"}, "limit": {"8"}}
|
||||||
|
built := buildVehicleCoverageSQL(query)
|
||||||
|
for _, want := range []string{
|
||||||
|
"vehicle_identity_binding",
|
||||||
|
"UNION",
|
||||||
|
"vehicle_realtime_snapshot",
|
||||||
|
"LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin",
|
||||||
|
"COUNT(DISTINCT s.protocol) = 0",
|
||||||
|
"MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(built.Text+built.CountText, want) {
|
||||||
|
t.Fatalf("no-data coverage SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildVehicleCoverageSummarySQL(t *testing.T) {
|
func TestBuildVehicleCoverageSummarySQL(t *testing.T) {
|
||||||
query := url.Values{"keyword": {"粤A"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "serviceStatus": {"healthy"}}
|
query := url.Values{"keyword": {"粤A"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "serviceStatus": {"healthy"}}
|
||||||
built := buildVehicleCoverageSummarySQL(query)
|
built := buildVehicleCoverageSummarySQL(query)
|
||||||
@@ -92,6 +109,7 @@ func TestBuildVehicleCoverageSummarySQL(t *testing.T) {
|
|||||||
"vehicle_coverage_summary",
|
"vehicle_coverage_summary",
|
||||||
"SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END)",
|
"SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END)",
|
||||||
"SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END)",
|
"SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END)",
|
||||||
|
"SUM(CASE WHEN source_count = 0 THEN 1 ELSE 0 END)",
|
||||||
"HAVING",
|
"HAVING",
|
||||||
"COUNT(DISTINCT s.protocol) > 1",
|
"COUNT(DISTINCT s.protocol) > 1",
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface VehicleCoverageSummary {
|
|||||||
onlineVehicles: number;
|
onlineVehicles: number;
|
||||||
singleSourceVehicles: number;
|
singleSourceVehicles: number;
|
||||||
multiSourceVehicles: number;
|
multiSourceVehicles: number;
|
||||||
|
noDataVehicles: number;
|
||||||
unboundVehicles: number;
|
unboundVehicles: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const serviceStatusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> =
|
|||||||
healthy: 'green',
|
healthy: 'green',
|
||||||
degraded: 'orange',
|
degraded: 'orange',
|
||||||
offline: 'red',
|
offline: 'red',
|
||||||
|
no_data: 'orange',
|
||||||
identity_required: 'orange'
|
identity_required: 'orange'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ const serviceStatusTitle: Record<string, string> = {
|
|||||||
healthy: '服务正常',
|
healthy: '服务正常',
|
||||||
degraded: '部分来源离线',
|
degraded: '部分来源离线',
|
||||||
offline: '车辆离线',
|
offline: '车辆离线',
|
||||||
|
no_data: '暂无数据来源',
|
||||||
identity_required: '身份未绑定'
|
identity_required: '身份未绑定'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,6 +106,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on
|
|||||||
{ label: '在线车辆', value: formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles), filters: { online: 'online' } },
|
{ label: '在线车辆', value: formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles), filters: { online: 'online' } },
|
||||||
{ label: '单源车辆', value: formatCount(serviceSummary?.singleSourceVehicles), filters: { coverage: 'single' } },
|
{ label: '单源车辆', value: formatCount(serviceSummary?.singleSourceVehicles), filters: { coverage: 'single' } },
|
||||||
{ label: '多源车辆', value: formatCount(serviceSummary?.multiSourceVehicles), filters: { coverage: 'multi' } },
|
{ label: '多源车辆', value: formatCount(serviceSummary?.multiSourceVehicles), filters: { coverage: 'multi' } },
|
||||||
|
{ label: '暂无来源车辆', value: formatCount(serviceSummary?.noDataVehicles), filters: { serviceStatus: 'no_data' } },
|
||||||
{ label: '身份未绑定', value: formatCount(serviceSummary?.identityRequiredVehicles), filters: { serviceStatus: 'identity_required' } }
|
{ label: '身份未绑定', value: formatCount(serviceSummary?.identityRequiredVehicles), filters: { serviceStatus: 'identity_required' } }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -239,6 +242,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality, onOpenVehicles }: { on
|
|||||||
<Select.Option value="healthy">服务正常</Select.Option>
|
<Select.Option value="healthy">服务正常</Select.Option>
|
||||||
<Select.Option value="degraded">部分来源离线</Select.Option>
|
<Select.Option value="degraded">部分来源离线</Select.Option>
|
||||||
<Select.Option value="offline">车辆离线</Select.Option>
|
<Select.Option value="offline">车辆离线</Select.Option>
|
||||||
|
<Select.Option value="no_data">暂无数据来源</Select.Option>
|
||||||
<Select.Option value="identity_required">身份未绑定</Select.Option>
|
<Select.Option value="identity_required">身份未绑定</Select.Option>
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
<Space>
|
<Space>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export function Vehicles({
|
|||||||
{ label: '在线车辆', value: (summary?.onlineVehicles ?? 0).toLocaleString(), filters: { online: 'online' } },
|
{ label: '在线车辆', value: (summary?.onlineVehicles ?? 0).toLocaleString(), filters: { online: 'online' } },
|
||||||
{ label: '单源车辆', value: (summary?.singleSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'single' } },
|
{ label: '单源车辆', value: (summary?.singleSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'single' } },
|
||||||
{ label: '多源车辆', value: (summary?.multiSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'multi' } },
|
{ label: '多源车辆', value: (summary?.multiSourceVehicles ?? 0).toLocaleString(), filters: { coverage: 'multi' } },
|
||||||
|
{ label: '暂无来源车辆', value: (summary?.noDataVehicles ?? 0).toLocaleString(), filters: { serviceStatus: 'no_data' } },
|
||||||
{ label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } }
|
{ label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } }
|
||||||
];
|
];
|
||||||
return items;
|
return items;
|
||||||
@@ -144,6 +145,7 @@ export function Vehicles({
|
|||||||
<Select.Option value="healthy">服务正常</Select.Option>
|
<Select.Option value="healthy">服务正常</Select.Option>
|
||||||
<Select.Option value="degraded">部分来源离线</Select.Option>
|
<Select.Option value="degraded">部分来源离线</Select.Option>
|
||||||
<Select.Option value="offline">车辆离线</Select.Option>
|
<Select.Option value="offline">车辆离线</Select.Option>
|
||||||
|
<Select.Option value="no_data">暂无数据来源</Select.Option>
|
||||||
<Select.Option value="identity_required">身份未绑定</Select.Option>
|
<Select.Option value="identity_required">身份未绑定</Select.Option>
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
<Form.Select field="online" label="在线" placeholder="全部" style={{ width: 130 }}>
|
<Form.Select field="online" label="在线" placeholder="全部" style={{ width: 130 }}>
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ test('dashboard renders vehicle service summary metrics', async () => {
|
|||||||
expect(screen.getByText('391')).toBeInTheDocument();
|
expect(screen.getByText('391')).toBeInTheDocument();
|
||||||
expect(screen.getByText('多源车辆')).toBeInTheDocument();
|
expect(screen.getByText('多源车辆')).toBeInTheDocument();
|
||||||
expect(screen.getByText('181')).toBeInTheDocument();
|
expect(screen.getByText('181')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('暂无来源车辆')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('461')).toBeInTheDocument();
|
||||||
expect(screen.getByText('身份未绑定')).toBeInTheDocument();
|
expect(screen.getByText('身份未绑定')).toBeInTheDocument();
|
||||||
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', undefined);
|
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', undefined);
|
||||||
});
|
});
|
||||||
@@ -171,12 +173,12 @@ test('opens vehicle list filtered by service summary KPI', async () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: /单源车辆/ }));
|
fireEvent.click(await screen.findByRole('button', { name: /暂无来源车辆/ }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&coverage=single'), undefined);
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&serviceStatus=no_data'), undefined);
|
||||||
});
|
});
|
||||||
expect(window.location.hash).toBe('#/vehicles?coverage=single');
|
expect(window.location.hash).toBe('#/vehicles?serviceStatus=no_data');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows vehicle service result summary on vehicle list filters', async () => {
|
test('shows vehicle service result summary on vehicle list filters', async () => {
|
||||||
@@ -261,6 +263,7 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
|
|||||||
expect(screen.getByText('73')).toBeInTheDocument();
|
expect(screen.getByText('73')).toBeInTheDocument();
|
||||||
expect(screen.getByText('在线车辆')).toBeInTheDocument();
|
expect(screen.getByText('在线车辆')).toBeInTheDocument();
|
||||||
expect(screen.getByText('单源车辆')).toBeInTheDocument();
|
expect(screen.getByText('单源车辆')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('暂无来源车辆')).toBeInTheDocument();
|
||||||
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);
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ GET /api/vehicles/coverage?keyword=粤AG18312&serviceStatus=degraded&limit=20&of
|
|||||||
|
|
||||||
Returns VIN-level source coverage rows for the vehicle service list. Each row includes the canonical vehicle-level `serviceStatus` so frontend, exports, and external integrations share the same health definition. `serviceStatus` accepts `healthy`, `degraded`, `offline`, and `identity_required`.
|
Returns VIN-level source coverage rows for the vehicle service list. Each row includes the canonical vehicle-level `serviceStatus` so frontend, exports, and external integrations share the same health definition. `serviceStatus` accepts `healthy`, `degraded`, `offline`, and `identity_required`.
|
||||||
|
|
||||||
|
Coverage summary also exposes `noDataVehicles`, so UI can show vehicles that exist in identity binding but have no GB32960, JT808, or Yutong MQTT source evidence. `/api/vehicles/coverage?serviceStatus=no_data` returns those bound vehicles for follow-up source onboarding.
|
||||||
|
|
||||||
### History Locations
|
### History Locations
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ Vehicle service lists and dashboard previews should label protocol coverage as `
|
|||||||
|
|
||||||
Dashboard and vehicle-list summary cards should expose both `单源车辆` and `多源车辆`. Single-source vehicles are operationally important because they cannot be cross-checked across sources, so they must be one-click filter targets rather than hidden behind the generic coverage filter.
|
Dashboard and vehicle-list summary cards should expose both `单源车辆` and `多源车辆`. Single-source vehicles are operationally important because they cannot be cross-checked across sources, so they must be one-click filter targets rather than hidden behind the generic coverage filter.
|
||||||
|
|
||||||
|
`暂无来源车辆` is also a first-class governance entry. It represents bound vehicles that have no current GB32960, JT808, or Yutong MQTT source evidence, and should route directly to the `no_data` vehicle service filter.
|
||||||
|
|
||||||
## Interaction Rules
|
## Interaction Rules
|
||||||
|
|
||||||
- Tables are the default data surface.
|
- Tables are the default data surface.
|
||||||
|
|||||||
Reference in New Issue
Block a user