feat(platform): filter vehicle archive gaps
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
°raded,
|
||||
&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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface VehicleCoverageSummary {
|
||||
unboundVehicles: number;
|
||||
archiveIncompleteVehicles: number;
|
||||
missingSources: MissingSourceStat[];
|
||||
archiveMissingFields: ArchiveMissingFieldStat[];
|
||||
}
|
||||
|
||||
export interface VehicleIdentityResolution {
|
||||
@@ -152,6 +153,7 @@ export interface VehicleServiceSummary {
|
||||
serviceStatuses: ServiceStatusStat[];
|
||||
protocols: ProtocolStat[];
|
||||
missingSources: MissingSourceStat[];
|
||||
archiveMissingFields: ArchiveMissingFieldStat[];
|
||||
}
|
||||
|
||||
export interface MissingSourceStat {
|
||||
@@ -159,6 +161,12 @@ export interface MissingSourceStat {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ArchiveMissingFieldStat {
|
||||
field: string;
|
||||
title: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface VehicleServiceStatus {
|
||||
status: string;
|
||||
severity: string;
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('parseAppHash', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
filters: {
|
||||
coverage: 'multi',
|
||||
@@ -31,6 +31,7 @@ describe('parseAppHash', () => {
|
||||
online: 'online',
|
||||
bindingStatus: 'bound',
|
||||
archiveStatus: 'incomplete',
|
||||
archiveMissing: 'phone',
|
||||
missingProtocol: 'YUTONG_MQTT'
|
||||
}
|
||||
});
|
||||
@@ -62,7 +63,7 @@ describe('buildAppHash', () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ const filterKeys = [
|
||||
'online',
|
||||
'bindingStatus',
|
||||
'archiveStatus',
|
||||
'archiveMissing',
|
||||
'missingProtocol',
|
||||
'issueType',
|
||||
'tab',
|
||||
|
||||
@@ -67,6 +67,11 @@ const archiveStatusLabel: Record<string, string> = {
|
||||
complete: '完整',
|
||||
incomplete: '不完整'
|
||||
};
|
||||
const archiveMissingLabel: Record<string, string> = {
|
||||
plate: '缺车牌',
|
||||
phone: '缺手机号',
|
||||
oem: '缺OEM'
|
||||
};
|
||||
|
||||
function sourceDiagnosisText(row: VehicleCoverageRow) {
|
||||
const parts = [sourceEvidenceText(row)];
|
||||
@@ -157,6 +162,14 @@ export function Vehicles({
|
||||
{ label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString(), filters: { bindingStatus: 'unbound' } },
|
||||
{ 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 ?? []) {
|
||||
if (source.count <= 0) continue;
|
||||
items.push({
|
||||
@@ -181,6 +194,10 @@ export function Vehicles({
|
||||
if (archiveIncompleteCount > 0) {
|
||||
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 ?? []) {
|
||||
if (source.count <= 0) continue;
|
||||
items.push({
|
||||
@@ -200,7 +217,8 @@ export function Vehicles({
|
||||
filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : '',
|
||||
filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '',
|
||||
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);
|
||||
|
||||
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?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
|
||||
if (values?.archiveStatus) params.set('archiveStatus', values.archiveStatus);
|
||||
if (values?.archiveMissing) params.set('archiveMissing', values.archiveMissing);
|
||||
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
|
||||
const summaryParams = new URLSearchParams(params);
|
||||
summaryParams.delete('limit');
|
||||
@@ -360,6 +379,11 @@ export function Vehicles({
|
||||
<Select.Option value="complete">完整</Select.Option>
|
||||
<Select.Option value="incomplete">不完整</Select.Option>
|
||||
</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>
|
||||
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
||||
<Button icon={<IconCopy />} onClick={copyVehicleShareURL}>复制筛选链接</Button>
|
||||
|
||||
@@ -594,6 +594,8 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
|
||||
multiSourceVehicles: 181,
|
||||
noDataVehicles: 4,
|
||||
unboundVehicles: 9,
|
||||
archiveIncompleteVehicles: 12,
|
||||
archiveMissingFields: [{ field: 'phone', title: '缺手机号', count: 7 }],
|
||||
missingSources: [{ protocol: 'YUTONG_MQTT', count: 181 }]
|
||||
},
|
||||
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.getByRole('button', { name: '维护身份绑定 9' })).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.getByText('档案完整度')).toBeInTheDocument();
|
||||
expect(screen.getByText('4/4')).toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user