Class: Item::KitComposer

Inherits:
BaseService show all
Defined in:
app/services/item/kit_composer.rb

Overview

Service object: kit composer.

Constant Summary collapse

CONTROLS =

Controls.

{
  # 'UTN4-4999': {
  #   kit_code: 'ON',
  #   model_suffix: 'UTN4',
  #   amazon_short_name: 'Digital Thermostat'
  # }, # OJ Non-Programmable
  # 'UDG-4999': {
  #   kit_code: 'OP',
  #   model_suffix: 'UDG',
  #   amazon_short_name: 'Programmable Thermostat'
  # }, # OJ Programmable Basic
  # 'UDG4-4999-WY': {
  #   kit_code: 'OT',
  #   model_suffix: 'UDG4',
  #   amazon_short_name: 'Touchscreen Programmable Thermostat'
  # }, # OJ Programmable Touch
  # 'UWG4-4999': {
  #   kit_code: 'OW',
  #   model_suffix: 'UWG4',
  #   amazon_short_name: 'WiFi Programmable Thermostat'
  # }, # OJ WiFi Touch
  'UWG5-4999-WY': {
    kit_code: 'UWG5',
    model_suffix: 'UWG5',
    amazon_short_name: 'WiFi LED Touch Programmable Thermostat'
  },
  'UTN5-4999' => {
    kit_code: 'UTN5',
    model_suffix: 'UTN5',
    amazon_short_name: 'Non-Programmable LED Thermostat'
  } # OJ WiFi LED Touch
}.with_indifferent_access
SUPPLEMENTAL_CATALOG_MAPS =

Supplemental catalog maps.

{
  1 => { catalog_id: CatalogConstants::AMAZON_SC_US_CATALOG_ID, discounted_price_factor: 0.75 }, # US to Seller, per venu 10% off base catalog discount
  2 => { catalog_id: CatalogConstants::AMAZON_SC_CA_CATALOG_ID, discounted_price_factor: 0.75 }
}.freeze
CORE_STORE_IDS =

Core store ids.

[1, 2].freeze
CORE_CATALOG_IDS =

Core catalog ids.

[1, 2].freeze
ACCESSORIES =

Accessories.

['SS-01'].freeze
FIXING_STRIP =

Fixing strip.

ItemConstants::TZ_CABLE_GRIPSTRIP_SKU
MEMBRANE_SMALL =

Membrane small.

ItemConstants::MEMBRANE_SMALL_SKU
MEMBRANE_LARGE =

Membrane large.

ItemConstants::MEMBRANE_LARGE_SKU
CABLE_SKU_PATTERN =

Regex pattern matching cable sku.

'TCT%-3.7W-%'.freeze
ROLL_SKU_PATTERN =

Regex pattern matching roll sku.

'TRT%-1.5x%'.freeze
PRODUCT_LINE_SLUG_LTREES =

Product line slug ltrees.

{
  easy_mats: [LtreePaths::PL_FLOOR_HEATING_TEMPZONE_EASY_MAT_TWIN],
  flex_rolls: [LtreePaths::PL_FLOOR_HEATING_TEMPZONE_FLEX_ROLL_TWIN_120V, LtreePaths::PL_FLOOR_HEATING_TEMPZONE_FLEX_ROLL_TWIN_240V],
  cable: [LtreePaths::PL_FLOOR_HEATING_TEMPZONE_CABLE]
}.freeze

Instance Attribute Summary

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, #tagged_logger

Constructor Details

#initialize(options = {}) ⇒ KitComposer

Returns a new instance of KitComposer.



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'app/services/item/kit_composer.rb', line 68

def initialize(options = {})
  @options = {
    force_sync_kit_contents: true
  }.merge(options)
  @operations = @options[:operations] || %i[attributes kit_contents catalog_items specs qty]
  @warnings = {}
  @item_changes = {}
  @force_sync_kit_contents = @options[:force_sync_kit_contents]
  control_skus = @options[:control_skus].presence || CONTROLS.keys
  @cable_install_methods = @options[:cable_install_methods] || %i[membrane fixing_strip]
  @he_items = @options[:he_items] || Item.all
  @product_line_keys = @options[:product_line_keys] || PRODUCT_LINE_SLUG_LTREES.keys
  @product_line_slug_ltrees = PRODUCT_LINE_SLUG_LTREES.slice(*@product_line_keys).values.flatten
  @he_items = @he_items.by_product_line_url(@product_line_slug_ltrees).heating_elements.condition_new.active.where.not("sku LIKE '%-MN'").where.not(is_kit: true)
  logger.info "Using heating elements: #{@he_items.pluck(:sku)}"

  @controls = Item.active.where(sku: control_skus)
  @accessories = Item.active.where(sku: ACCESSORIES)
  @fixing_strip = Item.active.find_by(sku: FIXING_STRIP)
  @membrane_small = Item.find_by(sku: MEMBRANE_SMALL)
  @membrane_large = Item.find_by(sku: MEMBRANE_LARGE)
  @kit_product_category = ProductCategory.find_by(url: 'goods-kits')
  @results_file_path = options[:results_file_path] || Rails.root.join('tmp', "kit_results_#{Time.current.to_i}.csv").to_s
  raise 'Items are missing, check initializer' if @kit_product_category.nil? ||
                                                  @controls.empty? || @accessories.empty? || @fixing_strip.nil? ||
                                                  @membrane_small.nil? || @membrane_large.nil?

  super(@options)
end

Class Method Details

.generate_kits_cable_membranes_controls(limit: nil) ⇒ Object



98
99
100
101
# File 'app/services/item/kit_composer.rb', line 98

def self.generate_kits_cable_membranes_controls(limit: nil)
  processor = new(cable_install_methods: %i[membrane], product_line_keys: %i[cable])
  processor.process(limit:)
end

Instance Method Details

#append_warning(item_sku, warning) ⇒ Object



365
366
367
368
369
370
371
# File 'app/services/item/kit_composer.rb', line 365

def append_warning(item_sku, warning)
  return if warning.blank?

  @warnings ||= {}
  @warnings[item_sku] ||= []
  @warnings[item_sku] << warning
end

#build_catalog_items(item, _supplemental_catalog_map: SUPPLEMENTAL_CATALOG_MAPS) ⇒ Object

Builds the catalog item for the supplied kit item in each core catalog with msrp pricing
and inside supplemental catalog with the catalog pricing



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'app/services/item/kit_composer.rb', line 254

def build_catalog_items(item, _supplemental_catalog_map: SUPPLEMENTAL_CATALOG_MAPS)
  # Hide in primary catalog
  item.catalog_items.where(catalog_id: [1, 2]).find_each { |ci| ci.update!(state: 'active_hidden') }

  # let's start by computing the MSRP of this kit for each main catalog
  default_amazon_state = item.amazon_asin.present? ? 'active' : 'pending_onboarding'
  CORE_CATALOG_IDS.each do |catalog_id|
    pricing_data = calculate_price(item, catalog_id, SUPPLEMENTAL_CATALOG_MAPS[catalog_id][:discounted_price_factor])
    # Create catalog item mapping
    # find store item of item
    store_id = Catalog.find(catalog_id).store_id
    store_item = item.store_items.find_by(store_id:, location: 'AVAILABLE')
    # Create a catalog item for primary catalog if missing
    ci = store_item.catalog_items.where(catalog_id:).first_or_initialize
    append_warning(item.sku, "Pricing difference for #{item.sku} in catalog #{catalog_id}: MSRP: #{ci.amount}!= #{pricing_data[:msrp]}") if ci.persisted? && (ci.amount != pricing_data[:msrp])
    ci.update!(amount: pricing_data[:msrp], state: 'active_hidden')
    # Now find the paired catalogs
    supplemental_catalod_id = SUPPLEMENTAL_CATALOG_MAPS[catalog_id][:catalog_id]
    # The catalog item after save will take care of populating the kit components but we need to specify skip_check_kit_components for it to be effective
    sup_ci = store_item.catalog_items.where(catalog_id: supplemental_catalod_id).first_or_initialize(amazon_desired_product_type: 'SPACE_HEATER')
    if sup_ci.persisted? && (sup_ci.amount != pricing_data[:catalog_price])
      append_warning(item.sku, " Pricing difference for #{item.sku} in catalog #{supplemental_catalod_id}: catalog original price: #{sup_ci.amount}!= #{pricing_data[:catalog_price]}")
    end
    sup_ci.update!(skip_check_kit_components: true, state: default_amazon_state, amount: pricing_data[:catalog_price])
  end
end

#build_store_items(item) ⇒ Object

Create or update store item in each main store (in the future Amazon FBA can be added here)



245
246
247
248
249
250
# File 'app/services/item/kit_composer.rb', line 245

def build_store_items(item)
  CORE_STORE_IDS.each do |store_id|
    store_item = item.store_items.where(store_id:, location: 'AVAILABLE').first_or_initialize
    store_item.update!(is_discontinued: false) if store_item.is_discontinued
  end
end

#calculate_membranes(area_sqft) ⇒ Object

This method can be used to find combination of membranes to fit
best a particular heating element, however since we limit our kits
to one membrane for the moment this is not used.



346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# File 'app/services/item/kit_composer.rb', line 346

def calculate_membranes(area_sqft)
  # Calculate the effective area with extra coverage
  small_cover = 54
  large_cover = 162
  target_coverage = area_sqft * 1.20
  # Since large is just a small * 3, let's see how many coverage units we need
  coverage_units = (target_coverage.to_r / small_cover).ceil
  # Take this number and determine how many will be large units
  large_needed = (coverage_units.to_r / 3).floor
  # What's left will be small needed
  small_needed = coverage_units - (large_needed * 3)
  total_coverage = (large_needed * large_cover) + (small_needed * small_cover)
  res = {}
  res[:small_needed] = small_needed if small_needed.positive?
  res[:large_needed] = large_needed if large_needed.positive?
  res[:total_coverage] = total_coverage
  res
end

#calculate_price(item, catalog_id, discounted_price_factor) ⇒ Object

Provided an msrp catalog (catalog id 1 or 2) and a percentage off, return the msrp price of the kit and the catalog price discounted.



282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'app/services/item/kit_composer.rb', line 282

def calculate_price(item, catalog_id, discounted_price_factor)
  # iterate through each component's msrp price in the target catalog
  res = { components: [], msrp: 0.0, catalog_price: 0.0 }
  item.target_item_relations.includes(:target_item).find_each do |tir|
    ci = CatalogItem.joins(:store_item).find_by(store_items: { item_id: tir.target_item_id, location: 'AVAILABLE' }, catalog_id:)
    res_component = {}
    res_component[:sku] = tir.target_item.sku
    res_component[:item_id] = tir.target_item_id
    res_component[:msrp] = ci.amount
    res_component[:catalog_price] = (ci.amount * discounted_price_factor).round(2)
    res[:components] << res_component
    # Except for kits we add the price
    unless res_component[:sku].in?(ItemConstants::TZ_CABLE_STRIP_SKUS)
      res[:msrp] += (res_component[:msrp] * tir.quantity).round(2)
      res[:catalog_price] += (res_component[:catalog_price] * tir.quantity).round(2)
    end
  end
  res
end

#compose_kits(main_he_item) ⇒ Object



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'app/services/item/kit_composer.rb', line 140

def compose_kits(main_he_item)
  kit_results = []
  # 1 kit per control
  @controls.each do |control_item|
    install_methods = (main_he_item.sku.start_with?('TCT') ? @cable_install_methods : [:na])
    install_methods.each do |install_method|
      logger.info "Main HE Item: #{main_he_item.sku}, Install Method: #{install_method}, Control Item: #{control_item.sku}"
      begin
        if (kit_result = create_or_fetch_kit(main_he_item:, control_item:, install_method:))
          kit_results << kit_result
        end
      rescue StandardError => e
        append_warning(main_he_item.sku, "#{e.message}.#{e.backtrace}")
        logger.info "Error with #{main_he_item.sku}, control: #{control_item.sku}, install_methods: #{install_methods} : #{e.message}"
        ap e.backtrace
      end
    end
  end
  kit_results
end

#create_or_fetch_kit(main_he_item:, control_item:, install_method:) ⇒ Object



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
190
191
192
193
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
237
238
239
240
241
242
# File 'app/services/item/kit_composer.rb', line 161

def create_or_fetch_kit(main_he_item:, control_item:, install_method:)
  # First we need to determine the sku of the kit
  kit_result = {}
  membranes = {}
  qty_fixing_strips = nil
  case install_method
  when :membrane
    membranes = find_best_membranes(main_he_item)
  when :fixing_strip
    qty_fixing_strips = find_best_qty_strips(main_he_item)
  end
  kit_attributes = generate_kit_attributes(main_he_item:, control_item:, accessories: @accessories, membranes:)
  item = Item.find_or_initialize_by(sku: kit_attributes[:sku])
  item.assign_attributes(kit_attributes)
  amazon_variation = AmazonVariation.find_or_create_variation_for_item(item)
  item.amazon_variation_id ||= amazon_variation&.id
  item.save!
  @item_changes[item.id] = item.saved_changes
  kit_result[:result] = item.new_record? ? :created : :updated

  # Synchronize the image to ensure it is linked back to this kit
  if kit_attributes[:primary_image_id].present?
    image = Image.find(kit_attributes[:primary_image_id])
    image.item_ids = ((image.item_ids || []) + [item.id]).compact.uniq.sort
  end

  if @operations.include?(:kit_contents)
    kit_contents = {}
    kit_contents[main_he_item.id] = { qty: 1, include_in_spec: true }
    membranes.each do |membrane_item, qty|
      kit_contents[membrane_item.id] = { qty:, include_in_spec: false }
    end
    kit_contents[control_item.id] = { qty: 1, include_in_spec: true }
    @accessories.each do |accessory_item|
      kit_contents[accessory_item.id] = { qty: 1, include_in_spec: false }
    end
    kit_contents[@fixing_strip.id] = { qty: qty_fixing_strips, include_in_spec: false } if qty_fixing_strips.present? && qty_fixing_strips > 0
    existing_kit_components_count = item.target_item_relations.where(relation_type: 'Kit Component').size
    build_kit_components = kit_result[:result] == :created || @force_sync_kit_contents || existing_kit_components_count.zero?
    audit_kit_components = kit_result[:result] == :updated && !@force_sync_kit_contents

    # Create two store items if we don't have them already
    build_store_items(item)

    if build_kit_components
      # Now build the kit proper
      kit_contents.each do |target_item_id, item_attributes|
        item.target_item_relations.where(relation_type: 'Kit Component', target_item_id:).first_or_initialize.update!(
          quantity: item_attributes[:qty],
          include_in_spec: item_attributes[:include_in_spec]
        )
      end
      append_warning(item.sku, 'Created kit components for existing item') if existing_kit_components_count.zero?
    elsif audit_kit_components # In this case we don't care since we cleaned them out anyway
      # log anomalies
      kit_contents.each do |item_id, item_attributes|
        target_item = item.target_item_relations.find_by(relation_type: 'Kit Component', target_item_id: item_id)
        if target_item
          append_warning(item.sku, "Item #{item_id} quantity mismatch: #{target_item.quantity}!= #{item_attributes[:qty]}") if target_item.quantity != item_attributes[:qty]
          append_warning(item.sku, "Item #{item_id} include_in_spec mismatch: #{target_item.include_in_spec}!= #{item_attributes[:include_in_spec]}") if target_item.include_in_spec != item_attributes[:include_in_spec]
        else
          append_warning(item.sku, "Item #{item_id} missing")
        end
      end
    end
    extra_items = item.target_item_relations.where.not(target_item_id: kit_contents.keys)
    extra_items_skus = extra_items.map { |tir| tir.target_item.sku }
    extra_items.delete_all if @force_sync_kit_contents # Delete extra items
    append_warning(item.sku, "Extra items in kit: #{extra_items_skus.join(', ')} #{'deleted' if @force_sync_kit_contents}") if extra_items_skus.present?

    # Run a consolidation
    Item::KitConsolidator.new(item).populate_components.consolidate_unit_cogs.consolidate_weights_and_dimensions.commit
  end

  if @operations.include?(:catalog_items)
    # Create catalog items
    build_catalog_items(item)
  end

  kit_result[:item] = item
  kit_result
end

#find_best_membranes(main_he_item) ⇒ Object



328
329
330
331
332
333
334
335
336
337
# File 'app/services/item/kit_composer.rb', line 328

def find_best_membranes(main_he_item)
  # determine membrane sku based on coverage
  area_sqft = main_he_item.coverage_at_3_75_in
  best_result = calculate_membranes(area_sqft)
  logger.debug "Membrane calculation for #{main_he_item.sku} / #{area_sqft}: #{best_result.inspect}"
  membranes = {}
  membranes[@membrane_large] = best_result[:large_needed] if best_result[:large_needed].present?
  membranes[@membrane_small] = best_result[:small_needed] if best_result[:small_needed].present?
  membranes
end

#find_best_qty_strips(main_he_item) ⇒ Object



339
340
341
# File 'app/services/item/kit_composer.rb', line 339

def find_best_qty_strips(main_he_item)
  main_he_item.calculate_number_of_fixing_strips_at_4_in
end

#find_or_create_kit_product_line(kag) ⇒ Object



320
321
322
323
324
325
326
# File 'app/services/item/kit_composer.rb', line 320

def find_or_create_kit_product_line(kag)
  parent_product_line_id = ProductLine.find_by!(slug_ltree: kag.base_primary_product_line_slug_ltree).id
  ProductLine.where(slug_ltree: kag.kit_product_line_slug_ltree).first_or_create!(
    name_en: kag.control_url_suffix.titleize,
    parent_id: parent_product_line_id
  )
end

#generate_kit_attributes(main_he_item:, control_item:, accessories:, membranes: nil) ⇒ Object



302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# File 'app/services/item/kit_composer.rb', line 302

def generate_kit_attributes(main_he_item:, control_item:, accessories:, membranes: nil)
  # main item name: TempZoneā„¢ Flex Roll Kit 1.5x21 ft (31.5 sqft) with nTrust Non Programmable Thermostat, 120V 4A
  attributes = {}
  kag = Item::KitAttributeGenerator.new(main_he_item:, control_item:, membranes:, accessories:)
  attributes[:sku] = kag.kit_sku
  attributes[:product_category_id] = @kit_product_category.id
  attributes[:tax_class] = 'g'
  attributes[:primary_product_line] = find_or_create_kit_product_line(kag)
  attributes[:is_cable_system] = main_he_item.is_cable_system
  attributes[:update_upc] = true
  attributes[:is_kit] = true
  attributes[:harmonization_code] = main_he_item.harmonization_code
  attributes[:item_category] = main_he_item.item_category
  attributes[:coo] = main_he_item.coo
  attributes[:name_en] = kag.item_name
  attributes
end

#process(limit: nil, trial_run: false) ⇒ Object

For cable, use sku_pattern: "TCT%-3.7W-%", for roll: "TRT%-1.5x%"



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'app/services/item/kit_composer.rb', line 104

def process(limit: nil, trial_run: false)
  @warnings = {}
  @item_changes = {}
  kit_results = []
  Item.transaction do
    @he_items = @he_items.limit(limit) if limit
    logger.info "Processing #{@he_items.count} items"

    @he_items.each do |main_he_item|
      logger.info "Processing #{main_he_item.sku}"
      kit_results += compose_kits(main_he_item)
    end
    # After the kits are created, run some qty consolidation and item async updates
    kit_results.filter_map { |r| r[:item] }.each do |item|
      logger.info "Performing post tasks on #{item.sku}"
      item.update_rendered_product_specifications if @operations.include?(:specs)
      # Get the right qty on hand
      Item::KitConsolidator.new(item).consolidate_qty.commit if @operations.include?(:qty)
    end
    csv = results_to_csv(kit_results)
    # logger.info csv
    if @results_file_path && kit_results.present?
      File.open(@results_file_path, 'wb') do |file|
        file.write(csv)
        file.flush
        file.fsync
      end
      logger.info "Results in #{@results_file_path}"
      system %(#{OS.open_file_command} "#{@results_file_path}") if Rails.env.development?
    end
    logger.warn("All Done with warnings", warning_count: @warnings&.size)
    raise ActiveRecord::Rollback if trial_run
  end
  kit_results
end

#results_to_csv(kit_results) ⇒ Object



373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
# File 'app/services/item/kit_composer.rb', line 373

def results_to_csv(kit_results)
  CSV.generate do |csv|
    csv << %w[result id sku name upc asin components shipping_weight
              shipping_length shipping_width shipping_height primary_image
              msrp_us msrp_ca
              amz_sc_us amz_sc_ca
              cogs_us cogs_ca
              gross_profit_us gross_profit_ca
              gross_profit_amz_us gross_profit_amz_ca
              gross_profit_margin_us gross_profit_margin_ca
              warnings
              changes]
    kit_results.each do |kit_result|
      item = kit_result[:item]
      result = kit_result[:result]
      warnings = @warnings[item.sku]
      changes = @item_changes[item.sku]
      csv << [
        result,
        item.id,
        item.sku,
        item.name,
        item.upc,
        item.amazon_asin,
        item.target_item_relations.order(:id).where(relation_type: 'Kit Component').includes(:target_item).map { |tir| "#{tir.target_item.sku} (#{tir.quantity.to_i})" }.join(', '),
        item.shipping_weight,
        item.shipping_length,
        item.shipping_width,
        item.shipping_height,
        item.primary_image_id,
        (msrp_us = item.catalog_items.find_by(catalog_id: 1)&.amount),
        (msrp_ca = item.catalog_items.find_by(catalog_id: 2)&.amount),
        (amz_sc_us = item.catalog_items.find_by(catalog_id: CatalogConstants::AMAZON_SC_US_CATALOG_ID)&.amount),
        (amz_sc_ca = item.catalog_items.find_by(catalog_id: CatalogConstants::AMAZON_SC_CA_CATALOG_ID)&.amount),
        (cogs_us = item.store_items.find_by(store_id: 1, location: 'AVAILABLE')&.unit_cogs),
        (cogs_ca = item.store_items.find_by(store_id: 2, location: 'AVAILABLE')&.unit_cogs),
        ((msrp_us - cogs_us).round(2) if msrp_us && cogs_us),
        ((msrp_ca - cogs_ca).round(2) if msrp_ca && cogs_ca),
        ((gross_profit_amz_us = (amz_sc_us - cogs_us)).round(2) if amz_sc_us && cogs_us),
        ((gross_profit_amz_ca = (amz_sc_ca - cogs_ca)).round(2) if amz_sc_ca && cogs_ca),
        ((gross_profit_amz_us / amz_sc_us).round(2) if gross_profit_amz_us && amz_sc_us),
        ((gross_profit_amz_ca / amz_sc_ca).round(2) if gross_profit_amz_ca && amz_sc_ca),
        warnings.presence&.join(', ') || '',
        changes.presence&.inspect || ''
      ]
    end
  end
end