Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats/query.go

2304 lines
81 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}
}