Class: Edi::Wayfair::ProductAdditionAttributes

Inherits:
Object
  • Object
show all
Defined in:
app/services/edi/wayfair/product_addition_attributes.rb

Overview

Builds the complete required-attribute payload for a Product Addition
(submitV2) from a CatalogItem and its class taxonomy WayfairSchema.

Replaces the heater-specific ListingGenerator for the new-product path. That
generator's attribute classes (Amperage 891, BtuOutput 892, PlugIn
189868, FuelType, …) map to a space-heater Wayfair class; submitting them
on floor-heating class 7441 makes Wayfair reject every one as
"invalid attribute", and it omits ~30 genuinely-required attributes besides
(UPC, weight, lead times, ship type, wholesale price, Prop 65, plus the
floor-heating-specific Voltage/Wattage/Coverage/Product Type set). The result
was a submitV2 that failed first-loop validation and never unlocked the
production Submit scope.

This builder is schema-driven: it walks the class schema's REQUIRED
attributes plus the catalog-wide Goods defaults and resolves each in order —

  1. ID_SOURCES / TITLE_SOURCES — accurate value from our Item/CatalogItem
  2. PLATFORM_DEFAULTS / CLASS_DEFAULTS — curated default (commerce +
    class-specific choice attributes we can't derive from product data)
    — then formats per the schema datatype (DECIMAL / INTEGER / BOOLEAN /
    MULTI_CHOICE / STRING). Attributes filled from a default rather than real
    product data are recorded in #defaulted so callers can log what a human
    should curate before a non-validateOnly submission.

⚠️ There is deliberately no "just use the first schema option" fallback.
There used to be, and because the walk covers REQUIRED attributes only it
applied precisely where guessing is least acceptable. Wayfair does not
validate every choice value — core::manufacturerId accepts "10721",
"WarmlyYours" and "Bush Furniture" alike, all with zero flaws — so a guess
ships silently onto a live listing. Two real US product additions went out
branded "Bush Furniture" that way. Anything we cannot source or curate now
lands in #missing, which fails validation loudly and visibly. Missing beats
confidently wrong.

Verified end-to-end against sandbox 2026-06-29: a payload from this builder
passes first-loop validation with zero flaws and unlocks the production
Submit scope (productAddition { submitV2 }).

Examples:

schema = WayfairSchema.for_category(ci.wayfair_effective_taxonomy_category_id).wayfair_us.first
attrs  = Edi::Wayfair::ProductAdditionAttributes.new(ci, schema:).build

Constant Summary collapse

MANUFACTURER_ID =

WarmlyYours' Wayfair manufacturer entity id for supplier 7083, read from
supplierBrand { brandAssociations } (brandId
b0404b64-4e14-4f77-ac7c-cf0f721732b3, manufacturer name "WarmlyYours").

Hard-coded because the query is PERMISSION_DENIED in production — it
only answers against the sandbox endpoint, so there is nothing to look it up
from at submit time. Replace this constant with a live
brandAssociations lookup once the "Query supplier brand associations"
permission is unlocked on the developer portal.

CA (supplier 24331) has no association in sandbox, so no id is known for it;
that is not blocking today because Product Addition is US/UK only.

'10721'
SCHEMA_CHOICES_NOT_AUTHORITATIVE =

Attributes whose schema possibleAttributeValues must NOT be treated as the
set of legal values.

core::manufacturerId is the only member: it is a SINGLE_CHOICE whose option
list holds ~82 manufacturer names belonging to other suppliers, while the
field itself wants an ID from brandAssociations. Validating our real id
(10721) against that list fails it, and since a rejected sourced value falls
through to nil, the correct answer would be silently dropped and the
attribute reported missing.

Returns:

  • (Array<String>)
%w[core::manufacturerId].freeze
ID_SOURCES =

Namespaced, class-agnostic platform attributes sourced from our data.

Returns:

  • (Hash{String=>Proc})
{
  'core::productName' => ->(item, ci) { item&.name.to_s.strip.presence || ci.sku },
  'core::supplierPartNumber' => ->(item, ci) { ci.sku.presence || item&.sku },
  'core::manufacturerPartNumber' => ->(_item, ci) { ci.try(:manufacturer_part_number).presence || ci.sku },
  # Our registered Wayfair manufacturer entity. This is an **ID, not a name** —
  # the docs are explicit ("parts.manufacturerId: ID! — The ID for the
  # product's brand, which you found using the brandAssociations query"), and
  # `brandAssociations` returns `manufacturer { id: 10721, name: WarmlyYours }`
  # for supplier 7083.
  #
  # ⚠️ Do NOT resolve this from the schema's `possibleAttributeValues`. That
  # list holds ~82 manufacturer *names* belonging to other suppliers, we are
  # not in it, and `canValueBeCustomized` is false — so the old
  # `spec(item, :brand_name)` lookup (nil on every item) fell through to the
  # since-removed schema-value fallback and shipped **"Bush Furniture"** on two
  # real US product additions, which validated with zero flaws because Wayfair
  # does not check this field at all. See {MANUFACTURER_ID}.
  'core::manufacturerId' => ->(_item, _ci) { MANUFACTURER_ID },
  'core::universalProductCode' => ->(item, _ci) { item&.upc.presence },
  # Wholesale "Base Cost" = the catalog-item amount (parent price already
  # discounted by catalog.parent_catalog_discount; see CatalogItem#amount).
  'price::wholesalePrice' => ->(_item, ci) { (a = ci.try(:amount)).to_f.positive? ? a : nil },
  # MAP + MSRP are not sourced here: the class schemas never flag them
  # REQUIRED, so the schema walk never resolves them. #build force-includes
  # them from ViewProductCatalog instead (submit-time is the ONLY API write
  # path — updateMarketSpecificCatalogItems rejects price:: attributes,
  # probe-verified 2026-07-06; existing listings use the Partner Home cost
  # workbook).
  'shippingAndFulfillment::productWeight' => ->(item, _ci) { item && (item.try(:shipping_weight) || item.try(:base_weight)) }
}.freeze
TITLE_SOURCES =

Class-specific data sources keyed by schema attribute title (downcased) —
title is stable across classes that share a measurement, so these resolve
wherever the attribute appears.

Returns:

  • (Hash{String=>Proc})
{
  'voltage' => ->(item, _ci) { item && (item.try(:volts) || item.try(:voltage)) },
  'wattage' => ->(item, _ci) { item.try(:watts) },
  'overall length - end to end' => ->(item, _ci) { item.try(:length) },
  'overall width - side to side' => ->(item, _ci) { item.try(:width) },
  'overall thickness' => ->(item, _ci) { item.try(:height).presence },
  'overall area' => ->(item, _ci) { spec(item, :coverage) },
  'heating cable square foot coverage' => ->(item, _ci) { spec(item, :coverage) },
  'power cord length - end to end' => ->(item, _ci) { spec(item, :cold_lead_length) },
  'country of manufacturer' => ->(item, _ci) { spec(item, :country_of_origin) }
}.merge(
  ProductAdditionGoodsDefaults::SPECIFICATIONS.transform_values do |specification|
    ->(item, _ci) { spec(item, specification) }
  end
).freeze
PLATFORM_DEFAULTS =

Curated commerce / compliance defaults — class-agnostic (namespaced ids).

Returns:

  • (Hash{String=>String})
{
  'shippingAndFulfillment::minimumOrderQuantity' => '1',
  'shippingAndFulfillment::forceQuantityMultiplier' => '1',
  'shippingAndFulfillment::displaySetQuantity' => '1',
  'shippingAndFulfillment::shipType' => 'Small Parcel',
  'shippingAndFulfillment::leadTime' => '48',
  'shippingAndFulfillment::replacementLeadTime' => '48',
  'propSixtyFive::warningRequired' => 'No',
  'propSixtyFive::countryOfManufacturer' => 'United States',
  'variantGrouping::variantType' => 'Not Variant'
}.freeze
CLASS_DEFAULTS =

Per-class curated defaults for class-specific choice attributes we can't
source from product data (classId => { attributeId => value }). Floor
heating (7441) is curated; an uncurated class reports the attribute in
#missing rather than guessing at it.

Returns:

  • (Hash{String=>Hash{String=>String}})
{
  '7441' => { # Underfloor Heating
    '242830' => 'Mat',             # Product Type
    '242679' => 'Marble / Tile',   # Compatible Floor Type
    '242833' => 'Concrete',        # Compatible Subfloor Types
    '244326' => 'Residential Use', # Supplier Intended and Approved
    '382081' => 'Copper',          # Material
    '242839' => 'Does Not Apply',  # Thermostat Type
    '243928' => '0.125',           # Heating Cable Thickness (in)
    '242697' => '0.125',           # Overall Thickness (in)
    '244329' => 'Does Not Apply',  # Commercial Warranty
    '249378' => 'No',              # Canada Product Restriction
    '251133' => 'Does Not Apply',  # Reason for Restriction
    '506032' => 'No',              # Wayfair Compliance Verified
    '1' => 'Underfloor Heating'    # Class ID
  }
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(catalog_item, schema:, variant_grouping: nil) ⇒ ProductAdditionAttributes

Returns a new instance of ProductAdditionAttributes.

Parameters:

  • catalog_item (CatalogItem)
  • schema (WayfairSchema)

    the class taxonomy schema for the request market

  • variant_grouping (VariantGrouping, nil) (defaults to: nil)

    group membership + option
    axes for the proposed product; when nil the product is submitted as
    Not Variant (the pre-Phase-0 behavior)



182
183
184
185
186
187
188
189
190
# File 'app/services/edi/wayfair/product_addition_attributes.rb', line 182

def initialize(catalog_item, schema:, variant_grouping: nil)
  @catalog_item = catalog_item
  @item = catalog_item.item
  @schema = schema
  @variant_grouping = variant_grouping
  @class_id = catalog_item.wayfair_effective_taxonomy_category_id.to_s
  @defaulted = []
  @missing = []
end

Instance Attribute Details

#defaultedArray<String> (readonly)

Attribute ids filled from a curated default rather than real product data.

Returns:

  • (Array<String>)


167
168
169
# File 'app/services/edi/wayfair/product_addition_attributes.rb', line 167

def defaulted
  @defaulted
end

#missingArray<String> (readonly)

Required attribute ids that resolved to nothing — no sourced value and no
curated default — and were therefore omitted. A payload missing one of these
will fail Wayfair's first-loop validation, so callers should log/curate them.
That failure is the point: it is the visible signal that replaced silently
guessing at a schema value.

Returns:

  • (Array<String>)


175
176
177
# File 'app/services/edi/wayfair/product_addition_attributes.rb', line 175

def missing
  @missing
end

Class Method Details

.spec(item, key) ⇒ Object?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Spec lookup helper, used by the source procs (kept module-level so the
frozen Proc constants can call it).

Parameters:

  • item (Item, nil)

    the catalog item's Item

  • key (Symbol, String)

    spec key (e.g. :coverage)

Returns:

  • (Object, nil)

    the spec value, or nil when unavailable



244
245
246
247
248
249
250
# File 'app/services/edi/wayfair/product_addition_attributes.rb', line 244

def self.spec(item, key)
  return nil unless item.respond_to?(:spec_value)

  item.spec_value(key)
rescue StandardError
  nil
end

Instance Method Details

#buildArray<Hash>

Returns [{ attributeId:, value: }, …] for every required
attribute and available Goods default, plus variant role and lead media.

Returns:

  • (Array<Hash>)

    [{ attributeId:, value: }, …] for every required
    attribute and available Goods default, plus variant role and lead media.



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'app/services/edi/wayfair/product_addition_attributes.rb', line 194

def build
  definitions = required_definitions + ProductAdditionGoodsDefaults.new(@schema).definitions
  attrs = definitions.filter_map { |defn| resolve(defn) }
  # An explicit grouping owns the whole variantGrouping namespace — drop any
  # schema/default-resolved entries (payload AND the defaulted/missing
  # tracking) so `Not Variant` can't shadow it and the curation log doesn't
  # report a correctly-overridden attribute.
  if @variant_grouping
    [attrs, @defaulted, @missing].each do |collection|
      collection.reject! { |entry| (entry.is_a?(Hash) ? entry[:attributeId] : entry).to_s.start_with?('variantGrouping::') }
    end
  end
  present = attrs.to_set { |a| a[:attributeId] }

  # core::universalProductCode is Wayfair-mandatory but the class schema does
  # not flag it REQUIRED, so the schema walk above skips it — force it in.
  upc = @item&.upc.presence
  attrs << { attributeId: 'core::universalProductCode', value: upc } if upc && present.exclude?('core::universalProductCode')
  # MAP + MSRP are OPTIONAL in the class schemas, but submit-time is the
  # ONLY API write path for pricing (updateMarketSpecificCatalogItems
  # rejects price:: attributes — probe-verified 2026-07-06), so new
  # listings must carry them from day one. Existing listings update
  # through the Partner Home cost workbook.
  # format_decimal to match the DECIMAL wire format the schema-walked
  # attributes use — a raw BigDecimal here JSON-serializes as scientific
  # notation (0.7832e3) instead of "783.2".
  if (map = view_product_catalog&.map_price).to_f.positive? && present.exclude?('price::minimumAdvertizedPrice')
    attrs << { attributeId: 'price::minimumAdvertizedPrice', value: format_decimal(map) }
  end
  if (msrp = view_product_catalog&.msrp).to_f.positive? && present.exclude?('price::manufacturerSuggestedRetailPrice')
    attrs << { attributeId: 'price::manufacturerSuggestedRetailPrice', value: format_decimal(msrp) }
  end
  if @variant_grouping
    @variant_grouping.to_attributes.each do |attr|
      attrs << attr if present.exclude?(attr[:attributeId])
    end
  elsif present.exclude?('variantGrouping::variantType')
    attrs << { attributeId: 'variantGrouping::variantType', value: 'Not Variant' }
  end
  img = lead_image_url
  attrs << { attributeId: 'media::imageValue', value: img } if img && present.exclude?('media::imageValue')
  attrs
end