Class: Edi::Amazon::ReturnsReportProcessor

Inherits:
BaseEdiService show all
Defined in:
app/services/edi/amazon/returns_report_processor.rb

Overview

Service object: returns report processor.

Applies the Seller Central returns flat file (category +return_batch+,
landed by ReturnsReportRetriever) to our RMAs:

  1. The retailer-stated return reason is recorded as ONE deduped note
    activity per (RMA, Amazon RMA ID) on the matching RMA, so staff see why
    Amazon says the customer sent it back without opening Seller Central.
  2. Open RMA items still carrying the placeholder +TBD+ reason are upgraded
    to the internal reason code mapped from the Amazon reason via
    RmaReasonCode.find_by_alias (aliases seeded from Amazon's
    return-reason vocabulary). A non-TBD reason is never changed, and RMAs
    are never created — Amazon-side returns without a matching order/RMA
    are counted as unmatched and warn-logged.

Rows are matched by the report's "Order ID" against +orders.edi_po_number+
(Amazon order ingestion stores the AmazonOrderId there). When Amazon
supplies "Merchant RMA ID", that internal RMA number is authoritative only
after verifying it belongs to the reported order or an RMA-generated
replacement-order descendant. Missing or unrelated Merchant RMA IDs are
quarantined without side effects. Older report shapes without that field
retain the most-recent-open-RMA fallback.

Defined Under Namespace

Classes: Result, RmaMatch

Constant Summary collapse

HEADER_MAP =

Normalized report header => canonical row key. Normalization strips
everything non-alphanumeric and downcases, so header variants (extra
spaces, different casing) all land on the same key.

{
  'returnrequestdate' => :return_request_date,
  'orderid' => :order_id,
  'sellersku' => :seller_sku,
  'merchantsku' => :seller_sku, # live reports use "Merchant SKU"
  'asin' => :asin,
  'returnreason' => :return_reason,
  'amazonrmaid' => :amazon_rma_id,
  'merchantrmaid' => :merchant_rma_id,
  'returnquantity' => :return_quantity,
  'returnrequeststatus' => :status,
  'returncarrier' => :carrier,
  'trackingid' => :tracking_id,
  'returndeliverydate' => :return_delivery_date
}.freeze
PLACEHOLDER_REASON =

The placeholder reason code assigned when a return is keyed in before
the retailer's reason is known (rma.rb keys new items with 'TBD').

'TBD'

Constants included from RequestIdentifiable

RequestIdentifiable::REQUEST_ID_HEADERS

Constants included from Edi::AddressAbbreviator

Edi::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 Edi::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

#apply_row(row, counts) ⇒ void

This method returns an undefined value.

Applies one report row: locate the order and RMA, write the deduped
reason note, upgrade a placeholder-reasoned item when mappable.

Parameters:

  • row (Hash)

    canonical row keys from HEADER_MAP.

  • counts (Hash)

    the running counters, mutated in place.



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'app/services/edi/amazon/returns_report_processor.rb', line 151

def apply_row(row, counts)
  order = find_order(row[:order_id])
  unless order
    counts[:rows_unmatched] += 1
    logger.warn "Returns report row unmatched — no order for Amazon order id #{row[:order_id].inspect} (Amazon RMA #{row[:amazon_rma_id]})"
    return
  end
  rma_match = resolve_rma(order, row)
  if rma_match.quarantine
    counts[:rows_quarantined] += 1
    counts[:quarantined_rows] << rma_match.quarantine
    logger.warn "Returns report row quarantined — #{rma_match.quarantine.inspect}"
    return
  end
  rma = rma_match.rma
  unless rma
    counts[:rows_unmatched] += 1
    logger.warn "Returns report row unmatched — order #{order.reference_number} has no RMA (Amazon RMA #{row[:amazon_rma_id]})"
    return
  end
  counts[:rows_matched] += 1

  item = match_item(rma, row[:seller_sku])
  upgrade = upgrade_for(item, row[:return_reason])
  note = build_note(row, upgrade)
  if note_already_recorded?(rma, note)
    counts[:duplicates_skipped] += 1
    return
  end

  if upgrade
    # update_column, not update!: legacy RmaItem rows can be invalid by
    # current rules (e.g. blank returned_item_location, which predates the
    # presence validation at rma_item.rb:159) and a full save would raise
    # on fields we are not touching. This upgrade only ever corrects the
    # reason code; the activity note below is the audit trail. (AppSignal
    # #1358 — a blank-location legacy row aborted a whole return_batch log.)
    item.update_column(:returned_reason, upgrade[:code])
    counts[:items_upgraded] += 1
  end
  # create!, not create: a note that fails validation must raise so the
  # per-log rescue marks the log exception and retries — a silently lost
  # note with notes_written += 1 and a completed log is unrecoverable.
  rma.activities.create!(notes: note)
  counts[:notes_written] += 1
end

#process(edi_logs = nil) ⇒ Array<Result>

Picks up the +return_batch+ logs in the queue ready to process and
applies each to our RMAs. Follows the canonical processor pattern: one
transaction per log, +complete!+ on success, +error+ + AppSignal report
on exception.

Parameters:

Returns:

  • (Array<Result>)

    per-log counters.



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
# File 'app/services/edi/amazon/returns_report_processor.rb', line 87

def process(edi_logs = nil)
  # Pick up queued logs AND prior exceptions: the daily retriever run
  # re-invokes this processor, so a transient failure self-heals on the
  # next day instead of dropping out of the queue (requiring_processing
  # only selects ready/retry). Bounded to once/day by the flow gate.
  edi_logs ||= EdiCommunicationLog.where(partner: orchestrator.partner, category: 'return_batch', state: %w[ready retry exception]).order(:created_at)
  edi_logs = [edi_logs].flatten
  results = []
  # System-actor whodunnit for the audit trail: reason upgrades and note
  # writes made by this ingest must name the processor, not NULL.
  PaperTrail.request(whodunnit: 'Edi::Amazon::ReturnsReportProcessor') do
    edi_logs.each do |edi_log|
      log_info "Starting processing edi communication log #{edi_log.id}"
      ErrorReporting.scoped({ edi_log_id: edi_log.id }) do
        EdiCommunicationLog.transaction do
          result = process_rows(edi_log.data)
          edi_log.file_info ||= {}
          edi_log.file_info[:returns_report] = result.to_h
          edi_log.complete!
          results << result
          log_info "Returns report edi log #{edi_log.id}: #{result.rows_processed} rows, #{result.rows_matched} matched, " \
                   "#{result.rows_unmatched} unmatched, #{result.rows_quarantined} quarantined, #{result.items_upgraded} items upgraded, " \
                   "#{result.notes_written} notes written, " \
                   "#{result.duplicates_skipped} duplicates skipped"
        end
      end
    rescue StandardError => e
      edi_log.notes = "#{e} at #{e&.backtrace_locations&.first} for edi log #{edi_log.id}"
      edi_log.error
      ErrorReporting.error(e, edi_communication_log_id: edi_log.id)
    end
  end
  results
end

#process_rows(data) ⇒ Result

Parses the TSV header-first and applies every row. Public for testing;
callers normally go through #process.

Parameters:

  • data (String)

    the flat-file TSV payload.

Returns:

  • (Result)

    the counters for this payload.



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'app/services/edi/amazon/returns_report_processor.rb', line 127

def process_rows(data)
  counts = {
    rows_processed: 0,
    rows_matched: 0,
    rows_unmatched: 0,
    rows_quarantined: 0,
    items_upgraded: 0,
    notes_written: 0,
    duplicates_skipped: 0,
    quarantined_rows: []
  }
  parse_rows(data).each do |row|
    counts[:rows_processed] += 1
    apply_row(row, counts)
  end
  Result.new(**counts)
end