Class: Edi::Amazon::ReturnsReportRetriever
- Inherits:
-
BaseEdiService
- Object
- BaseService
- BaseEdiService
- Edi::Amazon::ReturnsReportRetriever
- Defined in:
- app/services/edi/amazon/returns_report_retriever.rb
Overview
Service object: returns report retriever.
Pulls Amazon Seller Central customer-return data via the SP-API Reports
API (+GET_FLAT_FILE_RETURNS_DATA_BY_RETURN_DATE+) for a date window and
lands the flat-file TSV on an +EdiCommunicationLog+ (category
+return_batch+) for ReturnsReportProcessor to apply.
The flow is deliberately synchronous — createReport, poll getReport until
DONE, getReportDocument, download — and NOT the REPORT_PROCESSING_FINISHED
SQS notification path: that notification fires for every report ANY
authorized app runs on the seller account with no requester field to
correlate on (see lib/tasks/amazon_notifications.rake).
Constant Summary collapse
- REPORT_TYPE =
The SP-API report type for customer returns by return date.
'GET_FLAT_FILE_RETURNS_DATA_BY_RETURN_DATE'- REPORTS_API_VERSION =
Reports API version segment.
'2021-06-30'- MAX_POLL_ATTEMPTS =
getReport polling budget: 10 x 30s = 5 minutes worst case before giving up.
10- POLL_INTERVAL_SECONDS =
30
Constants included from RequestIdentifiable
RequestIdentifiable::REQUEST_ID_HEADERS
Constants included from Edi::AddressAbbreviator
Edi::AddressAbbreviator::MAX_LENGTH
Instance Attribute Summary
Attributes inherited from BaseEdiService
Attributes inherited from BaseService
Instance Method Summary collapse
-
#api_host ⇒ String
The SP-API host from the orchestrator's transport profile.
-
#create_report(transport, start_time, end_time) ⇒ String?
Requests report generation from the Reports API.
-
#documents_url ⇒ String
The getReportDocument endpoint base.
-
#download_document(transport, document_id) ⇒ String?
Fetches the document's pre-signed URL via getReportDocument and downloads (and, when Amazon says so, gunzips) the flat file.
- #instantiate_transporter(transporter, transporter_profile = nil) ⇒ Object
-
#poll_for_document(transport, report_id) ⇒ String, ...
Polls getReport until the report reaches a terminal state, bounded by MAX_POLL_ATTEMPTS x POLL_INTERVAL_SECONDS.
-
#process(start_time: nil, end_time: nil) ⇒ Array<EdiCommunicationLog>?
Retrieves the returns report for the window and stores it as an ECL.
-
#reports_url ⇒ String
The createReport/getReport endpoint base.
-
#resolve_window(start_time, end_time) ⇒ Array(Time, Time)
Resolves the report window, defaulting to the previous day (UTC).
-
#sp_api_payload(res) ⇒ HashWithIndifferentAccess
Parses a Reports API response body.
-
#store_log(tsv, file_name, start_time, end_time, report_id, document_id) ⇒ EdiCommunicationLog
Lands the TSV on an +EdiCommunicationLog+ for the processor to pick up.
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
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
#api_host ⇒ String
Returns the SP-API host from the orchestrator's transport profile.
227 228 229 |
# File 'app/services/edi/amazon/returns_report_retriever.rb', line 227 def api_host Heatwave::Configuration.fetch(orchestrator.transporter_profile&.to_sym, :api_host) end |
#create_report(transport, start_time, end_time) ⇒ String?
Requests report generation from the Reports API.
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'app/services/edi/amazon/returns_report_retriever.rb', line 97 def create_report(transport, start_time, end_time) body = { reportType: REPORT_TYPE, marketplaceIds: [orchestrator.marketplace], dataStartTime: start_time.utc.iso8601, dataEndTime: end_time.utc.iso8601 }.to_json res = transport.send_data(body, reports_url, 'POST', { 'content-type' => 'application/json' }) report_id = res[:success] ? sp_api_payload(res).dig(:reportId) : nil logger.warn "createReport for #{REPORT_TYPE} failed for #{orchestrator.partner}: #{res[:http_result]&.body}" if report_id.blank? report_id.presence rescue HTTP::RateLimitExceededError => e logger.warn "Amazon SP-API rate limited on createReport for #{orchestrator.partner}. Skipping run; next scheduled run will retry. #{e.}" nil end |
#documents_url ⇒ String
Returns the getReportDocument endpoint base.
208 209 210 |
# File 'app/services/edi/amazon/returns_report_retriever.rb', line 208 def documents_url "https://#{api_host}/reports/#{REPORTS_API_VERSION}/documents" end |
#download_document(transport, document_id) ⇒ String?
Fetches the document's pre-signed URL via getReportDocument and
downloads (and, when Amazon says so, gunzips) the flat file.
156 157 158 159 160 161 162 163 164 165 166 |
# File 'app/services/edi/amazon/returns_report_retriever.rb', line 156 def download_document(transport, document_id) res = transport.send_data('', "#{documents_url}/#{document_id}", 'GET') payload = res[:success] ? sp_api_payload(res) : {} url = payload[:url] if url.blank? logger.warn "getReportDocument #{document_id} failed: #{res[:http_result]&.body}" return nil end body = Faraday.get(url).body payload[:compressionAlgorithm] == 'GZIP' ? ActiveSupport::Gzip.decompress(body) : body end |
#instantiate_transporter(transporter, transporter_profile = nil) ⇒ Object
231 232 233 234 235 236 237 238 |
# File 'app/services/edi/amazon/returns_report_retriever.rb', line 231 def instantiate_transporter(transporter, transporter_profile = nil) case transporter when :http_seller_api Transport::HttpSellerApiConnection.new({ profile: transporter_profile }) else raise "Unknown transporter: #{transporter}" end end |
#poll_for_document(transport, report_id) ⇒ String, ...
Polls getReport until the report reaches a terminal state, bounded by
MAX_POLL_ATTEMPTS x POLL_INTERVAL_SECONDS.
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 |
# File 'app/services/edi/amazon/returns_report_retriever.rb', line 122 def poll_for_document(transport, report_id) MAX_POLL_ATTEMPTS.times do |attempt| res = transport.send_data('', "#{reports_url}/#{report_id}", 'GET') payload = res[:success] ? sp_api_payload(res) : {} status = payload[:processingStatus] if status == 'DONE' document_id = payload[:reportDocumentId].presence logger.info "Returns report #{report_id} DONE with no document — no returns in window" unless document_id return document_id elsif status == 'FATAL' # FATAL is a report-generation failure — retryable, but re-polling # THIS report is pointless; the caller drops it and starts fresh. logger.warn "Returns report #{report_id} finished FATAL; will retry with a new report next run" return :fatal elsif status == 'CANCELLED' logger.warn "Returns report #{report_id} finished CANCELLED; nothing to import" return nil end logger.debug { "Returns report #{report_id} status #{status || 'unknown'} (attempt #{attempt + 1}/#{MAX_POLL_ATTEMPTS})" } sleep(POLL_INTERVAL_SECONDS) end logger.warn "Returns report #{report_id} not DONE after #{MAX_POLL_ATTEMPTS} polls; giving up (next run will retry the window)" :failed rescue HTTP::RateLimitExceededError => e logger.warn "Amazon SP-API rate limited polling getReport #{report_id}. Giving up this run; next run will retry the window. #{e.}" :failed end |
#process(start_time: nil, end_time: nil) ⇒ Array<EdiCommunicationLog>?
Retrieves the returns report for the window and stores it as an ECL.
Idempotent per partner + window: the ECL file_name is deterministic
(+returns_YYYY-MM-DD_YYYY-MM-DD.tsv+), so a re-run over the same window
returns the already-stored log instead of duplicating it.
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 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'app/services/edi/amazon/returns_report_retriever.rb', line 39 def process(start_time: nil, end_time: nil) start_time, end_time = resolve_window(start_time, end_time) file_name = "returns_#{start_time.to_date.iso8601}_#{end_time.to_date.iso8601}.tsv" if (existing = EdiCommunicationLog.find_by(partner: orchestrator.partner, category: 'return_batch', file_name: file_name)) logger.info "#{file_name} already stored as edi log #{existing.id}; skipping" return [existing] end transport = instantiate_transporter(orchestrator.transporter, orchestrator.transporter_profile) # createReport has no idempotency key: after a mid-flow failure the # retry must resume polling the SAME report, not submit another one # for the same window (Amazon happily generates duplicates). pending_report_key = "edi/amazon/returns_report/pending_report/#{orchestrator.partner}/#{file_name}" report_id = Rails.cache.read(pending_report_key) || create_report(transport, start_time, end_time) return nil unless report_id Rails.cache.write(pending_report_key, report_id, expires_in: 24.hours) document_id = poll_for_document(transport, report_id) if document_id == :fatal # dead report — next run starts a fresh one Rails.cache.delete(pending_report_key) return nil end return nil if document_id == :failed # timeout/rate-limit — resume polling this report next run Rails.cache.delete(pending_report_key) if document_id.nil? # CANCELLED/DONE-no-document is terminal return [] if document_id.nil? # valid empty window tsv = download_document(transport, document_id) return nil if tsv.blank? Rails.cache.delete(pending_report_key) [store_log(tsv, file_name, start_time, end_time, report_id, document_id)] rescue StandardError => e # Transport errors (timeouts, connection failures) and store failures # must also leave the window retryable, not escape as an unhandled # exception — the caller only pins/gates on a nil result. logger.warn "Returns retrieval failed for #{orchestrator.partner} (#{file_name}): #{e.class}: #{e.} — leaving the window retryable" nil end |
#reports_url ⇒ String
Returns the createReport/getReport endpoint base.
203 204 205 |
# File 'app/services/edi/amazon/returns_report_retriever.rb', line 203 def reports_url "https://#{api_host}/reports/#{REPORTS_API_VERSION}/reports" end |
#resolve_window(start_time, end_time) ⇒ Array(Time, Time)
Resolves the report window, defaulting to the previous day (UTC).
84 85 86 87 88 |
# File 'app/services/edi/amazon/returns_report_retriever.rb', line 84 def resolve_window(start_time, end_time) start_time = (start_time.presence || 1.day.ago.beginning_of_day).to_time end_time = (end_time.presence || start_time.end_of_day).to_time [start_time, end_time] end |
#sp_api_payload(res) ⇒ HashWithIndifferentAccess
Parses a Reports API response body. The 2021-06-30 Reports API returns
its fields top-level ("..."); older SP-API endpoints wrap
them in a +payload+ key, which we tolerate for safety.
219 220 221 222 223 224 |
# File 'app/services/edi/amazon/returns_report_retriever.rb', line 219 def sp_api_payload(res) parsed = JSON.parse(res[:http_result].body.to_s).with_indifferent_access parsed[:payload].presence || parsed rescue JSON::ParserError {} end |
#store_log(tsv, file_name, start_time, end_time, report_id, document_id) ⇒ EdiCommunicationLog
Lands the TSV on an +EdiCommunicationLog+ for the processor to pick up.
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 |
# File 'app/services/edi/amazon/returns_report_retriever.rb', line 177 def store_log(tsv, file_name, start_time, end_time, report_id, document_id) edi_log = EdiCommunicationLog.create!( partner: orchestrator.partner, category: 'return_batch', data: tsv.encode('UTF-8', invalid: :replace, replace: ''), data_type: 'csv', file_name: file_name, file_info: { report_id: report_id, report_document_id: document_id, window_start: start_time.utc.iso8601, window_end: end_time.utc.iso8601 }, transmit_datetime: Time.current ) logger.info "#{file_name} saved to edi log #{edi_log.id}" edi_log rescue ActiveRecord::RecordNotUnique # A concurrent run stored this window first (partial unique index # idx_ecl_return_batch_partner_file_name_uniq) — reuse it. If the # winner vanished in between, re-raise the original RecordNotUnique # rather than masking it as RecordNotFound. EdiCommunicationLog.find_by(partner: orchestrator.partner, category: 'return_batch', file_name: file_name) || raise end |