Bring shipment from spree_core

This commit is contained in:
Luis Ramos
2020-07-01 17:29:18 +01:00
parent 55a4021157
commit 4e5259f491
2 changed files with 745 additions and 21 deletions

View File

@@ -0,0 +1,290 @@
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
number if number
generate_shipment_number unless number
number.to_s.to_url.upcase
end
def backordered?
inventory_units.any? { |inventory_unit| inventory_unit.backordered? }
end
def shipped=(value)
return unless value == '1' && shipped_at.nil?
self.shipped_at = Time.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)
self.save!
end
def refresh_rates
return shipping_rates if shipped?
self.shipping_rates = Stock::Estimator.new(order).shipping_rates(to_package)
if shipping_method
selected_rate = shipping_rates.detect { |rate|
rate.shipping_method_id == shipping_method.id
}
self.selected_shipping_rate_id = selected_rate.id if selected_rate
end
shipping_rates
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 but 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.joins(:variant).includes(:variant).group_by(&:variant).map do |variant, units|
states = {}
units.group_by(&:state).each { |state, iu| states[state] = iu.count }
OpenStruct.new(variant: variant, quantity: units.length, states: states)
end
end
def line_items
if order.complete? and Spree::Config[:track_inventory_levels]
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' and 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 = Spree::Config.package_factory.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)
self.inventory_units.create(variant_id: variant.id, state: state, order_id: order.id)
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 unless number.blank?
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
unless shipping_method.nil?
errors.add :shipping_method, Spree.t(:is_not_available_to_shipment_address) unless shipping_method.include?(address)
end
end
def after_ship
inventory_units.each &:ship!
adjustment.finalize!
send_shipped_email
touch :shipped_at
end
def send_shipped_email
ShipmentMailer.shipped_email(self.id).deliver
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
end
def update_order
order.update!
end
end
end

View File

@@ -1,33 +1,467 @@
require "spec_helper"
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) { mock_model Spree::Order, backordered?: false,
canceled?: false,
can_ship?: true,
currency: 'USD' }
let(:shipping_method) { create(:shipping_method, name: "UPS") }
let(:shipment) do
shipment = Spree::Shipment.new order: order
shipment.stub(shipping_method: shipping_method)
shipment.state = 'pending'
shipment
end
context "when the variant is soft-deleted" do
before { deleted_variant.delete }
let(:charge) { create(:adjustment) }
let(:variant) { mock_model(Spree::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 if its inventory_units is backordered' do
shipment.stub(inventory_units: [
mock_model(Spree::InventoryUnit, backordered?: false),
mock_model(Spree::InventoryUnit, backordered?: true)
])
shipment.should be_backordered
end
context "#cost" do
it "should return the amount of any shipping charges that it originated" do
shipment.stub_chain :adjustment, amount: 10
shipment.cost.should == 10
end
it "should return 0 if there are no relevant shipping adjustments" do
shipment.cost.should == 0
end
end
context "display_cost" do
it "retuns a Spree::Money" do
shipment.stub(:cost) { 21.22 }
shipment.display_cost.should == Spree::Money.new(21.22)
end
end
context "display_item_cost" do
it "retuns a Spree::Money" do
shipment.stub(:item_cost) { 21.22 }
shipment.display_item_cost.should == Spree::Money.new(21.22)
end
end
context "display_total_cost" do
it "retuns a Spree::Money" do
shipment.stub(:total_cost) { 21.22 }
shipment.display_total_cost.should == Spree::Money.new(21.22)
end
end
it "#item_cost" do
shipment = create(:shipment, order: create(:order_with_totals))
shipment.item_cost.should 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) }
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) }
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
before { deleted_variant.delete }
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))
end
end
context "when the product is soft-deleted" do
before { deleted_variant.product.delete }
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))
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
shipment.shipping_method.should 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
Spree::Stock::Estimator.should_receive(:new).with(shipment.order).and_return(mock_estimator)
shipment.stub(shipping_method: shipping_method2)
shipment.refresh_rates.should == shipping_rates
shipment.reload.selected_shipping_rate.shipping_method_id.should == shipping_method2.id
end
it 'should handle no shipping_method selection' do
Spree::Stock::Estimator.should_receive(:new).with(shipment.order).and_return(mock_estimator)
shipment.stub(shipping_method: nil)
shipment.refresh_rates.should == shipping_rates
shipment.reload.selected_shipping_rate.should_not be_nil
end
it 'should not refresh if shipment is shipped' do
Spree::Stock::Estimator.should_not_receive(:new)
shipment.shipping_rates.delete_all
shipment.stub(shipped?: true)
shipment.refresh_rates.should == []
end
context 'to_package' do
it 'should use symbols for states when adding contents to package' do
shipment.stub_chain(:inventory_units, includes: [ build(:inventory_unit, variant: variant, state: 'on_hand'),
build(:inventory_unit, variant: variant, state: 'backordered') ] )
package = shipment.to_package
package.on_hand.count.should eq 1
package.backordered.count.should eq 1
end
end
end
end
it '#total_cost' do
shipment.stub cost: 5.0
shipment.stub item_cost: 50.0
shipment.total_cost.should 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'
shipment.should_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
shipment.stub(inventory_units: [mock_model(Spree::InventoryUnit, backordered?: true)])
shipment.should_receive(:update_column).with(:state, 'pending')
shipment.update!(order)
end
end
context "when order cannot ship" do
before { order.stub can_ship?: false }
it "should result in a 'pending' state" do
shipment.should_receive(:update_column).with(:state, 'pending')
shipment.update!(order)
end
end
context "when order is paid" do
before { order.stub paid?: true }
it "should result in a 'ready' state" do
shipment.should_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
context "when order has balance due" do
before { order.stub paid?: false }
it "should result in a 'pending' state" do
shipment.state = 'ready'
shipment.should_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
context "when order has a credit owed" do
before { order.stub payment_state: 'credit_owed', paid?: true }
it "should result in a 'ready' state" do
shipment.state = 'pending'
shipment.should_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
context "when shipment state changes to shipped" do
it "should call after_ship" do
shipment.state = 'pending'
shipment.should_receive :after_ship
shipment.stub determine_state: 'shipped'
shipment.should_receive(:update_column).with(:state, 'shipped')
shipment.update!(order)
end
end
end
context "when track_inventory is false" do
before { Spree::Config.set track_inventory_levels: false }
after { Spree::Config.set track_inventory_levels: true }
it "should not use the line items from order when track_inventory_levels is false" do
line_items = [mock_model(Spree::LineItem)]
order.stub complete?: true
order.stub line_items: line_items
shipment.line_items.should == line_items
end
end
context "when order is completed" do
after { Spree::Config.set track_inventory_levels: true }
before do
order.stub completed?: true
order.stub canceled?: false
end
context "with inventory tracking" do
before { Spree::Config.set track_inventory_levels: true }
it "should validate with inventory" do
shipment.inventory_units = [create(:inventory_unit)]
shipment.valid?.should be_true
end
end
context "without inventory tracking" do
before { Spree::Config.set track_inventory_levels: false }
it "should validate with no inventory" do
shipment.valid?.should be_true
end
end
end
context "#cancel" do
it 'cancels the shipment' do
shipment.stub(:ensure_correct_adjustment)
shipment.order.stub(:update!)
shipment.state = 'pending'
shipment.should_receive(:after_cancel)
shipment.cancel!
shipment.state.should eq 'canceled'
end
it 'restocks the items' do
shipment.stub_chain(:inventory_units, :joins, includes: [mock_model(Spree::InventoryUnit, variant: variant)])
shipment.stock_location = mock_model(Spree::StockLocation)
shipment.stock_location.should_receive(:restock).with(variant, 1, shipment)
shipment.after_cancel
end
end
context "#resume" do
it 'will determine new state based on order' do
shipment.stub(:ensure_correct_adjustment)
shipment.order.stub(:update!)
shipment.state = 'canceled'
shipment.should_receive(:determine_state).and_return(:ready)
shipment.should_receive(:after_resume)
shipment.resume!
shipment.state.should eq 'ready'
end
it 'unstocks them items' do
shipment.stub_chain(:inventory_units, :joins, includes: [mock_model(Spree::InventoryUnit, variant: variant)])
shipment.stock_location = mock_model(Spree::StockLocation)
shipment.stock_location.should_receive(:unstock).with(variant, 1, shipment)
shipment.after_resume
end
it 'will determine new state based on order' do
shipment.stub(:ensure_correct_adjustment)
shipment.order.stub(:update!)
shipment.state = 'canceled'
shipment.should_receive(:determine_state).twice.and_return('ready')
shipment.should_receive(:after_resume)
shipment.resume!
# Shipment is pending because order is already paid
shipment.state.should eq 'pending'
end
end
context "#ship" do
before do
order.stub(:update!)
shipment.stub(require_inventory: false, update_order: true, state: 'ready')
shipment.stub(adjustment: charge)
shipping_method.stub(:create_adjustment)
shipment.stub(:ensure_correct_adjustment)
end
it "should update shipped_at timestamp" do
shipment.stub(:send_shipped_email)
shipment.ship!
shipment.shipped_at.should_not be_nil
# Ensure value is persisted
shipment.reload
shipment.shipped_at.should_not be_nil
end
it "should send a shipment email" do
mail_message = double 'Mail::Message'
shipment_id = nil
Spree::ShipmentMailer.should_receive(:shipped_email) { |*args|
shipment_id = args[0]
mail_message
}
mail_message.should_receive :deliver
shipment.ship!
shipment_id.should == shipment.id
end
it "should finalize the shipment's adjustment" do
shipment.stub(:send_shipped_email)
shipment.ship!
shipment.adjustment.state.should == 'finalized'
shipment.adjustment.should 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
order.stub(paid?: false)
assert !shipment.can_ready?
end
end
context "ensure_correct_adjustment" do
before { shipment.stub(:reload) }
it "should create adjustment when not present" do
shipment.stub(:selected_shipping_rate_id => 1)
shipping_method.should_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
shipment.stub(:selected_shipping_rate_id => 1)
shipping_method.stub(:adjustment_label => "Foobar")
shipping_method.should_receive(:create_adjustment).with("Foobar", order, shipment, true, "open")
shipment.send(:ensure_correct_adjustment)
end
it "should update originator when adjustment is present" do
shipment.stub(selected_shipping_rate: mock_model(Spree::ShippingRate, cost: 10.00))
shipment.stub(adjustment: mock_model(Spree::Adjustment, open?: true))
shipment.adjustment.should_receive(:originator=).with(shipping_method)
shipment.adjustment.should_receive(:label=).with(shipping_method.adjustment_label)
shipment.adjustment.should_receive(:amount=).with(10.00)
shipment.adjustment.should_receive(:save!)
shipment.adjustment.should_receive(:reload)
shipment.send(:ensure_correct_adjustment)
end
it 'should not update amount if adjustment is not open?' do
shipment.stub(selected_shipping_rate: mock_model(Spree::ShippingRate, cost: 10.00))
shipment.stub(adjustment: mock_model(Spree::Adjustment, open?: false))
shipment.adjustment.should_receive(:originator=).with(shipping_method)
shipment.adjustment.should_receive(:label=).with(shipping_method.adjustment_label)
shipment.adjustment.should_not_receive(:amount=).with(10.00)
shipment.adjustment.should_receive(:save!)
shipment.adjustment.should_receive(:reload)
shipment.send(:ensure_correct_adjustment)
end
end
context "update_order" do
it "should update order" do
order.should_receive(:update!)
shipment.send(:update_order)
end
end
context "after_save" do
it "should run correct callbacks" do
shipment.should_receive(:ensure_correct_adjustment)
shipment.should_receive(:update_order)
shipment.run_callbacks(:save)
end
end
context "currency" do
it "returns the order currency" do
shipment.currency.should == order.currency
end
end
context "#tracking_url" do
it "uses shipping method to determine url" do
shipping_method.should_receive(:build_tracking_url).with('1Z12345').and_return(:some_url)
shipment.tracking = '1Z12345'
shipment.tracking_url.should == :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 { shipment.stub 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