Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
1009 lines
41 KiB
Go
1009 lines
41 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"
|
|
QualityReasonTotalRollback = "TOTAL_MILEAGE_ROLLBACK"
|
|
QualityReasonHistorical = "historical_source_baseline"
|
|
QualityReasonCurrentDayFirst = "current_day_first_baseline"
|
|
QualityReasonStationaryCarry = "stationary_carry_forward"
|
|
maxSelectedDailyMileageKM = 2500
|
|
maxSelectedDailyMileageKMSQL = "2500"
|
|
maxNegativeMileageJitterKMSQL = "1"
|
|
directSourceKeySuffix = "@DIRECT"
|
|
platformSourceKeyPrefix = "@PLATFORM:"
|
|
)
|
|
|
|
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
|
|
PureHydrogenMileageKM float64
|
|
SampleCount int64
|
|
PureHydrogenSampleCount int64
|
|
FirstEventTime time.Time
|
|
LatestEventTime time.Time
|
|
LatestPureHydrogenActive bool
|
|
LatestPureHydrogenModeKnown bool
|
|
QualityStatus string
|
|
QualityReason string
|
|
}
|
|
|
|
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 == "" {
|
|
return "unknown", false
|
|
}
|
|
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 {
|
|
eventTime := sample.EventTime
|
|
return SourceMileageSample{
|
|
VIN: sample.VIN,
|
|
StatDate: sample.StatDate,
|
|
Protocol: sample.Protocol,
|
|
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,
|
|
PureHydrogenMileageKM: 0,
|
|
SampleCount: 1,
|
|
PureHydrogenSampleCount: boolCount(sample.PureHydrogenActive && sample.PureHydrogenModeKnown),
|
|
FirstEventTime: eventTime,
|
|
LatestEventTime: eventTime,
|
|
LatestPureHydrogenActive: sample.PureHydrogenActive,
|
|
LatestPureHydrogenModeKnown: sample.PureHydrogenModeKnown,
|
|
QualityStatus: QualityOK,
|
|
QualityReason: "realtime_sample",
|
|
}
|
|
}
|
|
|
|
func boolCount(value bool) int64 {
|
|
if value {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
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 reading of the same vehicle odometer or isolated terminal counter.
|
|
func DailyMileageFromDayBoundary(previousBaselineKM float64, currentDayLatestKM float64) float64 {
|
|
return currentDayLatestKM - previousBaselineKM
|
|
}
|
|
|
|
// IsUsableDailyMileageBoundary accepts older same-source odometers.
|
|
// Offline increments belong to the recovery day so cumulative totals reconcile.
|
|
func IsUsableDailyMileageBoundary(statDate string, baselineTime time.Time, loc *time.Location) bool {
|
|
if strings.TrimSpace(statDate) == "" || baselineTime.IsZero() {
|
|
return false
|
|
}
|
|
if loc == nil {
|
|
loc = baselineTime.Location()
|
|
}
|
|
day, err := time.ParseInLocation("2006-01-02", statDate, loc)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
baseline := baselineTime.In(loc)
|
|
baselineDay := time.Date(baseline.Year(), baseline.Month(), baseline.Day(), 0, 0, 0, 0, loc)
|
|
return !baselineDay.After(day)
|
|
}
|
|
|
|
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")
|
|
}
|
|
if sample.VIN == "" || sample.SourceKey == "" || strings.TrimSpace(sample.SourceIP) == "" {
|
|
return nil
|
|
}
|
|
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,
|
|
string(sample.Protocol),
|
|
sample.SourceKey,
|
|
sample.SourceIP,
|
|
sample.SourceEndpoint,
|
|
sample.Phone,
|
|
sample.DeviceID,
|
|
sample.PlatformName,
|
|
sample.FirstTotalKM,
|
|
sample.LatestTotalKM,
|
|
sample.DailyKM,
|
|
sample.PureHydrogenMileageKM,
|
|
sample.SampleCount,
|
|
sample.PureHydrogenSampleCount,
|
|
sample.FirstEventTime,
|
|
sample.LatestEventTime,
|
|
sample.LatestPureHydrogenActive,
|
|
sample.LatestPureHydrogenModeKnown,
|
|
sample.QualityStatus,
|
|
sample.QualityReason,
|
|
)
|
|
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
|
|
}
|
|
vins, err := PlatformSourceMileageLegacyVINs(ctx, db, statDate, protocol)
|
|
if 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
|
|
}
|
|
|
|
// PlatformSourceMileageLegacyVINs lets batch callers apply their own atomic
|
|
// projection policy while sharing the exact registry/terminal alias rules.
|
|
func PlatformSourceMileageLegacyVINs(ctx context.Context, db Queryer, statDate string, protocol envelope.Protocol) ([]string, error) {
|
|
rows, err := db.QueryContext(ctx, selectPlatformSourceMileageLegacyVINSQL, statDate, string(protocol))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var vins []string
|
|
for rows.Next() {
|
|
var vin string
|
|
if err := rows.Scan(&vin); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(vin) != "" {
|
|
vins = append(vins, vin)
|
|
}
|
|
}
|
|
return vins, rows.Err()
|
|
}
|
|
|
|
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")
|
|
}
|
|
if strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" {
|
|
return nil
|
|
}
|
|
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,
|
|
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 upsertSourceStatDateStartSQL = `CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME)`
|
|
|
|
const upsertSourcePreferIncomingFirstSQL = `(
|
|
first_event_time IS NULL
|
|
OR (
|
|
first_event_time < ` + upsertSourceStatDateStartSQL + `
|
|
AND VALUES(first_event_time) < ` + upsertSourceStatDateStartSQL + `
|
|
AND VALUES(first_event_time) >= first_event_time
|
|
)
|
|
OR (
|
|
first_event_time >= ` + upsertSourceStatDateStartSQL + `
|
|
AND VALUES(first_event_time) < ` + upsertSourceStatDateStartSQL + `
|
|
)
|
|
OR (
|
|
first_event_time >= ` + upsertSourceStatDateStartSQL + `
|
|
AND VALUES(first_event_time) >= ` + upsertSourceStatDateStartSQL + `
|
|
AND VALUES(first_event_time) <= first_event_time
|
|
)
|
|
)`
|
|
|
|
const upsertSourceMergedFirstTotalSQL = `CASE
|
|
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
|
|
THEN VALUES(first_total_mileage_km)
|
|
WHEN ` + upsertSourcePreferIncomingFirstSQL + `
|
|
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 ` + upsertSourcePreferIncomingFirstSQL + `
|
|
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 + `)`
|
|
|
|
// Prefer the newest boundary before this natural day. If no historical
|
|
// boundary exists, retain the earliest sample inside the day. Select the
|
|
// odometer and its timestamp from the same row so stale forwarding aliases
|
|
// cannot restore an older baseline after a repair or a cache refresh.
|
|
const normalizePlatformFirstOrderSQL = `CASE
|
|
WHEN s.first_event_time < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME) THEN 0
|
|
WHEN s.first_event_time IS NOT NULL THEN 1
|
|
ELSE 2 END,
|
|
CASE WHEN s.first_event_time < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME) THEN s.first_event_time ELSE NULL END DESC,
|
|
s.first_event_time ASC, s.updated_at DESC`
|
|
|
|
const normalizePlatformFirstTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_total_mileage_km ORDER BY ` + normalizePlatformFirstOrderSQL + ` SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
|
|
|
|
const normalizePlatformFirstEventSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_event_time ORDER BY ` + normalizePlatformFirstOrderSQL + ` SEPARATOR ','), ',', 1) AS DATETIME)`
|
|
|
|
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(` + normalizePlatformFirstEventSQL + `)))`
|
|
|
|
const normalizePlatformMaxMileageSQL = `(` + normalizePlatformQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)`
|
|
|
|
const normalizePlatformOutsideDailyRangeSQL = `(` + normalizePlatformDailySQL + ` < 0 OR ` + normalizePlatformDailySQL + ` > ` + normalizePlatformMaxMileageSQL + `)`
|
|
|
|
const upsertSourcePureHydrogenIncrementSQL = `CASE
|
|
WHEN latest_event_time IS NOT NULL
|
|
AND VALUES(latest_event_time) > latest_event_time
|
|
AND VALUES(latest_pure_hydrogen_mode_known) = 1
|
|
AND VALUES(latest_pure_hydrogen_active) = 1
|
|
AND VALUES(latest_total_mileage_km) >= latest_total_mileage_km
|
|
AND VALUES(latest_total_mileage_km) - latest_total_mileage_km <= ` + maxSelectedDailyMileageKMSQL + `
|
|
THEN VALUES(latest_total_mileage_km) - latest_total_mileage_km
|
|
ELSE 0
|
|
END`
|
|
|
|
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, pure_hydrogen_mileage_km,
|
|
sample_count, pure_hydrogen_sample_count, first_event_time, latest_event_time,
|
|
latest_pure_hydrogen_active, latest_pure_hydrogen_mode_known, 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),
|
|
pure_hydrogen_mileage_km = pure_hydrogen_mileage_km + ` + upsertSourcePureHydrogenIncrementSQL + `,
|
|
pure_hydrogen_sample_count = pure_hydrogen_sample_count + VALUES(pure_hydrogen_sample_count),
|
|
latest_pure_hydrogen_active = CASE
|
|
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time
|
|
THEN VALUES(latest_pure_hydrogen_active)
|
|
ELSE latest_pure_hydrogen_active
|
|
END,
|
|
latest_pure_hydrogen_mode_known = CASE
|
|
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time
|
|
THEN VALUES(latest_pure_hydrogen_mode_known)
|
|
ELSE latest_pure_hydrogen_mode_known
|
|
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 = ` + upsertSourceMergedFirstEventSQL + `,
|
|
latest_event_time = ` + upsertSourceMergedLatestEventSQL + `,
|
|
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 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, pure_hydrogen_mileage_km,
|
|
sample_count, pure_hydrogen_sample_count, first_event_time, latest_event_time,
|
|
latest_pure_hydrogen_active, latest_pure_hydrogen_mode_known, 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.pure_hydrogen_mileage_km) AS pure_hydrogen_mileage_km,
|
|
SUM(s.sample_count) AS sample_count,
|
|
SUM(s.pure_hydrogen_sample_count) AS pure_hydrogen_sample_count,
|
|
` + normalizePlatformFirstEventSQL + ` AS first_event_time,
|
|
MAX(s.latest_event_time) AS latest_event_time,
|
|
CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_pure_hydrogen_active ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS UNSIGNED) AS latest_pure_hydrogen_active,
|
|
CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_pure_hydrogen_mode_known ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS UNSIGNED) AS latest_pure_hydrogen_mode_known,
|
|
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 ` + normalizePlatformFirstEventSQL + ` < 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(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `'
|
|
AND s.first_total_mileage_km IS NOT NULL
|
|
AND s.latest_total_mileage_km IS NOT NULL
|
|
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),
|
|
pure_hydrogen_mileage_km = VALUES(pure_hydrogen_mileage_km),
|
|
pure_hydrogen_sample_count = VALUES(pure_hydrogen_sample_count),
|
|
latest_pure_hydrogen_active = CASE
|
|
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(latest_pure_hydrogen_active)
|
|
ELSE latest_pure_hydrogen_active
|
|
END,
|
|
latest_pure_hydrogen_mode_known = CASE
|
|
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(latest_pure_hydrogen_mode_known)
|
|
ELSE latest_pure_hydrogen_mode_known
|
|
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 = ` + upsertSourceMergedFirstEventSQL + `,
|
|
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(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `'
|
|
AND s.first_total_mileage_km IS NOT NULL
|
|
AND s.latest_total_mileage_km IS NOT NULL
|
|
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(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `'
|
|
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
|
|
`
|
|
|
|
// The same physical terminal can be forwarded by more than one platform.
|
|
// Preserve each forwarding source for audit, but rank the terminal as one
|
|
// evidence group: accumulated samples choose the established terminal and the
|
|
// freshest forwarding source keeps its projected mileage current.
|
|
const projectSourceIdentitySampleCountSQL = `(
|
|
SELECT COALESCE(SUM(identity_source.sample_count), 0)
|
|
FROM vehicle_daily_mileage_source identity_source
|
|
WHERE identity_source.vin = s.vin
|
|
AND identity_source.stat_date = s.stat_date
|
|
AND identity_source.protocol = s.protocol
|
|
AND identity_source.quality_status = '` + QualityOK + `'
|
|
AND COALESCE(NULLIF(TRIM(identity_source.phone), ''), NULLIF(TRIM(identity_source.device_id), ''), identity_source.source_key)
|
|
= COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), ''), s.source_key)
|
|
)`
|
|
|
|
// A fresh cumulative odometer is authoritative. When an upstream odometer is
|
|
// stale at zero but device-speed-backed coordinates prove movement, the
|
|
// coordinate estimate must beat the synthetic zero-day carry-forward. A true
|
|
// positive odometer still always outranks the estimate.
|
|
const projectMileageEvidenceRankSQL = `CASE
|
|
WHEN COALESCE(s.daily_mileage_km, 0) > 0
|
|
AND COALESCE(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `' THEN 0
|
|
WHEN COALESCE(s.daily_mileage_km, 0) > 0
|
|
AND COALESCE(s.quality_reason, '') = '` + QualityReasonGPSCoordinate + `' THEN 1
|
|
WHEN COALESCE(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `' THEN 2
|
|
ELSE 3
|
|
END`
|
|
|
|
const selectedMileageEvidenceRankSQL = `CASE
|
|
WHEN COALESCE(s2.daily_mileage_km, 0) > 0
|
|
AND COALESCE(s2.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `' THEN 0
|
|
WHEN COALESCE(s2.daily_mileage_km, 0) > 0
|
|
AND COALESCE(s2.quality_reason, '') = '` + QualityReasonGPSCoordinate + `' THEN 1
|
|
WHEN COALESCE(s2.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `' THEN 2
|
|
ELSE 3
|
|
END`
|
|
|
|
const selectedSourceIdentitySampleCountSQL = `(
|
|
SELECT COALESCE(SUM(identity_source.sample_count), 0)
|
|
FROM vehicle_daily_mileage_source identity_source
|
|
WHERE identity_source.vin = s2.vin
|
|
AND identity_source.stat_date = s2.stat_date
|
|
AND identity_source.protocol = s2.protocol
|
|
AND identity_source.quality_status = '` + QualityOK + `'
|
|
AND COALESCE(NULLIF(TRIM(identity_source.phone), ''), NULLIF(TRIM(identity_source.device_id), ''), identity_source.source_key)
|
|
= COALESCE(NULLIF(TRIM(s2.phone), ''), NULLIF(TRIM(s2.device_id), ''), s2.source_key)
|
|
)`
|
|
|
|
// projectDayEndTotalMileageSQL intentionally uses an independent source choice
|
|
// from the daily-mileage projection. GPS coordinate accumulation can be the
|
|
// best evidence for distance travelled during the day, but it must never
|
|
// replace a terminal-reported odometer as the day-end cumulative mileage.
|
|
const projectDayEndTotalMileageSQL = `CASE
|
|
WHEN COALESCE(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `' THEN s.latest_total_mileage_km
|
|
ELSE COALESCE((
|
|
SELECT total_candidate.latest_total_mileage_km
|
|
FROM vehicle_daily_mileage_source total_candidate
|
|
WHERE total_candidate.vin = s.vin
|
|
AND total_candidate.stat_date = s.stat_date
|
|
AND total_candidate.protocol = s.protocol
|
|
AND total_candidate.quality_status = '` + QualityOK + `'
|
|
AND total_candidate.latest_total_mileage_km IS NOT NULL
|
|
AND total_candidate.latest_total_mileage_km >= 0
|
|
ORDER BY
|
|
CASE WHEN COALESCE(total_candidate.quality_reason, '') = '` + QualityReasonGPSCoordinate + `' THEN 1 ELSE 0 END,
|
|
CASE WHEN total_candidate.latest_total_mileage_km > total_candidate.daily_mileage_km THEN 0 ELSE 1 END,
|
|
total_candidate.is_selected DESC,
|
|
total_candidate.latest_event_time DESC,
|
|
total_candidate.sample_count DESC,
|
|
total_candidate.source_key ASC
|
|
LIMIT 1
|
|
), s.latest_total_mileage_km) END`
|
|
|
|
const projectDailyMileageSQL = `
|
|
INSERT INTO vehicle_daily_mileage
|
|
(vin, stat_date, protocol, source_id, daily_mileage_km, pure_hydrogen_mileage_km, latest_total_mileage_km, day_end_total_mileage_km)
|
|
SELECT
|
|
s.vin,
|
|
s.stat_date,
|
|
s.protocol,
|
|
ds.id,
|
|
s.daily_mileage_km,
|
|
LEAST(s.pure_hydrogen_mileage_km, s.daily_mileage_km),
|
|
s.latest_total_mileage_km,
|
|
` + projectDayEndTotalMileageSQL + `
|
|
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.vin = ?
|
|
AND s.stat_date = ?
|
|
AND s.protocol = ?
|
|
AND s.quality_status = '` + QualityOK + `'
|
|
AND s.latest_total_mileage_km IS NOT NULL
|
|
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 ` + projectMileageEvidenceRankSQL + `,
|
|
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),
|
|
` + projectSourceIdentitySampleCountSQL + ` DESC,
|
|
s.latest_event_time DESC,
|
|
s.sample_count DESC,
|
|
s.source_key ASC
|
|
LIMIT 1
|
|
ON DUPLICATE KEY UPDATE
|
|
source_id = VALUES(source_id),
|
|
daily_mileage_km = VALUES(daily_mileage_km),
|
|
pure_hydrogen_mileage_km = VALUES(pure_hydrogen_mileage_km),
|
|
latest_total_mileage_km = VALUES(latest_total_mileage_km),
|
|
day_end_total_mileage_km = VALUES(day_end_total_mileage_km),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
`
|
|
|
|
const markSelectedSourceSQL = `
|
|
UPDATE vehicle_daily_mileage_source s
|
|
LEFT JOIN (
|
|
SELECT
|
|
s2.source_key,
|
|
s2.vin,
|
|
s2.stat_date,
|
|
s2.protocol
|
|
FROM vehicle_daily_mileage_source s2
|
|
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 (? * 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 ` + selectedMileageEvidenceRankSQL + `,
|
|
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),
|
|
` + selectedSourceIdentitySampleCountSQL + ` DESC,
|
|
s2.latest_event_time DESC,
|
|
s2.sample_count 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 = CASE WHEN selected_source.source_key IS NULL THEN 0 ELSE 1 END
|
|
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
|
|
}
|
|
sqlText := previousSourceBaselineSQL
|
|
if protocol == envelope.ProtocolGB32960 {
|
|
sqlText = previousVehicleOdometerBaselineSQL
|
|
}
|
|
rows, err := query.QueryContext(ctx, sqlText, 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: QualityReasonHistorical,
|
|
}, latestTotal.Valid, nil
|
|
}
|
|
|
|
// lookupPreviousProtocolBaseline is deliberately source-key independent. A
|
|
// device replacement must not allow a near-zero odometer to become a normal
|
|
// continuation of the same vehicle/protocol history.
|
|
func lookupPreviousProtocolBaseline(ctx context.Context, query Queryer, vin string, statDate string, protocol envelope.Protocol) (sourceBaseline, bool, error) {
|
|
if query == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" {
|
|
return sourceBaseline{}, false, nil
|
|
}
|
|
rows, err := query.QueryContext(ctx, previousProtocolBaselineSQL, vin, statDate, string(protocol))
|
|
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 total sql.NullFloat64
|
|
var event sql.NullTime
|
|
if err := rows.Scan(&total, &event); err != nil {
|
|
return sourceBaseline{}, false, err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return sourceBaseline{}, false, err
|
|
}
|
|
return sourceBaseline{LatestTotalKM: total.Float64, LatestEventTime: event.Time, QualityReason: QualityReasonHistorical}, total.Valid, nil
|
|
}
|
|
|
|
// LookupLatestSourceBaselineBefore returns the nearest durable odometer for the
|
|
// same VIN and protocol before statDate. GB32960 uses the latest selected
|
|
// vehicle odometer across forwarding sources; other protocols retain terminal
|
|
// source isolation. 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 protocol = ?
|
|
AND source_key = ?
|
|
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 previousProtocolBaselineSQL = `
|
|
SELECT latest_total_mileage_km, latest_event_time
|
|
FROM vehicle_daily_mileage_source
|
|
WHERE vin=? AND stat_date<? AND protocol=?
|
|
AND quality_status='OK'
|
|
AND latest_total_mileage_km IS NOT NULL AND latest_total_mileage_km>0
|
|
AND latest_event_time IS NOT NULL
|
|
ORDER BY stat_date DESC, latest_event_time DESC
|
|
LIMIT 1
|
|
`
|
|
|
|
// GB32960 total mileage belongs to the VIN's vehicle odometer. A forwarding IP
|
|
// is not a new odometer: prefer the latest selected vehicle baseline over an old
|
|
// same-source baseline so a returning feed cannot recount already reported miles.
|
|
// JT808 terminal counters deliberately do not use this cross-source rule.
|
|
var previousVehicleOdometerBaselineSQL = strings.Replace(
|
|
strings.Replace(previousSourceBaselineSQL, "AND source_key = ?", `AND (source_key = ? OR is_selected = 1)
|
|
AND quality_status = 'OK'
|
|
AND COALESCE(quality_reason,'') <> 'gps_coordinate_accumulation'
|
|
AND source_ip NOT IN ('legacy-mysql.lingniu-prod','manual-lingniu-prod-day-mileage')`, 1),
|
|
"ORDER BY stat_date DESC, latest_event_time DESC",
|
|
"ORDER BY stat_date DESC, is_selected DESC, latest_event_time DESC, source_key ASC", 1)
|