Class: Edi::Amazon::JsonListingGenerator::BaseGenerator

Inherits:
BaseService
  • Object
show all
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

Attributes inherited from BaseService

#options

Class Method Summary collapse

Instance Method Summary collapse

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.

Raises:

  • (ArgumentError)


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_schemaObject (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_actionsObject (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_availableObject (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_itemObject (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

#fbaObject (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

#itemObject (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_tagObject (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

#localeObject (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_isoObject (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_idObject (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_attributesObject (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_typeObject (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

#variationObject (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_localesArray<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.

Returns:

  • (Array<Symbol>)


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.

Parameters:

  • locale_tag (String, nil) (defaults to: language_tag)

    the tag the built entries will carry. Defaults
    to this generator's primary language_tag; merge_additional_locales
    passes the secondary tag, because it rewrites language_tag on the entries
    only after this runs.



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.

Parameters:

  • attribute (String, Symbol)
  • built (Array<Hash>, nil)

    whatever the attribute class produced

  • locale_tag (String, nil) (defaults to: language_tag)

    the tag these entries will carry once merged.
    Not read from the entry: on a secondary-locale pass the entry still holds
    the primary tag at this point, so logging that would blame the wrong
    language for a trim — the Dutch value would be reported as French.

Returns:

  • (Array<Hash>, nil)


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_mapperObject



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

#generateObject



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_patchesObject



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.

Parameters:

  • attribute (String)

Returns:

  • (Boolean)


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).

Parameters:

  • loc (String, Symbol)

Returns:

  • (String)


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.

Parameters:

  • attributes_hash (Hash)

Returns:

  • (Hash)

    the same hash, with translated entries merged in



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

Parameters:

  • attributes (Hash, nil) (defaults to: nil)

    an already-built attribute set to reuse;
    builds one when called on its own.



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.

Parameters:

  • attribute (String, Symbol)

Returns:

  • (Integer, nil)

    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.

Parameters:

  • replace_patches (Array<Hash>)

    the patches built for this submission

Returns:

  • (Array<Hash>)


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)
  tags = @catalog_item.amazon_gated_locales.map { |loc| language_tag_for(loc) }
  return [] if tags.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| tags.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.

Parameters:

  • value (String)
  • max (Integer)

Returns:

  • (String)


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