Class: Edi::BaseOrchestrator

Inherits:
Object
  • Object
show all
Extended by:
Memery
Defined in:
app/services/edi/base_orchestrator.rb

Overview

Service object: base orchestrator.

Constant Summary collapse

ORCHESTRATORS =

Orchestrators.

['Edi::Amazon::Orchestrator', 'Edi::Commercehub::Orchestrator', 'Edi::Houzz::Orchestrator', 'Edi::Wayfair::Orchestrator', 'Edi::AmazonVc::Orchestrator', 'Edi::MiraklSeller::Orchestrator',
'Edi::MftGateway::Orchestrator', 'Edi::Walmart::Orchestrator', 'Edi::Menard::Orchestrator', 'Edi::Openai::Orchestrator',
'Edi::ResellerInventory::Orchestrator'].freeze
[1, 2, 3, 4, 6, 8, 12, 24].freeze
DEFAULT_PENDING_DISCONTINUE_LIFETIME =

Default pending discontinue lifetime.

1.day

Instance Attribute Summary collapse

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(partner, options = {}) ⇒ BaseOrchestrator

Returns a new instance of BaseOrchestrator.

Raises:

  • (ArgumentError)


348
349
350
351
352
353
354
355
356
357
358
359
360
361
# File 'app/services/edi/base_orchestrator.rb', line 348

def initialize(partner, options = {})
  # `try` guards against a non-symbolizable partner (e.g. a Hash passed by a
  # mis-invoked `rails runner` one-liner) — fail fast with a clear message
  # instead of `NoMethodError: undefined method 'to_sym'` (AppSignal #5014).
  @config = self.class.partners[partner.try(:to_sym)]
  raise ArgumentError, "Unrecognized EDI partner: #{partner.inspect}" unless @config

  @config.each do |name, val|
    singleton_class.send :attr_accessor, name.to_sym
    public_send :"#{name}=", val
  end
  @options = options
  @logger = options[:logger] || Rails.logger
end

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



6
7
8
# File 'app/services/edi/base_orchestrator.rb', line 6

def config
  @config
end

#loggerObject (readonly)

Returns the value of attribute logger.



6
7
8
# File 'app/services/edi/base_orchestrator.rb', line 6

def logger
  @logger
end

#optionsObject (readonly)

Returns the value of attribute options.



6
7
8
# File 'app/services/edi/base_orchestrator.rb', line 6

def options
  @options
end

Class Method Details

.all_orchestrators_classObject



20
21
22
# File 'app/services/edi/base_orchestrator.rb', line 20

def all_orchestrators_class
  ORCHESTRATORS.map(&:constantize)
end

.build(partner_config_key, options = {}) ⇒ Object



79
80
81
82
83
84
85
# File 'app/services/edi/base_orchestrator.rb', line 79

def build(partner_config_key, options = {})
  # Find the orchestrator for this partner key
  orchestrator_class = all_orchestrators_class.find { |o| o.partners.key?(partner_config_key.to_sym) }
  return orchestrator_class.new(partner_config_key, options) if orchestrator_class

  raise "Cannot determine orchestrator class for partner #{partner_config_key}"
end

.build_customer_id_to_partner_key_map(partners) ⇒ Hash

Pure two-pass builder split out of customer_id_to_partner_key_map so
tests can drive it with a fixture WITHOUT stubbing the memoized
partners (see the note above). Not memoized — safe to call directly.

Parameters:

  • partners (Hash)

    partner-key => config (mirrors partners)

Returns:

  • (Hash)

    customer_id => partner key



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'app/services/edi/base_orchestrator.rb', line 49

def build_customer_id_to_partner_key_map(partners)
  map = {}
  # The two passes are deliberately separate and MUST NOT be combined:
  # collapsing them is exactly the PR #480 regression that made every
  # Hash-style multi-customer partner (Rona/Lowes/Reno) invisible, because
  # the first pass `next if ...is_a?(Hash)` and the second pass
  # `next unless ...is_a?(Hash)` cannot share one iteration.
  # rubocop:disable Style/CombinableLoops
  # First pass: single customer_id partners (higher priority)
  partners.each do |key, config|
    next unless config[:active]
    next if config[:inventory_feed_only] # push-only inventory feeds never answer a customer_id lookup
    next if config[:customer_id].is_a?(Hash) # Skip multi-customer partners in first pass

    map[config[:customer_id]] = key if config[:customer_id]
  end
  # Second pass: multi-customer partners (lower priority, only if not already mapped)
  partners.each do |key, config|
    next unless config[:active]
    next if config[:inventory_feed_only]
    next unless config[:customer_id].is_a?(Hash)

    config[:customer_id].values.each do |cid|
      map[cid] ||= key # Only set if not already mapped by single-customer partner
    end
  end
  # rubocop:enable Style/CombinableLoops
  map
end

.cached_build(partner_config_key, options = {}) ⇒ Object

Returns a cached orchestrator instance for the given partner key
This avoids expensive repeated instantiation of orchestrators with dynamic accessors



100
101
102
103
# File 'app/services/edi/base_orchestrator.rb', line 100

def cached_build(partner_config_key, options = {})
  cache_key = [partner_config_key.to_sym, options.hash].join('_')
  cached_orchestrators[cache_key] ||= build(partner_config_key, options)
end

.cached_orchestratorsObject

Request/job-scoped cache of orchestrator instances by partner key.
Backed by CurrentScope so it is automatically reset between web
requests (Rails) and Sidekiq jobs (Sidekiq::CurrentAttributes
middleware). The previous implementation used a class-level instance
variable (@cached_orchestrators ||= {}) which is shared across
threads and never reset -- so it both leaked memory unboundedly and
held stale partner config across deploys/reloads.



94
95
96
# File 'app/services/edi/base_orchestrator.rb', line 94

def cached_orchestrators
  CurrentScope.edi_orchestrator_cache ||= {}
end

.catalog_id_to_pending_discontinue_lifetimeObject

Returns a hash of { catalog_id => ActiveSupport::Duration } for all active
orchestrators that define a custom pending_discontinue_lifetime. Used by
Maintenance::ItemMaintenance to apply per-partner wait times.



411
412
413
414
415
416
417
418
419
420
421
422
423
# File 'app/services/edi/base_orchestrator.rb', line 411

def self.catalog_id_to_pending_discontinue_lifetime
  map = {}
  orchestrators.each do |o|
    next unless o.active

    lifetime = o.pending_discontinue_lifetime
    next if lifetime == DEFAULT_PENDING_DISCONTINUE_LIFETIME

    catalog_id = o.try(:catalog_id)
    map[catalog_id] = lifetime if catalog_id
  end
  map
end

.catalog_ids_edi_enabledObject



142
143
144
# File 'app/services/edi/base_orchestrator.rb', line 142

def catalog_ids_edi_enabled
  Customer.where(id: customer_ids_edi_enabled).pluck(:catalog_id).uniq.sort
end

.customer_id_to_partner_key_mapObject

Builds a lookup hash from customer_id to partner config key for fast lookups
Handles both single customer_id values and multi-customer partner configurations



38
39
40
# File 'app/services/edi/base_orchestrator.rb', line 38

def customer_id_to_partner_key_map
  build_customer_id_to_partner_key_map(partners)
end

.customer_ids_edi_enabledObject



125
126
127
# File 'app/services/edi/base_orchestrator.rb', line 125

def customer_ids_edi_enabled
  partners.values.select { |v| v[:customer_id].try(:values) || v[:customer_id] }.map { |v| v[:customer_id].try(:values) || v[:customer_id] }.flatten.uniq.sort
end

.customer_ids_with_invoice_message_enabledObject



130
131
132
133
134
135
136
137
138
139
# File 'app/services/edi/base_orchestrator.rb', line 130

def customer_ids_with_invoice_message_enabled
  Rails.cache.fetch('edi/customer_ids_with_invoice_message_enabled', expires_in: 1.hour) do
    partners.keys.filter_map do |key|
      o = cached_build(key)
      next unless o.respond_to?(:invoice_message_enabled?) && o.invoice_message_enabled?

      Array(o.config[:customer_id].is_a?(Hash) ? o.config[:customer_id].values : o.config[:customer_id])
    end.flatten.compact.uniq.sort
  end
end

.execute_discontinue_flow(orchestrator_name: nil, partner: nil, logger: Rails.logger, trial_run: false) ⇒ Object

Executes the discontinue flow for EDI orchestrators.
Picks up pending_discontinue catalog items and sends DELETE via SP-API.

orchestrator_name - The name of a specific orchestrator to run, optional.
partner - The partner key to run for, optional.
logger - The logger to use.
trial_run - If true, will not send real requests.



196
197
198
# File 'app/services/edi/base_orchestrator.rb', line 196

def self.execute_discontinue_flow(orchestrator_name: nil, partner: nil, logger: Rails.logger, trial_run: false)
  execute_flow(:execute_discontinue_flow, orchestrator_name:, partner:, logger:, trial_run:)
end

.execute_flow(flow, orchestrator_name: nil, partner: nil, logger: Rails.logger, trial_run: false) ⇒ Object

Executes the specified flow (inventory, order, or product data)
for the given orchestrator(s). Allows filtering by orchestrator name and partner.
Logs execution and returns results.



203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'app/services/edi/base_orchestrator.rb', line 203

def self.execute_flow(flow, orchestrator_name: nil, partner: nil, logger: Rails.logger, trial_run: false)
  valid_flows = %i[execute_inventory_flow execute_order_flow execute_product_data_flow execute_price_flow execute_listing_message_feed_flow execute_discontinue_flow]
  raise "Invalid flow option, must be one of #{valid_flows.join(', ')}" unless flow.in?(valid_flows)

  results = []
  all_orchestrators_class.each do |oc|
    next if orchestrator_name.present? && oc.name != orchestrator_name

    logger.tagged oc.name do
      oc.orchestrators.each do |orchestrator|
        next if partner.present? && orchestrator.partner.to_s != partner

        logger.tagged orchestrator.partner do
          logger.tagged flow do
            logger.info 'started'
            begin
              result = if orchestrator.should_execute_flow?(flow) && orchestrator.respond_to?(flow)
                         trial_run ? :trial_run : orchestrator.send(flow)
                       else
                         :scheduled_skip
                       end
              logger.info "Result: #{loggable_result(result)}"
              results << { orchestrator_class: oc.name, partner: orchestrator.partner, flow:, result: }
            rescue Exception => e
              msg = "#{oc.name} #{orchestrator.partner} #{flow} exception. #{e}"
              # Enhanced error logging with detailed context
              ErrorReporting.error(e, {
                orchestrator_class: oc.name,
                partner: orchestrator.partner,
                flow: flow,
                error_type: 'orchestrator_execution_error',
                orchestrator_name: orchestrator.class.name,
                flow_method: flow,
                exception_class: e.class.name,
                exception_message: e.message,
                backtrace: e.backtrace&.first(10),
                message: msg
              })
              logger.error msg
              # Add error result to results array instead of failing silently
              results << {
                orchestrator_class: oc.name,
                partner: orchestrator.partner,
                flow:,
                result: :error,
                error: e.message,
                error_class: e.class.name
              }
            end
            logger.info 'completed'
          end
        end
      end
    end
  end
  results
end

.execute_inventory_flow(orchestrator_name: nil, partner: nil, logger: Rails.logger, trial_run: false) ⇒ Object

Executes the inventory flow for EDI orchestrators.

orchestrator_name - The name of a specific orchestrator to run, optional.
partner - The partner key to run for, optional.
logger - The logger to use.
trial_run - If true, will not send real requests.



165
166
167
# File 'app/services/edi/base_orchestrator.rb', line 165

def self.execute_inventory_flow(orchestrator_name: nil, partner: nil, logger: Rails.logger, trial_run: false)
  execute_flow(:execute_inventory_flow, orchestrator_name:, partner:, logger:, trial_run:)
end

.execute_listing_message_feed_flow(orchestrator_name: nil, partner: nil, logger: Rails.logger, trial_run: false) ⇒ Object

Executes the listing message flow for EDI orchestrators.

orchestrator_name - The name of a specific orchestrator to run, optional.
partner - The partner key to run for, optional.
logger - The logger to use.
trial_run - If true, will not send real requests.



185
186
187
# File 'app/services/edi/base_orchestrator.rb', line 185

def self.execute_listing_message_feed_flow(orchestrator_name: nil, partner: nil, logger: Rails.logger, trial_run: false)
  execute_flow(:execute_listing_message_feed_flow, orchestrator_name:, partner:, logger:, trial_run:)
end

.execute_order_flow(options = {}) ⇒ Object



261
262
263
264
265
# File 'app/services/edi/base_orchestrator.rb', line 261

def self.execute_order_flow(options = {})
  # Queue for Ship confirm what can be confirmed right away
  # Edi::ShipConfirm.new.process # NO MORE AUTO SHIP CONFIRM
  orchestrators(options).each(&:execute_order_flow)
end

.execute_price_flow(orchestrator_name: nil, partner: nil, logger: Rails.logger, trial_run: false) ⇒ Object

Executes the price flow for EDI orchestrators.

orchestrator_name - The name of a specific orchestrator to run, optional.
partner - The partner key to run for, optional.
logger - The logger to use.
trial_run - If true, will not send real requests.



175
176
177
# File 'app/services/edi/base_orchestrator.rb', line 175

def self.execute_price_flow(orchestrator_name: nil, partner: nil, logger: Rails.logger, trial_run: false)
  execute_flow(:execute_price_flow, orchestrator_name:, partner:, logger:, trial_run:)
end

.execute_product_data_flow(options = {}) ⇒ Object



328
329
330
# File 'app/services/edi/base_orchestrator.rb', line 328

def self.execute_product_data_flow(options = {})
  orchestrators(options).each(&:execute_product_data_flow)
end

.orchestrator_for_customer_id(customer_id, use_cache: true) ⇒ Object



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'app/services/edi/base_orchestrator.rb', line 105

def orchestrator_for_customer_id(customer_id, use_cache: true)
  # 041619 Ramie: match first on single customer id partners, then dig into multi customer partners
  # this is necessary to properly match Amazon Vendor Central vendor partners: there is a single EDI entity partner:
  # :amazon_vendor_central_direct_fulfillment (for both WAX7V and WAT4D)
  # but two single partners for vendors WAX7V and WAT4D:
  # :amazon_vendor_central_direct_fulfillment_us_WAX7V and
  # :amazon_vendor_central_direct_fulfillment_us_WAT4D
  # and we want to drill down to one of the correct single customer id partners above, not the multi customer :amazon_vendor_central_direct_fulfillment partner (which is really only set up for inventory)

  # Use the cached lookup map for O(1) partner key lookups instead of O(n) detect operations
  partner_config_key = customer_id_to_partner_key_map[customer_id]
  return unless partner_config_key

  use_cache ? cached_build(partner_config_key) : build(partner_config_key)
end

.orchestrators(options = {}) ⇒ Object



121
122
123
# File 'app/services/edi/base_orchestrator.rb', line 121

def orchestrators(options = {})
  partners.keys.map { |partner| new(partner, options) }
end

.partnersObject



25
26
27
# File 'app/services/edi/base_orchestrator.rb', line 25

def partners
  all_orchestrators_class.map(&:partners).reduce({}, :merge)
end

Instance Method Details

#confirm_outbound_processing?Boolean

By default we don't require a two stage processing (ready -> processing -> complete)

Returns:

  • (Boolean)


426
427
428
# File 'app/services/edi/base_orchestrator.rb', line 426

def confirm_outbound_processing?
  false
end

#customer(segment = nil) ⇒ Object



367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'app/services/edi/base_orchestrator.rb', line 367

def customer(segment = nil)
  raise 'Orchestrator requires a segment for this partner to determine customer' if customer_id.is_a?(Hash) && segment.nil?

  if segment.present? && customer_id.is_a?(Hash)
    segment_cust_id = customer_id[segment.to_s.downcase.to_sym]
    segment_cust_id = customer_id[segment.to_s.to_sym] if segment_cust_id.nil? # In case we use uppercase keys
    cust = Customer.where(id: segment_cust_id).first
  elsif customer_id
    cust = Customer.where(id: customer_id).first
  end
  return cust if cust

  raise 'Orchestrator is unable to find a customer'
end

#customer_catalogObject

Alias for Customer#catalog

Returns:

  • (Object)

    Customer#customer_catalog

See Also:



15
# File 'app/services/edi/base_orchestrator.rb', line 15

delegate :catalog, to: :customer, prefix: true

#customer_idsObject



388
389
390
391
392
393
394
395
396
397
398
# File 'app/services/edi/base_orchestrator.rb', line 388

def customer_ids
  if respond_to?(:customer_id)
    if customer_id.respond_to?(:values)
      customer_id.values.uniq
    else
      [customer_id]
    end
  else
    []
  end
end

#customersObject

Returns customers (or single customer) associated with an orchestrator
as an active relation



384
385
386
# File 'app/services/edi/base_orchestrator.rb', line 384

def customers
  Customer.where(id: customer_ids)
end

#execute_inventory_flowObject



336
337
338
# File 'app/services/edi/base_orchestrator.rb', line 336

def execute_inventory_flow
  # Implement me in subclass
end

#execute_order_flowObject



332
333
334
# File 'app/services/edi/base_orchestrator.rb', line 332

def execute_order_flow
  # Implement me in subclass
end

#execute_price_flowObject



340
341
342
# File 'app/services/edi/base_orchestrator.rb', line 340

def execute_price_flow
  # Implement me in subclass
end

#execute_product_data_flowObject



344
345
346
# File 'app/services/edi/base_orchestrator.rb', line 344

def execute_product_data_flow
  # Implement me in subclass
end

#ignore_back_ordersObject

By default back orders are not ignored



431
432
433
# File 'app/services/edi/base_orchestrator.rb', line 431

def ignore_back_orders
  false
end

#inventory_message_enabled?Boolean

Returns:

  • (Boolean)


285
286
287
# File 'app/services/edi/base_orchestrator.rb', line 285

def inventory_message_enabled?
  try(:inventory_message_enabled).to_b
end

#pending_discontinue_lifetimeObject



403
404
405
406
# File 'app/services/edi/base_orchestrator.rb', line 403

def pending_discontinue_lifetime
  val = try(:pending_discontinue_lifetime_duration)
  val.is_a?(ActiveSupport::Duration) ? val : DEFAULT_PENDING_DISCONTINUE_LIFETIME
end

#price_message_enabled?Boolean

Returns:

  • (Boolean)


277
278
279
# File 'app/services/edi/base_orchestrator.rb', line 277

def price_message_enabled?
  try(:price_message_enabled).to_b
end

#product_data_enabled?Boolean

Returns:

  • (Boolean)


281
282
283
# File 'app/services/edi/base_orchestrator.rb', line 281

def product_data_enabled?
  try(:product_data_enabled).to_b
end

#return_notification_message_enabled?Boolean

Default false for orchestrators that don't handle inbound return notifications
(inventory-only feeds, push-only partners). Returns-capable orchestrators
(Amazon, Walmart, Wayfair, …) override this. Prevents a NoMethodError when a
customer_id resolves to a non-returns orchestrator (AppSignal #6069).

Returns:

  • (Boolean)


293
294
295
# File 'app/services/edi/base_orchestrator.rb', line 293

def return_notification_message_enabled?
  try(:return_notification_message_enabled).to_b
end

#should_execute_flow?(flow) ⇒ Boolean

flow is in the format of execute_inventory_flow or execute_price_flow

Returns:

  • (Boolean)


299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'app/services/edi/base_orchestrator.rb', line 299

def should_execute_flow?(flow)
  flow_every_x_hour_sym = :"#{flow}_every_x_hour"

  return true unless respond_to? flow_every_x_hour_sym

  every_x_hour = send(flow_every_x_hour_sym).to_i
  current_hour = Time.current.hour

  if every_x_hour > 0 && every_x_hour <= 24
    # valid integral value, let's use it
    # warn if it's not exactly one of the recommended value, i.e. integral factors of 24
    unless RECOMMENDED_EXECUTE_FLOW_EVERY_X_HOUR.include?(every_x_hour)
      msg = "EDI #{self.class} partner: #{partner} has a #{flow_every_x_hour_sym} value of #{every_x_hour} which is not one of the recommended values: #{RECOMMENDED_EXECUTE_FLOW_EVERY_X_HOUR}, inventory may not be sent at exactly the desired frequency"
      ErrorReporting.warning(msg)
      Rails.logger.info(msg)
    end
    # but do go ahead and send per the every_x_hour integral value
    return true if current_hour.modulo(every_x_hour) == 0
  else
    # invalid value, error but do send it at least once a day as a fall back
    msg = "EDI #{self.class} partner: #{partner} has an invalid #{flow_every_x_hour_sym} value of #{every_x_hour}. Valid values are between #{RECOMMENDED_EXECUTE_FLOW_EVERY_X_HOUR.min} and #{RECOMMENDED_EXECUTE_FLOW_EVERY_X_HOUR.max}, as a failsafe fallback, inventory will only be sent once per day!"
    ErrorReporting.error(msg)
    Rails.logger.error(msg)
    return true if current_hour.modulo(24) == 0
  end

  false
end

#should_execute_order_flow?Boolean

Determines if the order flow should run

Returns:

  • (Boolean)


268
269
270
# File 'app/services/edi/base_orchestrator.rb', line 268

def should_execute_order_flow?
  true
end

#should_execute_product_data_flow?Boolean

Determines if the product data flow should run

Returns:

  • (Boolean)


273
274
275
# File 'app/services/edi/base_orchestrator.rb', line 273

def should_execute_product_data_flow?
  true
end

#test_mode?Boolean

Returns:

  • (Boolean)


363
364
365
# File 'app/services/edi/base_orchestrator.rb', line 363

def test_mode?
  Rails.env.development?
end