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) }