diff --git a/app/models/spree/shipment.rb b/app/models/spree/shipment.rb new file mode 100644 index 0000000000..b49f8f5bdc --- /dev/null +++ b/app/models/spree/shipment.rb @@ -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 diff --git a/spec/models/spree/shipment_spec.rb b/spec/models/spree/shipment_spec.rb index b37dd498ff..a9992c89c5 100644 --- a/spec/models/spree/shipment_spec.rb +++ b/spec/models/spree/shipment_spec.rb @@ -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