mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-02-05 22:26:07 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
337
app/models/spree/shipment.rb
Normal file
337
app/models/spree/shipment.rb
Normal 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
|
||||
@@ -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
|
||||
55
app/models/spree/stock/availability_validator.rb
Normal file
55
app/models/spree/stock/availability_validator.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
16
lib/spree/core/environment.rb
Normal file
16
lib/spree/core/environment.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user