Class: Shipping::DeterminePackaging

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

Instance Method Summary collapse

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).

Parameters:

  • goods_lines (Array)

    child/standalone line items selected for packing

  • parent_lines (Array)

    parent-only line items (parent_id IS NULL)

  • calc_items (Array)

    entries already built from goods_lines with valid dims

Returns:

  • (Array)

    substitution entries (empty when nothing qualifies)



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

Parameters:

  • delivery (Delivery)
  • md5_hash_override (String) (defaults to: nil)

    override MD5 (test / admin use)

  • store_id (Integer) (defaults to: nil)

    unused — kept for callers that still pass it

  • is_freight (Boolean) (defaults to: false)
  • skip_calculator (Boolean) (defaults to: false)

    bypass PackingCalculator (e.g. packed_or_pre_packed?)

  • dim_weight_divisor (Integer) (defaults to: Shipping::PackingCalculator::DIM_WEIGHT_DIVISOR)

    carrier-specific (default 139 = FedEx/UPS/Purolator Ground).
    Pass 115 for Purolator Express when the carrier is known.



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