Merge pull request #5715 from luisramos0/ship_method

Fix issue in Spree 2.1 and bring Spree::Stock classes to OFN 🎉
This commit is contained in:
Luis Ramos
2020-07-06 19:15:50 +01:00
committed by GitHub
28 changed files with 1725 additions and 241 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<Spree::ShippingCategory>]
def shipping_categories
Spree::ShippingCategory.all
end
# Skips the methods that are not used by the order's distributor
#
# @return [Array<Spree::ShippingMethod>]
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<Spree::ShippingMethod>]
# @param shipping_method [Spree::ShippingMethod]
# @return [Boolean]
def ships_with?(shipping_methods, shipping_method)
shipping_methods.include?(shipping_method)
end
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<Spree::ShippingCategory>]
def shipping_categories
Spree::ShippingCategory.all
end
# Skips the methods that are not used by the order's distributor
#
# @return [Array<Spree::ShippingMethod>]
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<Spree::ShippingMethod>]
# @param shipping_method [Spree::ShippingMethod]
# @return [Boolean]
def ships_with?(shipping_methods, shipping_method)
shipping_methods.include?(shipping_method)
end
end
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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