-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Introduce new middleware stock coordinator #6484
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b67737d
8b98fa7
dbb8066
b762d99
f861dcd
7b98c26
a4a3a6d
c6f14a4
2feb441
26e9893
ca9ae0d
cec1968
a5893c3
898446b
c4a3a33
d449631
bb9e0ed
3028892
8547546
558d149
27c1073
851a690
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Spree | ||
| module MiddlewareRunner | ||
| def self.call(middlewares, context) | ||
| chain = middlewares.to_a.reverse.reduce(->(_ctx) {}) { |inner, klass| | ||
| ->(ctx) { klass.new.call(ctx, &inner) } | ||
| } | ||
|
|
||
| chain.call(context) | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Spree | ||
| module Stock | ||
| class Coordinator | ||
| def initialize(order, inventory_units: nil) | ||
| @order = order | ||
| @context = Context.new(order: order, inventory_units: inventory_units) | ||
| end | ||
|
|
||
| def shipments | ||
| Spree::MiddlewareRunner.call(Spree::Config.stock.coordinator_middlewares, @context) | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Spree | ||
| module Stock | ||
| class Coordinator | ||
| class Context | ||
| attr_reader :order | ||
| attr_accessor :inventory_units, :inventory_unit_groups, :stock_locations, | ||
| :on_hand_packages, :backordered_packages, | ||
| :packages, :shipments | ||
|
|
||
| def initialize(order:, inventory_units: nil) | ||
| @order = order | ||
| @inventory_units = inventory_units | ||
| end | ||
|
|
||
| def desired | ||
| @desired ||= Spree::StockQuantities.new(inventory_unit_groups.transform_values(&:count)) | ||
| end | ||
|
|
||
| def availability | ||
| @availability ||= Spree::Stock::Availability.new( | ||
| variants: desired.variants, | ||
| stock_locations: stock_locations | ||
| ) | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| 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? | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
| yield context | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| module Spree | ||
| module Stock | ||
| module Middleware | ||
| class InventoryUnit | ||
| def call(context) | ||
| context.inventory_units ||= | ||
| Spree::Config.stock.inventory_unit_builder_class.new(context.order).units | ||
|
|
||
| yield context | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| module Spree | ||
| module Stock | ||
| module Middleware | ||
| class InventoryUnitGroup | ||
| def call(context) | ||
| context.inventory_unit_groups = context.inventory_units.group_by(&:variant) | ||
|
|
||
| yield context | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| 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) | ||
|
|
||
| yield context | ||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm still leaning towards inlining these classes and having users replace |
||
| shipment | ||
| end | ||
|
|
||
| yield context | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Love the yield pattern! |
||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| module Spree | ||
| module Stock | ||
| module Middleware | ||
| class StockLocation | ||
| def call(context) | ||
| filtered_stock_locations = Spree::Config.stock.location_filter_class.new( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
| yield context | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def load_stock_locations | ||
| Spree::StockLocation.all | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,41 +1,11 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "active_support/core_ext/module" | ||
|
|
||
| module Spree | ||
| module Core | ||
| module ClassConstantizer | ||
| class Set | ||
| include Enumerable | ||
|
|
||
| def initialize(default: []) | ||
| @collection = ::Set.new(default) | ||
| end | ||
|
|
||
| def <<(klass) | ||
| @collection << klass.to_s | ||
| end | ||
|
|
||
| def concat(klasses) | ||
| klasses.each do |klass| | ||
| self << klass | ||
| end | ||
|
|
||
| self | ||
| end | ||
|
|
||
| delegate :clear, :empty?, to: :@collection | ||
|
|
||
| def delete(object) | ||
| @collection.delete(object.to_s) | ||
| end | ||
|
|
||
| def each | ||
| @collection.each do |klass| | ||
| yield klass.constantize | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
|
||
| require "spree/core/class_constantizer/set" | ||
| require "spree/core/class_constantizer/list" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "active_support/core_ext/module" | ||
|
|
||
| module Spree | ||
| module Core | ||
| module ClassConstantizer | ||
| # Ordered, array-backed companion to {ClassConstantizer::Set}. | ||
| # | ||
| # Stores class references as strings and constantizes them lazily on | ||
| # iteration, so entries survive code reloading in development. Unlike | ||
| # {ClassConstantizer::Set}, order is preserved. | ||
| class List | ||
| include Enumerable | ||
|
|
||
| def initialize | ||
| @collection = [] | ||
| end | ||
|
|
||
| def <<(klass) | ||
| @collection << klass.to_s | ||
| self | ||
| end | ||
|
|
||
| def concat(klasses) | ||
| klasses.each { |klass| self << klass } | ||
| self | ||
| end | ||
|
|
||
| def each | ||
| @collection.each { |klass| yield klass.constantize } | ||
| end | ||
|
|
||
| # Inserts one or more entries immediately before the anchor. | ||
| # | ||
| # @raise [ArgumentError] if the anchor is not in the list. | ||
| def insert_before(anchor, *klasses) | ||
| index = index_for(anchor) or | ||
| raise ArgumentError, "#{anchor} not found in #{self.class}" | ||
| @collection.insert(index, *klasses.map(&:to_s)) | ||
| self | ||
| end | ||
|
|
||
| # Inserts one or more entries immediately after the anchor. | ||
| # | ||
| # @raise [ArgumentError] if the anchor is not in the list. | ||
| def insert_after(anchor, *klasses) | ||
| index = index_for(anchor) or | ||
| raise ArgumentError, "#{anchor} not found in #{self.class}" | ||
| @collection.insert(index + 1, *klasses.map(&:to_s)) | ||
| self | ||
| end | ||
|
|
||
| def delete(object) | ||
| @collection.delete(object.to_s) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def index_for(object) | ||
| @collection.index(object.to_s) | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "active_support/core_ext/module" | ||
|
|
||
| module Spree | ||
| module Core | ||
| module ClassConstantizer | ||
| class Set | ||
| include Enumerable | ||
|
|
||
| def initialize(default: []) | ||
| @collection = ::Set.new(default) | ||
| end | ||
|
|
||
| def <<(klass) | ||
| @collection << klass.to_s | ||
| end | ||
|
|
||
| def concat(klasses) | ||
| klasses.each do |klass| | ||
| self << klass | ||
| end | ||
|
|
||
| self | ||
| end | ||
|
|
||
| delegate :clear, :empty?, to: :@collection | ||
|
|
||
| def delete(object) | ||
| @collection.delete(object.to_s) | ||
| end | ||
|
|
||
| def each | ||
| @collection.each do |klass| | ||
| yield klass.constantize | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TBH I prefer them as independent middleware pipeline stages
You are correct! But also, this is true for the whole pipeline! Almost every step is essential, and produces output required by the next. I'd rather implicitly assume that in the chain, or use a more strict configuration format (even defining
inputsin addition tooutputs), and have smaller middleware, easier to override, replace, or insert other middleware in between