Module: Models::Itemizable
- Extended by:
- ActiveSupport::Concern
- Included in:
- CreditMemo, Invoice, Order, Quote
- Defined in:
- app/concerns/models/itemizable.rb
Instance Attribute Summary collapse
-
#force_total_reset ⇒ Object
Returns the value of attribute force_total_reset.
-
#total_reset ⇒ Object
Returns the value of attribute total_reset.
Belongs to collapse
- #account_specialist ⇒ Employee
- #local_sales_rep ⇒ Employee
- #primary_sales_rep ⇒ Employee
- #secondary_sales_rep ⇒ Employee
Has many collapse
Instance Method Summary collapse
- #add_line_item(options) ⇒ Object
-
#additional_items ⇒ Object
GROUPING FUNCTIONS #.
- #assign_sequence(line_item) ⇒ Object
- #billing_entity ⇒ Object
- #breakdown_of_prices ⇒ Object
- #calculate_actual_insured_value ⇒ Object
-
#calculate_discounts(autosave = false) ⇒ Object
this method prioritizes discounts and recalculate each of them.
- #calculate_shipping_cost ⇒ Object
- #coupon_search(search_query, role_ids: nil, wild_search: nil) ⇒ Object
- #customer_applied_coupons ⇒ Object
- #customer_can_apply_coupon?(coupon_code) ⇒ Boolean
- #discounts_changed? ⇒ Boolean
-
#discounts_grouped_by_coupon(return_coupon_object = false) ⇒ Object
TOTALLING FUNCTIONS #.
- #discounts_subtotal(msrp = false) ⇒ Object
- #effective_discount ⇒ Object
- #effective_shipping_discount ⇒ Object
- #has_kits? ⇒ Boolean
- #has_kits_or_serial_numbers? ⇒ Boolean
- #has_serial_numbers? ⇒ Boolean
- #is_credit_order? ⇒ Boolean
- #line_items_requiring_serial_number ⇒ Object
- #line_items_with_counters ⇒ Object
- #line_total_plus_tax ⇒ Object
- #main_rep ⇒ Object
- #perform_db_total ⇒ Object
-
#purge_empty_quoting_deliveries ⇒ Object
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).
- #purge_shipping_when_no_other_lines ⇒ Object
- #remove_line_item(line_item, quantity = 1) ⇒ Object
- #require_total_reset? ⇒ Boolean
- #reset_discount(autosave: true, reset_item_pricing: true) ⇒ Object
- #set_for_recalc(include_shipping: true) ⇒ Object
- #set_signature_confirmation_on_shipping_address_change ⇒ Object
-
#set_totals ⇒ Object
COMMON CALLBACKS.
- #shipping_conditions_changed? ⇒ Boolean
- #shipping_discounted ⇒ Object
- #shipping_method_changed? ⇒ Boolean
-
#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.
- #smartinstall_data ⇒ Object
- #smartsupport_data(distance) ⇒ Object
- #store ⇒ Object
- #subtotal_cogs ⇒ Object
-
#sync_shipping_line ⇒ Object
Retrieve shipping costs might create a shipping line.
- #total_cogs ⇒ Object
Instance Attribute Details
#force_total_reset ⇒ Object
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_reset ⇒ Object
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_specialist ⇒ Employee
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() do_not_autosave = .delete(:do_not_autosave) ci = CatalogItem.find [:catalog_item_id] keep_line_items_separate = .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? [: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 == [:catalog_item_id].to_i and (li.room_configuration_id.to_i == [:room_configuration_id].to_i) } rescue StandardError nil end if line_item && !keep_line_items_separate line_item.quantity += [: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() 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_items ⇒ Object
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_entity ⇒ Object
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_prices ⇒ Object
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_value ⇒ Object
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_cost ⇒ Object
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 |
#coupons ⇒ ActiveRecord::Relation<Coupon>
20 |
# File 'app/concerns/models/itemizable.rb', line 20 has_many :coupons, through: :discounts |
#customer_applied_coupons ⇒ Object
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
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 |
#discounts ⇒ ActiveRecord::Relation<Discount>
19 |
# File 'app/concerns/models/itemizable.rb', line 19 has_many :discounts, as: :itemizable, dependent: :destroy, inverse_of: :itemizable, autosave: true |
#discounts_changed? ⇒ 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_discount ⇒ Object
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_discount ⇒ Object
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
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
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
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
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_number ⇒ Object
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_counters ⇒ Object
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_tax ⇒ Object
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_rep ⇒ Employee
16 |
# File 'app/concerns/models/itemizable.rb', line 16 belongs_to :local_sales_rep, class_name: 'Employee', optional: true |
#main_rep ⇒ Object
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_total ⇒ Object
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_rep ⇒ Employee
14 |
# File 'app/concerns/models/itemizable.rb', line 14 belongs_to :primary_sales_rep, class_name: 'Employee', optional: true |
#purge_empty_quoting_deliveries ⇒ Object
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_lines ⇒ Object
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
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_rep ⇒ Employee
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_change ⇒ Object
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_totals ⇒ Object
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
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_discounted ⇒ Object
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
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
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_data ⇒ Object
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 |
#store ⇒ Object
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_cogs ⇒ Object
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_line ⇒ Object
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_cogs ⇒ Object
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 |