Module: Models::Itemizable

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

Instance Attribute Summary collapse

Belongs to collapse

Has many collapse

Instance Method Summary collapse

Instance Attribute Details

#force_total_resetObject

Returns the value of attribute force_total_reset.



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

def force_total_reset
  @force_total_reset
end

#total_resetObject

Returns the value of attribute total_reset.



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

def total_reset
  @total_reset
end

Instance Method Details

#account_specialistEmployee

Returns:

See Also:



17
# File 'app/concerns/models/itemizable.rb', line 17

belongs_to :account_specialist, class_name: 'Employee', optional: true

#add_line_item(options) ⇒ Object



29
30
31
32
33
34
35
36
37
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
# File 'app/concerns/models/itemizable.rb', line 29

def add_line_item(options)
  do_not_autosave = options.delete(:do_not_autosave)
  ci = CatalogItem.find options[:catalog_item_id]
  keep_line_items_separate = options.delete(:keep_line_items_separate)
  # any item with sku in SINGLE_QUANTITY_SKUS defaults to be a separate line item unless overridden with keep_line_items_separate option
  keep_line_items_separate ||= Item::SINGLE_QUANTITY_SKUS.include?(ci.sku) if keep_line_items_separate.nil?
  options[:quantity] ||= 1

  # Use advisory lock to prevent deadlocks from concurrent cart modifications
  lock_key = "#{self.class.name.underscore}|#{id}|add_line_item"
  self.class.with_advisory_lock(lock_key, timeout_seconds: 10) do
    # Reload line_items to ensure we have fresh data after acquiring lock
    line_items.reload if persisted?

    line_item = begin
      line_items.find { |li| li.catalog_item_id == options[:catalog_item_id].to_i and (li.room_configuration_id.to_i == options[:room_configuration_id].to_i) }
    rescue StandardError
      nil
    end
    if line_item && !keep_line_items_separate
      line_item.quantity += options[:quantity].to_i
      # Persist quantity changes on existing rows immediately, even when the caller
      # passed do_not_autosave: true. The parent's autosave path is unreliable here
      # because downstream callbacks (e.g. Coupon::ItemizableDiscountCalculator's
      # load_active_lines hits line_items.any?(&:new_record?)) can re-load the
      # association from the DB and discard the in-memory dirty state, causing the
      # quantity bump to silently disappear when adding an item already in the cart.
      if line_item.persisted? && line_item.changed?
        Retryable.retryable(tries: 3, sleep: ->(n) { 2**n }, on: [ActiveRecord::Deadlocked]) do |attempt_number, exception|
          Rails.logger.warn("add_line_item save retry #{attempt_number}: #{exception.class}") if attempt_number > 1
          line_item.save
        end
      end
    else
      line_item = LineItem.new(options)
      line_item.price ||= line_item.catalog_item.amount
      line_items.build(line_item.attributes)
      unless do_not_autosave
        Retryable.retryable(tries: 3, sleep: ->(n) { 2**n }, on: [ActiveRecord::Deadlocked]) do |attempt_number, exception|
          Rails.logger.warn("add_line_item save retry #{attempt_number}: #{exception.class}") if attempt_number > 1
          line_item.save
        end
      end
    end
    line_item
  end
end

#additional_itemsObject

GROUPING FUNCTIONS #



106
107
108
# File 'app/concerns/models/itemizable.rb', line 106

def additional_items
  line_items.to_a.select { |li| li.room_configuration_id.nil? }
end

#assign_sequence(line_item) ⇒ Object



25
26
27
# File 'app/concerns/models/itemizable.rb', line 25

def assign_sequence(line_item)
  line_item.sequence = line_items.length + 1
end

#billing_entityObject



170
171
172
# File 'app/concerns/models/itemizable.rb', line 170

def billing_entity
  billing_address && billing_address.party_id.present? ? billing_address.party : customer
end

#breakdown_of_pricesObject



114
115
116
117
118
119
120
121
# File 'app/concerns/models/itemizable.rb', line 114

def breakdown_of_prices
  breakdown = {}
  breakdown['Item Subtotal'] = line_items.non_shipping.sum('price * quantity')
  breakdown['Shipping'] = line_items.shipping_only.sum('price * quantity')
  breakdown['Discounts'] = discounts.sum('amount')
  breakdown['Taxes'] = tax_total
  breakdown
end

#calculate_actual_insured_valueObject



532
533
534
# File 'app/concerns/models/itemizable.rb', line 532

def calculate_actual_insured_value
  [subtotal_discounted_without_shipping.to_f, subtotal_cogs.to_f].max
end

#calculate_discounts(autosave = false) ⇒ Object

this method prioritizes discounts and recalculate each of them



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'app/concerns/models/itemizable.rb', line 153

def calculate_discounts(autosave = false)
  return :not_applicable if is_a?(CreditMemo) || is_a?(Invoice)

  # Allow recalculation if explicitly flagged (e.g., shipping changed), even if locked
  # Otherwise, skip if the order is locked (completed, shipped, invoiced, etc.)
  if respond_to?(:editing_locked?) && editing_locked? && !(respond_to?(:recalculate_discounts) && recalculate_discounts)
    logger.debug 'calculate_discounts: editing_locked? is true and recalculate_discounts is false, skipping discount calculation'
    return :skipped
  end

  logger.debug 'calculate_discounts: recalculate_discounts flag is true, forcing recalculation even though order may be locked' if respond_to?(:recalculate_discounts) && recalculate_discounts

  Coupon::ItemizableDiscountCalculator.new(self).calculate
  save if autosave
  :success
end

#calculate_shipping_costObject



524
525
526
# File 'app/concerns/models/itemizable.rb', line 524

def calculate_shipping_cost
  line_items.shipping_only.to_a.sum(&:price)
end

#coupon_search(search_query, role_ids: nil, wild_search: nil) ⇒ Object



244
245
246
247
248
249
250
251
252
253
254
# File 'app/concerns/models/itemizable.rb', line 244

def coupon_search(search_query, role_ids: nil, wild_search: nil)
  cq = Coupon::Qualifier.new
  exclude_coupon_ids = discounts.pluck(:coupon_id)
  role_ids = nil if role_ids.present? && role_ids.include?(1) # Admin bypass
  cq.find_coupons(itemizable: self,
                  role_ids:,
                  exclude_coupon_ids:,
                  search_query:,
                  searchable_only: true,
                  wild_search:)
end

#couponsActiveRecord::Relation<Coupon>

Returns:

  • (ActiveRecord::Relation<Coupon>)

See Also:



20
# File 'app/concerns/models/itemizable.rb', line 20

has_many    :coupons, through: :discounts

#customer_applied_couponsObject



284
285
286
287
# File 'app/concerns/models/itemizable.rb', line 284

def customer_applied_coupons
  current_coupon_ids = discounts.pluck(:coupon_id)
  Coupon.where(id: current_coupon_ids).customer_accessible
end

#customer_can_apply_coupon?(coupon_code) ⇒ Boolean

Returns:

  • (Boolean)


256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'app/concerns/models/itemizable.rb', line 256

def customer_can_apply_coupon?(coupon_code)
  # puts "customer_can_apply_coupon?"
  coupon = nil
  msg = "Coupon code can't be blank."
  if coupon_code.present?
    current_customer_coupons = customer_applied_coupons
    if current_customer_coupons.any? { |cp| cp.code == coupon_code }
      msg = "Coupon code #{coupon_code} already applied."
    else
      coupon = coupon_search(coupon_code, wild_search: false).first
      if coupon.present? && coupon.public?
        # puts "customer_can_apply_coupon? coupon is present and public"
        msg = "Coupon code #{coupon_code} can be applied."

        if current_customer_coupons.any?(&:exclusive?) ||
           (coupon.exclusive? && discounts.joins(:coupon).merge(Coupon.active.tier3).present?)
          msg = "Coupon code #{coupon_code} cannot be applied in combination with any other offers."
          coupon = nil
        end
      else
        msg = "Coupon code #{coupon_code} not found or does not apply."
        coupon = nil
      end
    end
  end
  { coupon:, message: msg }
end

#discountsActiveRecord::Relation<Discount>

Returns:

See Also:



19
# File 'app/concerns/models/itemizable.rb', line 19

has_many    :discounts, as: :itemizable, dependent: :destroy, inverse_of: :itemizable, autosave: true

#discounts_changed?Boolean

Returns:

  • (Boolean)


334
335
336
337
338
339
# File 'app/concerns/models/itemizable.rb', line 334

def discounts_changed?
  return unless respond_to?(:discounts)
  return unless discounts.loaded?

  discounts.any?(&:changed?) || discounts.any?(&:marked_for_destruction?)
end

#discounts_grouped_by_coupon(return_coupon_object = false) ⇒ Object

TOTALLING FUNCTIONS #



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/concerns/models/itemizable.rb', line 211

def discounts_grouped_by_coupon(return_coupon_object = false)
  logger.debug "discounts_grouped_by_coupon!! self.id: #{id}"
  discounts_by_code = {}

  # line_item_ids = line_items.pluck(:id)
  logger.debug "discounts_grouped_by_coupon!! self.id: #{id}, line_item_ids: #{line_item_ids.inspect}"

  line_discounts = LineDiscount.joins(:line_item, :coupon, :discount)
                               .merge(line_items)
                               .includes(%i[coupon discount line_item])
                               .order(Coupon[:type], Coupon[:position])
  line_discounts = yield(line_discounts) if block_given?

  discounts_grouped = line_discounts.group_by(&:coupon)

  discounts_grouped.each do |coupon, line_discounts|
    # Collect notes from discounts (e.g., "Discount capped at 40% for item SKU")
    discount_notes = line_discounts.map { |ld| ld.discount.notes }.compact.uniq
    discounts_by_code[coupon.code] = {
      auto_apply: coupon.auto_apply?,
      title: coupon.title,
      amount: line_discounts.sum(&:amount),
      coupon_id: coupon.id,
      serial_number: line_discounts.first.discount.coupon_serial_number_id,
      notes: discount_notes
    }
    discounts_by_code[coupon.code][:coupon] = coupon if return_coupon_object == true
  end

  logger.debug "discounts_grouped_by_coupon!! self.id: #{id}, discounts_by_code: #{discounts_by_code.inspect}"
  discounts_by_code
end

#discounts_subtotal(msrp = false) ⇒ Object



289
290
291
292
293
294
295
296
297
# File 'app/concerns/models/itemizable.rb', line 289

def discounts_subtotal(msrp = false)
  discounts.select { |li| !li.marked_for_destruction? }.sum do |d|
    amt = d.amount || 0
    if msrp && customer.present? && customer.pricing_program_discount > 0
      amt = 0
    end # zero out if doing msrp quote for trade pro's customer
    amt
  end.to_f.round(2)
end

#effective_discountObject



145
146
147
148
149
150
# File 'app/concerns/models/itemizable.rb', line 145

def effective_discount
  line_items_array = line_items.to_a
  price_total = line_items_array.sum(&:price_total) || 0.0
  discounted_total = line_items_array.sum(&:discounted_total) || 0.0
  (discounted_total.positive? ? (100.0 - ((discounted_total / price_total) * 100)).round(2) : 0)
end

#effective_shipping_discountObject



141
142
143
# File 'app/concerns/models/itemizable.rb', line 141

def effective_shipping_discount
  100 - ((shipping_discounted / shipping_cost.to_f.round(2)) * 100)
end

#has_kits?Boolean

Returns:

  • (Boolean)


434
435
436
# File 'app/concerns/models/itemizable.rb', line 434

def has_kits?
  line_items_with_counters.any? { |li| li.children_count.to_i > 0 }
end

#has_kits_or_serial_numbers?Boolean

Returns:

  • (Boolean)


438
439
440
# File 'app/concerns/models/itemizable.rb', line 438

def has_kits_or_serial_numbers?
  has_serial_numbers? || has_kits?
end

#has_serial_numbers?Boolean

Returns:

  • (Boolean)


430
431
432
# File 'app/concerns/models/itemizable.rb', line 430

def has_serial_numbers?
  line_items_with_counters.any? { |li| li.reserved_serial_numbers_count.positive? || li.serial_numbers_count.positive? }
end

#is_credit_order?Boolean

Returns:

  • (Boolean)


407
408
409
# File 'app/concerns/models/itemizable.rb', line 407

def is_credit_order?
  (is_a? Order and order_type == Order::CREDIT_ORDER)
end

#line_items_requiring_serial_numberObject



110
111
112
# File 'app/concerns/models/itemizable.rb', line 110

def line_items_requiring_serial_number
  line_items.to_a.active_lines.select(&:require_serial_number?)
end

#line_items_with_countersObject



426
427
428
# File 'app/concerns/models/itemizable.rb', line 426

def line_items_with_counters
  line_items.with_reserved_serial_numbers_count.with_serial_numbers_count
end

#line_total_plus_taxObject



133
134
135
# File 'app/concerns/models/itemizable.rb', line 133

def line_total_plus_tax
  line_items.non_shipping.sum('line_items.discounted_price + line_items.tax_total')
end

#local_sales_repEmployee

Returns:

See Also:



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

belongs_to :local_sales_rep, class_name: 'Employee', optional: true

#main_repObject



87
88
89
90
91
# File 'app/concerns/models/itemizable.rb', line 87

def main_rep
  main_rep = primary_sales_rep
  main_rep ||= secondary_sales_rep
  main_rep
end

#perform_db_totalObject



518
519
520
521
522
# File 'app/concerns/models/itemizable.rb', line 518

def perform_db_total
  res = ActiveRecord::Base.connection.execute("SELECT calculate_itemizable_total('#{self.class.name}',#{id})")
  res = res.try(:[], 0).try(:[], 'calculate_itemizable_total')
  BigDecimal(res) if res
end

#primary_sales_repEmployee

Returns:

See Also:



14
# File 'app/concerns/models/itemizable.rb', line 14

belongs_to :primary_sales_rep, class_name: 'Employee', optional: true

#purge_empty_quoting_deliveriesObject

After line items are removed, some deliveries may have lost all their
non-shipping content (e.g. a service delivery whose service item was
deleted). Destroy these quoting deliveries so they don't progress
through the state machine as zombie deliveries.



352
353
354
355
356
357
358
359
360
361
362
363
# File 'app/concerns/models/itemizable.rb', line 352

def purge_empty_quoting_deliveries
  return unless respond_to?(:deliveries)

  deliveries.reload.each do |delivery|
    next unless delivery.quoting?
    next if delivery.has_shippable_content?

    logger.info "#{self.class.name}:#{id}:purge_empty_quoting_deliveries -> destroying empty delivery #{delivery.id}"
    delivery.line_items.destroy_all
    delivery.destroy
  end
end

#purge_shipping_when_no_other_linesObject



341
342
343
344
345
346
# File 'app/concerns/models/itemizable.rb', line 341

def purge_shipping_when_no_other_lines
  return unless (is_a?(Order) || is_a?(Quote)) && line_items.active_non_shipping_lines.empty?

  logger.debug "#{self.class.name}:#{id}:purge_shipping_when_no_other_lines -> removing line items as no non-shipping lines found"
  line_items.destroy_all # No lines no shipping required
end

#remove_line_item(line_item, quantity = 1) ⇒ Object



77
78
79
80
81
82
83
84
85
# File 'app/concerns/models/itemizable.rb', line 77

def remove_line_item(line_item, quantity = 1)
  if line_item.quantity > quantity
    line_item.quantity -= quantity
    line_item.save
  else
    line_items.delete(line_item)
    line_item.destroy
  end
end

#require_total_reset?Boolean

Returns:

  • (Boolean)


303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'app/concerns/models/itemizable.rb', line 303

def require_total_reset?
  logger.tagged("#{self.class.name}:#{id}#require_total_reset?") do
    logger.debug "editing_locked?: #{editing_locked?}"
    # commenting this out as it's preventing orders from invoicing 6/25/19 DW
    # return false if editing_locked? # this is to prevent an order or quote total from changing after it's been shipped, completed, invoiced, etc.

    logger.debug "do_not_set_totals: #{do_not_set_totals}"
    return false if do_not_set_totals

    logger.debug "force_total_reset: #{force_total_reset}"
    return true if force_total_reset

    logger.debug "line_items.lines_changed?: #{line_items.lines_changed?}"
    return true if line_items.lines_changed?

    logger.debug "discounts_changed?: #{discounts_changed?}"
    return true if discounts_changed?

    logger.debug "should_recalculate_shipping?: #{should_recalculate_shipping?}"
    return true if should_recalculate_shipping?

    logger.debug "customer_id_changed?: #{try(:customer_id_changed?)}"
    return true if try(:customer_id_changed?)

    logger.debug "shipping_method_changed?: #{shipping_method_changed?}"
    return true if shipping_method_changed?

    return false
  end
end

#reset_discount(autosave: true, reset_item_pricing: true) ⇒ Object



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
# File 'app/concerns/models/itemizable.rb', line 174

def reset_discount(autosave: true, reset_item_pricing: true)
  # Store transfers never reset price
  reset_item_pricing = false if try(:is_store_transfer?).to_b
  update_column(:recalculate_discounts, true) if respond_to?(:recalculate_discounts)
  self.do_not_set_totals = nil
  # Discount recalculation should NEVER trigger shipping recalculation
  # Shipping and discounts are independent concerns
  self.do_not_detect_shipping = true
  self.force_total_reset = true
  self.line_total = nil
  self.total = nil
  self.tax_total = nil
  if customer.present?
    self.pricing_program_discount = customer.pricing_program_discount if respond_to?(:pricing_program_discount=)
    self.pricing_program_description = customer.pricing_program_description if respond_to?(:pricing_program_description=)
  end
  # This is needed when you have older line items with an old price being re-quoted,
  # but we don't want to lose the pricing set by an edi_force_price_match either...
  # hopefully this logic will work for all situations, pending a more intentional explicit
  # way of dealing with price changes and honoring quotes
  if reset_item_pricing
    # Preload associations to prevent N+1 queries:
    # - item: accessed in set_defaults callback
    # - reserved_serial_numbers: validates_associated in Reservable concern
    # - line_discounts: used by discounted_total during validation
    line_items.parents_only.joins(:catalog_item)
              .includes(:catalog_item, :item, :reserved_serial_numbers, :line_discounts).each do |li|
      li.update(price: li.catalog_item.amount) unless li.edi_unit_cost.present? && li.price == li.edi_unit_cost
    end
  end
  save if autosave
end

#secondary_sales_repEmployee

Returns:

See Also:



15
# File 'app/concerns/models/itemizable.rb', line 15

belongs_to :secondary_sales_rep, class_name: 'Employee', optional: true

#set_for_recalc(include_shipping: true) ⇒ Object



123
124
125
126
127
128
129
130
131
# File 'app/concerns/models/itemizable.rb', line 123

def set_for_recalc(include_shipping: true)
  self.recalculate_shipping = true if include_shipping && respond_to?(:recalculate_shipping)
  self.recalculate_discounts = true if respond_to?(:recalculate_discounts)
  self.do_not_set_totals = nil
  self.force_total_reset = true
  self.line_total = nil
  self.total = nil
  self.tax_total = nil
end

#set_signature_confirmation_on_shipping_address_changeObject



509
510
511
512
513
514
515
516
# File 'app/concerns/models/itemizable.rb', line 509

def set_signature_confirmation_on_shipping_address_change
  # here's as good a place as any to apply the default signature_confirmation when a shipping address changes and it has require_signature_by_default true
  if shipping_address_id_changed? && respond_to?(:signature_confirmation) && (shipping_address&.require_signature_by_default? || customer&.is_direct_buy?) # require this by default on Direct Buy
    self.signature_confirmation = true
    need_to_recalculate_shipping
  end
  true # since this is a before_save callback
end

#set_totalsObject

COMMON CALLBACKS



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
# File 'app/concerns/models/itemizable.rb', line 445

def set_totals
  logger.debug "#{self.class.name}:#{id} set_totals called"
  if (self.total_reset = require_total_reset?)
    purge_empty_line_items if respond_to?(:purge_empty_line_items)
    purge_shipping_when_no_other_lines if respond_to?(:purge_shipping_when_no_other_lines)
    purge_empty_quoting_deliveries if respond_to?(:purge_empty_quoting_deliveries)
    # set_shipping_account_number if respond_to?(:set_shipping_account_number)

    # Capture shipping state before recalculation to detect changes
    # We track both cost AND method because coupon eligibility may depend on the shipping method
    # (e.g., "free shipping on FedEx Ground only" vs "FedEx 2Day")
    old_shipping_cost = calculate_shipping_cost if respond_to?(:calculate_shipping_cost)

    # Capture shipping method from the itemizable (order/quote), not delivery
    # The shipping_method attribute is stored on Order/Quote, not Delivery
    old_shipping_method = shipping_method if respond_to?(:shipping_method)

    retrieve_shipping_costs if respond_to?(:retrieve_shipping_costs) && should_recalculate_shipping?
    sync_shipping_line if respond_to?(:sync_shipping_line)

    # Detect if shipping changed (cost OR method) and ensure discount recalculation happens
    # This is critical because:
    # 1. Shipping discounts must be capped at the actual shipping cost
    # 2. Some coupons only apply to specific shipping methods (filters by method name)
    shipping_changed = false

    if old_shipping_cost && respond_to?(:calculate_shipping_cost)
      new_shipping_cost = calculate_shipping_cost
      if old_shipping_cost != new_shipping_cost
        logger.debug "Shipping cost changed from #{old_shipping_cost} to #{new_shipping_cost}, ensuring discount recalculation"
        shipping_changed = true
      end
    end

    # Check if shipping method changed (on the itemizable itself)
    if old_shipping_method && respond_to?(:shipping_method)
      new_shipping_method = shipping_method
      if old_shipping_method != new_shipping_method
        logger.debug "Shipping method changed from '#{old_shipping_method}' to '#{new_shipping_method}', ensuring discount recalculation"
        shipping_changed = true
      end
    end

    # If shipping changed in any way, set the persistent flag for discount recalculation
    # This database flag survives saves/reloads unlike ephemeral attributes
    self.recalculate_discounts = true if shipping_changed && respond_to?(:recalculate_discounts=)

    # Calculate discounts when line items changed, discounts need recalculating,
    # or discounts haven't been loaded yet. The discounts.loaded? check alone is
    # insufficient because validates_associated :discounts loads the association
    # during validation (before this before_save callback runs), making it always true.
    if respond_to?(:calculate_discounts) && (
         (respond_to?(:recalculate_discounts) && recalculate_discounts) ||
         !discounts.loaded? ||
         line_items.lines_changed?
       )
      calculate_discounts
      # Clear the flag after successful discount calculation
      self.recalculate_discounts = false if respond_to?(:recalculate_discounts) && recalculate_discounts
    end
  end
  true # since this is a before_save callback
end

#shipping_conditions_changed?Boolean

Returns:

  • (Boolean)


392
393
394
395
396
# File 'app/concerns/models/itemizable.rb', line 392

def shipping_conditions_changed?
  return true if shipping_address_id_changed? # before_save

  true if shipping_address&.address_element_changed?
end

#shipping_discountedObject



137
138
139
# File 'app/concerns/models/itemizable.rb', line 137

def shipping_discounted
  shipping_cost.to_f.round(2) + shipping_coupon.to_f.round(2)
end

#shipping_method_changed?Boolean

Returns:

  • (Boolean)


398
399
400
401
402
403
404
405
# File 'app/concerns/models/itemizable.rb', line 398

def shipping_method_changed?
  return false unless respond_to?(:deliveries)
  return false unless deliveries.loaded? # moot point to check on changed on something unloaded

  deliveries.any?(&:changed?)
  deliveries.any? { |d| d.shipping_costs.any?(&:changed?) }
  deliveries.any? { |d| d.shipping_costs.any?(&:destroyed?) }
end

#should_recalculate_shipping?Boolean

def set_shipping_account_number
return unless respond_to?(:shipping_account_number_id) && respond_to?(:bill_shipping_to_customer)
self.shipping_account_number_id = try(:chosen_shipping_method).try(:shipping_account_number_id)
end

Returns:

  • (Boolean)


370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
# File 'app/concerns/models/itemizable.rb', line 370

def should_recalculate_shipping?
  # Check recalculate_shipping flag first - when explicitly set, always recalculate
  if try(:recalculate_shipping)
    logger.debug 'recalculate_shipping: true'
    return true
  end

  # Then test if state prevents shipping recalculation
  return false if try(:prevent_recalculate_shipping?)
  return false if try(:do_not_detect_shipping)

  if line_items.changed_for_shipping?
    logger.debug 'line_items.changed_for_shipping?: true'
    return true
  elsif shipping_conditions_changed?
    logger.debug 'shipping_conditions_changed?: true'
    reset_deliveries_shipping_option if respond_to?(:reset_deliveries_shipping_option)
    return true
  end
  false
end

#smartinstall_dataObject



536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
# File 'app/concerns/models/itemizable.rb', line 536

def smartinstall_data
  indoor_floating_coverage = indoor_thinset_coverage = outdoor_mat_coverage = outdoor_cable_coverage = underlayment_coverage = service_fee = total = 0
  prodeso = false
  res = {}

  if (coverages = total_coverage_by_product_line).present?
    coverages.each do |product_line_name, coverage|
      prodeso = true if product_line_name.include?('Prodeso')
      indoor_floating_coverage += coverage if product_line_name.include?('Environ')
      indoor_thinset_coverage += coverage if product_line_name.include?('TempZone') && product_line_name.exclude?('Cable')
      underlayment_coverage += coverage if product_line_name.include?('Underlayment')
      outdoor_mat_coverage += coverage if product_line_name.include?('Snow Melting > Mat')
    end
  end
  if (linear_foot = total_linear_feet_by_product_line).present?
    linear_foot.each do |product_line_name, lf|
      if product_line_name.include?('TempZone') && product_line_name.include?('Cable')
        coverage = (lf * 0.3125).truncate(1) if prodeso
        coverage = (lf * 0.25).truncate(1) unless prodeso
        indoor_thinset_coverage += coverage
      elsif product_line_name.include?('Snow Melting > Cable')
        coverage = (lf * 0.25).truncate(1)
        outdoor_cable_coverage += coverage
      end
    end
  end

  if indoor_floating_coverage > 0
    min_coverage = 100
    sq_rate = 1.5
    indoor_floating_coverage = (indoor_floating_coverage < min_coverage ? min_coverage : indoor_floating_coverage)
    res['SmartInstall Floating $/Square Foot'] = [indoor_floating_coverage, indoor_floating_coverage * sq_rate]
    total += indoor_floating_coverage * sq_rate
    rate = Item.find_by(sku: 'SII_FLOATING_FIXRATE').catalog_items.main_catalogs.first.amount
    service_fee = rate unless service_fee > rate
  end
  if indoor_thinset_coverage > 0
    min_coverage = 50
    sq_rate = 2
    indoor_thinset_coverage = (indoor_thinset_coverage < min_coverage ? min_coverage : indoor_thinset_coverage)
    res['SmartInstall Thinset/SelfLeveling Applications $/Square Foot'] = [indoor_thinset_coverage, indoor_thinset_coverage * sq_rate]
    total += indoor_thinset_coverage * sq_rate
    rate = Item.find_by(sku: 'SII_THINSET_FIXRATE').catalog_items.main_catalogs.first.amount
    service_fee = rate unless service_fee > rate
  end
  if underlayment_coverage > 0
    sq_rate = 2
    res['SmartInstall Underlayment $/Square Foot'] = [underlayment_coverage, underlayment_coverage * sq_rate]
    total += underlayment_coverage * sq_rate
    rate = Item.find_by(sku: 'SII_THINSET_FIXRATE').catalog_items.main_catalogs.first.amount
    service_fee = rate unless service_fee > rate
  end
  if outdoor_mat_coverage > 0
    sq_rate = 2.5
    res['SmartInstall Outdoor Mats $/Square Foot'] = [outdoor_mat_coverage, outdoor_mat_coverage * sq_rate]
    total += outdoor_mat_coverage * sq_rate
    rate = Item.find_by(sku: 'SIO_MAT_FIXRATE').catalog_items.main_catalogs.first.amount
    service_fee = rate unless service_fee > rate
  end
  if outdoor_cable_coverage > 0
    sq_rate = 2.5
    res['SmartInstall Outdoor Cables $/Square Foot'] = [outdoor_cable_coverage, outdoor_cable_coverage * sq_rate]
    total += outdoor_cable_coverage * sq_rate
    rate = Item.find_by(sku: 'SIO_CABLE_FIXRATE').catalog_items.main_catalogs.first.amount
    service_fee = rate unless service_fee > rate
  end
  res['SmartInstall Service Fee'] = service_fee
  total += service_fee
  res['Total'] = total
  return nil if service_fee == 0

  res
end

#smartsupport_data(distance) ⇒ Object



610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
# File 'app/concerns/models/itemizable.rb', line 610

def smartsupport_data(distance)
  return nil unless electrical_heating_elements.any? && distance.present?

  res = {}

  if distance <= SMART_SERVICES_MAX_DISTANCE # Onsite support only offered within 100 miles
    osf = Item.find_by(sku: 'SGS_ONSITE_FIXRATE')
    omr = Item.find_by(sku: 'SGS_ONSITE_PER_MILE')
    onsite_service_fee = osf.catalog_items.active.main_catalogs.first&.amount
    onsite_mile_rate = omr.catalog_items.active.main_catalogs.first&.amount
    if onsite_service_fee && onsite_mile_rate
      distance_price = (distance - SMART_SERVICES_MAX_DISTANCE) < 0 ? 0 : ((distance - SMART_SERVICES_MAX_DISTANCE) * onsite_mile_rate) # First 100 miles free
      res[osf.name] = onsite_service_fee
      res[omr.name] = [distance, distance_price]
      res['SmartSupport Onsite Total'] = distance_price + onsite_service_fee
    else
      ErrorReporting.warning("onsite_mile_rate and onsite_service_fee not found in catalog for #{self.class.name} : #{id}")
    end
  end

  rfr = Item.find_by(sku: 'SGS_REMOTE_FIXRATE')
  if (remote_service_fee = rfr.catalog_items.active.main_catalogs.first&.amount)
    res[rfr.name] = remote_service_fee
    res['SmartSupport Remote Total'] = remote_service_fee
  else
    ErrorReporting.warning("remote_service_fee not found in catalog for #{self.class.name} : #{id}")
  end
  res
end

#storeObject



93
94
95
96
97
98
99
100
# File 'app/concerns/models/itemizable.rb', line 93

def store
  # At some point orders will need their own store id to base their fulfillment from, then we don't need to determine based on currency
  (begin
    customer.store
  rescue StandardError
    nil
  end) || Store.by_currency(currency)
end

#subtotal_cogsObject



528
529
530
# File 'app/concerns/models/itemizable.rb', line 528

def subtotal_cogs
  line_items.without_children.includes(catalog_item: :store_item).active_non_shipping_lines.sum(&:current_line_cogs)
end

#sync_shipping_lineObject

Retrieve shipping costs might create a shipping line. We need to make sure our line items collection is aware of it
Otherwise our discounts or other calculation might have no clue.



413
414
415
416
417
418
419
420
421
422
423
424
# File 'app/concerns/models/itemizable.rb', line 413

def sync_shipping_line
  return unless respond_to? :deliveries

  correct_shipping_lines = deliveries.reload.map { |dq| dq.line_items.active_shipping_lines }.flatten
  existing_shipping_lines = line_items.active_shipping_lines
  # missing lines get added
  missing_lines = correct_shipping_lines - existing_shipping_lines
  return if missing_lines.blank?

  line_items.reload
  self.line_items += missing_lines
end

#total_cogsObject



299
300
301
# File 'app/concerns/models/itemizable.rb', line 299

def total_cogs
  line_items.select { |li| !li.marked_for_destruction? }.map { |li| li.total_cogs }.sum
end