Class: Item::KitComposer

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

Constant Summary collapse

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 =
{
  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 }
}
CORE_STORE_IDS =
[1, 2]
CORE_CATALOG_IDS =
[1, 2]
ACCESSORIES =
['SS-01']
FIXING_STRIP =
ItemConstants::TZ_CABLE_GRIPSTRIP_SKU
MEMBRANE_SMALL =
ItemConstants::MEMBRANE_SMALL_SKU
MEMBRANE_LARGE =
ItemConstants::MEMBRANE_LARGE_SKU
CABLE_SKU_PATTERN =
'TCT%-3.7W-%'
ROLL_SKU_PATTERN =
'TRT%-1.5x%'
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

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from BaseService

#log_debug, #log_error, #log_info, #log_warning, #logger, #options, #tagged_logger

Constructor Details

#initialize(options = {}) ⇒ KitComposer

Returns a new instance of KitComposer.



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
# File 'app/services/item/kit_composer.rb', line 55

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



85
86
87
88
# File 'app/services/item/kit_composer.rb', line 85

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



352
353
354
355
356
357
358
# File 'app/services/item/kit_composer.rb', line 352

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



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'app/services/item/kit_composer.rb', line 241

def build_catalog_items(item, supplemental_catalog_map: SUPPLEMENTAL_CATALOG_MAPS)
  # Hide in primary catalog
  item.catalog_items.where(catalog_id: [1, 2]).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)



232
233
234
235
236
237
# File 'app/services/item/kit_composer.rb', line 232

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.



333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'app/services/item/kit_composer.rb', line 333

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.



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'app/services/item/kit_composer.rb', line 269

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



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'app/services/item/kit_composer.rb', line 127

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



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
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
# File 'app/services/item/kit_composer.rb', line 148

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



315
316
317
318
319
320
321
322
323
324
# File 'app/services/item/kit_composer.rb', line 315

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



326
327
328
# File 'app/services/item/kit_composer.rb', line 326

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



307
308
309
310
311
312
313
# File 'app/services/item/kit_composer.rb', line 307

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



289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
# File 'app/services/item/kit_composer.rb', line 289

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%"



91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'app/services/item/kit_composer.rb', line 91

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.map { |r| r[:item] }.compact.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



360
361
362
363
364
365
366
367
368
369
370
371
372
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
# File 'app/services/item/kit_composer.rb', line 360

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