package stats import ( "context" "database/sql" "errors" "math" "strings" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry" ) const ( QualityReasonGPSCoordinate = "gps_coordinate_accumulation" gpsCoordinateSourceSuffix = "#GPS_COORDINATE" gpsMaxSegmentGap = 10 * time.Minute gpsMaxImpliedSpeedKMH = 220.0 gpsMinimumDailyDistanceKM = 0.1 ) const GPSMileageStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_gps_mileage_state ( vin VARCHAR(32) NOT NULL, stat_date DATE NOT NULL, protocol VARCHAR(32) NOT NULL, source_key VARCHAR(256) NOT NULL, source_ip VARCHAR(64) NOT NULL, source_endpoint VARCHAR(128) NULL, phone VARCHAR(32) NULL, device_id VARCHAR(64) NULL, first_event_time DATETIME(3) NOT NULL, latest_event_time DATETIME(3) NOT NULL, latest_event_id VARCHAR(128) NULL, latest_longitude DOUBLE NOT NULL, latest_latitude DOUBLE NOT NULL, daily_mileage_km DECIMAL(18,6) NOT NULL DEFAULT 0, point_count BIGINT NOT NULL DEFAULT 1, usable_segment_count BIGINT NOT NULL DEFAULT 0, bad_jump_count BIGINT NOT NULL DEFAULT 0, long_gap_count BIGINT NOT NULL DEFAULT 0, out_of_order_count BIGINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (vin, stat_date, protocol, source_key), KEY idx_gps_mileage_date (stat_date, protocol, vin) )` type GPSMileagePoint struct { VIN string StatDate string Protocol envelope.Protocol SourceKey string SourceIP string SourceEndpoint string Phone string DeviceID string EventID string EventTime time.Time Longitude float64 Latitude float64 } type GPSMileageState struct { FirstEventTime time.Time LatestEventTime time.Time LatestEventID string LatestLongitude float64 LatestLatitude float64 DailyMileageKM float64 PointCount int64 UsableSegmentCount int64 BadJumpCount int64 LongGapCount int64 OutOfOrderCount int64 } type GPSMileageSegment struct { DistanceKM float64 Usable bool LongGap bool BadJump bool OutOfOrder bool } func CalculateGPSMileageSegment(previous, current GPSMileagePoint) GPSMileageSegment { if current.EventTime.IsZero() || previous.EventTime.IsZero() || !current.EventTime.After(previous.EventTime) { return GPSMileageSegment{OutOfOrder: true} } elapsed := current.EventTime.Sub(previous.EventTime) if elapsed > gpsMaxSegmentGap { return GPSMileageSegment{LongGap: true} } distanceKM := haversineDistanceKM(previous.Latitude, previous.Longitude, current.Latitude, current.Longitude) impliedSpeed := distanceKM / elapsed.Hours() if !isFiniteNonNegative(distanceKM) || !isFiniteNonNegative(impliedSpeed) || impliedSpeed > gpsMaxImpliedSpeedKMH { return GPSMileageSegment{BadJump: true} } return GPSMileageSegment{DistanceKM: distanceKM, Usable: true} } func GPSMileagePointFromEnvelope(env envelope.FrameEnvelope, identity SourceIdentity, loc *time.Location) (GPSMileagePoint, bool) { if !supportsGPSMileageFallback(env.Protocol) || strings.TrimSpace(env.VIN) == "" { return GPSMileagePoint{}, false } location, ok := telemetry.LocationProjectionForProtocol(env.Protocol, env.Fields) if !ok || !validGPSCoordinate(location.Longitude, location.Latitude) { return GPSMileagePoint{}, false } // JT808 terminals often omit speed together with odometer data, so keep the // established coordinate-only fallback. GB32960 and Yutong report much more // frequently; require positive device speed to prevent stationary GPS jitter // from becoming mileage and to keep the MySQL state path inexpensive. if env.Protocol != envelope.ProtocolJT808 && (location.SpeedKMH == nil || *location.SpeedKMH <= 0) { return GPSMileagePoint{}, false } eventMS, _, ok := envelope.NormalizedEventTimeMSWithReason(env) if !ok { return GPSMileagePoint{}, false } if loc == nil { loc = time.FixedZone("Asia/Shanghai", 8*3600) } eventTime := time.UnixMilli(eventMS).In(loc) sourceKey := SourceKeyForSource(env.Protocol, env.Phone, env.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode) return GPSMileagePoint{ VIN: strings.TrimSpace(env.VIN), StatDate: eventTime.Format("2006-01-02"), Protocol: env.Protocol, SourceKey: sourceKey, SourceIP: strings.TrimSpace(identity.SourceIP), SourceEndpoint: strings.TrimSpace(env.SourceEndpoint), Phone: strings.TrimSpace(env.Phone), DeviceID: strings.TrimSpace(env.DeviceID), EventID: strings.TrimSpace(env.EventID), EventTime: eventTime, Longitude: location.Longitude, Latitude: location.Latitude, }, true } func supportsGPSMileageFallback(protocol envelope.Protocol) bool { switch protocol { case envelope.ProtocolGB32960, envelope.ProtocolJT808, envelope.ProtocolYutongMQTT: return true default: return false } } func AccumulateGPSMileage(ctx context.Context, exec Execer, point GPSMileagePoint) (GPSMileageState, bool, error) { beginner, ok := exec.(txBeginner) if !ok || strings.TrimSpace(point.VIN) == "" || strings.TrimSpace(point.SourceKey) == "" || point.EventTime.IsZero() { return GPSMileageState{}, false, nil } tx, err := beginner.BeginTx(ctx, nil) if err != nil { return GPSMileageState{}, false, err } state, found, err := selectGPSMileageState(ctx, tx, point) if err != nil { _ = tx.Rollback() return GPSMileageState{}, false, err } if !found { baselineMileage, baselineFirstEvent, baselinePointCount, baselineSegmentCount, err := selectExistingGPSMileageBaseline(ctx, tx, point) if err != nil { _ = tx.Rollback() return GPSMileageState{}, false, err } state = GPSMileageState{ FirstEventTime: point.EventTime, LatestEventTime: point.EventTime, LatestEventID: point.EventID, LatestLongitude: point.Longitude, LatestLatitude: point.Latitude, DailyMileageKM: baselineMileage, PointCount: baselinePointCount + 1, UsableSegmentCount: baselineSegmentCount, } if !baselineFirstEvent.IsZero() && baselineFirstEvent.Before(point.EventTime) { state.FirstEventTime = baselineFirstEvent } if _, err := tx.ExecContext(ctx, insertGPSMileageStateSQL, point.VIN, point.StatDate, string(point.Protocol), point.SourceKey, point.SourceIP, point.SourceEndpoint, point.Phone, point.DeviceID, state.FirstEventTime, point.EventTime, point.EventID, point.Longitude, point.Latitude, state.DailyMileageKM, state.PointCount, state.UsableSegmentCount, ); err != nil { _ = tx.Rollback() return GPSMileageState{}, false, err } if err := tx.Commit(); err != nil { return GPSMileageState{}, false, err } return state, false, nil } segment := CalculateGPSMileageSegment(GPSMileagePoint{ EventTime: state.LatestEventTime, Longitude: state.LatestLongitude, Latitude: state.LatestLatitude, }, point) if segment.OutOfOrder { state.OutOfOrderCount++ if _, err := tx.ExecContext(ctx, incrementGPSMileageOutOfOrderSQL, point.VIN, point.StatDate, string(point.Protocol), point.SourceKey, ); err != nil { _ = tx.Rollback() return GPSMileageState{}, false, err } if err := tx.Commit(); err != nil { return GPSMileageState{}, false, err } return state, false, nil } state.LatestEventTime = point.EventTime state.LatestEventID = point.EventID state.LatestLongitude = point.Longitude state.LatestLatitude = point.Latitude state.PointCount++ if segment.Usable { state.DailyMileageKM += segment.DistanceKM state.UsableSegmentCount++ } if segment.BadJump { state.BadJumpCount++ } if segment.LongGap { state.LongGapCount++ } if _, err := tx.ExecContext(ctx, updateGPSMileageStateSQL, point.SourceIP, point.SourceEndpoint, point.Phone, point.DeviceID, state.LatestEventTime, state.LatestEventID, state.LatestLongitude, state.LatestLatitude, state.DailyMileageKM, state.PointCount, state.UsableSegmentCount, state.BadJumpCount, state.LongGapCount, state.OutOfOrderCount, point.VIN, point.StatDate, string(point.Protocol), point.SourceKey, ); err != nil { _ = tx.Rollback() return GPSMileageState{}, false, err } if !GPSMileageCandidateEligible(state.DailyMileageKM, state.UsableSegmentCount) { if err := tx.Commit(); err != nil { return GPSMileageState{}, false, err } return state, false, nil } if err := upsertGPSMileageCandidate(ctx, tx, point, state); err != nil { _ = tx.Rollback() return GPSMileageState{}, false, err } if err := projectDailyMileageWithExec(ctx, tx, point.VIN, point.StatDate, point.Protocol); err != nil { _ = tx.Rollback() return GPSMileageState{}, false, err } if err := tx.Commit(); err != nil { return GPSMileageState{}, false, err } return state, true, nil } // GPSMileageCandidateEligible keeps sub-100-metre stationary drift and // momentary speed noise in the durable accumulation state without exposing it // as a business daily-mileage candidate. Real movement continues accumulating // and becomes eligible once the evidence crosses the minimum distance. func GPSMileageCandidateEligible(distanceKM float64, usableSegmentCount int64) bool { return usableSegmentCount > 0 && isFiniteNonNegative(distanceKM) && distanceKM >= gpsMinimumDailyDistanceKM } // AccumulateJT808GPSMileage remains as a compatibility wrapper for callers and // operational tooling compiled against the original JT808-only fallback. func AccumulateJT808GPSMileage(ctx context.Context, exec Execer, point GPSMileagePoint) (GPSMileageState, bool, error) { return AccumulateGPSMileage(ctx, exec, point) } func selectExistingGPSMileageBaseline(ctx context.Context, tx *sql.Tx, point GPSMileagePoint) (float64, time.Time, int64, int64, error) { var mileage sql.NullFloat64 var firstEvent sql.NullTime var pointCount sql.NullInt64 err := tx.QueryRowContext(ctx, selectExistingGPSMileageBaselineSQL, point.VIN, point.StatDate, string(point.Protocol), QualityReasonGPSCoordinate, ).Scan(&mileage, &firstEvent, &pointCount) if errors.Is(err, sql.ErrNoRows) { return 0, time.Time{}, 0, 0, nil } if err != nil { return 0, time.Time{}, 0, 0, err } segments := pointCount.Int64 - 1 if segments < 0 { segments = 0 } return mileage.Float64, firstEvent.Time, pointCount.Int64, segments, nil } func selectGPSMileageState(ctx context.Context, tx *sql.Tx, point GPSMileagePoint) (GPSMileageState, bool, error) { var state GPSMileageState err := tx.QueryRowContext(ctx, selectGPSMileageStateSQL, point.VIN, point.StatDate, string(point.Protocol), point.SourceKey, ).Scan( &state.FirstEventTime, &state.LatestEventTime, &state.LatestEventID, &state.LatestLongitude, &state.LatestLatitude, &state.DailyMileageKM, &state.PointCount, &state.UsableSegmentCount, &state.BadJumpCount, &state.LongGapCount, &state.OutOfOrderCount, ) if errors.Is(err, sql.ErrNoRows) { return GPSMileageState{}, false, nil } return state, err == nil, err } func upsertGPSMileageCandidate(ctx context.Context, exec Execer, point GPSMileagePoint, state GPSMileageState) error { sourceIP := strings.TrimSpace(point.SourceIP) if len(sourceIP) > 56 { sourceIP = sourceIP[:56] } sourceIP += "#gps" _, err := exec.ExecContext(ctx, upsertGPSMileageCandidateSQL, point.VIN, point.StatDate, string(point.Protocol), gpsMileageCandidateSourceKey(point), sourceIP, point.SourceEndpoint, point.Phone, point.DeviceID, gpsMileagePlatformName(point.Protocol), 0, state.DailyMileageKM, state.DailyMileageKM, state.PointCount, state.FirstEventTime, state.LatestEventTime, QualityOK, QualityReasonGPSCoordinate, ) return err } func gpsMileagePlatformName(protocol envelope.Protocol) string { switch protocol { case envelope.ProtocolGB32960: return "GB32960 GPS轨迹估算" case envelope.ProtocolYutongMQTT: return "宇通 GPS轨迹估算" default: return "JT808 GPS轨迹估算" } } func gpsMileageCandidateSourceKey(point GPSMileagePoint) string { identity := strings.TrimSpace(point.Phone) if identity == "" { identity = strings.TrimSpace(point.DeviceID) } if identity == "" { identity = strings.TrimSpace(point.VIN) } sourceIP := strings.TrimSpace(point.SourceIP) if sourceIP != "" { return string(point.Protocol) + ":" + identity + gpsCoordinateSourceSuffix + ":" + sourceIP } return string(point.Protocol) + ":" + identity + gpsCoordinateSourceSuffix } func validGPSCoordinate(longitude, latitude float64) bool { return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && (longitude != 0 || latitude != 0) && !math.IsNaN(longitude) && !math.IsNaN(latitude) && !math.IsInf(longitude, 0) && !math.IsInf(latitude, 0) } func isFiniteNonNegative(value float64) bool { return value >= 0 && !math.IsNaN(value) && !math.IsInf(value, 0) } func haversineDistanceKM(lat1, lon1, lat2, lon2 float64) float64 { const earthRadiusKM = 6371.0088 toRadians := math.Pi / 180 lat1Rad := lat1 * toRadians lat2Rad := lat2 * toRadians dLat := (lat2 - lat1) * toRadians dLon := (lon2 - lon1) * toRadians a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dLon/2)*math.Sin(dLon/2) return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) } const selectGPSMileageStateSQL = `SELECT first_event_time, latest_event_time, COALESCE(latest_event_id, ''), latest_longitude, latest_latitude, daily_mileage_km, point_count, usable_segment_count, bad_jump_count, long_gap_count, out_of_order_count FROM vehicle_daily_gps_mileage_state WHERE vin = ? AND stat_date = ? AND protocol = ? AND source_key = ? FOR UPDATE` const insertGPSMileageStateSQL = `INSERT INTO vehicle_daily_gps_mileage_state (vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, first_event_time, latest_event_time, latest_event_id, latest_longitude, latest_latitude, daily_mileage_km, point_count, usable_segment_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` const selectExistingGPSMileageBaselineSQL = `SELECT daily_mileage_km, first_event_time, sample_count FROM vehicle_daily_mileage_source WHERE vin = ? AND stat_date = ? AND protocol = ? AND quality_reason = ? ORDER BY latest_event_time DESC, sample_count DESC LIMIT 1` const incrementGPSMileageOutOfOrderSQL = `UPDATE vehicle_daily_gps_mileage_state SET out_of_order_count = out_of_order_count + 1, updated_at = CURRENT_TIMESTAMP WHERE vin = ? AND stat_date = ? AND protocol = ? AND source_key = ?` const updateGPSMileageStateSQL = `UPDATE vehicle_daily_gps_mileage_state SET source_ip = ?, source_endpoint = ?, phone = ?, device_id = ?, latest_event_time = ?, latest_event_id = ?, latest_longitude = ?, latest_latitude = ?, daily_mileage_km = ?, point_count = ?, usable_segment_count = ?, bad_jump_count = ?, long_gap_count = ?, out_of_order_count = ?, updated_at = CURRENT_TIMESTAMP WHERE vin = ? AND stat_date = ? AND protocol = ? AND source_key = ?` const upsertGPSMileageCandidateSQL = `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 = VALUES(platform_name), first_total_mileage_km = VALUES(first_total_mileage_km), latest_total_mileage_km = VALUES(latest_total_mileage_km), daily_mileage_km = VALUES(daily_mileage_km), sample_count = VALUES(sample_count), first_event_time = VALUES(first_event_time), latest_event_time = VALUES(latest_event_time), quality_status = VALUES(quality_status), quality_reason = VALUES(quality_reason), updated_at = CURRENT_TIMESTAMP`