Class: Edi::Commercehub::InventoryMessageProcessor

Inherits:
BaseEdiService show all
Defined in:
app/services/edi/commercehub/inventory_message_processor.rb

Overview

Service object: inventory message processor.

Constant Summary collapse

NO_PRODUCT_LEVEL_NEXT_AVAILABLE =

thdca/inventory.xsd declares no product-level next_available_date/_qty — only
the warehouse-level <next_available> element. The other three partners declare
them (rona/costco optional, thehomedepot "Required if quantity of zero is
submitted"). See .agents/skills/commercehub-inventory-feed/SKILL.md.

%w[thdca].freeze

Constants included from RequestIdentifiable

RequestIdentifiable::REQUEST_ID_HEADERS

Constants included from AddressAbbreviator

AddressAbbreviator::MAX_LENGTH

Instance Attribute Summary

Attributes inherited from BaseEdiService

#orchestrator

Attributes inherited from BaseService

#options

Instance Method Summary collapse

Methods inherited from BaseEdiService

#amazon_feed_product_type, #duplicate_po_already_notified?, #initialize, #mark_duplicate_po_as_notified, #onboard_ordered_catalog_items, #report_order_creation_issues, #safe_process_edi_communication_log

Methods included from RequestIdentifiable

#partner_request_id

Methods included from AddressAbbreviator

#abbreviate_street, #collect_street_originals, #record_address_abbreviation_notes

Methods inherited from BaseService

#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #tagged_logger

Constructor Details

This class inherits a constructor from Edi::BaseEdiService

Instance Method Details

#append_catalog_items(xml, catalog_items) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'app/services/edi/commercehub/inventory_message_processor.rb', line 79

def append_catalog_items(xml, catalog_items)
  catalog_items.each do |ci|
    ErrorReporting.scoped(catalog_item_id: ci.id, partner: orchestrator.partner) do
      discontinued = ci.discontinued? || ci.pending_discontinue? || ci.in_hide_from_feed_state?
      merchant_sku = ci.third_party_part_number
      merchant_sku ||= ci.reported_vendor_sku if %w[costco walmartca].include?(orchestrator.ch_partner_id) # report something if this is missing because Costco, Walmart require this
      next if merchant_sku.blank? && %w[thdca thehomedepot].include?(orchestrator.ch_partner_id) # If merchant sku is missing this will error out for these channels, this is a patch RB please review

      xml.send(:product) do
        xml.send(:vendor_SKU, ci.reported_vendor_sku)
        future_stocks = {}
        if discontinued
          if %w[thehomedepot thdca walmartca].include?(orchestrator.ch_partner_id)
            # these partners only support YES or NO for available types, so set available = 'No' and total_available = 0
            available = 'No'
            total_available = 0
            stocks = {}
          elsif %w[rona costco].include?(orchestrator.ch_partner_id)
            # these partners support YES, NO, DISCONTINUED and DELETED for available types, so set available and total_available based on specific criteria
            # get actual stock, which we will pass on for NO and DISCONTINUED availibility
            stocks = ci.reported_stocks(use_alternate_warehouse: false)
            total_available = stocks.values.sum
            if ci.in_hide_from_feed_state?
              available = 'No'
            elsif ci.pending_discontinue?
              available = 'Discontinued'
            elsif ci.discontinued?
              available = 'Deleted'
              total_available = 0 # set stock 0 for DELETED items
            end
          end
          xml.send(:qtyonhand, total_available)
          xml.send(:available, available)
          xml.send(:discontinued_date, ci.discontinued_date&.strftime('%Y%m%d') || Date.current.strftime('%Y%m%d'))
        else
          stocks = ci.reported_stocks(use_alternate_warehouse: false)
          total_available = stocks.values.sum
          xml.send(:qtyonhand, total_available)
          xml.send(:available, total_available.positive? ? 'Yes' : 'No')
          # Rithum pairs next_available with a zero quantity ONLY — there is no
          # low-stock case anywhere in their model. The pre-2026 threshold was an
          # undocumented `< 10` (bumped from `< 5` in 98fc0348da, "total available")
          # which fired on the majority of the CA catalogues.
          future_stocks = next_available_by_warehouse(ci) if total_available < 1
          if future_stocks.present? && NO_PRODUCT_LEVEL_NEXT_AVAILABLE.exclude?(orchestrator.ch_partner_id)
            # thehomedepot/inventory.xsd on next_available_qty: "this value must be
            # equal to the sum of the Next Ship Quantity value from those warehouse
            # records". The date is the earliest across warehouses — %Y%m%d sorts
            # lexicographically, so #min is chronological.
            xml.send(:next_available_date, future_stocks.values.pluck(:next_available_date).min)
            xml.send(:next_available_qty, future_stocks.values.sum { |f| f[:next_available_qty] })
          end
        end
        xml.send(:description, ci.reported_name)
        xml.send(:unitOfMeasure, 'EA')
        xml.send(:merchantSKU, merchant_sku) if merchant_sku.present?
        xml.send(:UPC, ci.item.upc) if ci.item.upc.present?

        xml.send(:manufacturer_SKU, ci.reported_vendor_sku)
        if orchestrator.warehouse_id.present?
          xml.send(:warehouseBreakout) do
            orchestrator.warehouse_id.each do |ch_warehouse_name, wy_warehouse_name|
              xml.send(:warehouse, 'warehouse-id': ch_warehouse_name) do
                xml.send(:qtyonhand, stocks[wy_warehouse_name] || 0)
                if (warehouse_stock_data = future_stocks[wy_warehouse_name]).present?
                  # All four partner schemas declare <next_available> carrying date
                  # and quantity as ATTRIBUTES, never as child elements.
                  xml.send(:next_available, date: warehouse_stock_data[:next_available_date],
                                            quantity: warehouse_stock_data[:next_available_qty])
                end
              end
            end
          end
        end
      end
    rescue StandardError => e
      logger.error "Error building inventory for catalog item #{ci.id} partner #{orchestrator.partner}: #{e.message}"
      ErrorReporting.error(e, catalog_item_id: ci.id, partner: orchestrator.partner)
    end
  end
  xml
end

#build_xml(catalog_items: nil, states: nil) ⇒ Object

rubocop:disable Lint/UnusedMethodArgument



65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'app/services/edi/commercehub/inventory_message_processor.rb', line 65

def build_xml(catalog_items: nil, states: nil) # rubocop:disable Lint/UnusedMethodArgument
  logger.info "#{catalog_items.size} items in inventory payload"
  b = Nokogiri::XML::Builder.new do |xml|
    xml.send(:advice_file) do
      xml.send(:advice_file_control_number, 0)
      xml.send(:vendor, 'warmlyyours')
      xml.send(:vendorMerchID, orchestrator.ch_partner_id)
      append_catalog_items(xml, catalog_items)
      xml.send(:messageCount, catalog_items.size)
    end
  end
  b.to_xml
end

#load_catalog_items(states: nil) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'app/services/edi/commercehub/inventory_message_processor.rb', line 31

def load_catalog_items(states: nil)
  # CommerceHub is the one orchestrator that feeds `pending_onboarding`
  # (hence {CatalogItem::EDI_FEED_STATUSES} rather than ORCHESTRATOR_STATES):
  # the merchant assigns the SKU up front, so an item sitting in that state
  # WITH a merchant SKU is one they already list and sell. Starving it of
  # stock updates leaves the retailer selling from a stale number — Rona sold
  # TRT120-KIT-OP-3.0x10 two months after we stopped feeding it. Items with no
  # merchant SKU are genuinely not listed yet and are filtered out below.
  # Wayfair/Amazon/Walmart items in `pending_onboarding` have no live listing
  # at all, so feeding them would only generate rejections — those processors
  # keep the narrower default.
  states ||= CatalogItem::EDI_FEED_STATUSES
  catalog_item_ids = []
  orchestrator.customers.each do |customer|
    scope = customer.catalog.catalog_items
    catalog_items = scope.where(state: states)
    # A pending_onboarding item with no merchant SKU is genuinely not listed
    # yet — the retailer has nothing to key the advice on.
    catalog_items = catalog_items.where.not(id: scope.where(state: 'pending_onboarding', third_party_part_number: [nil, '']))
    # NOT #not_hidden_from_catalog: pending_onboarding is a HIDDEN_STATE, so
    # that scope would cancel the widened `states` above. Every other hidden
    # state (active_hidden, discontinued, …) stays excluded.
    catalog_items = catalog_items.where.not(state: CatalogItem::HIDDEN_STATES - %w[pending_onboarding])
    # When our catalog requires third party part number, do not grab those catalog items without one
    catalog_items = catalog_items.where.not(third_party_part_number: nil) if customer.catalog.third_party_part_number_required
    catalog_items = catalog_items.where('third_party_sku ~ ?', customer.catalog.third_party_sku_filter_regex) if customer.catalog.is_active_third_party_sku_filter
    catalog_item_ids += catalog_items.ids
  end
  CatalogItem.where(id: catalog_item_ids.uniq)
             .with_item
             .eager_load(:store_item, :item)
             .order(Item[:sku])
end

#next_available_by_warehouse(catalog_item) ⇒ Object

Warehouse name => { next_available_date: 'YYYYMMDD', next_available_qty: Integer }
for each warehouse with an open replenishment order. Warehouses with nothing on
order are omitted, so an empty hash means "nothing incoming anywhere".



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'app/services/edi/commercehub/inventory_message_processor.rb', line 165

def next_available_by_warehouse(catalog_item)
  catalog_item
    .next_available_by_warehouse_with_depth_limit(use_alternate_warehouse: true, max_depth: 10)
    .each_with_object({}) do |(warehouse_name, on_order_data), acc|
      next unless on_order_data&.next_available_date

      acc[warehouse_name] = {
        next_available_date: on_order_data.next_available_date.strftime('%Y%m%d'),
        next_available_qty: on_order_data.next_available_qty.to_i
      }
    end
rescue SystemStackError => e
  # Circular kit reference. max_depth already returns nil rather than raising, so
  # this is a backstop only. Report and send nothing — the elements are optional
  # at a positive quantity, and inventing a date misinforms the retailer.
  ErrorReporting.error(e,
    catalog_item_id: catalog_item.id,
    partner: orchestrator.partner,
    catalog_item_sku: catalog_item.item&.sku,
    error_type: 'stack_level_too_deep',
    message: 'Infinite recursion detected in next_available_by_warehouse method')
  {}
end

#process(catalog_items: nil, states: nil) ⇒ Object



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# File 'app/services/edi/commercehub/inventory_message_processor.rb', line 12

def process(catalog_items: nil, states: nil)
  ecl = nil
  EdiCommunicationLog.transaction do
    logger.info "Creating inventory advice for partner #{orchestrator.partner}"
    catalog_items ||= load_catalog_items(states: states)
    data_xml = build_xml(catalog_items: [catalog_items].flatten, states: states)
    ecl = EdiCommunicationLog.create_outbound_file_from_data(
      data: data_xml,
      file_extension: 'inv',
      partner: orchestrator.partner,
      category: 'inventory_advice',
      resources: catalog_items,
      data_type: 'xml',
      file_info: {}
    )
  end
  ecl
end