Class: Shipping::PackingCalculator

Inherits:
Object
  • Object
show all
Defined in:
app/services/shipping/packing_calculator.rb

Overview

Multi-box shipping calculator using First-Fit-Decreasing (FFD) 3D bin packing.

Used as a modern fallback in Shipping::DeterminePackaging when no historical
Packing record exists and items carry explicit shipping dimensions.

Unlike the legacy Packaging model (which looks up warehouse-specific box
assignments), this calculator works purely from item shipping dimensions and
weights, making it accurate for any item with complete shipping data.

Multi-box strategy

Items are expanded by quantity then sorted largest-volume-first (FFD).
Each unit is placed into the first existing bin that:

  1. Has sufficient remaining weight capacity.
  2. Can spatially fit all current bin contents + the new unit, verified via
    the same guillotine-cut packer used by Item::ShippingBoxCalculator.

If no bin accepts a unit, a new bin is opened. The algorithm therefore
produces the minimum number of boxes subject to both the weight limit and
true 3D spatial constraints.

Volumetric weight

Each returned Shipping::Container carries the tightest valid outer box
dimensions. Downstream code (Shipping::Container#dimensional_weight,
Shipping::PackageAuditor) derives volumetric / billable weight per carrier.

Examples:

Single order — two items that fit together

containers = Shipping::PackingCalculator.call(items: [
  { dimensions: [20, 15, 8], weight: 10.5, item_id: 1, quantity: 1 },
  { dimensions: [10,  8, 4], weight:  3.0, item_id: 2, quantity: 2 },
])
# => [Shipping::Container(length:…, width:…, height:…, weight: 16.5)]

Heavy order — split across two boxes by weight limit

containers = Shipping::PackingCalculator.call(items: [
  { dimensions: [30, 20, 10], weight: 40.0, item_id: 3, quantity: 1 },
  { dimensions: [30, 20, 10], weight: 40.0, item_id: 4, quantity: 1 },
])
# => [Shipping::Container(…, weight:40.0), Shipping::Container(…, weight:40.0)]

Defined Under Namespace

Classes: Bin, Unit

Constant Summary collapse

PER_BOX_WEIGHT_LIMIT =

70 lbs

Packaging::PER_PACKAGE_WEIGHT_LIMIT
DIM_WEIGHT_DIVISOR =

Default dimensional-weight divisor: FedEx, UPS, and Purolator Ground.
Purolator Express uses 115 (5 000 cm³/kg ≈ 115.3 in³/lb).
Canpar and most other carriers use 139.
Pass dim_weight_divisor: to .call when the carrier is known.

139
BOX_CONSOLIDATION_BENEFIT =

Per-box carrier overhead expressed in dim-weight-equivalent pounds.
Represents fixed per-package charges that don't scale with dim-weight:
residential delivery fee ($6.30), DAS surcharge ($4–6), and fuel
surcharge on those fixed fees. Combined: ~$13–16/box.

Empirically derived from 808 historical FedEx Ground shipments:

  • Median per-lb rate (recent residential): ~$2.72/lb
  • Per-box fixed overhead: ~$13–16
  • Break-even: $14 / $2.72 ≈ 5 lbs; being conservative → 8 lbs

The dim-weight escalation guard uses this credit so that consolidating two
items into one box is preferred when the combined dim-weight increase is
less than this threshold. Without it, accessories that naturally fit
alongside a mat roll get needlessly split into separate boxes even though
the extra per-box fees make that more expensive for the customer.

Validated:

  • mat roll (41×7×7, 15 lbs dw) + flex roll (19×4×4, 4 lbs dw)
    → 41×11×11 (36 lbs dw): escalation = 36 − 30 = 6 ≤ 8 → ALLOW ✓
  • two mat rolls → 44×16×16 (81 lbs dw): escalation = 81 − 48 = 33 > 8 → REJECT ✓
8

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(items, warehouse_country = :us, dim_weight_divisor = DIM_WEIGHT_DIVISOR) ⇒ PackingCalculator

Returns a new instance of PackingCalculator.



103
104
105
106
107
# File 'app/services/shipping/packing_calculator.rb', line 103

def initialize(items, warehouse_country = :us, dim_weight_divisor = DIM_WEIGHT_DIVISOR)
  @items              = items
  @warehouse_country  = warehouse_country
  @dim_weight_divisor = dim_weight_divisor
end

Class Method Details

.call(items:, warehouse_country: :us, dim_weight_divisor: DIM_WEIGHT_DIVISOR) ⇒ Object



99
100
101
# File 'app/services/shipping/packing_calculator.rb', line 99

def self.call(items:, warehouse_country: :us, dim_weight_divisor: DIM_WEIGHT_DIVISOR)
  new(items, warehouse_country, dim_weight_divisor).call
end

Instance Method Details

#callArray<Shipping::Container>

One container per required box,
sorted implicitly by the order bins were opened (largest items first).
Returns [] if input is blank or no item has valid dimensions.

Returns:

  • (Array<Shipping::Container>)

    one container per required box,
    sorted implicitly by the order bins were opened (largest items first).
    Returns [] if input is blank or no item has valid dimensions.



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'app/services/shipping/packing_calculator.rb', line 112

def call
  return [] if @items.blank?

  units = expand_items.sort_by { |u| -u.dimensions.reduce(:*) }
  return [] if units.empty?

  bins      = []
  @box_cache = {}
  # Load warehouse box catalog once per call.  build_container will snap each
  # bin to the smallest real box that fits, preferring non-oversize options.
  @catalog  = Shipping::BoxCatalog.parcel_boxes(country: @warehouse_country)

  units.each do |unit|
    bin = bins.find { |b| b.weight_allows?(unit) && fits_in_bin?(b, unit) }
    if bin
      bin.add(unit)
    else
      bins << Bin.new(units: [unit], total_weight: unit.weight)
    end
  end

  bins.filter_map { |bin| build_container(bin) }
end