diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml index 94fdd0bd31..c5b16934f0 100644 --- a/.rubocop_manual_todo.yml +++ b/.rubocop_manual_todo.yml @@ -391,6 +391,7 @@ Metrics/AbcSize: - app/models/spree/order_decorator.rb - app/models/spree/payment_decorator.rb - app/models/spree/product_decorator.rb + - app/models/spree/shipment.rb - app/models/spree/taxon_decorator.rb - app/models/spree/tax_rate_decorator.rb - app/serializers/api/admin/enterprise_serializer.rb @@ -400,6 +401,9 @@ Metrics/AbcSize: - app/services/create_order_cycle.rb - app/services/order_cycle_form.rb - app/services/order_syncer.rb + - engines/order_management/app/services/order_management/stock/estimator.rb + - engines/order_management/app/services/order_management/stock/package.rb + - engines/order_management/app/services/order_management/stock/packer.rb - engines/order_management/app/services/order_management/subscriptions/validator.rb - lib/active_merchant/billing/gateways/stripe_decorator.rb - lib/active_merchant/billing/gateways/stripe_payment_intents.rb @@ -457,6 +461,7 @@ Metrics/BlockLength: "scenario" ] Exclude: + - app/models/spree/shipment.rb - lib/tasks/data.rake - spec/controllers/spree/admin/invoices_controller_spec.rb - spec/factories/enterprise_factory.rb @@ -496,6 +501,7 @@ Metrics/CyclomaticComplexity: - app/models/spree/product_decorator.rb - app/models/variant_override_set.rb - app/services/cart_service.rb + - engines/order_management/app/services/order_management/stock/estimator.rb - lib/active_merchant/billing/gateways/stripe_payment_intents.rb - lib/discourse/single_sign_on.rb - lib/open_food_network/bulk_coop_report.rb @@ -520,6 +526,7 @@ Metrics/PerceivedComplexity: - app/models/spree/ability_decorator.rb - app/models/spree/order_decorator.rb - app/models/spree/product_decorator.rb + - engines/order_management/app/services/order_management/stock/estimator.rb - lib/active_merchant/billing/gateways/stripe_payment_intents.rb - lib/discourse/single_sign_on.rb - lib/open_food_network/bulk_coop_report.rb @@ -585,11 +592,14 @@ Metrics/MethodLength: - app/models/spree/payment_decorator.rb - app/models/spree/payment_method_decorator.rb - app/models/spree/product_decorator.rb + - app/models/spree/shipment.rb - app/serializers/api/admin/order_cycle_serializer.rb - app/serializers/api/cached_enterprise_serializer.rb - app/services/order_cycle_form.rb - app/services/permitted_attributes/checkout.rb - engines/order_management/app/services/order_management/reports/enterprise_fee_summary/scope.rb + - engines/order_management/app/services/order_management/stock/estimator.rb + - engines/order_management/app/services/order_management/stock/package.rb - lib/active_merchant/billing/gateways/stripe_payment_intents.rb - lib/discourse/single_sign_on.rb - lib/open_food_network/bulk_coop_report.rb @@ -652,6 +662,7 @@ Metrics/ClassLength: - app/models/product_import/entry_validator.rb - app/models/product_import/product_importer.rb - app/models/spree/ability_decorator.rb + - app/models/spree/shipment.rb - app/models/spree/user.rb - app/serializers/api/cached_enterprise_serializer.rb - app/serializers/api/enterprise_shopfront_serializer.rb @@ -676,6 +687,7 @@ Metrics/ModuleLength: - app/helpers/injection_helper.rb - app/helpers/spree/admin/base_helper.rb - app/helpers/spree/admin/navigation_helper.rb + - engines/order_management/spec/services/order_management/stock/package_spec.rb - engines/order_management/spec/services/order_management/subscriptions/estimator_spec.rb - engines/order_management/spec/services/order_management/subscriptions/form_spec.rb - engines/order_management/spec/services/order_management/subscriptions/proxy_order_syncer_spec.rb diff --git a/app/controllers/spree/orders_controller.rb b/app/controllers/spree/orders_controller.rb index 85939fe1ad..545ae1ce60 100644 --- a/app/controllers/spree/orders_controller.rb +++ b/app/controllers/spree/orders_controller.rb @@ -166,7 +166,7 @@ module Spree # recalculates the shipment taxes def update_totals_and_taxes @order.updater.update_totals - @order.shipment&.ensure_correct_adjustment_with_included_tax + @order.shipment&.ensure_correct_adjustment end # Sets the adjustments to open to perform the block's action and restores diff --git a/app/models/spree/order_decorator.rb b/app/models/spree/order_decorator.rb index a36d8e1312..71574359a7 100644 --- a/app/models/spree/order_decorator.rb +++ b/app/models/spree/order_decorator.rb @@ -89,6 +89,18 @@ Spree::Order.class_eval do where("state != ?", state) } + def create_proposed_shipments + adjustments.shipping.delete_all + shipments.destroy_all + + packages = OrderManagement::Stock::Coordinator.new(self).packages + packages.each do |package| + shipments << package.to_shipment + end + + shipments + end + # -- Methods def products_available_from_new_distribution # Check that the line_items in the current order are available from a newly selected distribution diff --git a/app/models/spree/shipment.rb b/app/models/spree/shipment.rb new file mode 100644 index 0000000000..98ea4ac982 --- /dev/null +++ b/app/models/spree/shipment.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +require 'ostruct' + +module Spree + class Shipment < ActiveRecord::Base + belongs_to :order, class_name: 'Spree::Order' + belongs_to :address, class_name: 'Spree::Address' + belongs_to :stock_location, class_name: 'Spree::StockLocation' + + has_many :shipping_rates, dependent: :delete_all + has_many :shipping_methods, through: :shipping_rates + has_many :state_changes, as: :stateful + has_many :inventory_units, dependent: :delete_all + has_one :adjustment, as: :source, dependent: :destroy + + before_create :generate_shipment_number + after_save :ensure_correct_adjustment, :update_order + + attr_accessor :special_instructions + + accepts_nested_attributes_for :address + accepts_nested_attributes_for :inventory_units + + make_permalink field: :number + + scope :shipped, -> { with_state('shipped') } + scope :ready, -> { with_state('ready') } + scope :pending, -> { with_state('pending') } + scope :with_state, ->(*s) { where(state: s) } + scope :trackable, -> { where("tracking IS NOT NULL AND tracking != ''") } + + # Shipment state machine + # See http://github.com/pluginaweek/state_machine/tree/master for details + state_machine initial: :pending, use_transactions: false do + event :ready do + transition from: :pending, to: :ready, if: lambda { |shipment| + # Fix for #2040 + shipment.determine_state(shipment.order) == 'ready' + } + end + + event :pend do + transition from: :ready, to: :pending + end + + event :ship do + transition from: :ready, to: :shipped + end + after_transition to: :shipped, do: :after_ship + + event :cancel do + transition to: :canceled, from: [:pending, :ready] + end + after_transition to: :canceled, do: :after_cancel + + event :resume do + transition from: :canceled, to: :ready, if: lambda { |shipment| + shipment.determine_state(shipment.order) == :ready + } + transition from: :canceled, to: :pending, if: lambda { |shipment| + shipment.determine_state(shipment.order) == :ready + } + transition from: :canceled, to: :pending + end + after_transition from: :canceled, to: [:pending, :ready], do: :after_resume + end + + def to_param + generate_shipment_number unless number + number.to_s.to_url.upcase + end + + def backordered? + inventory_units.any?(&:backordered?) + end + + def shipped=(value) + return unless value == '1' && shipped_at.nil? + + self.shipped_at = Time.zone.now + end + + def shipping_method + selected_shipping_rate.try(:shipping_method) || shipping_rates.first.try(:shipping_method) + end + + def add_shipping_method(shipping_method, selected = false) + shipping_rates.create(shipping_method: shipping_method, selected: selected) + end + + def selected_shipping_rate + shipping_rates.where(selected: true).first + end + + def selected_shipping_rate_id + selected_shipping_rate.try(:id) + end + + def selected_shipping_rate_id=(id) + shipping_rates.update_all(selected: false) + shipping_rates.update(id, selected: true) + save! + end + + def refresh_rates + return shipping_rates if shipped? + + # The call to Stock::Estimator below will replace the current shipping_method + original_shipping_method_id = shipping_method.try(:id) + self.shipping_rates = OrderManagement::Stock::Estimator.new(order).shipping_rates(to_package) + + keep_original_shipping_method_selection(original_shipping_method_id) + + shipping_rates + end + + def keep_original_shipping_method_selection(original_shipping_method_id) + return if shipping_method&.id == original_shipping_method_id + + rate_for_original_shipping_method = find_shipping_rate_for(original_shipping_method_id) + if rate_for_original_shipping_method.present? + self.selected_shipping_rate_id = rate_for_original_shipping_method.id + else + # If there's no original ship method to keep, or if it cannot be found on the ship rates + # But there's a new ship method selected (first if clause in this method) + # We need to save the shipment so that callbacks are triggered + save! + end + end + + def find_shipping_rate_for(shipping_method_id) + return unless shipping_method_id + + shipping_rates.detect { |rate| + rate.shipping_method_id == shipping_method_id + } + end + + def currency + order ? order.currency : Spree::Config[:currency] + end + + # The adjustment amount associated with this shipment (if any) + # Returns only the first adjustment to match the shipment + # There should never really be more than one. + def cost + adjustment ? adjustment.amount : 0 + end + + alias_method :amount, :cost + + def display_cost + Spree::Money.new(cost, currency: currency) + end + + alias_method :display_amount, :display_cost + + def item_cost + line_items.map(&:amount).sum + end + + def display_item_cost + Spree::Money.new(item_cost, currency: currency) + end + + def total_cost + cost + item_cost + end + + def display_total_cost + Spree::Money.new(total_cost, currency: currency) + end + + def editable_by?(_user) + !shipped? + end + + def manifest + inventory_units.group_by(&:variant).map do |variant, units| + states = {} + units.group_by(&:state).each { |state, iu| states[state] = iu.count } + scoper.scope(variant) + OpenStruct.new(variant: variant, quantity: units.length, states: states) + end + end + + def scoper + @scoper ||= OpenFoodNetwork::ScopeVariantToHub.new(order.distributor) + end + + def line_items + if order.complete? + order.line_items.select { |li| inventory_units.pluck(:variant_id).include?(li.variant_id) } + else + order.line_items + end + end + + def finalize! + InventoryUnit.finalize_units!(inventory_units) + manifest.each { |item| manifest_unstock(item) } + end + + def after_cancel + manifest.each { |item| manifest_restock(item) } + end + + def after_resume + manifest.each { |item| manifest_unstock(item) } + end + + # Updates various aspects of the Shipment while bypassing any callbacks. + # Note that this method takes an explicit reference to the Order object. + # This is necessary because the association actually has a stale (and unsaved) copy of the + # Order and so it will not yield the correct results. + def update!(order) + old_state = state + new_state = determine_state(order) + update_column :state, new_state + after_ship if new_state == 'shipped' && old_state != 'shipped' + end + + # Determines the appropriate +state+ according to the following logic: + # + # pending unless order is complete and +order.payment_state+ is +paid+ + # shipped if already shipped (ie. does not change the state) + # ready all other cases + def determine_state(order) + return 'canceled' if order.canceled? + return 'pending' unless order.can_ship? + return 'pending' if inventory_units.any?(&:backordered?) + return 'shipped' if state == 'shipped' + + order.paid? ? 'ready' : 'pending' + end + + def tracking_url + @tracking_url ||= shipping_method.build_tracking_url(tracking) + end + + def include?(variant) + inventory_units_for(variant).present? + end + + def inventory_units_for(variant) + inventory_units.group_by(&:variant_id)[variant.id] || [] + end + + def to_package + package = OrderManagement::Stock::Package.new(stock_location, order) + inventory_units.includes(:variant).each do |inventory_unit| + package.add inventory_unit.variant, 1, inventory_unit.state_name + end + package + end + + def set_up_inventory(state, variant, order) + inventory_units.create(variant_id: variant.id, state: state, order_id: order.id) + end + + def ensure_correct_adjustment + if adjustment + adjustment.originator = shipping_method + adjustment.label = shipping_method.adjustment_label + adjustment.amount = selected_shipping_rate.cost if adjustment.open? + adjustment.save! + adjustment.reload + elsif selected_shipping_rate_id + shipping_method.create_adjustment(shipping_method.adjustment_label, + order, + self, + true, + "open") + reload # ensure adjustment is present on later saves + end + + update_adjustment_included_tax if adjustment + end + + private + + def manifest_unstock(item) + stock_location.unstock item.variant, item.quantity, self + end + + def manifest_restock(item) + stock_location.restock item.variant, item.quantity, self + end + + def generate_shipment_number + return number if number.present? + + record = true + while record + random = "H#{Array.new(11) { rand(9) }.join}" + record = self.class.where(number: random).first + end + self.number = random + end + + def description_for_shipping_charge + "#{Spree.t(:shipping)} (#{shipping_method.name})" + end + + def validate_shipping_method + return if shipping_method.nil? + + return if shipping_method.include?(address) + + errors.add :shipping_method, Spree.t(:is_not_available_to_shipment_address) + end + + def after_ship + inventory_units.each(&:ship!) + adjustment.finalize! + send_shipped_email + touch :shipped_at + end + + def send_shipped_email + ShipmentMailer.shipped_email(id).deliver + end + + def update_adjustment_included_tax + if Config.shipment_inc_vat && (order.distributor.nil? || order.distributor.charges_sales_tax) + adjustment.set_included_tax! Config.shipping_tax_rate + else + adjustment.set_included_tax! 0 + end + end + + def update_order + order.update! + end + end +end diff --git a/app/models/spree/shipment_decorator.rb b/app/models/spree/shipment_decorator.rb deleted file mode 100644 index b23bd7266f..0000000000 --- a/app/models/spree/shipment_decorator.rb +++ /dev/null @@ -1,41 +0,0 @@ -module Spree - Shipment.class_eval do - def ensure_correct_adjustment_with_included_tax - ensure_correct_adjustment_without_included_tax - - update_adjustment_included_tax if adjustment - end - alias_method_chain :ensure_correct_adjustment, :included_tax - - def update_adjustment_included_tax - if Config.shipment_inc_vat && (order.distributor.nil? || order.distributor.charges_sales_tax) - adjustment.set_included_tax! Config.shipping_tax_rate - else - adjustment.set_included_tax! 0 - end - end - - def manifest - inventory_units.group_by(&:variant).map do |variant, units| - states = {} - units.group_by(&:state).each { |state, iu| states[state] = iu.count } - scoper.scope(variant) - OpenStruct.new(variant: variant, quantity: units.length, states: states) - end - end - - def scoper - @scoper ||= OpenFoodNetwork::ScopeVariantToHub.new(order.distributor) - end - - private - - # NOTE: This is an override of spree's method, needed to allow orders - # without line items (ie. user invoices) to not have inventory units - def require_inventory - return false unless line_items.count > 0 # This line altered - - order.completed? && !order.canceled? - end - end -end diff --git a/app/models/spree/stock/availability_validator.rb b/app/models/spree/stock/availability_validator.rb new file mode 100644 index 0000000000..6c9516cd08 --- /dev/null +++ b/app/models/spree/stock/availability_validator.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Spree + module Stock + class AvailabilityValidator < ActiveModel::Validator + def validate(line_item) + # OFN specific check for in-memory :skip_stock_check attribute + return if line_item.skip_stock_check + + quantity_to_validate = line_item.quantity - quantity_in_shipment(line_item) + return if quantity_to_validate < 1 + + validate_quantity(line_item, quantity_to_validate) + end + + private + + # This is an adapted version of a fix to the inventory_units not being considered here. + # See #3090 for details. + # This can be removed after upgrading to Spree 2.4. + def quantity_in_shipment(line_item) + shipment = line_item_shipment(line_item) + return 0 unless shipment + + units = shipment.inventory_units_for(line_item.variant) + units.count + end + + def line_item_shipment(line_item) + return line_item.target_shipment if line_item.target_shipment + return line_item.order.shipments.first if line_item.order.andand.shipments.any? + end + + # Overrides Spree v2.0.4 validate method version to: + # - scope variants to hub and thus acivate variant overrides + # - use calculated quantity instead of the line_item.quantity + # - rely on Variant.can_supply? instead of Stock::Quantified.can_supply? + # so that it works correctly for variant overrides + def validate_quantity(line_item, quantity) + line_item.scoper.scope(line_item.variant) + + add_out_of_stock_error(line_item) unless line_item.variant.can_supply? quantity + end + + def add_out_of_stock_error(line_item) + variant = line_item.variant + display_name = variant.name.to_s + display_name += %{(#{variant.options_text})} if variant.options_text.present? + line_item.errors[:quantity] << Spree.t(:out_of_stock, + scope: :order_populator, + item: display_name.inspect) + end + end + end +end diff --git a/app/models/spree/stock/availability_validator_decorator.rb b/app/models/spree/stock/availability_validator_decorator.rb deleted file mode 100644 index c3ee4c4818..0000000000 --- a/app/models/spree/stock/availability_validator_decorator.rb +++ /dev/null @@ -1,49 +0,0 @@ -Spree::Stock::AvailabilityValidator.class_eval do - def validate(line_item) - # OFN specific check for in-memory :skip_stock_check attribute - return if line_item.skip_stock_check - - quantity_to_validate = line_item.quantity - quantity_in_shipment(line_item) - return if quantity_to_validate < 1 - - validate_quantity(line_item, quantity_to_validate) - end - - private - - # This is an adapted version of a fix to the inventory_units not being considered here. - # See #3090 for details. - # This can be removed after upgrading to Spree 2.4. - def quantity_in_shipment(line_item) - shipment = line_item_shipment(line_item) - return 0 unless shipment - - units = shipment.inventory_units_for(line_item.variant) - units.count - end - - def line_item_shipment(line_item) - return line_item.target_shipment if line_item.target_shipment - return line_item.order.shipments.first if line_item.order.andand.shipments.any? - end - - # Overrides Spree v2.0.4 validate method version to: - # - scope variants to hub and thus acivate variant overrides - # - use calculated quantity instead of the line_item.quantity - # - rely on Variant.can_supply? instead of Stock::Quantified.can_supply? - # so that it works correctly for variant overrides - def validate_quantity(line_item, quantity) - line_item.scoper.scope(line_item.variant) - - add_out_of_stock_error(line_item) unless line_item.variant.can_supply? quantity - end - - def add_out_of_stock_error(line_item) - variant = line_item.variant - display_name = variant.name.to_s - display_name += %{(#{variant.options_text})} if variant.options_text.present? - line_item.errors[:quantity] << Spree.t(:out_of_stock, - scope: :order_populator, - item: display_name.inspect) - end -end diff --git a/app/models/stock/package.rb b/app/models/stock/package.rb deleted file mode 100644 index eada3741c1..0000000000 --- a/app/models/stock/package.rb +++ /dev/null @@ -1,45 +0,0 @@ -# Extends Spree's Package implementation to skip shipping methods that are not -# valid for OFN. -# -# It requires the following configuration in config/initializers/spree.rb: -# -# Spree.config do |config| -# ... -# config.package_factory = Stock::Package -# end -# -module Stock - class Package < Spree::Stock::Package - # Returns all existing shipping categories. - # It does not filter by the shipping categories of the products in the order. - # It allows checkout of products with categories that are not the shipping methods categories - # It disables the matching of product shipping category with shipping method's category - # - # @return [Array] - def shipping_categories - Spree::ShippingCategory.all - end - - # Skips the methods that are not used by the order's distributor - # - # @return [Array] - def shipping_methods - available_shipping_methods = super.to_a - - available_shipping_methods.keep_if do |shipping_method| - ships_with?(order.distributor.shipping_methods.to_a, shipping_method) - end - end - - private - - # Checks whether the given distributor provides the specified shipping method - # - # @param shipping_methods [Array] - # @param shipping_method [Spree::ShippingMethod] - # @return [Boolean] - def ships_with?(shipping_methods, shipping_method) - shipping_methods.include?(shipping_method) - end - end -end diff --git a/config/application.rb b/config/application.rb index d48b5d2b8b..547795ea5f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -76,17 +76,6 @@ module Openfoodnetwork ] end - # Every splitter (except Base splitter) will split the order in multiple packages - # Each package will generate a separate shipment in the order - # Base splitter does not split the packages - # So, because in OFN we have locked orders to have only one shipment, - # we must use this splitter and no other - initializer "spree.register.stock_splitters" do |app| - app.config.spree.stock_splitters = [ - Spree::Stock::Splitter::Base - ] - end - # Register Spree payment methods initializer "spree.gateway.payment_methods", :after => "spree.register.payment_methods" do |app| app.config.spree.payment_methods << Spree::Gateway::Migs diff --git a/config/initializers/spree.rb b/config/initializers/spree.rb index 5aca0161f8..6ff230c5f5 100644 --- a/config/initializers/spree.rb +++ b/config/initializers/spree.rb @@ -30,7 +30,6 @@ Spree.config do |config| config.auto_capture = true #config.override_actionmailer_config = false - config.package_factory = Stock::Package config.order_updater_decorator = OrderUpdater # S3 settings diff --git a/engines/order_management/app/services/order_management/stock/adjuster.rb b/engines/order_management/app/services/order_management/stock/adjuster.rb new file mode 100644 index 0000000000..ed1337a16a --- /dev/null +++ b/engines/order_management/app/services/order_management/stock/adjuster.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Used by Prioritizer to adjust item quantities +# see prioritizer_spec for use cases +module OrderManagement + module Stock + class Adjuster + attr_accessor :variant, :need, :status + + def initialize(variant, quantity, status) + @variant = variant + @need = quantity + @status = status + end + + def adjust(item) + if item.quantity >= need + item.quantity = need + @need = 0 + elsif item.quantity < need + @need -= item.quantity + end + end + + def fulfilled? + @need.zero? + end + end + end +end diff --git a/engines/order_management/app/services/order_management/stock/coordinator.rb b/engines/order_management/app/services/order_management/stock/coordinator.rb new file mode 100644 index 0000000000..64bbbc8cbc --- /dev/null +++ b/engines/order_management/app/services/order_management/stock/coordinator.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module OrderManagement + module Stock + class Coordinator + attr_reader :order + + def initialize(order) + @order = order + end + + def packages + packages = build_packages + packages = prioritize_packages(packages) + estimate_packages(packages) + end + + # Build package with default stock location + # No need to check items are in the stock location, + # there is only one stock location so the items will be on that stock location. + # + # Returns an array with a single Package for the default stock location + def build_packages + packer = build_packer(order) + [packer.package] + end + + private + + def prioritize_packages(packages) + prioritizer = OrderManagement::Stock::Prioritizer.new(order, packages) + prioritizer.prioritized_packages + end + + def estimate_packages(packages) + estimator = OrderManagement::Stock::Estimator.new(order) + packages.each do |package| + package.shipping_rates = estimator.shipping_rates(package) + end + packages + end + + def build_packer(order) + stock_location = DefaultStockLocation.find_or_create + OrderManagement::Stock::Packer.new(stock_location, order) + end + end + end +end diff --git a/engines/order_management/app/services/order_management/stock/estimator.rb b/engines/order_management/app/services/order_management/stock/estimator.rb new file mode 100644 index 0000000000..c97d9c3b99 --- /dev/null +++ b/engines/order_management/app/services/order_management/stock/estimator.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module OrderManagement + module Stock + class Estimator + attr_reader :order, :currency + + def initialize(order) + @order = order + @currency = order.currency + end + + def shipping_rates(package, frontend_only = true) + shipping_rates = [] + shipping_methods = shipping_methods(package) + return [] unless shipping_methods + + shipping_methods.each do |shipping_method| + cost = calculate_cost(shipping_method, package) + shipping_rates << shipping_method.shipping_rates.new(cost: cost) unless cost.nil? + end + + shipping_rates.sort_by! { |r| r.cost || 0 } + + unless shipping_rates.empty? + if frontend_only + shipping_rates.each do |rate| + if rate.shipping_method.frontend? + rate.selected = true + break + end + end + else + shipping_rates.first.selected = true + end + end + + shipping_rates + end + + private + + def shipping_methods(package) + shipping_methods = package.shipping_methods + shipping_methods.delete_if { |ship_method| !ship_method.calculator.available?(package) } + shipping_methods.delete_if { |ship_method| !ship_method.include?(order.ship_address) } + shipping_methods.delete_if { |ship_method| + !(ship_method.calculator.preferences[:currency].nil? || + ship_method.calculator.preferences[:currency] == currency) + } + shipping_methods + end + + def calculate_cost(shipping_method, package) + shipping_method.calculator.compute(package) + end + end + end +end diff --git a/engines/order_management/app/services/order_management/stock/package.rb b/engines/order_management/app/services/order_management/stock/package.rb new file mode 100644 index 0000000000..f3bfaf38f0 --- /dev/null +++ b/engines/order_management/app/services/order_management/stock/package.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module OrderManagement + module Stock + class Package + ContentItem = Struct.new(:variant, :quantity, :state) + + attr_reader :stock_location, :order, :contents + attr_accessor :shipping_rates + + def initialize(stock_location, order, contents = []) + @stock_location = stock_location + @order = order + @contents = contents + @shipping_rates = [] + end + + def add(variant, quantity, state = :on_hand) + contents << ContentItem.new(variant, quantity, state) + end + + def weight + contents.sum { |item| item.variant.weight * item.quantity } + end + + def on_hand + contents.select { |item| item.state == :on_hand } + end + + def backordered + contents.select { |item| item.state == :backordered } + end + + def find_item(variant, state = :on_hand) + contents.select do |item| + item.variant == variant && + item.state == state + end.first + end + + def quantity(state = nil) + case state + when :on_hand + on_hand.sum(&:quantity) + when :backordered + backordered.sum(&:quantity) + else + contents.sum(&:quantity) + end + end + + def empty? + quantity.zero? + end + + def flattened + flat = [] + contents.each do |item| + item.quantity.times do + flat << ContentItem.new(item.variant, 1, item.state) + end + end + flat + end + + def flattened=(flattened) + contents.clear + flattened.each do |item| + current_item = find_item(item.variant, item.state) + if current_item + current_item.quantity += 1 + else + add(item.variant, item.quantity, item.state) + end + end + end + + def currency + # TODO calculate from first variant? + end + + # Returns all existing shipping categories. + # It disables the matching of product shipping category with shipping method's category + # It allows checkout of products with categories that are not the ship method's categories + # + # @return [Array] + def shipping_categories + Spree::ShippingCategory.all + end + + # Skips the methods that are not used by the order's distributor + # + # @return [Array] + def shipping_methods + available_shipping_methods = shipping_categories.flat_map(&:shipping_methods).uniq.to_a + + available_shipping_methods.keep_if do |shipping_method| + ships_with?(order.distributor.shipping_methods.to_a, shipping_method) + end + end + + def inspect + out = "#{order} - " + out << contents.map do |content_item| + "#{content_item.variant.name} #{content_item.quantity} #{content_item.state}" + end.join('/') + out + end + + def to_shipment + shipment = Spree::Shipment.new + shipment.order = order + shipment.stock_location = stock_location + shipment.shipping_rates = shipping_rates + + contents.each do |item| + item.quantity.times do + unit = shipment.inventory_units.build + unit.pending = true + unit.order = order + unit.variant = item.variant + unit.state = item.state.to_s + end + end + + shipment + end + + private + + # Checks whether the given distributor provides the specified shipping method + # + # @param shipping_methods [Array] + # @param shipping_method [Spree::ShippingMethod] + # @return [Boolean] + def ships_with?(shipping_methods, shipping_method) + shipping_methods.include?(shipping_method) + end + end + end +end diff --git a/engines/order_management/app/services/order_management/stock/packer.rb b/engines/order_management/app/services/order_management/stock/packer.rb new file mode 100644 index 0000000000..49141d3fef --- /dev/null +++ b/engines/order_management/app/services/order_management/stock/packer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module OrderManagement + module Stock + class Packer + attr_reader :stock_location, :order + + def initialize(stock_location, order) + @stock_location = stock_location + @order = order + end + + def package + package = OrderManagement::Stock::Package.new(stock_location, order) + order.line_items.each do |line_item| + next unless stock_location.stock_item(line_item.variant) + + on_hand, backordered = stock_location.fill_status(line_item.variant, line_item.quantity) + package.add line_item.variant, on_hand, :on_hand if on_hand.positive? + package.add line_item.variant, backordered, :backordered if backordered.positive? + end + package + end + end + end +end diff --git a/engines/order_management/app/services/order_management/stock/prioritizer.rb b/engines/order_management/app/services/order_management/stock/prioritizer.rb new file mode 100644 index 0000000000..9e8eea4289 --- /dev/null +++ b/engines/order_management/app/services/order_management/stock/prioritizer.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module OrderManagement + module Stock + class Prioritizer + attr_reader :packages, :order + + def initialize(order, packages, adjuster_class = OrderManagement::Stock::Adjuster) + @order = order + @packages = packages + @adjuster_class = adjuster_class + end + + def prioritized_packages + adjust_packages + prune_packages + packages + end + + private + + def adjust_packages + order.line_items.each do |line_item| + adjuster = @adjuster_class.new(line_item.variant, line_item.quantity, :on_hand) + + visit_packages(adjuster) + + adjuster.status = :backordered + visit_packages(adjuster) + end + end + + def visit_packages(adjuster) + packages.each do |package| + item = package.find_item adjuster.variant, adjuster.status + adjuster.adjust(item) if item + end + end + + def prune_packages + packages.reject!(&:empty?) + end + end + end +end diff --git a/engines/order_management/spec/services/order_management/stock/coordinator_spec.rb b/engines/order_management/spec/services/order_management/stock/coordinator_spec.rb new file mode 100644 index 0000000000..c2ab44e5f7 --- /dev/null +++ b/engines/order_management/spec/services/order_management/stock/coordinator_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module OrderManagement + module Stock + describe Coordinator do + let!(:order) { create(:order_with_line_items, distributor: create(:distributor_enterprise)) } + + subject { Coordinator.new(order) } + + context "packages" do + it "builds, prioritizes and estimates" do + expect(subject).to receive(:build_packages).ordered + expect(subject).to receive(:prioritize_packages).ordered + expect(subject).to receive(:estimate_packages).ordered + subject.packages + end + end + end + end +end diff --git a/engines/order_management/spec/services/order_management/stock/estimator_spec.rb b/engines/order_management/spec/services/order_management/stock/estimator_spec.rb new file mode 100644 index 0000000000..6d14903cbd --- /dev/null +++ b/engines/order_management/spec/services/order_management/stock/estimator_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module OrderManagement + module Stock + describe Estimator do + let!(:shipping_method) { create(:shipping_method, zones: [Spree::Zone.global] ) } + let(:package) { build(:stock_package_fulfilled) } + let(:order) { package.order } + subject { Estimator.new(order) } + + context "#shipping rates" do + before(:each) do + shipping_method.zones.first.members.create(zoneable: order.ship_address.country) + allow_any_instance_of(Spree::ShippingMethod). + to receive_message_chain(:calculator, :available?).and_return(true) + allow_any_instance_of(Spree::ShippingMethod). + to receive_message_chain(:calculator, :compute).and_return(4.00) + allow_any_instance_of(Spree::ShippingMethod). + to receive_message_chain(:calculator, :preferences). + and_return(currency: order.currency) + allow_any_instance_of(Spree::ShippingMethod). + to receive_message_chain(:calculator, :marked_for_destruction?) + + allow(package).to receive_messages(shipping_methods: [shipping_method]) + end + + context "the order's ship address is in the same zone" do + it "returns shipping rates from a shipping method" do + shipping_rates = subject.shipping_rates(package) + expect(shipping_rates.first.cost).to eq 4.00 + end + end + + context "the order's ship address is in a different zone" do + it "still returns shipping rates from a shipping method" do + shipping_method.zones.each{ |z| z.members.delete_all } + shipping_rates = subject.shipping_rates(package) + expect(shipping_rates.first.cost).to eq 4.00 + end + end + + context "the calculator is not available for that order" do + it "does not return shipping rates from a shipping method" do + allow_any_instance_of(Spree::ShippingMethod). + to receive_message_chain(:calculator, :available?).and_return(false) + shipping_rates = subject.shipping_rates(package) + expect(shipping_rates).to eq [] + end + end + + context "the currency matches the order's currency" do + it "returns shipping rates from a shipping method" do + shipping_rates = subject.shipping_rates(package) + expect(shipping_rates.first.cost).to eq 4.00 + end + end + + context "the currency is different than the order's currency" do + it "does not return shipping rates from a shipping method" do + order.currency = "USD" + shipping_rates = subject.shipping_rates(package) + expect(shipping_rates).to eq [] + end + end + + it "sorts shipping rates by cost" do + shipping_methods = 3.times.map { create(:shipping_method) } + allow(shipping_methods[0]). + to receive_message_chain(:calculator, :compute).and_return(5.00) + allow(shipping_methods[1]). + to receive_message_chain(:calculator, :compute).and_return(3.00) + allow(shipping_methods[2]). + to receive_message_chain(:calculator, :compute).and_return(4.00) + + allow(subject).to receive(:shipping_methods).and_return(shipping_methods) + + expected_costs = %w[3.00 4.00 5.00].map(&BigDecimal.method(:new)) + expect(subject.shipping_rates(package).map(&:cost)).to eq expected_costs + end + + context "general shipping methods" do + let(:shipping_methods) { 2.times.map { create(:shipping_method) } } + + it "selects the most affordable shipping rate" do + allow(shipping_methods[0]). + to receive_message_chain(:calculator, :compute).and_return(5.00) + allow(shipping_methods[1]). + to receive_message_chain(:calculator, :compute).and_return(3.00) + + allow(subject).to receive(:shipping_methods).and_return(shipping_methods) + + shipping_rates = subject.shipping_rates(package) + expect(shipping_rates.sort_by(&:cost).map(&:selected)).to eq [true, false] + end + + it "selects the cheapest shipping rate and doesn't raise exception over nil cost" do + allow(shipping_methods[0]). + to receive_message_chain(:calculator, :compute).and_return(1.00) + allow(shipping_methods[1]). + to receive_message_chain(:calculator, :compute).and_return(nil) + + allow(subject).to receive(:shipping_methods).and_return(shipping_methods) + + subject.shipping_rates(package) + end + end + + context "involves backend only shipping methods" do + let(:backend_method) { create(:shipping_method, display_on: "back_end") } + let(:generic_method) { create(:shipping_method) } + + # regression for #3287 + it "doesn't select backend rates even if they're more affordable" do + allow(backend_method).to receive_message_chain(:calculator, :compute).and_return(0.00) + allow(generic_method).to receive_message_chain(:calculator, :compute).and_return(5.00) + + allow(subject). + to receive(:shipping_methods).and_return([backend_method, generic_method]) + + expect(subject.shipping_rates(package).map(&:selected)).to eq [false, true] + end + end + end + end + end +end diff --git a/engines/order_management/spec/services/order_management/stock/package_spec.rb b/engines/order_management/spec/services/order_management/stock/package_spec.rb new file mode 100644 index 0000000000..205d50ae1a --- /dev/null +++ b/engines/order_management/spec/services/order_management/stock/package_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module OrderManagement + module Stock + describe Package do + context "base tests" do + let(:variant) { build(:variant, weight: 25.0) } + let(:stock_location) { build(:stock_location) } + let(:distributor) { create(:enterprise) } + let(:order) { build(:order, distributor: distributor) } + + subject { Package.new(stock_location, order) } + + it 'calculates the weight of all the contents' do + subject.add variant, 4 + expect(subject.weight).to eq 100.0 + end + + it 'filters by on_hand and backordered' do + subject.add variant, 4, :on_hand + subject.add variant, 3, :backordered + expect(subject.on_hand.count).to eq 1 + expect(subject.backordered.count).to eq 1 + end + + it 'calculates the quantity by state' do + subject.add variant, 4, :on_hand + subject.add variant, 3, :backordered + + expect(subject.quantity).to eq 7 + expect(subject.quantity(:on_hand)).to eq 4 + expect(subject.quantity(:backordered)).to eq 3 + end + + it 'returns nil for content item not found' do + item = subject.find_item(variant, :on_hand) + expect(item).to be_nil + end + + it 'finds content item for a variant' do + subject.add variant, 4, :on_hand + item = subject.find_item(variant, :on_hand) + expect(item.quantity).to eq 4 + end + + it 'get flattened contents' do + subject.add variant, 4, :on_hand + subject.add variant, 2, :backordered + flattened = subject.flattened + expect(flattened.select { |i| i.state == :on_hand }.size).to eq 4 + expect(flattened.select { |i| i.state == :backordered }.size).to eq 2 + end + + it 'set contents from flattened' do + flattened = [Package::ContentItem.new(variant, 1, :on_hand), + Package::ContentItem.new(variant, 1, :on_hand), + Package::ContentItem.new(variant, 1, :backordered), + Package::ContentItem.new(variant, 1, :backordered)] + + subject.flattened = flattened + expect(subject.on_hand.size).to eq 1 + expect(subject.on_hand.first.quantity).to eq 2 + + expect(subject.backordered.size).to eq 1 + end + + # Contains regression test for #2804 + it 'builds a list of shipping methods from all categories' do + shipping_method1 = create(:shipping_method, distributors: [distributor]) + shipping_method2 = create(:shipping_method, distributors: [distributor]) + variant1 = create(:variant, + shipping_category: shipping_method1.shipping_categories.first) + variant2 = create(:variant, + shipping_category: shipping_method2.shipping_categories.first) + variant3 = create(:variant, shipping_category: nil) + contents = [Package::ContentItem.new(variant1, 1), + Package::ContentItem.new(variant1, 1), + Package::ContentItem.new(variant2, 1), + Package::ContentItem.new(variant3, 1)] + + package = Package.new(stock_location, order, contents) + expect(package.shipping_methods.size).to eq 2 + end + + it "can convert to a shipment" do + flattened = [Package::ContentItem.new(variant, 2, :on_hand), + Package::ContentItem.new(variant, 1, :backordered)] + subject.flattened = flattened + + shipping_method = build(:shipping_method) + subject.shipping_rates = [ + Spree::ShippingRate.new(shipping_method: shipping_method, cost: 10.00, selected: true) + ] + + shipment = subject.to_shipment + expect(shipment.order).to eq subject.order + expect(shipment.stock_location).to eq subject.stock_location + expect(shipment.inventory_units.size).to eq 3 + + first_unit = shipment.inventory_units.first + expect(first_unit.variant).to eq variant + expect(first_unit.state).to eq 'on_hand' + expect(first_unit.order).to eq subject.order + expect(first_unit).to be_pending + + last_unit = shipment.inventory_units.last + expect(last_unit.variant).to eq variant + expect(last_unit.state).to eq 'backordered' + expect(last_unit.order).to eq subject.order + + expect(shipment.shipping_method).to eq shipping_method + end + end + + context "#shipping_methods and #shipping_categories" do + let(:stock_location) { double(:stock_location) } + + subject(:package) { Package.new(stock_location, order, contents) } + + let(:enterprise) { create(:enterprise) } + let(:other_enterprise) { create(:enterprise) } + + let(:order) { build(:order, distributor: enterprise) } + + let(:variant1) do + instance_double( + Spree::Variant, + shipping_category: shipping_method1.shipping_categories.first + ) + end + let(:variant2) do + instance_double( + Spree::Variant, + shipping_category: shipping_method2.shipping_categories.first + ) + end + let(:variant3) do + instance_double(Spree::Variant, shipping_category: nil) + end + + let(:contents) do + [ + Package::ContentItem.new(variant1, 1), + Package::ContentItem.new(variant1, 1), + Package::ContentItem.new(variant2, 1), + Package::ContentItem.new(variant3, 1) + ] + end + + let(:shipping_method1) { create(:shipping_method, distributors: [enterprise]) } + let(:shipping_method2) { create(:shipping_method, distributors: [other_enterprise]) } + + describe '#shipping_methods' do + it 'does not return shipping methods not used by the package\'s order distributor' do + expect(package.shipping_methods).to eq [shipping_method1] + end + end + + describe '#shipping_categories' do + it "returns ship categories that are not the ship categories of the order's products" do + package + other_shipping_category = Spree::ShippingCategory.create(name: "Custom") + + expect(package.shipping_categories).to eq [shipping_method1.shipping_categories.first, + other_shipping_category] + end + end + end + end + end +end diff --git a/engines/order_management/spec/services/order_management/stock/packer_spec.rb b/engines/order_management/spec/services/order_management/stock/packer_spec.rb new file mode 100644 index 0000000000..1100090af0 --- /dev/null +++ b/engines/order_management/spec/services/order_management/stock/packer_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module OrderManagement + module Stock + describe Packer do + let(:order) { create(:order_with_line_items, line_items_count: 5) } + let(:stock_location) { create(:stock_location) } + + subject { Packer.new(stock_location, order) } + + before { order.line_items.first.variant.update(weight: 1) } + + it 'builds a package with all the items' do + package = subject.package + + expect(package.contents.size).to eq 5 + expect(package.weight).to be_positive + end + + it 'variants are added as backordered without enough on_hand' do + expect(stock_location).to receive(:fill_status).exactly(5).times.and_return([2, 3]) + + package = subject.package + expect(package.on_hand.size).to eq 5 + expect(package.backordered.size).to eq 5 + end + end + end +end diff --git a/engines/order_management/spec/services/order_management/stock/prioritizer_spec.rb b/engines/order_management/spec/services/order_management/stock/prioritizer_spec.rb new file mode 100644 index 0000000000..b1783eb734 --- /dev/null +++ b/engines/order_management/spec/services/order_management/stock/prioritizer_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module OrderManagement + module Stock + describe Prioritizer do + let(:order) { create(:order_with_line_items, line_items_count: 2) } + let(:stock_location) { build(:stock_location) } + let(:variant1) { order.line_items[0].variant } + let(:variant2) { order.line_items[1].variant } + + def pack + package = Package.new(order, stock_location) + yield(package) if block_given? + package + end + + it 'keeps a single package' do + package1 = pack do |package| + package.add variant1, 1, :on_hand + package.add variant2, 1, :on_hand + end + + packages = [package1] + prioritizer = Prioritizer.new(order, packages) + packages = prioritizer.prioritized_packages + expect(packages.size).to eq 1 + end + + it 'removes duplicate packages' do + package1 = pack do |package| + package.add variant1, 1, :on_hand + package.add variant2, 1, :on_hand + end + package2 = pack do |package| + package.add variant1, 1, :on_hand + package.add variant2, 1, :on_hand + end + + packages = [package1, package2] + prioritizer = Prioritizer.new(order, packages) + packages = prioritizer.prioritized_packages + expect(packages.size).to eq 1 + end + + it 'split over 2 packages' do + package1 = pack do |package| + package.add variant1, 1, :on_hand + end + package2 = pack do |package| + package.add variant2, 1, :on_hand + end + + packages = [package1, package2] + prioritizer = Prioritizer.new(order, packages) + packages = prioritizer.prioritized_packages + expect(packages.size).to eq 2 + end + + it '1st has some, 2nd has remaining' do + allow(order.line_items[0]).to receive_messages(quantity: 5) + package1 = pack do |package| + package.add variant1, 2, :on_hand + end + package2 = pack do |package| + package.add variant1, 5, :on_hand + end + + packages = [package1, package2] + prioritizer = Prioritizer.new(order, packages) + packages = prioritizer.prioritized_packages + expect(packages.count).to eq 2 + expect(packages[0].quantity).to eq 2 + expect(packages[1].quantity).to eq 3 + end + + it '1st has backorder, 2nd has some' do + allow(order.line_items[0]).to receive_messages(quantity: 5) + package1 = pack do |package| + package.add variant1, 5, :backordered + end + package2 = pack do |package| + package.add variant1, 2, :on_hand + end + + packages = [package1, package2] + prioritizer = Prioritizer.new(order, packages) + packages = prioritizer.prioritized_packages + + expect(packages[0].quantity(:backordered)).to eq 3 + expect(packages[1].quantity(:on_hand)).to eq 2 + end + + it '1st has backorder, 2nd has all' do + allow(order.line_items[0]).to receive_messages(quantity: 5) + package1 = pack do |package| + package.add variant1, 3, :backordered + package.add variant2, 1, :on_hand + end + package2 = pack do |package| + package.add variant1, 5, :on_hand + end + + packages = [package1, package2] + prioritizer = Prioritizer.new(order, packages) + packages = prioritizer.prioritized_packages + expect(packages[0].quantity(:backordered)).to eq 0 + expect(packages[1].quantity(:on_hand)).to eq 5 + end + end + end +end diff --git a/lib/spree/core/environment.rb b/lib/spree/core/environment.rb new file mode 100644 index 0000000000..4cd6fb5133 --- /dev/null +++ b/lib/spree/core/environment.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Spree + module Core + class Environment + include EnvironmentExtension + + attr_accessor :calculators, :payment_methods, :preferences + + def initialize + @calculators = Calculators.new + @preferences = Spree::AppConfiguration.new + end + end + end +end diff --git a/spec/config/application_spec.rb b/spec/config/application_spec.rb deleted file mode 100644 index 126325a4f1..0000000000 --- a/spec/config/application_spec.rb +++ /dev/null @@ -1,9 +0,0 @@ -require 'spec_helper' - -describe Openfoodnetwork::Application, 'configuration' do - let(:config) { described_class.config } - - it "sets Spree::Stock::Splitter::Base as the only stock splitter" do - expect(config.spree.stock_splitters).to eq [Spree::Stock::Splitter::Base] - end -end diff --git a/spec/features/admin/order_spec.rb b/spec/features/admin/order_spec.rb index 0017d6b72a..009092686b 100644 --- a/spec/features/admin/order_spec.rb +++ b/spec/features/admin/order_spec.rb @@ -285,7 +285,7 @@ feature ' from: 'selected_shipping_rate_id' find('.save-method').click - expect(page).to have_content different_shipping_method_for_distributor1.name + expect(page).to have_content "Shipping: #{different_shipping_method_for_distributor1.name}" end end diff --git a/spec/models/spree/calculator/flat_percent_item_total_spec.rb b/spec/models/spree/calculator/flat_percent_item_total_spec.rb index 529c2b11d9..ec4b54fc70 100644 --- a/spec/models/spree/calculator/flat_percent_item_total_spec.rb +++ b/spec/models/spree/calculator/flat_percent_item_total_spec.rb @@ -14,7 +14,7 @@ describe Spree::Calculator::FlatPercentItemTotal do it_behaves_like "a model using the LocalizedNumber module", [:preferred_flat_percent] end - it "computes amount correctly for a given Stock::Package" do + it "computes amount correctly for a given OrderManagement::Stock::Package" do order = double(:order, line_items: [line_item] ) package = double(:package, order: order) diff --git a/spec/models/spree/order/checkout_spec.rb b/spec/models/spree/order/checkout_spec.rb index e2a133ebbc..8eed383c6a 100644 --- a/spec/models/spree/order/checkout_spec.rb +++ b/spec/models/spree/order/checkout_spec.rb @@ -42,9 +42,6 @@ describe Spree::Order do it "can progress to delivery" do shipping_method.shipping_categories << other_shipping_category - # If the shipping category package splitter is enabled, - # an order with products with two shipping categories will be split into two shipments - # and the spec will fail with a unique constraint error on index_spree_shipments_on_order_id order.next order.next expect(order.state).to eq "delivery" diff --git a/spec/models/spree/shipment_spec.rb b/spec/models/spree/shipment_spec.rb index b37dd498ff..e24cebb801 100644 --- a/spec/models/spree/shipment_spec.rb +++ b/spec/models/spree/shipment_spec.rb @@ -1,33 +1,485 @@ -require "spec_helper" +# frozen_string_literal: true + +require 'spec_helper' +require 'benchmark' describe Spree::Shipment do - describe "manifest" do - let!(:product) { create(:product) } - let!(:order) { create(:order, distributor: product.supplier) } - let!(:deleted_variant) { create(:variant, product: product) } - let!(:other_variant) { create(:variant, product: product) } - let!(:line_item_for_deleted) { create(:line_item, order: order, variant: deleted_variant) } - let!(:line_item_for_other) { create(:line_item, order: order, variant: other_variant) } - let!(:shipment) { create(:shipment_with, :shipping_method, order: order) } + let(:order) { build(:order) } + let(:shipping_method) { build(:shipping_method, name: "UPS") } + let(:shipment) do + shipment = Spree::Shipment.new order: order + allow(shipment).to receive_messages(shipping_method: shipping_method) + shipment.state = 'pending' + shipment + end - context "when the variant is soft-deleted" do - before { deleted_variant.delete } + let(:charge) { build(:adjustment) } + let(:variant) { build(:variant) } - it "can still access the variant" do - shipment.reload - variants = shipment.manifest.map(&:variant).uniq - expect(variants.sort_by(&:id)).to eq([deleted_variant, other_variant].sort_by(&:id)) + it 'is backordered if one of its inventory_units is backordered' do + unit1 = create(:inventory_unit) + unit2 = create(:inventory_unit) + allow(unit1).to receive(:backordered?) { false } + allow(unit2).to receive(:backordered?) { true } + allow(shipment).to receive_messages(inventory_units: [unit1, unit2]) + expect(shipment).to be_backordered + end + + context "#cost" do + it "should return the amount of any shipping charges that it originated" do + allow(shipment).to receive_message_chain :adjustment, amount: 10 + expect(shipment.cost).to eq 10 + end + + it "should return 0 if there are no relevant shipping adjustments" do + expect(shipment.cost).to eq 0 + end + end + + context "display_cost" do + it "retuns a Spree::Money" do + allow(shipment).to receive(:cost) { 21.22 } + expect(shipment.display_cost).to eq Spree::Money.new(21.22) + end + end + + context "display_item_cost" do + it "retuns a Spree::Money" do + allow(shipment).to receive(:item_cost) { 21.22 } + expect(shipment.display_item_cost).to eq Spree::Money.new(21.22) + end + end + + context "display_total_cost" do + it "retuns a Spree::Money" do + allow(shipment).to receive(:total_cost) { 21.22 } + expect(shipment.display_total_cost).to eq Spree::Money.new(21.22) + end + end + + it "#item_cost" do + shipment = create(:shipment, order: create(:order_with_totals)) + expect(shipment.item_cost).to eql(10.0) + end + + context "manifest" do + let(:order) { Spree::Order.create } + let(:variant) { create(:variant) } + let!(:line_item) { order.contents.add variant } + let!(:shipment) { order.create_proposed_shipments.first } + + it "returns variant expected" do + expect(shipment.manifest.first.variant).to eq variant + end + + context "variant was removed" do + before { variant.product.destroy } + + it "still returns variant expected" do + expect(shipment.manifest.first.variant).to eq variant end end - context "when the product is soft-deleted" do - before { deleted_variant.product.delete } + describe "with soft-deleted products or variants" do + let!(:product) { create(:product) } + let!(:order) { create(:order, distributor: product.supplier) } - it "can still access the variant" do - shipment.reload - variants = shipment.manifest.map(&:variant) - expect(variants.sort_by(&:id)).to eq([deleted_variant, other_variant].sort_by(&:id)) + context "when the variant is soft-deleted" do + it "can still access the variant" do + order.line_items.first.variant.delete + + variants = shipment.reload.manifest.map(&:variant).uniq + expect(variants).to eq [order.line_items.first.variant] + end + end + + context "when the product is soft-deleted" do + it "can still access the variant" do + order.line_items.first.variant.delete + + variants = shipment.reload.manifest.map(&:variant) + expect(variants).to eq [order.line_items.first.variant] + end end end end + + context 'shipping_rates' do + let(:shipment) { create(:shipment) } + let(:shipping_method1) { create(:shipping_method) } + let(:shipping_method2) { create(:shipping_method) } + let(:shipping_rates) { + [ + Spree::ShippingRate.new(shipping_method: shipping_method1, cost: 10.00, selected: true), + Spree::ShippingRate.new(shipping_method: shipping_method2, cost: 20.00) + ] + } + + it 'returns shipping_method from selected shipping_rate' do + shipment.shipping_rates.delete_all + shipment.shipping_rates.create shipping_method: shipping_method1, cost: 10.00, selected: true + expect(shipment.shipping_method).to eq shipping_method1 + end + + context 'refresh_rates' do + let(:mock_estimator) { double('estimator', shipping_rates: shipping_rates) } + + it 'should request new rates, and maintain shipping_method selection' do + expect(OrderManagement::Stock::Estimator). + to receive(:new).with(shipment.order).and_return(mock_estimator) + # The first call is for the original shippping method, + # the second call is for the shippping method after the Estimator was executed + allow(shipment).to receive(:shipping_method).and_return(shipping_method2, shipping_method1) + + expect(shipment.refresh_rates).to eq shipping_rates + expect(shipment.reload.selected_shipping_rate.shipping_method_id).to eq shipping_method2.id + end + + it 'should handle no shipping_method selection' do + expect(OrderManagement::Stock::Estimator). + to receive(:new).with(shipment.order).and_return(mock_estimator) + allow(shipment).to receive_messages(shipping_method: nil) + expect(shipment.refresh_rates).to eq shipping_rates + expect(shipment.reload.selected_shipping_rate).to_not be_nil + end + + it 'should not refresh if shipment is shipped' do + expect(OrderManagement::Stock::Estimator).not_to receive(:new) + shipment.shipping_rates.delete_all + allow(shipment).to receive_messages(shipped?: true) + expect(shipment.refresh_rates).to eq [] + end + + context 'to_package' do + it 'should use symbols for states when adding contents to package' do + allow(shipment). + to receive_message_chain(:inventory_units, + includes: [build(:inventory_unit, variant: variant, + state: 'on_hand'), + build(:inventory_unit, variant: variant, + state: 'backordered')] ) + package = shipment.to_package + expect(package.on_hand.count).to eq 1 + expect(package.backordered.count).to eq 1 + end + end + end + end + + it '#total_cost' do + allow(shipment).to receive_messages cost: 5.0 + allow(shipment).to receive_messages item_cost: 50.0 + expect(shipment.total_cost).to eql(55.0) + end + + context "#update!" do + shared_examples_for "immutable once shipped" do + it "should remain in shipped state once shipped" do + shipment.state = 'shipped' + expect(shipment).to receive(:update_column).with(:state, 'shipped') + shipment.update!(order) + end + end + + shared_examples_for "pending if backordered" do + it "should have a state of pending if backordered" do + unit = create(:inventory_unit) + allow(unit).to receive(:backordered?) { true } + allow(shipment).to receive_messages(inventory_units: [unit]) + expect(shipment).to receive(:update_column).with(:state, 'pending') + shipment.update!(order) + end + end + + context "when order is canceled" do + it "should result in a 'pending' state" do + allow(order).to receive(:canceled?) { true } + + expect(shipment).to receive(:update_column).with(:state, 'canceled') + shipment.update!(order) + end + end + + context "when order cannot ship" do + it "should result in a 'pending' state" do + allow(order).to receive(:can_ship?) { false } + + expect(shipment).to receive(:update_column).with(:state, 'pending') + shipment.update!(order) + end + end + + context "when order can ship" do + before { allow(order).to receive(:can_ship?) { true } } + + context "when order is paid" do + before { allow(order).to receive(:paid?) { true } } + + it "should result in a 'ready' state" do + expect(shipment).to receive(:update_column).with(:state, 'ready') + shipment.update!(order) + end + + it_should_behave_like 'immutable once shipped' + + it_should_behave_like 'pending if backordered' + + context "when order has a credit owed" do + before { allow(order).to receive(:payment_state) { 'credit_owed' } } + + it "should result in a 'ready' state" do + shipment.state = 'pending' + expect(shipment).to receive(:update_column).with(:state, 'ready') + shipment.update!(order) + end + + it_should_behave_like 'immutable once shipped' + + it_should_behave_like 'pending if backordered' + end + end + + context "when order has balance due" do + before { allow(order).to receive(:paid?) { false } } + + it "should result in a 'pending' state" do + shipment.state = 'ready' + expect(shipment).to receive(:update_column).with(:state, 'pending') + shipment.update!(order) + end + + it_should_behave_like 'immutable once shipped' + + it_should_behave_like 'pending if backordered' + end + end + + context "when shipment state changes to shipped" do + it "should call after_ship" do + shipment.state = 'pending' + expect(shipment).to receive :after_ship + allow(shipment).to receive_messages determine_state: 'shipped' + expect(shipment).to receive(:update_column).with(:state, 'shipped') + shipment.update!(order) + end + end + end + + context "when order is completed" do + before do + allow(order).to receive_messages completed?: true + allow(order).to receive_messages canceled?: false + end + + it "should validate with inventory" do + shipment.inventory_units = [create(:inventory_unit)] + expect(shipment.valid?).to be_truthy + end + end + + context "#cancel" do + it 'cancels the shipment' do + allow(shipment).to receive(:ensure_correct_adjustment) + allow(shipment.order).to receive(:update!) + + shipment.state = 'pending' + expect(shipment).to receive(:after_cancel) + shipment.cancel! + expect(shipment.state).to eq 'canceled' + end + + it 'restocks the items' do + unit = double(:inventory_unit, variant: variant) + allow(unit).to receive(:quantity) { 1 } + allow(shipment).to receive_message_chain(:inventory_units, + :group_by, + map: [unit]) + shipment.stock_location = build(:stock_location) + expect(shipment.stock_location).to receive(:restock).with(variant, 1, shipment) + shipment.after_cancel + end + end + + context "#resume" do + it 'will determine new state based on order' do + allow(shipment).to receive(:ensure_correct_adjustment) + allow(shipment.order).to receive(:update!) + + shipment.state = 'canceled' + expect(shipment).to receive(:determine_state).and_return(:ready) + expect(shipment).to receive(:after_resume) + shipment.resume! + expect(shipment.state).to eq 'ready' + end + + it 'unstocks the items' do + unit = create(:inventory_unit, variant: variant) + allow(unit).to receive(:quantity) { 1 } + allow(shipment).to receive_message_chain(:inventory_units, + :group_by, + map: [unit]) + shipment.stock_location = create(:stock_location) + expect(shipment.stock_location).to receive(:unstock).with(variant, 1, shipment) + shipment.after_resume + end + + it 'will determine new state based on order' do + allow(shipment).to receive(:ensure_correct_adjustment) + allow(shipment.order).to receive(:update!) + + shipment.state = 'canceled' + expect(shipment).to receive(:determine_state).twice.and_return('ready') + expect(shipment).to receive(:after_resume) + shipment.resume! + # Shipment is pending because order is already paid + expect(shipment.state).to eq 'pending' + end + end + + context "#ship" do + before do + allow(order).to receive(:update!) + allow(shipment).to receive_messages(update_order: true, state: 'ready') + allow(shipment).to receive_messages(adjustment: charge) + allow(shipping_method).to receive(:create_adjustment) + allow(shipment).to receive(:ensure_correct_adjustment) + end + + it "should update shipped_at timestamp" do + allow(shipment).to receive(:send_shipped_email) + shipment.ship! + expect(shipment.shipped_at).to_not be_nil + # Ensure value is persisted + shipment.reload + expect(shipment.shipped_at).to_not be_nil + end + + it "should send a shipment email" do + mail_message = double 'Mail::Message' + shipment_id = nil + expect(Spree::ShipmentMailer).to receive(:shipped_email) { |*args| + shipment_id = args[0] + mail_message + } + expect(mail_message).to receive :deliver + shipment.ship! + expect(shipment_id).to eq shipment.id + end + + it "should finalize the shipment's adjustment" do + allow(shipment).to receive(:send_shipped_email) + shipment.ship! + expect(shipment.adjustment.state).to eq 'finalized' + expect(shipment.adjustment).to be_immutable + end + end + + context "#ready" do + # Regression test for #2040 + it "cannot ready a shipment for an order if the order is unpaid" do + allow(order).to receive_messages(paid?: false) + assert !shipment.can_ready? + end + end + + context "ensure_correct_adjustment" do + before { allow(shipment).to receive(:reload) } + + it "should create adjustment when not present" do + allow(shipment).to receive_messages(selected_shipping_rate_id: 1) + expect(shipping_method).to receive(:create_adjustment).with(shipping_method.adjustment_label, + order, shipment, true, "open") + shipment.__send__(:ensure_correct_adjustment) + end + + # Regression test for #3138 + it "should use the shipping method's adjustment label" do + allow(shipment).to receive_messages(selected_shipping_rate_id: 1) + allow(shipping_method).to receive_messages(adjustment_label: "Foobar") + expect(shipping_method).to receive(:create_adjustment).with("Foobar", order, + shipment, true, "open") + shipment.__send__(:ensure_correct_adjustment) + end + + it "should update originator when adjustment is present" do + allow(shipment). + to receive_messages(selected_shipping_rate: Spree::ShippingRate.new(cost: 10.00)) + adjustment = build(:adjustment) + allow(shipment).to receive_messages(adjustment: adjustment) + allow(adjustment).to receive(:open?) { true } + expect(shipment.adjustment).to receive(:originator=).with(shipping_method) + expect(shipment.adjustment).to receive(:label=).with(shipping_method.adjustment_label) + expect(shipment.adjustment).to receive(:amount=).with(10.00) + allow(shipment.adjustment).to receive(:save!) + expect(shipment.adjustment).to receive(:reload) + shipment.__send__(:ensure_correct_adjustment) + end + + it 'should not update amount if adjustment is not open?' do + allow(shipment). + to receive_messages(selected_shipping_rate: Spree::ShippingRate.new(cost: 10.00)) + adjustment = build(:adjustment) + allow(shipment).to receive_messages(adjustment: adjustment) + allow(adjustment).to receive(:open?) { false } + expect(shipment.adjustment).to receive(:originator=).with(shipping_method) + expect(shipment.adjustment).to receive(:label=).with(shipping_method.adjustment_label) + expect(shipment.adjustment).not_to receive(:amount=).with(10.00) + allow(shipment.adjustment).to receive(:save!) + expect(shipment.adjustment).to receive(:reload) + shipment.__send__(:ensure_correct_adjustment) + end + end + + context "update_order" do + it "should update order" do + expect(order).to receive(:update!) + shipment.__send__(:update_order) + end + end + + context "after_save" do + it "should run correct callbacks" do + expect(shipment).to receive(:ensure_correct_adjustment) + expect(shipment).to receive(:update_order) + shipment.run_callbacks(:save) + end + end + + context "currency" do + it "returns the order currency" do + expect(shipment.currency).to eq order.currency + end + end + + context "#tracking_url" do + it "uses shipping method to determine url" do + expect(shipping_method).to receive(:build_tracking_url).with('1Z12345').and_return(:some_url) + shipment.tracking = '1Z12345' + + expect(shipment.tracking_url).to eq :some_url + end + end + + context "set up new inventory units" do + let(:variant) { double("Variant", id: 9) } + let(:inventory_units) { double } + let(:params) do + { variant_id: variant.id, state: 'on_hand', order_id: order.id } + end + + before { allow(shipment).to receive_messages inventory_units: inventory_units } + + it "associates variant and order" do + expect(inventory_units).to receive(:create).with(params) + unit = shipment.set_up_inventory('on_hand', variant, order) + end + end + + # Regression test for #3349 + context "#destroy" do + it "destroys linked shipping_rates" do + reflection = Spree::Shipment.reflect_on_association(:shipping_rates) + reflection.options[:dependent] = :destroy + end + end end diff --git a/spec/models/stock/package_spec.rb b/spec/models/stock/package_spec.rb deleted file mode 100644 index 6a3ac355cc..0000000000 --- a/spec/models/stock/package_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -require 'spec_helper' - -module Stock - describe Package do - let(:stock_location) { double(:stock_location) } - - subject(:package) { Package.new(stock_location, order, contents) } - - let(:enterprise) { create(:enterprise) } - let(:other_enterprise) { create(:enterprise) } - - let(:order) { build(:order, distributor: enterprise) } - - let(:variant1) do - instance_double( - Spree::Variant, - shipping_category: shipping_method1.shipping_categories.first - ) - end - let(:variant2) do - instance_double( - Spree::Variant, - shipping_category: shipping_method2.shipping_categories.first - ) - end - let(:variant3) do - instance_double(Spree::Variant, shipping_category: nil) - end - - let(:contents) do - [ - Package::ContentItem.new(variant1, 1), - Package::ContentItem.new(variant1, 1), - Package::ContentItem.new(variant2, 1), - Package::ContentItem.new(variant3, 1) - ] - end - - let(:shipping_method1) { create(:shipping_method, distributors: [enterprise]) } - let(:shipping_method2) { create(:shipping_method, distributors: [other_enterprise]) } - - describe '#shipping_methods' do - it 'does not return shipping methods not used by the package\'s order distributor' do - expect(package.shipping_methods).to eq [shipping_method1] - end - end - - describe '#shipping_categories' do - it "returns shipping categories that are not shipping categories of the order's products" do - package - other_shipping_category = Spree::ShippingCategory.create(name: "Custom") - - expect(package.shipping_categories).to eq [shipping_method1.shipping_categories.first, - other_shipping_category] - end - end - end -end