696 lines
23 KiB
Go
696 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
|
)
|
|
|
|
func main() {
|
|
var input string
|
|
var dsn string
|
|
var legacyTable string
|
|
var apply bool
|
|
var ensureSchema bool
|
|
var reportLimit int
|
|
var unresolvedOut string
|
|
var conflictsOut string
|
|
var syncDataSources bool
|
|
var pruneUnmanagedDataSources bool
|
|
var pruneDataSourceMinAge time.Duration
|
|
var retireStaleUnmanagedDataSources bool
|
|
var retireDataSourceMinAge time.Duration
|
|
var timeout time.Duration
|
|
flag.StringVar(&input, "input", env("IDENTITY_MAPPING_INPUT", ""), "directory that contains 808 plate/phone mapping workbooks")
|
|
flag.StringVar(&dsn, "mysql-dsn", env("IDENTITY_MYSQL_DSN", env("MYSQL_DSN", "")), "MySQL DSN; empty means scan only")
|
|
flag.StringVar(&legacyTable, "legacy-table", env("VEHICLE_IDENTITY_TABLE", "vehicle_identity_binding"), "legacy VIN/plate binding table used to resolve VIN")
|
|
flag.BoolVar(&apply, "apply", false, "write resolved mappings to vehicle and vehicle_identifier")
|
|
flag.BoolVar(&ensureSchema, "ensure-schema", false, "create vehicle and vehicle_identifier tables before import")
|
|
flag.IntVar(&reportLimit, "report-limit", 50, "max unresolved/conflict items embedded in JSON; use -1 for all")
|
|
flag.StringVar(&unresolvedOut, "unresolved-out", "", "optional CSV path for all unresolved mappings")
|
|
flag.StringVar(&conflictsOut, "conflicts-out", "", "optional CSV path for all conflict mappings")
|
|
flag.BoolVar(&syncDataSources, "sync-data-sources", false, "infer JT808 vehicle_data_source platform names from jt808_registration and vehicle_identifier")
|
|
flag.BoolVar(&pruneUnmanagedDataSources, "prune-unmanaged-data-sources", false, "delete stale unclassified vehicle_data_source rows that have no registration evidence and are not referenced by final mileage")
|
|
flag.DurationVar(&pruneDataSourceMinAge, "prune-data-source-min-age", time.Hour, "minimum latest_seen/updated age before -prune-unmanaged-data-sources can delete rows")
|
|
flag.BoolVar(&retireStaleUnmanagedDataSources, "retire-stale-unmanaged-data-sources", false, "disable stale unclassified vehicle_data_source rows that have no registration evidence but may be referenced by historical mileage")
|
|
flag.DurationVar(&retireDataSourceMinAge, "retire-data-source-min-age", 24*time.Hour, "minimum latest_seen/updated age before -retire-stale-unmanaged-data-sources can disable rows")
|
|
flag.DurationVar(&timeout, "timeout", 2*time.Minute, "import timeout")
|
|
flag.Parse()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
input = strings.TrimSpace(input)
|
|
output := map[string]any{}
|
|
var records []identity.MappingRecord
|
|
var scan identity.MappingScanReport
|
|
if input != "" {
|
|
var err error
|
|
records, scan, err = identity.ReadMappingDirectory(input)
|
|
if err != nil {
|
|
fail(err)
|
|
}
|
|
output["input"] = input
|
|
output["scan"] = scan
|
|
} else if !syncDataSources && !pruneUnmanagedDataSources && !retireStaleUnmanagedDataSources {
|
|
fail(errors.New("-input is required unless -sync-data-sources, -prune-unmanaged-data-sources or -retire-stale-unmanaged-data-sources is set"))
|
|
}
|
|
if err := validateMappingApplyInput(apply, scan); err != nil {
|
|
fail(err)
|
|
}
|
|
if strings.TrimSpace(dsn) == "" {
|
|
if syncDataSources || pruneUnmanagedDataSources || retireStaleUnmanagedDataSources {
|
|
fail(errors.New("-mysql-dsn is required when -sync-data-sources, -prune-unmanaged-data-sources or -retire-stale-unmanaged-data-sources is set"))
|
|
}
|
|
output["mode"] = "scan_only"
|
|
writeJSON(output)
|
|
return
|
|
}
|
|
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
fail(err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
fail(err)
|
|
}
|
|
if input != "" && (ensureSchema || apply) {
|
|
if err := identity.EnsureVehicleIdentifierSchema(ctx, db); err != nil {
|
|
fail(err)
|
|
}
|
|
}
|
|
if input != "" {
|
|
report, err := identity.ImportMappingRecords(ctx, db, records, scan, identity.MappingImportOptions{
|
|
Apply: apply,
|
|
LegacyTable: legacyTable,
|
|
ReportItemLimit: reportItemLimit(reportLimit, unresolvedOut, conflictsOut),
|
|
})
|
|
if err != nil {
|
|
fail(err)
|
|
}
|
|
if strings.TrimSpace(unresolvedOut) != "" {
|
|
if err := writeUnresolvedCSV(unresolvedOut, report.UnresolvedItems); err != nil {
|
|
fail(err)
|
|
}
|
|
}
|
|
if strings.TrimSpace(conflictsOut) != "" {
|
|
if err := writeConflictsCSV(conflictsOut, report.ConflictItems); err != nil {
|
|
fail(err)
|
|
}
|
|
}
|
|
output["report"] = report
|
|
output["mode"] = map[bool]string{true: "apply", false: "dry_run"}[apply]
|
|
}
|
|
if syncDataSources {
|
|
syncReport, err := syncJT808DataSourcesFromIdentifiers(ctx, db, apply)
|
|
if err != nil {
|
|
fail(err)
|
|
}
|
|
output["data_source_sync"] = syncReport
|
|
}
|
|
if pruneUnmanagedDataSources {
|
|
pruneReport, err := pruneUnmanagedDataSourcesWithoutEvidence(ctx, db, apply, pruneDataSourceMinAge)
|
|
if err != nil {
|
|
fail(err)
|
|
}
|
|
output["data_source_prune"] = pruneReport
|
|
}
|
|
if retireStaleUnmanagedDataSources {
|
|
retireReport, err := retireStaleUnmanagedDataSourcesWithoutEvidence(ctx, db, apply, retireDataSourceMinAge)
|
|
if err != nil {
|
|
fail(err)
|
|
}
|
|
output["data_source_retire"] = retireReport
|
|
}
|
|
if input == "" {
|
|
output["mode"] = maintenanceMode(apply, syncDataSources, pruneUnmanagedDataSources, retireStaleUnmanagedDataSources)
|
|
}
|
|
writeJSON(output)
|
|
}
|
|
|
|
func maintenanceMode(apply bool, syncDataSources bool, pruneUnmanagedDataSources bool, retireStaleUnmanagedDataSources bool) string {
|
|
var parts []string
|
|
if syncDataSources {
|
|
parts = append(parts, "sync")
|
|
}
|
|
if pruneUnmanagedDataSources {
|
|
parts = append(parts, "prune")
|
|
}
|
|
if retireStaleUnmanagedDataSources {
|
|
parts = append(parts, "retire")
|
|
}
|
|
if len(parts) == 0 {
|
|
return map[bool]string{true: "apply", false: "dry_run"}[apply]
|
|
}
|
|
parts = append(parts, map[bool]string{true: "apply", false: "dry_run"}[apply])
|
|
return strings.Join(parts, "_")
|
|
}
|
|
|
|
func reportItemLimit(limit int, unresolvedOut string, conflictsOut string) int {
|
|
if strings.TrimSpace(unresolvedOut) != "" || strings.TrimSpace(conflictsOut) != "" {
|
|
return -1
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func validateMappingApplyInput(apply bool, scan identity.MappingScanReport) error {
|
|
if !apply {
|
|
return nil
|
|
}
|
|
return unsupportedMappingFilesError(scan)
|
|
}
|
|
|
|
func unsupportedMappingFilesError(scan identity.MappingScanReport) error {
|
|
if scan.UnsupportedFiles <= 0 {
|
|
return nil
|
|
}
|
|
items := make([]string, 0, len(scan.UnsupportedItems))
|
|
for _, item := range scan.UnsupportedItems {
|
|
path := strings.TrimSpace(item.File)
|
|
if path == "" {
|
|
path = strings.TrimSpace(item.Ext)
|
|
}
|
|
if path != "" {
|
|
items = append(items, path)
|
|
}
|
|
if len(items) >= 3 {
|
|
break
|
|
}
|
|
}
|
|
suffix := ""
|
|
if len(items) > 0 {
|
|
suffix = ": " + strings.Join(items, ", ")
|
|
}
|
|
return fmt.Errorf("identity mapping import has %d unsupported workbook(s); convert .xls/.xlsb to .xlsx before -apply%s", scan.UnsupportedFiles, suffix)
|
|
}
|
|
|
|
func env(key string, fallback string) string {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func writeJSON(value any) {
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
if err := encoder.Encode(value); err != nil {
|
|
fail(err)
|
|
}
|
|
}
|
|
|
|
func writeUnresolvedCSV(path string, items []identity.MappingRecord) error {
|
|
file, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
if err := writer.Write([]string{
|
|
"file", "sheet", "row", "source_code", "source_name", "protocol",
|
|
"identifier_type", "identifier_value", "raw_value", "plate", "oem", "reason",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
for _, item := range items {
|
|
if err := writer.Write([]string{
|
|
item.File,
|
|
item.Sheet,
|
|
fmt.Sprint(item.Row),
|
|
item.SourceCode,
|
|
item.SourceName,
|
|
item.Protocol,
|
|
item.IdentifierType,
|
|
item.IdentifierValue,
|
|
item.RawValue,
|
|
item.Plate,
|
|
item.OEM,
|
|
"vin_not_found_in_legacy_binding",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return writer.Error()
|
|
}
|
|
|
|
func writeConflictsCSV(path string, items []identity.MappingConflict) error {
|
|
file, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
if err := writer.Write([]string{
|
|
"file", "sheet", "row", "source_code", "source_name", "protocol",
|
|
"identifier_type", "identifier_value", "raw_value", "plate", "oem",
|
|
"existing_vin", "new_vin", "reason",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
for _, item := range items {
|
|
record := item.Record
|
|
if err := writer.Write([]string{
|
|
record.File,
|
|
record.Sheet,
|
|
fmt.Sprint(record.Row),
|
|
record.SourceCode,
|
|
record.SourceName,
|
|
record.Protocol,
|
|
record.IdentifierType,
|
|
record.IdentifierValue,
|
|
record.RawValue,
|
|
record.Plate,
|
|
record.OEM,
|
|
item.ExistingVIN,
|
|
item.NewVIN,
|
|
item.Reason,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return writer.Error()
|
|
}
|
|
|
|
type dataSourceSyncReport struct {
|
|
Apply bool `json:"apply"`
|
|
CandidateSources int64 `json:"candidate_sources"`
|
|
SkippedSources int64 `json:"skipped_sources"`
|
|
ConflictingSources int64 `json:"conflicting_sources"`
|
|
PlatformKindCandidates int64 `json:"platform_kind_candidates"`
|
|
PlatformKindClassified int64 `json:"platform_kind_classified,omitempty"`
|
|
ReprojectDailyMileageTargets int64 `json:"reproject_daily_mileage_targets,omitempty"`
|
|
ReprojectedDailyMileageTargets int64 `json:"reprojected_daily_mileage_targets,omitempty"`
|
|
Synced int64 `json:"synced,omitempty"`
|
|
}
|
|
|
|
type dailyMileageProjectionTarget struct {
|
|
VIN string
|
|
StatDate string
|
|
Protocol envelope.Protocol
|
|
}
|
|
|
|
type dataSourcePruneReport struct {
|
|
Apply bool `json:"apply"`
|
|
MinAgeSeconds int64 `json:"min_age_seconds"`
|
|
CandidateSources int64 `json:"candidate_sources"`
|
|
Pruned int64 `json:"pruned,omitempty"`
|
|
}
|
|
|
|
type dataSourceRetireReport struct {
|
|
Apply bool `json:"apply"`
|
|
MinAgeSeconds int64 `json:"min_age_seconds"`
|
|
CandidateSources int64 `json:"candidate_sources"`
|
|
Retired int64 `json:"retired,omitempty"`
|
|
}
|
|
|
|
func syncJT808DataSourcesFromIdentifiers(ctx context.Context, db *sql.DB, apply bool) (dataSourceSyncReport, error) {
|
|
report := dataSourceSyncReport{Apply: apply}
|
|
var err error
|
|
report.CandidateSources, err = queryInt64(ctx, db, syncJT808DataSourcesCandidateCountSQL)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
report.SkippedSources, err = queryInt64(ctx, db, syncJT808DataSourcesSkippedCountSQL)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
report.ConflictingSources, err = queryInt64(ctx, db, syncJT808DataSourcesConflictCountSQL)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
if apply {
|
|
result, err := db.ExecContext(ctx, syncJT808DataSourcesSQL)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
rowsAffected, err := result.RowsAffected()
|
|
if err == nil {
|
|
report.Synced = rowsAffected
|
|
}
|
|
}
|
|
report.PlatformKindCandidates, err = queryInt64(ctx, db, classifyConfiguredDataSourcesCountSQL)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
if apply {
|
|
result, err := db.ExecContext(ctx, classifyConfiguredDataSourcesSQL)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
rowsAffected, err := result.RowsAffected()
|
|
if err == nil {
|
|
report.PlatformKindClassified = rowsAffected
|
|
}
|
|
}
|
|
targets, err := queryDailyMileageProjectionTargets(ctx, db)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
report.ReprojectDailyMileageTargets = int64(len(targets))
|
|
if apply {
|
|
for _, target := range targets {
|
|
if err := stats.ProjectDailyMileage(ctx, db, target.VIN, target.StatDate, target.Protocol); err != nil {
|
|
return report, err
|
|
}
|
|
report.ReprojectedDailyMileageTargets++
|
|
}
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
func queryDailyMileageProjectionTargets(ctx context.Context, db *sql.DB) ([]dailyMileageProjectionTarget, error) {
|
|
rows, err := db.QueryContext(ctx, reprojectSelectableDailyMileageSourcesSQL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var targets []dailyMileageProjectionTarget
|
|
for rows.Next() {
|
|
var target dailyMileageProjectionTarget
|
|
var protocol string
|
|
if err := rows.Scan(&target.VIN, &target.StatDate, &protocol); err != nil {
|
|
return nil, err
|
|
}
|
|
target.Protocol = envelope.Protocol(strings.TrimSpace(protocol))
|
|
if strings.TrimSpace(target.VIN) != "" && strings.TrimSpace(target.StatDate) != "" && strings.TrimSpace(string(target.Protocol)) != "" {
|
|
targets = append(targets, target)
|
|
}
|
|
}
|
|
return targets, rows.Err()
|
|
}
|
|
|
|
func pruneUnmanagedDataSourcesWithoutEvidence(ctx context.Context, db *sql.DB, apply bool, minAge time.Duration) (dataSourcePruneReport, error) {
|
|
if minAge < 0 {
|
|
minAge = 0
|
|
}
|
|
minAgeSeconds := int64(minAge.Seconds())
|
|
report := dataSourcePruneReport{Apply: apply, MinAgeSeconds: minAgeSeconds}
|
|
var err error
|
|
report.CandidateSources, err = queryInt64WithArgs(ctx, db, pruneUnmanagedDataSourcesCountSQL, minAgeSeconds)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
if !apply {
|
|
return report, nil
|
|
}
|
|
result, err := db.ExecContext(ctx, pruneUnmanagedDataSourcesSQL, minAgeSeconds)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
rowsAffected, err := result.RowsAffected()
|
|
if err == nil {
|
|
report.Pruned = rowsAffected
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
func retireStaleUnmanagedDataSourcesWithoutEvidence(ctx context.Context, db *sql.DB, apply bool, minAge time.Duration) (dataSourceRetireReport, error) {
|
|
if minAge < 0 {
|
|
minAge = 0
|
|
}
|
|
minAgeSeconds := int64(minAge.Seconds())
|
|
report := dataSourceRetireReport{Apply: apply, MinAgeSeconds: minAgeSeconds}
|
|
var err error
|
|
report.CandidateSources, err = queryInt64WithArgs(ctx, db, retireStaleUnmanagedDataSourcesCountSQL, minAgeSeconds)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
if !apply {
|
|
return report, nil
|
|
}
|
|
result, err := db.ExecContext(ctx, retireStaleUnmanagedDataSourcesSQL, minAgeSeconds)
|
|
if err != nil {
|
|
return report, err
|
|
}
|
|
rowsAffected, err := result.RowsAffected()
|
|
if err == nil {
|
|
report.Retired = rowsAffected
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
func queryInt64(ctx context.Context, db *sql.DB, query string) (int64, error) {
|
|
return queryInt64WithArgs(ctx, db, query)
|
|
}
|
|
|
|
func queryInt64WithArgs(ctx context.Context, db *sql.DB, query string, args ...any) (int64, error) {
|
|
var count int64
|
|
err := db.QueryRowContext(ctx, query, args...).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const jt808DataSourceInferenceSQL = `
|
|
SELECT
|
|
'JT808' AS protocol,
|
|
TRIM(r.source_ip) AS source_ip,
|
|
MAX(TRIM(r.source_endpoint)) AS latest_source_endpoint,
|
|
MIN(COALESCE(NULLIF(TRIM(vi.oem), ''), NULLIF(TRIM(vi.source_code), ''))) AS platform_name,
|
|
MIN(NULLIF(TRIM(vi.source_code), '')) AS source_code,
|
|
MIN(COALESCE(r.first_registered_at, r.latest_registered_at, r.latest_authenticated_at, r.latest_seen_at, r.updated_at, CURRENT_TIMESTAMP)) AS first_seen_at,
|
|
MAX(COALESCE(r.latest_seen_at, r.latest_authenticated_at, r.latest_registered_at, r.updated_at, CURRENT_TIMESTAMP)) AS latest_seen_at,
|
|
COUNT(DISTINCT COALESCE(NULLIF(TRIM(vi.oem), ''), NULLIF(TRIM(vi.source_code), ''))) AS platform_count,
|
|
COUNT(DISTINCT NULLIF(TRIM(vi.source_code), '')) AS source_code_count
|
|
FROM jt808_registration r
|
|
JOIN vehicle_identifier vi
|
|
ON vi.protocol = 'JT808'
|
|
AND vi.identifier_type = 'JT808_PHONE'
|
|
AND vi.identifier_value = r.phone
|
|
AND vi.enabled = 1
|
|
WHERE r.source_endpoint IS NOT NULL
|
|
AND TRIM(r.source_endpoint) <> ''
|
|
AND r.source_ip IS NOT NULL
|
|
AND TRIM(r.source_ip) <> ''
|
|
GROUP BY TRIM(r.source_ip)
|
|
`
|
|
|
|
const jt808DataSourceCandidateWhereSQL = `inferred.source_ip IS NOT NULL
|
|
AND inferred.source_ip <> ''
|
|
AND inferred.platform_name IS NOT NULL
|
|
AND inferred.platform_name <> ''
|
|
AND inferred.platform_count = 1
|
|
AND inferred.source_code_count = 1`
|
|
|
|
const jt808DataSourceNeedsSyncWhereSQL = `(ds.id IS NULL
|
|
OR ds.platform_name IS NULL OR TRIM(ds.platform_name) = ''
|
|
OR ds.source_code IS NULL OR TRIM(ds.source_code) = ''
|
|
OR ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN'
|
|
OR (ds.enabled = 0 AND (ds.remark LIKE 'auto-retired:%' OR ds.remark = 'auto-reenabled: source evidence restored')))`
|
|
|
|
const syncJT808DataSourcesSQL = `
|
|
INSERT INTO vehicle_data_source
|
|
(protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind, first_seen_at, latest_seen_at)
|
|
SELECT
|
|
inferred.protocol,
|
|
inferred.source_ip,
|
|
inferred.latest_source_endpoint,
|
|
inferred.platform_name,
|
|
inferred.source_code,
|
|
'PLATFORM',
|
|
inferred.first_seen_at,
|
|
inferred.latest_seen_at
|
|
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
|
LEFT JOIN vehicle_data_source ds
|
|
ON ds.protocol = inferred.protocol
|
|
AND ds.source_ip = inferred.source_ip
|
|
WHERE ` + jt808DataSourceCandidateWhereSQL + `
|
|
AND ` + jt808DataSourceNeedsSyncWhereSQL + `
|
|
ON DUPLICATE KEY UPDATE
|
|
latest_source_endpoint = VALUES(latest_source_endpoint),
|
|
platform_name = CASE
|
|
WHEN vehicle_data_source.platform_name IS NULL OR TRIM(vehicle_data_source.platform_name) = ''
|
|
THEN VALUES(platform_name)
|
|
ELSE vehicle_data_source.platform_name
|
|
END,
|
|
source_code = CASE
|
|
WHEN vehicle_data_source.source_code IS NULL OR TRIM(vehicle_data_source.source_code) = ''
|
|
THEN VALUES(source_code)
|
|
ELSE vehicle_data_source.source_code
|
|
END,
|
|
source_kind = CASE
|
|
WHEN vehicle_data_source.source_kind IS NULL OR TRIM(vehicle_data_source.source_kind) = '' OR vehicle_data_source.source_kind = 'UNKNOWN'
|
|
THEN VALUES(source_kind)
|
|
ELSE vehicle_data_source.source_kind
|
|
END,
|
|
enabled = CASE
|
|
WHEN vehicle_data_source.enabled = 0
|
|
AND (vehicle_data_source.remark LIKE 'auto-retired:%' OR vehicle_data_source.remark = 'auto-reenabled: source evidence restored')
|
|
AND (
|
|
(VALUES(platform_name) IS NOT NULL AND TRIM(VALUES(platform_name)) <> '')
|
|
OR (VALUES(source_code) IS NOT NULL AND TRIM(VALUES(source_code)) <> '')
|
|
OR VALUES(source_kind) <> 'UNKNOWN'
|
|
)
|
|
THEN 1
|
|
ELSE vehicle_data_source.enabled
|
|
END,
|
|
remark = CASE
|
|
WHEN vehicle_data_source.enabled = 0
|
|
AND (vehicle_data_source.remark LIKE 'auto-retired:%' OR vehicle_data_source.remark = 'auto-reenabled: source evidence restored')
|
|
AND (
|
|
(VALUES(platform_name) IS NOT NULL AND TRIM(VALUES(platform_name)) <> '')
|
|
OR (VALUES(source_code) IS NOT NULL AND TRIM(VALUES(source_code)) <> '')
|
|
OR VALUES(source_kind) <> 'UNKNOWN'
|
|
)
|
|
THEN 'auto-reenabled: source evidence restored'
|
|
ELSE vehicle_data_source.remark
|
|
END,
|
|
first_seen_at = COALESCE(vehicle_data_source.first_seen_at, VALUES(first_seen_at)),
|
|
latest_seen_at = GREATEST(COALESCE(vehicle_data_source.latest_seen_at, VALUES(latest_seen_at)), VALUES(latest_seen_at)),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
`
|
|
|
|
const reprojectSelectableDailyMileageSourcesSQL = `
|
|
SELECT DISTINCT s.vin, s.stat_date, s.protocol
|
|
FROM vehicle_daily_mileage_source s
|
|
LEFT JOIN vehicle_data_source ds
|
|
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
|
|
LEFT JOIN vehicle_daily_mileage m
|
|
ON m.vin = s.vin AND m.stat_date = s.stat_date AND m.protocol = s.protocol
|
|
WHERE s.protocol = 'JT808'
|
|
AND s.quality_status = 'OK'
|
|
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) = '')
|
|
))
|
|
AND (
|
|
m.vin IS NULL
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM vehicle_daily_mileage_source selected
|
|
WHERE selected.vin = s.vin
|
|
AND selected.stat_date = s.stat_date
|
|
AND selected.protocol = s.protocol
|
|
AND selected.is_selected = 1
|
|
)
|
|
)
|
|
ORDER BY s.stat_date DESC, s.vin ASC
|
|
`
|
|
|
|
const syncJT808DataSourcesCandidateCountSQL = `
|
|
SELECT COUNT(*)
|
|
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
|
LEFT JOIN vehicle_data_source ds
|
|
ON ds.protocol = inferred.protocol
|
|
AND ds.source_ip = inferred.source_ip
|
|
WHERE ` + jt808DataSourceCandidateWhereSQL + `
|
|
AND ` + jt808DataSourceNeedsSyncWhereSQL
|
|
|
|
const syncJT808DataSourcesSkippedCountSQL = `
|
|
SELECT COUNT(*)
|
|
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
|
WHERE inferred.source_ip IS NOT NULL
|
|
AND inferred.source_ip <> ''
|
|
AND inferred.platform_name IS NOT NULL
|
|
AND inferred.platform_name <> ''
|
|
AND NOT (` + jt808DataSourceCandidateWhereSQL + `)`
|
|
|
|
const syncJT808DataSourcesConflictCountSQL = `
|
|
SELECT COUNT(*)
|
|
FROM (` + jt808DataSourceInferenceSQL + `) inferred
|
|
JOIN vehicle_data_source ds
|
|
ON ds.protocol = inferred.protocol
|
|
AND ds.source_ip = inferred.source_ip
|
|
WHERE ` + jt808DataSourceCandidateWhereSQL + `
|
|
AND ds.source_code IS NOT NULL
|
|
AND TRIM(ds.source_code) <> ''
|
|
AND ds.source_code <> inferred.source_code`
|
|
|
|
const classifyConfiguredDataSourcesWhereSQL = `
|
|
(source_kind IS NULL OR TRIM(source_kind) = '' OR source_kind = 'UNKNOWN')
|
|
AND (
|
|
(source_code IS NOT NULL AND TRIM(source_code) <> '')
|
|
OR (platform_name IS NOT NULL AND TRIM(platform_name) <> '')
|
|
)`
|
|
|
|
const classifyConfiguredDataSourcesCountSQL = `
|
|
SELECT COUNT(*)
|
|
FROM vehicle_data_source
|
|
WHERE ` + classifyConfiguredDataSourcesWhereSQL
|
|
|
|
const classifyConfiguredDataSourcesSQL = `
|
|
UPDATE vehicle_data_source
|
|
SET source_kind = 'PLATFORM',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE ` + classifyConfiguredDataSourcesWhereSQL
|
|
|
|
const pruneUnmanagedDataSourcesWhereSQL = `
|
|
(ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
|
|
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
|
|
AND (ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN')
|
|
AND TIMESTAMPDIFF(SECOND, COALESCE(ds.latest_seen_at, ds.updated_at, ds.created_at), CURRENT_TIMESTAMP) >= ?
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM vehicle_daily_mileage m
|
|
WHERE m.source_id = ds.id
|
|
LIMIT 1
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM jt808_registration r
|
|
WHERE ds.protocol = 'JT808'
|
|
AND r.source_ip = ds.source_ip
|
|
LIMIT 1
|
|
)`
|
|
|
|
const pruneUnmanagedDataSourcesCountSQL = `
|
|
SELECT COUNT(*)
|
|
FROM vehicle_data_source ds
|
|
WHERE ` + pruneUnmanagedDataSourcesWhereSQL
|
|
|
|
const pruneUnmanagedDataSourcesSQL = `
|
|
DELETE ds
|
|
FROM vehicle_data_source ds
|
|
WHERE ` + pruneUnmanagedDataSourcesWhereSQL
|
|
|
|
const retireStaleUnmanagedDataSourcesWhereSQL = `
|
|
ds.enabled = 1
|
|
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
|
|
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
|
|
AND (ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN')
|
|
AND TIMESTAMPDIFF(SECOND, COALESCE(ds.latest_seen_at, ds.updated_at, ds.created_at), CURRENT_TIMESTAMP) >= ?
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM jt808_registration r
|
|
WHERE ds.protocol = 'JT808'
|
|
AND r.source_ip = ds.source_ip
|
|
LIMIT 1
|
|
)`
|
|
|
|
const retireStaleUnmanagedDataSourcesCountSQL = `
|
|
SELECT COUNT(*)
|
|
FROM vehicle_data_source ds
|
|
WHERE ` + retireStaleUnmanagedDataSourcesWhereSQL
|
|
|
|
const retireStaleUnmanagedDataSourcesSQL = `
|
|
UPDATE vehicle_data_source ds
|
|
SET enabled = 0,
|
|
remark = CASE
|
|
WHEN remark IS NULL OR TRIM(remark) = ''
|
|
THEN 'auto-retired: stale unmanaged source without registration evidence'
|
|
ELSE remark
|
|
END,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE ` + retireStaleUnmanagedDataSourcesWhereSQL
|
|
|
|
func fail(err error) {
|
|
_, _ = fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|