Shipping Packaging Algorithm
Last updated: February 20, 2026
Scope: Shipping::DeterminePackaging, Shipping::PackingCalculator,
Item::ShippingBoxCalculator, Shipping::BoxCatalog, Packing model,
Shipping::Md5HashItem, Shipping::PackagingImporter
Overview
Section titled “Overview”The packaging system answers one question at shipment time:
Given these line items and this origin warehouse, what boxes do we ship them in?
It does so in two layers:
- History lookup — check the
packingstable for a known solution that exactly matches (or contains) the order’s item fingerprint. - 3D bin packing — if no history exists and every item has complete shipping dimensions, compute a fresh multi-box solution using a First-Fit-Decreasing algorithm constrained to real warehouse box stock.
The result is a Shipping::PackingSolution carrying one or more
Shipping::Container objects that downstream services (rate quoting, label
generation, invoice insurance) consume to obtain box dimensions, weight, and
billable weight.
Entry Point: Shipping::DeterminePackaging
Section titled “Entry Point: Shipping::DeterminePackaging”File: app/services/shipping/determine_packaging.rb
Called from:
Concerns::Models::ShipQuotable#retrieve_shipping_costs— at rate-quote timeShipment#packed_or_pre_packed?— to show the CRM pre-pack badge (withskip_calculator: trueto avoid heavy computation on page render)
Signature
Section titled “Signature”Shipping::DeterminePackaging.new.process( delivery:, md5_hash_override: nil, # for testing/manual override store_id: nil, # reserved is_freight: false, # routes to freight pallet logic instead skip_calculator: false # skip 3D packing and history fallback on UI paths)# => Shipping::PackingSolutionResolution Order
Section titled “Resolution Order”packings table └─ exact MD5 match ─→ return packing └─ exact relevant-MD5 match ─→ return packing └─ nested-MD5 match ─→ return subset of packing └─ no match ├─ multibox order (TBS-KIT, ships_in_single_box? == false) │ └─ individual per-item packing histories └─ single-origin order ├─ PackingCalculator (parcel, all dims present) ├─ individual per-item packing histories (fallback) └─ freight pallet calc (freight only)Multibox vs. single-box paths
Section titled “Multibox vs. single-box paths”If any goods item !ships_in_single_box? (e.g. multi-box kit products), the
order takes the multibox path:
item_hashis built frompackable_active_parent_lines_only- The MD5 fingerprint covers kit-parent line items only
- On no match: tries
get_individual_packings(one Packing per item)
Otherwise (most orders), the single-box path runs:
item_hashis built frompackable_active_lines(preloads:item,:catalog_item,parent: :catalog_itemto avoid N+1)- MD5 covers all packable goods line items after kit explosion
- On no match:
PackingCalculatorfirst, per-item histories second
MD5 Fingerprinting: Shipping::Md5HashItem
Section titled “MD5 Fingerprinting: Shipping::Md5HashItem”File: app/services/shipping/md5_hash_item.rb
Before querying the packings table, the order’s items are reduced to a
deterministic string, then MD5-hashed. Two hashes are produced:
| Hash | Contents |
|---|---|
md5 | All goods item IDs + quantities after kit explosion |
relevant_md5 | Same, but items contributing < 0.5% of total volume and < 0.2% of total weight are dropped |
This allows a packing record learned from a large order to still be reused for the same order with trivially small add-on items.
Kit explosion is handled by Shipping::ItemKitExploder: kit parent IDs
are replaced by their component item IDs and quantities so that two orders
with the same physical items — one ordered as a kit, one as loose components —
get the same fingerprint.
History Database: Packing Model
Section titled “History Database: Packing Model”File: app/models/packing.rb
Table: packings
Schema
Section titled “Schema”| Column | Type | Purpose |
|---|---|---|
md5 | string(32), unique+service_type | Primary lookup key |
relevant_md5 | string | Secondary lookup key (ignores tiny items) |
service_type | enum | parcel_service / freight_service |
origin | enum | from_delivery, from_shipment, from_item, from_manual_entry |
packdims | decimal[] | Array of [L, W, H, Weight, ContainerType] 5-tuples (one per box) |
contents | jsonb | { item_id => quantity } per box (single-box) or array of hashes (multi-box) |
packdim_contents | jsonb | Denormalized combination of packdims + contents + per-box md5; enables nested-md5 matching |
delivery_id | FK | Source delivery (if origin == from_delivery) |
shipment_id | FK | Source shipment (if origin == from_shipment) |
item_id | FK | Source item (if origin == from_item) |
Origin precedence
Section titled “Origin precedence”from_delivery > from_shipment > from_manual_entry > from_item
Records derived from a real completed delivery are the most authoritative.
The PackagingImporter and ItemMd5Extractor seed lower-priority records
that are overwritten if a real shipment record arrives.
Nested-MD5 matching
Section titled “Nested-MD5 matching”The packdim_contents JSONB column is GIN-indexed. Each element stores the
per-box item MD5, enabling a “does this exact item combination appear inside
a larger multi-box packing?” query:
SELECT * FROM packingsWHERE packdim_contents @> '[{"md5": "<target_md5>"}]'This allows a 5-box packing to satisfy a request for 1 box whose items are a strict subset of one of those boxes.
3D Bin Packing: Shipping::PackingCalculator
Section titled “3D Bin Packing: Shipping::PackingCalculator”File: app/services/shipping/packing_calculator.rb
Called when no packing history exists and all goods items have complete
shipping dimensions. Returns an array of Shipping::Container objects.
Algorithm: First-Fit-Decreasing
Section titled “Algorithm: First-Fit-Decreasing”- Expand each line item into individual
Unitstructs (one per unit of quantity), sorting by volume descending (largest first). - For each unit, find the first open bin that passes all guards (see below). If none fits, open a new bin.
- Convert each bin to a
Shipping::Containerby snapping to the smallest valid real warehouse box.
Bin acceptance guards (all must pass)
Section titled “Bin acceptance guards (all must pass)”fits_in_bin?(bin, unit)| Guard | Description |
|---|---|
| Bounding-box (O(n)) | Each axis of the candidate bin must stay within MAX_SIDE (106”). Cheap early reject. |
| 3D geometry | Item::ShippingBoxCalculator confirms all items actually fit in the snapped catalog box (guillotine-cut packer). |
| Surcharge escalation | Reject merge if the combined box incurs more carrier surcharges than either party would have separately. Score = count of dimension-based fees; see BoxCatalog.surcharge_score. |
| Dim-weight escalation | Reject merge if dim_billable_weight(combined_box) > dim_billable_weight(unit) + dim_billable_weight(bin) + BOX_CONSOLIDATION_BENEFIT. The credit (BOX_CONSOLIDATION_BENEFIT) represents the fixed per-box carrier overhead that is saved when two items share a box (residential surcharge, DAS, fuel on fixed fees — empirically ~$13–16/box at ~$2.72/lb → 8 lbs equivalent). Without this credit, the guard would incorrectly split items whose combined dim-weight increase is smaller than the per-box savings. |
BOX_CONSOLIDATION_BENEFIT
Section titled “BOX_CONSOLIDATION_BENEFIT”BOX_CONSOLIDATION_BENEFIT = 8 (lbs) — the dim-weight credit allowed per
consolidation decision. Derived empirically from 808 historical FedEx Ground
residential shipments:
- Median effective per-lb rate (recent): $2.72/lb
- Per-box fixed overhead (residential delivery + DAS + fuel): ~$13–16/box
- Break-even:
$14 / $2.72 ≈ 5 lbs; conservative value → 8 lbs
Zone sensitivity: At zone 1 (≤ 1-day FedEx Ground, e.g. local customers), the per-lb rate drops to ~$0.50–0.80/lb while per-box overhead stays ~$15–18. The break-even rises to ~20–30 lbs. The constant is calibrated for the typical zone 4–7 customer; zone 1 customers are better served by regional carriers (Speede Delivery, UPS Ground) where per-box fees are not additive in the same way.
Validated boundary conditions:
| Scenario | Escalation | Result |
|---|---|---|
| mat roll (41×7×7, 15 lbs dw) + cable (37×3×2, 2 lbs dw) → 41×9×9 (24 lbs dw) | 24 − 17 = 7 ≤ 8 | ALLOW ✓ |
| mat roll (41×7×7) + flex roll (19×4×4) → 41×11×11 (36 lbs dw) | 36 − 19 = 17 > 8 | REJECT ✓ |
| two mat rolls → 44×16×16 (81 lbs dw) | 81 − 30 = 51 > 8 | REJECT ✓ |
Weight limit
Section titled “Weight limit”PER_BOX_WEIGHT_LIMIT = 70 lbs (from Packaging::PER_PACKAGE_WEIGHT_LIMIT).
A unit is rejected from a bin when bin.total_weight + unit.weight > 70.
Dimensional-weight divisor
Section titled “Dimensional-weight divisor”DIM_WEIGHT_DIVISOR = 139 cu-in/lb. This is the FedEx/UPS/Purolator Ground
standard. Used to compute billable weight for the dim-weight escalation guard.
Box snapping: catalog_box_for
Section titled “Box snapping: catalog_box_for”def catalog_box_for(dims) Item::ShippingBoxCalculator.call(dims, candidate_boxes: @catalog, padding: 0) || Item::ShippingBoxCalculator.call(dims)endTries the warehouse catalog first (O(n)) with padding: 0 because the 2”
carrier padding is already baked into the catalog box outer dimensions —
adding it again would cause the calculator to search for a box 2” larger than
necessary, potentially skipping the correct (tight-fitting) box. Falls back to
the mathematical minimum-waste search (O(n³)) only when no catalog box fits.
The fallback is rare and limited to unusual item combinations outside warehouse
stock.
Box Geometry: Item::ShippingBoxCalculator
Section titled “Box Geometry: Item::ShippingBoxCalculator”File: app/services/item/shipping_box_calculator.rb
Answers: What is the smallest valid box that holds this set of item shipping dimensions?
Returns [length, width, height] in descending order, or nil if no valid
box exists.
Carrier constraints encoded
Section titled “Carrier constraints encoded”| Constant | Value | Description |
|---|---|---|
CARRIER_PADDING | 2” | Added to each dimension; standard carrier air-gap requirement |
MAX_SIDE | 106” (pre-padding) → 108” outer | Absolute FedEx/UPS maximum single dimension |
MAX_GIRTH | 165” | UPS/USPS: length + 2×(width + height) |
LONG_THRESHOLD | 46” | > 46” triggers length-based carrier surcharge |
WIDE_THRESHOLD | 34” | > 34” triggers width-based carrier surcharge |
GIRTH_THRESHOLD | 130” | > 130” combined girth triggers large-package surcharge |
PENALTY | 1.5× | Score multiplier applied to boxes exceeding a surcharge threshold |
Catalog mode (candidate_boxes:)
Section titled “Catalog mode (candidate_boxes:)”When PackingCalculator passes the warehouse catalog, ShippingBoxCalculator
iterates boxes smallest-surcharge-first and returns the first one whose inner
space (outer dims − 2” carrier padding) passes the 3D feasibility check.
Mathematical mode (no candidate_boxes:)
Section titled “Mathematical mode (no candidate_boxes:)”Full O(n³) search over (min_l..106) × (min_w..106) × (h_floor..106) space.
A min_score = (box_volume − item_volume) × surcharge_penalty_factors ensures
the result has the least waste while avoiding surcharge-triggering sizes. Used
as a fallback only when the catalog search returns nil.
3D feasibility: guillotine-cut bin packer (can_pack?)
Section titled “3D feasibility: guillotine-cut bin packer (can_pack?)”- Start with one free space = the full box interior.
- Sort items largest-volume-first (FFD).
- For each item, find the smallest free space it fits in (any rotation).
- Delete that space; split remainder into up to three non-overlapping guillotine sub-spaces (right, front, above).
- Return
falseif any item cannot be placed.
The space list is mutated by value (spaces.delete(space)) — not by index —
to avoid off-by-one errors when iterating a sorted view of the array.
Warehouse Box Catalog: Shipping::BoxCatalog
Section titled “Warehouse Box Catalog: Shipping::BoxCatalog”File: app/services/shipping/box_catalog.rb
Provides an ordered list of real carton sizes stocked in each warehouse, sorted to minimise carrier surcharges and dimensional weight.
Sources
Section titled “Sources”-
US_STANDARD_BOXES/CA_STANDARD_BOXES— empirically derived from ~58,000 authoritative packing records (from_delivery/from_shipment) in production, split by origin warehouse address. Only sizes used ≥ 50 times are included. Refreshed from production data in February 2026. -
WarehousePackagetable — supplements the standard list with any carton sizes added to the DB but not yet in the hard-coded arrays.
Sort order
Section titled “Sort order”1. surcharge_score (ascending) → fewest carrier fees first2. volume (ascending) → smallest dim-weight within each tier3. longest dimension (ascending) → avoid length-based fees as tiebreakerSurcharge scoring: BoxCatalog.surcharge_score(dims, country:)
Section titled “Surcharge scoring: BoxCatalog.surcharge_score(dims, country:)”Returns an integer 0–4 counting avoidable dimension-based carrier fees. Carrier-mix-aware:
| Condition | US (FedEx 73% + UPS 15%) | CA (UPS 46% + Canpar 35% + Purolator 9% + CP 10%) |
|---|---|---|
| length > 48” | +1 | +1 |
| width > 30” | +1 | +1 |
| L+G > 130” | +1 | +1 (Purolator large-pkg fee = $65, most expensive) |
| cubic > 17,280 cu-in | +1 (FedEx cubic) | (skipped — FedEx only 5% of CA) |
Catalog boxes are annotated in US_STANDARD_BOXES / CA_STANDARD_BOXES
when they exceed the width or L+G thresholds (e.g. [37, 31, 6] — width=31).
Caching
Section titled “Caching”parcel_boxes is memoized per process in @us_parcel_boxes /
@ca_parcel_boxes. Call Shipping::BoxCatalog.reset! in tests or after
refreshing warehouse inventory.
Legacy Data Migration: Shipping::PackagingImporter
Section titled “Legacy Data Migration: Shipping::PackagingImporter”File: app/services/shipping/packaging_importer.rb
The legacy Packaging model stored warehouse-specific stacking rules:
“N units of StoreItem X fit in WarehousePackage Y”. These records encode
knowledge that cannot be derived purely from per-item shipping dimensions
(e.g. 25 thermal sheets stacked flat in a 36×24×3 box).
PackagingImporter.process converts each multi-item Packaging record into
one or more Packing records with origin: :from_item:
- Single-box tier: qty = 1…N (representative subset when N > 50)
- Multi-box tier: qty = 2N, 3N, 4N, 5N
Delivery-derived records (from_delivery, from_shipment) are never
overwritten — they are authoritative. Item-derived records (from_item) are
re-seeded on each importer run (idempotent via upsert).
UI Integration: Shipment#packed_or_pre_packed?
Section titled “UI Integration: Shipment#packed_or_pre_packed?”File: app/models/shipment.rb
Controls whether the CRM order page shows the pre-pack badge.
def packed_or_pre_packed? return true if packed?
if suggested? packing_solution = Shipping::DeterminePackaging.new.process( delivery:, is_freight: delivery.ships_ltl_freight?, skip_calculator: true # ← avoids running PackingCalculator on render ) # Only history-backed solutions qualify for the badge %w[from_delivery from_item from_manual_entry].include?(packing_solution.source_type.to_s) endendskip_calculator: true is critical: without it, every order page render that
lacks a packing history record would run the full 3D bin packing algorithm
(~10ms of CPU per call, ×2 per page load = 20ms extra, and previously up to
7.5s when the mathematical fallback ran unconditionally).
Performance Characteristics
Section titled “Performance Characteristics”| Code path | Typical query count | CPU |
|---|---|---|
| History hit (MD5 exact match) | 4–6 SELECT | < 1 ms |
| History miss → PackingCalculator (catalog only) | 6–8 SELECT + 1 WarehousePackage load | ~10 ms |
| History miss → PackingCalculator (math fallback triggered) | same + O(n³) geometry | ~50–200 ms |
packed_or_pre_packed? with skip_calculator: true | 4–6 SELECT | < 1 ms |
N+1 hotspots addressed
Section titled “N+1 hotspots addressed”The packaging flow touches several association chains that are N+1-prone. Key preloads in place:
| Location | Associations preloaded | Reason |
|---|---|---|
DeterminePackaging#process line 7 | [:item, :direct_store_item, { catalog_item: :store_item }] on parent_lines | pack_at_kit_level, ships_in_single_box? checks |
Delivery#packable_active_lines | :item, :catalog_item, parent: :catalog_item | active_lines_for_packaging checks catalog_item.pack_at_kit_level and parent.catalog_item.pack_at_kit_level |
Delivery#calculate_declared_value | item: { supplier_items: :supplier_item_prices } | unit_value_for_commercial_invoice |
Data Flow Diagram
Section titled “Data Flow Diagram”Order / Delivery │ ▼Shipping::DeterminePackaging#process │ ├─ 1. Build item_hash from packable line items │ │ │ ▼ │ Shipping::Md5HashItem ──→ md5, relevant_md5 │ ├─ 2. Query packings table (4 SELECT attempts) │ │ │ ├─ exact md5 ─→ Packing#to_packages ─→ PackingSolution │ ├─ relevant_md5 ─→ Packing#to_packages ─→ PackingSolution │ ├─ nested md5 ─→ Packing#to_packages(md5) ─→ PackingSolution │ └─ nested relevant_md5 ─→ same │ └─ 3. No history → PackingCalculator (parcel, skip_calculator=false) │ ├─ BoxCatalog.parcel_boxes(country: :us/:ca) │ ├─ US_STANDARD_BOXES / CA_STANDARD_BOXES (hard-coded, ~58k packing history) │ └─ WarehousePackage.active (DB supplement) │ ├─ Expand items × quantity → Units (sorted vol desc) │ └─ FFD bin packing loop │ ├─ fits_in_bin?(bin, unit) │ ├─ Bounding-box guard (O(n)) │ ├─ Item::ShippingBoxCalculator ← catalog first, math fallback │ │ └─ can_pack?(l,w,h) ← guillotine-cut 3D packer │ ├─ Surcharge-escalation guard │ └─ Dim-weight escalation guard │ └─ build_container(bin) → Shipping::ContainerKey Constants Quick Reference
Section titled “Key Constants Quick Reference”| Constant | Value | File | Meaning |
|---|---|---|---|
PER_BOX_WEIGHT_LIMIT | 70 lbs | packing_calculator.rb | Max weight per parcel box |
DIM_WEIGHT_DIVISOR | 139 cu-in/lb | packing_calculator.rb | FedEx/UPS/Purolator Ground dim-weight standard |
BOX_CONSOLIDATION_BENEFIT | 8 lbs | packing_calculator.rb | Per-box fixed overhead credit used in the dim-weight escalation guard. Empirically derived from 808 FedEx Ground shipments (~$14 fixed overhead / $2.72 per-lb rate ≈ 5 lbs break-even; conservative → 8 lbs). |
CARRIER_PADDING | 2” | shipping_box_calculator.rb | Minimum air gap on each side |
MAX_SIDE | 108” (106 + 2) | shipping_box_calculator.rb | Absolute carrier max single dimension |
MAX_GIRTH | 165” | shipping_box_calculator.rb | UPS/USPS L + 2(W+H) limit |
HANDLING_LENGTH | 48” | box_catalog.rb | FedEx/UPS additional-handling length threshold |
HANDLING_WIDTH | 30” | box_catalog.rb | FedEx/UPS additional-handling width threshold |
LARGE_PKG_LG | 130” | box_catalog.rb | Large-package surcharge L+G threshold |
FEDEX_CUBIC_OS | 17,280 cu-in | box_catalog.rb | FedEx cubic-oversize threshold (US only) |