feat: build vehicle data platform and production pipeline
This commit is contained in:
@@ -10,10 +10,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
QualityOK = "OK"
|
||||
QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE"
|
||||
QualityInvalidDelta = "INVALID_DELTA"
|
||||
maxSelectedDailyMileageKM = 1000
|
||||
QualityOK = "OK"
|
||||
QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE"
|
||||
QualityInvalidDelta = "INVALID_DELTA"
|
||||
QualityReasonHistorical = "historical_source_baseline"
|
||||
QualityReasonCurrentDayFirst = "current_day_first_baseline"
|
||||
maxSelectedDailyMileageKM = 2500
|
||||
maxSelectedDailyMileageKMSQL = "2500"
|
||||
maxNegativeMileageJitterKMSQL = "1"
|
||||
directSourceKeySuffix = "@DIRECT"
|
||||
platformSourceKeyPrefix = "@PLATFORM:"
|
||||
)
|
||||
|
||||
type SourceMileageSample struct {
|
||||
@@ -37,14 +43,37 @@ type SourceMileageSample struct {
|
||||
}
|
||||
|
||||
func SourceKey(protocol envelope.Protocol, phone string, deviceID string, sourceIP string) string {
|
||||
identity, _ := sourceKeyIdentity(phone, deviceID)
|
||||
return string(protocol) + ":" + identity + "@" + strings.TrimSpace(sourceIP)
|
||||
}
|
||||
|
||||
func sourceKeyIdentity(phone string, deviceID string) (string, bool) {
|
||||
identity := strings.TrimSpace(phone)
|
||||
if identity == "" {
|
||||
identity = strings.TrimSpace(deviceID)
|
||||
}
|
||||
if identity == "" {
|
||||
identity = "unknown"
|
||||
return "unknown", false
|
||||
}
|
||||
return string(protocol) + ":" + identity + "@" + strings.TrimSpace(sourceIP)
|
||||
return identity, true
|
||||
}
|
||||
|
||||
func SourceKeyForKind(protocol envelope.Protocol, phone string, deviceID string, sourceIP string, sourceKind string) string {
|
||||
return SourceKeyForSource(protocol, phone, deviceID, sourceIP, sourceKind, "")
|
||||
}
|
||||
|
||||
func SourceKeyForSource(protocol envelope.Protocol, phone string, deviceID string, sourceIP string, sourceKind string, sourceCode string) string {
|
||||
identity, hasIdentity := sourceKeyIdentity(phone, deviceID)
|
||||
if strings.EqualFold(strings.TrimSpace(sourceKind), "DIRECT") && hasIdentity {
|
||||
return string(protocol) + ":" + identity + directSourceKeySuffix
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(sourceKind), "PLATFORM") && hasIdentity {
|
||||
sourceCode = strings.TrimSpace(sourceCode)
|
||||
if sourceCode != "" {
|
||||
return string(protocol) + ":" + identity + platformSourceKeyPrefix + sourceCode
|
||||
}
|
||||
}
|
||||
return SourceKey(protocol, phone, deviceID, sourceIP)
|
||||
}
|
||||
|
||||
func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity) SourceMileageSample {
|
||||
@@ -53,11 +82,12 @@ func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity)
|
||||
VIN: sample.VIN,
|
||||
StatDate: sample.StatDate,
|
||||
Protocol: sample.Protocol,
|
||||
SourceKey: SourceKey(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP),
|
||||
SourceKey: SourceKeyForSource(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode),
|
||||
SourceIP: identity.SourceIP,
|
||||
SourceEndpoint: identity.SourceEndpoint,
|
||||
Phone: sample.Phone,
|
||||
DeviceID: sample.DeviceID,
|
||||
PlatformName: firstNonEmpty(sample.PlatformName, identity.PlatformName),
|
||||
FirstTotalKM: sample.TotalMileageKM,
|
||||
LatestTotalKM: sample.TotalMileageKM,
|
||||
DailyKM: 0,
|
||||
@@ -69,6 +99,81 @@ func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func NormalizeDailyMileageDelta(deltaKM float64) (float64, bool, string) {
|
||||
return NormalizeDailyMileageDeltaForWindow(deltaKM, time.Time{}, time.Time{})
|
||||
}
|
||||
|
||||
// DailyMileageFromDayBoundary keeps the business formula explicit: the
|
||||
// current day's latest cumulative odometer minus the nearest earlier
|
||||
// cumulative odometer from the same source.
|
||||
func DailyMileageFromDayBoundary(previousBaselineKM float64, currentDayLatestKM float64) float64 {
|
||||
return currentDayLatestKM - previousBaselineKM
|
||||
}
|
||||
|
||||
func NormalizeDailyMileageDeltaForWindow(deltaKM float64, firstEventTime time.Time, latestEventTime time.Time) (float64, bool, string) {
|
||||
if deltaKM < 0 && deltaKM >= -maxNegativeMileageJitterKM {
|
||||
return 0, true, "negative_jitter_clamped"
|
||||
}
|
||||
maxMileage := float64(MileageQualityLimitKM(firstEventTime, latestEventTime))
|
||||
if deltaKM < 0 || deltaKM > maxMileage {
|
||||
return deltaKM, false, "outside_daily_range"
|
||||
}
|
||||
return deltaKM, true, ""
|
||||
}
|
||||
|
||||
func MileageQualityWindowDays(firstEventTime time.Time, latestEventTime time.Time) int {
|
||||
if firstEventTime.IsZero() || latestEventTime.IsZero() || !latestEventTime.After(firstEventTime) {
|
||||
return 1
|
||||
}
|
||||
location := latestEventTime.Location()
|
||||
firstYear, firstMonth, firstDay := firstEventTime.In(location).Date()
|
||||
latestYear, latestMonth, latestDay := latestEventTime.Date()
|
||||
firstDate := time.Date(firstYear, firstMonth, firstDay, 0, 0, 0, 0, time.UTC)
|
||||
latestDate := time.Date(latestYear, latestMonth, latestDay, 0, 0, 0, 0, time.UTC)
|
||||
days := int(latestDate.Sub(firstDate) / (24 * time.Hour))
|
||||
if days < 1 {
|
||||
return 1
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func MileageQualityLimitKM(firstEventTime time.Time, latestEventTime time.Time) int {
|
||||
// When the previous natural day is missing, the business baseline walks
|
||||
// farther back. Scale the plausibility guard by that calendar-day gap while
|
||||
// preserving the exact cumulative-odometer difference in the current row.
|
||||
return maxSelectedDailyMileageKM * MileageQualityWindowDays(firstEventTime, latestEventTime)
|
||||
}
|
||||
|
||||
func ApplyMileageQualityRules(sample *SourceMileageSample) {
|
||||
if sample == nil {
|
||||
return
|
||||
}
|
||||
if sample.QualityStatus == "" {
|
||||
sample.QualityStatus = QualityOK
|
||||
}
|
||||
if sample.QualityStatus != QualityOK {
|
||||
return
|
||||
}
|
||||
normalized, ok, reason := NormalizeDailyMileageDeltaForWindow(sample.DailyKM, sample.FirstEventTime, sample.LatestEventTime)
|
||||
sample.DailyKM = normalized
|
||||
if reason != "" {
|
||||
sample.QualityReason = reason
|
||||
}
|
||||
if !ok {
|
||||
sample.QualityStatus = QualityInvalidDelta
|
||||
}
|
||||
}
|
||||
|
||||
func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageSample) error {
|
||||
if exec == nil {
|
||||
panic("stats execer must not be nil")
|
||||
@@ -79,6 +184,11 @@ func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageS
|
||||
if sample.QualityStatus == "" {
|
||||
sample.QualityStatus = QualityOK
|
||||
}
|
||||
// MySQL DATETIME(0) may round 23:59:59.xxx into the next natural day.
|
||||
// Truncate protocol event timestamps before persistence so day boundaries
|
||||
// remain stable and match the TDengine event_time predicate.
|
||||
sample.FirstEventTime = truncateMileageEventTime(sample.FirstEventTime)
|
||||
sample.LatestEventTime = truncateMileageEventTime(sample.LatestEventTime)
|
||||
_, err := exec.ExecContext(ctx, upsertSourceMileageSQL,
|
||||
sample.VIN,
|
||||
sample.StatDate,
|
||||
@@ -101,6 +211,75 @@ func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageS
|
||||
return err
|
||||
}
|
||||
|
||||
func truncateMileageEventTime(value time.Time) time.Time {
|
||||
if value.IsZero() {
|
||||
return value
|
||||
}
|
||||
return value.Truncate(time.Second)
|
||||
}
|
||||
|
||||
func NormalizePlatformSourceMileage(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
|
||||
}
|
||||
_, err := exec.ExecContext(ctx, normalizePlatformSourceMileageInsertSQL, vin, statDate, string(protocol))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = exec.ExecContext(ctx, normalizePlatformSourceMileageDeleteSQL, vin, statDate, string(protocol))
|
||||
return err
|
||||
}
|
||||
|
||||
func NormalizePlatformSourceMileageForDate(ctx context.Context, db interface {
|
||||
Execer
|
||||
Queryer
|
||||
}, statDate string, protocol envelope.Protocol) (int, error) {
|
||||
if db == nil {
|
||||
panic("stats db must not be nil")
|
||||
}
|
||||
if strings.TrimSpace(statDate) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, selectPlatformSourceMileageLegacyVINSQL, statDate, string(protocol))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var vins []string
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strings.TrimSpace(vin) != "" {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
normalized := 0
|
||||
for _, vin := range vins {
|
||||
if err := NormalizePlatformSourceMileage(ctx, db, vin, statDate, protocol); err != nil {
|
||||
return normalized, err
|
||||
}
|
||||
if err := ProjectDailyMileage(ctx, db, vin, statDate, protocol); err != nil {
|
||||
return normalized, err
|
||||
}
|
||||
normalized++
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func ShouldNormalizePlatformSourceMileage(sample SourceMileageSample) bool {
|
||||
return strings.Contains(sample.SourceKey, platformSourceKeyPrefix)
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -108,9 +287,25 @@ func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate
|
||||
if strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := exec.ExecContext(ctx, clearSelectedSourceSQL, vin, statDate, string(protocol)); err != nil {
|
||||
return err
|
||||
if beginner, ok := exec.(txBeginner); ok {
|
||||
tx, err := beginner.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := projectDailyMileageWithExec(ctx, tx, vin, statDate, protocol); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
return projectDailyMileageWithExec(ctx, exec, vin, statDate, protocol)
|
||||
}
|
||||
|
||||
type txBeginner interface {
|
||||
BeginTx(context.Context, *sql.TxOptions) (*sql.Tx, error)
|
||||
}
|
||||
|
||||
func projectDailyMileageWithExec(ctx context.Context, exec Execer, vin string, statDate string, protocol envelope.Protocol) error {
|
||||
if _, err := exec.ExecContext(ctx, projectDailyMileageSQL,
|
||||
vin,
|
||||
statDate,
|
||||
@@ -142,6 +337,70 @@ func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertSourceMergedFirstTotalSQL = `CASE
|
||||
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
|
||||
THEN VALUES(first_total_mileage_km)
|
||||
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time
|
||||
THEN VALUES(first_total_mileage_km)
|
||||
ELSE first_total_mileage_km
|
||||
END`
|
||||
|
||||
const upsertSourceMergedLatestTotalSQL = `CASE
|
||||
WHEN latest_total_mileage_km IS NULL OR latest_total_mileage_km <= 0
|
||||
THEN VALUES(latest_total_mileage_km)
|
||||
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time
|
||||
THEN VALUES(latest_total_mileage_km)
|
||||
ELSE latest_total_mileage_km
|
||||
END`
|
||||
|
||||
const upsertSourceMergedFirstEventSQL = `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`
|
||||
|
||||
const upsertSourceMergedLatestEventSQL = `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`
|
||||
|
||||
const upsertSourceMergedDeltaSQL = `(` + upsertSourceMergedLatestTotalSQL + ` - ` + upsertSourceMergedFirstTotalSQL + `)`
|
||||
|
||||
const upsertSourceMergedDailySQL = `CASE
|
||||
WHEN ` + upsertSourceMergedDeltaSQL + ` < 0
|
||||
AND ` + upsertSourceMergedDeltaSQL + ` >= -` + maxNegativeMileageJitterKMSQL + `
|
||||
THEN 0
|
||||
ELSE ` + upsertSourceMergedDeltaSQL + `
|
||||
END`
|
||||
|
||||
const upsertSourceMergedOutsideDailyRangeSQL = `(` + upsertSourceMergedDailySQL + ` < 0 OR ` + upsertSourceMergedDailySQL + ` > ` + upsertSourceMergedMaxMileageSQL + `)`
|
||||
|
||||
const upsertSourceMergedMissingPreviousBaselineSQL = `(` + upsertSourceMergedFirstEventSQL + ` >= CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME))`
|
||||
|
||||
const upsertSourceMergedQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(` + upsertSourceMergedLatestEventSQL + `), DATE(` + upsertSourceMergedFirstEventSQL + `)))`
|
||||
|
||||
const upsertSourceMergedMaxMileageSQL = `(` + upsertSourceMergedQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)`
|
||||
|
||||
const normalizePlatformFirstTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_total_mileage_km ORDER BY s.first_event_time ASC, s.updated_at ASC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
|
||||
|
||||
const normalizePlatformLatestTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_total_mileage_km ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
|
||||
|
||||
const normalizePlatformDeltaSQL = `(` + normalizePlatformLatestTotalSQL + ` - ` + normalizePlatformFirstTotalSQL + `)`
|
||||
|
||||
const normalizePlatformDailySQL = `CASE
|
||||
WHEN ` + normalizePlatformDeltaSQL + ` < 0
|
||||
AND ` + normalizePlatformDeltaSQL + ` >= -` + maxNegativeMileageJitterKMSQL + `
|
||||
THEN 0
|
||||
ELSE ` + normalizePlatformDeltaSQL + `
|
||||
END`
|
||||
|
||||
const normalizePlatformQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(MAX(s.latest_event_time)), DATE(MIN(s.first_event_time))))`
|
||||
|
||||
const normalizePlatformMaxMileageSQL = `(` + normalizePlatformQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)`
|
||||
|
||||
const normalizePlatformOutsideDailyRangeSQL = `(` + normalizePlatformDailySQL + ` < 0 OR ` + normalizePlatformDailySQL + ` > ` + normalizePlatformMaxMileageSQL + `)`
|
||||
|
||||
const upsertSourceMileageSQL = `
|
||||
INSERT INTO vehicle_daily_mileage_source
|
||||
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name,
|
||||
@@ -154,48 +413,186 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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,
|
||||
first_total_mileage_km = ` + upsertSourceMergedFirstTotalSQL + `,
|
||||
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
|
||||
daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
|
||||
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
|
||||
first_event_time = ` + upsertSourceMergedFirstEventSQL + `,
|
||||
latest_event_time = ` + upsertSourceMergedLatestEventSQL + `,
|
||||
quality_status = CASE
|
||||
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `'
|
||||
ELSE '` + QualityOK + `'
|
||||
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
|
||||
quality_reason = CASE
|
||||
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN 'outside_daily_range'
|
||||
WHEN ` + upsertSourceMergedDailySQL + ` = 0 AND ` + upsertSourceMergedDeltaSQL + ` < 0 THEN 'negative_jitter_clamped'
|
||||
WHEN ` + upsertSourceMergedMissingPreviousBaselineSQL + ` THEN '` + QualityReasonCurrentDayFirst + `'
|
||||
ELSE '` + QualityReasonHistorical + `'
|
||||
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 normalizePlatformSourceMileageInsertSQL = `
|
||||
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)
|
||||
SELECT
|
||||
s.vin,
|
||||
s.stat_date,
|
||||
s.protocol,
|
||||
CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code)) AS stable_source_key,
|
||||
SUBSTRING_INDEX(GROUP_CONCAT(s.source_ip ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS source_ip,
|
||||
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.source_endpoint, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS source_endpoint,
|
||||
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.phone, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS phone,
|
||||
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.device_id, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS device_id,
|
||||
COALESCE(NULLIF(TRIM(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(TRIM(s.platform_name), ''), ds.platform_name, vi.platform_name, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1)), ''), MAX(TRIM(COALESCE(ds.platform_name, vi.platform_name)))) AS platform_name,
|
||||
` + normalizePlatformFirstTotalSQL + ` AS first_total_mileage_km,
|
||||
` + normalizePlatformLatestTotalSQL + ` AS latest_total_mileage_km,
|
||||
` + normalizePlatformDailySQL + ` AS daily_mileage_km,
|
||||
SUM(s.sample_count) AS sample_count,
|
||||
MIN(s.first_event_time) AS first_event_time,
|
||||
MAX(s.latest_event_time) AS latest_event_time,
|
||||
CASE
|
||||
WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `'
|
||||
ELSE '` + QualityOK + `'
|
||||
END AS quality_status,
|
||||
CASE
|
||||
WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN 'outside_daily_range'
|
||||
WHEN ` + normalizePlatformDailySQL + ` = 0 AND ` + normalizePlatformDeltaSQL + ` < 0 THEN 'negative_jitter_clamped'
|
||||
WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)
|
||||
THEN '` + QualityReasonHistorical + `'
|
||||
ELSE '` + QualityReasonCurrentDayFirst + `'
|
||||
END AS quality_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
|
||||
AND ds.enabled = 1
|
||||
AND ds.source_kind = 'PLATFORM'
|
||||
AND ds.source_code IS NOT NULL
|
||||
AND TRIM(ds.source_code) <> ''
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
identifier_value AS phone,
|
||||
MIN(TRIM(source_code)) AS source_code,
|
||||
MIN(COALESCE(NULLIF(TRIM(oem), ''), NULLIF(TRIM(source_code), ''))) AS platform_name
|
||||
FROM vehicle_identifier
|
||||
WHERE protocol = 'JT808'
|
||||
AND identifier_type = 'JT808_PHONE'
|
||||
AND enabled = 1
|
||||
AND source_code IS NOT NULL
|
||||
AND TRIM(source_code) <> ''
|
||||
GROUP BY identifier_value
|
||||
HAVING COUNT(DISTINCT TRIM(source_code)) = 1
|
||||
) vi
|
||||
ON s.protocol = 'JT808' AND vi.phone = TRIM(s.phone)
|
||||
WHERE s.vin = ?
|
||||
AND s.stat_date = ?
|
||||
AND s.protocol = ?
|
||||
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
|
||||
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
|
||||
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
|
||||
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
|
||||
GROUP BY s.vin, s.stat_date, s.protocol, stable_source_key
|
||||
ON DUPLICATE KEY UPDATE
|
||||
source_ip = CASE
|
||||
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(source_ip)
|
||||
ELSE source_ip
|
||||
END,
|
||||
source_endpoint = CASE
|
||||
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(source_endpoint)
|
||||
ELSE source_endpoint
|
||||
END,
|
||||
phone = COALESCE(NULLIF(TRIM(VALUES(phone)), ''), phone),
|
||||
device_id = COALESCE(NULLIF(TRIM(VALUES(device_id)), ''), device_id),
|
||||
platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name),
|
||||
first_total_mileage_km = ` + upsertSourceMergedFirstTotalSQL + `,
|
||||
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
|
||||
daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
|
||||
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 = CASE
|
||||
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `'
|
||||
ELSE '` + QualityOK + `'
|
||||
END,
|
||||
quality_reason = CASE
|
||||
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN 'outside_daily_range'
|
||||
WHEN ` + upsertSourceMergedDailySQL + ` = 0 AND ` + upsertSourceMergedDeltaSQL + ` < 0 THEN 'negative_jitter_clamped'
|
||||
WHEN ` + upsertSourceMergedMissingPreviousBaselineSQL + ` THEN '` + QualityReasonCurrentDayFirst + `'
|
||||
ELSE '` + QualityReasonHistorical + `'
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`
|
||||
|
||||
const normalizePlatformSourceMileageDeleteSQL = `
|
||||
DELETE s
|
||||
FROM vehicle_daily_mileage_source s
|
||||
LEFT JOIN vehicle_data_source ds
|
||||
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
|
||||
AND ds.enabled = 1
|
||||
AND ds.source_kind = 'PLATFORM'
|
||||
AND ds.source_code IS NOT NULL
|
||||
AND TRIM(ds.source_code) <> ''
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
identifier_value AS phone,
|
||||
MIN(TRIM(source_code)) AS source_code
|
||||
FROM vehicle_identifier
|
||||
WHERE protocol = 'JT808'
|
||||
AND identifier_type = 'JT808_PHONE'
|
||||
AND enabled = 1
|
||||
AND source_code IS NOT NULL
|
||||
AND TRIM(source_code) <> ''
|
||||
GROUP BY identifier_value
|
||||
HAVING COUNT(DISTINCT TRIM(source_code)) = 1
|
||||
) vi
|
||||
ON s.protocol = 'JT808' AND vi.phone = TRIM(s.phone)
|
||||
WHERE s.vin = ?
|
||||
AND s.stat_date = ?
|
||||
AND s.protocol = ?
|
||||
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
|
||||
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
|
||||
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
|
||||
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
|
||||
`
|
||||
|
||||
const selectPlatformSourceMileageLegacyVINSQL = `
|
||||
SELECT DISTINCT s.vin
|
||||
FROM vehicle_daily_mileage_source s
|
||||
LEFT JOIN vehicle_data_source ds
|
||||
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
|
||||
AND ds.enabled = 1
|
||||
AND ds.source_kind = 'PLATFORM'
|
||||
AND ds.source_code IS NOT NULL
|
||||
AND TRIM(ds.source_code) <> ''
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
identifier_value AS phone,
|
||||
MIN(TRIM(source_code)) AS source_code
|
||||
FROM vehicle_identifier
|
||||
WHERE protocol = 'JT808'
|
||||
AND identifier_type = 'JT808_PHONE'
|
||||
AND enabled = 1
|
||||
AND source_code IS NOT NULL
|
||||
AND TRIM(source_code) <> ''
|
||||
GROUP BY identifier_value
|
||||
HAVING COUNT(DISTINCT TRIM(source_code)) = 1
|
||||
) vi
|
||||
ON s.protocol = 'JT808' AND vi.phone = TRIM(s.phone)
|
||||
WHERE s.stat_date = ?
|
||||
AND s.protocol = ?
|
||||
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
|
||||
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
|
||||
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
|
||||
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
|
||||
ORDER BY s.vin
|
||||
`
|
||||
|
||||
const projectDailyMileageSQL = `
|
||||
@@ -209,15 +606,25 @@ SELECT
|
||||
s.daily_mileage_km,
|
||||
s.latest_total_mileage_km
|
||||
FROM vehicle_daily_mileage_source s
|
||||
JOIN vehicle_data_source ds
|
||||
LEFT 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,
|
||||
AND s.daily_mileage_km BETWEEN 0 AND (? * GREATEST(1, DATEDIFF(DATE(s.latest_event_time), DATE(s.first_event_time))))
|
||||
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) = '')
|
||||
))
|
||||
ORDER BY CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s.source_key LIKE '%` + platformSourceKeyPrefix + `%' THEN 'PLATFORM' WHEN s.source_key LIKE '%` + directSourceKeySuffix + `' THEN 'DIRECT' ELSE 'UNKNOWN' END)
|
||||
WHEN 'PLATFORM' THEN 0
|
||||
WHEN 'DIRECT' THEN 1
|
||||
WHEN 'UNKNOWN' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
COALESCE(ds.trust_priority, 100000),
|
||||
s.sample_count DESC,
|
||||
s.latest_event_time DESC,
|
||||
s.source_key ASC
|
||||
@@ -231,22 +638,32 @@ ON DUPLICATE KEY UPDATE
|
||||
|
||||
const markSelectedSourceSQL = `
|
||||
UPDATE vehicle_daily_mileage_source s
|
||||
JOIN (
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
s2.source_key,
|
||||
s2.vin,
|
||||
s2.stat_date,
|
||||
s2.protocol
|
||||
FROM vehicle_daily_mileage_source s2
|
||||
JOIN vehicle_data_source ds
|
||||
LEFT 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,
|
||||
AND s2.daily_mileage_km BETWEEN 0 AND (? * GREATEST(1, DATEDIFF(DATE(s2.latest_event_time), DATE(s2.first_event_time))))
|
||||
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) = '')
|
||||
))
|
||||
ORDER BY CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s2.source_key LIKE '%` + platformSourceKeyPrefix + `%' THEN 'PLATFORM' WHEN s2.source_key LIKE '%` + directSourceKeySuffix + `' THEN 'DIRECT' ELSE 'UNKNOWN' END)
|
||||
WHEN 'PLATFORM' THEN 0
|
||||
WHEN 'DIRECT' THEN 1
|
||||
WHEN 'UNKNOWN' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
COALESCE(ds.trust_priority, 100000),
|
||||
s2.sample_count DESC,
|
||||
s2.latest_event_time DESC,
|
||||
s2.source_key ASC
|
||||
@@ -256,7 +673,7 @@ JOIN (
|
||||
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
|
||||
SET s.is_selected = CASE WHEN selected_source.source_key IS NULL THEN 0 ELSE 1 END
|
||||
WHERE s.vin = ? AND s.stat_date = ? AND s.protocol = ?
|
||||
`
|
||||
|
||||
@@ -302,60 +719,30 @@ func lookupPreviousSourceBaseline(ctx context.Context, query Queryer, vin string
|
||||
return sourceBaseline{
|
||||
LatestTotalKM: latestTotal.Float64,
|
||||
LatestEventTime: latestEvent.Time,
|
||||
QualityReason: "historical_source_baseline",
|
||||
QualityReason: QualityReasonHistorical,
|
||||
}, 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,
|
||||
QualityReason: "current_day_first_sample",
|
||||
}, firstTotal.Valid, nil
|
||||
// LookupLatestSourceBaselineBefore returns the nearest durable odometer for the
|
||||
// same VIN, protocol and source before statDate. Missing calendar days are
|
||||
// skipped automatically.
|
||||
func LookupLatestSourceBaselineBefore(ctx context.Context, query Queryer, vin string, statDate string, protocol envelope.Protocol, sourceKey string) (float64, time.Time, bool, error) {
|
||||
baseline, found, err := lookupPreviousSourceBaseline(ctx, query, vin, statDate, protocol, sourceKey)
|
||||
return baseline.LatestTotalKM, baseline.LatestEventTime, found, err
|
||||
}
|
||||
|
||||
const previousSourceBaselineSQL = `
|
||||
SELECT latest_total_mileage_km, latest_event_time
|
||||
FROM vehicle_daily_mileage_source
|
||||
WHERE vin = ?
|
||||
AND stat_date < ?
|
||||
AND stat_date < ?
|
||||
AND protocol = ?
|
||||
AND source_key = ?
|
||||
AND quality_status = '` + QualityOK + `'
|
||||
AND latest_total_mileage_km IS NOT NULL
|
||||
AND latest_total_mileage_km > 0
|
||||
AND latest_event_time IS NOT NULL
|
||||
AND latest_event_time >= CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME)
|
||||
AND latest_event_time < DATE_ADD(CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME), INTERVAL 1 DAY)
|
||||
ORDER BY stat_date DESC, 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
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user