2304 lines
81 KiB
Go
2304 lines
81 KiB
Go
package stats
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||
)
|
||
|
||
type Queryer interface {
|
||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||
}
|
||
|
||
type MetricQuery struct {
|
||
VIN string
|
||
Protocol string
|
||
IncludeTotal bool
|
||
DateFrom string
|
||
DateTo string
|
||
Limit int
|
||
Offset int
|
||
}
|
||
|
||
type MetricSourceQuery struct {
|
||
VIN string
|
||
Protocol string
|
||
DateFrom string
|
||
DateTo string
|
||
SourceIP string
|
||
QualityStatus string
|
||
QualityReason string
|
||
Selected *bool
|
||
OrderBy string
|
||
IncludeTotal bool
|
||
Limit int
|
||
Offset int
|
||
}
|
||
|
||
type MetricDiagnosisQuery struct {
|
||
VIN string
|
||
Protocol string
|
||
Date string
|
||
Diagnosis string
|
||
Reason string
|
||
Severity string
|
||
IncludeTotal bool
|
||
Limit int
|
||
Offset int
|
||
}
|
||
|
||
type MetricRow struct {
|
||
VIN string `json:"vin"`
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
SourceID *int64 `json:"source_id,omitempty"`
|
||
SourceIP string `json:"source_ip,omitempty"`
|
||
LatestSourceEndpoint string `json:"latest_source_endpoint,omitempty"`
|
||
PlatformName string `json:"platform_name,omitempty"`
|
||
SourceCode string `json:"source_code,omitempty"`
|
||
SourceKind string `json:"source_kind,omitempty"`
|
||
DailyMileageKM float64 `json:"daily_mileage_km"`
|
||
LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
}
|
||
|
||
type MetricSourceRow struct {
|
||
VIN string `json:"vin"`
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
SourceKey string `json:"source_key"`
|
||
SourceIP string `json:"source_ip"`
|
||
SourceEndpoint string `json:"source_endpoint,omitempty"`
|
||
Phone string `json:"phone,omitempty"`
|
||
DeviceID string `json:"device_id,omitempty"`
|
||
PlatformName string `json:"platform_name,omitempty"`
|
||
SourceID *int64 `json:"source_id,omitempty"`
|
||
SourceCode string `json:"source_code,omitempty"`
|
||
SourceKind string `json:"source_kind,omitempty"`
|
||
SourceEnabled *bool `json:"source_enabled,omitempty"`
|
||
TrustPriority *int `json:"trust_priority,omitempty"`
|
||
FirstTotalMileageKM *float64 `json:"first_total_mileage_km,omitempty"`
|
||
LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"`
|
||
DailyMileageKM float64 `json:"daily_mileage_km"`
|
||
SampleCount int64 `json:"sample_count"`
|
||
FirstEventTime string `json:"first_event_time,omitempty"`
|
||
LatestEventTime string `json:"latest_event_time,omitempty"`
|
||
QualityStatus string `json:"quality_status"`
|
||
QualityReason string `json:"quality_reason,omitempty"`
|
||
IsSelected bool `json:"is_selected"`
|
||
SelectionStatus string `json:"selection_status,omitempty"`
|
||
SelectionReason string `json:"selection_reason,omitempty"`
|
||
SelectionAction string `json:"selection_action,omitempty"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
}
|
||
|
||
type MetricDiagnosisRow struct {
|
||
VIN string `json:"vin"`
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
Plate string `json:"plate,omitempty"`
|
||
PlatformName string `json:"platform_name,omitempty"`
|
||
Peer string `json:"peer,omitempty"`
|
||
SnapshotEventTime string `json:"snapshot_event_time,omitempty"`
|
||
LocationEventTime string `json:"location_event_time,omitempty"`
|
||
SnapshotUpdatedAt string `json:"snapshot_updated_at,omitempty"`
|
||
LocationUpdatedAt string `json:"location_updated_at,omitempty"`
|
||
RealtimeTotalMileageKM *float64 `json:"realtime_total_mileage_km,omitempty"`
|
||
RealtimeTotalMileageAt string `json:"realtime_total_mileage_event_time,omitempty"`
|
||
DailyMileageKM *float64 `json:"daily_mileage_km,omitempty"`
|
||
DailyLatestTotalMileageKM *float64 `json:"daily_latest_total_mileage_km,omitempty"`
|
||
SourceSampleCount int64 `json:"source_sample_count"`
|
||
OKSourceCount int64 `json:"ok_source_count"`
|
||
SelectableSourceCount int64 `json:"selectable_source_count"`
|
||
LatestStatEventTime string `json:"latest_stat_event_time,omitempty"`
|
||
QualityStatuses []string `json:"quality_statuses,omitempty"`
|
||
RealtimeFieldCount int `json:"realtime_field_count,omitempty"`
|
||
RealtimeSampleFields []string `json:"realtime_sample_fields,omitempty"`
|
||
RealtimeMileageFieldStatus string `json:"realtime_mileage_field_status,omitempty"`
|
||
RealtimeMileageCandidateFields []string `json:"realtime_mileage_candidate_fields,omitempty"`
|
||
RealtimeMileageEvidence string `json:"realtime_mileage_evidence,omitempty"`
|
||
RawFrameQueryPath string `json:"raw_frame_query_path,omitempty"`
|
||
SourceSampleQueryPath string `json:"source_sample_query_path,omitempty"`
|
||
Diagnosis string `json:"diagnosis"`
|
||
Reason string `json:"reason"`
|
||
Severity string `json:"severity,omitempty"`
|
||
RecommendedOperatorAction string `json:"recommended_operator_action,omitempty"`
|
||
}
|
||
|
||
type MetricDiagnosisSummaryRow struct {
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
ActiveCount int64 `json:"active_count"`
|
||
OKCount int64 `json:"ok_count"`
|
||
MissingDailyCount int64 `json:"missing_daily_count"`
|
||
NoSourceSampleCount int64 `json:"no_source_sample_count"`
|
||
NoTotalMileageCount int64 `json:"no_total_mileage_count"`
|
||
ActionableIssueCount int64 `json:"actionable_issue_count"`
|
||
}
|
||
|
||
type MetricDiagnosisReasonSummaryRow struct {
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
Diagnosis string `json:"diagnosis"`
|
||
Reason string `json:"reason"`
|
||
Count int64 `json:"count"`
|
||
Severity string `json:"severity,omitempty"`
|
||
RecommendedOperatorAction string `json:"recommended_operator_action,omitempty"`
|
||
}
|
||
|
||
type MetricDiagnosisFieldStatusSummaryRow struct {
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
Diagnosis string `json:"diagnosis"`
|
||
Reason string `json:"reason"`
|
||
RealtimeMileageFieldStatus string `json:"realtime_mileage_field_status"`
|
||
Count int64 `json:"count"`
|
||
Severity string `json:"severity,omitempty"`
|
||
FieldStatusSeverity string `json:"field_status_severity,omitempty"`
|
||
RecommendedOperatorAction string `json:"recommended_operator_action,omitempty"`
|
||
FieldStatusAction string `json:"field_status_action,omitempty"`
|
||
}
|
||
|
||
type MetricSourceQualitySummaryRow struct {
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
QualityStatus string `json:"quality_status"`
|
||
QualityReason string `json:"quality_reason,omitempty"`
|
||
SourceCount int64 `json:"source_count"`
|
||
VehicleCount int64 `json:"vehicle_count"`
|
||
SelectedCount int64 `json:"selected_count"`
|
||
SampleCount int64 `json:"sample_count"`
|
||
DailyMileageKM float64 `json:"daily_mileage_km"`
|
||
}
|
||
|
||
type MetricSourceSelectionSummaryRow struct {
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
SelectionStatus string `json:"selection_status"`
|
||
SelectionReason string `json:"selection_reason"`
|
||
SelectionAction string `json:"selection_action,omitempty"`
|
||
SourceCount int64 `json:"source_count"`
|
||
VehicleCount int64 `json:"vehicle_count"`
|
||
SelectedCount int64 `json:"selected_count"`
|
||
SampleCount int64 `json:"sample_count"`
|
||
DailyMileageKM float64 `json:"daily_mileage_km"`
|
||
}
|
||
|
||
type MetricRepository struct {
|
||
db Queryer
|
||
}
|
||
|
||
const (
|
||
metricDiagnosisSeverityOK = "ok"
|
||
metricDiagnosisSeverityPipeline = "pipeline"
|
||
metricDiagnosisSeveritySourceData = "source_data"
|
||
metricDiagnosisRealtimeFieldLimit = 80
|
||
metricDiagnosisMileageCandidateLimit = 20
|
||
)
|
||
|
||
func NewMetricRepository(db Queryer) *MetricRepository {
|
||
if db == nil {
|
||
panic("metric query db must not be nil")
|
||
}
|
||
return &MetricRepository{db: db}
|
||
}
|
||
|
||
func (r *MetricRepository) Query(ctx context.Context, query MetricQuery) ([]MetricRow, error) {
|
||
query = normalizeMetricQuery(query)
|
||
sqlText, args := buildMetricSQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := make([]MetricRow, 0)
|
||
for rows.Next() {
|
||
var row MetricRow
|
||
var statDate scanDate
|
||
var updatedAt scanDateTime
|
||
var sourceID sql.NullInt64
|
||
var sourceIP, endpoint, platform, sourceCode, sourceKind sql.NullString
|
||
var latest sql.NullFloat64
|
||
if err := rows.Scan(
|
||
&row.VIN,
|
||
&statDate,
|
||
&row.Protocol,
|
||
&sourceID,
|
||
&sourceIP,
|
||
&endpoint,
|
||
&platform,
|
||
&sourceCode,
|
||
&sourceKind,
|
||
&row.DailyMileageKM,
|
||
&latest,
|
||
&updatedAt,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
row.StatDate = statDate.String
|
||
row.UpdatedAt = updatedAt.String
|
||
if sourceID.Valid {
|
||
row.SourceID = &sourceID.Int64
|
||
}
|
||
row.SourceIP = sourceIP.String
|
||
row.LatestSourceEndpoint = endpoint.String
|
||
row.PlatformName = platform.String
|
||
row.SourceCode = sourceCode.String
|
||
row.SourceKind = normalizeSourceKindForRead(sourceKind.String)
|
||
if !sourceKind.Valid {
|
||
row.SourceKind = ""
|
||
}
|
||
if latest.Valid {
|
||
row.LatestTotalMileageKM = &latest.Float64
|
||
}
|
||
out = append(out, row)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) QuerySources(ctx context.Context, query MetricSourceQuery) ([]MetricSourceRow, error) {
|
||
query = normalizeMetricSourceQuery(query)
|
||
sqlText, args := buildMetricSourceSQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := make([]MetricSourceRow, 0)
|
||
for rows.Next() {
|
||
var row MetricSourceRow
|
||
var statDate scanDate
|
||
var endpoint, phone, deviceID, platform, sourceCode, sourceKind, qualityReason sql.NullString
|
||
var sourceID, enabled, trustPriority sql.NullInt64
|
||
var firstTotal, latestTotal sql.NullFloat64
|
||
var firstEvent, latestEvent, updatedAt scanDateTime
|
||
var selected int
|
||
if err := rows.Scan(
|
||
&row.VIN,
|
||
&statDate,
|
||
&row.Protocol,
|
||
&row.SourceKey,
|
||
&row.SourceIP,
|
||
&endpoint,
|
||
&phone,
|
||
&deviceID,
|
||
&platform,
|
||
&sourceID,
|
||
&sourceCode,
|
||
&sourceKind,
|
||
&enabled,
|
||
&trustPriority,
|
||
&firstTotal,
|
||
&latestTotal,
|
||
&row.DailyMileageKM,
|
||
&row.SampleCount,
|
||
&firstEvent,
|
||
&latestEvent,
|
||
&row.QualityStatus,
|
||
&qualityReason,
|
||
&selected,
|
||
&updatedAt,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
row.StatDate = statDate.String
|
||
row.SourceEndpoint = endpoint.String
|
||
row.Phone = phone.String
|
||
row.DeviceID = deviceID.String
|
||
row.PlatformName = platform.String
|
||
if sourceID.Valid {
|
||
row.SourceID = &sourceID.Int64
|
||
}
|
||
row.SourceCode = sourceCode.String
|
||
row.SourceKind = normalizeSourceKindForRead(sourceKind.String)
|
||
if !sourceKind.Valid {
|
||
row.SourceKind = ""
|
||
}
|
||
if enabled.Valid {
|
||
value := enabled.Int64 != 0
|
||
row.SourceEnabled = &value
|
||
}
|
||
if trustPriority.Valid {
|
||
value := int(trustPriority.Int64)
|
||
row.TrustPriority = &value
|
||
}
|
||
if firstTotal.Valid {
|
||
row.FirstTotalMileageKM = &firstTotal.Float64
|
||
}
|
||
if latestTotal.Valid {
|
||
row.LatestTotalMileageKM = &latestTotal.Float64
|
||
}
|
||
row.FirstEventTime = firstEvent.String
|
||
row.LatestEventTime = latestEvent.String
|
||
row.QualityReason = qualityReason.String
|
||
row.IsSelected = selected != 0
|
||
row.SelectionStatus, row.SelectionReason, row.SelectionAction = metricSourceSelectionEvidence(row)
|
||
row.UpdatedAt = updatedAt.String
|
||
out = append(out, row)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) QueryDiagnostics(ctx context.Context, query MetricDiagnosisQuery) ([]MetricDiagnosisRow, error) {
|
||
query = normalizeMetricDiagnosisQuery(query)
|
||
sqlText, args := buildMetricDiagnosisSQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := make([]MetricDiagnosisRow, 0)
|
||
for rows.Next() {
|
||
var row MetricDiagnosisRow
|
||
var statDate scanDate
|
||
var snapshotEvent, locationEvent, snapshotUpdated, locationUpdated, mileageAt, latestStatEvent scanDateTime
|
||
var plate, platform, peer, qualityStatuses, snapshotParsedJSON sql.NullString
|
||
var realtimeTotal, dailyMileage, dailyLatestTotal sql.NullFloat64
|
||
if err := rows.Scan(
|
||
&row.VIN,
|
||
&statDate,
|
||
&row.Protocol,
|
||
&plate,
|
||
&platform,
|
||
&peer,
|
||
&snapshotEvent,
|
||
&locationEvent,
|
||
&snapshotUpdated,
|
||
&locationUpdated,
|
||
&realtimeTotal,
|
||
&mileageAt,
|
||
&dailyMileage,
|
||
&dailyLatestTotal,
|
||
&row.SourceSampleCount,
|
||
&row.OKSourceCount,
|
||
&row.SelectableSourceCount,
|
||
&latestStatEvent,
|
||
&qualityStatuses,
|
||
&snapshotParsedJSON,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
row.StatDate = statDate.String
|
||
row.Plate = plate.String
|
||
row.PlatformName = platform.String
|
||
row.Peer = peer.String
|
||
row.SnapshotEventTime = snapshotEvent.String
|
||
row.LocationEventTime = locationEvent.String
|
||
row.SnapshotUpdatedAt = snapshotUpdated.String
|
||
row.LocationUpdatedAt = locationUpdated.String
|
||
row.RealtimeTotalMileageAt = mileageAt.String
|
||
row.LatestStatEventTime = latestStatEvent.String
|
||
if realtimeTotal.Valid {
|
||
row.RealtimeTotalMileageKM = &realtimeTotal.Float64
|
||
}
|
||
if dailyMileage.Valid {
|
||
row.DailyMileageKM = &dailyMileage.Float64
|
||
}
|
||
if dailyLatestTotal.Valid {
|
||
row.DailyLatestTotalMileageKM = &dailyLatestTotal.Float64
|
||
}
|
||
row.QualityStatuses = splitMetricStatuses(qualityStatuses.String)
|
||
row.RealtimeFieldCount, row.RealtimeSampleFields = metricDiagnosisRealtimeFields(snapshotParsedJSON.String, metricDiagnosisRealtimeFieldLimit)
|
||
row.Diagnosis, row.Reason = classifyMetricDiagnosis(dailyMileage.Valid, row.SourceSampleCount, row.OKSourceCount, row.SelectableSourceCount, realtimeTotal, row.RealtimeTotalMileageAt, row.StatDate)
|
||
row.Severity = metricDiagnosisIssueSeverity(row.Diagnosis, row.Reason)
|
||
row.RealtimeMileageFieldStatus, row.RealtimeMileageCandidateFields, row.RealtimeMileageEvidence = metricDiagnosisRealtimeMileageEvidence(
|
||
row.Protocol,
|
||
row.Diagnosis,
|
||
row.Reason,
|
||
row.RealtimeFieldCount,
|
||
row.RealtimeSampleFields,
|
||
snapshotParsedJSON.String,
|
||
row.RealtimeTotalMileageKM,
|
||
row.RealtimeTotalMileageAt,
|
||
row.StatDate,
|
||
)
|
||
row.RawFrameQueryPath = metricDiagnosisRawFrameQueryPath(row)
|
||
row.SourceSampleQueryPath = metricDiagnosisSourceSampleQueryPath(row)
|
||
row.RecommendedOperatorAction = metricDiagnosisRecommendedOperatorAction(row.Diagnosis, row.Reason)
|
||
out = append(out, row)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) QueryDiagnosisSummary(ctx context.Context, query MetricDiagnosisQuery) ([]MetricDiagnosisSummaryRow, error) {
|
||
query = normalizeMetricDiagnosisQuery(query)
|
||
query.Diagnosis = ""
|
||
sqlText, args := buildMetricDiagnosisSummarySQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := make([]MetricDiagnosisSummaryRow, 0)
|
||
for rows.Next() {
|
||
var row MetricDiagnosisSummaryRow
|
||
var statDate scanDate
|
||
if err := rows.Scan(
|
||
&statDate,
|
||
&row.Protocol,
|
||
&row.ActiveCount,
|
||
&row.OKCount,
|
||
&row.MissingDailyCount,
|
||
&row.NoSourceSampleCount,
|
||
&row.NoTotalMileageCount,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
row.StatDate = statDate.String
|
||
row.ActionableIssueCount = row.MissingDailyCount + row.NoSourceSampleCount + row.NoTotalMileageCount
|
||
out = append(out, row)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) QueryDiagnosisReasonSummary(ctx context.Context, query MetricDiagnosisQuery) ([]MetricDiagnosisReasonSummaryRow, error) {
|
||
query = normalizeMetricDiagnosisQuery(query)
|
||
sqlText, args := buildMetricDiagnosisReasonSummarySQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := make([]MetricDiagnosisReasonSummaryRow, 0)
|
||
for rows.Next() {
|
||
var row MetricDiagnosisReasonSummaryRow
|
||
var statDate scanDate
|
||
if err := rows.Scan(
|
||
&statDate,
|
||
&row.Protocol,
|
||
&row.Diagnosis,
|
||
&row.Reason,
|
||
&row.Count,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
row.StatDate = statDate.String
|
||
row.Severity = metricDiagnosisIssueSeverity(row.Diagnosis, row.Reason)
|
||
row.RecommendedOperatorAction = metricDiagnosisRecommendedOperatorAction(row.Diagnosis, row.Reason)
|
||
out = append(out, row)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) QueryDiagnosisFieldStatusSummary(ctx context.Context, query MetricDiagnosisQuery) ([]MetricDiagnosisFieldStatusSummaryRow, error) {
|
||
query = normalizeMetricDiagnosisQuery(query)
|
||
sqlText, args := buildMetricDiagnosisFieldStatusSummarySQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := make([]MetricDiagnosisFieldStatusSummaryRow, 0)
|
||
for rows.Next() {
|
||
var row MetricDiagnosisFieldStatusSummaryRow
|
||
var statDate scanDate
|
||
if err := rows.Scan(
|
||
&statDate,
|
||
&row.Protocol,
|
||
&row.Diagnosis,
|
||
&row.Reason,
|
||
&row.RealtimeMileageFieldStatus,
|
||
&row.Count,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
row.StatDate = statDate.String
|
||
row.Severity = metricDiagnosisIssueSeverity(row.Diagnosis, row.Reason)
|
||
row.FieldStatusSeverity = metricDiagnosisMileageFieldStatusSeverity(row.RealtimeMileageFieldStatus)
|
||
row.RecommendedOperatorAction = metricDiagnosisRecommendedOperatorAction(row.Diagnosis, row.Reason)
|
||
row.FieldStatusAction = metricDiagnosisMileageFieldStatusAction(row.RealtimeMileageFieldStatus)
|
||
out = append(out, row)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) QuerySourceQualitySummary(ctx context.Context, query MetricSourceQuery) ([]MetricSourceQualitySummaryRow, error) {
|
||
query = normalizeMetricSourceQuery(query)
|
||
sqlText, args := buildMetricSourceQualitySummarySQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := make([]MetricSourceQualitySummaryRow, 0)
|
||
for rows.Next() {
|
||
var row MetricSourceQualitySummaryRow
|
||
var statDate scanDate
|
||
var qualityReason sql.NullString
|
||
if err := rows.Scan(
|
||
&statDate,
|
||
&row.Protocol,
|
||
&row.QualityStatus,
|
||
&qualityReason,
|
||
&row.SourceCount,
|
||
&row.VehicleCount,
|
||
&row.SelectedCount,
|
||
&row.SampleCount,
|
||
&row.DailyMileageKM,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
row.StatDate = statDate.String
|
||
row.QualityReason = qualityReason.String
|
||
out = append(out, row)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) QuerySourceSelectionSummary(ctx context.Context, query MetricSourceQuery) ([]MetricSourceSelectionSummaryRow, error) {
|
||
query = normalizeMetricSourceQuery(query)
|
||
sqlText, args := buildMetricSourceSelectionSummarySQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := make([]MetricSourceSelectionSummaryRow, 0)
|
||
for rows.Next() {
|
||
var row MetricSourceSelectionSummaryRow
|
||
var statDate scanDate
|
||
if err := rows.Scan(
|
||
&statDate,
|
||
&row.Protocol,
|
||
&row.SelectionStatus,
|
||
&row.SelectionReason,
|
||
&row.SourceCount,
|
||
&row.VehicleCount,
|
||
&row.SelectedCount,
|
||
&row.SampleCount,
|
||
&row.DailyMileageKM,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
row.StatDate = statDate.String
|
||
row.SelectionAction = metricSourceSelectionAction(row.SelectionStatus, row.SelectionReason)
|
||
out = append(out, row)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) Count(ctx context.Context, query MetricQuery) (int64, error) {
|
||
query = normalizeMetricQuery(query)
|
||
sqlText, args := buildMetricCountSQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var total int64
|
||
if rows.Next() {
|
||
if err := rows.Scan(&total); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
return total, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) CountSources(ctx context.Context, query MetricSourceQuery) (int64, error) {
|
||
query = normalizeMetricSourceQuery(query)
|
||
sqlText, args := buildMetricSourceCountSQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var total int64
|
||
if rows.Next() {
|
||
if err := rows.Scan(&total); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
return total, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) CountSourceQualitySummary(ctx context.Context, query MetricSourceQuery) (int64, error) {
|
||
query = normalizeMetricSourceQuery(query)
|
||
sqlText, args := buildMetricSourceQualitySummaryCountSQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var total int64
|
||
if rows.Next() {
|
||
if err := rows.Scan(&total); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
return total, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) CountSourceSelectionSummary(ctx context.Context, query MetricSourceQuery) (int64, error) {
|
||
query = normalizeMetricSourceQuery(query)
|
||
sqlText, args := buildMetricSourceSelectionSummaryCountSQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var total int64
|
||
if rows.Next() {
|
||
if err := rows.Scan(&total); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
return total, rows.Err()
|
||
}
|
||
|
||
func (r *MetricRepository) CountDiagnostics(ctx context.Context, query MetricDiagnosisQuery) (int64, error) {
|
||
query = normalizeMetricDiagnosisQuery(query)
|
||
sqlText, args := buildMetricDiagnosisCountSQL(query)
|
||
rows, err := r.db.QueryContext(ctx, sqlText, args...)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var total int64
|
||
if rows.Next() {
|
||
if err := rows.Scan(&total); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
return total, rows.Err()
|
||
}
|
||
|
||
func metricDiagnosisIssueSeverity(diagnosis, reason string) string {
|
||
switch strings.ToUpper(strings.TrimSpace(diagnosis)) {
|
||
case "", "OK":
|
||
return metricDiagnosisSeverityOK
|
||
case "MISSING_DAILY":
|
||
switch strings.ToLower(strings.TrimSpace(reason)) {
|
||
case "source_samples_all_invalid", "source_samples_all_excluded":
|
||
return metricDiagnosisSeveritySourceData
|
||
}
|
||
return metricDiagnosisSeverityPipeline
|
||
case "NO_SOURCE_SAMPLE":
|
||
return metricDiagnosisSeverityPipeline
|
||
case "NO_TOTAL_MILEAGE":
|
||
switch strings.ToLower(strings.TrimSpace(reason)) {
|
||
case "realtime_total_mileage_time_missing":
|
||
return metricDiagnosisSeverityPipeline
|
||
default:
|
||
return metricDiagnosisSeveritySourceData
|
||
}
|
||
default:
|
||
return metricDiagnosisSeverityPipeline
|
||
}
|
||
}
|
||
|
||
func metricDiagnosisRecommendedOperatorAction(diagnosis, reason string) string {
|
||
diagnosis = strings.ToUpper(strings.TrimSpace(diagnosis))
|
||
reason = strings.ToLower(strings.TrimSpace(reason))
|
||
switch diagnosis {
|
||
case "", "OK":
|
||
return "无需处理,车辆当日里程已生成"
|
||
case "MISSING_DAILY":
|
||
switch reason {
|
||
case "source_samples_all_invalid":
|
||
return "统计候选样本已生成,但全部被质量规则排除,优先查看 source quality/selection 的 INVALID_DELTA、里程回退或异常跳变原因"
|
||
case "source_samples_all_excluded":
|
||
return "统计候选样本质量可用,但没有可参与选举的来源,优先检查 vehicle_data_source 是否被禁用或来源配置是否缺失"
|
||
}
|
||
return "统计源样本已存在但最终日表缺失,优先排查 stat-writer 投影任务、MySQL 写入和 source 选举"
|
||
case "NO_SOURCE_SAMPLE":
|
||
return "实时位置已有有效总里程但统计字段样本缺失,优先排查字段流 topic、stat-writer 消费和协议字段映射"
|
||
case "NO_TOTAL_MILEAGE":
|
||
switch reason {
|
||
case "realtime_total_mileage_time_missing":
|
||
return "总里程值存在但事件时间缺失,优先排查解析器和 history/realtime 写入的时间字段"
|
||
case "realtime_total_mileage_missing":
|
||
return "当日实时数据缺少总里程字段,核对平台是否上报该字段以及协议字段映射是否覆盖"
|
||
case "realtime_total_mileage_non_positive":
|
||
return "终端上报总里程小于等于 0,不参与差值统计,需核对设备或平台里程字段来源"
|
||
case "realtime_total_mileage_not_reported_on_stat_date":
|
||
return "最新总里程不是统计日期内上报;snapshot 里的里程可能是上一条完整帧保留值,需核对同日 raw_frames 是否真实上报总里程"
|
||
default:
|
||
return "车辆当日在线但没有可用于里程统计的总里程,优先核对源平台报文字段和解析映射"
|
||
}
|
||
default:
|
||
return "诊断状态未知,检查统计诊断 SQL 和上游字段流"
|
||
}
|
||
}
|
||
|
||
func metricDiagnosisMileageFieldStatusSeverity(status string) string {
|
||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||
case "daily_metric_exists":
|
||
return metricDiagnosisSeverityOK
|
||
case "source_sample_exists", "standard_field_not_consumed", "standard_field_time_missing", "candidate_field_unmapped", "no_realtime_fields":
|
||
return metricDiagnosisSeverityPipeline
|
||
case "standard_field_non_positive", "standard_field_stale", "mapped_protocol_field_without_fresh_evidence", "no_candidate_mileage_field":
|
||
return metricDiagnosisSeveritySourceData
|
||
default:
|
||
return metricDiagnosisSeverityPipeline
|
||
}
|
||
}
|
||
|
||
func metricDiagnosisMileageFieldStatusAction(status string) string {
|
||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||
case "daily_metric_exists":
|
||
return "最终日里程已生成,无需处理"
|
||
case "source_sample_exists":
|
||
return "统计候选样本已存在但最终表缺失,优先排查 source 选举和日表 upsert"
|
||
case "standard_field_not_consumed":
|
||
return "实时位置已有当日有效总里程但 stat-writer 未形成候选样本,优先排查字段流 topic 和消费位点"
|
||
case "standard_field_non_positive":
|
||
return "源头总里程小于等于 0,当前不参与差值统计,需要核对终端或平台字段来源"
|
||
case "standard_field_time_missing":
|
||
return "总里程值存在但缺少事件时间,优先修复协议解析或实时写入的时间字段"
|
||
case "standard_field_stale":
|
||
return "标准总里程仍停留在非统计日期;snapshot 字段可能是合并保留值,以同日 raw_frames 是否包含总里程为准"
|
||
case "candidate_field_unmapped":
|
||
return "实时字段中存在疑似里程字段但未进入标准 total_mileage_km,优先补字段映射和单位换算"
|
||
case "mapped_protocol_field_without_fresh_evidence":
|
||
return "合并快照保留了协议里程字段,但没有对应的新鲜里程时间;不能据此判定当前帧已上报,请按 raw_frame_query_path 核对统计日原始帧"
|
||
case "no_candidate_mileage_field":
|
||
return "实时字段中没有疑似里程字段,优先向源平台核对是否上报总里程"
|
||
case "no_realtime_fields":
|
||
return "实时快照没有解析字段,优先排查协议解析、实时写入和 snapshot 表更新"
|
||
default:
|
||
return "字段状态未知,检查诊断 SQL 和实时快照数据"
|
||
}
|
||
}
|
||
|
||
func metricSourceSelectionEvidence(row MetricSourceRow) (status string, reason string, action string) {
|
||
if row.IsSelected {
|
||
status, reason = "selected", "selected_current_projection"
|
||
return status, reason, metricSourceSelectionAction(status, reason)
|
||
}
|
||
qualityStatus := strings.ToUpper(strings.TrimSpace(row.QualityStatus))
|
||
qualityReason := strings.ToLower(strings.TrimSpace(row.QualityReason))
|
||
if qualityStatus != "" && qualityStatus != QualityOK {
|
||
if qualityReason == "" {
|
||
qualityReason = strings.ToLower(qualityStatus)
|
||
}
|
||
status, reason = "excluded", "quality_"+qualityReason
|
||
return status, reason, metricSourceSelectionAction(status, reason)
|
||
}
|
||
if row.SourceEnabled != nil && !*row.SourceEnabled {
|
||
status, reason = "excluded", "source_disabled"
|
||
return status, reason, metricSourceSelectionAction(status, reason)
|
||
}
|
||
if row.SourceID == nil {
|
||
if normalizeSourceKindForRead(row.SourceKind) == "DIRECT" {
|
||
status, reason = "not_selected", "lower_trust_priority_or_sample_count"
|
||
return status, reason, metricSourceSelectionAction(status, reason)
|
||
}
|
||
status, reason = "not_selected", "unmanaged_source_lower_priority"
|
||
return status, reason, metricSourceSelectionAction(status, reason)
|
||
}
|
||
switch normalizeSourceKindForRead(row.SourceKind) {
|
||
case "UNKNOWN":
|
||
status, reason = "not_selected", "unknown_source_kind_lower_priority"
|
||
default:
|
||
status, reason = "not_selected", "lower_trust_priority_or_sample_count"
|
||
}
|
||
return status, reason, metricSourceSelectionAction(status, reason)
|
||
}
|
||
|
||
func metricSourceSelectionAction(status string, reason string) string {
|
||
switch strings.ToLower(strings.TrimSpace(reason)) {
|
||
case "selected_current_projection":
|
||
return "当前来源已被投影到最终日里程表"
|
||
case "source_disabled":
|
||
return "来源已在 vehicle_data_source 中禁用,不参与最终日里程选举"
|
||
case "unmanaged_source_lower_priority":
|
||
return "来源尚未纳入 vehicle_data_source 管理;质量可用但未被本次选中"
|
||
case "unknown_source_kind_lower_priority":
|
||
return "来源类型仍是 UNKNOWN;质量可用但优先级低于已配置来源"
|
||
case "lower_trust_priority_or_sample_count":
|
||
return "来源质量可用,但被更高可信优先级、更多样本或更新时间更新的来源覆盖"
|
||
default:
|
||
if strings.EqualFold(strings.TrimSpace(status), "excluded") && strings.HasPrefix(strings.ToLower(strings.TrimSpace(reason)), "quality_") {
|
||
return "来源质量未通过,不参与最终日里程选举"
|
||
}
|
||
return "来源选举状态未知,检查 vehicle_daily_mileage_source 与 vehicle_data_source"
|
||
}
|
||
}
|
||
|
||
func metricDiagnosisRealtimeFields(raw string, limit int) (int, []string) {
|
||
keys := metricDiagnosisRealtimeFieldKeys(raw)
|
||
if limit <= 0 || len(keys) <= limit {
|
||
return len(keys), keys
|
||
}
|
||
return len(keys), keys[:limit]
|
||
}
|
||
|
||
func metricDiagnosisRealtimeFieldKeys(raw string) []string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
var values map[string]any
|
||
if err := json.Unmarshal([]byte(raw), &values); err != nil || len(values) == 0 {
|
||
return nil
|
||
}
|
||
keys := make([]string, 0, len(values))
|
||
for key := range values {
|
||
key = strings.TrimSpace(key)
|
||
if key != "" {
|
||
keys = append(keys, key)
|
||
}
|
||
}
|
||
sort.Strings(keys)
|
||
return keys
|
||
}
|
||
|
||
func metricDiagnosisRealtimeMileageEvidence(protocol string, diagnosis string, reason string, fieldCount int, sampleFields []string, raw string, realtimeTotal *float64, realtimeTotalAt string, statDate string) (string, []string, string) {
|
||
if strings.ToUpper(strings.TrimSpace(diagnosis)) != "NO_TOTAL_MILEAGE" {
|
||
return "", nil, ""
|
||
}
|
||
reason = strings.ToLower(strings.TrimSpace(reason))
|
||
if reason != "realtime_total_mileage_missing" && reason != "realtime_total_mileage_not_reported_on_stat_date" {
|
||
return "", nil, ""
|
||
}
|
||
if realtimeTotal != nil {
|
||
switch {
|
||
case *realtimeTotal <= 0:
|
||
return "standard_field_non_positive", nil, "实时位置标准总里程小于等于 0,当前不参与差值统计"
|
||
case strings.TrimSpace(realtimeTotalAt) == "":
|
||
return "standard_field_time_missing", nil, "实时位置标准总里程已存在,但缺少该字段对应的事件时间"
|
||
case reason == "realtime_total_mileage_not_reported_on_stat_date" || !dateTimeInStatDate(realtimeTotalAt, statDate):
|
||
return "standard_field_stale", nil, "实时位置标准总里程已存在,但该里程时间不在统计日内;snapshot 可能保留上一条完整帧字段,请用 raw_frame_query_path 核对同日原始帧是否真实上报总里程"
|
||
}
|
||
}
|
||
if fieldCount == 0 {
|
||
return "no_realtime_fields", nil, "实时快照没有解析字段,优先检查协议解析、实时写入和 snapshot 表更新"
|
||
}
|
||
keys := metricDiagnosisRealtimeFieldKeys(raw)
|
||
if len(keys) == 0 {
|
||
keys = sampleFields
|
||
}
|
||
candidates := metricDiagnosisMileageCandidateFields(keys, metricDiagnosisMileageCandidateLimit)
|
||
if len(candidates) == 0 {
|
||
return "no_candidate_mileage_field", nil, "实时快照已有字段,但没有发现 mileage/odometer/odo 等疑似总里程字段;大概率源头未上报可用总里程,必要时查询 raw_frames 核对是否上报了 0 或异常值"
|
||
}
|
||
if known := knownMappedMileageFields(protocol, candidates); len(known) > 0 {
|
||
return "mapped_protocol_field_without_fresh_evidence", known, "合并快照保留了已知协议总里程字段,但没有对应的新鲜 total_mileage_event_time;该字段可能来自历史完整帧,不能作为当前帧上报证据"
|
||
}
|
||
return "candidate_field_unmapped", candidates, "实时快照存在疑似里程字段但未进入标准 total_mileage_km,优先检查协议字段映射、单位换算和实时 location 写入"
|
||
}
|
||
|
||
func dateTimeInStatDate(value string, statDate string) bool {
|
||
value = strings.TrimSpace(value)
|
||
statDate = strings.TrimSpace(statDate)
|
||
if value == "" || statDate == "" {
|
||
return false
|
||
}
|
||
return strings.HasPrefix(value, statDate+" ")
|
||
}
|
||
|
||
func knownMappedMileageFields(protocol string, candidates []string) []string {
|
||
if len(candidates) == 0 {
|
||
return nil
|
||
}
|
||
known := map[string]struct{}{}
|
||
for _, mapping := range mileageMappingsByProtocol(envelope.Protocol(strings.TrimSpace(protocol))) {
|
||
known[strings.ToLower(strings.TrimSpace(mapping.Key))] = struct{}{}
|
||
}
|
||
out := make([]string, 0, len(candidates))
|
||
for _, candidate := range candidates {
|
||
normalized := strings.ToLower(strings.TrimSpace(candidate))
|
||
if _, ok := known[normalized]; !ok {
|
||
continue
|
||
}
|
||
out = append(out, candidate)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func metricDiagnosisRawFrameQueryPath(row MetricDiagnosisRow) string {
|
||
if strings.TrimSpace(row.VIN) == "" || strings.TrimSpace(row.Protocol) == "" || strings.TrimSpace(row.StatDate) == "" {
|
||
return ""
|
||
}
|
||
dateFrom, dateTo := metricDiagnosisDateWindow(row.StatDate)
|
||
values := url.Values{}
|
||
values.Set("protocol", row.Protocol)
|
||
values.Set("vin", row.VIN)
|
||
values.Set("dateFrom", dateFrom)
|
||
values.Set("dateTo", dateTo)
|
||
values.Set("orderBy", "eventTime")
|
||
values.Set("includeFields", "true")
|
||
values.Set("limit", "20")
|
||
return "/api/history/raw-frames?" + values.Encode()
|
||
}
|
||
|
||
func metricDiagnosisSourceSampleQueryPath(row MetricDiagnosisRow) string {
|
||
if strings.TrimSpace(row.VIN) == "" || strings.TrimSpace(row.Protocol) == "" || strings.TrimSpace(row.StatDate) == "" {
|
||
return ""
|
||
}
|
||
values := url.Values{}
|
||
values.Set("vin", row.VIN)
|
||
values.Set("protocol", row.Protocol)
|
||
values.Set("dateFrom", row.StatDate)
|
||
values.Set("dateTo", row.StatDate)
|
||
values.Set("includeTotal", "true")
|
||
values.Set("limit", "50")
|
||
return "/api/stats/daily-metrics/sources?" + values.Encode()
|
||
}
|
||
|
||
func metricDiagnosisDateWindow(statDate string) (string, string) {
|
||
date := strings.TrimSpace(statDate)
|
||
start, err := time.Parse("2006-01-02", date)
|
||
if err != nil {
|
||
return date, date
|
||
}
|
||
return start.Format("2006-01-02 15:04:05"), start.AddDate(0, 0, 1).Format("2006-01-02 15:04:05")
|
||
}
|
||
|
||
func metricDiagnosisMileageCandidateFields(fields []string, limit int) []string {
|
||
out := make([]string, 0)
|
||
seen := map[string]struct{}{}
|
||
for _, field := range fields {
|
||
field = strings.TrimSpace(field)
|
||
if field == "" || !looksLikeMileageField(field) {
|
||
continue
|
||
}
|
||
if _, ok := seen[field]; ok {
|
||
continue
|
||
}
|
||
seen[field] = struct{}{}
|
||
out = append(out, field)
|
||
if limit > 0 && len(out) >= limit {
|
||
return out
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func looksLikeMileageField(field string) bool {
|
||
normalized := strings.ToLower(strings.TrimSpace(field))
|
||
normalized = strings.ReplaceAll(normalized, "-", "_")
|
||
normalized = strings.ReplaceAll(normalized, "/", ".")
|
||
switch {
|
||
case strings.Contains(normalized, "mileage"):
|
||
return true
|
||
case strings.Contains(normalized, "odometer"):
|
||
return true
|
||
case strings.Contains(normalized, ".odo"):
|
||
return true
|
||
case strings.Contains(normalized, "_odo"):
|
||
return true
|
||
case strings.HasSuffix(normalized, ".odo"):
|
||
return true
|
||
case strings.HasSuffix(normalized, "_odo"):
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func metricDiagnosisReasonSummaryIssueTotals(rows []MetricDiagnosisReasonSummaryRow) (vehicleTotal, actionableTotal, pipelineTotal, sourceDataTotal int64) {
|
||
for _, row := range rows {
|
||
vehicleTotal += row.Count
|
||
switch row.Severity {
|
||
case metricDiagnosisSeverityPipeline:
|
||
actionableTotal += row.Count
|
||
pipelineTotal += row.Count
|
||
case metricDiagnosisSeveritySourceData:
|
||
actionableTotal += row.Count
|
||
sourceDataTotal += row.Count
|
||
default:
|
||
if strings.ToUpper(strings.TrimSpace(row.Diagnosis)) != "OK" {
|
||
actionableTotal += row.Count
|
||
}
|
||
}
|
||
}
|
||
return vehicleTotal, actionableTotal, pipelineTotal, sourceDataTotal
|
||
}
|
||
|
||
func metricDiagnosisFieldStatusSummaryIssueTotals(rows []MetricDiagnosisFieldStatusSummaryRow) (vehicleTotal, actionableTotal, pipelineTotal, sourceDataTotal int64) {
|
||
for _, row := range rows {
|
||
vehicleTotal += row.Count
|
||
severity := strings.ToLower(strings.TrimSpace(row.Severity))
|
||
if severity == "" {
|
||
severity = strings.ToLower(strings.TrimSpace(row.FieldStatusSeverity))
|
||
}
|
||
switch severity {
|
||
case metricDiagnosisSeverityPipeline:
|
||
actionableTotal += row.Count
|
||
pipelineTotal += row.Count
|
||
case metricDiagnosisSeveritySourceData:
|
||
actionableTotal += row.Count
|
||
sourceDataTotal += row.Count
|
||
default:
|
||
if strings.ToUpper(strings.TrimSpace(row.Diagnosis)) != "OK" {
|
||
actionableTotal += row.Count
|
||
}
|
||
}
|
||
}
|
||
return vehicleTotal, actionableTotal, pipelineTotal, sourceDataTotal
|
||
}
|
||
|
||
func normalizeMetricQuery(query MetricQuery) MetricQuery {
|
||
query.VIN = strings.TrimSpace(query.VIN)
|
||
query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
|
||
query.DateFrom = strings.TrimSpace(query.DateFrom)
|
||
query.DateTo = strings.TrimSpace(query.DateTo)
|
||
if query.Limit <= 0 {
|
||
query.Limit = 50
|
||
}
|
||
return query
|
||
}
|
||
|
||
func normalizeMetricSourceQuery(query MetricSourceQuery) MetricSourceQuery {
|
||
query.VIN = strings.TrimSpace(query.VIN)
|
||
query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
|
||
query.DateFrom = strings.TrimSpace(query.DateFrom)
|
||
query.DateTo = strings.TrimSpace(query.DateTo)
|
||
query.SourceIP = strings.TrimSpace(query.SourceIP)
|
||
query.QualityStatus = strings.ToUpper(strings.TrimSpace(query.QualityStatus))
|
||
query.QualityReason = strings.ToLower(strings.TrimSpace(query.QualityReason))
|
||
query.OrderBy = normalizeMetricSourceOrderBy(query.OrderBy)
|
||
if query.Limit <= 0 {
|
||
query.Limit = 50
|
||
}
|
||
return query
|
||
}
|
||
|
||
func normalizeMetricSourceOrderBy(value string) string {
|
||
normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), "_", ""))
|
||
switch normalized {
|
||
case "dailymileagedesc", "dailymileage":
|
||
return "dailyMileageDesc"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func normalizeMetricDiagnosisQuery(query MetricDiagnosisQuery) MetricDiagnosisQuery {
|
||
query.VIN = strings.TrimSpace(query.VIN)
|
||
query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol))
|
||
query.Date = strings.TrimSpace(query.Date)
|
||
if query.Date == "" {
|
||
query.Date = time.Now().In(time.FixedZone("Asia/Shanghai", 8*3600)).Format("2006-01-02")
|
||
}
|
||
query.Diagnosis = strings.ToUpper(strings.TrimSpace(query.Diagnosis))
|
||
query.Reason = strings.ToLower(strings.TrimSpace(query.Reason))
|
||
query.Severity = strings.ToLower(strings.TrimSpace(query.Severity))
|
||
if query.Limit <= 0 {
|
||
query.Limit = 50
|
||
}
|
||
return query
|
||
}
|
||
|
||
func buildMetricSQL(query MetricQuery) (string, []any) {
|
||
where, args := buildMetricWhere(query)
|
||
sqlText := `SELECT m.vin, m.stat_date, m.protocol, m.source_id,
|
||
ds.source_ip, ds.latest_source_endpoint, ds.platform_name, ds.source_code, ds.source_kind,
|
||
m.daily_mileage_km, m.latest_total_mileage_km, m.updated_at
|
||
FROM vehicle_daily_mileage m
|
||
LEFT JOIN vehicle_data_source ds ON ds.id = m.source_id`
|
||
if len(where) > 0 {
|
||
sqlText += " WHERE " + strings.Join(where, " AND ")
|
||
}
|
||
sqlText += " ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?"
|
||
args = append(args, query.Limit, query.Offset)
|
||
return sqlText, args
|
||
}
|
||
|
||
func buildMetricSourceSQL(query MetricSourceQuery) (string, []any) {
|
||
where, args := buildMetricSourceWhere(query)
|
||
sqlText := `SELECT s.vin, s.stat_date, s.protocol, s.source_key, s.source_ip, s.source_endpoint, s.phone, s.device_id,
|
||
COALESCE(NULLIF(TRIM(s.platform_name), ''), ds.platform_name) AS platform_name,
|
||
ds.id, ds.source_code, ` + metricSourceKindSQL("s") + ` AS source_kind, ds.enabled, ds.trust_priority,
|
||
s.first_total_mileage_km, s.latest_total_mileage_km, s.daily_mileage_km, s.sample_count,
|
||
s.first_event_time, s.latest_event_time, s.quality_status, s.quality_reason, s.is_selected, s.updated_at
|
||
FROM vehicle_daily_mileage_source s
|
||
LEFT JOIN vehicle_data_source ds ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip`
|
||
if len(where) > 0 {
|
||
sqlText += " WHERE " + strings.Join(where, " AND ")
|
||
}
|
||
sqlText += metricSourceOrderBySQL(query)
|
||
args = append(args, query.Limit, query.Offset)
|
||
return sqlText, args
|
||
}
|
||
|
||
func metricSourceOrderBySQL(query MetricSourceQuery) string {
|
||
if query.OrderBy == "dailyMileageDesc" {
|
||
return ` ORDER BY s.daily_mileage_km DESC, s.sample_count DESC, s.latest_event_time DESC,
|
||
s.stat_date DESC, s.vin ASC, s.protocol ASC, s.is_selected DESC,
|
||
CASE ` + metricSourceKindSQL("s") + `
|
||
WHEN 'PLATFORM' THEN 0
|
||
WHEN 'DIRECT' THEN 1
|
||
WHEN 'UNKNOWN' THEN 2
|
||
ELSE 3
|
||
END,
|
||
ds.trust_priority,
|
||
s.source_key ASC
|
||
LIMIT ? OFFSET ?`
|
||
}
|
||
return ` ORDER BY s.stat_date DESC, s.vin ASC, s.protocol ASC, s.is_selected DESC,
|
||
CASE ` + metricSourceKindSQL("s") + `
|
||
WHEN 'PLATFORM' THEN 0
|
||
WHEN 'DIRECT' THEN 1
|
||
WHEN 'UNKNOWN' THEN 2
|
||
ELSE 3
|
||
END,
|
||
ds.trust_priority,
|
||
s.sample_count DESC,
|
||
s.latest_event_time DESC,
|
||
s.source_key ASC
|
||
LIMIT ? OFFSET ?`
|
||
}
|
||
|
||
func buildMetricSourceQualitySummarySQL(query MetricSourceQuery) (string, []any) {
|
||
where, args := buildMetricSourceWhere(query)
|
||
sqlText := `SELECT s.stat_date, s.protocol, s.quality_status, s.quality_reason,
|
||
COUNT(*) AS source_count,
|
||
COUNT(DISTINCT s.vin) AS vehicle_count,
|
||
SUM(CASE WHEN s.is_selected = 1 THEN 1 ELSE 0 END) AS selected_count,
|
||
COALESCE(SUM(s.sample_count), 0) AS sample_count,
|
||
COALESCE(SUM(s.daily_mileage_km), 0) AS daily_mileage_km
|
||
FROM vehicle_daily_mileage_source s`
|
||
if len(where) > 0 {
|
||
sqlText += " WHERE " + strings.Join(where, " AND ")
|
||
}
|
||
sqlText += ` GROUP BY s.stat_date, s.protocol, s.quality_status, s.quality_reason
|
||
ORDER BY s.stat_date DESC,
|
||
s.protocol ASC,
|
||
CASE s.quality_status
|
||
WHEN 'OK' THEN 0
|
||
WHEN 'NO_PREVIOUS_BASELINE' THEN 1
|
||
WHEN 'INVALID_DELTA' THEN 2
|
||
ELSE 3
|
||
END,
|
||
source_count DESC,
|
||
s.quality_reason ASC
|
||
LIMIT ? OFFSET ?`
|
||
args = append(args, query.Limit, query.Offset)
|
||
return sqlText, args
|
||
}
|
||
|
||
func buildMetricSourceSelectionSummarySQL(query MetricSourceQuery) (string, []any) {
|
||
baseSQL, args := buildMetricSourceSelectionSummaryBaseSQL(query)
|
||
sqlText := `SELECT stat_date, protocol, selection_status, selection_reason,
|
||
COUNT(*) AS source_count,
|
||
COUNT(DISTINCT vin) AS vehicle_count,
|
||
SUM(CASE WHEN is_selected = 1 THEN 1 ELSE 0 END) AS selected_count,
|
||
COALESCE(SUM(sample_count), 0) AS sample_count,
|
||
COALESCE(SUM(daily_mileage_km), 0) AS daily_mileage_km
|
||
FROM (` + baseSQL + `
|
||
) source_selection
|
||
GROUP BY stat_date, protocol, selection_status, selection_reason
|
||
ORDER BY stat_date DESC,
|
||
protocol ASC,
|
||
CASE selection_status
|
||
WHEN 'selected' THEN 0
|
||
WHEN 'excluded' THEN 1
|
||
WHEN 'not_selected' THEN 2
|
||
ELSE 3
|
||
END,
|
||
source_count DESC,
|
||
selection_reason ASC
|
||
LIMIT ? OFFSET ?`
|
||
args = append(args, query.Limit, query.Offset)
|
||
return sqlText, args
|
||
}
|
||
|
||
func buildMetricDiagnosisSQL(query MetricDiagnosisQuery) (string, []any) {
|
||
fromSQL, args := buildMetricDiagnosisFromSQL(query)
|
||
sqlText := `SELECT rt.vin, ? AS stat_date, rt.protocol,
|
||
COALESCE(NULLIF(s.plate, ''), NULLIF(l.plate, ''), '') AS plate,
|
||
COALESCE(NULLIF(s.platform_name, ''), '') AS platform_name,
|
||
COALESCE(NULLIF(s.peer, ''), '') AS peer,
|
||
s.event_time AS snapshot_event_time,
|
||
l.event_time AS location_event_time,
|
||
s.updated_at AS snapshot_updated_at,
|
||
l.updated_at AS location_updated_at,
|
||
l.total_mileage_km AS realtime_total_mileage_km,
|
||
l.total_mileage_event_time AS realtime_total_mileage_event_time,
|
||
m.daily_mileage_km,
|
||
m.latest_total_mileage_km AS daily_latest_total_mileage_km,
|
||
COALESCE(ms.sample_count, 0) AS source_sample_count,
|
||
COALESCE(ms.ok_source_count, 0) AS ok_source_count,
|
||
COALESCE(ms.selectable_source_count, 0) AS selectable_source_count,
|
||
ms.latest_event_time AS latest_stat_event_time,
|
||
COALESCE(ms.quality_statuses, '') AS quality_statuses,
|
||
s.parsed_json AS snapshot_parsed_json
|
||
` + fromSQL + `
|
||
ORDER BY CASE WHEN m.vin IS NULL THEN 0 ELSE 1 END, rt.protocol ASC, rt.vin ASC
|
||
LIMIT ? OFFSET ?`
|
||
args = append([]any{query.Date}, args...)
|
||
args = append(args, query.Limit, query.Offset)
|
||
return sqlText, args
|
||
}
|
||
|
||
func buildMetricDiagnosisCountSQL(query MetricDiagnosisQuery) (string, []any) {
|
||
fromSQL, args := buildMetricDiagnosisFromSQL(query)
|
||
return "SELECT COUNT(*) " + fromSQL, args
|
||
}
|
||
|
||
func buildMetricDiagnosisSummarySQL(query MetricDiagnosisQuery) (string, []any) {
|
||
query.Diagnosis = ""
|
||
fromSQL, fromArgs := buildMetricDiagnosisFromSQL(query)
|
||
sqlText := `SELECT ? AS stat_date,
|
||
rt.protocol,
|
||
COUNT(*) AS active_count,
|
||
SUM(CASE WHEN m.vin IS NOT NULL THEN 1 ELSE 0 END) AS ok_count,
|
||
SUM(CASE WHEN m.vin IS NULL AND COALESCE(ms.sample_count, 0) > 0 THEN 1 ELSE 0 END) AS missing_daily_count,
|
||
SUM(CASE WHEN m.vin IS NULL AND COALESCE(ms.sample_count, 0) = 0
|
||
AND l.total_mileage_km > 0
|
||
AND l.total_mileage_event_time >= ? AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||
THEN 1 ELSE 0 END) AS no_source_sample_count,
|
||
SUM(CASE WHEN m.vin IS NULL AND COALESCE(ms.sample_count, 0) = 0
|
||
AND (l.total_mileage_km IS NULL OR l.total_mileage_km <= 0 OR l.total_mileage_event_time IS NULL OR l.total_mileage_event_time < ? OR l.total_mileage_event_time >= DATE_ADD(?, INTERVAL 1 DAY))
|
||
THEN 1 ELSE 0 END) AS no_total_mileage_count
|
||
` + fromSQL + `
|
||
GROUP BY rt.protocol
|
||
ORDER BY rt.protocol ASC`
|
||
args := []any{query.Date, query.Date, query.Date, query.Date, query.Date}
|
||
args = append(args, fromArgs...)
|
||
return sqlText, args
|
||
}
|
||
|
||
func buildMetricDiagnosisReasonSummarySQL(query MetricDiagnosisQuery) (string, []any) {
|
||
baseQuery := query
|
||
baseQuery.Diagnosis = ""
|
||
baseQuery.Reason = ""
|
||
baseQuery.Severity = ""
|
||
fromSQL, fromArgs := buildMetricDiagnosisFromSQL(baseQuery)
|
||
diagnosisSQL, diagnosisArgs := metricDiagnosisCaseSQL(query.Date)
|
||
reasonSQL, reasonArgs := metricDiagnosisReasonCaseSQL(query.Date)
|
||
severitySQL, severityArgs := metricDiagnosisSeverityCaseSQL(query.Date)
|
||
sqlText := `SELECT stat_date, protocol, diagnosis, reason, COUNT(*) AS count
|
||
FROM (
|
||
SELECT ? AS stat_date,
|
||
rt.protocol,
|
||
` + diagnosisSQL + ` AS diagnosis,
|
||
` + reasonSQL + ` AS reason,
|
||
` + severitySQL + ` AS severity
|
||
` + fromSQL + `
|
||
) diagnostic_rows`
|
||
args := []any{query.Date}
|
||
args = append(args, diagnosisArgs...)
|
||
args = append(args, reasonArgs...)
|
||
args = append(args, severityArgs...)
|
||
args = append(args, fromArgs...)
|
||
var outerWhere []string
|
||
if query.Diagnosis != "" {
|
||
outerWhere = append(outerWhere, "diagnosis = ?")
|
||
args = append(args, query.Diagnosis)
|
||
}
|
||
if query.Reason != "" {
|
||
outerWhere = append(outerWhere, "reason = ?")
|
||
args = append(args, query.Reason)
|
||
}
|
||
if query.Severity != "" {
|
||
outerWhere = append(outerWhere, "severity = ?")
|
||
args = append(args, query.Severity)
|
||
}
|
||
if len(outerWhere) > 0 {
|
||
sqlText += " WHERE " + strings.Join(outerWhere, " AND ")
|
||
}
|
||
sqlText += `
|
||
GROUP BY stat_date, protocol, diagnosis, reason
|
||
ORDER BY protocol ASC,
|
||
CASE diagnosis
|
||
WHEN 'OK' THEN 0
|
||
WHEN 'MISSING_DAILY' THEN 1
|
||
WHEN 'NO_SOURCE_SAMPLE' THEN 2
|
||
WHEN 'NO_TOTAL_MILEAGE' THEN 3
|
||
ELSE 4
|
||
END,
|
||
reason ASC`
|
||
return sqlText, args
|
||
}
|
||
|
||
func buildMetricDiagnosisFieldStatusSummarySQL(query MetricDiagnosisQuery) (string, []any) {
|
||
baseQuery := query
|
||
baseQuery.Diagnosis = ""
|
||
baseQuery.Reason = ""
|
||
baseQuery.Severity = ""
|
||
fromSQL, fromArgs := buildMetricDiagnosisFromSQL(baseQuery)
|
||
diagnosisSQL, diagnosisArgs := metricDiagnosisCaseSQL(query.Date)
|
||
reasonSQL, reasonArgs := metricDiagnosisReasonCaseSQL(query.Date)
|
||
severitySQL, severityArgs := metricDiagnosisSeverityCaseSQL(query.Date)
|
||
fieldStatusSQL, fieldStatusArgs := metricDiagnosisMileageFieldStatusCaseSQL(query.Date)
|
||
sqlText := `SELECT stat_date, protocol, diagnosis, reason, realtime_mileage_field_status, COUNT(*) AS count
|
||
FROM (
|
||
SELECT ? AS stat_date,
|
||
rt.protocol,
|
||
` + diagnosisSQL + ` AS diagnosis,
|
||
` + reasonSQL + ` AS reason,
|
||
` + severitySQL + ` AS severity,
|
||
` + fieldStatusSQL + ` AS realtime_mileage_field_status
|
||
` + fromSQL + `
|
||
) diagnostic_rows`
|
||
args := []any{query.Date}
|
||
args = append(args, diagnosisArgs...)
|
||
args = append(args, reasonArgs...)
|
||
args = append(args, severityArgs...)
|
||
args = append(args, fieldStatusArgs...)
|
||
args = append(args, fromArgs...)
|
||
var outerWhere []string
|
||
if query.Diagnosis != "" {
|
||
outerWhere = append(outerWhere, "diagnosis = ?")
|
||
args = append(args, query.Diagnosis)
|
||
}
|
||
if query.Reason != "" {
|
||
outerWhere = append(outerWhere, "reason = ?")
|
||
args = append(args, query.Reason)
|
||
}
|
||
if query.Severity != "" {
|
||
outerWhere = append(outerWhere, "severity = ?")
|
||
args = append(args, query.Severity)
|
||
}
|
||
if len(outerWhere) > 0 {
|
||
sqlText += " WHERE " + strings.Join(outerWhere, " AND ")
|
||
}
|
||
sqlText += `
|
||
GROUP BY stat_date, protocol, diagnosis, reason, realtime_mileage_field_status
|
||
ORDER BY protocol ASC,
|
||
CASE diagnosis
|
||
WHEN 'OK' THEN 0
|
||
WHEN 'MISSING_DAILY' THEN 1
|
||
WHEN 'NO_SOURCE_SAMPLE' THEN 2
|
||
WHEN 'NO_TOTAL_MILEAGE' THEN 3
|
||
ELSE 4
|
||
END,
|
||
reason ASC,
|
||
count DESC,
|
||
realtime_mileage_field_status ASC`
|
||
return sqlText, args
|
||
}
|
||
|
||
func metricDiagnosisCaseSQL(statDate string) (string, []any) {
|
||
return `CASE
|
||
WHEN m.vin IS NOT NULL THEN 'OK'
|
||
WHEN COALESCE(ms.sample_count, 0) > 0 THEN 'MISSING_DAILY'
|
||
WHEN l.total_mileage_km > 0
|
||
AND l.total_mileage_event_time >= ? AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||
THEN 'NO_SOURCE_SAMPLE'
|
||
ELSE 'NO_TOTAL_MILEAGE'
|
||
END`, []any{statDate, statDate}
|
||
}
|
||
|
||
func metricDiagnosisReasonCaseSQL(statDate string) (string, []any) {
|
||
return `CASE
|
||
WHEN m.vin IS NOT NULL THEN 'daily_metric_exists'
|
||
WHEN COALESCE(ms.sample_count, 0) > 0 AND COALESCE(ms.ok_source_count, 0) = 0 THEN 'source_samples_all_invalid'
|
||
WHEN COALESCE(ms.sample_count, 0) > 0 AND COALESCE(ms.selectable_source_count, 0) = 0 THEN 'source_samples_all_excluded'
|
||
WHEN COALESCE(ms.sample_count, 0) > 0 THEN 'source_samples_exist_but_final_metric_missing'
|
||
WHEN l.total_mileage_km > 0
|
||
AND l.total_mileage_event_time >= ? AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||
THEN 'realtime_location_has_total_mileage_but_no_stat_sample'
|
||
WHEN l.total_mileage_km IS NULL THEN 'realtime_total_mileage_missing'
|
||
WHEN l.total_mileage_km <= 0 THEN 'realtime_total_mileage_non_positive'
|
||
WHEN l.total_mileage_event_time IS NULL THEN 'realtime_total_mileage_time_missing'
|
||
WHEN l.total_mileage_event_time < ? OR l.total_mileage_event_time >= DATE_ADD(?, INTERVAL 1 DAY)
|
||
THEN 'realtime_total_mileage_not_reported_on_stat_date'
|
||
ELSE 'realtime_active_without_total_mileage'
|
||
END`, []any{statDate, statDate, statDate, statDate}
|
||
}
|
||
|
||
func metricDiagnosisSeverityCaseSQL(statDate string) (string, []any) {
|
||
return `CASE
|
||
WHEN m.vin IS NOT NULL THEN 'ok'
|
||
WHEN COALESCE(ms.sample_count, 0) > 0 AND COALESCE(ms.ok_source_count, 0) = 0 THEN 'source_data'
|
||
WHEN COALESCE(ms.sample_count, 0) > 0 AND COALESCE(ms.selectable_source_count, 0) = 0 THEN 'source_data'
|
||
WHEN COALESCE(ms.sample_count, 0) > 0 THEN 'pipeline'
|
||
WHEN l.total_mileage_km > 0
|
||
AND l.total_mileage_event_time >= ? AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||
THEN 'pipeline'
|
||
WHEN l.total_mileage_km IS NULL THEN 'source_data'
|
||
WHEN l.total_mileage_km <= 0 THEN 'source_data'
|
||
WHEN l.total_mileage_event_time IS NULL THEN 'pipeline'
|
||
ELSE 'source_data'
|
||
END`, []any{statDate, statDate}
|
||
}
|
||
|
||
func metricDiagnosisMileageFieldStatusCaseSQL(statDate string) (string, []any) {
|
||
return `CASE
|
||
WHEN m.vin IS NOT NULL THEN 'daily_metric_exists'
|
||
WHEN COALESCE(ms.sample_count, 0) > 0 THEN 'source_sample_exists'
|
||
WHEN l.total_mileage_km > 0
|
||
AND l.total_mileage_event_time >= ? AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||
THEN 'standard_field_not_consumed'
|
||
WHEN l.total_mileage_km <= 0 THEN 'standard_field_non_positive'
|
||
WHEN l.total_mileage_km > 0 AND l.total_mileage_event_time IS NULL THEN 'standard_field_time_missing'
|
||
WHEN l.total_mileage_km > 0
|
||
AND (l.total_mileage_event_time < ? OR l.total_mileage_event_time >= DATE_ADD(?, INTERVAL 1 DAY))
|
||
THEN 'standard_field_stale'
|
||
WHEN s.parsed_json IS NULL OR TRIM(s.parsed_json) = '' OR TRIM(s.parsed_json) = '{}' THEN 'no_realtime_fields'
|
||
WHEN rt.protocol = 'YUTONG_MQTT'
|
||
AND (
|
||
LOWER(s.parsed_json) LIKE '%yutong_mqtt.data.total_mileage%'
|
||
OR LOWER(s.parsed_json) LIKE '%yutong_mqtt.root.data.total_mileage%'
|
||
)
|
||
THEN 'mapped_protocol_field_without_fresh_evidence'
|
||
WHEN LOWER(s.parsed_json) LIKE '%mileage%'
|
||
OR LOWER(s.parsed_json) LIKE '%odometer%'
|
||
OR LOWER(s.parsed_json) LIKE '%.odo%'
|
||
OR LOWER(s.parsed_json) LIKE '%_odo%'
|
||
THEN 'candidate_field_unmapped'
|
||
ELSE 'no_candidate_mileage_field'
|
||
END`, []any{statDate, statDate, statDate, statDate}
|
||
}
|
||
|
||
func buildMetricCountSQL(query MetricQuery) (string, []any) {
|
||
where, args := buildMetricWhere(query)
|
||
sqlText := `SELECT COUNT(*) FROM vehicle_daily_mileage m`
|
||
if len(where) > 0 {
|
||
sqlText += " WHERE " + strings.Join(where, " AND ")
|
||
}
|
||
return sqlText, args
|
||
}
|
||
|
||
func buildMetricSourceCountSQL(query MetricSourceQuery) (string, []any) {
|
||
where, args := buildMetricSourceWhere(query)
|
||
sqlText := `SELECT COUNT(*) FROM vehicle_daily_mileage_source s`
|
||
if len(where) > 0 {
|
||
sqlText += " WHERE " + strings.Join(where, " AND ")
|
||
}
|
||
return sqlText, args
|
||
}
|
||
|
||
func buildMetricSourceQualitySummaryCountSQL(query MetricSourceQuery) (string, []any) {
|
||
where, args := buildMetricSourceWhere(query)
|
||
sqlText := `SELECT COUNT(*) FROM (
|
||
SELECT 1
|
||
FROM vehicle_daily_mileage_source s`
|
||
if len(where) > 0 {
|
||
sqlText += " WHERE " + strings.Join(where, " AND ")
|
||
}
|
||
sqlText += `
|
||
GROUP BY s.stat_date, s.protocol, s.quality_status, s.quality_reason
|
||
) q`
|
||
return sqlText, args
|
||
}
|
||
|
||
func buildMetricSourceSelectionSummaryCountSQL(query MetricSourceQuery) (string, []any) {
|
||
baseSQL, args := buildMetricSourceSelectionSummaryBaseSQL(query)
|
||
sqlText := `SELECT COUNT(*) FROM (
|
||
SELECT 1
|
||
FROM (` + baseSQL + `
|
||
) source_selection
|
||
GROUP BY stat_date, protocol, selection_status, selection_reason
|
||
) q`
|
||
return sqlText, args
|
||
}
|
||
|
||
func buildMetricSourceSelectionSummaryBaseSQL(query MetricSourceQuery) (string, []any) {
|
||
where, args := buildMetricSourceWhere(query)
|
||
sqlText := `SELECT s.stat_date, s.protocol, s.vin, s.is_selected, s.sample_count, s.daily_mileage_km,
|
||
` + metricSourceSelectionStatusSQL() + ` AS selection_status,
|
||
` + metricSourceSelectionReasonSQL() + ` AS selection_reason
|
||
FROM vehicle_daily_mileage_source s
|
||
LEFT JOIN vehicle_data_source ds ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip`
|
||
if len(where) > 0 {
|
||
sqlText += " WHERE " + strings.Join(where, " AND ")
|
||
}
|
||
return sqlText, args
|
||
}
|
||
|
||
func metricSourceKindSQL(sourceAlias string) string {
|
||
alias := strings.TrimSpace(sourceAlias)
|
||
if alias == "" {
|
||
alias = "s"
|
||
}
|
||
return `COALESCE(NULLIF(TRIM(ds.source_kind), ''), CASE WHEN ` + alias + `.source_key LIKE '%` + platformSourceKeyPrefix + `%' THEN 'PLATFORM' WHEN ` + alias + `.source_key LIKE '%` + directSourceKeySuffix + `' THEN 'DIRECT' ELSE 'UNKNOWN' END)`
|
||
}
|
||
|
||
func metricSourceSelectionStatusSQL() string {
|
||
return `CASE
|
||
WHEN s.is_selected = 1 THEN 'selected'
|
||
WHEN UPPER(COALESCE(NULLIF(TRIM(s.quality_status), ''), '')) <> ''
|
||
AND UPPER(COALESCE(NULLIF(TRIM(s.quality_status), ''), '')) <> '` + QualityOK + `' THEN 'excluded'
|
||
WHEN ds.enabled = 0 THEN 'excluded'
|
||
ELSE 'not_selected'
|
||
END`
|
||
}
|
||
|
||
func metricSourceSelectionReasonSQL() string {
|
||
return `CASE
|
||
WHEN s.is_selected = 1 THEN 'selected_current_projection'
|
||
WHEN UPPER(COALESCE(NULLIF(TRIM(s.quality_status), ''), '')) <> ''
|
||
AND UPPER(COALESCE(NULLIF(TRIM(s.quality_status), ''), '')) <> '` + QualityOK + `'
|
||
THEN CONCAT('quality_', LOWER(COALESCE(NULLIF(TRIM(s.quality_reason), ''), s.quality_status)))
|
||
WHEN ds.enabled = 0 THEN 'source_disabled'
|
||
WHEN ds.id IS NULL AND ` + metricSourceKindSQL("s") + ` <> 'DIRECT' THEN 'unmanaged_source_lower_priority'
|
||
WHEN ` + metricSourceKindSQL("s") + ` = 'UNKNOWN' THEN 'unknown_source_kind_lower_priority'
|
||
ELSE 'lower_trust_priority_or_sample_count'
|
||
END`
|
||
}
|
||
|
||
func buildMetricDiagnosisFromSQL(query MetricDiagnosisQuery) (string, []any) {
|
||
realtimeSQL, args := buildActiveRealtimeMetricSQL(query)
|
||
sourceSummarySQL := `LEFT JOIN (
|
||
SELECT s.vin, s.protocol, s.stat_date,
|
||
SUM(s.sample_count) AS sample_count,
|
||
SUM(CASE WHEN s.quality_status = 'OK' THEN 1 ELSE 0 END) AS ok_source_count,
|
||
SUM(CASE
|
||
WHEN s.quality_status = 'OK'
|
||
AND (ds.id IS NULL OR ds.enabled = 1 OR (
|
||
COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'
|
||
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
|
||
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
|
||
))
|
||
THEN 1 ELSE 0
|
||
END) AS selectable_source_count,
|
||
MAX(s.latest_event_time) AS latest_event_time,
|
||
GROUP_CONCAT(DISTINCT s.quality_status ORDER BY s.quality_status SEPARATOR ',') AS quality_statuses
|
||
FROM vehicle_daily_mileage_source s
|
||
LEFT JOIN vehicle_data_source ds ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
|
||
WHERE s.stat_date = ?
|
||
GROUP BY s.vin, s.protocol, s.stat_date
|
||
) ms ON ms.vin = rt.vin AND ms.protocol = rt.protocol`
|
||
args = append(args, query.Date)
|
||
fromSQL := `FROM (` + realtimeSQL + `) rt
|
||
LEFT JOIN vehicle_realtime_snapshot s ON s.protocol = rt.protocol AND s.vin = rt.vin
|
||
LEFT JOIN vehicle_realtime_location l ON l.protocol = rt.protocol AND l.vin = rt.vin
|
||
` + sourceSummarySQL + `
|
||
LEFT JOIN vehicle_daily_mileage m ON m.vin = rt.vin AND m.protocol = rt.protocol AND m.stat_date = ?`
|
||
args = append(args, query.Date)
|
||
where, whereArgs := buildMetricDiagnosisWhere(query)
|
||
if len(where) > 0 {
|
||
fromSQL += "\nWHERE " + strings.Join(where, " AND ")
|
||
args = append(args, whereArgs...)
|
||
}
|
||
return fromSQL, args
|
||
}
|
||
|
||
func buildActiveRealtimeMetricSQL(query MetricDiagnosisQuery) (string, []any) {
|
||
snapshotWhere, snapshotArgs := buildActiveRealtimeMetricWhere(query)
|
||
locationWhere, locationArgs := buildActiveRealtimeMetricWhere(query)
|
||
sqlText := `SELECT protocol, vin FROM vehicle_realtime_snapshot
|
||
WHERE ` + snapshotWhere + `
|
||
UNION
|
||
SELECT protocol, vin FROM vehicle_realtime_location
|
||
WHERE ` + locationWhere
|
||
args := append(snapshotArgs, locationArgs...)
|
||
return sqlText, args
|
||
}
|
||
|
||
func buildActiveRealtimeMetricWhere(query MetricDiagnosisQuery) (string, []any) {
|
||
where := []string{"vin <> ''", realtimeActiveDatePredicate()}
|
||
args := []any{
|
||
query.Date, query.Date,
|
||
query.Date, query.Date,
|
||
}
|
||
if query.Protocol != "" {
|
||
where = append(where, "protocol = ?")
|
||
args = append(args, query.Protocol)
|
||
}
|
||
if query.VIN != "" {
|
||
where = append(where, "vin = ?")
|
||
args = append(args, query.VIN)
|
||
}
|
||
return strings.Join(where, " AND "), args
|
||
}
|
||
|
||
func realtimeActiveDatePredicate() string {
|
||
return `((event_time >= ? AND event_time < DATE_ADD(?, INTERVAL 1 DAY))
|
||
OR (received_at >= ? AND received_at < DATE_ADD(?, INTERVAL 1 DAY)))`
|
||
}
|
||
|
||
func buildMetricWhere(query MetricQuery) ([]string, []any) {
|
||
var where []string
|
||
var args []any
|
||
add := func(clause string, value any) {
|
||
where = append(where, clause)
|
||
args = append(args, value)
|
||
}
|
||
if query.VIN != "" {
|
||
add("m.vin = ?", query.VIN)
|
||
}
|
||
if query.Protocol != "" {
|
||
add("m.protocol = ?", query.Protocol)
|
||
}
|
||
if query.DateFrom != "" {
|
||
add("m.stat_date >= ?", query.DateFrom)
|
||
}
|
||
if query.DateTo != "" {
|
||
add("m.stat_date <= ?", query.DateTo)
|
||
}
|
||
return where, args
|
||
}
|
||
|
||
func buildMetricSourceWhere(query MetricSourceQuery) ([]string, []any) {
|
||
var where []string
|
||
var args []any
|
||
add := func(clause string, value any) {
|
||
where = append(where, clause)
|
||
args = append(args, value)
|
||
}
|
||
if query.VIN != "" {
|
||
add("s.vin = ?", query.VIN)
|
||
}
|
||
if query.Protocol != "" {
|
||
add("s.protocol = ?", query.Protocol)
|
||
}
|
||
if query.DateFrom != "" {
|
||
add("s.stat_date >= ?", query.DateFrom)
|
||
}
|
||
if query.DateTo != "" {
|
||
add("s.stat_date <= ?", query.DateTo)
|
||
}
|
||
if query.SourceIP != "" {
|
||
add("s.source_ip = ?", query.SourceIP)
|
||
}
|
||
if query.QualityStatus != "" {
|
||
add("s.quality_status = ?", query.QualityStatus)
|
||
}
|
||
if query.QualityReason != "" {
|
||
add("s.quality_reason = ?", query.QualityReason)
|
||
}
|
||
if query.Selected != nil {
|
||
if *query.Selected {
|
||
add("s.is_selected = ?", 1)
|
||
} else {
|
||
add("s.is_selected = ?", 0)
|
||
}
|
||
}
|
||
return where, args
|
||
}
|
||
|
||
func buildMetricDiagnosisWhere(query MetricDiagnosisQuery) ([]string, []any) {
|
||
var where []string
|
||
var args []any
|
||
add := func(clause string, value any) {
|
||
where = append(where, clause)
|
||
args = append(args, value)
|
||
}
|
||
if query.VIN != "" {
|
||
add("rt.vin = ?", query.VIN)
|
||
}
|
||
if query.Protocol != "" {
|
||
add("rt.protocol = ?", query.Protocol)
|
||
}
|
||
switch query.Diagnosis {
|
||
case "OK":
|
||
where = append(where, "m.vin IS NOT NULL")
|
||
case "MISSING_DAILY":
|
||
where = append(where, "m.vin IS NULL AND COALESCE(ms.sample_count, 0) > 0")
|
||
case "NO_SOURCE_SAMPLE":
|
||
where = append(where, "m.vin IS NULL AND COALESCE(ms.sample_count, 0) = 0 AND l.total_mileage_km > 0 AND l.total_mileage_event_time >= ? AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)")
|
||
args = append(args, query.Date, query.Date)
|
||
case "NO_TOTAL_MILEAGE":
|
||
where = append(where, "m.vin IS NULL AND COALESCE(ms.sample_count, 0) = 0 AND (l.total_mileage_km IS NULL OR l.total_mileage_km <= 0 OR l.total_mileage_event_time IS NULL OR l.total_mileage_event_time < ? OR l.total_mileage_event_time >= DATE_ADD(?, INTERVAL 1 DAY))")
|
||
args = append(args, query.Date, query.Date)
|
||
}
|
||
if query.Reason != "" {
|
||
reasonSQL, reasonArgs := metricDiagnosisReasonCaseSQL(query.Date)
|
||
where = append(where, "("+reasonSQL+") = ?")
|
||
args = append(args, reasonArgs...)
|
||
args = append(args, query.Reason)
|
||
}
|
||
if query.Severity != "" {
|
||
severitySQL, severityArgs := metricDiagnosisSeverityCaseSQL(query.Date)
|
||
where = append(where, "("+severitySQL+") = ?")
|
||
args = append(args, severityArgs...)
|
||
args = append(args, query.Severity)
|
||
}
|
||
return where, args
|
||
}
|
||
|
||
type MetricHandler struct {
|
||
repository *MetricRepository
|
||
}
|
||
|
||
func NewMetricHandler(repository *MetricRepository) *MetricHandler {
|
||
if repository == nil {
|
||
panic("metric repository must not be nil")
|
||
}
|
||
return &MetricHandler{repository: repository}
|
||
}
|
||
|
||
func (h *MetricHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
writeMetricError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||
return
|
||
}
|
||
switch strings.Trim(r.URL.Path, "/") {
|
||
case "api/stats/daily-metrics":
|
||
h.handleDailyMetrics(w, r)
|
||
case "api/stats/daily-metrics/sources":
|
||
h.handleDailyMetricSources(w, r)
|
||
case "api/stats/daily-metrics/sources/quality":
|
||
h.handleDailyMetricSourceQuality(w, r)
|
||
case "api/stats/daily-metrics/sources/selection":
|
||
h.handleDailyMetricSourceSelection(w, r)
|
||
case "api/stats/daily-metrics/diagnostics":
|
||
h.handleDailyMetricDiagnostics(w, r)
|
||
case "api/stats/daily-metrics/diagnostics/summary":
|
||
h.handleDailyMetricDiagnosisSummary(w, r)
|
||
case "api/stats/daily-metrics/diagnostics/reasons":
|
||
h.handleDailyMetricDiagnosisReasonSummary(w, r)
|
||
case "api/stats/daily-metrics/diagnostics/field-status":
|
||
h.handleDailyMetricDiagnosisFieldStatusSummary(w, r)
|
||
default:
|
||
writeMetricError(w, http.StatusNotFound, "route not found")
|
||
}
|
||
}
|
||
|
||
func (h *MetricHandler) handleDailyMetrics(w http.ResponseWriter, r *http.Request) {
|
||
query, err := parseMetricQuery(r)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
var total int64
|
||
if query.IncludeTotal {
|
||
total, err = h.repository.Count(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
}
|
||
rows, err := h.repository.Query(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if !query.IncludeTotal {
|
||
total = int64(len(rows))
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"items": rows,
|
||
"total": total,
|
||
"limit": query.Limit,
|
||
"offset": query.Offset,
|
||
})
|
||
}
|
||
|
||
func (h *MetricHandler) handleDailyMetricSources(w http.ResponseWriter, r *http.Request) {
|
||
query, err := parseMetricSourceQuery(r)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
var total int64
|
||
if query.IncludeTotal {
|
||
total, err = h.repository.CountSources(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
}
|
||
rows, err := h.repository.QuerySources(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if !query.IncludeTotal {
|
||
total = int64(len(rows))
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"items": rows,
|
||
"total": total,
|
||
"limit": query.Limit,
|
||
"offset": query.Offset,
|
||
})
|
||
}
|
||
|
||
func (h *MetricHandler) handleDailyMetricSourceQuality(w http.ResponseWriter, r *http.Request) {
|
||
query, err := parseMetricSourceQuery(r)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
var total int64
|
||
if query.IncludeTotal {
|
||
total, err = h.repository.CountSourceQualitySummary(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
}
|
||
rows, err := h.repository.QuerySourceQualitySummary(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if !query.IncludeTotal {
|
||
total = int64(len(rows))
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"items": rows,
|
||
"total": total,
|
||
"limit": query.Limit,
|
||
"offset": query.Offset,
|
||
})
|
||
}
|
||
|
||
func (h *MetricHandler) handleDailyMetricSourceSelection(w http.ResponseWriter, r *http.Request) {
|
||
query, err := parseMetricSourceQuery(r)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
var total int64
|
||
if query.IncludeTotal {
|
||
total, err = h.repository.CountSourceSelectionSummary(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
}
|
||
rows, err := h.repository.QuerySourceSelectionSummary(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if !query.IncludeTotal {
|
||
total = int64(len(rows))
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"items": rows,
|
||
"total": total,
|
||
"limit": query.Limit,
|
||
"offset": query.Offset,
|
||
})
|
||
}
|
||
|
||
func (h *MetricHandler) handleDailyMetricDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||
query, err := parseMetricDiagnosisQuery(r)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
var total int64
|
||
if query.IncludeTotal {
|
||
total, err = h.repository.CountDiagnostics(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
}
|
||
rows, err := h.repository.QueryDiagnostics(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if !query.IncludeTotal {
|
||
total = int64(len(rows))
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"items": rows,
|
||
"total": total,
|
||
"limit": query.Limit,
|
||
"offset": query.Offset,
|
||
})
|
||
}
|
||
|
||
func (h *MetricHandler) handleDailyMetricDiagnosisSummary(w http.ResponseWriter, r *http.Request) {
|
||
query, err := parseMetricDiagnosisSummaryQuery(r)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
rows, err := h.repository.QueryDiagnosisSummary(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
var totalActive int64
|
||
var totalActionable int64
|
||
for _, row := range rows {
|
||
totalActive += row.ActiveCount
|
||
totalActionable += row.ActionableIssueCount
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"items": rows,
|
||
"total": len(rows),
|
||
"active_total": totalActive,
|
||
"actionable_issue_total": totalActionable,
|
||
})
|
||
}
|
||
|
||
func (h *MetricHandler) handleDailyMetricDiagnosisReasonSummary(w http.ResponseWriter, r *http.Request) {
|
||
query, err := parseMetricDiagnosisReasonSummaryQuery(r)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
rows, err := h.repository.QueryDiagnosisReasonSummary(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
totalVehicles, totalActionable, pipelineIssues, sourceDataIssues := metricDiagnosisReasonSummaryIssueTotals(rows)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"items": rows,
|
||
"total": len(rows),
|
||
"vehicle_total": totalVehicles,
|
||
"actionable_issue_total": totalActionable,
|
||
"pipeline_issue_total": pipelineIssues,
|
||
"source_data_issue_total": sourceDataIssues,
|
||
})
|
||
}
|
||
|
||
func (h *MetricHandler) handleDailyMetricDiagnosisFieldStatusSummary(w http.ResponseWriter, r *http.Request) {
|
||
query, err := parseMetricDiagnosisFieldStatusSummaryQuery(r)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
rows, err := h.repository.QueryDiagnosisFieldStatusSummary(r.Context(), query)
|
||
if err != nil {
|
||
writeMetricError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
totalVehicles, totalActionable, pipelineIssues, sourceDataIssues := metricDiagnosisFieldStatusSummaryIssueTotals(rows)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"items": rows,
|
||
"total": len(rows),
|
||
"vehicle_total": totalVehicles,
|
||
"actionable_issue_total": totalActionable,
|
||
"pipeline_issue_total": pipelineIssues,
|
||
"source_data_issue_total": sourceDataIssues,
|
||
})
|
||
}
|
||
|
||
func parseMetricQuery(r *http.Request) (MetricQuery, error) {
|
||
values := r.URL.Query()
|
||
limit, err := parseBoundedInt(values.Get("limit"), 50, 1, 1000, "limit")
|
||
if err != nil {
|
||
return MetricQuery{}, err
|
||
}
|
||
offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
|
||
if err != nil {
|
||
return MetricQuery{}, err
|
||
}
|
||
query := MetricQuery{
|
||
VIN: values.Get("vin"),
|
||
Protocol: values.Get("protocol"),
|
||
IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
|
||
DateFrom: values.Get("dateFrom"),
|
||
DateTo: values.Get("dateTo"),
|
||
Limit: limit,
|
||
Offset: offset,
|
||
}
|
||
if !validDate(query.DateFrom) || !validDate(query.DateTo) {
|
||
return MetricQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD")
|
||
}
|
||
return normalizeMetricQuery(query), nil
|
||
}
|
||
|
||
func parseMetricSourceQuery(r *http.Request) (MetricSourceQuery, error) {
|
||
values := r.URL.Query()
|
||
limit, err := parseBoundedInt(values.Get("limit"), 50, 1, 1000, "limit")
|
||
if err != nil {
|
||
return MetricSourceQuery{}, err
|
||
}
|
||
offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
|
||
if err != nil {
|
||
return MetricSourceQuery{}, err
|
||
}
|
||
selected, err := parseOptionalBool(values.Get("selected"))
|
||
if err != nil {
|
||
return MetricSourceQuery{}, errors.New("selected must be true or false")
|
||
}
|
||
query := MetricSourceQuery{
|
||
VIN: values.Get("vin"),
|
||
Protocol: values.Get("protocol"),
|
||
DateFrom: values.Get("dateFrom"),
|
||
DateTo: values.Get("dateTo"),
|
||
SourceIP: values.Get("sourceIP"),
|
||
QualityStatus: values.Get("qualityStatus"),
|
||
QualityReason: values.Get("qualityReason"),
|
||
Selected: selected,
|
||
OrderBy: values.Get("orderBy"),
|
||
IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
|
||
Limit: limit,
|
||
Offset: offset,
|
||
}
|
||
if !validDate(query.DateFrom) || !validDate(query.DateTo) {
|
||
return MetricSourceQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD")
|
||
}
|
||
return normalizeMetricSourceQuery(query), nil
|
||
}
|
||
|
||
func parseMetricDiagnosisQuery(r *http.Request) (MetricDiagnosisQuery, error) {
|
||
values := r.URL.Query()
|
||
limit, err := parseBoundedInt(values.Get("limit"), 50, 1, 1000, "limit")
|
||
if err != nil {
|
||
return MetricDiagnosisQuery{}, err
|
||
}
|
||
offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset")
|
||
if err != nil {
|
||
return MetricDiagnosisQuery{}, err
|
||
}
|
||
query := MetricDiagnosisQuery{
|
||
VIN: values.Get("vin"),
|
||
Protocol: values.Get("protocol"),
|
||
Date: values.Get("date"),
|
||
Diagnosis: values.Get("diagnosis"),
|
||
Reason: values.Get("reason"),
|
||
Severity: values.Get("severity"),
|
||
IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"),
|
||
Limit: limit,
|
||
Offset: offset,
|
||
}
|
||
query = normalizeMetricDiagnosisQuery(query)
|
||
if !validDate(query.Date) {
|
||
return MetricDiagnosisQuery{}, errors.New("date must use YYYY-MM-DD")
|
||
}
|
||
if !validMetricDiagnosis(query.Diagnosis) {
|
||
return MetricDiagnosisQuery{}, errors.New("diagnosis must be OK, MISSING_DAILY, NO_SOURCE_SAMPLE, or NO_TOTAL_MILEAGE")
|
||
}
|
||
if !validMetricDiagnosisReason(query.Reason) {
|
||
return MetricDiagnosisQuery{}, errors.New("reason is not a supported daily mileage diagnosis reason")
|
||
}
|
||
if !validMetricDiagnosisSeverity(query.Severity) {
|
||
return MetricDiagnosisQuery{}, errors.New("severity must be ok, pipeline, or source_data")
|
||
}
|
||
return query, nil
|
||
}
|
||
|
||
func parseMetricDiagnosisSummaryQuery(r *http.Request) (MetricDiagnosisQuery, error) {
|
||
values := r.URL.Query()
|
||
query := normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
|
||
VIN: values.Get("vin"),
|
||
Protocol: values.Get("protocol"),
|
||
Date: values.Get("date"),
|
||
})
|
||
if !validDate(query.Date) {
|
||
return MetricDiagnosisQuery{}, errors.New("date must use YYYY-MM-DD")
|
||
}
|
||
return query, nil
|
||
}
|
||
|
||
func parseMetricDiagnosisReasonSummaryQuery(r *http.Request) (MetricDiagnosisQuery, error) {
|
||
values := r.URL.Query()
|
||
query := normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
|
||
VIN: values.Get("vin"),
|
||
Protocol: values.Get("protocol"),
|
||
Date: values.Get("date"),
|
||
Diagnosis: values.Get("diagnosis"),
|
||
Reason: values.Get("reason"),
|
||
Severity: values.Get("severity"),
|
||
})
|
||
if !validDate(query.Date) {
|
||
return MetricDiagnosisQuery{}, errors.New("date must use YYYY-MM-DD")
|
||
}
|
||
if !validMetricDiagnosis(query.Diagnosis) {
|
||
return MetricDiagnosisQuery{}, errors.New("diagnosis must be OK, MISSING_DAILY, NO_SOURCE_SAMPLE, or NO_TOTAL_MILEAGE")
|
||
}
|
||
if !validMetricDiagnosisReason(query.Reason) {
|
||
return MetricDiagnosisQuery{}, errors.New("reason is not a supported daily mileage diagnosis reason")
|
||
}
|
||
if !validMetricDiagnosisSeverity(query.Severity) {
|
||
return MetricDiagnosisQuery{}, errors.New("severity must be ok, pipeline, or source_data")
|
||
}
|
||
return query, nil
|
||
}
|
||
|
||
func parseMetricDiagnosisFieldStatusSummaryQuery(r *http.Request) (MetricDiagnosisQuery, error) {
|
||
values := r.URL.Query()
|
||
query := normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
|
||
VIN: values.Get("vin"),
|
||
Protocol: values.Get("protocol"),
|
||
Date: values.Get("date"),
|
||
Diagnosis: values.Get("diagnosis"),
|
||
Reason: values.Get("reason"),
|
||
Severity: values.Get("severity"),
|
||
})
|
||
if !validDate(query.Date) {
|
||
return MetricDiagnosisQuery{}, errors.New("date must use YYYY-MM-DD")
|
||
}
|
||
if !validMetricDiagnosis(query.Diagnosis) {
|
||
return MetricDiagnosisQuery{}, errors.New("diagnosis must be OK, MISSING_DAILY, NO_SOURCE_SAMPLE, or NO_TOTAL_MILEAGE")
|
||
}
|
||
if !validMetricDiagnosisReason(query.Reason) {
|
||
return MetricDiagnosisQuery{}, errors.New("reason is not a supported daily mileage diagnosis reason")
|
||
}
|
||
if !validMetricDiagnosisSeverity(query.Severity) {
|
||
return MetricDiagnosisQuery{}, errors.New("severity must be ok, pipeline, or source_data")
|
||
}
|
||
return query, nil
|
||
}
|
||
|
||
func parseBoundedInt(raw string, fallback int, min int, max int, name string) (int, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return fallback, nil
|
||
}
|
||
value, err := strconv.Atoi(raw)
|
||
if err != nil || value < min || value > max {
|
||
return 0, fmt.Errorf("%s must be between %d and %d", name, min, max)
|
||
}
|
||
return value, nil
|
||
}
|
||
|
||
func validDate(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return true
|
||
}
|
||
if len(value) != len("2006-01-02") {
|
||
return false
|
||
}
|
||
for i, r := range value {
|
||
switch i {
|
||
case 4, 7:
|
||
if r != '-' {
|
||
return false
|
||
}
|
||
default:
|
||
if r < '0' || r > '9' {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func validMetricDiagnosis(value string) bool {
|
||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||
case "", "OK", "MISSING_DAILY", "NO_SOURCE_SAMPLE", "NO_TOTAL_MILEAGE":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func validMetricDiagnosisReason(value string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "",
|
||
"daily_metric_exists",
|
||
"source_samples_all_invalid",
|
||
"source_samples_all_excluded",
|
||
"source_samples_exist_but_final_metric_missing",
|
||
"realtime_location_has_total_mileage_but_no_stat_sample",
|
||
"realtime_total_mileage_missing",
|
||
"realtime_total_mileage_non_positive",
|
||
"realtime_total_mileage_time_missing",
|
||
"realtime_total_mileage_not_reported_on_stat_date",
|
||
"realtime_active_without_total_mileage":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func validMetricDiagnosisSeverity(value string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "", metricDiagnosisSeverityOK, metricDiagnosisSeverityPipeline, metricDiagnosisSeveritySourceData:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func classifyMetricDiagnosis(hasDailyMetric bool, sourceSampleCount int64, okSourceCount int64, selectableSourceCount int64, realtimeTotal sql.NullFloat64, totalMileageAt string, statDate string) (string, string) {
|
||
if hasDailyMetric {
|
||
return "OK", "daily_metric_exists"
|
||
}
|
||
if sourceSampleCount > 0 {
|
||
if okSourceCount == 0 {
|
||
return "MISSING_DAILY", "source_samples_all_invalid"
|
||
}
|
||
if selectableSourceCount == 0 {
|
||
return "MISSING_DAILY", "source_samples_all_excluded"
|
||
}
|
||
return "MISSING_DAILY", "source_samples_exist_but_final_metric_missing"
|
||
}
|
||
if hasUsableRealtimeTotalMileage(realtimeTotal, totalMileageAt, statDate) {
|
||
return "NO_SOURCE_SAMPLE", "realtime_location_has_total_mileage_but_no_stat_sample"
|
||
}
|
||
if !realtimeTotal.Valid {
|
||
return "NO_TOTAL_MILEAGE", "realtime_total_mileage_missing"
|
||
}
|
||
if realtimeTotal.Float64 <= 0 {
|
||
return "NO_TOTAL_MILEAGE", "realtime_total_mileage_non_positive"
|
||
}
|
||
if strings.TrimSpace(totalMileageAt) == "" {
|
||
return "NO_TOTAL_MILEAGE", "realtime_total_mileage_time_missing"
|
||
}
|
||
if !strings.HasPrefix(totalMileageAt, statDate) {
|
||
return "NO_TOTAL_MILEAGE", "realtime_total_mileage_not_reported_on_stat_date"
|
||
}
|
||
return "NO_TOTAL_MILEAGE", "realtime_active_without_total_mileage"
|
||
}
|
||
|
||
func hasUsableRealtimeTotalMileage(total sql.NullFloat64, totalMileageAt string, statDate string) bool {
|
||
return total.Valid && total.Float64 > 0 && strings.HasPrefix(totalMileageAt, statDate)
|
||
}
|
||
|
||
func splitMetricStatuses(value string) []string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(value, ",")
|
||
out := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
part = strings.TrimSpace(part)
|
||
if part != "" {
|
||
out = append(out, part)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func writeMetricError(w http.ResponseWriter, status int, message string) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"error": message})
|
||
}
|
||
|
||
type scanDate struct {
|
||
String string
|
||
}
|
||
|
||
func (s *scanDate) Scan(value any) error {
|
||
s.String = formatSQLTime(value, "2006-01-02")
|
||
return nil
|
||
}
|
||
|
||
type scanDateTime struct {
|
||
String string
|
||
}
|
||
|
||
func (s *scanDateTime) Scan(value any) error {
|
||
s.String = formatSQLTime(value, "2006-01-02 15:04:05")
|
||
return nil
|
||
}
|
||
|
||
func formatSQLTime(value any, layout string) string {
|
||
switch typed := value.(type) {
|
||
case nil:
|
||
return ""
|
||
case time.Time:
|
||
return typed.Format(layout)
|
||
case []byte:
|
||
return strings.TrimSpace(string(typed))
|
||
case string:
|
||
return strings.TrimSpace(typed)
|
||
default:
|
||
return strings.TrimSpace(fmt.Sprint(typed))
|
||
}
|
||
}
|