Class: Shipping::CreateSuggestedShipment
- Inherits:
-
BaseService
- Object
- BaseService
- Shipping::CreateSuggestedShipment
- Defined in:
- app/services/shipping/create_suggested_shipment.rb
Overview
Service object: create suggested shipment.
Instance Attribute Summary
Attributes inherited from BaseService
Instance Method Summary collapse
-
#add_line_to_box(box, line_item) ⇒ Object
Adds a goods line's content to a single Shipping::Container.
-
#build_item_map_from_line_items(line_items) ⇒ Object
Returns a hash, item_id as key, mapping to array of line item id and quantities.
-
#calculator_packages_for(line_items, delivery) ⇒ Object
Runs PackingCalculator over
line_items, returning its packages (or[]if the calculator can't produce a full, safe allocation). -
#distribute_by_weight(boxes, line_items) ⇒ void
Distributes
line_itemsacrossboxesusing a greedy "heaviest line into lightest bin" heuristic (first-fit-decreasing). - #process(delivery) ⇒ Object
-
#synthesize_multi_box_contents(packing_solution, line_items, delivery) ⇒ void
For multi-box solutions where the packing history has dims for N boxes but no per-box content map, fill in
packages[*].contentsso the downstream allocator can assign line items to specific shipments. -
#synthesize_single_box_contents(package, line_items) ⇒ Object
For single-box solutions where the packing history has dims but no per-box item mapping, synthesize contents from the delivery's line items so the allocation loop can assign them to the one shipment.
-
#valid_shipping_dims?(item) ⇒ Boolean
True when every shipping dimension on
itemis a positive number.
Methods inherited from BaseService
#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #tagged_logger
Constructor Details
This class inherits a constructor from BaseService
Instance Method Details
#add_line_to_box(box, line_item) ⇒ Object
Adds a goods line's content to a single Shipping::Container. Kits
(which are only in the input list when pack_at_kit_level: true)
expand to their components via get_kit_items so the downstream
allocator can resolve shipment_contents back to each child line.
Non-kit lines use the line's own item.
222 223 224 225 |
# File 'app/services/shipping/create_suggested_shipment.rb', line 222 def add_line_to_box(box, line_item) items = line_item.item.is_kit? ? line_item.item.get_kit_items.to_a : [line_item.item] items.each { |i| box.add_content(i.id, line_item.quantity.abs) } end |
#build_item_map_from_line_items(line_items) ⇒ Object
Returns a hash, item_id as key, mapping to array of line item id and quantities
237 238 239 240 241 242 243 244 245 246 247 248 249 250 |
# File 'app/services/shipping/create_suggested_shipment.rb', line 237 def build_item_map_from_line_items(line_items) item_map = {} # We will keep track of all line items to allocate line_items.each do |li| # Break kit items items = li.item.get_kit_items if li.item.is_kit? items ||= [li.item] items.each do |i| item_map[i.id] ||= {} item_map[i.id][li.id] = li.quantity.abs end end item_map end |
#calculator_packages_for(line_items, delivery) ⇒ Object
Runs PackingCalculator over line_items, returning its packages
(or [] if the calculator can't produce a full, safe allocation).
Trusts the caller-supplied line_items to already be at the right
granularity for packing — that's Delivery#packable_active_lines,
whose underlying LineItemExtension#active_lines_for_packaging
filter respects each kit's CatalogItem#pack_at_kit_level:
pack_at_kit_level: true→ kit parent appears, children excluded
(the kit ships as one indivisible unit at its catalog dims)pack_at_kit_level: false→ kit children appear, parent excluded
(the kit is broken; each component is packed at its own dims)
We therefore don't apply our own parent/children filter — doing so
would silently drop the kit-children of breakable kits (delivery
783655 in dev hit exactly that path: TBS-KIT had pack_at_kit_level
false, packable_active_lines yielded the 5 children + 2 standalones,
and a parent_id.nil? filter would have kept only the standalones).
Bails with [] when any goods line lacks shipping dimensions —
a partial calculator pass would silently drop the dimless lines
from the allocation entirely, leaving them unassigned to any
suggested shipment.
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'app/services/shipping/create_suggested_shipment.rb', line 168 def calculator_packages_for(line_items, delivery) return [] if delivery.ships_ltl_freight? return [] if line_items.empty? return [] if line_items.any? { |li| !valid_shipping_dims?(li.item) } calc_items = line_items.map do |li| dims = li.item.shipping_dimensions { 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 warehouse_country = delivery.origin_address&.country_iso3 == 'CAN' ? :ca : :us Shipping::PackingCalculator.call(items: calc_items, warehouse_country: warehouse_country) end |
#distribute_by_weight(boxes, line_items) ⇒ void
This method returns an undefined value.
Distributes line_items across boxes using a greedy "heaviest line
into lightest bin" heuristic (first-fit-decreasing). Trusts the
caller-supplied line_items to already be at the right granularity
for packing — see calculator_packages_for above for the
pack_at_kit_level filtering performed upstream by
Delivery#packable_active_lines.
Kit-as-one-physical-unit (pack_at_kit_level: true) → the parent
line is in line_items. We expand its kit components via
get_kit_items when emitting add_content, so the downstream
allocator records shipment_contents against the children's own
line_items via item_map. The whole kit lands in a single bin.
Breakable kit (pack_at_kit_level: false) → the children are in
line_items as separate non-kit lines; each is placed independently.
206 207 208 209 210 211 212 213 214 215 |
# File 'app/services/shipping/create_suggested_shipment.rb', line 206 def distribute_by_weight(boxes, line_items) bin_weights = Array.new(boxes.size, 0.0) sorted_lines = line_items.sort_by { |li| -li.item.shipping_weight.to_f * li.quantity.abs } sorted_lines.each do |li| bin_idx = bin_weights.each_with_index.min_by { |w, _| w }.last add_line_to_box(boxes[bin_idx], li) bin_weights[bin_idx] += li.item.shipping_weight.to_f * li.quantity.abs end end |
#process(delivery) ⇒ Object
4 5 6 7 8 9 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 |
# File 'app/services/shipping/create_suggested_shipment.rb', line 4 def process(delivery) # Shortcut return [] if delivery.is_service_only? suggested_shipments = [] Shipment.transaction do line_items_to_pack = delivery.packable_active_lines # Delete only fresh suggested shipments (container_code nil = never physically packed by warehouse). # Suggested shipments with a container_code were previously packed and measured by the warehouse # — they survive a CR Hold / shipping-method change so staff don't have to re-enter weights. delivery.shipments.suggested.delete_by(container_code: [nil, '']) # If any non-voided shipments remain (packed, awaiting_label, label_complete, or # previously-measured suggested shipments) return them directly without rebuilding. remaining_shipments = delivery.shipments.non_voided return remaining_shipments if remaining_shipments.present? # Try to find a matching packaging from previous order or use legacy packing model packing_solution = Shipping::DeterminePackaging.new.process(delivery: delivery, is_freight: delivery.ships_ltl_freight?) item_map = {} if packing_solution.packages.all? { |p| p.contents.present? } item_map = build_item_map_from_line_items(line_items_to_pack) elsif packing_solution.packages.size == 1 && line_items_to_pack.any? item_map = build_item_map_from_line_items(line_items_to_pack) synthesize_single_box_contents(packing_solution.packages.first, line_items_to_pack) elsif packing_solution.packages.size > 1 && line_items_to_pack.any? # Multi-box cached solution with empty per-box content map — the # historical Packing's `packdim_contents` was never populated (e.g. # the source delivery shipped before shipment_contents were assigned, # so DeliveryMd5Extractor wrote `{}`). Without a per-box map we'd # leave every suggested shipment empty and force the warehouse to # drag-and-drop each item by hand. # # Try PackingCalculator first; if it can't run (items lack shipping # dimensions, freight delivery, etc.), distribute the line items # by weight across the cached boxes using a first-fit-decreasing # heuristic so every box gets a meaningful starting allocation. # See `synthesize_multi_box_contents` for the full strategy. item_map = build_item_map_from_line_items(line_items_to_pack) synthesize_multi_box_contents(packing_solution, line_items_to_pack, delivery) end packing_solution.packages.each do |p| suggested_shipment = delivery.shipments.create(weight: p.weight, length: p.length, width: p.width, height: p.height, is_legacy: false, is_manual: false, flat_rate_package_type: p.flat_rate_package_type, container_type: p.container_type || Shipment.container_types.keys.first, # 'carton' state: 'suggested') if !suggested_shipment.valid? || !suggested_shipment.persisted? logger.error "Could not create suggested shipment: #{suggested_shipment.errors_to_s}" else if item_map.present? && p.contents.present? # map shipment contents p.contents.each do |package_content| # this returns an array of [line item id, quantities] qty_remaining_to_allocate = package_content.quantity # Sometimes garbage data or mismatch can cause an item to be fully allocated already break if item_map[package_content.item_id].nil? item_map[package_content.item_id].each do |(line_item_id, line_quantity)| allocatable_qty = [qty_remaining_to_allocate, line_quantity].min sc = suggested_shipment.shipment_contents.where(line_item_id: line_item_id).first_or_initialize sc.quantity = allocatable_qty sc.save # Remove or reduce the quantity from this line item_map[package_content.item_id][line_item_id] -= allocatable_qty # If the line is fully allocated, we remove this entry item_map[package_content.item_id].delete(line_item_id) if item_map[package_content.item_id][line_item_id] <= 0 # and remove the item id if its all allocated item_map.delete(package_content.item_id) if item_map[package_content.item_id].empty? end # If we allocated everything in this package we move on break if qty_remaining_to_allocate == 0 end end suggested_shipments << suggested_shipment end end end suggested_shipments end |
#synthesize_multi_box_contents(packing_solution, line_items, delivery) ⇒ void
This method returns an undefined value.
For multi-box solutions where the packing history has dims for N boxes but
no per-box content map, fill in packages[*].contents so the downstream
allocator can assign line items to specific shipments.
Three-tier strategy:
-
Run
Shipping::PackingCalculatorover the parent-only goods lines
(kit-collapsed). The calculator's output may legitimately differ
from the cached box count; when it produces packages with contents,
we replace the cached packages with the calculator's allocation
and mark the source as:packing_calculator. -
If the calculator can't run or returns no usable allocation
(items missing dims, kit double-count blows the per-bin spatial
budget, etc.), distribute the line items across the cached
boxes by weight — first-fit-decreasing into the lightest bin.
Preserves the cachedpackdims(which came from a real prior
shipment) while still giving the warehouse a per-box starting
allocation instead of an empty cargo manifest. -
As a last resort (cached box list is empty, somehow), dump
everything into box 1 a lasynthesize_single_box_contents.
130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'app/services/shipping/create_suggested_shipment.rb', line 130 def synthesize_multi_box_contents(packing_solution, line_items, delivery) calc_packages = calculator_packages_for(line_items, delivery) if calc_packages.present? && calc_packages.any? { |p| p.contents.present? } packing_solution.packages = calc_packages packing_solution.source_type = :packing_calculator logger.info "Shipping::CreateSuggestedShipment: synthesised #{calc_packages.size}-box allocation via PackingCalculator for delivery #{delivery.id}" elsif packing_solution.packages.any? distribute_by_weight(packing_solution.packages, line_items) logger.info "Shipping::CreateSuggestedShipment: distributed items by weight across cached #{packing_solution.packages.size}-box solution for delivery #{delivery.id}" else # Shouldn't happen — caller already verified `packing_solution.packages.size > 1`. synthesize_single_box_contents(packing_solution.packages.first, line_items) end end |
#synthesize_single_box_contents(package, line_items) ⇒ Object
For single-box solutions where the packing history has dims but no per-box
item mapping, synthesize contents from the delivery's line items so the
allocation loop can assign them to the one shipment.
96 97 98 99 100 101 |
# File 'app/services/shipping/create_suggested_shipment.rb', line 96 def synthesize_single_box_contents(package, line_items) line_items.each do |li| items = li.item.is_kit? ? li.item.get_kit_items : [li.item] items.each { |i| package.add_content(i.id, li.quantity.abs) } end end |
#valid_shipping_dims?(item) ⇒ Boolean
True when every shipping dimension on item is a positive number.
Centralises the predicate used by calculator_packages_for so both the
"all lines must qualify" guard and the per-line mapping read the same
check.
231 232 233 234 |
# File 'app/services/shipping/create_suggested_shipment.rb', line 231 def valid_shipping_dims?(item) dims = item&.shipping_dimensions dims.present? && dims.all? { |d| d.to_f > 0 } end |