Module: Models::Pickable
- Extended by:
- ActiveSupport::Concern
- Included in:
- Order, Quote, RoomConfiguration
- Defined in:
- app/concerns/models/pickable.rb
Overview
Mixin for resources whose LineItem children are picked from a
Catalog (orders, quotes, room configurations, etc.).
Hosts are expected to expose line_items, customer, currency, and
the total columns that the SQL aggregator writes to. Catalog coherence
is enforced both per-line (through LineItem#catalog_item_matches_resource_catalog)
and at the resource level when the pick catalog is flipped.
Has many collapse
- #line_discounts ⇒ ActiveRecord::Associations::CollectionProxy<LineDiscount>
- #line_items ⇒ ActiveRecord::Associations::CollectionProxy<LineItem>
Instance Method Summary collapse
-
#all_my_publications {|ActiveRecord::Relation<Publication>| ... } ⇒ ActiveRecord::Relation<Publication>
Collects publications referenced by all line items, scoped to the resource's store.
-
#append_suggested_items(suggested_items_list, containing_resource) ⇒ Hash{Itemizable => Array<LineItem>}
The updated accumulator.
-
#append_suggested_materials(suggested_items_list, _containing_resource = nil) ⇒ Hash{Itemizable => Array<LineItem>}
The updated accumulator.
-
#control_capacity(product_line_url = nil) ⇒ Integer
Total control capacity across matching control lines.
-
#determine_catalog_for_picking ⇒ Catalog
Catalog that line items should be picked from.
-
#determine_skus_to_filter ⇒ Array<String>?
SKUs available in the target store's primary catalog, or nil when this is not a store transfer.
-
#discounted_shipping_total ⇒ BigDecimal
Sum of discounted totals for active shipping lines.
-
#electrical_heating_elements ⇒ ActiveRecord::Relation<LineItem>
Goods lines for electrical heating elements.
- #fix_catalog ⇒ void
-
#get_material_alerts(for_www: false, for_cart: false, _cache: true) ⇒ Array<MaterialAlert>
Returns persisted material alerts for this resource.
-
#has_custom_products? ⇒ Boolean
Whether any line item is a custom product.
-
#invalidate_material_alerts! ⇒ Integer
Invalidates (deletes) all cached material alerts for this resource.
-
#line_items_grouped_by_room_configuration(ungrouped_name: nil, group_by_item_category: true, list_empty_group: true, include_shipping_lines: true, sort_by_product_category_priority: false, exclude_unnamed_empty_group: true) ⇒ Hash{RoomConfiguration, String => Array<LineItem>}
Groups parent line items by room configuration, with optional category grouping and sorting.
-
#lines_match_pick_catalog_on_flip ⇒ void
Rejects a flip that would leave a persisted goods line in the wrong catalog.
-
#meets_custom_products_threshold? ⇒ Boolean
Whether custom products meet the threshold for a custom order agreement.
-
#pricing_program_discount_factor ⇒ BigDecimal
Resolves the effective pricing-program discount as a decimal factor.
- #purge_empty_line_items ⇒ void
-
#soft_recalc(_reset_to_catalog_price = true) ⇒ Boolean
Admin helper to recompute totals while temporarily disabling auto-coupons.
-
#subtotal(limit_to_types = []) ⇒ BigDecimal
Sum of line totals for active non-shipping lines.
-
#subtotal_after_trade_discount_without_shipping ⇒ BigDecimal
Subtotal after trade discount for non-shipping lines.
-
#subtotal_discounted_without_shipping ⇒ BigDecimal
Discounted subtotal of non-shipping lines.
-
#subtotal_msrp_without_shipping ⇒ BigDecimal
MSRP subtotal of non-shipping lines.
-
#switching_pick_catalog? ⇒ Boolean
Whether this save changes the catalog the resource picks from (a currency change, or — for orders — a customer reassignment).
-
#synchronize_lines(target = nil, save_targets = false, _do_not_recalc_shipping = false) ⇒ Array<String>
Synchronizes parent line items between this resource and its related targets.
-
#total_amps_by_product_line ⇒ Hash{String => Integer}
Total amps per product line.
-
#total_coverage_by_product_line ⇒ Hash{String => Integer}
Total coverage per product line.
-
#total_linear_feet_by_product_line ⇒ Hash{String => Integer}
Total linear feet per product line.
-
#total_spec_by_product_line(scope, spec, multiplier = 1) ⇒ Hash{String => Integer}
Sums a numeric product spec across the given scope, grouped by product line.
-
#total_watts_by_product_line ⇒ Hash{String => Integer}
Total watts per product line.
-
#underlayments ⇒ ActiveRecord::Relation<LineItem>
Goods lines for underlayments.
Instance Method Details
#all_my_publications {|ActiveRecord::Relation<Publication>| ... } ⇒ ActiveRecord::Relation<Publication>
Collects publications referenced by all line items, scoped to the resource's store.
390 391 392 393 394 395 396 397 398 399 |
# File 'app/concerns/models/pickable.rb', line 390 def all_my_publications pubs = nil line_items.each do |li| item_pubs = li.item.all_my_publications pubs = pubs.nil? ? item_pubs : (pubs | item_pubs) # This builds up an or condition end pubs = pubs.in_store(store.id) # Only publications relevant to what the order's store should be used regardless of locale pubs = yield(pubs) if block_given? pubs end |
#append_suggested_items(suggested_items_list, containing_resource) ⇒ Hash{Itemizable => Array<LineItem>}
Returns the updated accumulator.
404 405 406 407 408 |
# File 'app/concerns/models/pickable.rb', line 404 def append_suggested_items(suggested_items_list, containing_resource) suggested_items_list.tap do |suggested_items_list| append_suggested_materials(suggested_items_list, containing_resource) end end |
#append_suggested_materials(suggested_items_list, _containing_resource = nil) ⇒ Hash{Itemizable => Array<LineItem>}
Returns the updated accumulator.
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 438 439 440 441 442 443 444 |
# File 'app/concerns/models/pickable.rb', line 413 def append_suggested_materials(suggested_items_list, _containing_resource = nil) suggested_items_list.tap do |suggested_items_list| catalog = customer.catalog # Now figure out the suggested items material_alerts = get_material_alerts material_alerts.select { |ma| ma.items.any? and (ma.recommended_qty > ma.actual_qty) }.each do |ma| logger.debug "append_suggested_materials, ma.group_name: #{ma.group_name}" logger.debug "append_suggested_materials, ma.items.map{|i| i.sku}: #{ma.items.map(&:sku).inspect}" ma.items.each do |item| ci = catalog.catalog_items.by_skus(item.sku).first next unless ci # re-apply stock thresholds and customer filters for quote pdf stock_threshold_ok = false stock_threshold_ok = true if ci.item.suggested_item_applies_to(customer) && (ci.qty_available > item.suggested_item_stock_threshold) add_item = true if stock_threshold_ok && (ma.group_name.to_s == 'Installation Kits') # ugh, hacky but this is the y to accomodate all the weird patterns for suggested add ons in the quote add_item = false logger.debug "append_suggested_materials, item.sku: #{item.sku}, add_item: #{add_item}" end next unless add_item ci_discount = [customer.pricing_program_discount, ci.max_discount].min discount_unit_price = (ci.amount * (1 - (ci_discount / 100)).to_f).round(2) suggested_items_list ||= {} suggested_items_list[self] ||= [] suggested_items_list[self] << LineItem.new(item: item, resource: self, catalog_item_id: ci.id, description: ci.public_name, quantity: (ma.recommended_qty - ma.actual_qty), price: ci.amount, discounted_price: discount_unit_price) end end end end |
#control_capacity(product_line_url = nil) ⇒ Integer
Returns total control capacity across matching control lines.
147 148 149 150 151 152 153 154 |
# File 'app/concerns/models/pickable.rb', line 147 def control_capacity(product_line_url = nil) lines = if product_line_url.present? [line_items.controls.by_product_line_url(product_line_url), line_items.integration_kits.by_product_line_url(product_line_url), line_items.powers.by_product_line_url(product_line_url)].flatten else [line_items.controls, line_items.integration_kits, line_items.powers].flatten end lines.sum { |li| li.quantity * li.item.control_capacity } end |
#determine_catalog_for_picking ⇒ Catalog
Returns catalog that line items should be picked from.
375 376 377 |
# File 'app/concerns/models/pickable.rb', line 375 def determine_catalog_for_picking try(:is_store_transfer?) ? from_store.primary_catalog : customer.catalog end |
#determine_skus_to_filter ⇒ Array<String>?
Returns SKUs available in the target store's primary catalog,
or nil when this is not a store transfer.
381 382 383 |
# File 'app/concerns/models/pickable.rb', line 381 def determine_skus_to_filter try(:is_store_transfer?) ? to_store.primary_catalog.items.pluck(:sku) : nil end |
#discounted_shipping_total ⇒ BigDecimal
Returns sum of discounted totals for active shipping lines.
276 277 278 |
# File 'app/concerns/models/pickable.rb', line 276 def discounted_shipping_total line_items.active_shipping_lines.sum(&:total) end |
#electrical_heating_elements ⇒ ActiveRecord::Relation<LineItem>
Returns goods lines for electrical heating elements.
99 100 101 |
# File 'app/concerns/models/pickable.rb', line 99 def electrical_heating_elements line_items.goods.joins(item: :primary_product_line).heating_elements.order('product_lines.lineage_expanded') end |
#fix_catalog ⇒ void
This method returns an undefined value.
370 371 372 |
# File 'app/concerns/models/pickable.rb', line 370 def fix_catalog CatalogItem::Remapper.new(self).fix_all end |
#get_material_alerts(for_www: false, for_cart: false, _cache: true) ⇒ Array<MaterialAlert>
Returns persisted material alerts for this resource.
Uses database persistence instead of Rails.cache to avoid serialization issues.
453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 |
# File 'app/concerns/models/pickable.rb', line 453 def get_material_alerts(for_www: false, for_cart: false, _cache: true) return [] unless respond_to?(:line_items) # Eager load items to avoid N+1 during signature calculation (which calls line_item.sku) current_signature = line_items.includes(:item).signature if current_signature.nil? ErrorReporting.warning("Material alerts: line_items.signature returned nil for #{self.class.name} #{id}") return [] end # Check for existing valid alerts with matching signature existing = MaterialAlert.for_resource(self, signature: current_signature) return existing.to_a if existing.exists? # Generate fresh alerts and persist them = { for_www: for_www, for_cart: for_cart } check_results = Item::Materials::Check.new.perform_checks_raw( container: self, options: ) MaterialAlert.regenerate_for(self, check_results: check_results, signature: current_signature) end |
#has_custom_products? ⇒ Boolean
Returns whether any line item is a custom product.
89 90 91 |
# File 'app/concerns/models/pickable.rb', line 89 def has_custom_products? line_items.joins(:item).where(items: { product_category_id: ProductCategory.custom_product_ids }).exists? end |
#invalidate_material_alerts! ⇒ Integer
Invalidates (deletes) all cached material alerts for this resource.
Call this when line items change significantly outside normal flow,
such as during order split operations.
482 483 484 |
# File 'app/concerns/models/pickable.rb', line 482 def invalidate_material_alerts! MaterialAlert.invalidate_for(self) end |
#line_discounts ⇒ ActiveRecord::Associations::CollectionProxy<LineDiscount>
27 |
# File 'app/concerns/models/pickable.rb', line 27 has_many :line_discounts, through: :line_items |
#line_items ⇒ ActiveRecord::Associations::CollectionProxy<LineItem>
19 20 21 22 23 24 |
# File 'app/concerns/models/pickable.rb', line 19 has_many :line_items, as: :resource, dependent: :destroy, extend: LineItemExtension, autosave: true, inverse_of: :resource |
#line_items_grouped_by_room_configuration(ungrouped_name: nil, group_by_item_category: true, list_empty_group: true, include_shipping_lines: true, sort_by_product_category_priority: false, exclude_unnamed_empty_group: true) ⇒ Hash{RoomConfiguration, String => Array<LineItem>}
Groups parent line items by room configuration, with optional category grouping
and sorting.
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 |
# File 'app/concerns/models/pickable.rb', line 212 def line_items_grouped_by_room_configuration(ungrouped_name: nil, group_by_item_category: true, list_empty_group: true, include_shipping_lines: true, sort_by_product_category_priority: false, exclude_unnamed_empty_group: true) ungrouped_name ||= 'Ungrouped items' items = {} if instance_of? RoomConfiguration l_items = if include_shipping_lines line_items.parents_only.with_associations else line_items.parents_only.with_associations.reject(&:is_shipping?) end items[name] = l_items.to_a else rc_ids = [] room_configurations.order(:name).each do |rc| rc_ids << rc.id items[rc] ||= [] # Required to set order end items[ungrouped_name] = [] line_items.parents_only .includes(:item, :line_discounts, :inventory_commits, catalog_item: :store_item) .reject(&:is_shipping?).each do |li| rc = (li.resource.is_a?(RoomConfiguration) ? li.resource : li.room_configuration) group_id = (!rc.nil? && rc_ids.include?(rc.id) ? rc : ungrouped_name) items[group_id] ||= [] items[group_id] << li end end items.each { |key, lines| items[key] = LineItem.group_by_category(lines) } if group_by_item_category if sort_by_product_category_priority items.each do |key, lines| items[key] = lines.sort_by do |li| [(begin li.item.product_category.priority rescue StandardError 99 end), li.item.sku] end end end items.delete_if { |_key, lines| lines.empty? } unless list_empty_group items.delete_if { |key, lines| key == ungrouped_name && lines.empty? } if exclude_unnamed_empty_group items end |
#lines_match_pick_catalog_on_flip ⇒ void
This method returns an undefined value.
Rejects a flip that would leave a persisted goods line in the wrong catalog.
Queries the DB (not the cached association) so a legitimate flip through
Catalog::SwitchItemizableCatalog — which remaps + saves the lines before the
resource is saved — sees them already coherent, while a raw currency/customer
change with un-remapped lines is rejected and rolled back.
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'app/concerns/models/pickable.rb', line 66 def lines_match_pick_catalog_on_flip return if try(:is_store_transfer?) return if is_a?(Order) && order_type == Order::CREDIT_ORDER # credits keep the original cross-catalog item target = begin determine_catalog_for_picking rescue StandardError nil end return if target&.id.blank? # `goods` (not `non_shipping`) to mirror LineItem#catalog_item_matches_resource_catalog, # which only enforces goods lines (is_goods? == tax_class 'g'). stranded = line_items.goods.joins(:catalog_item) .where(CatalogItem[:catalog_id].not_eq(target.id)).count return if stranded.zero? errors.add(:base, "#{stranded} line item(s) are not in catalog #{target.id} (#{target.name}) — " \ 'use Catalog::SwitchItemizableCatalog to change catalog/currency') end |
#meets_custom_products_threshold? ⇒ Boolean
Returns whether custom products meet the threshold for a custom order agreement.
94 95 96 |
# File 'app/concerns/models/pickable.rb', line 94 def meets_custom_products_threshold? has_custom_products? && subtotal >= OrderConstants::CUSTOM_ORDER_AGREEMENT_THRESHOLD end |
#pricing_program_discount_factor ⇒ BigDecimal
Resolves the effective pricing-program discount as a decimal factor.
Prefers the resource's own discount, then its quote, then its customer.
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 |
# File 'app/concerns/models/pickable.rb', line 284 def pricing_program_discount_factor effective_discount = (begin pricing_program_discount rescue StandardError nil end) effective_discount ||= begin quote.pricing_program_discount rescue StandardError nil end # Order's quote will have precedence over customers effective_discount ||= begin customer.pricing_program_discount rescue StandardError nil end (effective_discount.to_f / 100).round(2) end |
#purge_empty_line_items ⇒ void
This method returns an undefined value.
157 158 159 160 |
# File 'app/concerns/models/pickable.rb', line 157 def purge_empty_line_items logger.info "#{self.class.name}:#{id} purge_empty_line_items called" line_items.select { |li| li.quantity == 0 }.each(&:destroy) end |
#soft_recalc(_reset_to_catalog_price = true) ⇒ Boolean
Admin helper to recompute totals while temporarily disabling auto-coupons.
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'app/concerns/models/pickable.rb', line 166 def soft_recalc(_reset_to_catalog_price = true) prev_value = begin disable_auto_coupon rescue StandardError nil end unless is_a? RoomConfiguration reload self.disable_auto_coupon = true self.line_total = nil self.tax_total = nil self.total = nil end save! update_attribute(:disable_auto_coupon, prev_value) unless is_a? RoomConfiguration end |
#subtotal(limit_to_types = []) ⇒ BigDecimal
Returns sum of line totals for active non-shipping lines.
264 265 266 267 268 269 270 271 272 273 |
# File 'app/concerns/models/pickable.rb', line 264 def subtotal(limit_to_types = []) l_its = line_items.active_non_shipping_lines if limit_to_types.present? && limit_to_types.any? l_its = [] limit_to_types.each do |ltype| l_its += line_items.send(ltype) end end l_its.sum(&:total) end |
#subtotal_after_trade_discount_without_shipping ⇒ BigDecimal
Returns subtotal after trade discount for non-shipping lines.
191 192 193 194 195 |
# File 'app/concerns/models/pickable.rb', line 191 def subtotal_after_trade_discount_without_shipping # Use line_discounts for exact totals (prevents rounding errors) # This matches LineItem#discounted_total and SQL calculate_itemizable_total function line_items.non_shipping.includes(:line_discounts).to_a.sum(&:discounted_total) end |
#subtotal_discounted_without_shipping ⇒ BigDecimal
Returns discounted subtotal of non-shipping lines.
184 185 186 187 188 |
# File 'app/concerns/models/pickable.rb', line 184 def subtotal_discounted_without_shipping # Use line_discounts for exact totals (prevents rounding errors) # This matches LineItem#discounted_total and SQL calculate_itemizable_total function line_items.non_shipping.includes(:line_discounts).to_a.sum(&:discounted_total) end |
#subtotal_msrp_without_shipping ⇒ BigDecimal
Returns MSRP subtotal of non-shipping lines.
198 199 200 |
# File 'app/concerns/models/pickable.rb', line 198 def subtotal_msrp_without_shipping BigDecimal(line_items.non_shipping.sum('(price * quantity)')) end |
#switching_pick_catalog? ⇒ Boolean
Returns whether this save changes the catalog the resource picks
from (a currency change, or — for orders — a customer reassignment).
53 54 55 56 57 |
# File 'app/concerns/models/pickable.rb', line 53 def switching_pick_catalog? return true if will_save_change_to_currency? has_attribute?(:customer_id) && will_save_change_to_customer_id? end |
#synchronize_lines(target = nil, save_targets = false, _do_not_recalc_shipping = false) ⇒ Array<String>
Synchronizes parent line items between this resource and its related targets.
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 |
# File 'app/concerns/models/pickable.rb', line 312 def synchronize_lines(target = nil, save_targets = false, _do_not_recalc_shipping = false) lines_changed = [] targets = [target || try(:synchronization_targets)].flatten # Rooms push to their quotes, quotes and orders push to their room targets.each do |i| # The lines we match against varies, a room configuration will sync into quotes and order and the lines need to be filtered to be just those of the room configuration, but from a quote or order to room there's no need to do a filter target_lines = [] rc_id_filter = nil if i.is_a? RoomConfiguration rc_id_filter = i.id target_lines = i.line_items.active_parent_lines source_lines = line_items.active_parent_lines.select { |li| li.room_configuration_id == i.id } else rc_id_filter = id source_lines = line_items.active_parent_lines target_lines = i.line_items.active_parent_lines.select { |lir| lir.room_configuration_id == id } end # Try to match these room lines in the itemizable to our room merged_line_ids = [] change_detected = false target_lines.each do |lir| # iterate through each target line and find it in the source lines. beware of what was already matched. matched_line_in_source = source_lines.find do |li| li.catalog_item_id == lir.catalog_item_id and merged_line_ids.exclude?(li.id) end if matched_line_in_source # update quantities if different unless lir.quantity == matched_line_in_source.quantity lir.quantity = matched_line_in_source.quantity lines_changed << "#{i.id}[#{lir.id}M]" change_detected = true end # Keep track of lines we processed merged_line_ids << matched_line_in_source.id else lines_changed << "#{i.id}[#{lir.id}-]" lir.destroy change_detected = true end end # Anything unmatched is added source_lines.reject { |li| merged_line_ids.include? li.id }.each do |new_line| change_detected = true attrs = new_line.attributes.except('id', 'resource_id', 'resource_type', 'delivery_id', 'created_at', 'updated_at') attrs['room_configuration_id'] = rc_id_filter i.line_items.build(attrs) lines_changed << "#{i.id}[#{new_line.id}+]" end i.save if save_targets i.refresh_plans(delay: 10.seconds) if i.is_a?(RoomConfiguration) && i.respond_to?(:refresh_plans) && change_detected end lines_changed end |
#total_amps_by_product_line ⇒ Hash{String => Integer}
Returns total amps per product line.
141 142 143 |
# File 'app/concerns/models/pickable.rb', line 141 def total_amps_by_product_line total_spec_by_product_line(electrical_heating_elements, 'amps') end |
#total_coverage_by_product_line ⇒ Hash{String => Integer}
Returns total coverage per product line.
124 125 126 127 128 |
# File 'app/concerns/models/pickable.rb', line 124 def total_coverage_by_product_line total_spec_by_product_line(electrical_heating_elements, 'coverage').merge( total_spec_by_product_line(underlayments, 'coverage') ) end |
#total_linear_feet_by_product_line ⇒ Hash{String => Integer}
Returns total linear feet per product line.
131 132 133 |
# File 'app/concerns/models/pickable.rb', line 131 def total_linear_feet_by_product_line total_spec_by_product_line(electrical_heating_elements, 'length', 1.to_f / 12) end |
#total_spec_by_product_line(scope, spec, multiplier = 1) ⇒ Hash{String => Integer}
Sums a numeric product spec across the given scope, grouped by product line.
114 115 116 117 118 119 120 121 |
# File 'app/concerns/models/pickable.rb', line 114 def total_spec_by_product_line(scope, spec, multiplier = 1) r = scope.where(parent_id: nil).pluck(Arel.sql("product_lines.lineage_expanded,items.rendered_product_specifications->'#{spec}'->'raw',quantity")).select { |e| e[1] && e[2] } r.each_with_object({}) do |r, hsh| hsh[r[0]] ||= 0.0 hsh[r[0]] += (r[1].to_f * r[2].to_f) * multiplier hsh[r[0]] = hsh[r[0]].round end end |
#total_watts_by_product_line ⇒ Hash{String => Integer}
Returns total watts per product line.
136 137 138 |
# File 'app/concerns/models/pickable.rb', line 136 def total_watts_by_product_line total_spec_by_product_line(electrical_heating_elements, 'watts') end |
#underlayments ⇒ ActiveRecord::Relation<LineItem>
Returns goods lines for underlayments.
104 105 106 |
# File 'app/concerns/models/pickable.rb', line 104 def underlayments line_items.goods.joins(item: :primary_product_line).underlayments.order('product_lines.lineage_expanded') end |