package stats import ( "context" "database/sql" "strings" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) const ( QualityOK = "OK" QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE" QualityInvalidDelta = "INVALID_DELTA" maxSelectedDailyMileageKM = 1000 ) type SourceMileageSample struct { VIN string StatDate string Protocol envelope.Protocol SourceKey string SourceIP string SourceEndpoint string Phone string DeviceID string PlatformName string FirstTotalKM float64 LatestTotalKM float64 DailyKM float64 SampleCount int64 FirstEventTime time.Time LatestEventTime time.Time QualityStatus string QualityReason string } func SourceKey(protocol envelope.Protocol, phone string, deviceID string, sourceIP string) string { identity := strings.TrimSpace(phone) if identity == "" { identity = strings.TrimSpace(deviceID) } if identity == "" { identity = "unknown" } return string(protocol) + ":" + identity + "@" + strings.TrimSpace(sourceIP) } func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity) SourceMileageSample { eventTime := sample.EventTime return SourceMileageSample{ VIN: sample.VIN, StatDate: sample.StatDate, Protocol: sample.Protocol, SourceKey: SourceKey(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP), SourceIP: identity.SourceIP, SourceEndpoint: identity.SourceEndpoint, Phone: sample.Phone, DeviceID: sample.DeviceID, FirstTotalKM: sample.TotalMileageKM, LatestTotalKM: sample.TotalMileageKM, DailyKM: 0, SampleCount: 1, FirstEventTime: eventTime, LatestEventTime: eventTime, QualityStatus: QualityOK, QualityReason: "realtime_sample", } } func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageSample) error { if exec == nil { panic("stats execer must not be nil") } if sample.VIN == "" || sample.SourceKey == "" || strings.TrimSpace(sample.SourceIP) == "" { return nil } if sample.QualityStatus == "" { sample.QualityStatus = QualityOK } _, err := exec.ExecContext(ctx, upsertSourceMileageSQL, sample.VIN, sample.StatDate, string(sample.Protocol), sample.SourceKey, sample.SourceIP, sample.SourceEndpoint, sample.Phone, sample.DeviceID, sample.PlatformName, sample.FirstTotalKM, sample.LatestTotalKM, sample.DailyKM, sample.SampleCount, sample.FirstEventTime, sample.LatestEventTime, sample.QualityStatus, sample.QualityReason, ) return err } func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate string, protocol envelope.Protocol) error { if exec == nil { panic("stats execer must not be nil") } if strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" { return nil } if _, err := exec.ExecContext(ctx, clearSelectedSourceSQL, vin, statDate, string(protocol)); err != nil { return err } if _, err := exec.ExecContext(ctx, projectDailyMileageSQL, vin, statDate, string(protocol), maxSelectedDailyMileageKM, ); err != nil { return err } _, err := exec.ExecContext(ctx, markSelectedSourceSQL, vin, statDate, string(protocol), maxSelectedDailyMileageKM, vin, statDate, string(protocol), ) if err != nil { return err } _, err = exec.ExecContext(ctx, cleanupProjectedDailyMileageSQL, vin, statDate, string(protocol), vin, statDate, string(protocol), ) return err } const upsertSourceMileageSQL = ` INSERT INTO vehicle_daily_mileage_source (vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name, first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, sample_count, first_event_time, latest_event_time, quality_status, quality_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE source_ip = VALUES(source_ip), source_endpoint = VALUES(source_endpoint), phone = VALUES(phone), device_id = VALUES(device_id), platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name), first_total_mileage_km = CASE WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0 THEN VALUES(first_total_mileage_km) ELSE LEAST(first_total_mileage_km, VALUES(first_total_mileage_km)) END, latest_total_mileage_km = CASE WHEN latest_total_mileage_km IS NULL OR latest_total_mileage_km <= 0 THEN VALUES(latest_total_mileage_km) ELSE GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km)) END, daily_mileage_km = GREATEST( CASE WHEN latest_total_mileage_km IS NULL OR latest_total_mileage_km <= 0 THEN VALUES(latest_total_mileage_km) ELSE latest_total_mileage_km END, VALUES(latest_total_mileage_km) ) - CASE WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0 THEN VALUES(first_total_mileage_km) ELSE LEAST(first_total_mileage_km, VALUES(first_total_mileage_km)) END, sample_count = sample_count + VALUES(sample_count), first_event_time = CASE WHEN first_event_time IS NULL OR VALUES(first_event_time) < first_event_time THEN VALUES(first_event_time) ELSE first_event_time END, latest_event_time = CASE WHEN latest_event_time IS NULL OR VALUES(latest_event_time) > latest_event_time THEN VALUES(latest_event_time) ELSE latest_event_time END, quality_status = VALUES(quality_status), quality_reason = VALUES(quality_reason), updated_at = CURRENT_TIMESTAMP ` const clearSelectedSourceSQL = ` UPDATE vehicle_daily_mileage_source SET is_selected = 0 WHERE vin = ? AND stat_date = ? AND protocol = ? ` const projectDailyMileageSQL = ` INSERT INTO vehicle_daily_mileage (vin, stat_date, protocol, source_id, daily_mileage_km, latest_total_mileage_km) SELECT s.vin, s.stat_date, s.protocol, ds.id, s.daily_mileage_km, s.latest_total_mileage_km FROM vehicle_daily_mileage_source s JOIN vehicle_data_source ds ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip WHERE s.vin = ? AND s.stat_date = ? AND s.protocol = ? AND s.quality_status = '` + QualityOK + `' AND s.daily_mileage_km BETWEEN 0 AND ? AND ds.enabled = 1 ORDER BY ds.trust_priority, s.sample_count DESC, s.latest_event_time DESC, s.source_key ASC LIMIT 1 ON DUPLICATE KEY UPDATE source_id = VALUES(source_id), daily_mileage_km = VALUES(daily_mileage_km), latest_total_mileage_km = VALUES(latest_total_mileage_km), updated_at = CURRENT_TIMESTAMP ` const markSelectedSourceSQL = ` UPDATE vehicle_daily_mileage_source s JOIN ( SELECT s2.source_key, s2.vin, s2.stat_date, s2.protocol FROM vehicle_daily_mileage_source s2 JOIN vehicle_data_source ds ON ds.protocol = s2.protocol AND ds.source_ip = s2.source_ip WHERE s2.vin = ? AND s2.stat_date = ? AND s2.protocol = ? AND s2.quality_status = '` + QualityOK + `' AND s2.daily_mileage_km BETWEEN 0 AND ? AND ds.enabled = 1 ORDER BY ds.trust_priority, s2.sample_count DESC, s2.latest_event_time DESC, s2.source_key ASC LIMIT 1 ) selected_source ON selected_source.source_key = s.source_key AND selected_source.vin = s.vin AND selected_source.stat_date = s.stat_date AND selected_source.protocol = s.protocol SET s.is_selected = 1 WHERE s.vin = ? AND s.stat_date = ? AND s.protocol = ? ` const cleanupProjectedDailyMileageSQL = ` DELETE FROM vehicle_daily_mileage WHERE vin = ? AND stat_date = ? AND protocol = ? AND NOT EXISTS ( SELECT 1 FROM vehicle_daily_mileage_source WHERE vin = ? AND stat_date = ? AND protocol = ? AND is_selected = 1 ) ` type sourceBaseline struct { LatestTotalKM float64 LatestEventTime time.Time } func lookupPreviousSourceBaseline(ctx context.Context, query Queryer, vin string, statDate string, protocol envelope.Protocol, sourceKey string) (sourceBaseline, bool, error) { if query == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" || strings.TrimSpace(sourceKey) == "" { return sourceBaseline{}, false, nil } previousDate, ok := previousStatDate(statDate) if !ok { return sourceBaseline{}, false, nil } rows, err := query.QueryContext(ctx, previousSourceBaselineSQL, vin, previousDate, string(protocol), sourceKey) if err != nil { return sourceBaseline{}, false, err } defer rows.Close() if !rows.Next() { if err := rows.Err(); err != nil { return sourceBaseline{}, false, err } return sourceBaseline{}, false, nil } var latestTotal sql.NullFloat64 var latestEvent sql.NullTime if err := rows.Scan(&latestTotal, &latestEvent); err != nil { return sourceBaseline{}, false, err } if err := rows.Err(); err != nil { return sourceBaseline{}, false, err } return sourceBaseline{ LatestTotalKM: latestTotal.Float64, LatestEventTime: latestEvent.Time, }, latestTotal.Valid, nil } func lookupCurrentSourceBaseline(ctx context.Context, query Queryer, vin string, statDate string, protocol envelope.Protocol, sourceKey string) (sourceBaseline, bool, error) { if query == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" || strings.TrimSpace(sourceKey) == "" { return sourceBaseline{}, false, nil } rows, err := query.QueryContext(ctx, currentSourceBaselineSQL, vin, statDate, string(protocol), sourceKey) if err != nil { return sourceBaseline{}, false, err } defer rows.Close() if !rows.Next() { if err := rows.Err(); err != nil { return sourceBaseline{}, false, err } return sourceBaseline{}, false, nil } var firstTotal sql.NullFloat64 var firstEvent sql.NullTime if err := rows.Scan(&firstTotal, &firstEvent); err != nil { return sourceBaseline{}, false, err } if err := rows.Err(); err != nil { return sourceBaseline{}, false, err } return sourceBaseline{ LatestTotalKM: firstTotal.Float64, LatestEventTime: firstEvent.Time, }, firstTotal.Valid, nil } func previousStatDate(statDate string) (string, bool) { day, err := time.Parse("2006-01-02", strings.TrimSpace(statDate)) if err != nil { return "", false } return day.AddDate(0, 0, -1).Format("2006-01-02"), true } const previousSourceBaselineSQL = ` SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source WHERE vin = ? AND stat_date = ? AND protocol = ? AND source_key = ? ORDER BY latest_event_time DESC LIMIT 1 ` const currentSourceBaselineSQL = ` SELECT first_total_mileage_km, first_event_time FROM vehicle_daily_mileage_source WHERE vin = ? AND stat_date = ? AND protocol = ? AND source_key = ? AND quality_status = '` + QualityOK + `' ORDER BY latest_event_time DESC LIMIT 1 `