Module: Models::Pickable

Extended by:
ActiveSupport::Concern
Included in:
Order, Quote
Defined in:
app/concerns/models/pickable.rb

Has many collapse

Instance Method Summary collapse

Instance Method Details

#all_my_publicationsObject



268
269
270
271
272
273
274
275
276
277
# File 'app/concerns/models/pickable.rb', line 268

def all_my_publications
  pubs = nil
  line_items.each do |li|
    item_pubs = li.item.all_my_publications
    pubs = pubs.nil? ? item_pubs : (pubs | item_pubs) # This builds up an or condition
  end
  pubs = pubs.in_store(store.id) # Only publications relevant to what the order's store should be used regardless of locale
  pubs = yield(pubs) if block_given?
  pubs
end

#append_suggested_items(suggested_items_list, containing_resource) ⇒ Object



279
280
281
282
283
# File 'app/concerns/models/pickable.rb', line 279

def append_suggested_items(suggested_items_list, containing_resource)
  suggested_items_list.tap do |suggested_items_list|
    append_suggested_materials(suggested_items_list, containing_resource)
  end
end

#append_suggested_materials(suggested_items_list, _containing_resource = nil) ⇒ Object



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'app/concerns/models/pickable.rb', line 285

def append_suggested_materials(suggested_items_list, _containing_resource = nil)
  suggested_items_list.tap do |suggested_items_list|
    catalog = customer.catalog
    # Now figure out the suggested items
    material_alerts = get_material_alerts
    material_alerts.select { |ma| ma.items.any? and (ma.recommended_qty > ma.actual_qty) }.each do |ma|
      logger.debug "append_suggested_materials, ma.group_name: #{ma.group_name}"
      logger.debug "append_suggested_materials, ma.items.map{|i| i.sku}: #{ma.items.map(&:sku).inspect}"
      ma.items.each do |item|
        ci = catalog.catalog_items.by_skus(item.sku).first
        next unless ci

        # re-apply stock thresholds and customer filters for quote pdf
        stock_threshold_ok = false
        stock_threshold_ok = true if ci.item.suggested_item_applies_to(customer) && (ci.qty_available > item.suggested_item_stock_threshold)
        add_item = true
        if stock_threshold_ok && (ma.group_name.to_s == 'Installation Kits')
          # ugh, hacky but this is the y to accomodate all the weird patterns for suggested add ons in the quote
          add_item = false
          logger.debug "append_suggested_materials, item.sku: #{item.sku}, add_item: #{add_item}"
        end
        next unless add_item

        ci_discount = [customer.pricing_program_discount, ci.max_discount].min
        discount_unit_price = (ci.amount * (1 - (ci_discount / 100)).to_f).round(2)
        suggested_items_list ||= {}
        suggested_items_list[self] ||= []
        suggested_items_list[self] << LineItem.new(item: item, resource: self, catalog_item_id: ci.id, description: ci.public_name, quantity: (ma.recommended_qty - ma.actual_qty), price: ci.amount, discounted_price: discount_unit_price)
      end
    end
  end
end

#control_capacity(product_line_url = nil) ⇒ Object



65
66
67
68
69
70
71
72
# File 'app/concerns/models/pickable.rb', line 65

def control_capacity(product_line_url = nil)
  lines = if product_line_url.present?
            [line_items.controls.by_product_line_url(product_line_url), line_items.integration_kits.by_product_line_url(product_line_url), line_items.powers.by_product_line_url(product_line_url)].flatten
          else
            [line_items.controls, line_items.integration_kits, line_items.powers].flatten
          end
  lines.map { |li| li.quantity * li.item.control_capacity }.sum
end

#determine_catalog_for_pickingObject



260
261
262
# File 'app/concerns/models/pickable.rb', line 260

def determine_catalog_for_picking
  try(:is_store_transfer?) ? from_store.primary_catalog : customer.catalog
end

#determine_skus_to_filterObject



264
265
266
# File 'app/concerns/models/pickable.rb', line 264

def determine_skus_to_filter
  try(:is_store_transfer?) ? to_store.primary_catalog.items.pluck(:sku) : nil
end

#discounted_shipping_totalObject



174
175
176
# File 'app/concerns/models/pickable.rb', line 174

def discounted_shipping_total
  line_items.active_shipping_lines.sum(&:total)
end

#electrical_heating_elementsObject



30
31
32
# File 'app/concerns/models/pickable.rb', line 30

def electrical_heating_elements
  line_items.goods.joins(item: :primary_product_line).heating_elements.order('product_lines.lineage_expanded')
end

#fix_catalogObject



256
257
258
# File 'app/concerns/models/pickable.rb', line 256

def fix_catalog
  CatalogItem::Remapper.new(self).fix_all
end

#get_material_alerts(for_www: false, for_cart: false, _cache: true) ⇒ Array<MaterialAlert>

Returns persisted material alerts for this resource.
Uses database persistence instead of Rails.cache to avoid serialization issues.

Parameters:

  • for_www (Boolean) (defaults to: false)

    whether alerts are for www display (affects some alert messages)

  • for_cart (Boolean) (defaults to: false)

    whether alerts are for cart display

  • cache (Boolean)

    whether to use persistence (always true now, kept for API compatibility)

Returns:

  • (Array<MaterialAlert>)

    array of material alerts with items eager-loaded



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'app/concerns/models/pickable.rb', line 325

def get_material_alerts(for_www: false, for_cart: false, _cache: true)
  return [] unless respond_to?(:line_items)

  # Eager load items to avoid N+1 during signature calculation (which calls line_item.sku)
  current_signature = line_items.includes(:item).signature
  if current_signature.nil?
    ErrorReporting.warning("Material alerts: line_items.signature returned nil for #{self.class.name} #{id}")
    return []
  end

  # Check for existing valid alerts with matching signature
  existing = MaterialAlert.for_resource(self, signature: current_signature)
  return existing.to_a if existing.exists?

  # Generate fresh alerts and persist them
  options = { for_www: for_www, for_cart: for_cart }
  check_results = Item::Materials::Check.new.perform_checks_raw(
    container: self,
    options: options
  )

  MaterialAlert.regenerate_for(self, check_results: check_results, signature: current_signature)
end

#has_custom_products?Boolean

Returns:

  • (Boolean)


22
23
24
# File 'app/concerns/models/pickable.rb', line 22

def has_custom_products?
  line_items.joins(:item).where(items: { product_category_id: ProductCategory.custom_product_ids }).exists?
end

#invalidate_material_alerts!Integer

Invalidates (deletes) all cached material alerts for this resource.
Call this when line items change significantly outside normal flow,
such as during order split operations.

Returns:

  • (Integer)

    number of deleted alerts



354
355
356
# File 'app/concerns/models/pickable.rb', line 354

def invalidate_material_alerts!
  MaterialAlert.invalidate_for(self)
end

#line_discountsActiveRecord::Relation<LineDiscount>

Returns:

See Also:



16
# File 'app/concerns/models/pickable.rb', line 16

has_many :line_discounts, through: :line_items

#line_itemsActiveRecord::Relation<LineItem>

Returns:

See Also:



10
11
12
13
14
15
# File 'app/concerns/models/pickable.rb', line 10

has_many             :line_items,
as: :resource,
dependent: :destroy,
extend: LineItemExtension,
autosave: true,
inverse_of: :resource

#line_items_grouped_by_room_configuration(ungrouped_name: nil, group_by_item_category: true, list_empty_group: true, include_shipping_lines: true, sort_by_product_category_priority: false, exclude_unnamed_empty_group: true) ⇒ Object



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
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'app/concerns/models/pickable.rb', line 113

def line_items_grouped_by_room_configuration(ungrouped_name: nil,
                                             group_by_item_category: true,
                                             list_empty_group: true,
                                             include_shipping_lines: true,
                                             sort_by_product_category_priority: false,
                                             exclude_unnamed_empty_group: true)
  ungrouped_name ||= 'Ungrouped items'
  items = {}
  if instance_of? RoomConfiguration
    l_items = if include_shipping_lines
                line_items.parents_only.with_associations
              else
                line_items.parents_only.with_associations.reject(&:is_shipping?)
              end
    items[name] = l_items.to_a
  else
    rc_ids = []
    room_configurations.order(:name).each do |rc|
      rc_ids << rc.id
      items[rc] ||= [] # Required to set order
    end
    items[ungrouped_name] = []

    line_items.parents_only
              .includes(:item, :line_discounts, :inventory_commits, catalog_item: :store_item)
              .reject(&:is_shipping?).each do |li|
      rc = (li.resource.is_a?(RoomConfiguration) ? li.resource : li.room_configuration)
      group_id = (!rc.nil? && rc_ids.include?(rc.id) ? rc : ungrouped_name)
      items[group_id] ||= []
      items[group_id] << li
    end
  end

  items.each { |key, lines| items[key] = LineItem.group_by_category(lines) } if group_by_item_category
  if sort_by_product_category_priority
    items.each do |key, lines|
      items[key] = lines.sort_by do |li|
        [(begin
          li.item.product_category.priority
        rescue StandardError
          99
        end), li.item.sku]
      end
    end
  end
  items = items.delete_if { |_key, lines| lines.empty? } unless list_empty_group
  items = items = items.delete_if { |key, lines| key == ungrouped_name && lines.empty? } if exclude_unnamed_empty_group
  items
end

#meets_custom_products_threshold?Boolean

Returns:

  • (Boolean)


26
27
28
# File 'app/concerns/models/pickable.rb', line 26

def meets_custom_products_threshold?
  has_custom_products? && subtotal >= OrderConstants::CUSTOM_ORDER_AGREEMENT_THRESHOLD
end

#pricing_program_discount_factorObject



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'app/concerns/models/pickable.rb', line 178

def pricing_program_discount_factor
  effective_discount = (begin
    pricing_program_discount
  rescue StandardError
    nil
  end)
  effective_discount ||= begin
    quote.pricing_program_discount
  rescue StandardError
    nil
  end
  # Order's quote will have precedence over customers
  effective_discount ||= begin
    customer.pricing_program_discount
  rescue StandardError
    nil
  end

  (effective_discount.to_f / 100).round(2)
end

#purge_empty_line_itemsObject



74
75
76
77
# File 'app/concerns/models/pickable.rb', line 74

def purge_empty_line_items
  logger.info "#{self.class.name}:#{id} purge_empty_line_items called"
  line_items.select { |li| li.quantity == 0 }.each(&:destroy)
end

#soft_recalc(_reset_to_catalog_price = true) ⇒ Object

this is an admin method for us when fixing order discrepancies



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'app/concerns/models/pickable.rb', line 80

def soft_recalc(_reset_to_catalog_price = true)
  prev_value = begin
    disable_auto_coupon
  rescue StandardError
    nil
  end
  unless is_a? RoomConfiguration
    reload
    self.disable_auto_coupon = true
    self.line_total = nil
    self.tax_total = nil
    self.total = nil
  end
  save!
  update_attribute(:disable_auto_coupon, prev_value) unless is_a? RoomConfiguration
end

#subtotal(limit_to_types = []) ⇒ Object



163
164
165
166
167
168
169
170
171
172
# File 'app/concerns/models/pickable.rb', line 163

def subtotal(limit_to_types = [])
  l_its = line_items.active_non_shipping_lines
  if limit_to_types.present? && limit_to_types.any?
    l_its = []
    limit_to_types.each do |ltype|
      l_its += line_items.send(ltype)
    end
  end
  l_its.sum(&:total)
end

#subtotal_after_trade_discount_without_shippingObject



103
104
105
106
107
# File 'app/concerns/models/pickable.rb', line 103

def subtotal_after_trade_discount_without_shipping
  # Use line_discounts for exact totals (prevents rounding errors)
  # This matches LineItem#discounted_total and SQL calculate_itemizable_total function
  line_items.non_shipping.includes(:line_discounts).to_a.sum(&:discounted_total)
end

#subtotal_discounted_without_shippingObject



97
98
99
100
101
# File 'app/concerns/models/pickable.rb', line 97

def subtotal_discounted_without_shipping
  # Use line_discounts for exact totals (prevents rounding errors)
  # This matches LineItem#discounted_total and SQL calculate_itemizable_total function
  line_items.non_shipping.includes(:line_discounts).to_a.sum(&:discounted_total)
end

#subtotal_msrp_without_shippingObject



109
110
111
# File 'app/concerns/models/pickable.rb', line 109

def subtotal_msrp_without_shipping
  BigDecimal(line_items.non_shipping.sum('(price * quantity)'))
end

#synchronize_lines(target = nil, save_targets = false, _do_not_recalc_shipping = false) ⇒ Object



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
243
244
245
246
247
248
249
250
251
252
253
254
# File 'app/concerns/models/pickable.rb', line 199

def synchronize_lines(target = nil, save_targets = false, _do_not_recalc_shipping = false)
  lines_changed = []

  targets = [target || try(:synchronization_targets)].flatten
  # Rooms push to their quotes, quotes and orders push to their room
  targets.each do |i|
    # The lines we match against varies, a room configuration will sync into quotes and order and the lines need to be filtered to be just those of the room configuration, but from a quote or order to room there's no need to do a filter
    target_lines = []
    rc_id_filter = nil
    if i.is_a? RoomConfiguration
      rc_id_filter = i.id
      target_lines = i.line_items.active_parent_lines
      source_lines = line_items.active_parent_lines.select { |li| li.room_configuration_id == i.id }
    else
      rc_id_filter = id
      source_lines = line_items.active_parent_lines
      target_lines = i.line_items.active_parent_lines.select { |lir| lir.room_configuration_id == id }
    end
    # Try to match these room lines in the itemizable to our room
    merged_line_ids = []
    change_detected = false
    target_lines.each do |lir|
      # iterate through each target line and find it in the source lines. beware of what was already matched.
      matched_line_in_source = source_lines.detect do |li|
        li.catalog_item_id == lir.catalog_item_id and !merged_line_ids.include? li.id
      end

      if matched_line_in_source
        # update quantities if different
        unless lir.quantity == matched_line_in_source.quantity
          lir.quantity = matched_line_in_source.quantity
          lines_changed << "#{i.id}[#{lir.id}M]"
          change_detected = true
        end
        # Keep track of lines we processed
        merged_line_ids << matched_line_in_source.id
      else
        lines_changed << "#{i.id}[#{lir.id}-]"
        lir.destroy
        change_detected = true
      end
    end
    # Anything unmatched is added
    source_lines.reject { |li| merged_line_ids.include? li.id }.each do |new_line|
      change_detected = true
      attrs = new_line.attributes.except('id', 'resource_id', 'resource_type', 'delivery_id', 'created_at', 'updated_at')
      attrs['room_configuration_id'] = rc_id_filter
      i.line_items.build(attrs)
      lines_changed << "#{i.id}[#{new_line.id}+]"
    end
    i.save if save_targets
    i.refresh_plans(delay: 10.seconds) if i.is_a?(RoomConfiguration) && i.respond_to?(:refresh_plans) && change_detected
  end

  lines_changed
end

#total_amps_by_product_lineObject



61
62
63
# File 'app/concerns/models/pickable.rb', line 61

def total_amps_by_product_line
  total_spec_by_product_line(electrical_heating_elements, 'amps')
end

#total_coverage_by_product_lineObject



47
48
49
50
51
# File 'app/concerns/models/pickable.rb', line 47

def total_coverage_by_product_line
  total_spec_by_product_line(electrical_heating_elements, 'coverage').merge(
    total_spec_by_product_line(underlayments, 'coverage')
  )
end

#total_linear_feet_by_product_lineObject



53
54
55
# File 'app/concerns/models/pickable.rb', line 53

def total_linear_feet_by_product_line
  total_spec_by_product_line(electrical_heating_elements, 'length', 1.to_f / 12)
end

#total_spec_by_product_line(scope, spec, multiplier = 1) ⇒ Object



38
39
40
41
42
43
44
45
# File 'app/concerns/models/pickable.rb', line 38

def total_spec_by_product_line(scope, spec, multiplier = 1)
  r = scope.where(parent_id: nil).pluck(Arel.sql("product_lines.lineage_expanded,items.rendered_product_specifications->'#{spec}'->'raw',quantity")).select { |e| e[1] && e[2] }
  r.each_with_object({}) do |r, hsh|
    hsh[r[0]] ||= 0.0
    hsh[r[0]] += (r[1].to_f * r[2].to_f) * multiplier
    hsh[r[0]] = hsh[r[0]].round
  end
end

#total_watts_by_product_lineObject



57
58
59
# File 'app/concerns/models/pickable.rb', line 57

def total_watts_by_product_line
  total_spec_by_product_line(electrical_heating_elements, 'watts')
end

#underlaymentsObject



34
35
36
# File 'app/concerns/models/pickable.rb', line 34

def underlayments
  line_items.goods.joins(item: :primary_product_line).underlayments.order('product_lines.lineage_expanded')
end