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.
Belongs to collapse
-
#account_specialist ⇒ Employee?
Account specialist assigned to this document.
-
#local_sales_rep ⇒ Employee?
Local sales rep credited for this document.
-
#primary_sales_rep ⇒ Employee?
Primary sales rep credited for this document.
-
#secondary_sales_rep ⇒ Employee?
Secondary sales rep credited for this document.
Has many collapse
-
#coupons ⇒ ActiveRecord::Associations::CollectionProxy<Coupon>
Coupons backing the applied discounts.
-
#discounts ⇒ ActiveRecord::Associations::CollectionProxy<Discount>
Applied discounts attached to this document.
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.
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 ⇒ Array<LineItem>
Returns line items not attached to any room configuration.
-
#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) ⇒ Symbol
Recalculates and applies coupon discounts in priority order via Coupon::ItemizableDiscountCalculator.
-
#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) ⇒ Hash
Validates whether the customer-facing shopper may apply
coupon_codeto this document — checks for blank codes, duplicates, public visibility, and exclusivity conflicts with already-applied coupons. -
#discounts_changed? ⇒ Boolean?
Whether any loaded discount has unsaved changes or is marked for destruction — one of the triggers for #require_total_reset?.
-
#discounts_grouped_by_coupon(return_coupon_object = false) {|line_discounts| ... } ⇒ Hash{String => Hash}
Groups applied discounts by coupon code, returning per-coupon title, summed amount, serial-number id and collected discount notes.
-
#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
Whether any line item has child lines (i.e. is a kit) — drives the kit badge/section in kit views.
-
#has_kits_or_serial_numbers? ⇒ Boolean
Convenience OR of #has_serial_numbers? and #has_kits? for views that render either section.
-
#has_serial_numbers? ⇒ Boolean
Whether any line item carries reserved or assigned serial numbers — drives the serial-number badge/section in kit views.
-
#is_credit_order? ⇒ Boolean
Whether this document is an Order of credit-order type — used to pick return/refund flows over normal checkout behaviour.
- #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 ⇒ void
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
Whether the cached totals must be recalculated on the next save — true when totals were forced (
force_total_reset), line items or discounts changed, shipping needs re-quoting, or the customer or shipping method changed. -
#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 Address requires a signature by default (or on every Direct-Buy customer change). -
#set_totals ⇒ Boolean
COMMON CALLBACKS.
-
#shipping_conditions_changed? ⇒ Boolean?
Whether inputs to shipping pricing changed — the shipping address id, or any address element on the current shipping address.
-
#shipping_discounted ⇒ Float
Net shipping cost after the shipping coupon —
shipping_cost + shipping_coupon(coupon is stored negative). -
#shipping_method_changed? ⇒ Boolean
Whether any loaded delivery or its shipping costs changed — one of the triggers for #require_total_reset?.
-
#should_recalculate_shipping? ⇒ Boolean
Whether shipping should be recalculated for this record.
-
#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 ⇒ void
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
#account_specialist ⇒ Employee?
Account specialist assigned to this document.
36 |
# File 'app/concerns/models/itemizable.rb', line 36 belongs_to :account_specialist, class_name: 'Employee', optional: true |
#coupons ⇒ ActiveRecord::Associations::CollectionProxy<Coupon>
Coupons backing the applied discounts.
45 |
# File 'app/concerns/models/itemizable.rb', line 45 has_many :coupons, through: :discounts |
#discounts ⇒ ActiveRecord::Associations::CollectionProxy<Discount>
Applied discounts attached to this document.
41 |
# File 'app/concerns/models/itemizable.rb', line 41 has_many :discounts, as: :itemizable, dependent: :destroy, inverse_of: :itemizable, autosave: true |
#force_total_reset ⇒ Object
Returns the value of attribute force_total_reset.
47 48 49 |
# File 'app/concerns/models/itemizable.rb', line 47 def force_total_reset @force_total_reset end |
#local_sales_rep ⇒ Employee?
Local sales rep credited for this document.
32 |
# File 'app/concerns/models/itemizable.rb', line 32 belongs_to :local_sales_rep, class_name: 'Employee', optional: true |
#primary_sales_rep ⇒ Employee?
Primary sales rep credited for this document.
24 |
# File 'app/concerns/models/itemizable.rb', line 24 belongs_to :primary_sales_rep, class_name: 'Employee', optional: true |
#secondary_sales_rep ⇒ Employee?
Secondary sales rep credited for this document.
28 |
# File 'app/concerns/models/itemizable.rb', line 28 belongs_to :secondary_sales_rep, class_name: 'Employee', optional: true |
#total_reset ⇒ Object
Returns the value of attribute total_reset.
47 48 49 |
# File 'app/concerns/models/itemizable.rb', line 47 def total_reset @total_reset end |
Instance Method Details
#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.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'app/concerns/models/itemizable.rb', line 75 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 ⇒ Array<LineItem>
Returns line items not attached to any room configuration
175 176 177 |
# File 'app/concerns/models/itemizable.rb', line 175 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.
56 57 58 |
# File 'app/concerns/models/itemizable.rb', line 56 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.
283 284 285 |
# File 'app/concerns/models/itemizable.rb', line 283 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.
191 192 193 194 195 196 197 198 |
# File 'app/concerns/models/itemizable.rb', line 191 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).
785 786 787 |
# File 'app/concerns/models/itemizable.rb', line 785 def calculate_actual_insured_value [subtotal_discounted_without_shipping.to_f, subtotal_cogs.to_f].max end |
#calculate_discounts(autosave = false) ⇒ Symbol
Recalculates and applies coupon discounts in priority order via
Coupon::ItemizableDiscountCalculator. Skipped entirely on
CreditMemo and Invoice, and skipped on editing-locked documents
unless recalculate_discounts was explicitly flagged.
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 |
# File 'app/concerns/models/itemizable.rb', line 261 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.
768 769 770 |
# File 'app/concerns/models/itemizable.rb', line 768 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).
391 392 393 394 395 396 397 398 399 400 401 |
# File 'app/concerns/models/itemizable.rb', line 391 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 |
#customer_applied_coupons ⇒ ActiveRecord::Relation<Coupon>
Coupons currently applied to this document that are visible to
the customer (filters out trade-only / internal coupons).
443 444 445 446 |
# File 'app/concerns/models/itemizable.rb', line 443 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) ⇒ Hash
Validates whether the customer-facing shopper may apply
coupon_code to this document — checks for blank codes, duplicates,
public visibility, and exclusivity conflicts with already-applied
coupons.
411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 |
# File 'app/concerns/models/itemizable.rb', line 411 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_changed? ⇒ Boolean?
Whether any loaded discount has unsaved changes or is marked for
destruction — one of the triggers for #require_total_reset?.
515 516 517 518 519 520 |
# File 'app/concerns/models/itemizable.rb', line 515 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) {|line_discounts| ... } ⇒ Hash{String => Hash}
Groups applied discounts by coupon code, returning per-coupon
title, summed amount, serial-number id and collected discount notes.
An optional block can narrow the LineDiscount relation first.
349 350 351 352 353 354 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 |
# File 'app/concerns/models/itemizable.rb', line 349 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.
455 456 457 458 459 460 461 462 463 |
# File 'app/concerns/models/itemizable.rb', line 455 def discounts_subtotal(msrp = false) discounts.reject(&: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.
247 248 249 250 251 252 |
# File 'app/concerns/models/itemizable.rb', line 247 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.
238 239 240 |
# File 'app/concerns/models/itemizable.rb', line 238 def effective_shipping_discount 100 - ((shipping_discounted / shipping_cost.to_f.round(2)) * 100) end |
#has_kits? ⇒ Boolean
Whether any line item has child lines (i.e. is a kit) — drives the
kit badge/section in kit views.
650 651 652 |
# File 'app/concerns/models/itemizable.rb', line 650 def has_kits? line_items_with_counters.any? { |li| li.children_count.to_i > 0 } end |
#has_kits_or_serial_numbers? ⇒ Boolean
Convenience OR of #has_serial_numbers? and #has_kits? for views
that render either section.
658 659 660 |
# File 'app/concerns/models/itemizable.rb', line 658 def has_kits_or_serial_numbers? has_serial_numbers? || has_kits? end |
#has_serial_numbers? ⇒ Boolean
Whether any line item carries reserved or assigned serial numbers —
drives the serial-number badge/section in kit views.
642 643 644 |
# File 'app/concerns/models/itemizable.rb', line 642 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
Whether this document is an Order of credit-order type — used to
pick return/refund flows over normal checkout behaviour.
608 609 610 |
# File 'app/concerns/models/itemizable.rb', line 608 def is_credit_order? (is_a? Order and order_type == Order::CREDIT_ORDER) end |
#line_items_requiring_serial_number ⇒ Array<LineItem>
183 184 185 |
# File 'app/concerns/models/itemizable.rb', line 183 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.
634 635 636 |
# File 'app/concerns/models/itemizable.rb', line 634 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.
222 223 224 |
# File 'app/concerns/models/itemizable.rb', line 222 def line_total_plus_tax line_items.non_shipping.sum('line_items.discounted_price + line_items.tax_total') end |
#main_rep ⇒ Employee?
Best sales-rep available for commission attribution — primary,
falling back to secondary. Used by reports and the rep-attribution
mailers.
149 150 151 152 153 |
# File 'app/concerns/models/itemizable.rb', line 149 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.
758 759 760 761 762 |
# File 'app/concerns/models/itemizable.rb', line 758 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 |
#purge_empty_quoting_deliveries ⇒ void
This method returns an undefined value.
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.
540 541 542 543 544 545 546 547 548 549 550 551 |
# File 'app/concerns/models/itemizable.rb', line 540 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
527 528 529 530 531 532 |
# File 'app/concerns/models/itemizable.rb', line 527 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.
134 135 136 137 138 139 140 141 142 |
# File 'app/concerns/models/itemizable.rb', line 134 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
Whether the cached totals must be recalculated on the next save —
true when totals were forced (force_total_reset), line items or
discounts changed, shipping needs re-quoting, or the customer or
shipping method changed. Returns false early when do_not_set_totals
is set.
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 508 509 |
# File 'app/concerns/models/itemizable.rb', line 480 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.
295 296 297 298 299 300 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 333 334 335 |
# File 'app/concerns/models/itemizable.rb', line 295 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 # Line items on an invoiced (locked) delivery are financially frozen: the # deferred `locked_delivery_line_items_guard` DB trigger rejects any write. # On a partially-invoiced split order, re-pricing a live delivery must not # touch the frozen one — otherwise this `li.update` aborts the whole # transaction at commit (mirrors the guard in # Coupon::ItemizableDiscountCalculator#reject_locked_delivery_lines). locked_delivery_ids = Delivery.locked_ids_among(line_items.where.not(delivery_id: nil).select(:delivery_id)) # 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| next if li.delivery_id && locked_delivery_ids.include?(li.delivery_id) 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 |
#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.
208 209 210 211 212 213 214 215 216 |
# File 'app/concerns/models/itemizable.rb', line 208 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 Address requires a signature by default
(or on every Direct-Buy customer change). Keeps the carrier flag
consistent with the address's risk profile.
743 744 745 746 747 748 749 750 |
# File 'app/concerns/models/itemizable.rb', line 743 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 ⇒ Boolean
COMMON CALLBACKS
before_save hook — when #require_total_reset? flips
total_reset, purges empty/shipping-orphan lines and zombie
quoting deliveries, re-quotes shipping when needed, syncs shipping
lines, flags discount recalculation when shipping cost or method
changed, then recalculates discounts. Always returns true so the
save proceeds.
673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 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 |
# File 'app/concerns/models/itemizable.rb', line 673 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?
Whether inputs to shipping pricing changed — the shipping address
id, or any address element on the current shipping address.
585 586 587 588 589 |
# File 'app/concerns/models/itemizable.rb', line 585 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.
230 231 232 |
# File 'app/concerns/models/itemizable.rb', line 230 def shipping_discounted shipping_cost.to_f.round(2) + shipping_coupon.to_f.round(2) end |
#shipping_method_changed? ⇒ Boolean
Whether any loaded delivery or its shipping costs changed — one of
the triggers for #require_total_reset?.
595 596 597 598 599 600 601 602 |
# File 'app/concerns/models/itemizable.rb', line 595 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
Whether shipping should be recalculated for this record
559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 |
# File 'app/concerns/models/itemizable.rb', line 559 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).
797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 |
# File 'app/concerns/models/itemizable.rb', line 797 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.
880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 |
# File 'app/concerns/models/itemizable.rb', line 880 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.
160 161 162 163 164 165 166 167 |
# File 'app/concerns/models/itemizable.rb', line 160 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.
776 777 778 |
# File 'app/concerns/models/itemizable.rb', line 776 def subtotal_cogs line_items.without_children.includes(catalog_item: :store_item).active_non_shipping_lines.sum(&:current_line_cogs) end |
#sync_shipping_line ⇒ void
This method returns an undefined value.
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.
616 617 618 619 620 621 622 623 624 625 626 627 |
# File 'app/concerns/models/itemizable.rb', line 616 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.
469 470 471 |
# File 'app/concerns/models/itemizable.rb', line 469 def total_cogs line_items.reject(&:marked_for_destruction?).sum(&:total_cogs) end |