Maintenance::BackfillProtasLegacyScopeTask

Source code
# frozen_string_literal: true

# One-off backfill for the UK/IE Protas -> D365 migration.
#
# Acceptance criteria for TASK-72572 require the D365 import to feed `legacy_scope` from the import
# file into the Protas custom fields referenced in column W (`custom_field_id`). That was never
# implemented, so the fields already in production were left with an empty scope.
#
# This task replays that step from the same file. Upload the `custom_fields` sheet as CSV; for each
# row it reads `legacy_scope` and `custom_field_id`, and writes the scope into every referenced
# Protas field that has none yet. Nothing is inferred — the file is the source of truth.
#
# - Fill-only-if-empty: a scope already set in production is never overwritten, so a disagreement
#   between the sheet and production is reported rather than silently resolved.
# - Every reference in a multi-id column W cell is processed, not just the first.
# - Idempotent: a re-run only ever fills what is still empty.
# - A row the database rejects is reported as FAILED and the run continues, so one bad cell does
#   not cost the remaining rows.
#
# Expected against production (12,357 rows / 30,429 references):
#   30,429  reference empty, file supplies a scope  -> UPDATED
#    1,620  already equal                          -> SKIPPED_ALREADY_SET
#      648  production has a different scope        -> SKIPPED_CONFLICT
#
# Delete alongside the rest of the `d365`/Protas migration code once UK/IE is fully migrated.
class Maintenance::BackfillProtasLegacyScopeTask < MaintenanceTasks::Task
  include Maintenance::CsvReportable

  csv_collection

  SCOPE_COLUMN = 'legacy_scope'
  REFERENCE_COLUMN = 'custom_field_id'
  UUID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/

  REPORT_COLUMNS = %w[
    csv_custom_name
    csv_mbo_id
    csv_legacy_scope
    reference_custom_field_id
    reference_name
    action
    existing_scope
    errors
  ].freeze

  # Value object: what happened to one (csv row, reference) pair.
  # Keeps the reporting fields together instead of threading five arguments through append_row.
  Outcome = Data.define(:action, :reference_id, :reference_name, :existing, :errors) do
    def self.for_row(action, errors: nil)
      new(action: action, reference_id: nil, reference_name: nil, existing: nil, errors: errors)
    end

    def self.for_reference(action, reference, existing)
      new(action: action, reference_id: reference.id, reference_name: reference.name,
          existing: existing, errors: nil)
    end

    def self.missing_reference(reference_id)
      new(action: 'SKIPPED_REFERENCE_NOT_FOUND', reference_id: reference_id, reference_name: nil,
          existing: nil, errors: nil)
    end

    def columns
      [reference_id, reference_name, action, Array(existing).join('|'), errors]
    end
  end

  # A row the database rejects is reported and the run carries on: one bad cell in a 12,357-row
  # sheet must not cost the whole pass, and the report is what the operator acts on afterwards.
  # Re-running after a fix is safe because the backfill only ever fills an empty scope.
  def process(row)
    backfill_row(row)
  rescue StandardError => e
    append_row(row, Outcome.for_row('FAILED', errors: "#{e.class}: #{e.message}"))
  end

  private

  def backfill_row(row)
    scope = split_list(row[SCOPE_COLUMN])
    return append_row(row, Outcome.for_row('SKIPPED_NO_SCOPE_IN_FILE')) if scope.empty?

    ids = reference_ids(row[REFERENCE_COLUMN])
    return append_row(row, Outcome.for_row('SKIPPED_NO_REFERENCE_IN_FILE')) if ids.empty?

    write_scope(row, ids, scope)
  end

  def write_scope(row, ids, scope)
    found = find_references(ids)
    ids.each { |id| append_row(row, outcome_for(found[id], id, scope)) }
  end

  def outcome_for(reference, reference_id, scope)
    return Outcome.missing_reference(reference_id) unless reference

    backfill(reference, scope)
  end

  # Fill only when the reference has no scope; never overwrite what production already holds.
  def backfill(reference, scope)
    existing = reference.legacy_scope
    return Outcome.for_reference('SKIPPED_ALREADY_SET', reference, existing) if existing == scope
    return Outcome.for_reference('SKIPPED_CONFLICT', reference, existing) if existing.present?

    reference.update!(legacy_scope: scope)
    Outcome.for_reference('UPDATED', reference, [])
  end

  # Column I (legacy_scope) and column W (custom_field_id) are both comma-separated lists.
  def split_list(value)
    value.to_s.split(',').map(&:strip).compact_blank
  end

  # custom_fields.id is a Postgres uuid, so the database hands back canonical lowercase. D365
  # exports GUIDs uppercase, and Postgres matches those in WHERE regardless of case — so without
  # downcasing here the reference is found in SQL but missed in the map find_references keys by
  # id, and a field that exists is reported SKIPPED_REFERENCE_NOT_FOUND and never backfilled.
  def reference_ids(value)
    split_list(value).map(&:downcase)
  end

  # One query per CSV row rather than one per reference: a single column-W cell lists up to 28 ids.
  # The format guard keeps a malformed cell a clean miss, while genuine database errors still
  # surface instead of being swallowed.
  def find_references(ids)
    valid = ids.grep(UUID_FORMAT)
    return {} if valid.empty?

    CustomField.where(id: valid).index_by(&:id)
  end

  # Reporting (lifecycle in Maintenance::CsvReportable)

  def append_row(row, outcome)
    report.append([row['custom_name'], row['mbo_id'], row[SCOPE_COLUMN]] + outcome.columns)
  end

  def report_columns
    REPORT_COLUMNS
  end

  def report_file_prefix
    'backfill_protas_legacy_scope'
  end

  def report_sender_name
    'Backfill Protas Legacy Scope'
  end
end