docs/plans/6.0-order-routing.md
Status: Phase 1 shipped in 5.5 (Channel + OrderRoutingRule + strategy base/Rules/Reducer/Legacy); Phase 2+ pending in 6.0
Target: Spree 5.5 (Phase 1 ✓) → 6.0 (Phase 2+)
Depends on: none for Phase 1; 6.0 phases extend 6.0-channels-catalogs-b2b.md, 6.0-stock-reservations.md, 6.0-typed-stock-movements.md, 6.0-cart-order-split.md
Author: Damian + Claude
Last updated: 2026-05-20
Replace the implicit, hard-to-extend stock-location selection that lives inside Spree::Stock::Coordinator + Prioritizer with a layered, two-tier extension model:
Spree::OrderRouting::Strategy::Base — a four-method strategy interface (for_allocation, for_sale, for_release, for_cancellation). Strategies are whole algorithms — rules-walking, OMS delegation, ML, optimization. Swappable per-Store today, per-Channel once 6.0-channels-catalogs-b2b.md lands.Spree::OrderRouting::Strategy::Rules — the only concrete strategy in core. Walks Spree::OrderRoutingRule rows in priority order, applies a Shopify-style reducer, returns Spree::Stock::Package[].Spree::OrderRoutingRule STI base + subclass per rule kind — rules are signals the rules engine considers. Plugins extend by defining a new STI subclass (class AcmeFresh::Routing::RefrigeratedRule < Spree::OrderRoutingRule) and registering it via Spree.order_routing.rules. The persisted type column drives runtime polymorphism; the registry is the allowlist for admin pickers + type validation. Mirrors Spree::PromotionRule/Spree::PromotionAction (STI and registered via config.spree.promotions.rules).The two extension points address fundamentally different needs:
| Need | Mechanism | How often used |
|---|---|---|
| New algorithm (OMS, ML, solver, batch routing) | Custom OrderRouting::Strategy::Base subclass | Rare — handful of plugins ever |
| New signal (metafield, customer attribute, day-of-week) | Custom STI subclass of Spree::OrderRoutingRule | Common — most extensions |
Industry parity: Vendure has the strategy contract but no admin UI. Shopify has the admin UI but gates customization behind Plus + Wasm Functions. Saleor has a two-value enum. Medusa picks ~at random. Spree gets both — a clean Ruby strategy contract and a built-in admin-configurable rules engine extended via Spree's standard STI pattern.
Today Spree::Stock::Coordinator (spree/core/app/models/spree/stock/coordinator.rb) loops through every active StockLocation that stocks the cart's variants, packs each one, and runs them through Spree::Stock::Prioritizer — whose Adjuster distributes units across packages in raw database iteration order, with sort_packages left as an empty TODO. There's no place to plug in "prefer the Brooklyn store" or "minimize split shipments" — customizers monkey-patch the coordinator. The packing pipeline itself is solid; what's missing is the merchant-controllable ordering layer in front of it.
Stores with multiple StockLocations have no admin lever. The system picks via insertion order + the default flag. Shopify has had configurable rules for years; Spree has nothing.
Real merchants want routing to consider: shipping address proximity, market scoping, customer-group preference, whether splitting hurts margin, B2B Company default location, admin-set "fulfill from this store" hint, custom metafield values. None of these are inputs to today's selection.
5.5 Spree::StockReservation (already shipped) holds units against a stock_item per cart, but Spree::StockReservations::Reserve#select_stock_item picks the location via a detect over variant.stock_items — first active match. That's effectively random; it has no awareness of merchant routing preferences and is independent of whatever location ends up fulfilling the order. 6.0-typed-stock-movements.md will introduce typed allocated/shipped/released movements that need the same per-location decision. Both want a single canonical owner of the location decision: that's routing's job — but routing doesn't exist as a concept yet.
| Concern | How it works today (pre-5.5) | Gap |
|---|---|---|
| Location selection | Coordinator packs every active location stocking the cart; Prioritizer.Adjuster distributes units across packages in iteration order | No merchant control over location order |
| Splits | Splitter chains run per-location; multi-location splits emerge naturally from Prioritizer's first-package-wins-on-hand walk | Cannot influence whether to split across locations |
| Address proximity | Not considered | Storefront UX worse than competitors |
| Market scoping | Not considered | Multi-market merchants can't keep orders in-market |
| Admin override | None — must monkey-patch | No "fulfill from this store" affordance |
| Per-channel routing | None — Order.channel is a free-text string | POS vs online vs wholesale all use same logic |
Base declares four NotImplementedError methods: for_allocation(order:), for_sale(fulfillment:), for_release(order:), for_cancellation(order:). No defaults. No hooks. Subclasses implement all four.
This is Vendure's choice and it's the right one — partial overriding leads to "did I override for_release? Let me grep the parent" confusion. A custom strategy is a complete, self-contained algorithm.
The base class lives in Spree::OrderRouting::Strategy::Base and Rules does not extend Base with rule-specific helpers — it's a peer that happens to be the default. Strategies do not share infrastructure.
Rules is the only concrete strategy in coreCore ships exactly one strategy: Spree::OrderRouting::Strategy::Rules. It walks Spree::OrderRoutingRule rows. Everything else is a downstream plugin.
There is no Default strategy class. The default strategy IS Rules; Rules is configured to behave like today by seeding three default rules (preferred_location → minimize_splits → default_location). Stores upgrading from earlier Spree versions get those rules automatically via a data migration.
Spree::OrderRoutingRuleSpree::OrderRoutingRule is the STI base. It declares #rank(order, locations) → Array<LocationRanking>. Each rule kind is an STI subclass:
class Spree::OrderRouting::Rules::PreferredLocation < Spree::OrderRoutingRule; end
class Spree::OrderRouting::Rules::MinimizeSplits < Spree::OrderRoutingRule; end
class Spree::OrderRouting::Rules::DefaultLocation < Spree::OrderRoutingRule; end
Persistence uses Rails STI: the type column (e.g. 'Spree::OrderRouting::Rules::PreferredLocation') drives instance class on read; instantiating the subclass writes the right type.
Plugins extend by defining a new subclass and registering it via Spree.order_routing.rules:
# In a plugin
module AcmeFresh
module Routing
class RefrigeratedRule < Spree::OrderRoutingRule
def rank(order, locations)
# ... return Array<Spree::OrderRoutingRule::LocationRanking>
end
end
end
end
Once defined, autoloaded, and registered, the subclass behaves like any other rule kind: persists via STI, appears in the admin UI, feeds the reducer.
Custom rules are the dominant extension path. Custom strategies are for unusual algorithms (OMS, ML, optimization solvers); custom rules are for new signals within rules-walking.
This pattern mirrors Spree::PromotionRule, Spree::PromotionAction, Spree::Calculator, and Spree::PaymentMethod — established Spree patterns that pair STI with a config.spree.* registry. Register rule kinds in config.after_initialize (or a plugin initializer) so they're available after autoload, the same way core registers the three built-ins.
Each rule produces a LocationRanking[] — one entry per eligible location, with rank integer where lower is better, or nil if the rule abstains for that location.
The reducer walks rules in position order:
rank is nil (rule abstains).StockLocation.default, then by id.This matches Shopify's mental model. Weighted-sum scoring was considered and rejected — it makes individual rules' effects unpredictable for merchants tweaking config.
# Spree::Store
preference :order_routing_strategy, :string, default: 'Spree::OrderRouting::Strategy::Rules'
Resolved on Order via:
class Spree::Order < Spree.base_class
def order_routing_strategy
klass_name = channel&.preferred_order_routing_strategy.presence ||
store.preferred_order_routing_strategy
klass_name.constantize.new(order: self)
end
end
channel&.preferred_order_routing_strategy.presence || is dead code in 5.5 (Order has no channel yet) but the resolver is shaped correctly so 6.0 channels integration is purely additive.
Spree::OrderRoutingRule Phase 1 schema: store_id (NOT NULL), channel_id (NOT NULL), type (STI), position, active, preferences (Spree::Preferences serialized text — same pattern as Spree::PromotionRule/Spree::Calculator).
Every rule is tied to a Channel. The Order#ensure_channel_presence before_validation guarantees every Order has a Channel set (defaulting to store.default_channel), so the strategy resolver is a single-axis lookup: order.channel.order_routing_rules.active.ordered. There is no "store-wide rules with channel fallback" model — Channels are first-class and seed their own defaults.
store_id is denormalized for fast filtering and acts_as_list scope: :channel_id; the validator enforces channel.store_id == store_id.
Per-rule config (e.g. RefrigeratedRule's temperature threshold) lives on the subclass via preference :max_temp_c, :integer, default: 4. No separate config JSON column — Spree::Preferences provides typed defaults, validation, and admin UI integration out of the box.
Order.preferred_stock_location_id is one rule's inputOrder.preferred_stock_location_id is a real FK column. The preferred_location rule reads it. No bias logic in Base, no special handling outside that one rule.
The cascade method on Order:
def inferred_preferred_stock_location_id
preferred_stock_location_id.presence ||
company_location&.preferred_stock_location_id ||
created_by&.try(:preferred_stock_location_id) ||
channel&.default_stock_location_id
end
Phase 1 only Order.preferred_stock_location_id and the cascade reading from created_by&.preferred_stock_location_id (if AdminUser ships with the column). Phase 2 adds Channel.default_stock_location_id and CompanyLocation.preferred_stock_location_id at the same time as those plans land.
Phase 1 for_allocation returns Package[] exactly as Coordinator#packages does today. The packages become Shipments. 5.5 reservations operate independently of routing — Spree::StockReservations::Reserve#select_stock_item picks the first active stock_item for a variant, which can disagree with what the routing strategy chooses for the same order. The Quantifier is per-variant-across-locations so global availability arithmetic is correct, but stock is conceptually double-locked at the chosen-but-not-reserved location for the TTL window. See the Stock reservations and routing section in the dev guide for the user-facing version.
Phase 3 makes routing the canonical owner of the location decision. The handoff:
Reserve stops picking a location. Cart-time reservations are held against the variant total, not a specific stock_item. Schema change: spree_stock_reservations.stock_item_id becomes nullable; a non-null stock_item_id means "pinned by routing." Quantifier#reserved_quantity shifts from SUM(quantity) WHERE stock_item_id IN (...) to SUM(quantity) WHERE variant_id = ? (with stock_item_id IS NULL rows counting against any of the variant's locations).Strategy#for_allocation pins reservations. When the strategy chooses location L for variant V's units, it UPDATEs the unpinned reservation rows for that (order, variant) pair to set stock_item_id to L's stock_item. This becomes the moment "we reserve units at this specific location."for_sale(fulfillment:) writes typed shipped movements. Pinned reservation row stays alive until shipment ships; on after_ship, the strategy writes a typed shipped movement that decrements count_on_hand at the pinned location, then deletes the reservation.for_release writes typed released movements. When a checkout reverts to cart or the reservation expires, pinned rows are released — typed released movement is conceptually a no-op on stock today, but lets us audit the lifecycle.for_cancellation writes typed received movements. Restock at the location the order shipped from.Strategy contract doesn't change between Phase 1 and Phase 3 — implementations grow. Plugin-authored strategies that worked in 5.5 keep working in 6.0, with the addition that custom strategies are now expected to coordinate with the reservation system per the contract above.
Open questions for Phase 3 (parked until 6.0-stock-reservations.md and 6.0-typed-stock-movements.md are scheduled):
for_allocation call already runs inside the cart-state transition's transaction; pinning reservations needs to happen in the same transaction (or a nested savepoint) so a packer failure rolls back the pin.Reserve invariant validates :line_item_id, uniqueness: { scope: :stock_item_id }. Splitting a single line item across two stock_items requires either a different uniqueness key (e.g. [line_item_id, stock_item_id] already permits multiple stock_items per line_item — verify) or splitting the reservation by per-location quantity.A strategy returning two Packages for a four-line cart produces two Shipments. Phase 1's seeded rules include minimize_splits near the top — without merchant action, single-shipment is preferred. Merchants who want aggressive splitting (faster delivery) demote it.
If for_allocation returns an empty Package[] (no location stocks the cart), today's behavior happens — Order#create_proposed_shipments results in no shipments, downstream validation fails. Phase 4 (with 6.0-fulfillment-and-delivery.md) adds fulfillment_status: 'on_hold' for graceful handling.
Spree::OrderRouting::Strategy::Basemodule Spree
module OrderRouting
module Strategy
class Base
attr_reader :order
def initialize(order:)
@order = order
end
# Decide which locations fulfill which items at order placement.
# Returns Spree::Stock::Package[] — one per chosen location.
# Each package owns a subset of the order's inventory units.
def for_allocation
raise NotImplementedError
end
# Convert allocation -> shipped at the location bound to this fulfillment.
# Default implementations should delegate to StockLocation.unstock.
def for_sale(fulfillment:)
raise NotImplementedError
end
# Release allocations when an order/draft is canceled before shipment.
def for_release
raise NotImplementedError
end
# Restock when a fulfilled order is canceled (return / RA).
def for_cancellation
raise NotImplementedError
end
end
end
end
end
Spree::OrderRouting::Strategy::Rulesmodule Spree
module OrderRouting
module Strategy
class Rules < Base
# Sketch — the actual implementation lives in
# spree/core/app/models/spree/order_routing/strategy/rules.rb.
def for_allocation
locations = eligible_locations
return [] if locations.empty?
# Rank ALL eligible locations best-first via the reducer, then
# let Prioritizer's Adjuster distribute units across packages —
# units the top-ranked location can't cover spill into the
# next-ranked location's packages. Preserves pre-5.5 Coordinator
# multi-location split behavior with rule-driven location order.
ordered = Reducer.new(applicable_rules.to_a, order: order).rank_all(locations)
return [] if ordered.empty?
packages = build_packages(ordered) # one Packer per location
packages = Spree::Stock::Prioritizer.new(packages).prioritized_packages
estimate_rates(packages)
end
# Stock decrement / restock today happens via Spree::Shipment's
# state machine. The hooks below are part of the contract for the
# Phase 3 reservation + typed-movement work; in 5.5 they are no-ops.
def for_sale(fulfillment:); end
def for_release; end
def for_cancellation; end
private
def applicable_rules
order.channel.order_routing_rules.active.ordered
end
end
end
end
end
Spree::OrderRoutingRule (STI base)class Spree::OrderRoutingRule < Spree.base_class
# Lower rank wins; nil rank = abstain.
LocationRanking = Struct.new(:location, :rank, keyword_init: true)
has_prefix_id :rrule
belongs_to :store, class_name: 'Spree::Store'
attribute :active, :boolean, default: true
# Subclasses declare typed preferences via `preference :foo, :string`
# (Spree::Preferences serialized into the `preferences` text column).
validates :type, presence: true
validates :position, presence: true, numericality: { only_integer: true }
scope :active, -> { where(active: true) }
scope :ordered, -> { order(:position) }
scope :for_channel, ->(channel) { where(channel_id: channel.id) }
acts_as_list scope: :channel_id
# Subclasses override.
# @param order [Spree::Order]
# @param locations [Array<Spree::StockLocation>]
# @return [Array<LocationRanking>]
def rank(_order, _locations)
raise NotImplementedError, "#{self.class} must implement #rank(order, locations)"
end
end
module Spree
module OrderRouting
module Rules
# Reads order.inferred_preferred_stock_location_id.
# Rank 0 if found, nil otherwise (abstain).
class PreferredLocation < Spree::OrderRoutingRule
def rank(order, locations)
preferred = order.inferred_preferred_stock_location_id
locations.map do |loc|
LocationRanking.new(location: loc, rank: loc.id == preferred ? 0 : nil)
end
end
end
# Higher coverage = lower rank.
# Coverage = number of line items this location can fulfill alone.
class MinimizeSplits < Spree::OrderRoutingRule
def rank(order, locations)
# ... (batched stock_item lookup omitted for brevity)
end
end
# Mirrors today's behavior: StockLocation.default first.
# Provides a baseline so the reducer always has a winner.
class DefaultLocation < Spree::OrderRoutingRule
def rank(_order, locations)
locations.map do |loc|
LocationRanking.new(location: loc, rank: loc.default? ? 0 : 1)
end
end
end
end
end
end
module Spree
module OrderRouting
module Strategy
class Reducer
def initialize(rules, order:)
@rules, @order = rules, order
end
# Pick the single best location.
# @param locations [Array<Spree::StockLocation>]
# @return [Spree::StockLocation, nil]
def pick(locations)
remaining = locations
@rules.each do |rule|
rankings = rule.rank(@order, remaining).reject { |r| r.rank.nil? }
next if rankings.empty?
min_rank = rankings.map(&:rank).min
top = rankings.select { |r| r.rank == min_rank }.map(&:location)
return top.first if top.size == 1
remaining = top # carry tied set forward
end
# Out of rules with ties: prefer default location, else first by id.
remaining.sort_by { |l| [l.default? ? 0 : 1, l.id] }.first
end
# Rank ALL locations best-first. Used by Strategy::Rules for
# multi-location split fallback: when the top location can't cover
# the cart, the next-ranked one picks up the slack.
# @param locations [Array<Spree::StockLocation>]
# @return [Array<Spree::StockLocation>]
def rank_all(locations)
remaining = locations.dup
ordered = []
until remaining.empty?
chosen = pick(remaining) or break
ordered << chosen
remaining = remaining.reject { |l| l.id == chosen.id }
end
ordered
end
end
end
end
end
Strategy::Rules)The Rules strategy embeds the packing pipeline directly — there is no separate PackageBuilder class. The flow is:
# Inside Strategy::Rules
def build_packages(locations)
locations.flat_map do |location|
Spree::Stock::Packer.new(location, inventory_units, Spree.stock_splitters).packages
end
end
def inventory_units
@inventory_units ||= Spree::Stock::InventoryUnitBuilder.new(order).units
end
def estimate_rates(packages)
estimator = Spree::Stock::Estimator.new(order)
packages.each { |pkg| pkg.shipping_rates = estimator.shipping_rates(pkg) }
packages
end
The full sequence in for_allocation:
eligible_locations — fetch active locations stocking any cart variant.Reducer#rank_all — full ranking, best-first.build_packages — one Packer per location; each emits one or more packages depending on the configured splitters.Spree::Stock::Prioritizer#prioritized_packages — Adjuster walks packages in rank order, assigns each unit to the first package with on-hand stock, prunes empty packages.estimate_rates — Estimator attaches shipping rates to each surviving package.This reuses the legacy Coordinator's packing machinery wholesale; the only thing routing changes is the order in which locations are presented to the Prioritizer.
Spree::OrderRouting::Strategy::Legacy (5.x escape hatch)Thin wrapper around Spree::Stock::Coordinator for merchants who heavily customized Coordinator/Packer/Prioritizer in 5.4 and aren't ready to adopt rules-based routing. Drops in 6.0 along with Coordinator.
module Spree
module OrderRouting
module Strategy
class Legacy < Base
def for_allocation
Spree::Stock::Coordinator.new(order).packages
end
def for_sale(fulfillment:); end
def for_release; end
def for_cancellation; end
end
end
end
end
Opt-in via store.update!(preferred_order_routing_strategy: 'Spree::OrderRouting::Strategy::Legacy'). Documented in docs/developer/upgrades/5.4-to-5.5.mdx.
Order.preferred_stock_location_idclass Spree::Order < Spree.base_class
belongs_to :preferred_stock_location, class_name: 'Spree::StockLocation', optional: true
def inferred_preferred_stock_location_id
preferred_stock_location_id.presence ||
company_location&.preferred_stock_location_id ||
created_by&.try(:preferred_stock_location_id) ||
channel&.try(:default_stock_location_id)
end
def order_routing_strategy
klass_name = channel&.try(:preferred_order_routing_strategy).presence ||
store.preferred_order_routing_strategy
klass_name.constantize.new(order: self)
end
end
| File | Before | After |
|---|---|---|
Order#create_proposed_shipments (order.rb:752) | Spree::Stock::Coordinator.new(self).shipments | order_routing_strategy.for_allocation.map { |pkg| pkg.to_shipment.tap { ... } } |
Exchange#shipments (exchange.rb:22) | Spree::Stock::Coordinator.new(@order, units).shipments | unchanged for now — Exchange takes custom inventory units, defer integration to Phase 2 |
Cart::EstimateShippingRates (deprecated) | unchanged | unchanged — deprecated path, do not touch |
spree_order_routing_rulesclass CreateSpreeOrderRoutingRules < ActiveRecord::Migration[7.2]
def change
create_table :spree_order_routing_rules do |t|
t.references :store, null: false
t.references :channel, null: false
t.string :type, null: false # STI discriminator
t.integer :position, null: false
t.boolean :active, null: false
t.text :preferences # Spree::Preferences serialized hash (same as PromotionRule)
t.timestamps
end
add_index :spree_order_routing_rules, [:channel_id, :position]
add_index :spree_order_routing_rules, [:channel_id, :active, :position],
name: 'idx_order_routing_rules_lookup'
add_index :spree_order_routing_rules, :type
end
end
Order.preferred_stock_location_idclass AddPreferredStockLocationToSpreeOrders < ActiveRecord::Migration[7.2]
def change
add_reference :spree_orders, :preferred_stock_location
end
end
class SeedDefaultChannelAndRulesForStores < ActiveRecord::Migration[7.2]
def up
Spree::Store.find_each do |store|
channel = store.channels.find_by(code: 'online') ||
store.channels.create!(name: 'Online Store', code: 'online')
next if channel.order_routing_rules.any?
Spree::OrderRouting::Rules::PreferredLocation.create!(store: store, channel: channel, position: 1)
Spree::OrderRouting::Rules::MinimizeSplits.create!(store: store, channel: channel, position: 2)
Spree::OrderRouting::Rules::DefaultLocation.create!(store: store, channel: channel, position: 3)
end
end
def down
# No-op; rules + channels tables drop in their own down migrations.
end
end
Store seeds a default Channel; Channel seeds its own rules. Both are idempotent and after_create-based.
class Spree::Store < Spree.base_class
after_create :ensure_default_channel
private
def ensure_default_channel
return if channels.any?
channels.create!(name: 'Online Store', code: Spree::Channel::DEFAULT_CODE)
end
end
class Spree::Channel < Spree.base_class
after_create :ensure_default_order_routing_rules
private
def ensure_default_order_routing_rules
return if order_routing_rules.any?
Spree::OrderRouting::Rules::PreferredLocation.create!(store: store, channel: self, position: 1)
Spree::OrderRouting::Rules::MinimizeSplits.create!(store: store, channel: self, position: 2)
Spree::OrderRouting::Rules::DefaultLocation.create!(store: store, channel: self, position: 3)
end
end
Phase 1 ships no admin UI for routing rules. The 5.5 default seed (3 rules per store) covers the customer's wedge case. Admin API + SPA settings page land in Phase 2 when channels are present and merchants have a real reason to tune per-channel routing.
Phase 1 admin API change is small:
PATCH /api/v3/admin/orders/:id { preferred_stock_location_id: "sloc_..." }
— exposing the new column on the existing Order admin endpoint.
spree_channels table.Order.channel (deprecated string) → Order.channel_id FK.spree_order_routing_rules table (type STI discriminator, channel_id NOT NULL).Order.preferred_stock_location_id FK.Spree::Channel — minimal (store, name, code, active, preferences, metafields). No catalogs, no listings — those land in 6.0.Spree::OrderRoutingRule STI base + LocationRanking struct + for_channel scope + channel_belongs_to_store validator.OrderRouting::Strategy::Base, OrderRouting::Strategy::Rules, OrderRouting::Strategy::Legacy (escape hatch), Reducer (with both pick and rank_all).Spree::OrderRouting::Rules::PreferredLocation, MinimizeSplits, DefaultLocation.Store#preferred_order_routing_strategy (default), Channel#preferred_order_routing_strategy (override).preferred_stock_location association, channel association (auto-defaults to store.default_channel), inferred_preferred_stock_location_id, order_routing_strategy (channel pref → store pref fallback).Strategy::Rules#applicable_rules walks order.channel.order_routing_rules.active.ordered — single-axis lookup, every rule belongs to a channel.Order#create_proposed_shipments Coordinator call.ensure_default_channel creates one 'online' channel.ensure_default_order_routing_rules creates the 3 default rules tied to the channel.preferred_stock_location_id on Order.Order#create_proposed_shipments honoring preferred_stock_location_id + channel-scoped rules and strategy override.spec/models/spree/order_routing/strategy/parity_spec.rb runs shared examples against both Strategy::Legacy and Strategy::Rules to lock in no-regression invariants vs. the pre-5.5 Coordinator (single-location coverage, multi-location splits at variant or quantity granularity, backorder behavior, track_inventory: false, stock reservation interactions).6.0-channels-catalogs-b2b.mdChannel already exists from Phase 1; Phase 2 layers on top:
Channel.default_stock_location_id FK.Channel.preferred_stock_location_id (or similar) for B2B / staff-default cascade input.Channel.default_catalog_id FK (added when Catalog ships).CompanyLocation.preferred_stock_location_id FK.Spree::Admin::User.preferred_stock_location_id FK (staff sign-in default).closest_location, stay_in_market, ranked_locations, metafield.6.0-stock-reservations.md + 6.0-typed-stock-movements.mdThe reservation/routing handoff. See "Reservation-first allocation (Phase 3)" above for the design; the work breakdown:
spree_stock_reservations.stock_item_id becomes nullable. variant_id added (NOT NULL, FK), allowing reservations to be held against a variant total before location pinning.Spree::StockReservations::Reserve no longer picks a location. select_stock_item is removed; the service writes reservations with stock_item_id: nil and variant_id: variant.id.Spree::Stock::Quantifier#reserved_quantity sums all reservations for the variant — pinned ones (subtract from the pinned stock_item.count_on_hand) and unpinned (subtract from the variant's pool, no specific location).Strategy#for_allocation pins reservations. When the chosen location is decided, the strategy UPDATEs the order's unpinned reservations for the variant to set stock_item_id. This happens in the same transaction as create_proposed_shipments.Strategy#for_sale(fulfillment:) writes a typed kind: 'shipped' Spree::StockMovement and deletes the now-redundant pinned reservation. Replaces the implicit Shipment#after_ship → StockLocation#unstock path.Strategy#for_release writes a typed kind: 'released' movement (audit trail) and deletes the unpinned reservation. Wired from cart-revert and TTL expiry paths.Strategy#for_cancellation writes a typed kind: 'received' movement at the location the order shipped from and creates a restocking entry.for_allocation (e.g. an OMS delegator) keep working — their for_sale/etc. can keep their 5.5 no-op behavior or implement the typed-movement hooks too.6.0-fulfillment-and-delivery.md + 6.0-cart-order-split.mdSpree::Carts::Complete (the cart→order conversion site post-split).Packages become Fulfillments (renamed from Shipment).for_allocation returning empty → fulfillment_status: 'on_hold'.Exchange#shipments migrates to use the strategy via custom inventory units.OrderRouting::Strategy.Stock::Coordinator or Prioritizer. They're being absorbed into OrderRouting::Strategy::Rules as implementation detail.StockLocation.default selection in new code. Take the location as an argument or call order.order_routing_strategy.for_allocation.Channel — it lives on OrderRoutingRule. Channel just provides scoping and a strategy override.StockLocation.active.first.Order.channel (string) should not — wait for the FK in 6.0-channels-catalogs-b2b.md.Spree::OrderRoutingRule directly (STI) and register the class via Spree.order_routing.rules << MyRule. STI still does the runtime dispatch via the type column — the registry is the curated allowlist of available/valid kinds (it backs admin pickers and the type inclusion validation), not a dispatch table. Don't add if rule.is_a?(Foo) branches to Rules strategy or the reducer; rule classes own their own #rank logic.Strategy vs rules. Both. Strategies are different algorithms (rules-walking, OMS, ML). Rules are different signals within the rules-walking algorithm. Custom rules are the dominant extension path; custom strategies are rare.
Rule reducer model. First non-tie wins (Shopify-style). Weighted-sum scoring rejected — too unpredictable for merchants tuning config.
Strategy registration. Two layers. Selection is a Per-Store preference (Phase 1) → per-Channel preference falling back to Store (Phase 2) — NOT Spree::Dependencies, which would bake "one strategy per app" into the API. Availability is an allowlist registry: Spree.order_routing.strategies (Spree's standard config.spree.* extension-point convention, alongside calculators, stock_splitters, payment_methods). Core registers Strategy::Rules and Strategy::Legacy; plugins register their own and may unregister (e.g. drop Legacy). Models read Spree.order_routing.strategies directly (no wrapper API), like every other config.spree.* extension point. A strategy must be both a Strategy::Base subclass and registered to be selectable — Store#preferred_order_routing_strategy / Channel#preferred_order_routing_strategy validate inclusion in the registry, and Order#order_routing_strategy only instantiates registered classes (unregistering or a stale persisted value falls back to the default Rules rather than raising). This replaces the original "validate by Strategy::Base subclass only" check and is what closes the class of bug where a non-strategy (e.g. the internal Reducer collaborator) could be selected.
Where the strategy fires. Today: Order#create_proposed_shipments. Phase 4: Spree::Carts::Complete per 6.0-cart-order-split.md.
Per-line-item override? Not in any phase. Strategy decides per-package, which is the same granularity as Shopify and Vendure. Per-line-item override is a future consideration if real demand emerges.
Plus-tier features. None. All rules ship in core OSS. The strategy class + rule registry are the extension points — paid integrations ship their own strategies / rule kinds as plugins.
Default strategy class? None. The default strategy is Rules. The default behavior comes from seeded rules. Avoids "Default vs Rules" confusion when both would coexist.
Multi-location splits in Phase 1. Required, no regressions vs. pre-5.5 Coordinator. Approach: the reducer ranks all eligible locations (not just picks one), each is packed independently, then Spree::Stock::Prioritizer walks the packages in rank order and assigns each unit to the first package with on-hand stock — units the top-ranked location can't cover spill into the next-ranked location's packages. This is the same Adjuster behavior the legacy Coordinator relied on; routing now provides the location ordering instead of the database iteration order.
Legacy strategy escape hatch in 5.x. Ship Spree::OrderRouting::Strategy::Legacy (a thin wrapper around Spree::Stock::Coordinator) so merchants who heavily customized Coordinator/Packer/Prioritizer in 5.4 can opt out of rules-based routing during the upgrade window via store.update!(preferred_order_routing_strategy: 'Spree::OrderRouting::Strategy::Legacy'). Documented in the 5.4-to-5.5 upgrade guide. Drops in 6.0 along with Coordinator.
Geocoder gem in core OSS. closest_location rule (Phase 2) needs distance calculation. Lean toward bundling geocoder gem with lazy lat/lng on StockLocation. Defer decision to Phase 2.
Rule conflict UX. When rules disagree (preferred vs in-market), the merchant might be surprised by the priority order's effect. Defer to Phase 2 admin UI design — likely a "rule preview" with sample addresses.
Custom rules in admin UI. Plugin-registered kinds need to render in the admin SPA. Likely a config_schema method on rule classes that returns a JSON schema for the rule's config field. Defer to Phase 2.
Reservation-aware eligibility. Phase 1 routing reads raw count_on_hand from Spree::StockItem — neither OrderRouting::Strategy::Rules#eligible_locations (existence check) nor Rules::MinimizeSplits (coverage check) subtract active Spree::StockReservation quantities. Reservations still protect at the Quantifier layer (can_supply? gates entry) and at order completion (AvailabilityValidator), so a routing decision against reservation-held stock fails closed rather than oversells. Going through Quantifier per location × variant adds a SUM query per cell, which is too costly for the eligibility hot path. Revisit when 6.0-typed-stock-movements.md lands the allocated_count column on Spree::StockItem — that lets available_count become a single SQL expression and routing can join it directly without per-pair queries.
spree/core/app/models/spree/stock/coordinator.rbsort_packages): spree/core/app/models/spree/stock/prioritizer.rbdocs/plans/6.0-tax-provider.md, docs/plans/6.0-delivery-rate-provider.mddocs/plans/6.0-stock-reservations.md, docs/plans/6.0-typed-stock-movements.mddocs/plans/6.0-channels-catalogs-b2b.mdfor_allocation: docs/plans/6.0-cart-order-split.md