@spree/docs 0.1.46 → 0.1.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-reference/webhooks-events.md +20 -18
- package/dist/developer/core-concepts/adjustments.md +2 -2
- package/dist/developer/core-concepts/events.md +2 -2
- package/dist/developer/core-concepts/orders.md +22 -19
- package/dist/developer/core-concepts/payments.md +1 -1
- package/dist/developer/core-concepts/shipments.md +124 -32
- package/dist/developer/core-concepts/webhooks.md +1 -1
- package/dist/developer/how-to/custom-order-routing.md +308 -0
- package/dist/developer/how-to/custom-stock-splitter.md +213 -0
- package/dist/developer/upgrades/5.4-to-5.5.md +47 -0
- package/package.json +1 -1
|
@@ -37,7 +37,7 @@ For details on creating webhook endpoints and verifying signatures, see [Webhook
|
|
|
37
37
|
|
|
38
38
|
Events: `order.created`, `order.updated`, `order.completed`, `order.canceled`, `order.resumed`, `order.paid`, `order.shipped`
|
|
39
39
|
|
|
40
|
-
Order payloads include nested `items`, `shipments
|
|
40
|
+
Order payloads include nested `items`, `fulfillments` (the Store API name for shipments), `payments`, `billing_address`, `shipping_address`, `payment_methods`, and `discounts`.
|
|
41
41
|
|
|
42
42
|
```json
|
|
43
43
|
{
|
|
@@ -49,12 +49,12 @@ Order payloads include nested `items`, `shipments`, `payments`, `bill_address`,
|
|
|
49
49
|
"special_instructions": null,
|
|
50
50
|
"currency": "USD",
|
|
51
51
|
"item_count": 3,
|
|
52
|
-
"
|
|
52
|
+
"fulfillment_status": "shipped",
|
|
53
53
|
"payment_state": "paid",
|
|
54
54
|
"item_total": "89.99",
|
|
55
55
|
"display_item_total": "$89.99",
|
|
56
|
-
"
|
|
57
|
-
"
|
|
56
|
+
"delivery_total": "10.00",
|
|
57
|
+
"display_delivery_total": "$10.00",
|
|
58
58
|
"adjustment_total": "0.00",
|
|
59
59
|
"display_adjustment_total": "$0.00",
|
|
60
60
|
"promo_total": "0.00",
|
|
@@ -90,19 +90,20 @@ Order payloads include nested `items`, `shipments`, `payments`, `bill_address`,
|
|
|
90
90
|
"..."
|
|
91
91
|
}
|
|
92
92
|
],
|
|
93
|
-
"
|
|
93
|
+
"fulfillments": [
|
|
94
94
|
{
|
|
95
|
-
"id": "
|
|
95
|
+
"id": "ful_9xPq4wMn",
|
|
96
96
|
"number": "H123456789",
|
|
97
|
-
"
|
|
97
|
+
"status": "shipped",
|
|
98
|
+
"fulfillment_type": "shipping",
|
|
98
99
|
"tracking": "1Z999AA10123456784",
|
|
99
100
|
"tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=1Z999AA10123456784",
|
|
100
101
|
"cost": "10.00",
|
|
101
102
|
"display_cost": "$10.00",
|
|
102
|
-
"
|
|
103
|
-
"
|
|
103
|
+
"fulfilled_at": "2025-01-16T14:00:00Z",
|
|
104
|
+
"delivery_method": { "id": "dm_2wMn7xRt", "..." },
|
|
104
105
|
"stock_location": { "id": "sl_2wMn7xRt", "..." },
|
|
105
|
-
"
|
|
106
|
+
"delivery_rates": [],
|
|
106
107
|
"..."
|
|
107
108
|
}
|
|
108
109
|
],
|
|
@@ -268,29 +269,30 @@ Payment setup session payloads include a nested `payment_method`.
|
|
|
268
269
|
|
|
269
270
|
Events: `shipment.created`, `shipment.updated`, `shipment.shipped`, `shipment.canceled`, `shipment.resumed`
|
|
270
271
|
|
|
271
|
-
|
|
272
|
+
The Store API/SDK exposes shipments as `fulfillments`, and webhook payloads use the same V3 serializer. Payloads include nested `delivery_method`, `stock_location`, and `delivery_rates`.
|
|
272
273
|
|
|
273
274
|
```json
|
|
274
275
|
{
|
|
275
|
-
"id": "
|
|
276
|
+
"id": "ful_9xPq4wMn",
|
|
276
277
|
"number": "H123456789",
|
|
277
|
-
"
|
|
278
|
+
"status": "shipped",
|
|
279
|
+
"fulfillment_type": "shipping",
|
|
278
280
|
"tracking": "1Z999AA10123456784",
|
|
279
281
|
"tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=1Z999AA10123456784",
|
|
280
282
|
"cost": "10.00",
|
|
281
283
|
"display_cost": "$10.00",
|
|
282
|
-
"
|
|
284
|
+
"fulfilled_at": "2025-01-16T14:00:00Z",
|
|
283
285
|
"created_at": "2025-01-15T10:30:00Z",
|
|
284
286
|
"updated_at": "2025-01-16T14:00:00Z",
|
|
285
|
-
"
|
|
286
|
-
"id": "
|
|
287
|
+
"delivery_method": {
|
|
288
|
+
"id": "dm_2wMn7xRt",
|
|
287
289
|
"..."
|
|
288
290
|
},
|
|
289
291
|
"stock_location": {
|
|
290
292
|
"id": "sl_2wMn7xRt",
|
|
291
293
|
"..."
|
|
292
294
|
},
|
|
293
|
-
"
|
|
295
|
+
"delivery_rates": []
|
|
294
296
|
}
|
|
295
297
|
```
|
|
296
298
|
|
|
@@ -448,7 +450,7 @@ Events: `stock_movement.created`, `stock_movement.updated`, `stock_movement.dele
|
|
|
448
450
|
"quantity": -1,
|
|
449
451
|
"action": "sold",
|
|
450
452
|
"originator_type": "Spree::Shipment",
|
|
451
|
-
"originator_id": "
|
|
453
|
+
"originator_id": "ful_9xPq4wMn",
|
|
452
454
|
"stock_item_id": "si_6nRt2xLq",
|
|
453
455
|
"created_at": "2025-01-15T10:30:00Z",
|
|
454
456
|
"updated_at": "2025-01-15T10:30:00Z"
|
|
@@ -70,7 +70,7 @@ Adjustments are created by two sources:
|
|
|
70
70
|
|
|
71
71
|
## Store API
|
|
72
72
|
|
|
73
|
-
Adjustments appear in order responses when you expand line items or
|
|
73
|
+
Adjustments appear in order responses when you expand line items or fulfillments:
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
```typescript SDK
|
|
@@ -93,7 +93,7 @@ order.included_tax_total
|
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
```bash cURL
|
|
96
|
-
curl 'https://api.mystore.com/api/v3/store/orders/or_abc123?expand=items,
|
|
96
|
+
curl 'https://api.mystore.com/api/v3/store/orders/or_abc123?expand=items,fulfillments' \
|
|
97
97
|
-H 'Authorization: Bearer pk_xxx' \
|
|
98
98
|
-H 'X-Spree-Token: <token>'
|
|
99
99
|
```
|
|
@@ -306,10 +306,10 @@ Spree includes V3 serializers for all core models in [`api/app/serializers/spree
|
|
|
306
306
|
|
|
307
307
|
| Serializer | Model |
|
|
308
308
|
|------------|-------|
|
|
309
|
-
| `OrderSerializer` | Orders with totals,
|
|
309
|
+
| `OrderSerializer` | Orders with totals, statuses, nested line items, fulfillments, payments, addresses |
|
|
310
310
|
| `ProductSerializer` | Products with pricing, stock status, availability |
|
|
311
311
|
| `PaymentSerializer` | Payments with amounts, states, nested payment method and source |
|
|
312
|
-
| `
|
|
312
|
+
| `FulfillmentSerializer` | Fulfillments (shipments) with tracking, nested delivery method and delivery rates |
|
|
313
313
|
| `LineItemSerializer` | Line items with quantity, pricing, nested option values |
|
|
314
314
|
| `VariantSerializer` | Variants with SKU, pricing, nested option values |
|
|
315
315
|
| `PriceSerializer` | Prices with amounts, currency, price list |
|
|
@@ -67,13 +67,13 @@ The API returns these key fields on every order:
|
|
|
67
67
|
| `currency` | Order currency (e.g., `USD`) |
|
|
68
68
|
| `item_count` | Total number of items |
|
|
69
69
|
| `item_total` / `display_item_total` | Sum of line item prices |
|
|
70
|
-
| `
|
|
70
|
+
| `delivery_total` / `display_delivery_total` | Delivery cost |
|
|
71
71
|
| `tax_total` / `display_tax_total` | Total tax |
|
|
72
72
|
| `promo_total` / `display_promo_total` | Total discount from promotions |
|
|
73
|
-
| `adjustment_total` / `display_adjustment_total` | Sum of all adjustments (tax +
|
|
73
|
+
| `adjustment_total` / `display_adjustment_total` | Sum of all adjustments (tax + delivery + promos) |
|
|
74
74
|
| `total` / `display_total` | Final order total |
|
|
75
75
|
| `payment_state` | Payment status (`balance_due`, `paid`, `credit_owed`, `failed`, `void`) |
|
|
76
|
-
| `
|
|
76
|
+
| `fulfillment_status` | Fulfillment status (`pending`, `ready`, `partial`, `shipped`, `backorder`) |
|
|
77
77
|
| `completed_at` | Timestamp when the order was placed |
|
|
78
78
|
|
|
79
79
|
The `display_*` fields return formatted strings with currency symbols (e.g., `"$15.99"`).
|
|
@@ -183,10 +183,11 @@ await client.carts.update(cartId, {
|
|
|
183
183
|
},
|
|
184
184
|
})
|
|
185
185
|
|
|
186
|
-
// Get
|
|
187
|
-
|
|
188
|
-
await client.carts.
|
|
189
|
-
|
|
186
|
+
// Get fulfillments and select a delivery rate
|
|
187
|
+
// (the Store API/SDK exposes shipments as `fulfillments`)
|
|
188
|
+
const cart = await client.carts.get(cartId, { expand: ['fulfillments'] })
|
|
189
|
+
await client.carts.fulfillments.update(cartId, cart.fulfillments[0].id, {
|
|
190
|
+
selected_delivery_rate_id: 'rate_xxx',
|
|
190
191
|
})
|
|
191
192
|
|
|
192
193
|
// Create a payment session (e.g., Stripe)
|
|
@@ -218,12 +219,12 @@ curl -X PATCH 'https://api.mystore.com/api/v3/store/carts/cart_xxx' \
|
|
|
218
219
|
}
|
|
219
220
|
}'
|
|
220
221
|
|
|
221
|
-
# Select a
|
|
222
|
-
curl -X PATCH 'https://api.mystore.com/api/v3/store/carts/cart_xxx/
|
|
222
|
+
# Select a delivery rate
|
|
223
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/store/carts/cart_xxx/fulfillments/ful_xxx' \
|
|
223
224
|
-H 'Authorization: Bearer pk_xxx' \
|
|
224
225
|
-H 'X-Spree-Token: abc123' \
|
|
225
226
|
-H 'Content-Type: application/json' \
|
|
226
|
-
-d '{ "
|
|
227
|
+
-d '{ "selected_delivery_rate_id": "rate_xxx" }'
|
|
227
228
|
|
|
228
229
|
# Complete the order
|
|
229
230
|
curl -X POST 'https://api.mystore.com/api/v3/store/carts/cart_xxx/complete' \
|
|
@@ -271,7 +272,7 @@ const { data: orders } = await client.customer.orders.list()
|
|
|
271
272
|
|
|
272
273
|
// Get a specific order with details
|
|
273
274
|
const order = await client.orders.get('or_xxx', {
|
|
274
|
-
expand: ['items', '
|
|
275
|
+
expand: ['items', 'fulfillments', 'payments'],
|
|
275
276
|
})
|
|
276
277
|
```
|
|
277
278
|
|
|
@@ -281,7 +282,7 @@ curl 'https://api.mystore.com/api/v3/store/customer/orders' \
|
|
|
281
282
|
-H 'Authorization: Bearer <jwt_token>'
|
|
282
283
|
|
|
283
284
|
# Get a specific order
|
|
284
|
-
curl 'https://api.mystore.com/api/v3/store/orders/or_xxx?expand=items,
|
|
285
|
+
curl 'https://api.mystore.com/api/v3/store/orders/or_xxx?expand=items,fulfillments,payments' \
|
|
285
286
|
-H 'Authorization: Bearer <jwt_token>'
|
|
286
287
|
```
|
|
287
288
|
|
|
@@ -306,14 +307,16 @@ When a variant is added to an order, the price is locked on the line item. If th
|
|
|
306
307
|
| `failed` | Most recent payment attempt failed |
|
|
307
308
|
| `void` | Order was canceled and payments voided |
|
|
308
309
|
|
|
309
|
-
##
|
|
310
|
+
## Fulfillment Statuses
|
|
310
311
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
|
314
|
-
|
|
315
|
-
| `
|
|
316
|
-
| `
|
|
312
|
+
The order's `fulfillment_status` field summarizes the state of all fulfillments (the Store API/SDK exposes shipments as `fulfillments`).
|
|
313
|
+
|
|
314
|
+
| Status | Description |
|
|
315
|
+
|--------|-------------|
|
|
316
|
+
| `pending` | All fulfillments are pending |
|
|
317
|
+
| `ready` | All fulfillments are ready to ship |
|
|
318
|
+
| `partial` | At least one fulfillment is shipped, others are not |
|
|
319
|
+
| `shipped` | All fulfillments have been shipped |
|
|
317
320
|
| `backorder` | Some inventory is on backorder |
|
|
318
321
|
|
|
319
322
|
For more details, see [Shipments](shipments.md) and [Payments](payments.md).
|
|
@@ -237,7 +237,7 @@ After the customer completes payment, the frontend calls the Complete Payment Se
|
|
|
237
237
|
|
|
238
238
|
**Step 4: Complete Order**
|
|
239
239
|
|
|
240
|
-
The frontend calls `POST /carts/:id/complete` to finalize the order. Spree validates the order is ready (addresses,
|
|
240
|
+
The frontend calls `POST /carts/:id/complete` to finalize the order. Spree validates the order is ready (addresses, fulfillments, payment), advances through any remaining checkout states, and marks the order as complete.
|
|
241
241
|
|
|
242
242
|
This separation ensures the same flow works for all payment types — inline cards, offsite redirects, and wallet payments.
|
|
243
243
|
|
|
@@ -3,6 +3,8 @@ title: Shipments
|
|
|
3
3
|
description: Shipping methods, rates, split shipments, and fulfillment
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
+
import { Since } from '/snippets/since.mdx';
|
|
7
|
+
|
|
6
8
|
## Overview
|
|
7
9
|
|
|
8
10
|
A shipment represents a package being sent to a customer from a [Stock Location](inventory.md#stock-locations). Each order can have one or more shipments — Spree automatically splits orders into multiple shipments when items need to ship from different locations or require different shipping methods.
|
|
@@ -49,15 +51,19 @@ erDiagram
|
|
|
49
51
|
|
|
50
52
|
## Shipment Attributes
|
|
51
53
|
|
|
54
|
+
The Store API/SDK exposes shipments as `fulfillments` with these attributes:
|
|
55
|
+
|
|
52
56
|
| Attribute | Description | Example |
|
|
53
57
|
|-----------|-------------|---------|
|
|
54
|
-
| `number` | Unique
|
|
58
|
+
| `number` | Unique fulfillment identifier | `H12345678901` |
|
|
55
59
|
| `tracking` | Carrier tracking number | `1Z999AA10123456784` |
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
60
|
+
| `status` | Current fulfillment status | `shipped` |
|
|
61
|
+
| `fulfillment_type` | `shipping` or `digital` | `shipping` |
|
|
62
|
+
| `cost` | Delivery cost | `9.99` |
|
|
63
|
+
| `fulfilled_at` | When the fulfillment was shipped | `2025-07-21T14:36:00Z` |
|
|
59
64
|
| `stock_location` | Where items ship from | `Warehouse NYC` |
|
|
60
|
-
| `
|
|
65
|
+
| `delivery_method` | The selected delivery method | `{ id: "dm_xxx", name: "UPS Ground" }` |
|
|
66
|
+
| `delivery_rates` | Available rates for the customer to pick from | `[{ id: "rate_xxx", cost: "9.99", selected: true, ... }]` |
|
|
61
67
|
|
|
62
68
|
## Shipment States
|
|
63
69
|
|
|
@@ -84,35 +90,36 @@ During checkout, after the customer provides a shipping address, Spree calculate
|
|
|
84
90
|
|
|
85
91
|
|
|
86
92
|
```typescript SDK
|
|
87
|
-
// Get
|
|
93
|
+
// Get fulfillments with available delivery rates
|
|
94
|
+
// (the Store API/SDK exposes shipments as `fulfillments`)
|
|
88
95
|
const order = await client.orders.get(orderId, {
|
|
89
|
-
expand: ['
|
|
96
|
+
expand: ['fulfillments'],
|
|
90
97
|
})
|
|
91
98
|
|
|
92
|
-
// Each
|
|
93
|
-
order.
|
|
94
|
-
console.log(
|
|
95
|
-
console.log(
|
|
99
|
+
// Each fulfillment has available delivery rates
|
|
100
|
+
order.fulfillments?.forEach(fulfillment => {
|
|
101
|
+
console.log(fulfillment.number) // "H12345678901"
|
|
102
|
+
console.log(fulfillment.delivery_rates) // [{ id: "rate_xxx", name: "UPS Ground", cost: "9.99", selected: true }, ...]
|
|
96
103
|
})
|
|
97
104
|
|
|
98
|
-
// Select a
|
|
99
|
-
await client.carts.
|
|
100
|
-
|
|
105
|
+
// Select a delivery rate
|
|
106
|
+
await client.carts.fulfillments.update(cartId, fulfillment.id, {
|
|
107
|
+
selected_delivery_rate_id: 'rate_xxx',
|
|
101
108
|
})
|
|
102
109
|
```
|
|
103
110
|
|
|
104
111
|
```bash cURL
|
|
105
|
-
# Get
|
|
106
|
-
curl 'https://api.mystore.com/api/v3/store/carts/cart_xxx?expand=
|
|
112
|
+
# Get fulfillments
|
|
113
|
+
curl 'https://api.mystore.com/api/v3/store/carts/cart_xxx?expand=fulfillments' \
|
|
107
114
|
-H 'Authorization: Bearer pk_xxx' \
|
|
108
115
|
-H 'X-Spree-Token: abc123'
|
|
109
116
|
|
|
110
|
-
# Select a
|
|
111
|
-
curl -X PATCH 'https://api.mystore.com/api/v3/store/carts/cart_xxx/
|
|
117
|
+
# Select a delivery rate
|
|
118
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/store/carts/cart_xxx/fulfillments/ful_xxx' \
|
|
112
119
|
-H 'Authorization: Bearer pk_xxx' \
|
|
113
120
|
-H 'X-Spree-Token: abc123' \
|
|
114
121
|
-H 'Content-Type: application/json' \
|
|
115
|
-
-d '{ "
|
|
122
|
+
-d '{ "selected_delivery_rate_id": "rate_xxx" }'
|
|
116
123
|
```
|
|
117
124
|
|
|
118
125
|
|
|
@@ -151,31 +158,115 @@ Each shipping method uses a [Calculator](calculators.md) to determine the cost.
|
|
|
151
158
|
|
|
152
159
|
You can create custom calculators for more complex pricing. See the [Calculators guide](calculators.md).
|
|
153
160
|
|
|
161
|
+
## Order Routing
|
|
162
|
+
|
|
163
|
+
When an order moves from cart to checkout, Spree decides which [Stock Location](inventory.md#stock-locations) fulfills it. **Order Routing** is the system that makes that decision — driven by configurable rules so merchants can express preferences like "fulfill from the customer's preferred warehouse first," "minimize the number of split shipments," or "always pick the closest location."
|
|
164
|
+
|
|
165
|
+
```mermaid
|
|
166
|
+
flowchart LR
|
|
167
|
+
Order[Order entering checkout] --> Strategy
|
|
168
|
+
Strategy[Routing strategy] --> Rules[(Rules,<br/>per channel)]
|
|
169
|
+
Strategy --> Locations[(Eligible<br/>stock locations)]
|
|
170
|
+
Rules --> Reducer
|
|
171
|
+
Locations --> Reducer
|
|
172
|
+
Reducer[Reducer:<br/>full ranking,<br/>best-first] --> Ordered[Locations<br/>in rank order]
|
|
173
|
+
Ordered --> Shipments[Shipments created<br/>top location first]
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### How the decision is made
|
|
177
|
+
|
|
178
|
+
Each Channel (the distribution surface — online storefront, POS, wholesale portal) has an ordered list of **routing rules**. When the customer enters checkout, Spree walks the rules from highest priority to lowest and asks each rule to rank the candidate locations. The result is a full best-to-worst ordering of every eligible location. The top-ranked location packs as much of the cart as it can; anything it can't cover spills over to the next-ranked location, and so on.
|
|
179
|
+
|
|
180
|
+
The default rules every channel ships with:
|
|
181
|
+
|
|
182
|
+
| Order | Rule | What it does |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| 1 | **Preferred Location** | If the order has a preferred location set (e.g. by an admin staff member), that location ranks first. Otherwise abstains. |
|
|
185
|
+
| 2 | **Minimize Splits** | Prefers locations that can fulfill the most line items single-handedly. The location that covers the most cart on its own ranks higher. |
|
|
186
|
+
| 3 | **Default Location** | Tie-breaker: ranks the store default first, then other active locations. Always ranks every candidate so there's always a complete order. |
|
|
187
|
+
|
|
188
|
+
Reorder them, deactivate them, or add new rules without touching code — they're just rows in `spree_order_routing_rules`.
|
|
189
|
+
|
|
190
|
+
### Channels
|
|
191
|
+
|
|
192
|
+
Every order belongs to a Channel. Routing rules are scoped to the channel, so the wholesale channel can have completely different fulfillment logic from the online storefront. New channels seed their own three default rules automatically.
|
|
193
|
+
|
|
194
|
+
A channel can also override the routing **strategy** entirely — useful when one channel needs an algorithmic shape that's different from rules-walking, e.g. a POS channel that always picks the brick-and-mortar location, or a wholesale channel that delegates routing to an external warehouse management system.
|
|
195
|
+
|
|
196
|
+
### When it fires
|
|
197
|
+
|
|
198
|
+
Routing fires once, when the order transitions from `cart` to `address` (the start of checkout). It produces one or more `Shipment`s, each tied to a chosen stock location. The decision is sticky — once the shipments are created, their locations stay fixed unless the merchant edits them in the admin or the cart is cleared and re-routed.
|
|
199
|
+
|
|
200
|
+
Routing happens **after** stock reservations: by the time routing runs, the cart has already reserved the units it needs. Reservations and routing make their decisions at different layers — reservations protect the variant's total inventory across all locations, routing picks which location ships. They coexist correctly today, with a small inefficiency around location-pinning that's planned to be tightened in 6.0.
|
|
201
|
+
|
|
202
|
+
### Extending routing
|
|
203
|
+
|
|
204
|
+
For business-specific logic — proximity to the shipping address, customer-tier-aware fulfillment, refrigerated SKUs, day-of-week dispatch — you write a custom **rule** that plugs into the existing pipeline. For replacing the algorithm entirely (OMS delegation, ML-based routing, multi-order optimization solvers), you write a custom **strategy**.
|
|
205
|
+
|
|
206
|
+
See the [Build Custom Order Routing](../how-to/custom-order-routing.md) guide for both.
|
|
207
|
+
|
|
154
208
|
## Split Shipments
|
|
155
209
|
|
|
156
|
-
|
|
210
|
+
An order's allocation can split along two independent axes:
|
|
211
|
+
|
|
212
|
+
| Axis | Decided by | Question it answers |
|
|
213
|
+
|---|---|---|
|
|
214
|
+
| **Across stock locations** | [Order Routing](#order-routing) | _Which_ locations fulfill this order? |
|
|
215
|
+
| **Within a stock location** | [Stock Splitters](#stock-splitters) | _How_ do we break each location's allocation into separate packages? |
|
|
216
|
+
|
|
217
|
+
The two layers compose cleanly — routing picks and ranks the locations, then each chosen location is independently broken down by the splitter chain. The Prioritizer then walks all the resulting packages in rank order and decides which package fulfills each inventory unit.
|
|
157
218
|
|
|
158
219
|
```mermaid
|
|
159
220
|
flowchart TB
|
|
160
|
-
A[Order with 3 items] -->
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
221
|
+
A[Order with 3 items] --> R[Routing strategy]
|
|
222
|
+
R --> NYC[NYC Warehouse<br/>covers 2 items]
|
|
223
|
+
R --> LA[LA Warehouse<br/>covers 1 item]
|
|
224
|
+
NYC --> P1[Packer + splitters]
|
|
225
|
+
LA --> P2[Packer + splitters]
|
|
226
|
+
P1 --> S1["Shipment 1: Light items<br/>UPS Ground from NYC"]
|
|
227
|
+
P1 --> S2["Shipment 2: Heavy items<br/>FedEx Freight from NYC"]
|
|
228
|
+
P2 --> S3["Shipment 3: Backup item<br/>UPS Ground from LA"]
|
|
165
229
|
```
|
|
166
230
|
|
|
167
231
|
### How Splitting Works
|
|
168
232
|
|
|
169
|
-
1.
|
|
170
|
-
2.
|
|
171
|
-
3.
|
|
172
|
-
4. The customer selects a shipping rate for each shipment independently
|
|
233
|
+
1. **Order Routing** produces a ranked list of stock locations (best first).
|
|
234
|
+
2. **Per-location packing**: each location's units pass through `Spree::Stock::Packer` plus the configured splitter chain (`Spree.stock_splitters`), producing one or more packages per location — broken out by shipping category, on-hand vs backorder, digital vs physical, and so on.
|
|
235
|
+
3. **Prioritizer**: walks all resulting packages in rank order and assigns each inventory unit to the first package that has it on hand. Units the top-ranked location can't cover spill into lower-ranked location packages; unfilled units are flagged backordered.
|
|
236
|
+
4. **Shipment creation**: each remaining package becomes a `Shipment`. The customer selects a shipping rate for each shipment independently.
|
|
237
|
+
|
|
238
|
+
## Stock Splitters
|
|
239
|
+
|
|
240
|
+
Splitters are the per-location half of the split-shipment story. Each splitter takes the packages produced so far for one location and decides whether to break them further along its own axis. Splitters are chained — every splitter's output feeds into the next.
|
|
241
|
+
|
|
242
|
+
Splitters never see more than one location's allocation at a time, so they cannot overlap with routing's location decision. Routing answers "which locations?"; splitters answer "how do we slice each location's packages?"
|
|
243
|
+
|
|
244
|
+
### Built-in Splitters
|
|
245
|
+
|
|
246
|
+
| Splitter | Default? | What it does |
|
|
247
|
+
|---|---|---|
|
|
248
|
+
| `Spree::Stock::Splitter::ShippingCategory` | Yes | Groups items in each package by their product's [Shipping Category](#shipping-categories), so each package has only one category. Ensures shipping methods scoped to specific categories receive the right items. |
|
|
249
|
+
| `Spree::Stock::Splitter::Backordered` | Yes | Splits each package into an on-hand part and a backordered part. The two halves can ship at different times with different ETAs. |
|
|
250
|
+
| `Spree::Stock::Splitter::Digital` | Yes | Separates digital items from physical items so digital deliveries don't get bundled with a physical package. |
|
|
251
|
+
| `Spree::Stock::Splitter::Weight` | Opt-in | Caps each package at a weight threshold (default `150`). Splits heavy packages until each is under the limit. Used by carriers with per-package weight limits. |
|
|
252
|
+
|
|
253
|
+
The default chain is set in `Spree::Core::Engine` and can be overridden:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
# config/initializers/spree.rb
|
|
257
|
+
Rails.application.config.spree.stock_splitters = [
|
|
258
|
+
Spree::Stock::Splitter::ShippingCategory,
|
|
259
|
+
Spree::Stock::Splitter::Backordered,
|
|
260
|
+
Spree::Stock::Splitter::Digital,
|
|
261
|
+
Spree::Stock::Splitter::Weight # add the opt-in weight cap
|
|
262
|
+
]
|
|
263
|
+
```
|
|
173
264
|
|
|
174
|
-
|
|
265
|
+
The order matters — each splitter's output is the next one's input.
|
|
175
266
|
|
|
176
|
-
|
|
267
|
+
### Extending Splitters
|
|
177
268
|
|
|
178
|
-
|
|
269
|
+
To add custom splitting logic — refrigerated SKUs, gift wrap separation, bulky-vs-small bin separation, anything that needs to fan one location's allocation into multiple shipments — write a new subclass of `Spree::Stock::Splitter::Base`. See the [Build Custom Stock Splitter](../how-to/custom-stock-splitter.md) guide.
|
|
179
270
|
|
|
180
271
|
## Examples
|
|
181
272
|
|
|
@@ -209,4 +300,5 @@ A store shipping from 2 locations (New York, Los Angeles) with 3 carriers and 3
|
|
|
209
300
|
- [Inventory](inventory.md) — Stock locations and inventory management
|
|
210
301
|
- [Calculators](calculators.md) — Shipping rate calculators
|
|
211
302
|
- [Addresses](addresses.md) — Shipping address and zones
|
|
303
|
+
- [Build Custom Order Routing](../how-to/custom-order-routing.md) — Custom rules and strategies for choosing the fulfillment location
|
|
212
304
|
- [Events](events.md) — Subscribe to shipment events (e.g., `shipment.shipped`)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Build Custom Order Routing
|
|
3
|
+
description: Step-by-step guide to extending Spree's order routing — write custom rules to add new signals, or a custom strategy to replace the algorithm entirely.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
import { Since } from '/snippets/since.mdx';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Order routing decides which [Stock Location](../core-concepts/inventory.md#stock-locations) fulfills an order at checkout. Spree gives you two extension points:
|
|
12
|
+
|
|
13
|
+
- **Rules** — add a new signal to the existing rules-walking algorithm (proximity, customer tier, refrigerated SKUs, day-of-week dispatch).
|
|
14
|
+
- **Strategies** — replace the algorithm entirely (delegate to a warehouse management system, run an ML model, call an optimization solver).
|
|
15
|
+
|
|
16
|
+
This guide covers both. Most extensions are rules — they compose with the built-ins and don't require rewriting the pipeline.
|
|
17
|
+
|
|
18
|
+
Before starting, make sure you understand [how order routing works in Spree](../core-concepts/shipments.md#order-routing).
|
|
19
|
+
|
|
20
|
+
| If the answer is "yes" | Pick |
|
|
21
|
+
|---|---|
|
|
22
|
+
| Could this work just by reordering or adding signals to the existing algorithm? | A **rule** |
|
|
23
|
+
| Does the data live in another system you have to call? | A **strategy** |
|
|
24
|
+
| Will the algorithm need access to multiple orders simultaneously (batching, capacity-aware)? | A **strategy** |
|
|
25
|
+
| Are you replacing the entire decision (no rules at all)? | A **strategy** |
|
|
26
|
+
|
|
27
|
+
## Custom Routing Rules
|
|
28
|
+
|
|
29
|
+
A rule is one input to the rules-walking algorithm. Each rule subclasses `Spree::OrderRoutingRule` and implements `#rank`, returning an array of `LocationRanking` — one per candidate location.
|
|
30
|
+
|
|
31
|
+
### Step 1: Create the Rule Class
|
|
32
|
+
|
|
33
|
+
```ruby app/models/spree/order_routing/rules/closest_location.rb
|
|
34
|
+
module Spree
|
|
35
|
+
module OrderRouting
|
|
36
|
+
module Rules
|
|
37
|
+
class ClosestLocation < Spree::OrderRoutingRule
|
|
38
|
+
preference :max_distance_km, :integer, default: 1000
|
|
39
|
+
|
|
40
|
+
def rank(order, locations)
|
|
41
|
+
target = order.ship_address&.coordinates
|
|
42
|
+
return locations.map { |l| LocationRanking.new(location: l, rank: nil) } if target.nil?
|
|
43
|
+
|
|
44
|
+
locations.map do |loc|
|
|
45
|
+
distance = loc.distance_from(target)
|
|
46
|
+
ranked = distance && distance <= preferred_max_distance_km ? distance.to_i : nil
|
|
47
|
+
LocationRanking.new(location: loc, rank: ranked)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
#### Key Method to Implement
|
|
57
|
+
|
|
58
|
+
| Method | Required | Description |
|
|
59
|
+
|--------|----------|-------------|
|
|
60
|
+
| `rank(order, locations)` | Yes | Returns one `LocationRanking` per input location. Lower rank wins (`0` is best); `nil` means abstain (the rule has no opinion about that location and is skipped for that pass). |
|
|
61
|
+
|
|
62
|
+
#### Using Preferences
|
|
63
|
+
|
|
64
|
+
Rules use Spree's preference system for configuration, the same way [Promotion Rules](custom-promotion.md#using-preferences) do. Each preference creates getter/setter methods automatically:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
preference :max_distance_km, :integer, default: 1000
|
|
68
|
+
|
|
69
|
+
# Creates:
|
|
70
|
+
# preferred_max_distance_km / preferred_max_distance_km=
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Available types: `:string`, `:integer`, `:decimal`, `:boolean`, `:array`.
|
|
74
|
+
|
|
75
|
+
### Step 2: Activate the Rule on a Channel
|
|
76
|
+
|
|
77
|
+
Every routing rule belongs to a Channel. Pick the channel(s) you want it active on and insert a row:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
store = Spree::Store.default
|
|
81
|
+
channel = store.default_channel
|
|
82
|
+
|
|
83
|
+
Spree::OrderRouting::Rules::ClosestLocation.create!(
|
|
84
|
+
store: store,
|
|
85
|
+
channel: channel,
|
|
86
|
+
position: 0, # before the seeded preferred_location rule
|
|
87
|
+
preferred_max_distance_km: 500
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Spree's autoloader picks up models under `app/models/` automatically — no separate registration step.
|
|
92
|
+
|
|
93
|
+
To activate the rule across multiple channels, create one row per channel. That keeps each channel's rule list explicit and lets you tune per-channel preferences independently.
|
|
94
|
+
|
|
95
|
+
### Rank Semantics
|
|
96
|
+
|
|
97
|
+
The reducer composes rules using "first non-tie wins":
|
|
98
|
+
|
|
99
|
+
| Situation | What the reducer does |
|
|
100
|
+
|---|---|
|
|
101
|
+
| All rules abstain (`nil`) for a location | Falls back to `StockLocation.default`, then by `id` |
|
|
102
|
+
| One rule returns a unique minimum rank | That location wins; remaining rules skipped |
|
|
103
|
+
| One rule returns a tied minimum | The tied locations carry forward; the next rule weighs in *only on those* |
|
|
104
|
+
| All rules tie through the whole chain | Final tiebreak: default location, then by `id` |
|
|
105
|
+
|
|
106
|
+
Practical implications:
|
|
107
|
+
|
|
108
|
+
- **Returning `0` for everything is a reset.** If every location ties at `0`, all locations carry forward — the reducer treats it as "no signal" and moves on.
|
|
109
|
+
- **Coverage-style metrics negate.** When higher-is-better, return `-coverage` so lower wins. See `Spree::OrderRouting::Rules::MinimizeSplits` for the canonical example.
|
|
110
|
+
- **Abstaining yields to other rules.** `nil` is the right answer when your rule has no opinion — it lets later rules decide.
|
|
111
|
+
|
|
112
|
+
### Step 3: Test the Rule
|
|
113
|
+
|
|
114
|
+
```ruby spec/models/spree/order_routing/rules/closest_location_spec.rb
|
|
115
|
+
require 'rails_helper'
|
|
116
|
+
|
|
117
|
+
RSpec.describe Spree::OrderRouting::Rules::ClosestLocation, type: :model do
|
|
118
|
+
let(:store) { @default_store }
|
|
119
|
+
let(:channel) { store.default_channel }
|
|
120
|
+
let(:near) { create(:stock_location, latitude: 40.71, longitude: -74.00) } # NYC
|
|
121
|
+
let(:far) { create(:stock_location, latitude: 34.05, longitude: -118.24) } # LA
|
|
122
|
+
let(:order) { build(:order, store: store, ship_address: build(:address, latitude: 40.75, longitude: -73.99)) }
|
|
123
|
+
|
|
124
|
+
subject(:rule) do
|
|
125
|
+
described_class.new(store: store, channel: channel, position: 99, preferred_max_distance_km: 5000)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'ranks the closer location lower' do
|
|
129
|
+
rankings = rule.rank(order, [near, far])
|
|
130
|
+
expect(rankings.find { |r| r.location == near }.rank).to be < rankings.find { |r| r.location == far }.rank
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'abstains for every location when the order has no shippable address' do
|
|
134
|
+
addressless_order = build(:order, store: store, ship_address: nil)
|
|
135
|
+
rankings = rule.rank(addressless_order, [near, far])
|
|
136
|
+
expect(rankings.map(&:rank)).to all(be_nil)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Common Pitfalls
|
|
142
|
+
|
|
143
|
+
- **Forgetting `position`.** `position` is required and `acts_as_list`-scoped per channel. Use a number that doesn't collide with the seeded `1` / `2` / `3` (low for "before the defaults", `100+` for "after the defaults").
|
|
144
|
+
- **Returning fewer rankings than locations.** Always return one entry per input location, including abstains (`nil` rank). The reducer needs to see every location.
|
|
145
|
+
- **Trying to "block" a location.** Rules rank, they don't filter. To exclude a location, lower its rank to a value worse than every other rule produces, or abstain everywhere except the locations you want and rely on a later rule to cover the rest.
|
|
146
|
+
|
|
147
|
+
## Custom Routing Strategies
|
|
148
|
+
|
|
149
|
+
A strategy is a complete algorithm — when rules-walking doesn't fit your problem, you write a strategy that owns the entire allocation pipeline.
|
|
150
|
+
|
|
151
|
+
### Step 1: Create the Strategy Class
|
|
152
|
+
|
|
153
|
+
The contract is `Spree::OrderRouting::Strategy::Base`. There are no defaults — you implement all four methods:
|
|
154
|
+
|
|
155
|
+
| Method | When it fires | Returns |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `#for_allocation` | Cart → checkout transition (`Order#create_proposed_shipments`) | `Array<Spree::Stock::Package>` |
|
|
158
|
+
| `#for_sale(fulfillment:)` | A shipment ships | (side effect) |
|
|
159
|
+
| `#for_release` | An in-flight order is canceled before shipping | (side effect) |
|
|
160
|
+
| `#for_cancellation` | A shipped order is canceled (return) | (side effect) |
|
|
161
|
+
|
|
162
|
+
The four methods bracket the lifecycle: allocation → sale, allocation → release, or allocation → cancellation. Whatever your algorithm does at allocation time, the other three are where you reverse or settle it.
|
|
163
|
+
|
|
164
|
+
```ruby app/models/acme/oms/strategy.rb
|
|
165
|
+
module Acme
|
|
166
|
+
module Oms
|
|
167
|
+
class Strategy < Spree::OrderRouting::Strategy::Base
|
|
168
|
+
def for_allocation
|
|
169
|
+
decision = client.allocate(order_payload)
|
|
170
|
+
return [] if decision.assignments.empty?
|
|
171
|
+
|
|
172
|
+
decision.assignments.map { |assignment| build_package(assignment) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def for_sale(fulfillment:)
|
|
176
|
+
client.notify_shipped(fulfillment_payload(fulfillment))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def for_release
|
|
180
|
+
client.release(order.number)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def for_cancellation
|
|
184
|
+
client.cancel_and_restock(order.number)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def client
|
|
190
|
+
@client ||= Acme::Oms::Client.new(api_key: ENV.fetch('ACME_OMS_API_KEY'))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def order_payload
|
|
194
|
+
{
|
|
195
|
+
order_number: order.number,
|
|
196
|
+
line_items: order.line_items.map { |li| { sku: li.variant.sku, qty: li.quantity } },
|
|
197
|
+
ship_to: order.ship_address&.country&.iso
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def build_package(assignment)
|
|
202
|
+
location = Spree::StockLocation.find_by!(code: assignment.location_code)
|
|
203
|
+
units = order.inventory_units.where(variant_id: assignment.variant_ids).to_a
|
|
204
|
+
|
|
205
|
+
package = Spree::Stock::Packer.new(location, units, Spree.stock_splitters).packages.first
|
|
206
|
+
package.shipping_rates = Spree::Stock::Estimator.new(order).shipping_rates(package)
|
|
207
|
+
package
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def fulfillment_payload(fulfillment)
|
|
211
|
+
# ...
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Notes:
|
|
219
|
+
|
|
220
|
+
- **Strategies are plain Ruby classes**, not ActiveRecord models. Live under `app/models/` so the autoloader picks them up; or anywhere on the load path if you'd rather organize them as services.
|
|
221
|
+
- **`for_allocation` returns `Spree::Stock::Package` objects.** The order's `create_proposed_shipments` turns those into `Shipment`s by calling `package.to_shipment`. Returning shipments directly will break the call site.
|
|
222
|
+
- **Reuse the existing primitives.** `Spree::Stock::Packer`, `Spree::Stock::Estimator`, and `Spree::Stock::InventoryUnitBuilder` handle packing, rate estimation, and inventory unit construction. Custom strategies are about the location decision, not re-implementing the packing pipeline.
|
|
223
|
+
|
|
224
|
+
### Step 2: Register the Strategy
|
|
225
|
+
|
|
226
|
+
Strategies are selected by class name string — set on `Spree::Store` (default) or `Spree::Channel` (override).
|
|
227
|
+
|
|
228
|
+
Activate on the whole store:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
Spree::Store.default.update!(
|
|
232
|
+
preferred_order_routing_strategy: 'Acme::Oms::Strategy'
|
|
233
|
+
)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Override on one channel only:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
store = Spree::Store.default
|
|
240
|
+
store.channels.find_by(code: 'pos').update!(
|
|
241
|
+
preferred_order_routing_strategy: 'Acme::Oms::Strategy'
|
|
242
|
+
)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Resolution order: `channel.preferred_order_routing_strategy` → `store.preferred_order_routing_strategy`. The class is `safe_constantize`-d and rejected if it doesn't subclass `Strategy::Base`.
|
|
246
|
+
|
|
247
|
+
### Step 3: Test the Strategy
|
|
248
|
+
|
|
249
|
+
Strategy tests are integration tests — build an order, instantiate the strategy, exercise the four methods, assert on the resulting shipments and mocked side effects.
|
|
250
|
+
|
|
251
|
+
```ruby spec/models/acme/oms/strategy_spec.rb
|
|
252
|
+
require 'rails_helper'
|
|
253
|
+
|
|
254
|
+
RSpec.describe Acme::Oms::Strategy, type: :model do
|
|
255
|
+
let(:store) { @default_store }
|
|
256
|
+
let(:variant) { create(:variant) }
|
|
257
|
+
let(:location) { create(:stock_location, code: 'NYC') }
|
|
258
|
+
let(:order) { create(:order_with_line_items, store: store, line_items_attributes: [{ variant: variant, quantity: 1 }]) }
|
|
259
|
+
|
|
260
|
+
before { location.stock_item_or_create(variant).update!(count_on_hand: 10) }
|
|
261
|
+
|
|
262
|
+
subject(:strategy) { described_class.new(order: order) }
|
|
263
|
+
|
|
264
|
+
describe '#for_allocation' do
|
|
265
|
+
it 'returns packages from the OMS-chosen locations' do
|
|
266
|
+
stub_oms_decision(assignments: [{ location_code: 'NYC', variant_ids: [variant.id] }])
|
|
267
|
+
|
|
268
|
+
packages = strategy.for_allocation
|
|
269
|
+
expect(packages.map(&:stock_location)).to all(eq(location))
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it 'returns no packages when the OMS has nothing to assign' do
|
|
273
|
+
stub_oms_decision(assignments: [])
|
|
274
|
+
expect(strategy.for_allocation).to eq([])
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Common Pitfalls
|
|
281
|
+
|
|
282
|
+
- **Forgetting the lifecycle hooks.** `for_release` and `for_cancellation` raise `NotImplementedError` by default. If your algorithm doesn't need post-allocation hooks, override them as no-ops explicitly.
|
|
283
|
+
- **Ignoring inventory.** Even custom strategies should query `Spree::StockLocation.active` and respect the order's reserved units. Hardcoding `Spree::StockLocation.first` will work in tests and break in production.
|
|
284
|
+
- **Side effects in `for_sale` / `for_release`.** These fire from state-machine callbacks where the order may already be partially mutated. Treat the methods as side-effect endpoints; pull what you need from the `order` and `fulfillment` arguments — don't reload state mid-call.
|
|
285
|
+
|
|
286
|
+
## Coexistence with Stock Reservations
|
|
287
|
+
|
|
288
|
+
Stock reservations and order routing are independent systems in 5.5 — they make decisions at different times and protect different invariants.
|
|
289
|
+
|
|
290
|
+
| Concern | Reservation system | Order routing |
|
|
291
|
+
|---|---|---|
|
|
292
|
+
| **When it fires** | Cart mutation (add item, change qty, enter checkout) | Cart → checkout transition |
|
|
293
|
+
| **What it decides** | How many units of a variant are held for this cart | Which `StockLocation` fulfills the order |
|
|
294
|
+
| **Granularity** | Per-variant | Per-order |
|
|
295
|
+
|
|
296
|
+
`Spree::Stock::Quantifier` already subtracts active reservations from `count_on_hand` per-variant across all locations, so global "can we sell this variant?" math is correct even when the reservation and the routing decision land on different locations.
|
|
297
|
+
|
|
298
|
+
What this means for you when writing custom rules and strategies:
|
|
299
|
+
|
|
300
|
+
- **Don't read `StockReservation` from inside a routing rule's `#rank`.** The reservations were created against arbitrary stock_items at cart time and don't reflect the routing decision.
|
|
301
|
+
- **Don't relocate reservations from a custom strategy's `for_allocation`.** That's the path 6.0 codifies; doing it ad-hoc in 5.5 races against the cart services that own reservation lifecycle.
|
|
302
|
+
- **`AvailabilityValidator` is the safety net.** If a routing decision picks a location that's actually short on stock, the validator catches it before the order completes.
|
|
303
|
+
|
|
304
|
+
## Next Steps
|
|
305
|
+
|
|
306
|
+
- [How orders work](../core-concepts/orders.md) — Order lifecycle and the routing context
|
|
307
|
+
- [Inventory & Stock Locations](../core-concepts/inventory.md) — Where the locations live
|
|
308
|
+
- [Build Custom Promotion Rules & Actions](custom-promotion.md) — A similar STI-based extension pattern
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Build a Custom Stock Splitter
|
|
3
|
+
description: Step-by-step guide to extending Spree's stock splitter chain — break a location's allocation into multiple shipments along your own axis (refrigeration, gift wrap, bin size, hazmat, anything you need to physically separate).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
When [Order Routing](../core-concepts/shipments.md#order-routing) picks one or more stock locations to fulfill an order, each location's allocation is then run through a chain of **splitters**. Each splitter looks at the packages produced so far and decides whether to break them further along its own axis.
|
|
9
|
+
|
|
10
|
+
Spree ships with four splitters out of the box (`ShippingCategory`, `Backordered`, `Digital`, `Weight`). You add your own when you need a *physical* separation that isn't expressed by any of the existing ones — refrigerated SKUs that can't share a box with ambient ones, hazmat goods that need their own carrier label, gift-wrap items that ship from a separate processing room, and so on.
|
|
11
|
+
|
|
12
|
+
Before starting, make sure you understand [how splitting works in Spree](../core-concepts/shipments.md#stock-splitters) and the [order routing](../core-concepts/shipments.md#order-routing) layer that runs before splitters.
|
|
13
|
+
|
|
14
|
+
| If the answer is "yes" | Pick |
|
|
15
|
+
|---|---|
|
|
16
|
+
| Should this affect _which_ locations fulfill the order? | A [routing rule](custom-order-routing.md#custom-routing-rules), not a splitter |
|
|
17
|
+
| Does it just need to break one location's allocation into multiple physical packages? | A **splitter** |
|
|
18
|
+
| Should it apply to every location, every order? | A **splitter** registered globally |
|
|
19
|
+
| Should it apply only on certain channels or stores? | A **splitter** plus a guard inside `#split` |
|
|
20
|
+
|
|
21
|
+
### Splitters vs Order Routing — quick recap
|
|
22
|
+
|
|
23
|
+
Routing picks _which_ locations ship; splitters decide _how_ each location's packages are broken up. They live at different layers and never overlap:
|
|
24
|
+
|
|
25
|
+
| Layer | Decides | Sees | Output |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
| Routing | Location order | All eligible locations + the whole order | Ranked location list |
|
|
28
|
+
| Splitter | Intra-location packaging | One location's packages so far | More (or fewer) packages |
|
|
29
|
+
|
|
30
|
+
A plugin can absolutely ship both — for example, a refrigerated-goods plugin might add a `RefrigeratedRouting::Rule` (prefer locations with cold storage) **and** a `RefrigeratedSplitter` (separate cold items from ambient items within each location). The two extension points are independent.
|
|
31
|
+
|
|
32
|
+
## Custom Stock Splitters
|
|
33
|
+
|
|
34
|
+
A splitter subclasses `Spree::Stock::Splitter::Base` and implements `#split(packages)`. The method receives an array of packages produced by the previous splitter (or the initial single-package output of `Packer#default_package`), returns an array of packages, and *must* call `return_next` so the chain continues.
|
|
35
|
+
|
|
36
|
+
### Step 1: Create the Splitter Class
|
|
37
|
+
|
|
38
|
+
```ruby app/models/spree/stock/splitter/refrigerated.rb
|
|
39
|
+
module Spree
|
|
40
|
+
module Stock
|
|
41
|
+
module Splitter
|
|
42
|
+
class Refrigerated < Spree::Stock::Splitter::Base
|
|
43
|
+
def split(packages)
|
|
44
|
+
split_packages = packages.flat_map { |pkg| split_by_temperature(pkg) }
|
|
45
|
+
return_next(split_packages)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def split_by_temperature(package)
|
|
51
|
+
grouped = package.contents.group_by { |item| item.variant.refrigerated? }
|
|
52
|
+
grouped.values.map { |contents| build_package(contents) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
#### Key Method to Implement
|
|
61
|
+
|
|
62
|
+
| Method | Required | Description |
|
|
63
|
+
|--------|----------|-------------|
|
|
64
|
+
| `#split(packages)` | Yes | Receives the packages produced by the previous splitter, returns the new (possibly larger or smaller) array of packages. **Must call `return_next(packages)`** so the next splitter in the chain runs. |
|
|
65
|
+
|
|
66
|
+
#### Helpers Inherited from `Splitter::Base`
|
|
67
|
+
|
|
68
|
+
| Helper | Returns | When to use |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `build_package(contents = [])` | A new `Spree::Stock::Package` for the splitter's `stock_location` | When you create a new package out of contents you've separated from an input package |
|
|
71
|
+
| `return_next(packages)` | The result of the next splitter, or `packages` if this is the last splitter | Always — at the end of `#split` |
|
|
72
|
+
| `stock_location` | The location the parent `Packer` is packing for | When you need to inspect or compare against the location |
|
|
73
|
+
| `packer` | The `Packer` instance | Rarely needed; available for advanced cases |
|
|
74
|
+
|
|
75
|
+
#### What `package.contents` Looks Like
|
|
76
|
+
|
|
77
|
+
Each `package.contents` is an array of `Spree::Stock::ContentItem` — wrappers around `InventoryUnit`. The two attributes you'll use most:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
content.variant # The Spree::Variant being shipped
|
|
81
|
+
content.inventory_unit # The Spree::InventoryUnit (gives access to line_item, order, etc.)
|
|
82
|
+
content.weight # Convenience: variant.weight × inventory_unit.quantity
|
|
83
|
+
content.state # :on_hand or :backordered
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
So the splitter's job is "look at each package's contents, decide which contents stay together, build new packages, return the chained result."
|
|
87
|
+
|
|
88
|
+
### Step 2: Register the Splitter
|
|
89
|
+
|
|
90
|
+
Splitters are registered globally on `Rails.application.config.spree.stock_splitters` — every order runs through the full chain. Add yours via an initializer:
|
|
91
|
+
|
|
92
|
+
```ruby config/initializers/spree.rb
|
|
93
|
+
Rails.application.config.to_prepare do
|
|
94
|
+
Rails.application.config.spree.stock_splitters << Spree::Stock::Splitter::Refrigerated
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The `to_prepare` block re-runs on Zeitwerk code reloads in development, so the splitter survives reloads correctly. Putting the line at the top of the initializer (outside `to_prepare`) works too in production but can leave a stale registry in development.
|
|
99
|
+
|
|
100
|
+
#### Replacing the Whole Chain
|
|
101
|
+
|
|
102
|
+
If you'd rather control the full chain (uncommon — the defaults are well-chosen), assign instead of append:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
Rails.application.config.spree.stock_splitters = [
|
|
106
|
+
Spree::Stock::Splitter::ShippingCategory,
|
|
107
|
+
Spree::Stock::Splitter::Refrigerated, # custom one slotted in
|
|
108
|
+
Spree::Stock::Splitter::Backordered,
|
|
109
|
+
Spree::Stock::Splitter::Digital
|
|
110
|
+
]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### Ordering Matters
|
|
114
|
+
|
|
115
|
+
Splitters run in array order, each feeding the next. Two practical rules:
|
|
116
|
+
|
|
117
|
+
1. **Coarse before fine.** `ShippingCategory` runs first by default because it groups packages by carrier-relevant category before any other axis cuts in. Your custom splitter usually wants to run after it, unless you're separating items that should *never* share a package even within a category (e.g. hazmat).
|
|
118
|
+
2. **`Backordered` should usually be last among the "type" splitters.** It splits on-hand from backordered items, which is a state-axis split rather than a packaging-axis split. Splitting before `Backordered` gives you finer category buckets; splitting after does too — pick based on whether your axis applies to backorders. Refrigerated items have the same handling whether on-hand or backordered, so running before `Backordered` is fine. Hazmat shipping rules might differ between on-hand (real package) and backorder (paperwork only), so running after might be cleaner.
|
|
119
|
+
|
|
120
|
+
### Step 3: Test the Splitter
|
|
121
|
+
|
|
122
|
+
Splitter tests are unit tests — instantiate a fake `Packer`, hand the splitter a hand-built package, assert on the output. There's no factory required.
|
|
123
|
+
|
|
124
|
+
```ruby spec/models/spree/stock/splitter/refrigerated_spec.rb
|
|
125
|
+
require 'rails_helper'
|
|
126
|
+
|
|
127
|
+
RSpec.describe Spree::Stock::Splitter::Refrigerated, type: :model do
|
|
128
|
+
let(:stock_location) { build_stubbed(:stock_location) }
|
|
129
|
+
let(:packer) { instance_double(Spree::Stock::Packer, stock_location: stock_location) }
|
|
130
|
+
let(:cold_variant) { build_stubbed(:variant).tap { |v| allow(v).to receive(:refrigerated?).and_return(true) } }
|
|
131
|
+
let(:warm_variant) { build_stubbed(:variant).tap { |v| allow(v).to receive(:refrigerated?).and_return(false) } }
|
|
132
|
+
|
|
133
|
+
let(:cold_item) { content_item_for(cold_variant) }
|
|
134
|
+
let(:warm_item) { content_item_for(warm_variant) }
|
|
135
|
+
|
|
136
|
+
subject(:splitter) { described_class.new(packer) }
|
|
137
|
+
|
|
138
|
+
it 'splits a mixed package into a refrigerated package and an ambient package' do
|
|
139
|
+
package = Spree::Stock::Package.new(stock_location, [cold_item, warm_item])
|
|
140
|
+
|
|
141
|
+
result = splitter.split([package])
|
|
142
|
+
|
|
143
|
+
expect(result.size).to eq(2)
|
|
144
|
+
expect(result.flat_map { |p| p.contents.map(&:variant) }).to contain_exactly(cold_variant, warm_variant)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'leaves an all-cold package as a single package' do
|
|
148
|
+
package = Spree::Stock::Package.new(stock_location, [cold_item])
|
|
149
|
+
|
|
150
|
+
result = splitter.split([package])
|
|
151
|
+
|
|
152
|
+
expect(result.size).to eq(1)
|
|
153
|
+
expect(result.first.contents.map(&:variant)).to eq([cold_variant])
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'returns chained packages unchanged when there is a next_splitter' do
|
|
157
|
+
next_splitter = instance_double(Spree::Stock::Splitter::Base, split: [:done])
|
|
158
|
+
splitter = described_class.new(packer, next_splitter)
|
|
159
|
+
package = Spree::Stock::Package.new(stock_location, [cold_item, warm_item])
|
|
160
|
+
|
|
161
|
+
expect(splitter.split([package])).to eq([:done])
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def content_item_for(variant)
|
|
165
|
+
inventory_unit = build_stubbed(:inventory_unit, variant: variant)
|
|
166
|
+
Spree::Stock::ContentItem.new(inventory_unit, :on_hand)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Step 4 (optional): Add a Variant-side Predicate
|
|
172
|
+
|
|
173
|
+
The example above relies on `variant.refrigerated?`. In a real plugin you'd back that with a [Custom Field](../core-concepts/custom-fields.md) on `Spree::Variant` — say a boolean metafield with key `refrigerated` — and define the predicate as a thin reader:
|
|
174
|
+
|
|
175
|
+
```ruby app/models/spree/variant_decorator.rb
|
|
176
|
+
Spree::Variant.class_eval do
|
|
177
|
+
def refrigerated?
|
|
178
|
+
metafields.find_by(namespace: 'logistics', key: 'refrigerated')&.value == 'true'
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
This keeps the splitter pure and lets merchants flag SKUs from the admin UI without touching code.
|
|
184
|
+
|
|
185
|
+
### Common Pitfalls
|
|
186
|
+
|
|
187
|
+
- **Forgetting to call `return_next`.** If you return raw packages instead of `return_next(packages)`, every splitter after yours in the chain is silently skipped. The integration test will catch it; the unit test usually won't.
|
|
188
|
+
- **Building empty packages.** If you `group_by` and the group has no contents, `build_package([])` produces a package with no contents that downstream code may treat as a real package. Skip empty groups: `grouped.values.reject(&:empty?).map { |c| build_package(c) }`.
|
|
189
|
+
- **Mutating input packages in place.** A splitter should return new packages built from the contents of the inputs, not edit the originals — `Packer` and other splitters keep references. Use `build_package(contents)`, not `package.contents = ...`.
|
|
190
|
+
- **Doing routing-style work.** A splitter only sees one location's contents. If your logic needs to compare across locations ("ship the cheapest one first"), that's a routing rule or strategy, not a splitter.
|
|
191
|
+
|
|
192
|
+
## Coexistence with Order Routing
|
|
193
|
+
|
|
194
|
+
Splitters and routing run in series, not in parallel — every package a splitter sees comes from a single location that routing already chose. The interaction is purely top-down:
|
|
195
|
+
|
|
196
|
+
| Step | Layer | What runs |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| 1 | Routing | `Strategy::Rules#for_allocation` ranks all eligible locations |
|
|
199
|
+
| 2 | Per-location packing | One `Packer` per location runs the full splitter chain |
|
|
200
|
+
| 3 | Cross-location dedup | `Prioritizer.Adjuster` walks packages in rank order, assigns each inventory unit to the first package with on-hand stock |
|
|
201
|
+
| 4 | Rate estimation | `Estimator` attaches shipping rates |
|
|
202
|
+
|
|
203
|
+
Practical implications:
|
|
204
|
+
|
|
205
|
+
- **Splitter output feeds the Prioritizer.** A splitter that produces 3 packages from one location adds 3 candidates to the Prioritizer's pool. The Prioritizer keeps each unit in the highest-ranked package that still has it on hand and prunes empties.
|
|
206
|
+
- **Splitters can produce backordered-only packages.** That's fine — the `Backordered` splitter is the canonical example. The Prioritizer treats backordered packages as a fallback after exhausting on-hand options across all higher-ranked locations.
|
|
207
|
+
- **A splitter can't see which location ranked higher.** It runs per-location with no awareness of the rank. If you need rank-aware splitting (rare), do it in a custom strategy's `build_packages`, not a splitter.
|
|
208
|
+
|
|
209
|
+
## Next Steps
|
|
210
|
+
|
|
211
|
+
- [Shipments — Stock Splitters](../core-concepts/shipments.md#stock-splitters) — Concept overview and built-in splitter list
|
|
212
|
+
- [Build Custom Order Routing](custom-order-routing.md) — The other layer of split-shipment customization
|
|
213
|
+
- [Custom Fields](../core-concepts/custom-fields.md) — Tag variants with the data your splitter reads
|
|
@@ -19,6 +19,31 @@ description: This guide covers upgrading a Spree 5.4 application to Spree 5.5.
|
|
|
19
19
|
bin/rake spree:install:migrations && bin/rails db:migrate
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
### Run the channels upgrade
|
|
23
|
+
|
|
24
|
+
Spree 5.5 promotes `Order#channel` from a free-text string column to a real `Spree::Channel` FK. **Run this rake task immediately after `db:migrate`** — it seeds the channel rows your existing stores need and backfills `spree_orders.channel_id` from the legacy string column:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bin/rake spree:channels:upgrade
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The task is idempotent — safe to re-run if it fails partway, and a no-op on stores/orders that have already been upgraded. It runs two sub-tasks in order:
|
|
31
|
+
|
|
32
|
+
1. **`spree:channels:create_defaults`** — creates a default `online` channel for every store that doesn't already have one. New 5.5+ stores get this automatically via the `Store` after_create callback; this sub-task only matters for stores that already exist when you upgrade. The channel's after_create hook seeds the three default routing rules (Preferred Location → Minimize Splits → Default Location).
|
|
33
|
+
|
|
34
|
+
2. **`spree:channels:backfill_order_channel_ids`** — scans each store's orders, creates one `Spree::Channel` per distinct legacy `channel` value (claiming `NULL`/blank rows under the `online` default channel), and writes `channel_id`.
|
|
35
|
+
|
|
36
|
+
You can also run the two steps individually if you'd rather pace the upgrade:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bin/rake spree:channels:create_defaults
|
|
40
|
+
bin/rake spree:channels:backfill_order_channel_ids
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Orders modified after the upgrade auto-set `channel_id` via the model's `before_validation` callback, so the backfill is only strictly required for orders that aren't touched again post-upgrade — but running it once at upgrade time avoids surprises later.
|
|
44
|
+
|
|
45
|
+
The legacy `channel` string column is **kept** on `spree_orders` and ignored by ActiveRecord (`Spree::Order` declares it in `ignored_columns`). It will be dropped in a later Spree release once enough time has passed for everyone to have run the backfill — until then it's harmless DB ballast that lets the rake task be re-run if you need to.
|
|
46
|
+
|
|
22
47
|
### Schedule the Stock Reservations expiry job
|
|
23
48
|
|
|
24
49
|
Spree 5.5 introduces time-limited stock reservations during checkout to prevent two customers from buying the same last unit at the same time. Abandoned checkouts leave behind expired reservation rows, and Spree does **not** auto-schedule the cleanup — your application's job runner must run `Spree::StockReservations::ExpireJob` periodically (every minute is the recommended cadence).
|
|
@@ -86,6 +111,20 @@ Spree::Config[:stock_reservations_enabled] = false
|
|
|
86
111
|
|
|
87
112
|
The Quantifier short-circuits before any reservation query when this is `false`, so there's no runtime cost and no table growth.
|
|
88
113
|
|
|
114
|
+
### (Optional) Opt out of rules-based Order Routing
|
|
115
|
+
|
|
116
|
+
Spree 5.5 introduces [Order Routing](../core-concepts/shipments.md#order-routing) — a configurable, per-channel pipeline that decides which stock locations fulfill an order. Every store and every channel ships with three default rules (Preferred Location → Minimize Splits → Default Location) that produce sensible behavior out of the box, with no migration work required.
|
|
117
|
+
|
|
118
|
+
If you've heavily customized fulfillment in Spree 5.4 and aren't ready to adopt the new rules engine, you can keep the legacy pre-5.5 routing by switching the store's strategy to `Spree::OrderRouting::Strategy::Legacy`:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
store.update!(preferred_order_routing_strategy: 'Spree::OrderRouting::Strategy::Legacy')
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The Legacy strategy delegates to `Spree::Stock::Coordinator`, which is the exact pre-5.5 packing pipeline — every active stock location is packed, the Prioritizer distributes inventory units across the resulting packages, and no merchant routing rules are consulted. Your existing customizations on `Coordinator`, `Packer`, `Prioritizer`, and the splitters keep working unchanged.
|
|
125
|
+
|
|
126
|
+
> **WARNING:** `Spree::OrderRouting::Strategy::Legacy` and `Spree::Stock::Coordinator` are slated for removal in Spree 6.0. Use this only as a temporary escape hatch while you evaluate the new Rules strategy. New 5.5+ installations should stay on the default Rules strategy.
|
|
127
|
+
|
|
89
128
|
## Behavior changes worth knowing
|
|
90
129
|
|
|
91
130
|
### Cart changes during checkout can now fail with insufficient stock
|
|
@@ -97,3 +136,11 @@ Storefronts and custom integrations that act on the cart should expect this new
|
|
|
97
136
|
### Storefront availability drops faster under contention
|
|
98
137
|
|
|
99
138
|
Other customers now see availability reduced by all active reservations, not just by completed orders. This is the intended fix to overselling — but if you have a real-time inventory dashboard that reads `count_on_hand` directly (rather than going through Spree's availability checks), you'll want to expose a "Reserved" axis to merchants so they can see in-checkout demand.
|
|
139
|
+
|
|
140
|
+
### Order Routing chooses location order via merchant rules instead of database order
|
|
141
|
+
|
|
142
|
+
The default routing strategy (`Spree::OrderRouting::Strategy::Rules`) packs the same set of stock locations as before, but the **order** in which locations are tried is now determined by the routing rules — Preferred Location → Minimize Splits → Default Location — rather than by raw database row order. The unit distribution (Prioritizer + Adjuster) is unchanged: top-ranked location's packages get first pick of on-hand inventory, the rest spills over.
|
|
143
|
+
|
|
144
|
+
For most stores this is invisible: when one location can fulfill the entire cart, that location now wins consistently (instead of depending on database iteration order). When the cart needs to split across locations, the same multi-location split happens — just with the location order driven by rules.
|
|
145
|
+
|
|
146
|
+
If you rely on the legacy "every location packed in iteration order, no rule consulted" behavior, see [Opt out of rules-based Order Routing](#optional-opt-out-of-rules-based-order-routing) above.
|