diff --git a/CHANGELOG.md b/CHANGELOG.md index adce7cfa18f..7448c27bf10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Solidus 2.8.0 (master, unreleased) +### Core + +- Implement stock location sorters [#2783](https://github.com/solidusio/solidus/pull/2783) ([aldesantis](https://github.com/aldesantis)) + ## Solidus 2.7.0 (2018-09-14) ### Major Changes diff --git a/core/app/models/spree/stock/location_sorter/base.rb b/core/app/models/spree/stock/location_sorter/base.rb new file mode 100644 index 00000000000..821f34b1930 --- /dev/null +++ b/core/app/models/spree/stock/location_sorter/base.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Spree + module Stock + module LocationSorter + # Stock location sorters are used to determine the order in which + # inventory units will be allocated when packaging a shipment. + # + # This allows you, for example, to allocate inventory from the default + # stock location first. + # + # @abstract To implement your own location sorter, subclass and + # implement {#sort}. + class Base + # @!attribute [r] stock_locations + # @return [Enumerable] + # a collection of locations to sort + attr_reader :stock_locations + + # Initializes the stock location sorter. + # + # @param stock_locations [Enumerable] + # a collection of locations to sort + def initialize(stock_locations) + @stock_locations = stock_locations + end + + # Sorts the stock locations. + # + # @return [Enumerable] + # a collection of sorted stock locations + def sort + raise NotImplementedError + end + end + end + end +end diff --git a/core/app/models/spree/stock/location_sorter/default_first.rb b/core/app/models/spree/stock/location_sorter/default_first.rb new file mode 100644 index 00000000000..43790a822d8 --- /dev/null +++ b/core/app/models/spree/stock/location_sorter/default_first.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Spree + module Stock + module LocationSorter + # This stock location sorter will give priority to the default stock + # location. + class DefaultFirst < Spree::Stock::LocationSorter::Base + def sort + stock_locations.order_default + end + end + end + end +end diff --git a/core/app/models/spree/stock/location_sorter/unsorted.rb b/core/app/models/spree/stock/location_sorter/unsorted.rb new file mode 100644 index 00000000000..59668f7c811 --- /dev/null +++ b/core/app/models/spree/stock/location_sorter/unsorted.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Spree + module Stock + module LocationSorter + # This stock location sorter will leave the stock locations unsorted. + class Unsorted < Spree::Stock::LocationSorter::Base + def sort + stock_locations + end + end + end + end +end diff --git a/core/app/models/spree/stock/simple_coordinator.rb b/core/app/models/spree/stock/simple_coordinator.rb index 86ca5c71ff3..47507078d77 100644 --- a/core/app/models/spree/stock/simple_coordinator.rb +++ b/core/app/models/spree/stock/simple_coordinator.rb @@ -23,7 +23,7 @@ def initialize(order, inventory_units = nil) @order = order @inventory_units = inventory_units || InventoryUnitBuilder.new(order).units @splitters = Spree::Config.environment.stock_splitters - @stock_locations = Spree::StockLocation.active + @stock_locations = Spree::Config.stock.location_sorter_class.new(Spree::StockLocation.active).sort @inventory_units_by_variant = @inventory_units.group_by(&:variant) @desired = Spree::StockQuantities.new(@inventory_units_by_variant.transform_values(&:count)) @@ -85,7 +85,9 @@ def split_packages(initial_packages) end def allocate_inventory(availability_by_location) - availability_by_location.transform_values do |available| + sorted_availability = sort_availability(availability_by_location) + + sorted_availability.transform_values do |available| # Find the desired inventory which is available at this location packaged = available & @desired @@ -96,6 +98,16 @@ def allocate_inventory(availability_by_location) end end + def sort_availability(availability) + sorted_availability = availability.sort_by do |stock_location_id, _| + @stock_locations.find_index do |stock_location| + stock_location.id == stock_location_id + end + end + + Hash[sorted_availability] + end + def get_units(quantities) # Change our raw quantities back into inventory units quantities.flat_map do |variant, quantity| diff --git a/core/lib/spree/core/stock_configuration.rb b/core/lib/spree/core/stock_configuration.rb index ff8bfb00378..567aa395a86 100644 --- a/core/lib/spree/core/stock_configuration.rb +++ b/core/lib/spree/core/stock_configuration.rb @@ -5,6 +5,7 @@ module Core class StockConfiguration attr_writer :coordinator_class attr_writer :estimator_class + attr_writer :location_sorter_class def coordinator_class @coordinator_class ||= '::Spree::Stock::SimpleCoordinator' @@ -15,6 +16,11 @@ def estimator_class @estimator_class ||= '::Spree::Stock::Estimator' @estimator_class.constantize end + + def location_sorter_class + @location_sorter_class ||= '::Spree::Stock::LocationSorter::Unsorted' + @location_sorter_class.constantize + end end end end diff --git a/core/spec/lib/spree/core/stock_configuration_spec.rb b/core/spec/lib/spree/core/stock_configuration_spec.rb index d8795736907..940c9e0de5c 100644 --- a/core/spec/lib/spree/core/stock_configuration_spec.rb +++ b/core/spec/lib/spree/core/stock_configuration_spec.rb @@ -20,6 +20,7 @@ end end end + describe '#estimator_class' do let(:stock_configuration) { described_class.new } subject { stock_configuration.estimator_class } @@ -37,4 +38,22 @@ end end end + + describe '#location_sorter_class' do + let(:stock_configuration) { described_class.new } + subject { stock_configuration.location_sorter_class } + + it "returns Spree::Stock::LocationSorter::Unsorted" do + is_expected.to be ::Spree::Stock::LocationSorter::Unsorted + end + + context "with another constant name assiged" do + MySorter = Class.new + before { stock_configuration.location_sorter_class = MySorter.to_s } + + it "returns the constant" do + is_expected.to be MySorter + end + end + end end diff --git a/core/spec/models/spree/stock/location_sorter/default_first_spec.rb b/core/spec/models/spree/stock/location_sorter/default_first_spec.rb new file mode 100644 index 00000000000..ff14ca34220 --- /dev/null +++ b/core/spec/models/spree/stock/location_sorter/default_first_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module Spree + module Stock + module LocationSorter + RSpec.describe DefaultFirst, type: :model do + subject { described_class.new(stock_locations) } + + let(:stock_locations) { OpenStruct.new(order_default: sorted_stock_locations) } + let(:sorted_stock_locations) { instance_double('Spree::StockLocation::ActiveRecord_Relation') } + + it 'returns the default stock location first' do + expect(subject.sort).to eq(sorted_stock_locations) + end + end + end + end +end diff --git a/core/spec/models/spree/stock/location_sorter/unsorted_spec.rb b/core/spec/models/spree/stock/location_sorter/unsorted_spec.rb new file mode 100644 index 00000000000..dcc99ee3a01 --- /dev/null +++ b/core/spec/models/spree/stock/location_sorter/unsorted_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module Spree + module Stock + module LocationSorter + RSpec.describe Unsorted, type: :model do + subject { described_class.new(stock_locations) } + + let(:stock_locations) { instance_double('Spree::StockLocation::ActiveRecord_Relation') } + + it 'returns the original stock locations unsorted' do + expect(subject.sort).to eq(stock_locations) + end + end + end + end +end diff --git a/core/spec/models/spree/stock/simple_coordinator_spec.rb b/core/spec/models/spree/stock/simple_coordinator_spec.rb index 6abd1887d0d..2a9d6e775d0 100644 --- a/core/spec/models/spree/stock/simple_coordinator_spec.rb +++ b/core/spec/models/spree/stock/simple_coordinator_spec.rb @@ -15,6 +15,11 @@ module Stock subject.shipments end + it 'uses the configured stock location sorter' do + expect(Spree::Config.stock).to receive(:location_sorter_class).and_call_original + subject.shipments + end + it 'builds shipments' do expect(subject.shipments.size).to eq(1) end diff --git a/guides/data/nav_tree.yml b/guides/data/nav_tree.yml index 704278e58bb..0e0a1b4337a 100644 --- a/guides/data/nav_tree.yml +++ b/guides/data/nav_tree.yml @@ -150,6 +150,8 @@ main: href: "/developers/shipments/cartons.html" - title: "Custom shipping calculators" href: "/developers/shipments/custom-shipping-calculators.html" + - title: "Stock location sorters" + href: "/developers/shipments/stock-location-sorters.html" - title: "Shipment setup examples" href: "/developers/shipments/shipment-setup-examples.html" - title: "Shipping method filters" diff --git a/guides/source/developers/shipments/stock-location-sorters.html.md b/guides/source/developers/shipments/stock-location-sorters.html.md new file mode 100644 index 00000000000..637cf3804f0 --- /dev/null +++ b/guides/source/developers/shipments/stock-location-sorters.html.md @@ -0,0 +1,52 @@ +# Stock location sorters + +This article explains the purpose, interface and correct usage of custom stock location sorters. + +Your app's stock location sorter defines the order in which stock locations are used to allocate +inventory when creating packages for an order. The sorter is called by `Spree::Stock::SimpleCoordinator` +when allocating inventory for an order. + +## Pre-configured sorters + +Currently, we only have two sorters, which you should use unless you need custom logic: + +- [Unsorted](https://github.com/solidusio/solidus/blob/master/core/app/models/spree/stock/sorter/unsorted.rb), + which allocates inventory from stock locations as they are returned from the DB. +- [Default first](https://github.com/solidusio/solidus/blob/master/core/app/models/spree/stock/sorter/default_first.rb), + which allocates inventory from the default stock location first. + +## Custom sorter API + +A custom sorter should inherit from `Spree::Stock::LocationSorter::Base` and implement a `sort` method +which accepts a `Spree::StockLocation::ActiveRecord_Relation` and returns an enumerable of stock +locations. Note that the return value does not have to be an AR relation. + +Here's an example that sorts stock locations by a custom `priority` attribute: + +```ruby +class Spree::Stock::LocationSorter::Priority < Spree::Stock::LocationSorter::Base + def sort + stock_locations.order(priority: :asc) + end +end +``` + +### Switching the sorter + +Once you have created the logic for the new sorter, you need to register it so that it's used by +`Spree::Stock::SimpleCoordinator`. + +For example, you can register it in your `/config/application.rb` initializer: + +```ruby +# /config/application.rb +module MyStore + class Application < Rails::Application + # ... + + initializer 'spree.register.stock_location_sorter' do |app| + app.config.spree.stock.location_sorter_class = 'Spree::Stock::LocationSorter::Priority' + end + end +end +```