Class: Edi::Wayfair::ProductAdditionSender

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

Overview

Programmatic new-product onboarding via Wayfair's Product Addition API
(productAddition { submitV2 }) — the API replacement for the formula-locked
Quick-Upload workbook. Builds each product as a flat list of attribute
key→value pairs — every REQUIRED attribute for the class, resolved by
ProductAdditionAttributes (namespaced core:: / media:: /
variantGrouping:: / shippingAndFulfillment:: / price:: / propSixtyFive::
plus the numeric class attributes) — submits a batch, and polls submissions
for the async two-loop validation result.

Wire format reverse-engineered + verified in sandbox 2026-06-29 (schema
introspection + a successful submitV2 call returning a productAdditionRequestId).

IMPORTANT CONSTRAINTS:

  • Product Addition is US / UK only — Wayfair rejects CANADA with
    "The market is not supported. Supported markets are Wayfair US and Wayfair
    UK". CA new products still go through the Quick-Upload workbook.
  • US production Submit scope is enabled (self-served 2026-06-29: a
    sandbox submitV2 whose payload passes first-loop validation unlocks the
    production scope). A complete ProductAdditionAttributes payload is what
    made that pass — an incomplete one fails first-loop and never unlocks.
  • submitV2 supports validateOnly (under options), so it can be dry-run
    server-side without creating anything. Default to validate_only: true
    until a human has curated the Edi::Wayfair::ProductAdditionAttributes#defaulted
    attributes for the batch.

Examples:

sender = Edi::Wayfair::ProductAdditionSender.new(Edi::Wayfair::Orchestrator.build(:wayfair_us))
result = sender.process(catalog_items: [item], validate_only: true)

Defined Under Namespace

Classes: Result

Constant Summary collapse

CATALOG_API_URL =

Production Product Catalog API endpoint.

'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_BATCH =

Max products per submitV2 request (Wayfair cap; >500 needs multi-batch jobContext).

500
SUBMIT_MUTATION =

submitV2 mutation — return selection verified against the live schema
(ProductResult{productId,status,validationFlaws},
AttributeValidationFlaw{attributeId,flawType,flaw}).

<<~GRAPHQL.squish
  mutation SubmitV2($request: SubmitProductAdditionsRequestV2!) {
    productAddition {
      submitV2(request: $request) {
        productAdditionRequestId
        batchId
        status
        processedProducts
        productResults { productId status validationFlaws { attributeId flawType flaw } }
      }
    }
  }
GRAPHQL
SUBMISSIONS_QUERY =

Poll the async submission status by productAdditionRequestId.
NB: submissions returns ProductAddition.validationFlaws: ValidationFlaw
(questionId), NOT the submit mutation's AttributeValidationFlaw
(attributeId). Verified via live schema introspection 2026-06-29.

<<~GRAPHQL.squish
  query Submissions($request: GetProductAdditionsRequest!) {
    submissions(request: $request) {
      requestId supplierPartNumber classId status validationStatus submissionStatus
      validationFlaws { questionId flawType flaw } submissionError
    }
  }
GRAPHQL

Constants included from CatalogApiTransport

CatalogApiTransport::CATALOG_API_BASE, 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, #tagged_logger

Constructor Details

This class inherits a constructor from Edi::BaseEdiService

Instance Method Details

#build_proposed_product(catalog_item, variant_grouping: nil) ⇒ Hash?

Build the { productId, classId, attributes } payload for one item:
core identity + media + variant role + the schema-driven class attributes.
Returns nil when the item has no Wayfair class, or when attribute generation
fails/yields nothing — so a build failure drops the product rather than
submitting an empty, invalid payload.

Parameters:

Returns:

  • (Hash, nil)


133
134
135
136
137
138
139
140
141
142
# File 'app/services/edi/wayfair/product_addition_sender.rb', line 133

def build_proposed_product(catalog_item, variant_grouping: nil)
  class_id = catalog_item.wayfair_effective_taxonomy_category_id
  sku = catalog_item.sku.presence || catalog_item.store_item&.item&.sku.presence
  return nil if class_id.blank? || sku.blank?

  attributes = attributes_for(catalog_item, sku, variant_grouping:)
  return nil if attributes.blank?

  { productId: sku, classId: class_id.to_s, attributes: }
end

#poll(request_id) ⇒ Array<Hash>

Poll submission status.

Parameters:

  • request_id (String)

Returns:

  • (Array<Hash>)

    submissions



147
148
149
150
151
152
153
154
155
# File 'app/services/edi/wayfair/product_addition_sender.rb', line 147

def poll(request_id)
  transport = build_catalog_transport
  vars = { request: { supplierId: orchestrator.try(:warehouse_code).to_s, ids: [request_id] } }
  payload = { query: SUBMISSIONS_QUERY, variables: vars, operationName: 'Submissions' }.to_json
  http = transport.send_request('POST', catalog_api_url, payload, request_headers(transport))[:http_res]
  JSON.parse(http.body.to_s).dig('data', 'submissions') || []
rescue JSON::ParserError, NoMethodError
  []
end

#process(catalog_items:, validate_only: true, ignore_warnings: true, variant_grouping_for: nil) ⇒ Result

Submit a batch of catalog items as new products.

Parameters:

  • catalog_items (Array<CatalogItem>)

    up to MAX_BATCH

  • validate_only (Boolean) (defaults to: true)

    server-side validation without creating (default true)

  • ignore_warnings (Boolean) (defaults to: true)

    proceed past WARNING flaws

  • variant_grouping_for (Proc, nil) (defaults to: nil)

    optional ->(catalog_item) { VariantGrouping | nil }
    resolver; when it returns a VariantGrouping the product is submitted as a
    member of that group instead of Not Variant (Phase 0 of
    doc/tasks/202607051428_VARIANT_GROUPING_UNIFICATION.md)

Returns:



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

def process(catalog_items:, validate_only: true, ignore_warnings: true, variant_grouping_for: nil)
  items = Array(catalog_items)
  return Result.new(success: false, errors: ["Batch exceeds #{MAX_BATCH}"]) if items.size > MAX_BATCH

  built = items.index_with do |item|
    # A failing grouping resolver must not abort the batch — and must not
    # silently submit a family member ungrouped (the exact listing debt this
    # Phase-0 path removes) — so the item is dropped into Result#skipped.

    build_proposed_product(item, variant_grouping: variant_grouping_for&.call(item))
  rescue StandardError => e
    logger.warn "Wayfair ProductAddition: variant_grouping_for failed for #{item.try(:sku) || item.try(:id)}: #{e.message}"
    nil
  end
  products = built.values.compact
  skipped = built.select { |_item, product| product.nil? }.keys.map { |item| item.try(:sku).presence || "catalog_item ##{item.try(:id)}" }
  return Result.new(success: false, errors: ['No products could be built'], skipped:) if products.empty?

  request = {
    marketContext: market_context,
    jobContext: { productAdditionRequestId: nil, hasMoreProducts: false },
    options: { validateOnly: validate_only, ignoreWarnings: ignore_warnings, rejectAllOnErrors: false },
    proposedProductAdditions: products
  }
  result = send_submit(request)
  skipped.any? ? result.with(skipped:) : result
end