Class: Shipping::PackingCalculator
- Inherits:
-
Object
- Object
- Shipping::PackingCalculator
- 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:
- Has sufficient remaining weight capacity.
- 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.
Defined Under Namespace
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
-
#call ⇒ Array<Shipping::Container>
One container per required box, sorted implicitly by the order bins were opened (largest items first).
-
#initialize(items, warehouse_country = :us, dim_weight_divisor = DIM_WEIGHT_DIVISOR) ⇒ PackingCalculator
constructor
A new instance of PackingCalculator.
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
#call ⇒ 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 = .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 |