Class: Shipping::CreateSuggestedShipment

Inherits:
BaseService show all
Defined in:
app/services/shipping/create_suggested_shipment.rb

Overview

Service object: create suggested shipment.

Instance Attribute Summary

Attributes inherited from BaseService

#options

Instance Method Summary collapse

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.

Parameters:

  • boxes (Array<Shipping::Container>)

    cached boxes (dims, no contents)

  • line_items (Array<LineItem>)

    packable_active_lines (already kit-resolved)



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:

  1. Run Shipping::PackingCalculator over 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.

  2. 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 cached packdims (which came from a real prior
    shipment) while still giving the warehouse a per-box starting
    allocation instead of an empty cargo manifest.

  3. As a last resort (cached box list is empty, somehow), dump
    everything into box 1 a la synthesize_single_box_contents.

Parameters:



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.

Returns:

  • (Boolean)


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