Ran for less than 10 seconds, finished .
# 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
Processed 3,525 out of 3,525 items (100%).
Ran for less than 10 seconds, finished .
[FILTERED]
-1