Class: Item::ShippingBoxCalculator

Inherits:
Object
  • Object
show all
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:

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

  2. 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"

Examples:

dims = Item::ShippingBoxCalculator.call([
  [24, 18, 4],   # mat in its shipping box
  [6,  4,  3],   # thermostat box
])
# => [28, 22, 8]  (sorted descending, padded)

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

Instance Method Summary collapse

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