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 {
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) {

View File

@@ -159,6 +159,7 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values
return VehicleCoverageSummary{}, err
}
summary := VehicleCoverageSummary{TotalVehicles: coverage.Total}
missingFieldCounts := map[string]int{}
for _, row := range coverage.Items {
if row.Online {
summary.OnlineVehicles++
@@ -178,6 +179,9 @@ func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values
if vehicleArchiveIncomplete(row) {
summary.ArchiveIncompleteVehicles++
}
for _, field := range vehicleArchiveMissingFields(row) {
missingFieldCounts[field]++
}
}
for _, protocol := range canonicalVehicleProtocols {
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.ArchiveMissingFields = buildArchiveMissingFieldStats(
missingFieldCounts["plate"],
missingFieldCounts["phone"],
missingFieldCounts["oem"],
)
return summary, nil
}
@@ -210,6 +219,7 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu
"identity_required": {Status: "identity_required", Title: "身份未绑定"},
}
protocolCounts := map[string]*ProtocolStat{}
missingFieldCounts := map[string]int{}
for _, row := range coverage.Items {
if row.BindingStatus == "bound" {
summary.BoundVehicles++
@@ -217,6 +227,9 @@ func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSu
if vehicleArchiveIncomplete(row) {
summary.ArchiveIncompleteVehicles++
}
for _, field := range vehicleArchiveMissingFields(row) {
missingFieldCounts[field]++
}
if row.Online {
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.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 })
return summary, nil
}
@@ -288,6 +306,28 @@ func vehicleArchiveIncomplete(row VehicleCoverageRow) bool {
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) {
rows := m.locations
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {

View File

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

View File

@@ -111,6 +111,9 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
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)")
}
if predicate := archiveMissingFieldPredicate(query.Get("archiveMissing")); predicate != "" {
having = append(having, predicate)
}
switch strings.TrimSpace(query.Get("serviceStatus")) {
case "identity_required":
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":
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")) {
case "identity_required":
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 NULLIF(MAX(NULLIF(b.phone, '')), '') 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 ` +
`LEFT JOIN vehicle_identity_binding b ON b.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 = 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 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`,
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 {
limit := parsePositive(query.Get("limit"), 20)
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 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 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 > 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
@@ -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 NULLIF(MAX(NULLIF(b.phone, '')), '') 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 (
SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> ''
UNION
@@ -154,6 +160,7 @@ GROUP BY v.vin
defer rows.Close()
var summary VehicleServiceSummary
var healthy, degraded, offline int
var missingPlate, missingPhone, missingOEM int
if rows.Next() {
if err := rows.Scan(
&summary.TotalVehicles,
@@ -164,6 +171,9 @@ GROUP BY v.vin
&summary.NoDataVehicles,
&summary.IdentityRequiredVehicles,
&summary.ArchiveIncompleteVehicles,
&missingPlate,
&missingPhone,
&missingOEM,
&healthy,
&degraded,
&offline,
@@ -181,6 +191,7 @@ GROUP BY v.vin
{Status: "no_data", Title: "暂无数据来源", Count: summary.NoDataVehicles},
{Status: "identity_required", Title: "身份未绑定", Count: summary.IdentityRequiredVehicles},
}
summary.ArchiveMissingFields = buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM)
protocols, err := s.protocolStats(ctx)
if err != nil {
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) {
built := buildVehicleCoverageSummarySQL(query)
var summary VehicleCoverageSummary
var missingPlate, missingPhone, missingOEM int
if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(
&summary.TotalVehicles,
&summary.OnlineVehicles,
@@ -325,9 +337,13 @@ func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url.
&summary.NoDataVehicles,
&summary.UnboundVehicles,
&summary.ArchiveIncompleteVehicles,
&missingPlate,
&missingPhone,
&missingOEM,
); err != nil {
return VehicleCoverageSummary{}, err
}
summary.ArchiveMissingFields = buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM)
missingSources, err := s.coverageMissingSourceStats(ctx, query)
if err != nil {
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) {
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)
for _, want := range []string{
"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 = 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",
"COUNT(DISTINCT s.protocol) > 1",
"NULLIF(MAX(NULLIF(b.oem, '')), '') IS NULL",
} {
if !strings.Contains(built.Text, want) {
t.Fatalf("summary SQL missing %q: %s", want, built.Text)