diff --git a/app/models/spree/stock_location.rb b/app/models/spree/stock_location.rb new file mode 100644 index 0000000000..57cfd3f2b1 --- /dev/null +++ b/app/models/spree/stock_location.rb @@ -0,0 +1,79 @@ +module Spree + class StockLocation < ActiveRecord::Base + has_many :stock_items, dependent: :delete_all + has_many :stock_movements, through: :stock_items + + belongs_to :state, class_name: 'Spree::State' + belongs_to :country, class_name: 'Spree::Country' + + validates_presence_of :name + + scope :active, -> { where(active: true) } + + after_create :create_stock_items, :if => "self.propagate_all_variants?" + + # Wrapper for creating a new stock item respecting the backorderable config + def propagate_variant(variant) + self.stock_items.create!(variant: variant, backorderable: self.backorderable_default) + end + + # Return either an existing stock item or create a new one. Useful in + # scenarios where the user might not know whether there is already a stock + # item for a given variant + def set_up_stock_item(variant) + self.stock_item(variant) || propagate_variant(variant) + end + + def stock_item(variant) + stock_items.where(variant_id: variant).order(:id).first + end + + def stock_item_or_create(variant) + stock_item(variant) || stock_items.create(variant: variant) + end + + def count_on_hand(variant) + stock_item(variant).try(:count_on_hand) + end + + def backorderable?(variant) + stock_item(variant).try(:backorderable?) + end + + def restock(variant, quantity, originator = nil) + move(variant, quantity, originator) + end + + def unstock(variant, quantity, originator = nil) + move(variant, -quantity, originator) + end + + def move(variant, quantity, originator = nil) + stock_item_or_create(variant).stock_movements.create!(quantity: quantity, + originator: originator) + end + + def fill_status(variant, quantity) + if item = stock_item(variant) + + if item.count_on_hand >= quantity + on_hand = quantity + backordered = 0 + else + on_hand = item.count_on_hand + on_hand = 0 if on_hand < 0 + backordered = item.backorderable? ? (quantity - on_hand) : 0 + end + + [on_hand, backordered] + else + [0, 0] + end + end + + private + def create_stock_items + Variant.find_each { |variant| self.propagate_variant(variant) } + end + end +end diff --git a/app/models/spree/stock_movement.rb b/app/models/spree/stock_movement.rb new file mode 100644 index 0000000000..33281653c0 --- /dev/null +++ b/app/models/spree/stock_movement.rb @@ -0,0 +1,25 @@ +module Spree + class StockMovement < ActiveRecord::Base + belongs_to :stock_item, class_name: 'Spree::StockItem' + belongs_to :originator, polymorphic: true + + + after_create :update_stock_item_quantity + + validates :stock_item, presence: true + validates :quantity, presence: true + + scope :recent, -> { order('created_at DESC') } + + def readonly? + !new_record? + end + + private + def update_stock_item_quantity + return unless Spree::Config[:track_inventory_levels] + stock_item.adjust_count_on_hand quantity + end + end +end + diff --git a/spec/models/spree/stock_location_spec.rb b/spec/models/spree/stock_location_spec.rb new file mode 100644 index 0000000000..02a55ac877 --- /dev/null +++ b/spec/models/spree/stock_location_spec.rb @@ -0,0 +1,205 @@ +require 'spec_helper' + +module Spree + describe StockLocation do + subject { create(:stock_location_with_items, backorderable_default: true) } + let(:stock_item) { subject.stock_items.order(:id).first } + let(:variant) { stock_item.variant } + + it 'creates stock_items for all variants' do + subject.stock_items.count.should eq Variant.count + end + + context "handling stock items" do + let!(:variant) { create(:variant) } + + context "given a variant" do + subject { StockLocation.create(name: "testing", propagate_all_variants: false) } + + context "set up" do + it "creates stock item" do + subject.should_receive(:propagate_variant) + subject.set_up_stock_item(variant) + end + + context "stock item exists" do + let!(:stock_item) { subject.propagate_variant(variant) } + + it "returns existing stock item" do + subject.set_up_stock_item(variant).should == stock_item + end + end + end + + context "propagate variants" do + let(:stock_item) { subject.propagate_variant(variant) } + + it "creates a new stock item" do + expect { + subject.propagate_variant(variant) + }.to change{ StockItem.count }.by(1) + end + + context "passes backorderable default config" do + context "true" do + before { subject.backorderable_default = true } + it { stock_item.backorderable.should be_true } + end + + context "false" do + before { subject.backorderable_default = false } + it { stock_item.backorderable.should be_false } + end + end + end + + context "propagate all variants" do + subject { StockLocation.new(name: "testing") } + + context "true" do + before { subject.propagate_all_variants = true } + + specify do + subject.should_receive(:propagate_variant).at_least(:once) + subject.save! + end + end + + context "false" do + before { subject.propagate_all_variants = false } + + specify do + subject.should_not_receive(:propagate_variant) + subject.save! + end + end + end + end + end + + it 'finds a stock_item for a variant' do + stock_item = subject.stock_item(variant) + stock_item.count_on_hand.should eq 10 + end + + it 'finds a stock_item for a variant by id' do + stock_item = subject.stock_item(variant.id) + stock_item.variant.should eq variant + end + + it 'returns nil when stock_item is not found for variant' do + stock_item = subject.stock_item(100) + stock_item.should be_nil + end + + it 'creates a stock_item if not found for a variant' do + variant = create(:variant) + variant.stock_items.destroy_all + variant.save + + stock_item = subject.stock_item_or_create(variant) + stock_item.variant.should eq variant + end + + it 'finds a count_on_hand for a variant' do + subject.count_on_hand(variant).should eq 10 + end + + it 'finds determines if you a variant is backorderable' do + subject.backorderable?(variant).should be_true + end + + it 'restocks a variant with a positive stock movement' do + originator = double + subject.should_receive(:move).with(variant, 5, originator) + subject.restock(variant, 5, originator) + end + + it 'unstocks a variant with a negative stock movement' do + originator = double + subject.should_receive(:move).with(variant, -5, originator) + subject.unstock(variant, 5, originator) + end + + it 'it creates a stock_movement' do + expect { + subject.move variant, 5 + }.to change { subject.stock_movements.where(stock_item_id: stock_item).count }.by(1) + end + + it 'can be deactivated' do + create(:stock_location, :active => true) + create(:stock_location, :active => false) + Spree::StockLocation.active.count.should eq 1 + end + + context 'fill_status' do + it 'all on_hand with no backordered' do + on_hand, backordered = subject.fill_status(variant, 5) + on_hand.should eq 5 + backordered.should eq 0 + end + + it 'some on_hand with some backordered' do + on_hand, backordered = subject.fill_status(variant, 20) + on_hand.should eq 10 + backordered.should eq 10 + end + + it 'zero on_hand with all backordered' do + zero_stock_item = mock_model(StockItem, + count_on_hand: 0, + backorderable?: true) + subject.should_receive(:stock_item).with(variant).and_return(zero_stock_item) + + on_hand, backordered = subject.fill_status(variant, 20) + on_hand.should eq 0 + backordered.should eq 20 + end + + context 'when backordering is not allowed' do + before do + @stock_item = mock_model(StockItem, backorderable?: false) + subject.should_receive(:stock_item).with(variant).and_return(@stock_item) + end + + it 'all on_hand' do + @stock_item.stub(count_on_hand: 10) + + on_hand, backordered = subject.fill_status(variant, 5) + on_hand.should eq 5 + backordered.should eq 0 + end + + it 'some on_hand' do + @stock_item.stub(count_on_hand: 10) + + on_hand, backordered = subject.fill_status(variant, 20) + on_hand.should eq 10 + backordered.should eq 0 + end + + it 'zero on_hand' do + @stock_item.stub(count_on_hand: 0) + + on_hand, backordered = subject.fill_status(variant, 20) + on_hand.should eq 0 + backordered.should eq 0 + end + end + + context 'without stock_items' do + subject { create(:stock_location) } + let(:variant) { create(:base_variant) } + + it 'zero on_hand and backordered', focus: true do + subject + variant.stock_items.destroy_all + on_hand, backordered = subject.fill_status(variant, 1) + on_hand.should eq 0 + backordered.should eq 0 + end + end + end + end +end diff --git a/spec/models/spree/stock_movement_spec.rb b/spec/models/spree/stock_movement_spec.rb new file mode 100644 index 0000000000..fcc387c460 --- /dev/null +++ b/spec/models/spree/stock_movement_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' + +describe Spree::StockMovement do + let(:stock_location) { create(:stock_location_with_items) } + let(:stock_item) { stock_location.stock_items.order(:id).first } + subject { build(:stock_movement, stock_item: stock_item) } + + it 'should belong to a stock item' do + subject.should respond_to(:stock_item) + end + + it 'is readonly unless new' do + subject.save + expect { + subject.save + }.to raise_error(ActiveRecord::ReadOnlyRecord) + end + + it 'does not update count on hand when track inventory levels is false' do + Spree::Config[:track_inventory_levels] = false + subject.quantity = 1 + subject.save + stock_item.reload + stock_item.count_on_hand.should == 10 + end + + context "when quantity is negative" do + context "after save" do + it "should decrement the stock item count on hand" do + subject.quantity = -1 + subject.save + stock_item.reload + stock_item.count_on_hand.should == 9 + end + end + end + + context "when quantity is positive" do + context "after save" do + it "should increment the stock item count on hand" do + subject.quantity = 1 + subject.save + stock_item.reload + stock_item.count_on_hand.should == 11 + end + end + end +end