Class: Edi::Amazon::JsonListingGenerator::BaseGenerator
- Inherits:
-
BaseService
- Object
- BaseService
- Edi::Amazon::JsonListingGenerator::BaseGenerator
- Includes:
- Memery
- Defined in:
- app/services/edi/amazon/json_listing_generator/base_generator.rb
Overview
Service object: base generator.
Defined Under Namespace
Classes: GenerateResult
Constant Summary collapse
- OFFER_ATTRIBUTES =
Note:
merchant_suggested_asin is intentionally NOT here — it identifies the product/ASIN and
is accepted on a product-only PUT.Offer/fulfillment attributes — excluded from the DEFAULT product-listing payload.
They are submitted through their own channels: price via price_advice (purchasable_offer,
list_price), inventory via inventory_advice (fulfillment_availability). condition_type and
skip_offer are offer-creation controls that don't belong in a LISTING_PRODUCT_ONLY submission
(Amazon rejects them with INVALID_ATTRIBUTE / 90000900). Callers that genuinely need these
(the price/inventory feeds) pass an explicit attribute_actions hash, which bypasses the default. %i[purchasable_offer list_price fulfillment_availability condition_type skip_offer].freeze
Instance Attribute Summary collapse
-
#amazon_schema ⇒ Object
readonly
Returns the value of attribute amazon_schema.
-
#attribute_actions ⇒ Object
readonly
Returns the value of attribute attribute_actions.
-
#business_price_available ⇒ Object
readonly
Returns the value of attribute business_price_available.
-
#catalog_item ⇒ Object
readonly
Returns the value of attribute catalog_item.
-
#fba ⇒ Object
readonly
Returns the value of attribute fba.
-
#item ⇒ Object
readonly
Returns the value of attribute item.
-
#language_tag ⇒ Object
readonly
Returns the value of attribute language_tag.
-
#locale ⇒ Object
readonly
Returns the value of attribute locale.
-
#marketplace_country_iso ⇒ Object
readonly
Returns the value of attribute marketplace_country_iso.
-
#marketplace_id ⇒ Object
readonly
Returns the value of attribute marketplace_id.
-
#missing_attributes ⇒ Object
readonly
Returns the value of attribute missing_attributes.
-
#product_type ⇒ Object
readonly
Returns the value of attribute product_type.
-
#variation ⇒ Object
readonly
Returns the value of attribute variation.
Attributes inherited from BaseService
Class Method Summary collapse
-
.default_product_attribute_actions(schema_keys) ⇒ Object
Default action set for a full product listing PUT: every schema attribute set to :replace, minus OFFER_ATTRIBUTES and the other_*_image_locator attributes (only reliably settable via Seller Central bulk upload, per Christian Billen's (cbillen@warmlyyours.com) email of 11/21/25 with subject 'Amazon patch/put').
Instance Method Summary collapse
-
#additional_locales ⇒ Array<Symbol>
The marketplace's Amazon locales other than the one this generator builds.
-
#append_attribute(attributes_hash, attribute, include_nil: false, locale_tag: language_tag) ⇒ Object
Appends an attribute to the given attributes hash based on the attribute name.
-
#build_attributes(include_nil: false) ⇒ Object
Builds a hash of attributes for a product listing.
-
#clamp_to_schema_length(attribute, built, locale_tag: language_tag) ⇒ Array<Hash>?
Trims values to the
maxLengththeir product-type schema declares. - #enum_mapper ⇒ Object
- #generate ⇒ Object
- #get_patches ⇒ Object
-
#initialize(catalog_item, locale: nil, attribute_actions: nil, product_type: nil, logger: nil, variation: nil, marketplace_id: nil, fba: false, language_tag: nil, business_price_available: true) ⇒ BaseGenerator
constructor
A new instance of BaseGenerator.
-
#language_scoped?(attribute) ⇒ Boolean
Whether the product-type schema keys this attribute by language, i.e.
-
#language_tag_for(loc) ⇒ String
Amazon's BCP-47 tag for a locale on THIS marketplace.
-
#merge_additional_locales(attributes_hash) ⇒ Hash
Amazon takes one entry per language_tag inside a single attribute array, but we only ever built the catalog's FIRST locale.
- #product_data_hash(attributes = nil) ⇒ Object
-
#schema_max_length(attribute) ⇒ Integer?
The schema's declared maxLength, when it declares one.
-
#stale_locale_delete_patches(replace_patches) ⇒ Array<Hash>
Deletes that strip languages this marketplace refuses off the attributes we are already writing.
-
#truncate_on_word_boundary(value, max) ⇒ String
Cuts at the last word boundary inside the limit so the value still reads as a sentence rather than stopping mid-word.
Methods inherited from BaseService
#log_debug, #log_error, #log_info, #log_warning, #logger, #process, #tagged_logger
Constructor Details
#initialize(catalog_item, locale: nil, attribute_actions: nil, product_type: nil, logger: nil, variation: nil, marketplace_id: nil, fba: false, language_tag: nil, business_price_available: true) ⇒ BaseGenerator
Returns a new instance of BaseGenerator.
38 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 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 38 def initialize(catalog_item, locale: nil, attribute_actions: nil, product_type: nil, logger: nil, variation: nil, marketplace_id: nil, fba: false, language_tag: nil, business_price_available: true) raise ArgumentError, "catalog_item is required for #{self.class.name}" if catalog_item.nil? @catalog_item = catalog_item @variation = variation @item = catalog_item.item @product_type = product_type || @variation&.amazon_desired_product_type || catalog_item.amazon_product_type_in_effect @locale = locale || @catalog_item.amazon_locales.first @business_price_available = business_price_available @marketplace_id = marketplace_id || @catalog_item.catalog.amazon_marketplace.marketplace_identifier # MUST be present @marketplace = AmazonMarketplace.find_by(marketplace_identifier: @marketplace_id) @marketplace_country_iso = @marketplace.country.iso if @locale.blank? raise ArgumentError, "Cannot initialize #{self.class.name} without locale defined, product_type: #{@product_type}, @marketplace&.id: #{@marketplace&.id}, catalog_item&.sku: #{@catalog_item&.sku}, variation&.id: #{@variation&.id}" end # Qualify the primary tag the same way the additional locales are qualified, # so a bare language gets its marketplace country. Amazon.be's first locale # is `:fr`, which would otherwise submit a bare "fr" alongside "nl_BE" and # "de_BE". Set after @marketplace_country_iso and the blank-locale guard, # both of which language_tag_for depends on. @language_tag = language_tag || language_tag_for(@locale) raise ArgumentError, "Cannot initialize #{self.class.name} for CatalogItem[#{@catalog_item&.id}] (sku: #{@catalog_item&.sku}) — no product_type set. Assign an amazon_product_type on the catalog item or variation first." if @product_type.blank? @amazon_schema = AmazonSchema.amazon_channel_seller.where(product_type: @product_type).where(amazon_marketplace_id: @marketplace.id).first # If we don't have the schema, try to pull it if @amazon_schema.blank? @catalog_item.amazon_pull_listing_schema(@product_type) @amazon_schema = AmazonSchema.amazon_channel_seller.where(product_type: @product_type).where(amazon_marketplace_id: @marketplace.id).first end # If we still don't have the schema raise the error raise ArgumentError, "Cannot initialize #{self.class.name} for CatalogItem[#{@catalog_item&.id}] (sku: #{@catalog_item&.sku}) without #{@product_type} schema present for marketplace: #{@marketplace.name}" if @amazon_schema.blank? # Pass attributes and actions as a hash of symbols,. # For e.g. for inventory we pass: # see: https://developer-docs.amazon.com/sp-api/docs/listing-workflow-migration-tutorial#submitting-inventory-data # attribute_actions: {fulfillment_availability: :replace} # For e.g. for creating new listing offers we might pass: # see: https://developer-docs.amazon.com/sp-api/docs/listing-workflow-migration-tutorial # attribute_actions: {condition_type: :replace, merchant_suggested_asin: :replace, purchasable_offer: :replace} # If attribute actions are not specified, we use the product-listing defaults: every schema # attribute set to :replace, minus offer/fulfillment (OFFER_ATTRIBUTES) and other_*_image_locator # attributes. Offers/inventory are submitted separately via price_advice / inventory_advice. @attribute_actions = attribute_actions&.transform_keys(&:to_sym)&.transform_values(&:to_sym) || self.class.default_product_attribute_actions(@amazon_schema.keys) # Until we refactor FBA into catalog_items, we need to pass this to generate the correct FBA logic for JSON feeds @fba = fba super(logger:) end |
Instance Attribute Details
#amazon_schema ⇒ Object (readonly)
Returns the value of attribute amazon_schema.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def amazon_schema @amazon_schema end |
#attribute_actions ⇒ Object (readonly)
Returns the value of attribute attribute_actions.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def attribute_actions @attribute_actions end |
#business_price_available ⇒ Object (readonly)
Returns the value of attribute business_price_available.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def business_price_available @business_price_available end |
#catalog_item ⇒ Object (readonly)
Returns the value of attribute catalog_item.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def catalog_item @catalog_item end |
#fba ⇒ Object (readonly)
Returns the value of attribute fba.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def fba @fba end |
#item ⇒ Object (readonly)
Returns the value of attribute item.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def item @item end |
#language_tag ⇒ Object (readonly)
Returns the value of attribute language_tag.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def language_tag @language_tag end |
#locale ⇒ Object (readonly)
Returns the value of attribute locale.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def locale @locale end |
#marketplace_country_iso ⇒ Object (readonly)
Returns the value of attribute marketplace_country_iso.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def marketplace_country_iso @marketplace_country_iso end |
#marketplace_id ⇒ Object (readonly)
Returns the value of attribute marketplace_id.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def marketplace_id @marketplace_id end |
#missing_attributes ⇒ Object (readonly)
Returns the value of attribute missing_attributes.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def missing_attributes @missing_attributes end |
#product_type ⇒ Object (readonly)
Returns the value of attribute product_type.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def product_type @product_type end |
#variation ⇒ Object (readonly)
Returns the value of attribute variation.
7 8 9 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 7 def variation @variation end |
Class Method Details
.default_product_attribute_actions(schema_keys) ⇒ Object
Default action set for a full product listing PUT: every schema attribute set to :replace,
minus OFFER_ATTRIBUTES and the other_*_image_locator attributes (only reliably settable via
Seller Central bulk upload, per Christian Billen's (cbillen@warmlyyours.com) email of 11/21/25
with subject 'Amazon patch/put').
27 28 29 30 31 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 27 def self.default_product_attribute_actions(schema_keys) schema_keys.reject do |k| OFFER_ATTRIBUTES.include?(k.to_sym) || (k.to_s.include?('other_') && k.to_s.include?('image_locator')) end.index_with { |_attribute| :replace } end |
Instance Method Details
#additional_locales ⇒ Array<Symbol>
The marketplace's Amazon locales other than the one this generator builds.
Empty for every single-language marketplace, so the merge below is a no-op
there; only Amazon.ca (en-CA + fr) and Amazon.be (fr + nl + de) have any.
118 119 120 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 118 def additional_locales Array(@catalog_item.amazon_locales) - [locale] end |
#append_attribute(attributes_hash, attribute, include_nil: false, locale_tag: language_tag) ⇒ Object
Appends an attribute to the given attributes hash based on the attribute name.
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 267 def append_attribute(attributes_hash, attribute, include_nil: false, locale_tag: language_tag) attribute_builder = begin Edi::Amazon::JsonListingGenerator::Attributes::AttributeFactory.build(attribute, catalog_item:, variation:, language_tag:, enum_mapper:, marketplace_id:, fba:) rescue StandardError nil end return attributes_hash unless attribute_builder r = clamp_to_schema_length(attribute, attribute_builder.build, locale_tag:) if r.nil? && !include_nil if required_attribute?(attribute) @missing_attributes << attribute logger.error "#{attribute_builder.class.name} required attribute #{attribute} returned nil" end else attributes_hash[attribute] = r end attributes_hash end |
#build_attributes(include_nil: false) ⇒ Object
Builds a hash of attributes for a product listing.
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 98 def build_attributes(include_nil: false) attributes_hash = {} @missing_attributes = [] # To make everything with locale, we will wrap into a mobility call Mobility.with_locale(locale) do @attribute_actions.each do |attribute, attribute_action| next if attribute_action == :skip include_nil = true if attribute_action.to_sym == :delete append_attribute(attributes_hash, attribute, include_nil:) end end merge_additional_locales(attributes_hash) attributes_hash end |
#clamp_to_schema_length(attribute, built, locale_tag: language_tag) ⇒ Array<Hash>?
Trims values to the maxLength their product-type schema declares.
Amazon rejects the WHOLE submission when any single value is over — HTTP 200
with "status": "INVALID" — so one long string stops every other attribute
on that listing from being written. A 149-character Dutch
title_differentiation was doing exactly that across the EU: 227 values on
32 SKUs breached the 125-char cap, and because every push was rejected, none
of those listings could be corrected at all.
Sending a trimmed value beats sending nothing, and beats losing the other 89
attributes in the payload. 33 attributes on TOWEL_HOLDER declare a
maxLength, so this is applied generically rather than per attribute class.
307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 307 def clamp_to_schema_length(attribute, built, locale_tag: language_tag) max = schema_max_length(attribute) return built if max.nil? || built.nil? Array(built).map do |entry| next entry unless entry.is_a?(Hash) && entry[:value].is_a?(String) && entry[:value].length > max trimmed = truncate_on_word_boundary(entry[:value], max) # Report what actually goes to Amazon, not the cap: cutting back to a word # boundary usually lands well short of it, and logging the limit would # overstate how much copy survived. logger.warn "Trimming #{attribute} from #{entry[:value].length} to #{trimmed.length} chars " \ "(limit #{max}) for #{locale_tag} — an over-length value would make Amazon " \ 'reject the entire submission' entry.merge(value: trimmed) end end |
#enum_mapper ⇒ Object
92 93 94 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 92 def enum_mapper Edi::Amazon::EnumMapper.new(amazon_schema.schema) end |
#generate ⇒ Object
191 192 193 194 195 196 197 198 199 200 201 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 191 def generate # Built once and shared. `product_data_hash` used to rebuild the whole set # itself, so every attribute was constructed twice per generate — and once # trimming started logging, each over-length value warned twice as well. attributes = build_attributes GenerateResult.new( attributes_hash: attributes, product_data_hash: product_data_hash(attributes), missing_attributes: @missing_attributes ) end |
#get_patches ⇒ Object
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 203 def get_patches patches = generate.product_data_hash.values.flat_map do |locale_data| locale_data[:attributes].map do |attribute, value_arr| operation = attribute_actions[attribute] next if value_arr.blank? && operation.in?(%i[skip replace]) # Delete operation value value_arr = [{ marketplace_id: }] if operation == :delete { op: operation, path: "/attributes/#{attribute}", value: value_arr }.compact end end patches = patches.compact (patches + stale_locale_delete_patches(patches)).uniq # we might have the same attribute generated for two different locales that are not locale dependent end |
#language_scoped?(attribute) ⇒ Boolean
Whether the product-type schema keys this attribute by language, i.e.
whether it can hold a per-language entry at all. Deleting by language_tag on
an attribute that is not language-scoped would be a meaningless patch.
258 259 260 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 258 def language_scoped?(attribute) Array(amazon_schema.schema.dig('properties', attribute, 'selectors')).include?('language_tag') end |
#language_tag_for(loc) ⇒ String
Amazon's BCP-47 tag for a locale on THIS marketplace. A locale carrying its
own region is used as-is ("en-CA" -> "en_CA"); a bare language is qualified
with the marketplace's country, because Amazon.ca wants "fr_CA" and rejects
a bare "fr" (the same :fr locale is "fr_BE" on Amazon.be).
128 129 130 131 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 128 def language_tag_for(loc) tag = loc.to_s.tr('-', '_') tag.include?('_') ? tag : "#{tag}_#{marketplace_country_iso}" end |
#merge_additional_locales(attributes_hash) ⇒ Hash
Amazon takes one entry per language_tag inside a single attribute array, but
we only ever built the catalog's FIRST locale. On a multi-language
marketplace that left the other languages empty, so Amazon machine-translated
our copy on our behalf — and its translation can exceed the attribute's
length cap, which is what raises 90225 on title_differentiation even though
the value we submitted is well inside the limit (SP-API case 21439941921).
Only genuinely translated values are merged: Mobility falls back to the
default locale when a translation is missing, and shipping English text
tagged fr_CA would be worse than letting Amazon translate. Comparing against
the primary value skips those fallbacks, which also keeps this inert until
real translations exist.
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 148 def merge_additional_locales(attributes_hash) # Snapshot the PRIMARY locale's values before merging anything. The # fallback test below has to compare against the primary only: comparing # against the accumulating hash would drop a genuine translation that # happens to match an already-merged sibling — on Amazon.be (fr, nl, de) a # Dutch and a German translation that read alike would lose the German. primary_values = attributes_hash.transform_values do |value_arr| Array(value_arr).filter_map { |v| v[:value] if v.is_a?(Hash) && v[:language_tag] == language_tag } end additional_locales.each do |loc| tag = language_tag_for(loc) next if tag == language_tag translated = {} Mobility.with_locale(loc) do @attribute_actions.each do |attribute, attribute_action| next if attribute_action == :skip # A :delete carries no value to translate, and the primary pass # already allowed its nil via include_nil:. Re-building it here # without that flag would file a bogus missing-required-attribute # error for every additional locale. next if attribute_action.to_sym == :delete next unless attributes_hash.key?(attribute) append_attribute(translated, attribute, locale_tag: tag) end end translated.each do |attribute, value_arr| base = primary_values[attribute] || [] entries = Array(value_arr).select { |v| v.is_a?(Hash) && v.key?(:language_tag) } .reject { |v| base.include?(v[:value]) } .map { |v| v.merge(language_tag: tag) } next if entries.empty? attributes_hash[attribute] = (Array(attributes_hash[attribute]) + entries) .uniq { |v| [v[:language_tag], v[:value]] } end end attributes_hash end |
#product_data_hash(attributes = nil) ⇒ Object
358 359 360 361 362 363 364 365 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 358 def product_data_hash(attributes = nil) { language_tag => { asin: @catalog_item.amazon_asin, attributes: attributes || build_attributes } } end |
#schema_max_length(attribute) ⇒ Integer?
Returns the schema's declared maxLength, when it declares one.
327 328 329 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 327 def schema_max_length(attribute) amazon_schema&.schema&.dig('properties', attribute.to_s, 'items', 'properties', 'value', 'maxLength') end |
#stale_locale_delete_patches(replace_patches) ⇒ Array<Hash>
Deletes that strip languages this marketplace refuses off the attributes we
are already writing.
A PATCH is selector-scoped: replace carrying only en_CA overwrites the
English entry and leaves any fr_CA sitting beside it. Amazon.ca then
answers 100720 ("Invalid language data") and suppresses the attribute, so a
listing can be re-pushed indefinitely without ever clearing — an en_CA-only
re-push of 327 items moved almost nothing. Only an explicit delete carrying
the selector removes it (a bare delete is rejected outright with "Invalid
empty value provided in patch at index 0").
Riding along with the normal push, rather than living in a one-off sweep,
is what makes this self-healing: a delete whose selector matches nothing is
accepted as a no-op, so it costs a listing that is already clean nothing but
a patch entry, and any stale value that reappears is removed on the next
push. Only attributes whose schema actually declares a language_tag
selector are targeted, which keeps the patch list from doubling.
244 245 246 247 248 249 250 251 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 244 def stale_locale_delete_patches(replace_patches) = @catalog_item.amazon_gated_locales.map { |loc| language_tag_for(loc) } return [] if .empty? paths = replace_patches.filter_map { |patch| patch[:path] if patch[:op] == :replace }.uniq paths.select { |path| language_scoped?(path.delete_prefix('/attributes/')) } .flat_map { |path| .map { |tag| { op: :delete, path:, value: [{ marketplace_id:, language_tag: tag }] } } } end |
#truncate_on_word_boundary(value, max) ⇒ String
Cuts at the last word boundary inside the limit so the value still reads as
a sentence rather than stopping mid-word. Falls back to a hard cut when a
single token is longer than the limit.
337 338 339 340 341 342 |
# File 'app/services/edi/amazon/json_listing_generator/base_generator.rb', line 337 def truncate_on_word_boundary(value, max) cut = value[0, max] boundary = cut.rindex(/\s/) trimmed = boundary&.positive? ? cut[0, boundary] : cut trimmed.sub(/[\s,;:.\-–—]+\z/, '') end |