Class: Shipping::DeterminePackaging
- Inherits:
-
BaseService
- Object
- BaseService
- Shipping::DeterminePackaging
- Defined in:
- app/services/shipping/determine_packaging.rb
Instance Method Summary collapse
- #get_individual_packings(item_hash, packing_query) ⇒ Object
-
#kit_parent_substitutes(goods_lines, parent_lines, calc_items) ⇒ Array
When a kit's children all lack dimension data, substitute the kit parent's shipping dimensions so the PackingCalculator can still run.
-
#packing_items_from(line_items) ⇒ Object
Converts goods line items into the flat item-hash format expected by Shipping::PackingCalculator.
- #process(delivery:, md5_hash_override: nil, store_id: nil, is_freight: false, skip_calculator: false, dim_weight_divisor: Shipping::PackingCalculator::DIM_WEIGHT_DIVISOR) ⇒ Object
Methods inherited from BaseService
#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #options, #tagged_logger
Constructor Details
This class inherits a constructor from BaseService
Instance Method Details
#get_individual_packings(item_hash, packing_query) ⇒ Object
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 |
# File 'app/services/shipping/determine_packaging.rb', line 240 def get_individual_packings(item_hash, packing_query) # We will try individual items from package history individual_packings = {} logger.info "Shipping::DeterminePackaging: Attempting individual item match" item_hash.each do |item, qty| r = Shipping::Md5HashItem.process(item, qty_override: qty) if (np = packing_query.where(md5: r.md5).first || packing_query.md5_nested_find(r.md5).first) individual_packings[item] = np end end # If we found a match for all items, when we can use this solution, otherwise we have to abandon it if individual_packings.size == item_hash.size individual_packings else {} end end |
#kit_parent_substitutes(goods_lines, parent_lines, calc_items) ⇒ Array
When a kit's children all lack dimension data, substitute the kit parent's
shipping dimensions so the PackingCalculator can still run. The parent item
carries the whole-kit box size, so treating the kit as a single unit is both
correct and safe. A parent is only substituted when NONE of its children
already appear in calc_items (partial coverage → use available children).
269 270 271 272 273 274 275 276 277 278 279 280 281 |
# File 'app/services/shipping/determine_packaging.rb', line 269 def kit_parent_substitutes(goods_lines, parent_lines, calc_items) parent_lines.filter_map do |parent_li| child_ids = goods_lines.select { |gl| gl.parent_id == parent_li.id }.map(&:item_id) next if child_ids.empty? # not a kit parent line next if calc_items.any? { |ci| child_ids.include?(ci[:item_id]) } # children already have dims dims = parent_li.item&.shipping_dimensions next unless dims&.all? { |d| d.to_f > 0 } { dimensions: dims.map { |d| d.to_f.ceil }, weight: parent_li.item&.shipping_weight.to_f, item_id: parent_li.item_id, quantity: parent_li.quantity.abs } end end |
#packing_items_from(line_items) ⇒ Object
Converts goods line items into the flat item-hash format expected by
Shipping::PackingCalculator. Returns one entry per line (quantity preserved).
Returns a partial array if any line item is missing shipping dimensions;
the caller must compare sizes to detect incomplete coverage.
287 288 289 290 291 292 293 294 295 296 297 298 299 |
# File 'app/services/shipping/determine_packaging.rb', line 287 def packing_items_from(line_items) line_items.filter_map do |li| dims = li.item&.shipping_dimensions next unless dims&.all? { |d| d.to_f > 0 } { dimensions: dims.map { |d| d.to_f.ceil }, weight: li.item&.shipping_weight.to_f, item_id: li.item_id, quantity: li.quantity.abs } end end |
#process(delivery:, md5_hash_override: nil, store_id: nil, is_freight: false, skip_calculator: false, dim_weight_divisor: Shipping::PackingCalculator::DIM_WEIGHT_DIVISOR) ⇒ Object
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 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 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'app/services/shipping/determine_packaging.rb', line 10 def process(delivery:, md5_hash_override: nil, store_id: nil, is_freight: false, skip_calculator: false, dim_weight_divisor: Shipping::PackingCalculator::DIM_WEIGHT_DIVISOR) # Request-level memoization — avoids re-running the full lookup (including # PackingCalculator) when the same delivery is inspected multiple times in # one request (e.g. audit sidebar + packed_or_pre_packed? on the same page). cache_key = "#{delivery.id}:#{is_freight}:#{skip_calculator}:#{dim_weight_divisor}" if (cached = CurrentScope.packing_solution_cache&.[](cache_key)) logger.debug "Shipping::DeterminePackaging: returning cached solution for delivery #{delivery.id}" return cached end solution = Shipping::PackingSolution.new parent_lines = delivery.packable_active_parent_lines_only ActiveRecord::Associations::Preloader.new(records: parent_lines, associations: [:item, :direct_store_item, { catalog_item: :store_item }]).call if parent_lines.any?{|li| !li.item.ships_in_single_box?} multibox = true # Only goods, safety precaution, for now this still has a depdency on line items because of the legacy model line_items_to_pack = parent_lines skip_spare_parts = false if line_items_to_pack.any?{|li| li.sku == 'TBS-KIT'} # deal with all the variety of spare parts that go with these multibox kits but are all small enough to fit on one of the boxes without affecting shipping cost hardly at all skip_spare_parts = true end item_hash = delivery.packable_parent_lines_item_hash(skip_spare_parts) # Map line to an item hash for our md5 processor r = Shipping::Md5HashItem.process(item_hash) solution.md5_line_items = md5_hash_override || r.md5 solution.relevant_md5_line_items = md5_hash_override || r.relevant_md5 # Using our packing model, find a match packing_query = Packing.all if is_freight packing_query = packing_query.freight_service else packing_query = packing_query.parcel_service end packing = packing_query.find_by(md5: solution.md5_line_items) if packing logger.info "Shipping::DeterminePackaging: Found a packing id: #{packing.id} matching the md5: #{solution.md5_line_items}" elsif (packing = packing_query.find_by(md5: solution.relevant_md5_line_items)) logger.info "Shipping::DeterminePackaging: Found a packing id: #{packing.id} matching the relevant md5: #{solution.relevant_md5_line_items}" else # Try a subpack match logger.info "Shipping::DeterminePackaging: No packing found for #{solution.md5_line_items}, trying a nested packing match" packing = packing_query.md5_nested_find(solution.md5_line_items).first if packing nested_md5 = solution.md5_line_items logger.info "Shipping::DeterminePackaging: Found a shipment inside a packing with content matching the package content requested. nested_md5: #{nested_md5}" else # Try a subpack match on relevant md5 logger.info "Shipping::DeterminePackaging: No nested packing found for #{solution.md5_line_items}, trying a nested packing match on relevant md5: #{solution.relevant_md5_line_items}" packing = packing_query.md5_nested_find(solution.relevant_md5_line_items).first if packing nested_md5 = solution.relevant_md5_line_items logger.info "Shipping::DeterminePackaging: Found a shipment inside a packing with content matching the package content requested. nested_md5: #{nested_md5}" else logger.info "Shipping::DeterminePackaging: No nested packing match" end end end else multibox = false # Only goods, safety precaution, for now this still has a depdency on line items because of the legacy model line_items_to_pack = delivery.packable_active_lines item_hash = delivery.packable_item_hash # Map line to an item hash for our md5 processor r = Shipping::Md5HashItem.process(item_hash) solution.md5_line_items = md5_hash_override || r.md5 solution.relevant_md5_line_items = md5_hash_override || r.relevant_md5 # Using our packing model, find a match packing_query = Packing.all if is_freight packing_query = packing_query.freight_service else packing_query = packing_query.parcel_service end packing = packing_query.find_by(md5: solution.md5_line_items) if packing logger.info "Shipping::DeterminePackaging: Found a packing id: #{packing.id} matching the md5: #{solution.md5_line_items}" elsif (packing = packing_query.find_by(md5: solution.relevant_md5_line_items)) logger.info "Shipping::DeterminePackaging: Found a packing id: #{packing.id} matching the relevant md5: #{solution.relevant_md5_line_items}" else # Try a subpack match logger.info "Shipping::DeterminePackaging: No packing found for #{solution.md5_line_items}, trying a nested packing match" packing = packing_query.md5_nested_find(solution.md5_line_items).first if packing nested_md5 = solution.md5_line_items logger.info "Shipping::DeterminePackaging: Found a shipment inside a packing with content matching the package content requested. nested_md5: #{nested_md5}" else # Try a subpack match on relevant md5 logger.info "Shipping::DeterminePackaging: No nested packing found for #{solution.md5_line_items}, trying a nested packing match on relevant md5: #{solution.relevant_md5_line_items}" packing = packing_query.md5_nested_find(solution.relevant_md5_line_items).first if packing nested_md5 = solution.relevant_md5_line_items logger.info "Shipping::DeterminePackaging: Found a shipment inside a packing with content matching the package content requested. nested_md5: #{nested_md5}" else logger.info "Shipping::DeterminePackaging: No nested packing match" end end end end if packing # If our packing match was from a nested md5 we pass the filter to only get those boxes solution.packing_id = packing.id solution.packages = packing.to_packages(nested_md5) solution.equivalent_delivery_id = packing.delivery_id solution.source_type = packing.origin&.to_sym || :unknown elsif multibox # For multibox kits (e.g. Amazon Vendor Central ASINs that ship across multiple # boxes), first try per-item packing history. If that fails, fall back to # PackingCalculator using each parent's shipping dimensions — the same path # used for single-box orders. individual_packings = get_individual_packings(item_hash, packing_query) if individual_packings.any? solution.packages = [] individual_packings.each do |_item, packing| solution.packages += packing.to_packages end solution.source_type = :item_group_packaging elsif !skip_calculator && !is_freight goods_lines = line_items_to_pack.select(&:is_goods?) calc_items = packing_items_from(goods_lines) warehouse_country = delivery.origin_address&.country_iso3 == 'CAN' ? :ca : :us if calc_items.any? calc_packages = Shipping::PackingCalculator.call(items: calc_items, warehouse_country: warehouse_country, dim_weight_divisor: dim_weight_divisor) if calc_packages.present? solution.packages = calc_packages solution.source_type = :packing_calculator logger.info "Shipping::DeterminePackaging: PackingCalculator (multibox fallback) produced #{calc_packages.size} box(es) for delivery #{delivery.id}" Packing.from_calculator_containers( calc_packages, md5: solution.md5_line_items, relevant_md5: solution.relevant_md5_line_items, service_type: :parcel_service ) end end end else # No packing history found. Resolution order: # 1. Dimension-based PackingCalculator (parcel only) — multi-item 3D bin packing. # Preferred over individual histories because it consolidates items across boxes. # 2. Individual per-item packing history — safety net for items missing dimensions. # Each record reflects a single-item historical shipment, so it only applies when # the calculator cannot run (incomplete dimension data). # 3. Freight pallet calculation (freight only) # parent_lines already has item/store_item associations preloaded above # Try the dimension-based packing calculator for parcel shipments when all # goods carry complete shipping dimensions. # Use line_items_to_pack (not parent_lines) so that standalone items in # single-box orders are included alongside kit parents. calc_packages = [] unless is_freight || skip_calculator goods_lines = line_items_to_pack.select(&:is_goods?) calc_items = packing_items_from(goods_lines) warehouse_country = delivery.origin_address&.country_iso3 == 'CAN' ? :ca : :us pre_sub_size = calc_items.size # When a kit's children all lack dimension data, substitute the kit parent's # shipping dimensions. The parent item carries the whole-kit box size, so # treating the kit as a single unit here is both correct and safe. # Standalone items without dimensions are silently excluded from the # calculator run — they are small enough to fit alongside whatever boxes # the other items produce. if calc_items.size < goods_lines.size substitutes = kit_parent_substitutes(goods_lines, parent_lines, calc_items) if substitutes.any? logger.info "Shipping::DeterminePackaging: substituting #{substitutes.size} kit parent(s) for dimensionless children on delivery #{delivery.id}" calc_items += substitutes end end missing_count = goods_lines.size - pre_sub_size if missing_count > 0 suffix = calc_items.any? ? ' — proceeding with available dimension data' : ' — skipping calculator' logger.warn "Shipping::DeterminePackaging: #{missing_count} item line(s) missing shipping dimensions for delivery #{delivery.id}#{suffix}" end if calc_items.any? calc_packages = Shipping::PackingCalculator.call(items: calc_items, warehouse_country: warehouse_country, dim_weight_divisor: dim_weight_divisor) logger.info "Shipping::DeterminePackaging: PackingCalculator produced #{calc_packages.size} box(es) for #{calc_items.size} item line(s) [#{warehouse_country}]" end end if calc_packages.present? solution.packages = calc_packages solution.source_type = :packing_calculator Packing.from_calculator_containers( calc_packages, md5: solution.md5_line_items, relevant_md5: solution.relevant_md5_line_items, service_type: :parcel_service ) elsif !skip_calculator # Calculator could not run (missing dimensions on some items) — fall back to # individual per-item packing histories when every item has coverage. # Skipped when skip_calculator is true because the caller (e.g. packed_or_pre_packed?) # only cares about history-backed solutions and would discard this result anyway. individual_packings = get_individual_packings(item_hash, packing_query) if individual_packings.size > 0 solution.packages ||= [] individual_packings.each do |item, packing| solution.packages += packing.to_packages end solution.source_type = :item_group_packaging end end if solution.packages.blank? && is_freight # Freight falls back to pallet calculation which requires item density and dimensions. freight_packaging = Shipping::FreightPalletCalculator.call(parent_lines.select(&:is_goods?)) if freight_packaging.present? freight_packaging[:weights].each_with_index do |lbs, idx| l, w, h = freight_packaging[:dimensions][idx].sort.reverse package = Shipping::Container.new(length: l, width: w, height: h, weight: lbs, container_type: freight_packaging[:container_types][idx]) solution.packages << package end solution.source_type = :legacy_packaging end end end # Store in request-level cache for subsequent calls in the same request. (CurrentScope.packing_solution_cache ||= {})[cache_key] = solution solution end |