Class: Item::ShippingBoxCalculator
- Inherits:
-
Object
- Object
- Item::ShippingBoxCalculator
- Defined in:
- app/services/item/shipping_box_calculator.rb
Overview
Finds the smallest valid outer shipping box for a kit, given the already-boxed
shipping dimensions of its individual components.
Each component arrives pre-packaged with its own shipping_length/width/height.
This class determines what outer box is needed to ship all of them together.
Algorithm
Two-phase approach:
-
Candidate search – iterate candidate box dimensions starting from
the per-axis minimum required, with a minimum-height derivation per (l,w)
pair that skips most of the O(n³) search space. -
Feasibility check – for each candidate, run a First-Fit-Decreasing
3D guillotine-cut bin packer to verify the items actually fit together
in 3D space (not just individually).
Carrier constraints encoded
- 2" padding on every side (standard carrier requirement)
- Max girth ≤ 165" (UPS/USPS combined length + girth limit)
- Max single dimension ≤ 108" (after padding)
- Penalty scoring for boxes over carrier surcharge thresholds:
length > 48", width > 36", girth > 130"
Constant Summary collapse
- CARRIER_PADDING =
inches added to each side for carrier requirements
2- MAX_SIDE =
max dimension (pre-padding); 106 + 2 = 108"
106- MAX_GIRTH =
UPS/USPS: length + 2*(width+height) ≤ 165"
165- LONG_THRESHOLD =
Carrier surcharge tier thresholds (pre-padding values).
Boxes exceeding these receive a PENALTY multiplier in the score, so the
algorithm prefers smaller shapes when multiple valid boxes exist. 46- WIDE_THRESHOLD =
46 → 48"+ triggers carrier length surcharge
34- GIRTH_THRESHOLD =
34 → 36"+ triggers carrier width surcharge
130- PENALTY =
130" combined girth triggers surcharge
1.5- MAX_OUTER =
108" — absolute UPS/FedEx maximum
MAX_SIDE + CARRIER_PADDING
Class Method Summary collapse
-
.call(component_dimensions, candidate_boxes: nil, padding: CARRIER_PADDING) ⇒ Object
Returns [length, width, height] sorted descending (matching the convention of KitConsolidator), or nil if no valid box exists within carrier limits.
Instance Method Summary collapse
- #call(candidate_boxes: nil, padding: CARRIER_PADDING) ⇒ Object
-
#initialize(component_dimensions) ⇒ ShippingBoxCalculator
constructor
A new instance of ShippingBoxCalculator.
Constructor Details
#initialize(component_dimensions) ⇒ ShippingBoxCalculator
Returns a new instance of ShippingBoxCalculator.
69 70 71 72 73 |
# File 'app/services/item/shipping_box_calculator.rb', line 69 def initialize(component_dimensions) # Canonicalise each item's own dimensions longest-first for rotation logic. @items = component_dimensions.map { |d| d.map(&:to_i).sort.reverse } @total_vol = @items.sum { |i| i.reduce(:*) } end |
Class Method Details
.call(component_dimensions, candidate_boxes: nil, padding: CARRIER_PADDING) ⇒ Object
Returns [length, width, height] sorted descending (matching the convention
of KitConsolidator), or nil if no valid box exists within carrier limits.
When +candidate_boxes+ is supplied (an array of [l,w,h] tuples, ordered by
preference), the first catalog box whose inner space can geometrically contain
all items is returned. This is used by PackingCalculator so that suggested
shipping boxes always correspond to boxes that actually exist in the warehouse.
Without +candidate_boxes+, the original mathematical search is used.
+padding+ controls how many inches are deducted from each catalog box
dimension before the feasibility check (default: CARRIER_PADDING = 2").
Pass +padding: 0+ when item dimensions already represent the fully-packaged
shipping box (e.g. PackingCalculator) so that, e.g., a single [41,7,7] mat
roll correctly maps to the [41,7,7] warehouse carton instead of [44,16,16].
63 64 65 66 67 |
# File 'app/services/item/shipping_box_calculator.rb', line 63 def self.call(component_dimensions, candidate_boxes: nil, padding: CARRIER_PADDING) return nil if component_dimensions.blank? new(component_dimensions).call(candidate_boxes: candidate_boxes, padding: padding) end |
Instance Method Details
#call(candidate_boxes: nil, padding: CARRIER_PADDING) ⇒ Object
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 |
# File 'app/services/item/shipping_box_calculator.rb', line 77 def call(candidate_boxes: nil, padding: CARRIER_PADDING) return call_from_catalog(candidate_boxes, padding: padding) if candidate_boxes best = nil min_score = Float::INFINITY # Per-axis lower bounds: outer box must accommodate the largest item on each axis. min_l = @items.map { |i| i[0] }.max min_w = @items.map { |i| i[1] }.max min_h = @items.map { |i| i[2] }.max (min_l..MAX_SIDE).each do |l| lp = l + CARRIER_PADDING l_weight = l > LONG_THRESHOLD ? PENALTY : 1.0 (min_w..MAX_SIDE).each do |w| wp = w + CARRIER_PADDING w_weight = w > WIDE_THRESHOLD ? PENALTY : 1.0 lw_vol = lp * wp # Skip (l,w) pairs where even the tallest allowed box can't hold total volume. next if lw_vol * (MAX_SIDE + CARRIER_PADDING) < @total_vol # Start at the minimum height where box_volume ≥ total_volume. h_floor = [(@total_vol.to_f / lw_vol).ceil - CARRIER_PADDING, min_h].max (h_floor..MAX_SIDE).each do |h| hp = h + CARRIER_PADDING girth = lp + (2 * (wp + hp)) # Girth increases monotonically with h — break instead of next. break if girth > MAX_GIRTH next unless can_pack?(lp, wp, hp) g_weight = girth > GIRTH_THRESHOLD ? PENALTY : 1.0 score = ((lp * wp * hp) - @total_vol) * l_weight * w_weight * g_weight if score < min_score min_score = score best = [lp, wp, hp].sort.reverse end end end end best end |