feat(platform): filter vehicle archive gaps

This commit is contained in:
lingniu
2026-07-04 09:50:41 +08:00
parent b127b22a4b
commit b5a72ce0ec
11 changed files with 174 additions and 26 deletions

View File

@@ -208,6 +208,9 @@ func TestHandlerVehicleCoverageSummary(t *testing.T) {
if len(body.Data.MissingSources) == 0 { if len(body.Data.MissingSources) == 0 {
t.Fatalf("coverage summary should expose missing source counts, got %+v", body.Data) t.Fatalf("coverage summary should expose missing source counts, got %+v", body.Data)
} }
if len(body.Data.ArchiveMissingFields) == 0 {
t.Fatalf("coverage summary should expose archive missing-field counts, got %+v", body.Data)
}
} }
func TestSplitCSVEmptyReturnsEmptySlice(t *testing.T) { func TestSplitCSVEmptyReturnsEmptySlice(t *testing.T) {

View File

@@ -159,6 +159,7 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values
return VehicleCoverageSummary{}, err return VehicleCoverageSummary{}, err
} }
summary := VehicleCoverageSummary{TotalVehicles: coverage.Total} summary := VehicleCoverageSummary{TotalVehicles: coverage.Total}
missingFieldCounts := map[string]int{}
for _, row := range coverage.Items { for _, row := range coverage.Items {
if row.Online { if row.Online {
summary.OnlineVehicles++ summary.OnlineVehicles++
@@ -178,6 +179,9 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values
if vehicleArchiveIncomplete(row) { if vehicleArchiveIncomplete(row) {
summary.ArchiveIncompleteVehicles++ summary.ArchiveIncompleteVehicles++
} }
for _, field := range vehicleArchiveMissingFields(row) {
missingFieldCounts[field]++
}
} }
for _, protocol := range canonicalVehicleProtocols { for _, protocol := range canonicalVehicleProtocols {
missing := 0 missing := 0
@@ -188,6 +192,11 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values
} }
summary.MissingSources = append(summary.MissingSources, MissingSourceStat{Protocol: protocol, Count: missing}) summary.MissingSources = append(summary.MissingSources, MissingSourceStat{Protocol: protocol, Count: missing})
} }
summary.ArchiveMissingFields = buildArchiveMissingFieldStats(
missingFieldCounts["plate"],
missingFieldCounts["phone"],
missingFieldCounts["oem"],
)
return summary, nil return summary, nil
} }
@@ -210,6 +219,7 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu
"identity_required": {Status: "identity_required", Title: "身份未绑定"}, "identity_required": {Status: "identity_required", Title: "身份未绑定"},
} }
protocolCounts := map[string]*ProtocolStat{} protocolCounts := map[string]*ProtocolStat{}
missingFieldCounts := map[string]int{}
for _, row := range coverage.Items { for _, row := range coverage.Items {
if row.BindingStatus == "bound" { if row.BindingStatus == "bound" {
summary.BoundVehicles++ summary.BoundVehicles++
@@ -217,6 +227,9 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu
if vehicleArchiveIncomplete(row) { if vehicleArchiveIncomplete(row) {
summary.ArchiveIncompleteVehicles++ summary.ArchiveIncompleteVehicles++
} }
for _, field := range vehicleArchiveMissingFields(row) {
missingFieldCounts[field]++
}
if row.Online { if row.Online {
summary.OnlineVehicles++ summary.OnlineVehicles++
} }
@@ -277,6 +290,11 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu
} }
summary.MissingSources = append(summary.MissingSources, MissingSourceStat{Protocol: protocol, Count: missing}) summary.MissingSources = append(summary.MissingSources, MissingSourceStat{Protocol: protocol, Count: missing})
} }
summary.ArchiveMissingFields = buildArchiveMissingFieldStats(
missingFieldCounts["plate"],
missingFieldCounts["phone"],
missingFieldCounts["oem"],
)
sort.Slice(summary.ServiceStatuses, func(i, j int) bool { return summary.ServiceStatuses[i].Status < summary.ServiceStatuses[j].Status }) sort.Slice(summary.ServiceStatuses, func(i, j int) bool { return summary.ServiceStatuses[i].Status < summary.ServiceStatuses[j].Status })
return summary, nil return summary, nil
} }
@@ -288,6 +306,28 @@ func vehicleArchiveIncomplete(row VehicleCoverageRow) bool {
strings.TrimSpace(row.OEM) == "" strings.TrimSpace(row.OEM) == ""
} }
func vehicleArchiveMissingFields(row VehicleCoverageRow) []string {
fields := []string{}
if strings.TrimSpace(row.Plate) == "" {
fields = append(fields, "plate")
}
if strings.TrimSpace(row.Phone) == "" {
fields = append(fields, "phone")
}
if strings.TrimSpace(row.OEM) == "" {
fields = append(fields, "oem")
}
return fields
}
func buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM int) []ArchiveMissingFieldStat {
return []ArchiveMissingFieldStat{
{Field: "plate", Title: "缺车牌", Count: missingPlate},
{Field: "phone", Title: "缺手机号", Count: missingPhone},
{Field: "oem", Title: "缺OEM", Count: missingOEM},
}
}
func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
rows := m.locations rows := m.locations
if vin := strings.TrimSpace(query.Get("vin")); vin != "" { if vin := strings.TrimSpace(query.Get("vin")); vin != "" {

View File

@@ -67,14 +67,15 @@ type VehicleCoverageRow struct {
} }
type VehicleCoverageSummary struct { type VehicleCoverageSummary struct {
TotalVehicles int `json:"totalVehicles"` TotalVehicles int `json:"totalVehicles"`
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"` NoDataVehicles int `json:"noDataVehicles"`
UnboundVehicles int `json:"unboundVehicles"` UnboundVehicles int `json:"unboundVehicles"`
ArchiveIncompleteVehicles int `json:"archiveIncompleteVehicles"` ArchiveIncompleteVehicles int `json:"archiveIncompleteVehicles"`
MissingSources []MissingSourceStat `json:"missingSources"` MissingSources []MissingSourceStat `json:"missingSources"`
ArchiveMissingFields []ArchiveMissingFieldStat `json:"archiveMissingFields"`
} }
type VehicleIdentityResolution struct { type VehicleIdentityResolution struct {
@@ -144,17 +145,18 @@ type VehicleServiceOverview struct {
} }
type VehicleServiceSummary struct { type VehicleServiceSummary struct {
TotalVehicles int `json:"totalVehicles"` TotalVehicles int `json:"totalVehicles"`
BoundVehicles int `json:"boundVehicles"` BoundVehicles int `json:"boundVehicles"`
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"` NoDataVehicles int `json:"noDataVehicles"`
IdentityRequiredVehicles int `json:"identityRequiredVehicles"` IdentityRequiredVehicles int `json:"identityRequiredVehicles"`
ArchiveIncompleteVehicles int `json:"archiveIncompleteVehicles"` ArchiveIncompleteVehicles int `json:"archiveIncompleteVehicles"`
ServiceStatuses []ServiceStatusStat `json:"serviceStatuses"` ServiceStatuses []ServiceStatusStat `json:"serviceStatuses"`
Protocols []ProtocolStat `json:"protocols"` Protocols []ProtocolStat `json:"protocols"`
MissingSources []MissingSourceStat `json:"missingSources"` MissingSources []MissingSourceStat `json:"missingSources"`
ArchiveMissingFields []ArchiveMissingFieldStat `json:"archiveMissingFields"`
} }
type VehicleServiceStatus struct { type VehicleServiceStatus struct {
@@ -171,6 +173,12 @@ type MissingSourceStat struct {
Count int `json:"count"` Count int `json:"count"`
} }
type ArchiveMissingFieldStat struct {
Field string `json:"field"`
Title string `json:"title"`
Count int `json:"count"`
}
type VehicleSourceStatus struct { type VehicleSourceStatus struct {
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Online bool `json:"online"` Online bool `json:"online"`

View File

@@ -111,6 +111,9 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
case "incomplete": case "incomplete":
having = append(having, "(v.vin IS NULL OR v.vin = '' OR COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL OR NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL OR NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL)") having = append(having, "(v.vin IS NULL OR v.vin = '' OR COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL OR NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL OR NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL)")
} }
if predicate := archiveMissingFieldPredicate(query.Get("archiveMissing")); predicate != "" {
having = append(having, predicate)
}
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")
@@ -209,6 +212,9 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
case "incomplete": case "incomplete":
having = append(having, "(v.vin IS NULL OR v.vin = '' OR COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL OR NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL OR NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL)") having = append(having, "(v.vin IS NULL OR v.vin = '' OR COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL OR NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL OR NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL)")
} }
if predicate := archiveMissingFieldPredicate(query.Get("archiveMissing")); predicate != "" {
having = append(having, predicate)
}
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")
@@ -243,7 +249,10 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
`AND COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NOT NULL ` + `AND COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NOT NULL ` +
`AND NULLIF(MAX(NULLIF(b.phone, '')), '') IS NOT NULL ` + `AND NULLIF(MAX(NULLIF(b.phone, '')), '') IS NOT NULL ` +
`AND NULLIF(MAX(NULLIF(b.oem, '')), '') IS NOT NULL ` + `AND NULLIF(MAX(NULLIF(b.oem, '')), '') IS NOT NULL ` +
`THEN 1 ELSE 0 END AS archive_complete ` + `THEN 1 ELSE 0 END AS archive_complete, ` +
`CASE WHEN COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL THEN 1 ELSE 0 END AS missing_plate, ` +
`CASE WHEN NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL THEN 1 ELSE 0 END AS missing_phone, ` +
`CASE WHEN NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL THEN 1 ELSE 0 END AS missing_oem ` +
`FROM (` + vehicleSetSQL + `) v ` + `FROM (` + vehicleSetSQL + `) v ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` + `LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` + `LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
@@ -256,12 +265,28 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
`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 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, ` +
`COALESCE(SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END), 0) AS archive_incomplete_vehicles ` + `COALESCE(SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END), 0) AS archive_incomplete_vehicles, ` +
`COALESCE(SUM(CASE WHEN missing_plate = 1 THEN 1 ELSE 0 END), 0) AS missing_plate_vehicles, ` +
`COALESCE(SUM(CASE WHEN missing_phone = 1 THEN 1 ELSE 0 END), 0) AS missing_phone_vehicles, ` +
`COALESCE(SUM(CASE WHEN missing_oem = 1 THEN 1 ELSE 0 END), 0) AS missing_oem_vehicles ` +
`FROM (` + groupSQL + `) vehicle_coverage_summary`, `FROM (` + groupSQL + `) vehicle_coverage_summary`,
Args: args, Args: args,
} }
} }
func archiveMissingFieldPredicate(field string) string {
switch strings.TrimSpace(field) {
case "plate":
return "COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL"
case "phone":
return "NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL"
case "oem":
return "NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL"
default:
return ""
}
}
func buildRealtimeLocationSQL(query url.Values) SQLQuery { func buildRealtimeLocationSQL(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)

View File

@@ -126,6 +126,9 @@ COALESCE(SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END), 0) AS multi_source_v
COALESCE(SUM(CASE WHEN source_count = 0 THEN 1 ELSE 0 END), 0) AS no_data_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 identity_required_vehicles, COALESCE(SUM(CASE WHEN bound = 0 THEN 1 ELSE 0 END), 0) AS identity_required_vehicles,
COALESCE(SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END), 0) AS archive_incomplete_vehicles, COALESCE(SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END), 0) AS archive_incomplete_vehicles,
COALESCE(SUM(CASE WHEN missing_plate = 1 THEN 1 ELSE 0 END), 0) AS missing_plate_vehicles,
COALESCE(SUM(CASE WHEN missing_phone = 1 THEN 1 ELSE 0 END), 0) AS missing_phone_vehicles,
COALESCE(SUM(CASE WHEN missing_oem = 1 THEN 1 ELSE 0 END), 0) AS missing_oem_vehicles,
COALESCE(SUM(CASE WHEN bound = 1 AND source_count = `+canonicalSourceCount+` AND online_source_count = source_count THEN 1 ELSE 0 END), 0) AS healthy_vehicles, COALESCE(SUM(CASE WHEN bound = 1 AND source_count = `+canonicalSourceCount+` AND online_source_count = source_count THEN 1 ELSE 0 END), 0) AS healthy_vehicles,
COALESCE(SUM(CASE WHEN bound = 1 AND source_count > 0 AND online_source_count > 0 AND (online_source_count < source_count OR source_count < `+canonicalSourceCount+`) THEN 1 ELSE 0 END), 0) AS degraded_vehicles, COALESCE(SUM(CASE WHEN bound = 1 AND source_count > 0 AND online_source_count > 0 AND (online_source_count < source_count OR source_count < `+canonicalSourceCount+`) THEN 1 ELSE 0 END), 0) AS degraded_vehicles,
COALESCE(SUM(CASE WHEN bound = 1 AND source_count > 0 AND online_source_count = 0 THEN 1 ELSE 0 END), 0) AS offline_vehicles COALESCE(SUM(CASE WHEN bound = 1 AND source_count > 0 AND online_source_count = 0 THEN 1 ELSE 0 END), 0) AS offline_vehicles
@@ -138,7 +141,10 @@ CASE WHEN v.vin IS NOT NULL AND v.vin <> ''
AND COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NOT NULL AND COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NOT NULL
AND NULLIF(MAX(NULLIF(b.phone, '')), '') IS NOT NULL AND NULLIF(MAX(NULLIF(b.phone, '')), '') IS NOT NULL
AND NULLIF(MAX(NULLIF(b.oem, '')), '') IS NOT NULL AND NULLIF(MAX(NULLIF(b.oem, '')), '') IS NOT NULL
THEN 1 ELSE 0 END AS archive_complete THEN 1 ELSE 0 END AS archive_complete,
CASE WHEN COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), NULLIF(MAX(NULLIF(b.plate, '')), '')) IS NULL THEN 1 ELSE 0 END AS missing_plate,
CASE WHEN NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL THEN 1 ELSE 0 END AS missing_phone,
CASE WHEN NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL THEN 1 ELSE 0 END AS missing_oem
FROM ( FROM (
SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> '' SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> ''
UNION UNION
@@ -154,6 +160,7 @@ GROUP BY v.vin
defer rows.Close() defer rows.Close()
var summary VehicleServiceSummary var summary VehicleServiceSummary
var healthy, degraded, offline int var healthy, degraded, offline int
var missingPlate, missingPhone, missingOEM int
if rows.Next() { if rows.Next() {
if err := rows.Scan( if err := rows.Scan(
&summary.TotalVehicles, &summary.TotalVehicles,
@@ -164,6 +171,9 @@ GROUP BY v.vin
&summary.NoDataVehicles, &summary.NoDataVehicles,
&summary.IdentityRequiredVehicles, &summary.IdentityRequiredVehicles,
&summary.ArchiveIncompleteVehicles, &summary.ArchiveIncompleteVehicles,
&missingPlate,
&missingPhone,
&missingOEM,
&healthy, &healthy,
&degraded, &degraded,
&offline, &offline,
@@ -181,6 +191,7 @@ GROUP BY v.vin
{Status: "no_data", Title: "暂无数据来源", Count: summary.NoDataVehicles}, {Status: "no_data", Title: "暂无数据来源", Count: summary.NoDataVehicles},
{Status: "identity_required", Title: "身份未绑定", Count: summary.IdentityRequiredVehicles}, {Status: "identity_required", Title: "身份未绑定", Count: summary.IdentityRequiredVehicles},
} }
summary.ArchiveMissingFields = buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM)
protocols, err := s.protocolStats(ctx) protocols, err := s.protocolStats(ctx)
if err != nil { if err != nil {
return VehicleServiceSummary{}, err return VehicleServiceSummary{}, err
@@ -317,6 +328,7 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values)
func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) { func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
built := buildVehicleCoverageSummarySQL(query) built := buildVehicleCoverageSummarySQL(query)
var summary VehicleCoverageSummary var summary VehicleCoverageSummary
var missingPlate, missingPhone, missingOEM int
if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan( if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(
&summary.TotalVehicles, &summary.TotalVehicles,
&summary.OnlineVehicles, &summary.OnlineVehicles,
@@ -325,9 +337,13 @@ func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url.
&summary.NoDataVehicles, &summary.NoDataVehicles,
&summary.UnboundVehicles, &summary.UnboundVehicles,
&summary.ArchiveIncompleteVehicles, &summary.ArchiveIncompleteVehicles,
&missingPlate,
&missingPhone,
&missingOEM,
); err != nil { ); err != nil {
return VehicleCoverageSummary{}, err return VehicleCoverageSummary{}, err
} }
summary.ArchiveMissingFields = buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM)
missingSources, err := s.coverageMissingSourceStats(ctx, query) missingSources, err := s.coverageMissingSourceStats(ctx, query)
if err != nil { if err != nil {
return VehicleCoverageSummary{}, err return VehicleCoverageSummary{}, err

View File

@@ -133,8 +133,22 @@ func TestBuildVehicleCoverageSQLFiltersArchiveStatus(t *testing.T) {
} }
} }
func TestBuildVehicleCoverageSQLFiltersArchiveMissingField(t *testing.T) {
query := url.Values{"archiveMissing": {"phone"}, "limit": {"8"}}
built := buildVehicleCoverageSQL(query)
for _, want := range []string{
"HAVING",
"NULLIF(MAX(NULLIF(b.phone, '')), '') IS NULL",
"vehicle_coverage_count",
} {
if !strings.Contains(built.Text+built.CountText, want) {
t.Fatalf("archive missing-field 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"}, "archiveMissing": {"oem"}}
built := buildVehicleCoverageSummarySQL(query) built := buildVehicleCoverageSummarySQL(query)
for _, want := range []string{ for _, want := range []string{
"vehicle_realtime_snapshot", "vehicle_realtime_snapshot",
@@ -144,8 +158,11 @@ func TestBuildVehicleCoverageSummarySQL(t *testing.T) {
"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)", "SUM(CASE WHEN source_count = 0 THEN 1 ELSE 0 END)",
"SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END)", "SUM(CASE WHEN archive_complete = 0 THEN 1 ELSE 0 END)",
"SUM(CASE WHEN missing_phone = 1 THEN 1 ELSE 0 END)",
"SUM(CASE WHEN missing_oem = 1 THEN 1 ELSE 0 END)",
"HAVING", "HAVING",
"COUNT(DISTINCT s.protocol) > 1", "COUNT(DISTINCT s.protocol) > 1",
"NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL",
} { } {
if !strings.Contains(built.Text, want) { if !strings.Contains(built.Text, want) {
t.Fatalf("summary SQL missing %q: %s", want, built.Text) t.Fatalf("summary SQL missing %q: %s", want, built.Text)

View File

@@ -72,6 +72,7 @@ export interface VehicleCoverageSummary {
unboundVehicles: number; unboundVehicles: number;
archiveIncompleteVehicles: number; archiveIncompleteVehicles: number;
missingSources: MissingSourceStat[]; missingSources: MissingSourceStat[];
archiveMissingFields: ArchiveMissingFieldStat[];
} }
export interface VehicleIdentityResolution { export interface VehicleIdentityResolution {
@@ -152,6 +153,7 @@ export interface VehicleServiceSummary {
serviceStatuses: ServiceStatusStat[]; serviceStatuses: ServiceStatusStat[];
protocols: ProtocolStat[]; protocols: ProtocolStat[];
missingSources: MissingSourceStat[]; missingSources: MissingSourceStat[];
archiveMissingFields: ArchiveMissingFieldStat[];
} }
export interface MissingSourceStat { export interface MissingSourceStat {
@@ -159,6 +161,12 @@ export interface MissingSourceStat {
count: number; count: number;
} }
export interface ArchiveMissingFieldStat {
field: string;
title: string;
count: number;
}
export interface VehicleServiceStatus { export interface VehicleServiceStatus {
status: string; status: string;
severity: string; severity: string;

View File

@@ -23,7 +23,7 @@ describe('parseAppHash', () => {
}); });
test('parses vehicle list filters from hash query', () => { test('parses vehicle list filters from hash query', () => {
expect(parseAppHash('#/vehicles?coverage=multi&serviceStatus=degraded&online=online&bindingStatus=bound&archiveStatus=incomplete&missingProtocol=YUTONG_MQTT')).toEqual({ expect(parseAppHash('#/vehicles?coverage=multi&serviceStatus=degraded&online=online&bindingStatus=bound&archiveStatus=incomplete&archiveMissing=phone&missingProtocol=YUTONG_MQTT')).toEqual({
page: 'vehicles', page: 'vehicles',
filters: { filters: {
coverage: 'multi', coverage: 'multi',
@@ -31,6 +31,7 @@ describe('parseAppHash', () => {
online: 'online', online: 'online',
bindingStatus: 'bound', bindingStatus: 'bound',
archiveStatus: 'incomplete', archiveStatus: 'incomplete',
archiveMissing: 'phone',
missingProtocol: 'YUTONG_MQTT' missingProtocol: 'YUTONG_MQTT'
} }
}); });
@@ -62,7 +63,7 @@ describe('buildAppHash', () => {
}); });
test('builds shareable vehicle list hash with filters', () => { test('builds shareable vehicle list hash with filters', () => {
expect(buildAppHash({ page: 'vehicles', filters: { coverage: 'multi', serviceStatus: 'degraded', archiveStatus: 'incomplete', missingProtocol: 'YUTONG_MQTT' } })).toBe('#/vehicles?coverage=multi&serviceStatus=degraded&archiveStatus=incomplete&missingProtocol=YUTONG_MQTT'); expect(buildAppHash({ page: 'vehicles', filters: { coverage: 'multi', serviceStatus: 'degraded', archiveStatus: 'incomplete', archiveMissing: 'phone', missingProtocol: 'YUTONG_MQTT' } })).toBe('#/vehicles?coverage=multi&serviceStatus=degraded&archiveStatus=incomplete&archiveMissing=phone&missingProtocol=YUTONG_MQTT');
}); });
test('builds shareable quality hash with filters', () => { test('builds shareable quality hash with filters', () => {

View File

@@ -15,6 +15,7 @@ const filterKeys = [
'online', 'online',
'bindingStatus', 'bindingStatus',
'archiveStatus', 'archiveStatus',
'archiveMissing',
'missingProtocol', 'missingProtocol',
'issueType', 'issueType',
'tab', 'tab',

View File

@@ -67,6 +67,11 @@ const archiveStatusLabel: Record<string, string> = {
complete: '完整', complete: '完整',
incomplete: '不完整' incomplete: '不完整'
}; };
const archiveMissingLabel: Record<string, string> = {
plate: '缺车牌',
phone: '缺手机号',
oem: '缺OEM'
};
function sourceDiagnosisText(row: VehicleCoverageRow) { function sourceDiagnosisText(row: VehicleCoverageRow) {
const parts = [sourceEvidenceText(row)]; const parts = [sourceEvidenceText(row)];
@@ -157,6 +162,14 @@ export function Vehicles({
{ label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } }, { label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } },
{ label: '档案不完整', value: (summary?.archiveIncompleteVehicles ?? 0).toLocaleString(), filters: { archiveStatus: 'incomplete' } } { label: '档案不完整', value: (summary?.archiveIncompleteVehicles ?? 0).toLocaleString(), filters: { archiveStatus: 'incomplete' } }
]; ];
for (const field of summary?.archiveMissingFields ?? []) {
if (field.count <= 0) continue;
items.push({
label: field.title,
value: field.count.toLocaleString(),
filters: { archiveMissing: field.field }
});
}
for (const source of summary?.missingSources ?? []) { for (const source of summary?.missingSources ?? []) {
if (source.count <= 0) continue; if (source.count <= 0) continue;
items.push({ items.push({
@@ -181,6 +194,10 @@ export function Vehicles({
if (archiveIncompleteCount > 0) { if (archiveIncompleteCount > 0) {
items.push({ label: '完善车辆档案', count: archiveIncompleteCount, filters: { archiveStatus: 'incomplete' }, color: 'orange' }); items.push({ label: '完善车辆档案', count: archiveIncompleteCount, filters: { archiveStatus: 'incomplete' }, color: 'orange' });
} }
for (const field of summary?.archiveMissingFields ?? []) {
if (field.count <= 0) continue;
items.push({ label: `补齐${field.title}`, count: field.count, filters: { archiveMissing: field.field }, color: 'orange' });
}
for (const source of summary?.missingSources ?? []) { for (const source of summary?.missingSources ?? []) {
if (source.count <= 0) continue; if (source.count <= 0) continue;
items.push({ items.push({
@@ -200,7 +217,8 @@ export function Vehicles({
filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : '', filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : '',
filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '', filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '',
filters.bindingStatus ? `绑定:${bindingStatusLabel[filters.bindingStatus] ?? filters.bindingStatus}` : '', filters.bindingStatus ? `绑定:${bindingStatusLabel[filters.bindingStatus] ?? filters.bindingStatus}` : '',
filters.archiveStatus ? `档案:${archiveStatusLabel[filters.archiveStatus] ?? filters.archiveStatus}` : '' filters.archiveStatus ? `档案:${archiveStatusLabel[filters.archiveStatus] ?? filters.archiveStatus}` : '',
filters.archiveMissing ? `档案缺项:${archiveMissingLabel[filters.archiveMissing] ?? filters.archiveMissing}` : ''
].filter(Boolean); ].filter(Boolean);
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
@@ -213,6 +231,7 @@ export function Vehicles({
if (values?.online) params.set('online', values.online); if (values?.online) params.set('online', values.online);
if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus); if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
if (values?.archiveStatus) params.set('archiveStatus', values.archiveStatus); if (values?.archiveStatus) params.set('archiveStatus', values.archiveStatus);
if (values?.archiveMissing) params.set('archiveMissing', values.archiveMissing);
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus); if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
const summaryParams = new URLSearchParams(params); const summaryParams = new URLSearchParams(params);
summaryParams.delete('limit'); summaryParams.delete('limit');
@@ -360,6 +379,11 @@ export function Vehicles({
<Select.Option value="complete"></Select.Option> <Select.Option value="complete"></Select.Option>
<Select.Option value="incomplete"></Select.Option> <Select.Option value="incomplete"></Select.Option>
</Form.Select> </Form.Select>
<Form.Select field="archiveMissing" label="档案缺项" placeholder="全部" style={{ width: 140 }}>
<Select.Option value="plate"></Select.Option>
<Select.Option value="phone"></Select.Option>
<Select.Option value="oem">OEM</Select.Option>
</Form.Select>
<Space> <Space>
<Button htmlType="submit" theme="solid" type="primary"></Button> <Button htmlType="submit" theme="solid" type="primary"></Button>
<Button icon={<IconCopy />} onClick={copyVehicleShareURL}></Button> <Button icon={<IconCopy />} onClick={copyVehicleShareURL}></Button>

View File

@@ -594,6 +594,8 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
multiSourceVehicles: 181, multiSourceVehicles: 181,
noDataVehicles: 4, noDataVehicles: 4,
unboundVehicles: 9, unboundVehicles: 9,
archiveIncompleteVehicles: 12,
archiveMissingFields: [{ field: 'phone', title: '缺手机号', count: 7 }],
missingSources: [{ protocol: 'YUTONG_MQTT', count: 181 }] missingSources: [{ protocol: 'YUTONG_MQTT', count: 181 }]
}, },
traceId: 'trace-test', traceId: 'trace-test',
@@ -661,9 +663,12 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
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('缺手机号')).toBeInTheDocument();
expect(screen.getByText('处置队列')).toBeInTheDocument(); expect(screen.getByText('处置队列')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '维护身份绑定 9' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '维护身份绑定 9' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '确认平台转发 4' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '确认平台转发 4' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '补齐缺手机号 7' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '补齐 YUTONG_MQTT 来源 181' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '补齐 YUTONG_MQTT 来源 181' })).toBeInTheDocument();
expect(screen.getByText('档案完整度')).toBeInTheDocument(); expect(screen.getByText('档案完整度')).toBeInTheDocument();
expect(screen.getByText('4/4')).toBeInTheDocument(); expect(screen.getByText('4/4')).toBeInTheDocument();