Module: Models::Itemizable
- Extended by:
- ActiveSupport::Concern
- Included in:
- CreditMemo, Invoice, Order, Quote
- Defined in:
- app/concerns/models/itemizable.rb
Overview
Mixin shared by Order, Quote, Invoice and CreditMemo that
encapsulates the line-item bag, discount totals and shipping
recalculation logic each of these documents has in common. The host
is expected to expose line_items, currency, customer and the
total columns the SQL aggregator (set_totals / perform_db_total)
writes to.
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) ⇒ LineItem
Adds a CatalogItem to this itemizable, merging quantity into an existing matching LineItem when possible (same catalog item + room configuration) unless
keep_line_items_separateis set or the SKU is in the always-separate list. -
#additional_items ⇒ Object
GROUPING FUNCTIONS #.
-
#assign_sequence(line_item) ⇒ Integer
Numbers a freshly-built LineItem with the next sequential position within this document.
-
#billing_entity ⇒ Party
The Party actually being billed — the address-level party when the billing address is owned by a different party (e.g. parent account, buying group), otherwise the document's own customer.
-
#breakdown_of_prices ⇒ Hash{String => BigDecimal}
Subtotal/shipping/discounts/taxes hash used by the customer-facing PDF and emails to render a price-breakdown table.
-
#calculate_actual_insured_value ⇒ Float
Insured-value figure declared to carriers — the larger of the discounted subtotal and the COGS subtotal (so we never under-insure a heavily-discounted shipment).
-
#calculate_discounts(autosave = false) ⇒ Object
this method prioritizes discounts and recalculate each of them.
-
#calculate_shipping_cost ⇒ BigDecimal
Sum of
priceacross shipping-only LineItems — used to detect whether a re-quote produced a different shipping cost. -
#coupon_search(search_query, role_ids: nil, wild_search: nil) ⇒ Array<Coupon>
Finds Coupons that match
search_queryand could still be applied to this document — excludes coupons already on the discounts list and respects role-based visibility unless the user is an admin (role 1). -
#customer_applied_coupons ⇒ ActiveRecord::Relation<Coupon>
Coupons currently applied to this document that are visible to the customer (filters out trade-only / internal coupons).
- #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) ⇒ Float
Sum of all applied discount amounts; when
msrpis true and the customer is on a pricing-program tier (trade pro), discounts are zeroed so the MSRP version of the quote shows the customer's full retail price. -
#effective_discount ⇒ Float
Effective percentage discount across all line items —
(price_total − discounted_total) / price_total * 100. -
#effective_shipping_discount ⇒ Float
Effective percentage discount applied to shipping — 0 when no coupon, 100 when shipping is free.
- #has_kits? ⇒ Boolean
- #has_kits_or_serial_numbers? ⇒ Boolean
- #has_serial_numbers? ⇒ Boolean
- #is_credit_order? ⇒ Boolean
- #line_items_requiring_serial_number ⇒ Array<LineItem>
-
#line_items_with_counters ⇒ ActiveRecord::Relation<LineItem>
Line-items relation with serial-number/reserved counters preloaded so the kit/serial-number views can render badge counts without triggering a per-row query.
-
#line_total_plus_tax ⇒ BigDecimal
SQL aggregate of
discounted_price + tax_totalacross non-shipping line items — the line-level total a customer pays excluding freight. -
#main_rep ⇒ Employee?
Best sales-rep available for commission attribution — primary, falling back to secondary.
-
#perform_db_total ⇒ BigDecimal?
after_save hook — invokes the
calculate_itemizable_totalPostgres function which recomputes line/total/tax columns from current line items inside the database. -
#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 ⇒ void
-
#remove_line_item(line_item, quantity = 1) ⇒ LineItem
Decrements quantity on
line_itembyquantityor destroys the row outright ifquantitycovers the whole line. - #require_total_reset? ⇒ Boolean
-
#reset_discount(autosave: true, reset_item_pricing: true) ⇒ void
Wipes cached totals and re-runs discount/coupon evaluation, then re-prices each parent line item against the current catalog (preserving prices set by EDI quotes).
-
#set_for_recalc(include_shipping: true) ⇒ void
Forces a full total-recalculation on the next save by clearing the cached
line_total/total/tax_totalcolumns and turning onrecalculate_shipping/recalculate_discounts/force_total_reset. -
#set_signature_confirmation_on_shipping_address_change ⇒ Boolean
before_save hook — flips
signature_confirmationon whenever a newly-assigned ShippingAddress requires a signature by default (or on every Direct-Buy customer change). -
#set_totals ⇒ Object
COMMON CALLBACKS.
- #shipping_conditions_changed? ⇒ Boolean
-
#shipping_discounted ⇒ Float
Net shipping cost after the shipping coupon —
shipping_cost + shipping_coupon(coupon is stored negative). - #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 ⇒ Hash{String => Object}?
Computes the SmartInstall service price for this document by bucketing coverage and linear-feet from the line items into indoor-floating / indoor-thinset / underlayment / outdoor-mat / outdoor-cable categories, applying minimum coverages and square-foot rates per category.
-
#smartsupport_data(distance) ⇒ Hash{String => Object}?
Computes the SmartSupport (onsite + remote) service price for this document, given the distance from the nearest tech in miles.
-
#store ⇒ Store?
Resolves the Store that should fulfil this document — the customer's store when set, otherwise the canonical store for the document's currency.
-
#subtotal_cogs ⇒ BigDecimal
Cost-of-goods total for active non-shipping parent lines, with catalog/store-item eager-loaded so
current_line_cogsdoesn't N+1. -
#sync_shipping_line ⇒ Object
Retrieve shipping costs might create a shipping line.
-
#total_cogs ⇒ BigDecimal
Cost-of-goods total across every LineItem (children too) not marked for destruction.
Instance Attribute Details
#force_total_reset ⇒ Object
Returns the value of attribute force_total_reset.
29 30 31 |
# File 'app/concerns/models/itemizable.rb', line 29 def force_total_reset @force_total_reset end |
#total_reset ⇒ Object
Returns the value of attribute total_reset.
29 30 31 |
# File 'app/concerns/models/itemizable.rb', line 29 def total_reset @total_reset end |
Instance Method Details
#account_specialist ⇒ Employee
24 |
# File 'app/concerns/models/itemizable.rb', line 24 belongs_to :account_specialist, class_name: 'Employee', optional: true |
#add_line_item(options) ⇒ LineItem
Adds a CatalogItem to this itemizable, merging quantity into an
existing matching LineItem when possible (same catalog item +
room configuration) unless keep_line_items_separate is set or
the SKU is in the always-separate list. Wraps the change in a
named advisory lock to serialise concurrent cart adds and retries
the inner save up to 3 times on ActiveRecord::Deadlocked.
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'app/concerns/models/itemizable.rb', line 52 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 seed = LineItem.new() seed.price ||= seed.catalog_item.amount # Reassign so we save (and return) the built line_item that's wired up # to this resource via the association — saving `seed` directly would # persist an orphan LineItem with resource_id = nil because # `belongs_to :resource` is optional. line_item = line_items.build(seed.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 #
150 151 152 |
# File 'app/concerns/models/itemizable.rb', line 150 def additional_items line_items.to_a.select { |li| li.room_configuration_id.nil? } end |
#assign_sequence(line_item) ⇒ Integer
Numbers a freshly-built LineItem with the next sequential position
within this document. Called from line-item form helpers where the
caller hasn't pre-computed sequence.
38 39 40 |
# File 'app/concerns/models/itemizable.rb', line 38 def assign_sequence(line_item) line_item.sequence = line_items.length + 1 end |
#billing_entity ⇒ Party
The Party actually being billed — the address-level party when
the billing address is owned by a different party (e.g. parent
account, buying group), otherwise the document's own customer.
252 253 254 |
# File 'app/concerns/models/itemizable.rb', line 252 def billing_entity billing_address && billing_address.party_id.present? ? billing_address.party : customer end |
#breakdown_of_prices ⇒ Hash{String => BigDecimal}
Subtotal/shipping/discounts/taxes hash used by the customer-facing
PDF and emails to render a price-breakdown table.
166 167 168 169 170 171 172 173 |
# File 'app/concerns/models/itemizable.rb', line 166 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 ⇒ Float
Insured-value figure declared to carriers — the larger of the
discounted subtotal and the COGS subtotal (so we never under-insure
a heavily-discounted shipment).
681 682 683 |
# File 'app/concerns/models/itemizable.rb', line 681 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
230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'app/concerns/models/itemizable.rb', line 230 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 ⇒ BigDecimal
Sum of price across shipping-only LineItems — used to detect
whether a re-quote produced a different shipping cost.
664 665 666 |
# File 'app/concerns/models/itemizable.rb', line 664 def calculate_shipping_cost line_items.shipping_only.to_a.sum(&:price) end |
#coupon_search(search_query, role_ids: nil, wild_search: nil) ⇒ Array<Coupon>
Finds Coupons that match search_query and could still be
applied to this document — excludes coupons already on the
discounts list and respects role-based visibility unless the user
is an admin (role 1).
343 344 345 346 347 348 349 350 351 352 353 |
# File 'app/concerns/models/itemizable.rb', line 343 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>
27 |
# File 'app/concerns/models/itemizable.rb', line 27 has_many :coupons, through: :discounts |
#customer_applied_coupons ⇒ ActiveRecord::Relation<Coupon>
Coupons currently applied to this document that are visible to
the customer (filters out trade-only / internal coupons).
387 388 389 390 |
# File 'app/concerns/models/itemizable.rb', line 387 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
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 |
# File 'app/concerns/models/itemizable.rb', line 355 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>
26 |
# File 'app/concerns/models/itemizable.rb', line 26 has_many :discounts, as: :itemizable, dependent: :destroy, inverse_of: :itemizable, autosave: true |
#discounts_changed? ⇒ Boolean
448 449 450 451 452 453 |
# File 'app/concerns/models/itemizable.rb', line 448 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 #
301 302 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 301 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) ⇒ Float
Sum of all applied discount amounts; when msrp is true and the
customer is on a pricing-program tier (trade pro), discounts are
zeroed so the MSRP version of the quote shows the customer's full
retail price.
399 400 401 402 403 404 405 406 407 |
# File 'app/concerns/models/itemizable.rb', line 399 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 ⇒ Float
Effective percentage discount across all line items — (price_total − discounted_total) / price_total * 100. Used by the CRM
discount-summary widget.
222 223 224 225 226 227 |
# File 'app/concerns/models/itemizable.rb', line 222 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 ⇒ Float
Effective percentage discount applied to shipping — 0 when no
coupon, 100 when shipping is free.
213 214 215 |
# File 'app/concerns/models/itemizable.rb', line 213 def effective_shipping_discount 100 - ((shipping_discounted / shipping_cost.to_f.round(2)) * 100) end |
#has_kits? ⇒ Boolean
558 559 560 |
# File 'app/concerns/models/itemizable.rb', line 558 def has_kits? line_items_with_counters.any? { |li| li.children_count.to_i > 0 } end |
#has_kits_or_serial_numbers? ⇒ Boolean
562 563 564 |
# File 'app/concerns/models/itemizable.rb', line 562 def has_kits_or_serial_numbers? has_serial_numbers? || has_kits? end |
#has_serial_numbers? ⇒ Boolean
554 555 556 |
# File 'app/concerns/models/itemizable.rb', line 554 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
526 527 528 |
# File 'app/concerns/models/itemizable.rb', line 526 def is_credit_order? (is_a? Order and order_type == Order::CREDIT_ORDER) end |
#line_items_requiring_serial_number ⇒ Array<LineItem>
158 159 160 |
# File 'app/concerns/models/itemizable.rb', line 158 def line_items_requiring_serial_number line_items.to_a.active_lines.select(&:require_serial_number?) end |
#line_items_with_counters ⇒ ActiveRecord::Relation<LineItem>
Line-items relation with serial-number/reserved counters preloaded
so the kit/serial-number views can render badge counts without
triggering a per-row query.
550 551 552 |
# File 'app/concerns/models/itemizable.rb', line 550 def line_items_with_counters line_items.with_reserved_serial_numbers_count.with_serial_numbers_count end |
#line_total_plus_tax ⇒ BigDecimal
SQL aggregate of discounted_price + tax_total across non-shipping
line items — the line-level total a customer pays excluding freight.
197 198 199 |
# File 'app/concerns/models/itemizable.rb', line 197 def line_total_plus_tax line_items.non_shipping.sum('line_items.discounted_price + line_items.tax_total') end |
#local_sales_rep ⇒ Employee
23 |
# File 'app/concerns/models/itemizable.rb', line 23 belongs_to :local_sales_rep, class_name: 'Employee', optional: true |
#main_rep ⇒ Employee?
Best sales-rep available for commission attribution — primary,
falling back to secondary. Used by reports and the rep-attribution
mailers.
126 127 128 129 130 |
# File 'app/concerns/models/itemizable.rb', line 126 def main_rep main_rep = primary_sales_rep main_rep ||= secondary_sales_rep main_rep end |
#perform_db_total ⇒ BigDecimal?
after_save hook — invokes the calculate_itemizable_total Postgres
function which recomputes line/total/tax columns from current line
items inside the database. Returns the new total. Runs only when
total_reset was flipped during the save.
654 655 656 657 658 |
# File 'app/concerns/models/itemizable.rb', line 654 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
21 |
# File 'app/concerns/models/itemizable.rb', line 21 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.
471 472 473 474 475 476 477 478 479 480 481 482 |
# File 'app/concerns/models/itemizable.rb', line 471 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 ⇒ void
460 461 462 463 464 465 |
# File 'app/concerns/models/itemizable.rb', line 460 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) ⇒ LineItem
Decrements quantity on line_item by quantity or destroys the
row outright if quantity covers the whole line. Returns the
destroyed/updated record.
111 112 113 114 115 116 117 118 119 |
# File 'app/concerns/models/itemizable.rb', line 111 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
417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 |
# File 'app/concerns/models/itemizable.rb', line 417 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) ⇒ void
This method returns an undefined value.
Wipes cached totals and re-runs discount/coupon evaluation, then
re-prices each parent line item against the current catalog
(preserving prices set by EDI quotes). Used when a coupon is
added/removed mid-edit so the document reflects the new state.
264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
# File 'app/concerns/models/itemizable.rb', line 264 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
22 |
# File 'app/concerns/models/itemizable.rb', line 22 belongs_to :secondary_sales_rep, class_name: 'Employee', optional: true |
#set_for_recalc(include_shipping: true) ⇒ void
This method returns an undefined value.
Forces a full total-recalculation on the next save by clearing the
cached line_total / total / tax_total columns and turning on
recalculate_shipping / recalculate_discounts / force_total_reset.
Used by edits that touch shipping inputs or coupons where the
change-detection in #require_total_reset? is insufficient.
183 184 185 186 187 188 189 190 191 |
# File 'app/concerns/models/itemizable.rb', line 183 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 ⇒ Boolean
before_save hook — flips signature_confirmation on whenever a
newly-assigned ShippingAddress requires a signature by default
(or on every Direct-Buy customer change). Keeps the carrier flag
consistent with the address's risk profile.
639 640 641 642 643 644 645 646 |
# File 'app/concerns/models/itemizable.rb', line 639 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
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 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 |
# File 'app/concerns/models/itemizable.rb', line 569 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
511 512 513 514 515 |
# File 'app/concerns/models/itemizable.rb', line 511 def shipping_conditions_changed? return true if shipping_address_id_changed? # before_save true if shipping_address&.address_element_changed? end |
#shipping_discounted ⇒ Float
Net shipping cost after the shipping coupon — shipping_cost + shipping_coupon (coupon is stored negative). Rounded to 2dp.
205 206 207 |
# File 'app/concerns/models/itemizable.rb', line 205 def shipping_discounted shipping_cost.to_f.round(2) + shipping_coupon.to_f.round(2) end |
#shipping_method_changed? ⇒ Boolean
517 518 519 520 521 522 523 524 |
# File 'app/concerns/models/itemizable.rb', line 517 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
489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 |
# File 'app/concerns/models/itemizable.rb', line 489 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 ⇒ Hash{String => Object}?
Computes the SmartInstall service price for this document by
bucketing coverage and linear-feet from the line items into
indoor-floating / indoor-thinset / underlayment / outdoor-mat /
outdoor-cable categories, applying minimum coverages and
square-foot rates per category. Returns nil when no SmartInstall
service can be sold (no service-fee SKU matched).
693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 |
# File 'app/concerns/models/itemizable.rb', line 693 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) ⇒ Hash{String => Object}?
Computes the SmartSupport (onsite + remote) service price for this
document, given the distance from the nearest tech in miles. Onsite
is only offered within SMART_SERVICES_MAX_DISTANCE miles; the
first 100 are free, additional miles bill at the per-mile catalog
rate. Remote support is always quoted. Returns nil when there's
nothing electrically heated to support.
776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 |
# File 'app/concerns/models/itemizable.rb', line 776 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 ⇒ Store?
Resolves the Store that should fulfil this document — the
customer's store when set, otherwise the canonical store for the
document's currency. Used by inventory and pricing lookups.
137 138 139 140 141 142 143 144 |
# File 'app/concerns/models/itemizable.rb', line 137 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 ⇒ BigDecimal
Cost-of-goods total for active non-shipping parent lines, with
catalog/store-item eager-loaded so current_line_cogs doesn't N+1.
672 673 674 |
# File 'app/concerns/models/itemizable.rb', line 672 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.
532 533 534 535 536 537 538 539 540 541 542 543 |
# File 'app/concerns/models/itemizable.rb', line 532 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 ⇒ BigDecimal
Cost-of-goods total across every LineItem (children too) not
marked for destruction. Used by margin/profit calculations.
413 414 415 |
# File 'app/concerns/models/itemizable.rb', line 413 def total_cogs line_items.select { |li| !li.marked_for_destruction? }.map(&:total_cogs).sum end |