362 lines
10 KiB
Go
362 lines
10 KiB
Go
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
|
|
QualityReason string
|
|
}
|
|
|
|
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
|
|
}
|
|
rows, err := query.QueryContext(ctx, previousSourceBaselineSQL, 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 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,
|
|
QualityReason: "historical_source_baseline",
|
|
}, 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
|
|
}
|
|
|
|
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 = ?
|
|
AND quality_status = '` + QualityOK + `'
|
|
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
|
|
`
|