Class: Edi::Wayfair::CatalogReadV2Retriever

Inherits:
BaseEdiService show all
Includes:
CatalogApiTransport
Defined in:
app/services/edi/wayfair/catalog_read_v2_retriever.rb

Overview

Pulls Wayfair's Catalog Read v2 supplierCatalogItems query and persists each
item's insights (Wayfair's own problems / warnings / opportunities) and its
live status (catalogItemStatuswayfair_status) onto the matching
CatalogItem, so ListingIssues::WayfairAdapter can map them to listing_issues
and our DB stays in sync with what Wayfair actually has on-site. Reads only —
never mutates the Wayfair catalog.

Why v2: unlike the v1 supplier-catalog query, v2 exposes insights (with stable
insightTypeId + a resolution.url) and catalogItemStatus — Wayfair computing
the missing-attribute / quality anomalies for us. Production access is per
region and gated behind the sandbox test (see
doc/tasks/202606281210_WAYFAIR_API_V2_ACCESS.md).

Also persists the listing's populated taxonomy attributes
(attributes.chosenAttributeValues) — the listing's real spec coverage, which
ListingIssues::WayfairAdapter diffs against WayfairSchema to report specs
Wayfair genuinely has no value for.

Examples:

Edi::Wayfair::CatalogReadV2Retriever.new(Edi::Wayfair::Orchestrator.build(:wayfair_us)).pull

Defined Under Namespace

Classes: Result

Constant Summary collapse

CATALOG_API_URL =

Product Catalog API endpoint (v2 read shares the product-catalog-api host).

'https://api.wayfair.io/v1/product-catalog-api/graphql'
SANDBOX_CATALOG_API_URL =

Sandbox endpoint.

'https://api.wayfair.io/sandbox/v1/product-catalog-api/graphql'
MAX_PAGE_SIZE =

Hard page-size cap from the API (30 catalog items per response).

30
DEFAULT_PAGE_SIZE =

SKUs per request. Well under MAX_PAGE_SIZE: attributes makes each
response heavy enough that 30-item batches read-time out against the live
catalog (~2.5s per 10-SKU batch, measured 2026-07-25).

10
QUERY =

Only the fields we persist — insights + status + populated attribute ids.
Union must branch both ways.

<<~GRAPHQL.squish
  query SupplierCatalogItems($input: SupplierCatalogItemsInput!) {
    supplierCatalogItems(input: $input) {
      ... on SupplierCatalogItems {
        paginationInfo { page hasNextPage }
        catalogItems {
          supplierPartNumber
          catalogItemStatus
          class { classId className }
          attributes { attribute { attributeId } chosenAttributeValues { value } }
          insights {
            problems { insightId title explanation insightTypeId resolution { url } }
            warnings { insightId title explanation insightTypeId resolution { url } }
            opportunities { insightId title explanation insightTypeId resolution { url } }
          }
        }
      }
      ... on SupplierCatalogItemsError { httpError { code message } internalError { code message } }
    }
  }
GRAPHQL

Constants included from CatalogApiTransport

Edi::Wayfair::CatalogApiTransport::CATALOG_API_BASE, Edi::Wayfair::CatalogApiTransport::CATALOG_AUTH_URL

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, #process, #tagged_logger

Constructor Details

This class inherits a constructor from Edi::BaseEdiService

Instance Method Details

#pull(batch_size: DEFAULT_PAGE_SIZE, max_batches: nil) ⇒ Result

Ask Wayfair about OUR active SKUs in batches and persist what comes back.

Deliberately NOT a paginationOptions walk of the whole supplier catalog:
measured 2026-07-25, unfiltered pagination returned 360 CA catalog entries
and silently omitted 56 of our active CA SKUs that the very same API
returns when asked for them by supplierPartNumbers. Driving off our own
catalog also halves the request count (407 active items vs 953 catalog
entries) and makes "Wayfair has no such SKU" an answerable question.

⚠️ The filter drops items nondeterministically. A 10-SKU batch can come
back with 6, and the 4 missing ones return fine when asked again (measured
2026-07-25: 10 of 14 "absent" CA SKUs were present on a single-SKU re-ask).
So anything unreturned is re-asked once, one SKU per request, before we call
it absent. Never treat a first-pass miss as a catalog gap.

Parameters:

  • batch_size (Integer) (defaults to: DEFAULT_PAGE_SIZE)

    identifiers per request, 1..MAX_PAGE_SIZE

  • max_batches (Integer, nil) (defaults to: nil)

    cap batches (nil = whole catalog)

Returns:



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
# File 'app/services/edi/wayfair/catalog_read_v2_retriever.rb', line 99

def pull(batch_size: DEFAULT_PAGE_SIZE, max_batches: nil)
  supplier_id = orchestrator.try(:warehouse_code).to_s
  catalog = orchestrator.customer&.catalog
  return Result.new(errors: ['No catalog for orchestrator']) unless catalog

  items_by_sku = active_items_by_sku(catalog)
  return Result.new(errors: ['No active catalog items']) if items_by_sku.empty?

  state = { batches: 0, requested: 0, matched: 0, updated: 0, errors: [], seen: Set.new }
  slices = items_by_sku.keys.each_slice(batch_size.clamp(1, MAX_PAGE_SIZE)).to_a
  slices = slices.first(max_batches) if max_batches
  slices.each { |skus| consume(supplier_id, skus, items_by_sku, state) }

  # Retry pass: one identifier per request, so a dropped item can't hide behind
  # a neighbour. Scoped to what we actually asked about — under `max_batches`
  # the un-asked remainder is out of scope, and retrying it would turn the cap
  # into one request per SKU for the whole rest of the catalog. Identifiers
  # whose item was already found under its *other* identifier are skipped.
  asked = slices.flatten
  pending = asked.reject { |sku| state[:seen].include?(items_by_sku[sku]&.id) }
  pending.each { |sku| consume(supplier_id, [sku], items_by_sku, state) }

  Result.new(batches: state[:batches], requested: state[:requested], matched: state[:matched],
             updated: state[:updated], errors: state[:errors],
             unreturned: asked.reject { |sku| state[:seen].include?(items_by_sku[sku]&.id) })
end