Maintenance::Atom::ImportSubcompaniesTask

Source code
# frozen_string_literal: true

# Imports a TreeNode, its MboProfiles, and its GdsProfile from a single CSV row.
#
# The row's CompanyGroup is create-or-found by its atom-side company_group_id
# (stored as agent_port_id) and (re)named from company_group_name, then linked to
# the node. The group takes a generated uuid; the atom-side parent_id is recorded
# on its atom_company_ids rather than used as the primary key. A row with a blank
# company_group_id carries no group, so the node is left unlinked (and an
# existing link is preserved on re-run rather than detached).
#
# `reason_group_names` is a Postgres-style array of quoted strings referencing
# already-imported ReasonGroup records, e.g.
#   {"Global Air Exceptions","Specific Hotel Savings"}
# May be empty. Missing group names raise.
#
# Expected CSV headers:
#   id, company_guid, name, status, country_code,
#   mbo_country_id, brand, mbo_name, mbo_id, mbo,
#   gds_locator, gds_name, ppc_code, created_at, updated_at,
#   company_group_name, company_group_id, parent_id, reason_group_names

class Maintenance::Atom::ImportSubcompaniesTask < MaintenanceTasks::Task
  include DatadogTrace
  include Maintenance::Atom::BlankIdSkippable
  include Maintenance::Atom::CompanyGroupResolvable
  include Maintenance::Atom::CsvTimestampParsable
  include Maintenance::Atom::GdsProfileImportable
  include Maintenance::Atom::MboProfileImportable
  include Maintenance::Atom::PgArrayParsable
  include Maintenance::Atom::StatusMappable

  csv_collection
  report_on(StandardError)

  # instance follows the agent-port region: US companies live on the US instance,
  # everything else on EU (the EMEA agent port). Casing mirrors the AgentPort
  # webhook convention ('US'/'EU'); CompanyPresenter downcases when it maps the
  # value to a client_center_url host.
  US_COUNTRY_CODE = 'US'
  US_INSTANCE = 'US'
  DEFAULT_INSTANCE = 'EU'

  def process(row)
    return if skip_blank_id?(row)

    # MboProfile / GdsProfile inserts happen outside the TreeNode transaction and
    # only log on failure, so a prior run may have created the node but left its
    # profiles half-imported. Reuse the existing node and re-run the (idempotent)
    # profile inserts so a re-run repairs it instead of skipping the whole row.
    tree_node = find_or_insert_tree_node(row)
    safely_insert_mbo_profile(row, tree_node)
    safely_insert_gds_profile(row, tree_node)
  end

  private

  def find_or_insert_tree_node(row)
    company_group = resolve_company_group(row)
    existing = TreeNode.find_by(id: row['id'])
    return reconcile_existing_tree_node(existing, company_group, row) if existing

    ActiveRecord::Base.transaction do
      tree_node = insert_tree_node(row, company_group)
      associate_reason_groups(tree_node, row['reason_group_names'])
      tree_node
    end
  end

  # Reconcile a node a prior run already created. The company-group update and the
  # reason-group links share a transaction so a later failure (e.g. a missing
  # ReasonGroup) rolls back the group change too, keeping the row atomic like the
  # new-node path rather than leaving a half-applied update behind.
  def reconcile_existing_tree_node(existing, company_group, row)
    Rails.logger.info("TreeNode already exists for #{row_label(row)}; reconciling mbo/gds profiles")
    ActiveRecord::Base.transaction do
      assign_company_group(existing, company_group)
      associate_reason_groups(existing, row['reason_group_names'])
    end
    existing
  end

  # Links the node to the ReasonGroups named in the row's reason_group_names
  # Postgres array (already-imported records, resolved by name). Idempotent, so a
  # re-run adds no duplicate joins; a name with no matching ReasonGroup raises.
  def associate_reason_groups(tree_node, value)
    names = parse_pg_array(value)
    return if names.empty?

    reason_groups = ReasonGroup.where(name: names).to_a
    missing = names - reason_groups.map(&:name)
    raise ActiveRecord::RecordNotFound, "ReasonGroup names not found: #{missing.join(', ')}" if missing.any?

    reason_groups.each do |reason_group|
      ReasonGroupToTreeNode.find_or_create_by!(
        tree_node_id: tree_node.id,
        reason_group_id: reason_group.id
      )
    end
  end

  def row_label(row)
    "row '#{row['name']}' (#{row['id']})"
  end

  def insert_tree_node(row, company_group)
    tree_node = TreeNode.new(id: row['id'])
    tree_node.assign_attributes(tree_node_attributes(row, company_group))
    tree_node.skip_remote_validation = true
    tree_node.save!

    restamp_create_version(tree_node)
    tree_node
  end

  # PaperTrail copies the create version's created_at from the record's
  # (historical, CSV-sourced) updated_at, which would land the version outside
  # the CompanyEntityChanges rolling 31-day window and hide the import. Restamp
  # the create version with the wall-clock import time so the node surfaces as a
  # recent change while keeping its real ATOM created_at/updated_at intact.
  # update_column skips callbacks, so it emits no further version.
  def restamp_create_version(tree_node)
    tree_node.versions.find_by(event: 'create')&.update_column(:created_at, Time.current) # rubocop:disable Rails/SkipsModelValidations -- intentional: skips callbacks so it emits no further version
  end

  def tree_node_attributes(row, company_group)
    {
      name: row['name'],
      status: map_status(row['status']),
      company_guid: row['company_guid'].presence || CompanyProfiles::GuidNormalizer.generate_provisional,
      service_country: find_country(row['country_code']),
      brand: Brand.find_by!(name: row['brand']),
      company_group: company_group,
      instance: instance_for(row['country_code']),
      created_at: parse_timestamp(row['created_at']),
      updated_at: parse_timestamp(row['updated_at'])
    }
  end

  # US companies belong to the US agent-port instance; every other country maps
  # to the EU (EMEA) instance.
  def instance_for(country_code)
    country_code.to_s.strip.upcase == US_COUNTRY_CODE ? US_INSTANCE : DEFAULT_INSTANCE
  end

  def find_country(code)
    Country.find_by!(code: code)
  end
end

Previous Runs

Succeeded

Processed 220 out of 220 items (100%).

Ran for less than 5 seconds, finished .


Metadata:
user_id
-1
Succeeded

Processed 4,783 out of 4,783 items (100%).

Ran for 5 minutes, finished .


Metadata:
user_id
-1