Class: Edi::Walmart::ReturnsReportProcessor

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

Overview

Service object: returns report processor.

Applies the Walmart Returns API payload (category +return_batch+, landed
by ReturnsReportRetriever) to our RMAs:

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

Rows are matched by the line's +purchaseOrderId+ (falling back to the
return order's +customerOrderId+) against +orders.edi_po_number+ (Walmart
order ingestion stores the purchaseOrderId there), then to the order's
most recent OPEN RMA (Rma::RETURN_OPEN_STATES), falling back to the
most recent RMA of any state so late-arriving returns still land their
note somewhere sensible.

Defined Under Namespace

Classes: Result

Constant Summary collapse

FIELD_MAP =

Walmart v3 returns payload (GET /v3/returns) => canonical row keys.
Order-level keys are read from each returnOrder, line-level keys from
each of its returnOrderLines; every (returnOrder, line) pair is one row.
Nested line fields (+item.sku+, the status trio) are dig-resolved in
#parse_rows and documented here:
item.sku => :seller_sku
status => :status
currentDeliveryStatus => :delivery_status
currentRefundStatus => :refund_status

{
  order: {
    'returnOrderId' => :return_order_id,
    'customerOrderId' => :customer_order_id,
    'returnOrderDate' => :return_order_date
  },
  line: {
    'purchaseOrderId' => :purchase_order_id,
    'returnOrderLineNumber' => :line_number,
    'returnReason' => :return_reason,
    'returnDescription' => :return_description
  }
}.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 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

#apply_row(row, counts) ⇒ void

This method returns an undefined value.

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

Parameters:

  • row (Hash)

    canonical row keys from FIELD_MAP.

  • counts (Hash)

    the running counters, mutated in place.



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
161
162
163
164
165
# File 'app/services/edi/walmart/returns_report_processor.rb', line 127

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 Walmart order id #{row[:order_id].inspect} (return order #{row[:return_order_id]})"
    return
  end
  rma = find_rma(order)
  unless rma
    counts[:rows_unmatched] += 1
    logger.warn "Returns report row unmatched — order #{order.reference_number} has no RMA (return order #{row[:return_order_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 line_already_noted?(rma, row)
    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.



73
74
75
76
77
78
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
# File 'app/services/edi/walmart/returns_report_processor.rb', line 73

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::Walmart::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.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 JSON payload and applies every return line. Public for
testing; callers normally go through #process.

Parameters:

  • data (String)

    the Returns API JSON payload.

Returns:

  • (Result)

    the counters for this payload.



112
113
114
115
116
117
118
119
# File 'app/services/edi/walmart/returns_report_processor.rb', line 112

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