Ran for less than 5 seconds, finished .
# 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
Processed 220 out of 220 items (100%).
Ran for less than 5 seconds, finished .
-1
Processed 4,783 out of 4,783 items (100%).
Ran for 5 minutes, finished .
-1