@spree/docs 0.1.47 → 0.1.49
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/developer/core-concepts/media.md +64 -6
- package/dist/developer/core-concepts/shipments.md +100 -13
- 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 +65 -0
- package/package.json +1 -1
|
@@ -12,17 +12,74 @@ Spree handles uploads, processing, and delivery for product media. Images are au
|
|
|
12
12
|
|
|
13
13
|
## Product Media
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
A media record carries:
|
|
16
16
|
|
|
17
|
-
- **
|
|
18
|
-
- **Media
|
|
17
|
+
- **Position** for ordering within the gallery
|
|
18
|
+
- **Media type** — `image`, `video`, or `external_video` (defaults to `image`)
|
|
19
19
|
- **Alt text** for accessibility and SEO
|
|
20
20
|
- **Focal point** coordinates for smart cropping
|
|
21
21
|
- **Preprocessed named variants** for fast delivery
|
|
22
|
+
- **`variant_ids`** — which product variants the media represents. An empty array means it represents the product as a whole.
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
### Product-level Gallery
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
In Spree 5.5 the **product** is the default owner of media. Before 5.5, every image was pinned to a specific variant (usually the master), and sharing the same image across variants meant re-uploading the file. From 5.5 onward, an image lives on the product, and any subset of variants can reference it through `variant_ids` — without duplicating the underlying file.
|
|
27
|
+
|
|
28
|
+
#### Uploading a product-level image
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
```typescript Admin SDK
|
|
32
|
+
import { createAdminClient } from '@spree/admin-sdk'
|
|
33
|
+
|
|
34
|
+
const client = createAdminClient({ baseUrl, secretKey })
|
|
35
|
+
|
|
36
|
+
// `signed_id` comes from a direct upload; see the Active Storage docs for
|
|
37
|
+
// generating one client-side.
|
|
38
|
+
const media = await client.products.media.create('prod_86Rf07xd4z', {
|
|
39
|
+
signed_id: signedBlobId,
|
|
40
|
+
alt: 'Front view',
|
|
41
|
+
position: 1,
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```bash cURL
|
|
46
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/products/prod_86Rf07xd4z/media' \
|
|
47
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
48
|
+
-H 'Content-Type: application/json' \
|
|
49
|
+
-d '{
|
|
50
|
+
"signed_id": "<signed-blob-id>",
|
|
51
|
+
"alt": "Front view",
|
|
52
|
+
"position": 1
|
|
53
|
+
}'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
#### Sharing a single image across variants
|
|
58
|
+
|
|
59
|
+
Pass a `variant_ids` array on the same media endpoint to link/unlink variants. The server replaces the asset's link set on every call — empty array clears all links, omitting the field leaves them untouched.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
```typescript Admin SDK
|
|
63
|
+
await client.products.media.update('prod_86Rf07xd4z', 'media_k5nR8xLq', {
|
|
64
|
+
variant_ids: ['variant_redM', 'variant_redL'],
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```bash cURL
|
|
69
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/admin/products/prod_86Rf07xd4z/media/media_k5nR8xLq' \
|
|
70
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
71
|
+
-H 'Content-Type: application/json' \
|
|
72
|
+
-d '{ "variant_ids": ["variant_redM", "variant_redL"] }'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
Variants belonging to a different product are silently dropped — the API rejects cross-product tampering at the model layer. Reordering happens once on the product gallery; every linked variant inherits the new order.
|
|
77
|
+
|
|
78
|
+
#### Storefront gallery resolution
|
|
79
|
+
|
|
80
|
+
The Store API's `media` field on a product returns its gallery — product-level media when present, falling back to legacy variant-pinned images during the transition. On a variant, `media` returns the assets linked to that variant via `variant_ids`, falling back to direct variant uploads.
|
|
81
|
+
|
|
82
|
+
This dual rendering means existing storefronts keep working during the upgrade; new uploads attach to the product, and you opt into a [one-shot migration](../upgrades/5.4-to-5.5.md) to re-home legacy variant-pinned data when convenient.
|
|
26
83
|
|
|
27
84
|
### Named Variant Sizes
|
|
28
85
|
|
|
@@ -134,8 +191,9 @@ curl 'https://api.mystore.com/api/v3/store/products/spree-tote?expand=media,vari
|
|
|
134
191
|
| `focal_point_x` | number \| null | Horizontal focal point (0.0–1.0) |
|
|
135
192
|
| `focal_point_y` | number \| null | Vertical focal point (0.0–1.0) |
|
|
136
193
|
| `external_video_url` | string \| null | External video URL (YouTube/Vimeo) |
|
|
137
|
-
| `original_url` | string \| null | Full-size image URL |
|
|
194
|
+
| `original_url` | string \| null | Full-size image URL (inline disposition) |
|
|
138
195
|
| `mini_url` ... `xlarge_url` | string \| null | Named variant URLs |
|
|
196
|
+
| `download_url` | string \| null | Same blob as `original_url` but with `Content-Disposition: attachment`. Admin API only. |
|
|
139
197
|
|
|
140
198
|
## Image Processing
|
|
141
199
|
|
|
@@ -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.
|
|
@@ -156,31 +158,115 @@ Each shipping method uses a [Calculator](calculators.md) to determine the cost.
|
|
|
156
158
|
|
|
157
159
|
You can create custom calculators for more complex pricing. See the [Calculators guide](calculators.md).
|
|
158
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
|
+
|
|
159
208
|
## Split Shipments
|
|
160
209
|
|
|
161
|
-
|
|
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.
|
|
162
218
|
|
|
163
219
|
```mermaid
|
|
164
220
|
flowchart TB
|
|
165
|
-
A[Order with 3 items] -->
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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"]
|
|
170
229
|
```
|
|
171
230
|
|
|
172
231
|
### How Splitting Works
|
|
173
232
|
|
|
174
|
-
1.
|
|
175
|
-
2.
|
|
176
|
-
3.
|
|
177
|
-
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
|
+
```
|
|
178
264
|
|
|
179
|
-
|
|
265
|
+
The order matters — each splitter's output is the next one's input.
|
|
180
266
|
|
|
181
|
-
|
|
267
|
+
### Extending Splitters
|
|
182
268
|
|
|
183
|
-
|
|
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.
|
|
184
270
|
|
|
185
271
|
## Examples
|
|
186
272
|
|
|
@@ -214,4 +300,5 @@ A store shipping from 2 locations (New York, Los Angeles) with 3 carriers and 3
|
|
|
214
300
|
- [Inventory](inventory.md) — Stock locations and inventory management
|
|
215
301
|
- [Calculators](calculators.md) — Shipping rate calculators
|
|
216
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
|
|
217
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,49 @@ 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
|
+
### Migrate legacy variant-pinned media
|
|
23
|
+
|
|
24
|
+
In 5.5 the [product is the default owner of media](../core-concepts/media.md#product-level-gallery). Existing variant-pinned images keep rendering, but new admin uploads attach to the product. To consolidate both into a single gallery, run:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bin/rails spree:media:migrate_master_images_to_product_media
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The task enqueues one `Spree::Media::MigrateProductAssetsJob` per product onto the `images` queue — make sure your job runner is processing that queue. Each job is idempotent, so re-running the task is safe; it skips products that no longer have variant-pinned assets.
|
|
31
|
+
|
|
32
|
+
For larger catalogs, tune the batching with `BATCH_SIZE`:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bin/rails spree:media:migrate_master_images_to_product_media BATCH_SIZE=1000
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> **WARNING:** Run the task locally and on production. It does not block storefront rendering — new uploads attach to the product immediately — but until the enqueued jobs finish, old assets remain pinned to variants.
|
|
39
|
+
|
|
40
|
+
### Run the channels upgrade
|
|
41
|
+
|
|
42
|
+
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:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bin/rake spree:channels:upgrade
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
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:
|
|
49
|
+
|
|
50
|
+
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).
|
|
51
|
+
|
|
52
|
+
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`.
|
|
53
|
+
|
|
54
|
+
You can also run the two steps individually if you'd rather pace the upgrade:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bin/rake spree:channels:create_defaults
|
|
58
|
+
bin/rake spree:channels:backfill_order_channel_ids
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
22
65
|
### Schedule the Stock Reservations expiry job
|
|
23
66
|
|
|
24
67
|
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 +129,20 @@ Spree::Config[:stock_reservations_enabled] = false
|
|
|
86
129
|
|
|
87
130
|
The Quantifier short-circuits before any reservation query when this is `false`, so there's no runtime cost and no table growth.
|
|
88
131
|
|
|
132
|
+
### (Optional) Opt out of rules-based Order Routing
|
|
133
|
+
|
|
134
|
+
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.
|
|
135
|
+
|
|
136
|
+
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`:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
store.update!(preferred_order_routing_strategy: 'Spree::OrderRouting::Strategy::Legacy')
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
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.
|
|
143
|
+
|
|
144
|
+
> **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.
|
|
145
|
+
|
|
89
146
|
## Behavior changes worth knowing
|
|
90
147
|
|
|
91
148
|
### Cart changes during checkout can now fail with insufficient stock
|
|
@@ -97,3 +154,11 @@ Storefronts and custom integrations that act on the cart should expect this new
|
|
|
97
154
|
### Storefront availability drops faster under contention
|
|
98
155
|
|
|
99
156
|
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.
|
|
157
|
+
|
|
158
|
+
### Order Routing chooses location order via merchant rules instead of database order
|
|
159
|
+
|
|
160
|
+
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.
|
|
161
|
+
|
|
162
|
+
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.
|
|
163
|
+
|
|
164
|
+
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.
|