Class: HeatingSystemCalculator::AccessoriesFinder

Inherits:
BaseItemFinder show all
Defined in:
app/services/heating_system_calculator/accessories_finder.rb

Overview

Service object: accessories finder.

Constant Summary collapse

CONCRETE_ONLY_CABLE_ACCESSORY_SKUS =

SKUs that only make sense when fixing strips are attached to a concrete slab
(you can't nail/staple into concrete, so you need the screws, drill bit, and
double-sided tape). Fixing strips themselves are NOT concrete-only — they're
required for any non-membrane install (plywood, OSB, concrete, etc.).

[
  ItemConstants::TZ_CABLE_TAPE_SKU,
  ItemConstants::TZ_CABLE_CONCRETE_SCREW_LARGE_SKU,
  ItemConstants::TZ_CABLE_CONCRETE_SCREW_SMALL_SKU,
  ItemConstants::TZ_CABLE_CONCRETE_DRILL_BIT_SKU
].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods inherited from BaseItemFinder

#get_number_of_poles_from_consolidated_elements, #get_total_from_solution

Constructor Details

#initialize(options) ⇒ AccessoriesFinder

Returns a new instance of AccessoriesFinder.



8
9
10
11
12
13
14
15
# File 'app/services/heating_system_calculator/accessories_finder.rb', line 8

def initialize(options)
  @heated_area = options[:heated_area]
  @heating_system = options[:heating_system]
  @accessory_set = options[:accessory_set]
  @underlayment_set = options[:underlayment_set]
  @rough_in_kit_for_tstat = options[:rough_in_kit_for_tstat]
  @rough_in_kit_for_power_module = options[:rough_in_kit_for_power_module]
end

Instance Attribute Details

#accessoriesObject (readonly)

Returns the value of attribute accessories.



6
7
8
# File 'app/services/heating_system_calculator/accessories_finder.rb', line 6

def accessories
  @accessories
end

#errorObject (readonly)

Returns the value of attribute error.



6
7
8
# File 'app/services/heating_system_calculator/accessories_finder.rb', line 6

def error
  @error
end

Instance Method Details

#calculate_elements_qty_and_tz_cable_accessories_qty(elements) ⇒ Object



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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
# File 'app/services/heating_system_calculator/accessories_finder.rb', line 123

def calculate_elements_qty_and_tz_cable_accessories_qty(elements)
  # decide on quantities, generally one per heating element except for TZ cable guides
  qty = 0
  tz_cable_accessories_qtys = {}
  # decide if we find tz cable strips and related accessories
  find_tz_cable_strips = RoomConfiguration::PUBLIC_TZ_CABLE_SPACINGS.include?(@heating_system.cable_spacing) && !@heating_system.membrane_recommended ? true : false

  # Batch load all element items upfront to avoid N+1 queries
  element_skus = elements.pluck('sku').compact
  items_by_sku = Item.where(sku: element_skus).index_by(&:sku)

  elements.each do |e|
    qty += e['qty'] || 1
    next unless @heating_system.heating_system_product_line.is_cable_system?

    item = items_by_sku[e['sku']]
    next unless item && (item.is_tz_thin_cable? || item.is_tz_cable?) && find_tz_cable_strips

    cable_spacing = @heating_system.cable_spacing.to_f.round
    number_of_strips = item.calculate_number_of_fixing_strips_at_cable_spacing(cable_spacing)
    if number_of_strips&.> 0
      tz_cable_accessories_qtys[ItemConstants::TZ_CABLE_GRIPSTRIP_SKU] ||= 0
      tz_cable_accessories_qtys[ItemConstants::TZ_CABLE_GRIPSTRIP_SKU] += number_of_strips
    end
  end

  num_strips = ItemConstants::TZ_CABLE_STRIP_SKUS.sum { |sku| tz_cable_accessories_qtys[sku].to_i }

  if @heating_system.sub_floor_type&.seo_key&.index('concrete') && num_strips.positive?
    # we have concrete subfloor and TZ cable, we need concrete drill bits, screws and tape
    # figure out the number of screws
    num_screws = (num_strips * HeatingSystemItems::SCREWS_PER_STRIP_WITH_OVERAGE).round
    # figure out the length of tape
    length_tape = (num_strips * HeatingSystemItems::TAPE_FEET_PER_STRIP_WITH_OVERAGE).round
    qtys_by_sku = {}
    # calculate number of screw packs and drill bit of various skus
    # if we are most of the way into a 100 pack take that because of the included drill bit
    large_pack_size = HeatingSystemItems::CONCRETE_SCREW_PACK_LARGE_SIZE
    small_pack_size = HeatingSystemItems::CONCRETE_SCREW_PACK_SMALL_SIZE
    qtys_by_sku[ItemConstants::TZ_CABLE_CONCRETE_SCREW_LARGE_SKU] = (num_screws / large_pack_size).round
    # figure out if we need any small packs to reach the number of screws needed
    num_exc_screws = (num_screws - (large_pack_size * qtys_by_sku[ItemConstants::TZ_CABLE_CONCRETE_SCREW_LARGE_SKU]))
    qtys_by_sku[ItemConstants::TZ_CABLE_CONCRETE_SCREW_SMALL_SKU] = (num_exc_screws.to_f / small_pack_size).ceil if num_exc_screws.positive?
    # figure out if we need to enclose a drill bit
    qtys_by_sku[ItemConstants::TZ_CABLE_CONCRETE_DRILL_BIT_SKU] = 1 if qtys_by_sku[ItemConstants::TZ_CABLE_CONCRETE_SCREW_LARGE_SKU].zero?
    # calculate number of tape items of various skus
    # for now we assume only the TZ cable double-sided tape
    tape_roll_length = HeatingSystemItems::TZ_CABLE_TAPE_ROLL_LENGTH_FT
    qtys_by_sku[ItemConstants::TZ_CABLE_TAPE_SKU] = (length_tape.to_f / tape_roll_length).ceil
    # iterate over these to populate tz_cable_accessories_qtys, but only when qty > 0
    qtys_by_sku.each do |sku, q|
      tz_cable_accessories_qtys[sku] = q if q.to_i.positive?
    end
  end
  [qty, tz_cable_accessories_qtys]
end

#calculate_tstat_qty_and_pm_control_qty(controls) ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
# File 'app/services/heating_system_calculator/accessories_finder.rb', line 111

def calculate_tstat_qty_and_pm_control_qty(controls)
  tstat_qty = 1
  pm_control_qty = 0
  # controls are an array of control hashes with a bundle sku, so group by that and take the first (nSpire series tstats use power modules in exactly the same way so they are all the same) which will yield an array with two values: a bundle sku and an array of control hashes, count the pms in the array of control hashes
  if controls.any? { |c| c['symbol'] == 'RELAY' }
    controls.each do |c|
      pm_control_qty += c['qty'] if c['symbol'] == 'RELAY'
    end
  end
  [tstat_qty, pm_control_qty]
end

#find_accessories(elements, controls) ⇒ Object



17
18
19
20
21
22
23
24
25
26
# File 'app/services/heating_system_calculator/accessories_finder.rb', line 17

def find_accessories(elements, controls)
  @accessories = []
  if @heating_system.heating_system_product_line.snow_melt?
    select_snow_melting_accessories(elements, controls)
  else
    select_floor_heating_accessories(elements, controls)
    select_underlayment(elements, controls)
  end
  self
end

#select_floor_heating_accessories(elements, controls) ⇒ Object



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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'app/services/heating_system_calculator/accessories_finder.rb', line 57

def select_floor_heating_accessories(elements, controls)
  # calculate number of rough-in kits with 2 conduits for the tstat and with 1 conduit for each pm
  tstat_qty, pm_control_qty = calculate_tstat_qty_and_pm_control_qty(controls)

  # calculate quantities of elements and tz cable accessories, like strips, tape and screws
  qty, tz_cable_accessories_qtys = calculate_elements_qty_and_tz_cable_accessories_qty(elements)

  # Batch load all accessory items upfront to avoid N+1 queries
  accessory_skus = @accessory_set.pluck('sku').compact
  items_by_sku = Item.where(sku: accessory_skus)
                     .includes(:primary_product_line, :exclusive_item_group)
                     .index_by(&:sku)

  # Concrete-only attachment accessories (screws, drill bit, tape) only apply
  # when fixing strips are being attached to a concrete slab. The inner calc
  # already excludes them for non-concrete subfloors, so this guard is mostly
  # belt-and-suspenders — but it also drops them when membrane is used.
  subfloor_is_concrete = @heating_system.sub_floor_type&.seo_key == 'existing-concrete-slab'
  show_concrete_only_accessories = !@heating_system.membrane_recommended && subfloor_is_concrete

  # populate the accessories
  @accessory_set.each do |accessory|
    next unless (acc_item = items_by_sku[accessory['sku']])
    next if CONCRETE_ONLY_CABLE_ACCESSORY_SKUS.include?(accessory['sku']) && !show_concrete_only_accessories

    if @heating_system.heating_system_product_line.is_cable_system? && tz_cable_accessories_qtys[accessory['sku']].to_i.positive?
      # @accessories << accessory.merge({'qty' => tz_cable_accessories_qtys[accessory['sku']], 'symbol' => 'CABLESTRIP'})
      @accessories << accessory.merge({ 'qty' => tz_cable_accessories_qtys[accessory['sku']], 'symbol' => 'CABLESTRIP', 'price' => 0.0 }) # force TC stripts to be free with a quoted system
    # CABLESTRIP accessories (TC-GRIPSTRIP, TC-STRIP-PL) must ONLY enter via the IF branch above,
    # where find_tz_cable_strips correctly gates them on membrane_recommended. The elsif below
    # uses primary_product_line.is_cable_system? which returns false for ruler_cable / thin_cable
    # (path_includes?('cable') matches '.cable' as a segment, not '_cable' as a suffix), so
    # without this guard a TC-GRIPSTRIP whose primary_product_line is floor_heating.tempzone.ruler_cable
    # leaks into Cable + Prodeso quotes with qty=1. See: quote IQR1616897.
    elsif accessory['symbol'].to_s.upcase != 'CABLESTRIP' && !acc_item.primary_product_line.is_cable_system? && !acc_item.is_membrane?
      # here we have non tz cable accessories
      @accessories << if acc_item.exclusive_item_group # installation kits, these need the exclusive groupings since you only choose one within the group ie radio button
                        accessory.merge({ 'qty' => 1, 'exclusive_group_type' => acc_item.exclusive_item_group.key,
                                          'exclusive_group_name' => acc_item.exclusive_item_group.name })
                      elsif acc_item.is_circuit_check? # circuit check: 1 per heating element
                        accessory.merge({ 'qty' => qty })
                      else # any other accessory
                        accessory.merge({ 'qty' => 1 })
                      end
    end
  end
  # treat tstat rough in kits separately since they are differently categorized
  @accessories << @rough_in_kit_for_tstat.merge({ 'qty' => tstat_qty }) if tstat_qty.positive? && @rough_in_kit_for_tstat
  # treat power module rough in kits separately since they are differently categorized
  return unless pm_control_qty.positive?

  @accessories << @rough_in_kit_for_power_module.merge({ 'qty' => pm_control_qty }) if @rough_in_kit_for_power_module
end

#select_snow_melting_accessories(elements, _controls) ⇒ Object



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'app/services/heating_system_calculator/accessories_finder.rb', line 28

def select_snow_melting_accessories(elements, _controls)
  # add SMP sku if present
  accessory = @accessory_set.find { |a| a['sku'] == 'SMP' }
  @accessories << accessory.merge({ 'qty' => 1 }) if accessory
  # add junction box sku based on the number of heating elements
  selected_junction_box = 0
  selected_junction_box_num_cold_leads = HeatingSystemCalculator::HeatingSystemItems::JUNCTION_BOX_COLD_LEAD_MAX_NUMBERS[0]
  elements_count = elements.to_a.sum { |e| e['qty'] }
  HeatingSystemCalculator::HeatingSystemItems::JUNCTION_BOX_COLD_LEAD_MAX_NUMBERS.each_with_index do |jb_num_cold_leads, i|
    selected_junction_box = i
    selected_junction_box_num_cold_leads = HeatingSystemCalculator::HeatingSystemItems::JUNCTION_BOX_COLD_LEAD_MAX_NUMBERS[i]
    break if jb_num_cold_leads >= elements_count
  end
  accessory = @accessory_set.find { |a| a['sku'] == ItemConstants::JUNCTION_BOX_SKUS[selected_junction_box] }
  qty = (elements_count.to_f / selected_junction_box_num_cold_leads).ceil
  @accessories << accessory.merge({ 'qty' => qty }) if accessory
end

#select_underlayment(_elements, _controls) ⇒ Object



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
# File 'app/services/heating_system_calculator/accessories_finder.rb', line 180

def select_underlayment(_elements, _controls)
  underlayment_options = HeatingElementProductLineOption.get_suggested_underlayments({
                                                                                       sqft: @heated_area.insulation_surface,
                                                                                       sub_floor_type_id: @heating_system.sub_floor_type&.id,
                                                                                       floor_type_id: @heating_system.floor_type.id,
                                                                                       heating_system_type_name: @heating_system.heating_system_type_name,
                                                                                       catalog_id: @heating_system.catalog.id,
                                                                                       store_id: @heating_system.catalog.store_id,
                                                                                       ideal_cable_spacing: @heating_system.cable_spacing,
                                                                                       membrane_recommended: @heating_system.membrane_recommended
                                                                                     })

  underlayment_options.each do |sku, line|
    underlayment = @underlayment_set.find { |u| u['sku'] == sku }
    if underlayment
      # Calculate area from width x length if not pre-computed (width/length in inches)
      unit_area = underlayment['area'] || (underlayment['width'].to_f * underlayment['length'].to_f / HeatingSystemItems::SQUARE_INCHES_PER_SQUARE_FOOT)
      total_sqft = (unit_area * line[:quantity]).round
      name_appended_blurb = total_sqft.positive? ? ", total sq.ft.: #{total_sqft}" : ''
      # Mark as recommended since it came from get_suggested_underlayments
      @accessories << underlayment.merge({
                                           'qty' => line[:quantity],
                                           'name' => "#{underlayment['name']}#{name_appended_blurb}",
                                           'recommended' => true
                                         })
    else
      @error = { error_status: :underlayment_not_found, error_message: "Required underlayment with SKU #{sku} not found." }
      return self
    end
  end
end