Class: Edi::Walmart::FeedSubmissionResultProcessor

Inherits:
BaseEdiService show all
Defined in:
app/services/edi/walmart/feed_submission_result_processor.rb

Overview

Service object: feed submission result processor.

Constant Summary collapse

INVENTORY_INGESTION_RETRY_LIMIT =

How many times a rejected inventory feed is resubmitted before it becomes an
exception. See #retry_rejected_inventory_feed.

1

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

#get_errors_from_feed_result(json_data) ⇒ Object



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
# File 'app/services/edi/walmart/feed_submission_result_processor.rb', line 105

def get_errors_from_feed_result(json_data)
  errs = []
  # see: oh wait there is no *&^%*^&% documentation!!!! Probably because this is a ridiculously stupid ^&%&^% implementation!
  # There's just this sample operation garbage-y response in postman (see my overview below and full details at the end)

  # errors: []
  # feedId:  <string>
  # feedStatus:  PROCESSED
  # ingestionErrors: {ingestionError: []}
  # itemsReceived: <integer>
  # itemsSucceeded:  <integer>
  # itemsFailed: <integer>
  # itemsProcessing: <integer>
  # offset:  <integer>
  # limit: <integer>
  # itemDetails: {itemIngestionStatus: []}

  hsh = JSON.parse(json_data).with_indifferent_access
  num_processed = hsh&.dig(:itemsReceived).to_i
  num_errors = hsh&.dig(:itemsFailed).to_i
  if num_processed > 0 && num_errors > 0
    # First deal with the &*^&*^ errors, I mean this is utter &^*&^& stupidity
    hsh&.dig(:errors)&.each do |err_hsh|
      errs << "Error code: #{err_hsh.dig(:code)}, field: #{err_hsh.dig(:field)}, description: #{err_hsh.dig(:description)}, info: #{err_hsh.dig(:info)}, severity: #{err_hsh.dig(:severity)}, category: #{err_hsh.dig(:category)}, component: #{err_hsh.dig(:component)}, type: #{err_hsh.dig(:type)}, serviceName: #{err_hsh.dig(:serviceName)}, gatewayErrorCategory: #{err_hsh.dig(:gatewayErrorCategory)}, causes: #{err_hsh.dig(:causes)}"
    end
    # Now deal with the &*^&*^ ingestion errors, more &^*&^& stupidity
    hsh&.dig(:ingestionErrors)&.dig(:ingestionError)&.each do |ing_err_hsh|
      errs << "Ingestion Error code: #{ing_err_hsh.dig(:code)}, type: #{ing_err_hsh.dig(:type)}, description: #{ing_err_hsh.dig(:description)}"
    end
    # Now deal with the &*^&*^ item details ingestion errors
    hsh&.dig(:itemDetails)&.dig(:itemIngestionStatus)&.each do |item_ing_status_hsh|
      next unless item_ing_status_hsh.dig(:ingestionStatus)&.upcase&.include?('ERROR')

      sku = item_ing_status_hsh.dig(:sku)
      item_ing_status_hsh.dig(:ingestionErrors)&.dig(:ingestionError)&.each do |item_ing_err_hsh|
        errs << "SKU: #{sku}, error code: #{item_ing_err_hsh.dig(:code)}, type: #{item_ing_err_hsh.dig(:type)}, description: #{item_ing_err_hsh.dig(:description)}"
      end
    end
  end
  errs
end

#instantiate_transporter(transporter, transporter_profile = nil) ⇒ Object



96
97
98
99
100
101
102
103
# File 'app/services/edi/walmart/feed_submission_result_processor.rb', line 96

def instantiate_transporter(transporter, transporter_profile = nil)
  case transporter
  when :http_walmart_seller_api
    Transport::HttpWalmartSellerApiConnection.new({ profile: transporter_profile }.merge(options))
  else
    raise "Unknown transporter: #{transporter}"
  end
end

#process(edi_communication_logs = nil) ⇒ Object



10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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
# File 'app/services/edi/walmart/feed_submission_result_processor.rb', line 10

def process(edi_communication_logs = nil)
  edi_communication_logs ||= EdiCommunicationLog
                             .where(state: 'processing')
                             .where(partner: orchestrator.partner)
                             .where.not(transaction_id: nil)
                             .order(:created_at)
  [edi_communication_logs].flatten.each do |ecl|
    logger.info "EdiCommunicationLog:#{ecl.id} - Retrieving API transaction result from #{orchestrator.partner} for transaction id: #{ecl.transaction_id}"
    begin
      transport = instantiate_transporter(orchestrator.transporter, orchestrator.transporter_profile)
      res = transport.send_data('', "#{orchestrator.feed_message_remote_path}/#{ecl.transaction_id}?includeDetails=true", 'GET') # with this, you only get the first 50 by default, see below (from Postman gobbledygook):
      # includeDetails: Includes details of each entity in the feed.
      # offset: The object response to start with, where 0 is the first entity that can be requested. It can only be used when includeDetails is set to true.
      # limit: The number of entities to be returned. It cannot be more than 50 entities. Use it only when the includeDetails is set to true.
      # Read the response body once (HTTP::Response::Body can only be consumed once)
      data = res[:http_result]&.body.to_s
      # Carry the submission's correlation id forward — this update replaces the
      # notes FeedMessageSender wrote, and that id is what a Walmart support case
      # needs. The poll's own id is recorded separately; they are different requests.
      submit_correlation_id = ecl.file_info.to_h['wm_correlation_id']
      ecl.update(notes: "HTTP CODE: #{res[:http_result]&.status}, HTTP BODY: #{data}, SUBMIT WM_QOS.CORRELATION_ID: #{submit_correlation_id || 'not recorded'}, POLL WM_QOS.CORRELATION_ID: #{res[:correlation_id]}, Timestamp: #{Time.current.to_datetime.to_fs(:crm_default)}")
      logger.info "Result: HTTP CODE: #{res[:http_result]&.status}, HTTP BODY: #{data}"
      if res[:success] && data.present?
        json_hash = JSON.parse(data).with_indifferent_access
        status = json_hash.dig('feedStatus')
        timeout_time = (ecl.transmit_datetime || Time.current) + orchestrator.failure_timeout_in_minutes.minutes
        # See: https://developer.walmart.com/us-marketplace/docs/feeds-overview
        if status == 'PROCESSED'
          upload = Upload.uploadify_from_data(file_name: "#{ecl.transaction_id}.json", data: data, category: 'feed_document_result_json')
          if upload
            ecl.uploads << upload
            if (errors = get_errors_from_feed_result(data)).empty?
              ecl.complete!
            else
              ecl.update(notes: [ecl.notes, "There were #{errors.size} errors reported in feed result: #{errors.join('| ')}"].compact.join(' || '))
              ecl.error
            end
          else
            ecl.update(notes: [ecl.notes, "Could not upload feed result #{ecl.transaction_id}"].compact.join(' | '))
            ecl.error
          end
        elsif Time.current > timeout_time
          ecl.update(notes: [ecl.notes, "Timed out after #{orchestrator.failure_timeout_in_minutes} minutes"].compact.join(' | '))
          ecl.error!
        elsif %w[ERROR].include?(status)
          ecl.error! unless retry_rejected_inventory_feed(ecl, json_hash)
        end
      else
        ecl.error
      end
    end
  end
end

#retry_rejected_inventory_feed(ecl, result) ⇒ Boolean

Walmart rejects a small share of structurally valid inventory feeds with
itemsReceived 0 (ERR_EXT_DATA_0503009, ERR_PDI_0001). It is their bug, not our
payload: the byte-identical feed is accepted on resubmission — CA md5 D7C2D442E4
processed 86/86 nine times on 2026-07-29 and was rejected once at 12:00. At the
~11% rejection rate seen since 2026-07-28 a single retry takes a run's odds from
89% to ~99%, instead of leaving inventory stale until the next hourly flow.

Inventory only, and once per feed — a feed Walmart rejects twice is a real
problem and belongs in the ediadmin mail, not in a resubmission loop.

Parameters:

  • ecl (EdiCommunicationLog)

    the feed Walmart reported ERROR for

  • result (ActiveSupport::HashWithIndifferentAccess)

    the parsed feed-status body

Returns:

  • (Boolean)

    true when the feed was resubmitted, false to fall through to #error!



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'app/services/edi/walmart/feed_submission_result_processor.rb', line 77

def retry_rejected_inventory_feed(ecl, result)
  return false unless ecl.category == 'inventory_advice'
  # itemsReceived > 0 means Walmart parsed the feed and rejected rows on their
  # merits — resending the same rows would just fail the same way.
  return false unless result[:itemsReceived].to_i.zero?

  attempts = ecl.file_info.to_h['ingestion_retries'].to_i
  return false if attempts >= INVENTORY_INGESTION_RETRY_LIMIT

  logger.warn "[Walmart FeedResult] EdiCommunicationLog:#{ecl.id} rejected with itemsReceived 0, resubmitting (attempt #{attempts + 1}/#{INVENTORY_INGESTION_RETRY_LIMIT})"
  ecl.file_info = ecl.file_info.to_h.merge('ingestion_retries' => attempts + 1)
  # next_attempt so the row is still picked up by `requiring_processing` if the
  # inline resubmission below dies before it can transition the record.
  ecl.next_attempt = Time.current
  ecl.schedule_retry!
  orchestrator.inventory_message_sender.process(ecl)
  true
end