Class: Edi::Wayfair::ProductAdditionAttributes
- Inherits:
-
Object
- Object
- Edi::Wayfair::ProductAdditionAttributes
- 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 —
- ID_SOURCES / TITLE_SOURCES — accurate value from our Item/CatalogItem
- 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-validateOnlysubmission.
⚠️ 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 }).
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
brandAssociationslookup 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
possibleAttributeValuesmust NOT be treated as the
set of legal values.core::manufacturerIdis 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 frombrandAssociations. 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. %w[core::manufacturerId].freeze
- ID_SOURCES =
Namespaced, class-agnostic platform attributes sourced from our data.
{ '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. { '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).
{ '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. { '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
-
#defaulted ⇒ Array<String>
readonly
Attribute ids filled from a curated default rather than real product data.
-
#missing ⇒ Array<String>
readonly
Required attribute ids that resolved to nothing — no sourced value and no curated default — and were therefore omitted.
Class Method Summary collapse
-
.spec(item, key) ⇒ Object?
private
Spec lookup helper, used by the source procs (kept module-level so the frozen Proc constants can call it).
Instance Method Summary collapse
-
#build ⇒ Array<Hash>
[{ attributeId:, value: }, …]for every required attribute and available Goods default, plus variant role and lead media. -
#initialize(catalog_item, schema:, variant_grouping: nil) ⇒ ProductAdditionAttributes
constructor
A new instance of ProductAdditionAttributes.
Constructor Details
#initialize(catalog_item, schema:, variant_grouping: nil) ⇒ ProductAdditionAttributes
Returns a new instance of ProductAdditionAttributes.
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
#defaulted ⇒ Array<String> (readonly)
Attribute ids filled from a curated default rather than real product data.
167 168 169 |
# File 'app/services/edi/wayfair/product_addition_attributes.rb', line 167 def defaulted @defaulted end |
#missing ⇒ Array<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.
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).
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
#build ⇒ Array<Hash>
Returns [{ 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 |