Skip to content
Draft
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b67737d
Make SimpleCoordinator inventory_units keyword arg
sofiabesenski4 May 14, 2026
8b98fa7
Port SimpleCoordinator code into new Coordinator
sofiabesenski4 May 14, 2026
dbb8066
Update to use the @ instance variable syntax
sofiabesenski4 May 14, 2026
b762d99
Introduce InventoryUnit Stock middleware
sofiabesenski4 May 14, 2026
f861dcd
Delegate InventoryUnit grouping to middleware
sofiabesenski4 May 14, 2026
7b98c26
Delegate Stock StockLocation logic to middleware
sofiabesenski4 May 14, 2026
a4a3a6d
Delegate Desired logic to middleware
sofiabesenski4 May 14, 2026
c6f14a4
Delegate Availability logic to middleware
sofiabesenski4 May 14, 2026
2feb441
Delegate Allocate logic to middleware
sofiabesenski4 May 14, 2026
26e9893
Delegate Package logic to middleware
sofiabesenski4 May 14, 2026
ca9ae0d
Delegate Shipment logic to middleware
sofiabesenski4 May 14, 2026
cec1968
Add Runner and Context for stock coordination middleware
sofiabesenski4 May 21, 2026
a5893c3
Add Spree::Core::ClassConstantizer::List for ordered class registries
jarednorman Apr 24, 2026
898446b
Move Spree::Core::ClassConstantizer::Set to its own file
jarednorman Apr 24, 2026
c4a3a33
Add add_class_list helper to Spree::Core::EnvironmentExtension
jarednorman Apr 24, 2026
d449631
Register stock coordination middleware list in config
sofiabesenski4 May 21, 2026
bb9e0ed
Delegate middleware chaining to Runner via config
sofiabesenski4 May 21, 2026
3028892
Extract generic Spree::MiddlewareRunner
sofiabesenski4 May 21, 2026
8547546
Move desired and availability to computed methods on Context
sofiabesenski4 May 21, 2026
558d149
WIP: Maybe remove redundant assignment
sofiabesenski4 Jun 2, 2026
27c1073
WIP: Fix linting
sofiabesenski4 Jun 2, 2026
851a690
FIXUP Simplify coordinator logic
sofiabesenski4 Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion core/app/models/spree/exchange.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ def display_amount

def perform!
begin
shipments = Spree::Config.stock.coordinator_class.new(@order, @reimbursement_objects.map(&:build_exchange_inventory_unit)).shipments
shipments = Spree::Config.stock.coordinator_class.new(
@order,
inventory_units: @reimbursement_objects.map(&:build_exchange_inventory_unit)
).shipments
rescue Spree::Order::InsufficientStock
raise UnableToCreateShipments.new("Could not generate shipments for all items. Out of stock?")
end
Expand Down
4 changes: 2 additions & 2 deletions core/app/models/spree/order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -513,9 +513,9 @@ def create_proposed_shipments
end

def create_shipments_for_line_item(line_item)
units = Spree::Config.stock.inventory_unit_builder_class.new(self).missing_units_for_line_item(line_item)
inventory_units = Spree::Config.stock.inventory_unit_builder_class.new(self).missing_units_for_line_item(line_item)

Spree::Config.stock.coordinator_class.new(self, units).shipments.each do |shipment|
Spree::Config.stock.coordinator_class.new(self, inventory_units:).shipments.each do |shipment|
shipments << shipment
end
end
Expand Down
33 changes: 33 additions & 0 deletions core/app/models/spree/stock/coordinator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

module Spree
module Stock
class Coordinator
def initialize(order, inventory_units: nil)
@context = {order:, inventory_units:}
@order = order

Middleware::InventoryUnit.new.call(@context)
Middleware::InventoryUnitGroup.new.call(@context)
Middleware::StockLocation.new.call(@context)
Middleware::Desired.new.call(@context)
Middleware::Availability.new.call(@context)
end

def shipments
@shipments ||= begin
Middleware::Allocate.new.call(@context)
Middleware::Package.new.call(@context)
Middleware::Shipment.new.call(@context)

shipments = @context[:shipments]

# Make sure we don't add the proposed shipments to the order
@order.shipments = @order.shipments - shipments

shipments
end

Check failure on line 29 in core/app/models/spree/stock/coordinator.rb

View workflow job for this annotation

GitHub Actions / Check Ruby

Layout/BeginEndAlignment: `end` at 29, 23 is not aligned with `@shipments ||= begin` at 18, 8.
end
end
end
end
17 changes: 17 additions & 0 deletions core/app/models/spree/stock/middleware/allocate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module Spree
module Stock
module Middleware
class Allocate
def call(context)
allocator = Spree::Config.stock.allocator_class.new(context[:availability])
on_hand_packages, backordered_packages, leftover = allocator.allocate_inventory(context[:desired])

raise Spree::Order::InsufficientStock.new(items: leftover.quantities) unless leftover.empty?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part of the goal of middleware is to make it really easy for users to change behavior with minor config changes or overridding tiny classes.

This seems like a great candidate to be split into multiple middleware.

add_class_list :coordinator_middlewares, default: [
  ...
  "Spree::Stock::Middleware::AllocateOnHandFirst",
  "Spree::Stock::Middleware::RaiseIfLeftoverStock",
  ...
]


context[:on_hand_packages] = on_hand_packages
context[:backordered_packages] = backordered_packages
end
end
end
end
end
14 changes: 14 additions & 0 deletions core/app/models/spree/stock/middleware/availability.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module Spree
module Stock
module Middleware
class Availability

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a similar manner to thinking about just ignoring the configured classes/monkey patches and developing a new pattern users can opt into, I think the code would be easier to understand, trace and override if we just copied the stock availability logic into this class

def call(context)
context[:availability] = Spree::Stock::Availability.new(
variants: context[:desired].variants,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Piggybacking off jared's point here - I would use a load_variants middleware to set variants on the context for the next middleware to use, which hides the relationship between stock quantities and availability and provides more flex.

stock_locations: context[:stock_locations]
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely an example of how overusing this approach makes the code worse. This has seriously obfuscated the dependency chain of the availability and desired variants objects. If we have some dependent computed stuff like this, maybe we just make this methods on the context object or something? It's worth considering this further.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed! I didn't put too much thought into how things shook out entirely at this point in the draft. Will definitely revisit before opening for review.

end
end
end
end
end
11 changes: 11 additions & 0 deletions core/app/models/spree/stock/middleware/desired.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Spree
module Stock
module Middleware
class Desired
def call(context)
context[:desired] = Spree::StockQuantities.new(context[:inventory_unit_groups].transform_values(&:count))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think stock quantities is a great object to have defined on the context and be created by middleware - it's a well defined custom object, build and used by items farther down in the chain! If anything, it's value can be read, overridden and replaced by inserted middleware if a user desires

end
end
end
end
end
15 changes: 15 additions & 0 deletions core/app/models/spree/stock/middleware/inventory_unit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module Spree
module Stock
module Middleware
class InventoryUnit
def call(context)
order = context[:order]

context[:inventory_units] = context[:inventory_units] ||
Spree::Config.stock.inventory_unit_builder_class.new(order).units

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as I mentioned in a previous comment, WDYT of getting rid of this configuration altogether in favor of putting the work in this class? Reducing obfuscation, but at the cost of a potential harder migration?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's worth entertaining, at least.

end
end
end
end
end

Check failure on line 15 in core/app/models/spree/stock/middleware/inventory_unit.rb

View workflow job for this annotation

GitHub Actions / Check Ruby

Layout/TrailingEmptyLines: 1 trailing blank lines detected.
11 changes: 11 additions & 0 deletions core/app/models/spree/stock/middleware/inventory_unit_group.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Spree
module Stock
module Middleware
class InventoryUnitGroup
def call(context)
context[:inventory_unit_groups] = context[:inventory_units].group_by(&:variant)
end
end
end
end
end
41 changes: 41 additions & 0 deletions core/app/models/spree/stock/middleware/package.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module Spree
module Stock
module Middleware
class Package
def call(context)
packages = context[:stock_locations].map do |stock_location|
on_hand = context[:on_hand_packages][stock_location.id] || Spree::StockQuantities.new
backordered = context[:backordered_packages][stock_location.id] || Spree::StockQuantities.new

next if on_hand.empty? && backordered.empty?

package = Spree::Stock::Package.new(stock_location)
package.add_multiple(get_units(context, on_hand), :on_hand)
package.add_multiple(get_units(context, backordered), :backordered)

package
end.compact

context[:packages] = split_packages(packages)
end

private

def get_units(context, quantities)
quantities.flat_map do |variant, quantity|
context[:inventory_unit_groups][variant].shift(quantity)
end
end

def split_packages(initial_packages)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO splitter chain should be a separate middleware that splits packages after they are created

splitters = Spree::Config.environment.stock_splitters

initial_packages.flat_map do |initial_package|
stock_location = initial_package.stock_location
Spree::Stock::SplitterChain.new(stock_location, splitters).split([initial_package])
end
end
end
end
end
end
15 changes: 15 additions & 0 deletions core/app/models/spree/stock/middleware/shipment.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module Spree
module Stock
module Middleware
class Shipment
def call(context)
context[:shipments] = context[:packages].map do |package|
shipment = package.shipment = package.to_shipment
shipment.shipping_rates = Spree::Config.stock.estimator_class.new.shipping_rates(package)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still leaning towards inlining these classes and having users replace Spree::Stock::Middleware::BuildShipment in the middleware list if they want to change behavior

shipment
end
end
end
end
end
end
24 changes: 24 additions & 0 deletions core/app/models/spree/stock/middleware/stock_location.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module Spree
module Stock
module Middleware
class StockLocation
def call(context)
filtered_stock_locations = Spree::Config.stock.location_filter_class.new(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think one of the valuable ways to use middleware is to allow these transformations to occur inline, chains!

I was imagining the middleware chain would look like

[
    ...
    "Spree::Stock::Middleware::LoadStockLocations",
    "Spree::Stock::Middleware::SortStockLocations",
    ...
]

load_stock_locations, context[:order]
).filter
sorted_stock_locations = Spree::Config.stock.location_sorter_class.new(
filtered_stock_locations
).sort

context[:stock_locations] = sorted_stock_locations
end

private

def load_stock_locations
Spree::StockLocation.all
end
end
end
end
end
9 changes: 8 additions & 1 deletion core/app/models/spree/stock/simple_coordinator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ class SimpleCoordinator
:filtered_stock_locations, :inventory_units_by_variant, :desired,
:availability, :allocator, :packages

def initialize(order, inventory_units = nil)
def initialize(order, inventory_units_deprecated = nil, inventory_units: nil)
if inventory_units_deprecated
Spree.deprecator.warn "Using the `inventory_units` positional " \
"argument is deprecated in favor of using the keyword argument. "

inventory_units ||= inventory_units_deprecated
end

@order = order
@inventory_units =
inventory_units || Spree::Config.stock.inventory_unit_builder_class.new(order).units
Expand Down
Loading
Loading