Maintenance::ImportPersonaVisibilityTask

Source code
# frozen_string_literal: true

# Applies AgentPort persona visibility to existing custom fields and emails one CSV
# report. The legacy columns are compared against the database and reported, never
# written.
#
# Expected CSV headers:
#   custom_field_id, company_guid, mbo_profile, agentport_profile_name,
#   custom_field_name, traveller_visibility, admin_visibility,
#   approval_visibility, legacy_type, legacy_number
class Maintenance::ImportPersonaVisibilityTask < MaintenanceTasks::Task
  include ArrayHelper

  csv_collection(in_batches: 100)
  report_on(StandardError)

  REPORT_COLUMNS = %w[
    report company_guid mbo_profile agentport_profile_name custom_field_id custom_field_name
    file_legacy_type file_legacy_number database_legacy_type database_legacy_number
    discrepancy_flag detail
  ].freeze

  # Fixed, so a run resumed in another process appends to the same report.
  REPORT_FILE = 'tmp/import_persona_visibility.csv'
  IMPORT_REPORTS = %w[IMPORTED WOULD_IMPORT].freeze
  ROW_COLUMNS = %w[company_guid mbo_profile agentport_profile_name custom_field_id
                   custom_field_name].freeze

  attribute :emails, :string
  attribute :report_only, :boolean, default: false

  validates :emails, presence: true, fcm_email_format: true

  after_start    :prepare_report
  after_complete :send_report
  after_error    :send_report

  def process(batch_of_rows)
    lines = batch_of_rows.flat_map { |row| process_row(row.to_h) }
    File.write(report_path, lines.join, mode: 'a')
  end

  def report_path
    Rails.root.join(REPORT_FILE).to_s
  end

  private

  def process_row(row)
    return [line(row, report: 'FIELD_NOT_IN_COMPANY_PROFILE')] if row['custom_field_id'].blank?

    custom_field = CustomField.find_by(id: row['custom_field_id'])
    return [line(row, report: 'ERROR', detail: 'custom field no longer exists')] unless custom_field

    [apply_visibility(row, custom_field), legacy_discrepancy(row, custom_field)].compact
  end

  def apply_visibility(row, custom_field)
    return line(row, report: 'WOULD_IMPORT') if report_only

    custom_field.update!(visibility_attributes(row))
    line(row, report: 'IMPORTED')
  rescue ActiveRecord::RecordInvalid => e
    line(row, report: 'ERROR', detail: e.record.errors.full_messages.join('; '))
  end

  def visibility_attributes(row)
    { traveller_visibility: row['traveller_visibility'],
      admin_visibility: row['admin_visibility'],
      travel_arranger_visibility: row['approval_visibility'] }
  end

  # nil when file and database agree: consistent records stay out of the report.
  def legacy_discrepancy(row, custom_field)
    stored = custom_field.legacy_agent_port_code
    return line(row, report: 'LEGACY_DISCREPANCY', discrepancy_flag: 'MISSING_IN_DATABASE') if stored.blank?

    flag = discrepancy_flag(row, stored)
    return unless flag

    line(row, report: 'LEGACY_DISCREPANCY', discrepancy_flag: flag,
              database_legacy_type: stored[0], database_legacy_number: stored[1..])
  end

  def discrepancy_flag(row, stored)
    same_type = stored[0] == row['legacy_type']
    same_number = stored[1..] == row['legacy_number'].to_s
    return nil if same_type && same_number
    return 'TYPE_AND_NUMBER_DIFFER' unless same_type || same_number

    same_type ? 'NUMBER_DIFFERS' : 'TYPE_DIFFERS'
  end

  def line(row, **extra)
    values = row.slice(*ROW_COLUMNS)
                .merge('file_legacy_type' => row['legacy_type'],
                       'file_legacy_number' => row['legacy_number'])
                .merge(extra.transform_keys(&:to_s))
    REPORT_COLUMNS.map { |column| values[column] }.to_csv
  end

  def prepare_report
    File.write(report_path, REPORT_COLUMNS.to_csv)
  end

  def send_report
    return unless File.exist?(report_path)

    append_confirmation
    CsvReportMailer.send_report(recipients: emails_array(emails), file_path: report_path,
                                report_sender: 'Import Persona Visibility').deliver_now
  ensure
    FileUtils.rm_f(report_path)
  end

  # Counted from the file, not memory, so a resumed run still reports what landed.
  def append_confirmation
    counts = CSV.read(report_path, headers: true)
                .select { |line| IMPORT_REPORTS.include?(line['report']) }
                .group_by { |line| line['company_guid'] }
                .transform_values(&:size)
    File.open(report_path, 'a') { |file| counts.sort.each { |pair| file.puts(confirmation_line(*pair)) } }
  end

  def confirmation_line(company_guid, count)
    values = { 'report' => 'CONFIRMATION', 'company_guid' => company_guid, 'detail' => count }
    REPORT_COLUMNS.map { |column| values[column] }.to_csv
  end
end