+
+
+
+
diff --git a/app/views/spree/api/errors/variant_not_in_stock_transfer.json.jbuilder b/app/views/spree/api/errors/variant_not_in_stock_transfer.json.jbuilder
new file mode 100644
index 0000000..9b894fe
--- /dev/null
+++ b/app/views/spree/api/errors/variant_not_in_stock_transfer.json.jbuilder
@@ -0,0 +1 @@
+json.error(I18n.t('spree.item_not_in_stock_transfer'))
diff --git a/app/views/spree/api/stock_transfers/receive.json.jbuilder b/app/views/spree/api/stock_transfers/receive.json.jbuilder
new file mode 100644
index 0000000..94f7c66
--- /dev/null
+++ b/app/views/spree/api/stock_transfers/receive.json.jbuilder
@@ -0,0 +1,4 @@
+json.(@stock_transfer, *stock_transfer_attributes)
+json.received_item do
+ json.partial!("spree/api/transfer_items/transfer_item", transfer_item: @transfer_item)
+end
diff --git a/app/views/spree/api/transfer_items/_transfer_item.json.jbuilder b/app/views/spree/api/transfer_items/_transfer_item.json.jbuilder
new file mode 100644
index 0000000..a5d7db5
--- /dev/null
+++ b/app/views/spree/api/transfer_items/_transfer_item.json.jbuilder
@@ -0,0 +1,5 @@
+json.(transfer_item, *transfer_item_attributes)
+json.variant do
+ json.partial!("spree/api/variants/small", variant: transfer_item.variant)
+ json.(transfer_item.variant, *transfer_item_variant_attributes)
+end
diff --git a/app/views/spree/api/transfer_items/show.json.jbuilder b/app/views/spree/api/transfer_items/show.json.jbuilder
new file mode 100644
index 0000000..d7f1545
--- /dev/null
+++ b/app/views/spree/api/transfer_items/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial!("spree/api/transfer_items/transfer_item", transfer_item: @transfer_item)
diff --git a/lib/spree/permission_sets/restricted_stock_transfer_display.rb b/lib/spree/permission_sets/restricted_stock_transfer_display.rb
new file mode 100644
index 0000000..219b162
--- /dev/null
+++ b/lib/spree/permission_sets/restricted_stock_transfer_display.rb
@@ -0,0 +1,17 @@
+module Spree
+ module PermissionSets
+ class RestrictedStockTransferDisplay < PermissionSets::Base
+ def activate!
+ can [:display, :admin], Spree::StockTransfer, source_location_id: location_ids
+ can [:display, :admin], Spree::StockTransfer, destination_location_id: location_ids
+ can :display, Spree::StockLocation, id: location_ids
+ end
+
+ private
+
+ def location_ids
+ @ids ||= user.stock_locations.pluck(:id)
+ end
+ end
+ end
+end
diff --git a/lib/spree/permission_sets/restricted_stock_transfer_management.rb b/lib/spree/permission_sets/restricted_stock_transfer_management.rb
new file mode 100644
index 0000000..125978b
--- /dev/null
+++ b/lib/spree/permission_sets/restricted_stock_transfer_management.rb
@@ -0,0 +1,52 @@
+module Spree
+ module PermissionSets
+ # This is a permission set that offers an alternative to {StockManagement}.
+ #
+ # Instead of allowing management access for all stock transfers and items, only allow
+ # the management of stock transfers for locations the user is associated with.
+ #
+ # Users can be associated with stock locations via the admin user interface.
+ #
+ # The logic here is unfortunately rather complex and boils down to:
+ # - A user has read only access to all stock locations (including inactive ones)
+ # - A user can see all stock transfers for their associated stock locations regardless of the
+ # fact that they may not be associated with both the destination and the source, as long as
+ # they are associated with at least one of the two.
+ # - A user can manage stock transfers only if they are associated with both the destination and the source,
+ # or if the user is associated with the source, and the transfer has not yet been assigned a destination.
+ #
+ # @see Spree::PermissionSets::Base
+ class RestrictedStockTransferManagement < PermissionSets::Base
+ def activate!
+ if user.stock_locations.any?
+ can :display, Spree::StockLocation, id: user_location_ids
+
+ can :transfer_from, Spree::StockLocation, id: user_location_ids
+ can :transfer_to, Spree::StockLocation, id: user_location_ids
+
+ can :display, Spree::StockTransfer, source_location_id: user_location_ids
+ can :manage, Spree::StockTransfer, source_location_id: user_location_ids + [nil], shipped_at: nil
+ can :manage, Spree::StockTransfer, destination_location_id: user_location_ids
+ # Do not allow managing transfers to a permitted destination_location_id from an
+ # unauthorized stock location until it's been shipped to the permitted location.
+ cannot :manage, Spree::StockTransfer, source_location_id: not_permitted_location_ids, shipped_at: nil
+
+ can :display, Spree::TransferItem, stock_transfer: { source_location_id: user_location_ids }
+ can :manage, Spree::TransferItem, stock_transfer: { source_location_id: user_location_ids + [nil], shipped_at: nil }
+ can :manage, Spree::TransferItem, stock_transfer: { destination_location_id: user_location_ids }
+ cannot :manage, Spree::TransferItem, stock_transfer: { source_location_id: not_permitted_location_ids, shipped_at: nil }
+ end
+ end
+
+ private
+
+ def user_location_ids
+ @user_location_ids ||= user.stock_locations.pluck(:id)
+ end
+
+ def not_permitted_location_ids
+ @not_permitted_location_ids ||= Spree::StockLocation.where.not(id: user_location_ids).pluck(:id)
+ end
+ end
+ end
+end
diff --git a/lib/spree/permission_sets/stock_transfer_display.rb b/lib/spree/permission_sets/stock_transfer_display.rb
new file mode 100644
index 0000000..94e103d
--- /dev/null
+++ b/lib/spree/permission_sets/stock_transfer_display.rb
@@ -0,0 +1,10 @@
+module Spree
+ module PermissionSets
+ class StockTransferDisplay < PermissionSets::Base
+ def activate!
+ can [:display, :admin], Spree::StockTransfer
+ can :display, Spree::StockLocation
+ end
+ end
+ end
+end
diff --git a/lib/spree/permission_sets/stock_transfer_management.rb b/lib/spree/permission_sets/stock_transfer_management.rb
new file mode 100644
index 0000000..482c300
--- /dev/null
+++ b/lib/spree/permission_sets/stock_transfer_management.rb
@@ -0,0 +1,11 @@
+module Spree
+ module PermissionSets
+ class StockTransferManagement < PermissionSets::Base
+ def activate!
+ can :manage, Spree::StockTransfer
+ can :manage, Spree::TransferItem
+ can :display, Spree::StockLocation
+ end
+ end
+ end
+end
diff --git a/lib/spree/testing_support/factories/stock_transfer_factory.rb b/lib/spree/testing_support/factories/stock_transfer_factory.rb
new file mode 100644
index 0000000..6c3723a
--- /dev/null
+++ b/lib/spree/testing_support/factories/stock_transfer_factory.rb
@@ -0,0 +1,31 @@
+FactoryBot.define do
+ sequence(:source_code) { |n| "SRC#{n}" }
+ sequence(:destination_code) { |n| "DEST#{n}" }
+
+ factory :stock_transfer, class: 'Spree::StockTransfer' do
+ source_location { Spree::StockLocation.create!(name: "Source Location", code: generate(:source_code), admin_name: "Source") }
+
+ factory :stock_transfer_with_items do
+ destination_location { Spree::StockLocation.create!(name: "Destination Location", code: generate(:destination_code), admin_name: "Destination") }
+
+ after(:create) do |stock_transfer, _evaluator|
+ variant_1 = create(:variant)
+ variant_2 = create(:variant)
+
+ variant_1.stock_items.find_by(stock_location: stock_transfer.source_location).set_count_on_hand(10)
+ variant_2.stock_items.find_by(stock_location: stock_transfer.source_location).set_count_on_hand(10)
+
+ stock_transfer.transfer_items.create(variant: variant_1, expected_quantity: 5)
+ stock_transfer.transfer_items.create(variant: variant_2, expected_quantity: 5)
+
+ stock_transfer.created_by = create(:admin_user)
+ stock_transfer.save!
+ end
+
+ factory :receivable_stock_transfer_with_items do
+ finalized_at { Time.current }
+ shipped_at { Time.current }
+ end
+ end
+ end
+end
diff --git a/spec/controllers/spree/admin/stock_transfers_controller_spec.rb b/spec/controllers/spree/admin/stock_transfers_controller_spec.rb
new file mode 100644
index 0000000..5856430
--- /dev/null
+++ b/spec/controllers/spree/admin/stock_transfers_controller_spec.rb
@@ -0,0 +1,365 @@
+require 'spec_helper'
+
+module Spree
+ describe Admin::StockTransfersController, type: :controller do
+ stub_authorization!
+
+ let(:warehouse) { StockLocation.create(name: "Warehouse") }
+ let(:ny_store) { StockLocation.create(name: "NY Store") }
+ let(:la_store) { StockLocation.create(name: "LA Store") }
+
+ context "#index" do
+ let!(:stock_transfer1) {
+ StockTransfer.create do |transfer|
+ transfer.source_location_id = warehouse.id
+ transfer.destination_location_id = ny_store.id
+ end
+ }
+
+ let!(:stock_transfer2) {
+ StockTransfer.create do |transfer|
+ transfer.source_location_id = warehouse.id
+ transfer.destination_location_id = la_store.id
+ transfer.finalized_at = DateTime.current
+ transfer.closed_at = DateTime.current
+ end
+ }
+
+ describe "stock location filtering" do
+ let(:user) { create(:admin_user) }
+ let(:ability) { Spree::Ability.new(user) }
+ let!(:sf_store) { StockLocation.create(name: "SF Store") }
+
+ before do
+ ability.cannot :manage, Spree::StockLocation
+ ability.can :display, Spree::StockLocation, id: [warehouse.id]
+ ability.can :display, Spree::StockLocation, id: [ny_store.id, la_store.id]
+
+ allow_any_instance_of(Spree::Admin::BaseController).to receive(:spree_current_user).and_return(user)
+ allow_any_instance_of(Spree::Admin::BaseController).to receive(:current_ability).and_return(ability)
+ end
+
+ it "doesn't display stock locations the user doesn't have access to" do
+ get :index
+ expect(assigns(:stock_locations)).to match_array [warehouse, ny_store, la_store]
+ end
+ end
+
+ it "searches by stock location" do
+ get :index, params: { q: { source_location_id_or_destination_location_id_eq: ny_store.id } }
+ expect(assigns(:stock_transfers).count).to eq 1
+ expect(assigns(:stock_transfers)).to include(stock_transfer1)
+ end
+
+ it "filters the closed stock transfers" do
+ get :index, params: { q: { closed_at_null: '1' } }
+ expect(assigns(:stock_transfers)).to match_array [stock_transfer1]
+ end
+
+ it "doesn't filter any stock transfers" do
+ get :index, params: { q: { closed_at_null: '0' } }
+ expect(assigns(:stock_transfers)).to match_array [stock_transfer1, stock_transfer2]
+ end
+ end
+
+ context "#create" do
+ let(:warehouse) { StockLocation.create(name: "Warehouse", active: false) }
+
+ subject do
+ post :create, params: { stock_transfer: { source_location_id: warehouse.id, description: nil } }
+ end
+
+ context "user doesn't have read access to the selected stock location" do
+ before do
+ expect(controller).to receive(:authorize!) { raise CanCan::AccessDenied }
+ end
+
+ it "redirects to authorization_failure" do
+ subject
+ expect(response).to redirect_to('/unauthorized')
+ end
+ end
+
+ context "valid parameters" do
+ let!(:user) { create(:user) }
+
+ before do
+ allow(controller).to receive(:try_spree_current_user) { user }
+ end
+
+ it "redirects to the edit page" do
+ subject
+ expect(response).to redirect_to(spree.edit_admin_stock_transfer_path(assigns(:stock_transfer)))
+ end
+
+ it "sets the created_by to the current user" do
+ subject
+ expect(assigns(:stock_transfer).created_by).to eq(user)
+ end
+ end
+
+ # Regression spec for Solidus issue #1087
+ context "missing source_stock_location parameter" do
+ subject do
+ post :create, params: { stock_transfer: { source_location_id: nil, description: nil } }
+ end
+
+ it "sets a flash error" do
+ subject
+ expect(flash[:error]).to eq assigns(:stock_transfer).errors.full_messages.join(', ')
+ end
+ end
+ end
+
+ context "#receive" do
+ let!(:transfer_with_items) { create(:receivable_stock_transfer_with_items) }
+ let(:variant_1) { transfer_with_items.transfer_items[0].variant }
+ let(:variant_2) { transfer_with_items.transfer_items[1].variant }
+ let(:parameters) { { id: transfer_with_items.to_param } }
+
+ subject do
+ get :receive, params: parameters
+ end
+
+ context 'stock transfer is not receivable' do
+ before do
+ transfer_with_items.update_attributes(finalized_at: nil, shipped_at: nil)
+ end
+
+ it 'redirects back to index' do
+ subject
+ expect(flash[:error]).to eq I18n.t('spree.stock_transfer_must_be_receivable')
+ expect(response).to redirect_to(spree.admin_stock_transfers_path)
+ end
+ end
+
+ context "no items have been received" do
+ let(:parameters) do
+ { id: transfer_with_items.to_param }
+ end
+
+ before { subject }
+
+ it "doesn't assign received_items" do
+ expect(assigns(:received_items)).to be_empty
+ end
+ end
+
+ context "some items have been received" do
+ let(:transfer_item) { transfer_with_items.transfer_items.first }
+ let(:parameters) do
+ { id: transfer_with_items.to_param, variant_search_term: variant_1.sku }
+ end
+
+ before do
+ transfer_item.update_attributes(received_quantity: 1)
+ subject
+ end
+
+ it "assigns received_items correctly" do
+ expect(assigns(:received_items)).to match_array [transfer_item]
+ end
+ end
+ end
+
+ context "#finalize" do
+ let!(:user) { create(:user) }
+ let!(:transfer_with_items) { create(:receivable_stock_transfer_with_items, finalized_at: nil, shipped_at: nil) }
+
+ before do
+ allow(controller).to receive(:try_spree_current_user) { user }
+ end
+
+ subject do
+ put :finalize, params: { id: transfer_with_items.to_param }
+ end
+
+ context 'stock transfer is not finalizable' do
+ before do
+ transfer_with_items.update_attributes(finalized_at: Time.current)
+ end
+
+ it 'redirects back to edit' do
+ subject
+ expect(flash[:error]).to eq I18n.t('spree.stock_transfer_cannot_be_finalized')
+ expect(response).to redirect_to(spree.edit_admin_stock_transfer_path(transfer_with_items))
+ end
+ end
+
+ context "successfully finalized" do
+ it "redirects to tracking_info" do
+ subject
+ expect(response).to redirect_to(spree.tracking_info_admin_stock_transfer_path(transfer_with_items))
+ end
+
+ it "sets the finalized_by to the current user" do
+ subject
+ expect(transfer_with_items.reload.finalized_by).to eq(user)
+ end
+
+ it "sets the finalized_at date" do
+ subject
+ expect(transfer_with_items.reload.finalized_at).to_not be_nil
+ end
+ end
+
+ context "error finalizing the stock transfer" do
+ before do
+ transfer_with_items.update_attributes(destination_location_id: nil)
+ end
+
+ it "redirects back to edit" do
+ subject
+ expect(response).to redirect_to(spree.edit_admin_stock_transfer_path(transfer_with_items))
+ end
+
+ it "displays a flash error message" do
+ subject
+ expect(flash[:error]).to eq "Destination location can't be blank"
+ end
+ end
+ end
+
+ context "#close" do
+ let!(:user) { create(:user) }
+ let!(:transfer_with_items) { create(:receivable_stock_transfer_with_items) }
+
+ before do
+ allow(controller).to receive(:try_spree_current_user) { user }
+ end
+
+ subject do
+ put :close, params: { id: transfer_with_items.to_param }
+ end
+
+ context 'stock transfer is not receivable' do
+ before do
+ transfer_with_items.update_attributes(finalized_at: nil, shipped_at: nil)
+ end
+
+ it 'redirects back to receive' do
+ subject
+ expect(flash[:error]).to eq I18n.t('spree.stock_transfer_must_be_receivable')
+ expect(response).to redirect_to(spree.receive_admin_stock_transfer_path(transfer_with_items))
+ end
+ end
+
+ context "successfully closed" do
+ it "redirects back to index" do
+ subject
+ expect(response).to redirect_to(spree.admin_stock_transfers_path)
+ end
+
+ it "sets the closed_by to the current user" do
+ subject
+ expect(transfer_with_items.reload.closed_by).to eq(user)
+ end
+
+ it "sets the closed_at date" do
+ subject
+ expect(transfer_with_items.reload.closed_at).to_not be_nil
+ end
+
+ context "stock movements" do
+ let(:source) { transfer_with_items.source_location }
+ let(:destination) { transfer_with_items.destination_location }
+ let(:transfer_item_1) { transfer_with_items.transfer_items[0] }
+ let(:transfer_item_2) { transfer_with_items.transfer_items[1] }
+
+ before do
+ transfer_item_1.update_columns(received_quantity: 2)
+ transfer_item_2.update_columns(received_quantity: 5)
+ subject
+ end
+
+ it 'creates 2 stock movements' do
+ expect(assigns(:stock_movements).length).to eq 2
+ end
+
+ it 'sets the stock transfer as the originator of the stock movements' do
+ subject
+ originators = assigns(:stock_movements).map(&:originator)
+ expect(originators).to match_array [transfer_with_items, transfer_with_items]
+ end
+
+ it 'only creates stock movements for the destination stock location' do
+ subject
+ locations = assigns(:stock_movements).map(&:stock_item).flat_map(&:stock_location)
+ expect(locations).to match_array [destination, destination]
+ end
+
+ it 'creates the stock movements for the received quantities' do
+ subject
+ movement_for_transfer_item_1 = assigns(:stock_movements).find { |sm| sm.stock_item.variant == transfer_item_1.variant }
+ expect(movement_for_transfer_item_1.quantity).to eq 2
+ movement_for_transfer_item_2 = assigns(:stock_movements).find { |sm| sm.stock_item.variant == transfer_item_2.variant }
+ expect(movement_for_transfer_item_2.quantity).to eq 5
+ end
+ end
+ end
+
+ context "error closing the stock transfer" do
+ before do
+ transfer_with_items.update_columns(destination_location_id: nil)
+ end
+
+ it "redirects back to receive" do
+ subject
+ expect(response).to redirect_to(spree.receive_admin_stock_transfer_path(transfer_with_items))
+ end
+
+ it "displays a flash error message" do
+ subject
+ expect(flash[:error]).to eq "Destination location can't be blank"
+ end
+ end
+ end
+
+ context "#ship" do
+ let(:stock_transfer) { Spree::StockTransfer.create(source_location: warehouse, destination_location: ny_store, created_by: create(:admin_user)) }
+ let(:transfer_variant) { create(:variant) }
+ let(:warehouse_stock_item) { warehouse.stock_items.find_by(variant: transfer_variant) }
+ let(:ny_stock_item) { ny_store.stock_items.find_by(variant: transfer_variant) }
+
+ subject { put :ship, params: { id: stock_transfer.number } }
+
+ before do
+ warehouse_stock_item.set_count_on_hand(1)
+ stock_transfer.transfer_items.create!(variant: transfer_variant, expected_quantity: 1)
+ end
+
+ context "with transferable items" do
+ it "marks the transfer shipped" do
+ subject
+
+ expect(stock_transfer.reload.shipped_at).to_not be_nil
+ expect(flash[:success]).to be_present
+ end
+
+ it "makes stock movements for the transferred items" do
+ subject
+
+ expect(Spree::StockMovement.count).to eq 1
+ expect(warehouse_stock_item.reload.count_on_hand).to eq 0
+ end
+ end
+
+ context "with non-transferable items" do
+ before { warehouse_stock_item.set_count_on_hand(0) }
+
+ it "does not mark the transfer shipped" do
+ subject
+
+ expect(stock_transfer.reload.shipped_at).to be_nil
+ end
+
+ it "errors and redirects to tracking_info page" do
+ subject
+
+ expect(flash[:error]).to match /not enough inventory/
+ expect(response).to redirect_to(spree.tracking_info_admin_stock_transfer_path(stock_transfer))
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/admin/stock_transfer_spec.rb b/spec/features/admin/stock_transfer_spec.rb
new file mode 100644
index 0000000..b939d69
--- /dev/null
+++ b/spec/features/admin/stock_transfer_spec.rb
@@ -0,0 +1,115 @@
+require 'spec_helper'
+
+describe 'Stock Transfers', type: :feature, js: true do
+ stub_authorization!
+
+ let(:admin_user) { create(:admin_user) }
+ let(:description) { 'Test stock transfer' }
+
+ before do
+ allow_any_instance_of(Spree::Admin::BaseController).to receive(:spree_current_user).and_return(admin_user)
+ end
+
+ describe 'create stock transfer' do
+ it 'can create a stock transfer' do
+ create(:stock_location_with_items, name: 'NY')
+ create(:stock_location, name: 'SF')
+
+ visit spree.new_admin_stock_transfer_path
+ select "SF", from: 'Source Location'
+ fill_in 'Description', with: description
+ click_button 'Continue'
+
+ expect(page).to have_field('stock_transfer_description', with: description)
+
+ select "NY", from: 'Destination Location'
+ click_on 'Save'
+
+ expect(page).to have_content('Stock Transfer has been successfully updated')
+ expect(page).to have_select("Destination Location", selected: 'NY')
+ end
+
+ # Regression spec for Solidus issue #1087
+ it 'displays an error if no source location is selected' do
+ create(:stock_location_with_items, name: 'NY')
+ create(:stock_location, name: 'SF')
+ visit spree.new_admin_stock_transfer_path
+ fill_in 'Description', with: description
+ click_button 'Continue'
+
+ expect(page).to have_content("Source location can't be blank")
+ end
+ end
+
+ describe 'view a stock transfer' do
+ let(:stock_transfer) do
+ create(:stock_transfer_with_items,
+ source_location: source_location,
+ destination_location: nil,
+ description: "Test stock transfer")
+ end
+ let(:source_location) { create(:stock_location, name: 'SF') }
+
+ context "stock transfer does not have a destination" do
+ it 'displays the stock transfer details' do
+ visit spree.admin_stock_transfer_path(stock_transfer)
+ expect(page).to have_content("SF")
+ expect(page).to have_content("Test stock transfer")
+ end
+ end
+ end
+
+ describe 'ship stock transfer' do
+ let(:stock_transfer) { create(:stock_transfer_with_items) }
+
+ before do
+ stock_transfer.transfer_items.each do |item|
+ item.update_attributes!(expected_quantity: 1)
+ end
+ end
+
+ describe "tracking info" do
+ it 'adds tracking number' do
+ visit spree.tracking_info_admin_stock_transfer_path(stock_transfer)
+
+ fill_in 'stock_transfer_tracking_number', with: "12345"
+ click_button 'Save'
+
+ expect(page).to have_content('Stock Transfer has been successfully updated')
+ expect(stock_transfer.reload.tracking_number).to eq '12345'
+ end
+ end
+
+ describe 'with enough stock' do
+ it 'ships stock transfer' do
+ visit spree.tracking_info_admin_stock_transfer_path(stock_transfer)
+
+ accept_confirm I18n.t('spree.ship_stock_transfer.confirm') do
+ click_on 'Ship'
+ end
+
+ expect(page).to have_current_path(spree.admin_stock_transfers_path)
+ expect(stock_transfer.reload.shipped_at).to_not be_nil
+ end
+ end
+
+ describe 'without enough stock' do
+ before do
+ stock_transfer.transfer_items.each do |item|
+ stock_transfer.source_location.stock_item(item.variant).set_count_on_hand(0)
+ end
+ end
+
+ it 'does not ship stock transfer' do
+ visit spree.tracking_info_admin_stock_transfer_path(stock_transfer)
+
+ accept_confirm I18n.t('spree.ship_stock_transfer.confirm') do
+ click_on 'Ship'
+ end
+
+ expect(page).to have_current_path(spree.tracking_info_admin_stock_transfer_path(stock_transfer))
+ expect(stock_transfer.reload.shipped_at).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/lib/spree/core/testing_support/factories/stock_transfer_factory_spec.rb b/spec/lib/spree/core/testing_support/factories/stock_transfer_factory_spec.rb
new file mode 100644
index 0000000..77dd377
--- /dev/null
+++ b/spec/lib/spree/core/testing_support/factories/stock_transfer_factory_spec.rb
@@ -0,0 +1,12 @@
+require 'rails_helper'
+require 'spree/testing_support/factories/stock_transfer_factory'
+
+RSpec.describe 'stock transfer factory' do
+ let(:factory_class) { Spree::StockTransfer }
+
+ describe 'plain stock transfer' do
+ let(:factory) { :stock_transfer }
+
+ it_behaves_like 'a working factory'
+ end
+end
diff --git a/spec/models/spree/permission_sets/restricted_stock_transfer_display_spec.rb b/spec/models/spree/permission_sets/restricted_stock_transfer_display_spec.rb
new file mode 100644
index 0000000..510a2b9
--- /dev/null
+++ b/spec/models/spree/permission_sets/restricted_stock_transfer_display_spec.rb
@@ -0,0 +1,49 @@
+require 'rails_helper'
+
+RSpec.describe Spree::PermissionSets::RestrictedStockTransferDisplay do
+ let(:ability) { Spree::Ability.new(user) }
+ let(:user) { create :user }
+
+ subject { ability }
+
+ let!(:sl1) { create :stock_location, active: false }
+ let!(:sl2) { create :stock_location, active: false }
+
+ let!(:source_transfer) { create :stock_transfer, source_location: sl1 }
+ let!(:other_source_transfer) { create :stock_transfer, source_location: sl2 }
+ let!(:dest_transfer) { create :stock_transfer, source_location: sl2, destination_location: sl1 }
+
+ before do
+ user.stock_locations << sl1
+ end
+
+ context "when activated" do
+ before do
+ described_class.new(ability).activate!
+ end
+
+ it { is_expected.to be_able_to(:display, sl1) }
+ it { is_expected.to_not be_able_to(:display, sl2) }
+
+ it { is_expected.to be_able_to(:display, source_transfer) }
+ it { is_expected.to_not be_able_to(:display, other_source_transfer) }
+ it { is_expected.to be_able_to(:display, dest_transfer) }
+
+ it { is_expected.to be_able_to(:admin, source_transfer) }
+ it { is_expected.to_not be_able_to(:admin, other_source_transfer) }
+ it { is_expected.to be_able_to(:admin, dest_transfer) }
+ end
+
+ context "when not activated" do
+ it { is_expected.to_not be_able_to(:display, sl1) }
+ it { is_expected.to_not be_able_to(:display, sl2) }
+
+ it { is_expected.to_not be_able_to(:display, source_transfer) }
+ it { is_expected.to_not be_able_to(:display, other_source_transfer) }
+ it { is_expected.to_not be_able_to(:display, dest_transfer) }
+
+ it { is_expected.to_not be_able_to(:admin, source_transfer) }
+ it { is_expected.to_not be_able_to(:admin, other_source_transfer) }
+ it { is_expected.to_not be_able_to(:admin, dest_transfer) }
+ end
+end
diff --git a/spec/models/spree/permission_sets/restricted_stock_transfer_management_spec.rb b/spec/models/spree/permission_sets/restricted_stock_transfer_management_spec.rb
new file mode 100644
index 0000000..987cac4
--- /dev/null
+++ b/spec/models/spree/permission_sets/restricted_stock_transfer_management_spec.rb
@@ -0,0 +1,218 @@
+require 'rails_helper'
+
+RSpec.describe Spree::PermissionSets::RestrictedStockTransferManagement do
+ let(:ability) { Spree::Ability.new(user) }
+
+ subject { ability }
+
+ # Inactive stock locations will default to not being visible
+ # for users without explicit permissions.
+ let!(:source_location) { create :stock_location, active: false }
+ let!(:destination_location) { create :stock_location, active: false }
+
+ # This has the side effect of creating a stock item for each stock location above,
+ # which is what we actually want.
+ let!(:variant) { create :variant }
+
+ let(:transfer_with_source) { create :stock_transfer, source_location: source_location }
+ let(:transfer_with_destination) { create :stock_transfer, source_location: destination_location }
+ let(:transfer_with_source_and_destination) do
+ create :stock_transfer, source_location: source_location, destination_location: destination_location
+ end
+
+ let(:transfer_amount) { 1 }
+ let(:source_transfer_item) do
+ transfer_with_source.transfer_items.create(variant: variant, expected_quantity: transfer_amount)
+ end
+ let(:destination_transfer_item) do
+ transfer_with_destination.transfer_items.create(variant: variant, expected_quantity: transfer_amount)
+ end
+ let(:source_and_destination_transfer_item) do
+ transfer_with_source_and_destination.transfer_items.create(variant: variant, expected_quantity: transfer_amount)
+ end
+
+ context "when activated" do
+ let(:user) { create :user, stock_locations: stock_locations }
+ let(:stock_locations) { [] }
+
+ before do
+ user.stock_locations = stock_locations
+ # When creating transfer_items for a stock transfer, stock items must have a count on hand
+ # with an amount that would allow a transfer item to pass validations (meaning the count on hand has to be equal
+ # to the expected_quantity for the transfer)
+ variant.stock_items.update_all count_on_hand: transfer_amount
+
+ described_class.new(ability).activate!
+ end
+
+ context "when the user is only associated with the source location" do
+ let(:stock_locations) { [source_location] }
+
+ it { is_expected.to be_able_to(:display, source_location) }
+ it { is_expected.to_not be_able_to(:display, destination_location) }
+
+ it { is_expected.to be_able_to(:display, Spree::StockTransfer) }
+ it { is_expected.to be_able_to(:admin, Spree::StockTransfer) }
+ it { is_expected.to be_able_to(:create, Spree::StockTransfer) }
+
+ it { is_expected.to be_able_to(:transfer_from, source_location) }
+ it { is_expected.to be_able_to(:transfer_to, source_location) }
+
+ it { is_expected.to_not be_able_to(:transfer_from, destination_location) }
+ it { is_expected.to_not be_able_to(:transfer_to, destination_location) }
+
+ it { is_expected.to be_able_to(:display, transfer_with_source) }
+ it { is_expected.to be_able_to(:display, transfer_with_source_and_destination) }
+ it { is_expected.to_not be_able_to(:display, transfer_with_destination) }
+
+ it { is_expected.to be_able_to(:manage, transfer_with_source) }
+ it { is_expected.to be_able_to(:manage, transfer_with_source_and_destination) }
+ it { is_expected.to_not be_able_to(:manage, transfer_with_destination) }
+
+ it { is_expected.to be_able_to(:manage, source_transfer_item) }
+ it { is_expected.to be_able_to(:manage, source_and_destination_transfer_item) }
+ it { is_expected.to_not be_able_to(:manage, destination_transfer_item) }
+
+ context "stock transfer has been shipped" do
+ before do
+ transfer_with_source_and_destination.update_attributes!(shipped_at: Time.current)
+ described_class.new(ability).activate!
+ end
+
+ it { is_expected.to_not be_able_to(:manage, transfer_with_source_and_destination) }
+ it { is_expected.to_not be_able_to(:manage, source_and_destination_transfer_item) }
+ end
+ end
+
+ context "when the user is only associated with the destination location" do
+ let(:stock_locations) { [destination_location] }
+
+ it { is_expected.to be_able_to(:display, destination_location) }
+ it { is_expected.to_not be_able_to(:display, source_location) }
+
+ it { is_expected.to be_able_to(:display, Spree::StockTransfer) }
+ it { is_expected.to be_able_to(:admin, Spree::StockTransfer) }
+ it { is_expected.to be_able_to(:create, Spree::StockTransfer) }
+
+ it { is_expected.to_not be_able_to(:transfer_from, source_location) }
+ it { is_expected.to_not be_able_to(:transfer_to, source_location) }
+
+ it { is_expected.to be_able_to(:transfer_from, destination_location) }
+ it { is_expected.to be_able_to(:transfer_to, destination_location) }
+
+ it { is_expected.to be_able_to(:display, transfer_with_destination) }
+ it { is_expected.to_not be_able_to(:display, transfer_with_source) }
+ it { is_expected.to_not be_able_to(:display, transfer_with_source_and_destination) }
+
+ it { is_expected.to be_able_to(:manage, transfer_with_destination) }
+ it { is_expected.to_not be_able_to(:manage, transfer_with_source) }
+ it { is_expected.to_not be_able_to(:manage, transfer_with_source_and_destination) }
+
+ it { is_expected.to be_able_to(:manage, destination_transfer_item) }
+ it { is_expected.to_not be_able_to(:manage, source_transfer_item) }
+ it { is_expected.to_not be_able_to(:manage, source_and_destination_transfer_item) }
+
+ context "stock transfer has been shipped" do
+ before do
+ transfer_with_source_and_destination.update_attributes!(shipped_at: Time.current)
+ described_class.new(ability).activate!
+ end
+
+ it { is_expected.to be_able_to(:manage, transfer_with_source_and_destination) }
+ it { is_expected.to be_able_to(:manage, source_and_destination_transfer_item) }
+ end
+ end
+
+ context "when the user is associated with both locations" do
+ let(:stock_locations) { [source_location, destination_location] }
+
+ it { is_expected.to be_able_to(:display, source_location) }
+ it { is_expected.to be_able_to(:display, destination_location) }
+
+ it { is_expected.to be_able_to(:display, Spree::StockTransfer) }
+ it { is_expected.to be_able_to(:admin, Spree::StockTransfer) }
+ it { is_expected.to be_able_to(:create, Spree::StockTransfer) }
+
+ it { is_expected.to be_able_to(:transfer_from, source_location) }
+ it { is_expected.to be_able_to(:transfer_to, source_location) }
+
+ it { is_expected.to be_able_to(:transfer_from, destination_location) }
+ it { is_expected.to be_able_to(:transfer_to, destination_location) }
+
+ it { is_expected.to be_able_to(:display, transfer_with_source) }
+ it { is_expected.to be_able_to(:display, transfer_with_source_and_destination) }
+ it { is_expected.to be_able_to(:display, transfer_with_destination) }
+
+ it { is_expected.to be_able_to(:manage, transfer_with_source) }
+ it { is_expected.to be_able_to(:manage, transfer_with_destination) }
+ it { is_expected.to be_able_to(:manage, transfer_with_source_and_destination) }
+
+ it { is_expected.to be_able_to(:manage, source_transfer_item) }
+ it { is_expected.to be_able_to(:manage, destination_transfer_item) }
+ it { is_expected.to be_able_to(:manage, source_and_destination_transfer_item) }
+
+ context "stock transfer has been shipped" do
+ before do
+ transfer_with_source_and_destination.update_attributes!(shipped_at: Time.current)
+ described_class.new(ability).activate!
+ end
+
+ it { is_expected.to be_able_to(:manage, transfer_with_source_and_destination) }
+ it { is_expected.to be_able_to(:manage, source_and_destination_transfer_item) }
+ end
+ end
+
+ context "when the user is associated with neither location" do
+ let(:stock_locations) { [] }
+
+ it { is_expected.to_not be_able_to(:display, source_location) }
+ it { is_expected.to_not be_able_to(:display, destination_location) }
+
+ it { is_expected.to_not be_able_to(:display, Spree::StockTransfer) }
+ it { is_expected.to_not be_able_to(:admin, Spree::StockTransfer) }
+ it { is_expected.to_not be_able_to(:create, Spree::StockTransfer) }
+
+ it { is_expected.to_not be_able_to(:transfer_from, source_location) }
+ it { is_expected.to_not be_able_to(:transfer_to, source_location) }
+
+ it { is_expected.to_not be_able_to(:transfer_from, destination_location) }
+ it { is_expected.to_not be_able_to(:transfer_to, destination_location) }
+
+ it { is_expected.to_not be_able_to(:manage, transfer_with_source) }
+ it { is_expected.to_not be_able_to(:manage, transfer_with_destination) }
+ it { is_expected.to_not be_able_to(:manage, transfer_with_source_and_destination) }
+
+ it { is_expected.to_not be_able_to(:manage, source_transfer_item) }
+ it { is_expected.to_not be_able_to(:manage, destination_transfer_item) }
+ it { is_expected.to_not be_able_to(:manage, source_and_destination_transfer_item) }
+ end
+ end
+
+ context "when not activated" do
+ let(:user) { create :user }
+
+ it { is_expected.to_not be_able_to(:display, Spree::StockTransfer) }
+ it { is_expected.to_not be_able_to(:admin, Spree::StockTransfer) }
+ it { is_expected.to_not be_able_to(:create, Spree::StockTransfer) }
+
+ it { is_expected.to_not be_able_to(:display, source_location) }
+ it { is_expected.to_not be_able_to(:display, destination_location) }
+
+ it { is_expected.to_not be_able_to(:transfer_from, source_location) }
+ it { is_expected.to_not be_able_to(:transfer_to, source_location) }
+
+ it { is_expected.to_not be_able_to(:transfer_from, destination_location) }
+ it { is_expected.to_not be_able_to(:transfer_to, destination_location) }
+
+ it { is_expected.to_not be_able_to(:display, source_location) }
+ it { is_expected.to_not be_able_to(:display, destination_location) }
+
+ it { is_expected.to_not be_able_to(:manage, transfer_with_source) }
+ it { is_expected.to_not be_able_to(:manage, transfer_with_destination) }
+ it { is_expected.to_not be_able_to(:manage, transfer_with_source_and_destination) }
+
+ it { is_expected.to_not be_able_to(:manage, source_transfer_item) }
+ it { is_expected.to_not be_able_to(:manage, destination_transfer_item) }
+ it { is_expected.to_not be_able_to(:manage, source_and_destination_transfer_item) }
+ end
+end
diff --git a/spec/models/spree/permission_sets/stock_transfer_display_spec.rb b/spec/models/spree/permission_sets/stock_transfer_display_spec.rb
new file mode 100644
index 0000000..1c8f50d
--- /dev/null
+++ b/spec/models/spree/permission_sets/stock_transfer_display_spec.rb
@@ -0,0 +1,23 @@
+require 'rails_helper'
+
+RSpec.describe Spree::PermissionSets::StockTransferDisplay do
+ let(:ability) { DummyAbility.new }
+
+ subject { ability }
+
+ context "when activated" do
+ before do
+ described_class.new(ability).activate!
+ end
+
+ it { is_expected.to be_able_to(:display, Spree::StockTransfer) }
+ it { is_expected.to be_able_to(:admin, Spree::StockTransfer) }
+ it { is_expected.to be_able_to(:display, Spree::StockLocation) }
+ end
+
+ context "when not activated" do
+ it { is_expected.not_to be_able_to(:display, Spree::StockTransfer) }
+ it { is_expected.not_to be_able_to(:admin, Spree::StockTransfer) }
+ it { is_expected.not_to be_able_to(:display, Spree::StockLocation) }
+ end
+end
diff --git a/spec/models/spree/permission_sets/stock_transfer_management_spec.rb b/spec/models/spree/permission_sets/stock_transfer_management_spec.rb
new file mode 100644
index 0000000..e33af15
--- /dev/null
+++ b/spec/models/spree/permission_sets/stock_transfer_management_spec.rb
@@ -0,0 +1,23 @@
+require 'rails_helper'
+
+RSpec.describe Spree::PermissionSets::StockTransferManagement do
+ let(:ability) { DummyAbility.new }
+
+ subject { ability }
+
+ context "when activated" do
+ before do
+ described_class.new(ability).activate!
+ end
+
+ it { is_expected.to be_able_to(:manage, Spree::StockTransfer) }
+ it { is_expected.to be_able_to(:manage, Spree::TransferItem) }
+ it { is_expected.to be_able_to(:display, Spree::StockLocation) }
+ end
+
+ context "when not activated" do
+ it { is_expected.to_not be_able_to(:manage, Spree::StockTransfer) }
+ it { is_expected.to_not be_able_to(:manage, Spree::TransferItem) }
+ it { is_expected.not_to be_able_to(:display, Spree::StockLocation) }
+ end
+end
diff --git a/spec/models/spree/stock_transfer_spec.rb b/spec/models/spree/stock_transfer_spec.rb
new file mode 100644
index 0000000..247212b
--- /dev/null
+++ b/spec/models/spree/stock_transfer_spec.rb
@@ -0,0 +1,308 @@
+require 'rails_helper'
+
+module Spree
+ RSpec.describe StockTransfer, type: :model do
+ let(:destination_location) { create(:stock_location_with_items) }
+ let(:source_location) { create(:stock_location_with_items) }
+ let(:stock_item) { source_location.stock_items.order(:id).first }
+ let(:variant) { stock_item.variant }
+ let(:stock_transfer) do
+ StockTransfer.create(description: 'PO123', source_location: source_location, destination_location: destination_location)
+ end
+
+ subject { stock_transfer }
+
+ describe '#description' do
+ subject { super().description }
+ it { is_expected.to eq 'PO123' }
+ end
+
+ describe '#to_param' do
+ subject { super().to_param }
+ it { is_expected.to match /T\d+/ }
+ end
+
+ describe "transfer item building" do
+ let(:stock_transfer) do
+ variant = source_location.stock_items.first.variant
+ stock_transfer = Spree::StockTransfer.new(
+ number: "T123",
+ source_location: source_location,
+ destination_location: destination_location
+ )
+ stock_transfer.transfer_items.build(variant: variant, expected_quantity: 5)
+ stock_transfer
+ end
+
+ subject { stock_transfer.save }
+
+ it { is_expected.to eq true }
+
+ it "creates the associated transfer item" do
+ expect { subject }.to change { Spree::TransferItem.count }.by(1)
+ end
+ end
+
+ describe "#receivable?" do
+ subject { stock_transfer.receivable? }
+
+ context "finalized" do
+ before do
+ stock_transfer.update_attributes(finalized_at: Time.current)
+ end
+
+ it { is_expected.to eq false }
+ end
+
+ context "shipped" do
+ before do
+ stock_transfer.update_attributes(shipped_at: Time.current)
+ end
+
+ it { is_expected.to eq false }
+ end
+
+ context "closed" do
+ before do
+ stock_transfer.update_attributes(closed_at: Time.current)
+ end
+
+ it { is_expected.to eq false }
+ end
+
+ context "finalized and closed" do
+ before do
+ stock_transfer.update_attributes(finalized_at: Time.current, closed_at: Time.current)
+ end
+
+ it { is_expected.to eq false }
+ end
+
+ context "shipped and closed" do
+ before do
+ stock_transfer.update_attributes(shipped_at: Time.current, closed_at: Time.current)
+ end
+
+ it { is_expected.to eq false }
+ end
+
+ context "finalized and shipped" do
+ before do
+ stock_transfer.update_attributes(finalized_at: Time.current, shipped_at: Time.current)
+ end
+
+ it { is_expected.to eq true }
+ end
+ end
+
+ describe "#finalizable?" do
+ subject { stock_transfer.finalizable? }
+
+ context "finalized" do
+ before do
+ stock_transfer.update_attributes(finalized_at: Time.current)
+ end
+
+ it { is_expected.to eq false }
+ end
+
+ context "shipped" do
+ before do
+ stock_transfer.update_attributes(shipped_at: Time.current)
+ end
+
+ it { is_expected.to eq false }
+ end
+
+ context "closed" do
+ before do
+ stock_transfer.update_attributes(closed_at: Time.current)
+ end
+
+ it { is_expected.to eq false }
+ end
+
+ context "finalized and closed" do
+ before do
+ stock_transfer.update_attributes(finalized_at: Time.current, closed_at: Time.current)
+ end
+
+ it { is_expected.to eq false }
+ end
+
+ context "shipped and closed" do
+ before do
+ stock_transfer.update_attributes(shipped_at: Time.current, closed_at: Time.current)
+ end
+
+ it { is_expected.to eq false }
+ end
+
+ context "no action taken on stock transfer" do
+ before do
+ stock_transfer.update_attributes(finalized_at: nil, shipped_at: nil, closed_at: nil)
+ end
+
+ it { is_expected.to eq true }
+ end
+ end
+
+ describe "#finalize" do
+ let(:user) { create(:user) }
+
+ subject { stock_transfer.finalize(user) }
+
+ context "can be finalized" do
+ it "sets a finalized_at date" do
+ expect { subject }.to change { stock_transfer.finalized_at }
+ end
+
+ it "sets the finalized_by to the supplied user" do
+ subject
+ expect(stock_transfer.finalized_by).to eq user
+ end
+ end
+
+ context "can't be finalized" do
+ before do
+ stock_transfer.update_attributes(finalized_at: Time.current)
+ end
+
+ it "doesn't set a finalized_at date" do
+ expect { subject }.to_not change { stock_transfer.finalized_at }
+ end
+
+ it "doesn't set a finalized_by user" do
+ expect { subject }.to_not change { stock_transfer.finalized_by }
+ end
+
+ it "adds an error message" do
+ subject
+ expect(stock_transfer.errors.full_messages).to include I18n.t('spree.stock_transfer_cannot_be_finalized')
+ end
+ end
+ end
+
+ describe "#close" do
+ let(:user) { create(:user) }
+ let(:stock_transfer) { create(:receivable_stock_transfer_with_items) }
+
+ subject { stock_transfer.close(user) }
+
+ context "can be closed" do
+ it "sets a closed_at date" do
+ expect { subject }.to change { stock_transfer.closed_at }
+ end
+
+ it "sets the closed_by to the supplied user" do
+ subject
+ expect(stock_transfer.closed_by).to eq user
+ end
+ end
+
+ context "can't be closed" do
+ before do
+ stock_transfer.update_attributes(finalized_at: nil)
+ end
+
+ it "doesn't set a closed_at date" do
+ expect { subject }.to_not change { stock_transfer.closed_at }
+ end
+
+ it "doesn't set a closed_by user" do
+ expect { subject }.to_not change { stock_transfer.closed_by }
+ end
+
+ it "adds an error message" do
+ subject
+ expect(stock_transfer.errors.full_messages).to include I18n.t('spree.stock_transfer_must_be_receivable')
+ end
+ end
+ end
+
+ describe "destroying" do
+ subject { stock_transfer.destroy }
+
+ context "stock transfer is finalized" do
+ before do
+ stock_transfer.update_attributes!(finalized_at: Time.current)
+ end
+
+ it "doesn't destroy the stock transfer" do
+ expect { subject }.to_not change { Spree::StockTransfer.count }
+ end
+
+ it "adds an error message to the model" do
+ subject
+ expect(stock_transfer.errors.full_messages).to include I18n.t('spree.errors.messages.cannot_delete_finalized_stock_transfer')
+ end
+ end
+
+ context "stock transfer is not finalized" do
+ before do
+ stock_transfer.update_attributes!(finalized_at: nil)
+ end
+
+ it "destroys the stock transfer" do
+ expect { subject }.to change { Spree::StockTransfer.count }.by(-1)
+ end
+ end
+ end
+
+ describe '#ship' do
+ let(:stock_transfer) { create(:stock_transfer, tracking_number: "ABC123") }
+
+ context "tracking number is provided" do
+ subject { stock_transfer.ship(tracking_number: "XYZ123") }
+
+ it "updates the tracking number" do
+ expect { subject }.to change { stock_transfer.tracking_number }.from("ABC123").to("XYZ123")
+ end
+ end
+
+ context "tracking number is not provided" do
+ subject { stock_transfer.ship }
+
+ it "preserves the existing tracking number" do
+ expect { subject }.to_not change { stock_transfer.tracking_number }.from("ABC123")
+ end
+ end
+ end
+
+ describe '#transfer' do
+ let(:stock_transfer) { create(:stock_transfer_with_items) }
+
+ before do
+ stock_transfer.transfer_items.each { |item| item.update_attributes(expected_quantity: 1) }
+ end
+
+ subject { stock_transfer.transfer }
+
+ context 'with enough stock' do
+ it 'creates stock movements for transfer items' do
+ expect{ subject }.to change{ Spree::StockMovement.count }.by(stock_transfer.transfer_items.count)
+ end
+ end
+
+ context 'without enough stock' do
+ before do
+ stockless_variant = stock_transfer.transfer_items.last.variant
+ stock_transfer.source_location.stock_item(stockless_variant).set_count_on_hand(0)
+ end
+
+ it 'rollsback the transaction' do
+ expect{ subject }.to_not change{ Spree::StockMovement.count }
+ end
+
+ it 'adds errors' do
+ subject
+ expect(stock_transfer.errors.full_messages.join(', ')).to match /not enough inventory/
+ end
+
+ it 'returns false' do
+ expect(subject).to eq false
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/spree/transfer_item_spec.rb b/spec/models/spree/transfer_item_spec.rb
new file mode 100644
index 0000000..5ed7f69
--- /dev/null
+++ b/spec/models/spree/transfer_item_spec.rb
@@ -0,0 +1,275 @@
+require 'rails_helper'
+
+RSpec.describe Spree::TransferItem do
+ let(:stock_location) { create(:stock_location, name: "Warehouse") }
+ let(:stock_transfer) { create(:stock_transfer_with_items, source_location: stock_location) }
+ let(:transfer_item) { stock_transfer.transfer_items.first }
+
+ subject { transfer_item }
+
+ describe "validation" do
+ before do
+ transfer_item.assign_attributes(expected_quantity: expected_quantity, received_quantity: received_quantity)
+ end
+
+ describe "expected vs received quantity" do
+ context "expected quantity is the same as the received quantity" do
+ let(:expected_quantity) { 1 }
+ let(:received_quantity) { 1 }
+ it { is_expected.to be_valid }
+ end
+
+ context "expected quantity is larger than the received quantity" do
+ let(:expected_quantity) { 3 }
+ let(:received_quantity) { 1 }
+ it { is_expected.to be_valid }
+ end
+
+ context "expected quantity is lower than the received quantity" do
+ let(:expected_quantity) { 1 }
+ let(:received_quantity) { 3 }
+ it { is_expected.to_not be_valid }
+ end
+ end
+
+ describe "numericality" do
+ context "expected_quantity is less than 0" do
+ let(:expected_quantity) { -1 }
+ let(:received_quantity) { 3 }
+ it { is_expected.to_not be_valid }
+ end
+
+ context "received_quantity is less than 0" do
+ let(:expected_quantity) { 1 }
+ let(:received_quantity) { -3 }
+ it { is_expected.to_not be_valid }
+ end
+ end
+
+ describe "availability" do
+ let(:stock_item) do
+ transfer_item.variant.stock_items.find_by(stock_location: stock_transfer.source_location)
+ end
+ let(:expected_quantity) { 1 }
+ let(:received_quantity) { 1 }
+
+ subject { transfer_item.valid? }
+
+ shared_examples_for 'availability check fails' do
+ it "validates the availability" do
+ subject
+ expect(transfer_item.errors.full_messages).to include I18n.t('spree.errors.messages.transfer_item_insufficient_stock')
+ end
+ end
+
+ shared_examples_for 'availability check passes' do
+ it "doesn't validate the availability" do
+ subject
+ expect(transfer_item.errors.full_messages).to_not include I18n.t('spree.errors.messages.transfer_item_insufficient_stock')
+ end
+ end
+
+ context "transfer order is shipped" do
+ before do
+ stock_transfer.update_attributes!(shipped_at: Time.current)
+ end
+
+ context "variant is not available" do
+ before do
+ stock_item.set_count_on_hand(0)
+ end
+ include_examples 'availability check passes'
+
+ context "stock location doesn't check stock" do
+ before do
+ stock_location.update_attributes!(check_stock_on_transfer: false)
+ end
+ include_examples 'availability check passes'
+ end
+ end
+
+ context "variant available" do
+ before do
+ stock_item.set_count_on_hand(transfer_item.expected_quantity)
+ end
+ include_examples 'availability check passes'
+ end
+
+ context "variant does not exist in stock location" do
+ before do
+ stock_item.destroy
+ end
+ include_examples 'availability check passes'
+ end
+ end
+
+ context "transfer order isn't closed" do
+ before do
+ stock_transfer.update_attributes!(closed_at: nil)
+ end
+
+ context "variant is not available" do
+ before do
+ stock_item.set_count_on_hand(0)
+ end
+ include_examples 'availability check fails'
+
+ context "stock location doesn't check stock" do
+ before do
+ stock_location.update_attributes!(check_stock_on_transfer: false)
+ end
+ include_examples 'availability check passes'
+ end
+ end
+
+ context "variant available" do
+ before do
+ stock_item.set_count_on_hand(transfer_item.expected_quantity)
+ end
+ include_examples 'availability check passes'
+ end
+
+ context "variant does not exist in stock location" do
+ before do
+ stock_item.destroy
+ end
+ include_examples 'availability check fails'
+ end
+ end
+ end
+ end
+
+ describe "received stock transfer guard" do
+ subject { transfer_item.reload.update_attributes(received_quantity: 2) }
+
+ describe "closed stock transfer" do
+ context "stock_transfer is not closed" do
+ before do
+ stock_transfer.update_attributes!(closed_at: nil)
+ end
+
+ it { is_expected.to eq true }
+ end
+
+ context "stock_transfer is closed" do
+ before do
+ stock_transfer.update_attributes!(closed_at: Time.current)
+ end
+
+ it { is_expected.to eq false }
+
+ it "adds an error message" do
+ subject
+ expect(transfer_item.errors.full_messages).to include I18n.t('spree.errors.messages.cannot_modify_transfer_item_closed_stock_transfer')
+ end
+ end
+ end
+ end
+
+ describe "expected quantity update guard" do
+ let(:attrs) { { expected_quantity: 1 } }
+
+ subject { transfer_item.update_attributes(attrs) }
+
+ context "stock transfer is finalized" do
+ before do
+ stock_transfer.update_attributes(finalized_at: Time.current)
+ end
+
+ it "adds an error message" do
+ subject
+ expect(transfer_item.errors.full_messages).to include I18n.t('spree.errors.messages.cannot_update_expected_transfer_item_with_finalized_stock_transfer')
+ end
+
+ context "updating received_quantity" do
+ let(:attrs) { { received_quantity: 1 } }
+
+ it "updates the received quantity successfully" do
+ expect { subject }.to change { transfer_item.received_quantity }.to(1)
+ end
+ end
+ end
+
+ context "stock transfer is not finalized" do
+ before do
+ stock_transfer.update_attributes(finalized_at: nil, shipped_at: nil)
+ end
+
+ it "updates the expected quantity successfully" do
+ expect { subject }.to change { transfer_item.expected_quantity }.to(1)
+ end
+ end
+ end
+
+ describe "destroy finalized stock transfer guard" do
+ subject { transfer_item.destroy }
+
+ context "stock transfer is finalized" do
+ before do
+ stock_transfer.update_attributes(finalized_at: Time.current)
+ end
+
+ it "does not destroy the transfer item" do
+ expect { subject }.to_not change { Spree::TransferItem.count }
+ end
+
+ it "adds an error message" do
+ subject
+ expect(transfer_item.errors.full_messages).to include I18n.t('spree.errors.messages.cannot_delete_transfer_item_with_finalized_stock_transfer')
+ end
+ end
+
+ context "stock transfer is not finalized" do
+ before do
+ stock_transfer.update_attributes(finalized_at: nil, shipped_at: nil)
+ end
+
+ it "destroys the transfer item" do
+ expect { subject }.to change { Spree::TransferItem.count }.by(-1)
+ end
+ end
+
+ context "scopes" do
+ let(:partially_received) { stock_transfer.transfer_items.first }
+ let(:fully_received) { stock_transfer.transfer_items.last }
+ let(:variant) { create(:variant) }
+
+ before do
+ fully_received.update_attributes(expected_quantity: 1, received_quantity: 1)
+ partially_received.update_attributes(expected_quantity: 2, received_quantity: 1)
+
+ stock_transfer.source_location.stock_item(variant).set_count_on_hand(5)
+ stock_transfer.transfer_items.create!(variant: variant, expected_quantity: 1, received_quantity: 0)
+ end
+
+ context '.received' do
+ it 'only returns items that have received quantity greater than 0' do
+ expect(Spree::TransferItem.received).to match_array [fully_received, partially_received]
+ end
+ end
+
+ context '.fully_received' do
+ it 'returns only items that have not been fully received' do
+ expect(Spree::TransferItem.fully_received).to eq [fully_received]
+ end
+ end
+
+ context '.partially_received' do
+ it 'returns only items where received quantity is less that expected' do
+ expect(Spree::TransferItem.partially_received).to eq [partially_received]
+ end
+ end
+ end
+ end
+
+ describe "variant association" do
+ context "variant has been soft-deleted" do
+ before do
+ subject.variant.destroy
+ end
+ it "still returns the variant" do
+ expect(subject.variant).not_to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/requests/spree/api/stock_transfers_controller_spec.rb b/spec/requests/spree/api/stock_transfers_controller_spec.rb
new file mode 100644
index 0000000..bd8a55f
--- /dev/null
+++ b/spec/requests/spree/api/stock_transfers_controller_spec.rb
@@ -0,0 +1,97 @@
+require 'spec_helper'
+
+module Spree
+ describe Api::StockTransfersController do
+
+ let!(:stock_transfer) { create(:stock_transfer_with_items) }
+ let(:transfer_item) { stock_transfer.transfer_items.first }
+
+ before do
+ stub_authentication!
+ end
+
+ context "as a normal user" do
+ describe "#receive" do
+ it "cannot receive transfer items from a stock transfer" do
+ post spree.receive_api_stock_transfer_path(stock_transfer), params: { variant_id: transfer_item.variant }
+ expect(response.status).to eq 401
+ end
+ end
+ end
+
+ context "as an admin" do
+ sign_in_as_admin!
+
+ describe "#receive" do
+ subject do
+ post spree.receive_api_stock_transfer_path(stock_transfer), params: { variant_id: variant_id }
+ end
+
+ context "valid parameters" do
+ let(:variant_id) { transfer_item.variant.to_param }
+
+ it "can receive a transfer items from a stock transfer" do
+ subject
+ expect(response.status).to eq 200
+ end
+
+ it "increments the received quantity for the transfer_item" do
+ expect { subject }.to change { transfer_item.reload.received_quantity }.by(1)
+ end
+
+ it "returns the received transfer item in the response" do
+ subject
+ expect(JSON.parse(response.body)["received_item"]["id"]).to eq transfer_item.id
+ end
+ end
+
+ context "transfer item does not have stock in source location after ship" do
+ let(:variant_id) { transfer_item.variant.to_param }
+ let(:user) { create :user }
+
+ before do
+ stock_transfer.finalize(user)
+ stock_transfer.ship(shipped_at: Time.current)
+ stock_transfer.source_location.stock_item(transfer_item.variant_id).set_count_on_hand(0)
+ end
+
+ it "can still receive item" do
+ expect { subject }.to change { transfer_item.reload.received_quantity }.by(1)
+ end
+ end
+
+ context "transfer item has been fully received" do
+ let(:variant_id) { transfer_item.variant.to_param }
+
+ before do
+ transfer_item.update_attributes!(expected_quantity: 1, received_quantity: 1)
+ end
+
+ it "returns a 422" do
+ subject
+ expect(response.status).to eq 422
+ end
+
+ it "returns a specific error message" do
+ subject
+ expect(JSON.parse(response.body)["errors"]["received_quantity"]).to eq ["must be less than or equal to 1"]
+ end
+ end
+
+ context "variant is not in the transfer order" do
+ let(:variant_id) { create(:variant).to_param }
+
+ it "returns a 422" do
+ subject
+ expect(response.status).to eq 422
+ end
+
+ it "returns a specific error message" do
+ subject
+ expect(JSON.parse(response.body)["error"]).to eq I18n.t('spree.item_not_in_stock_transfer')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/spree/api/transfer_items_controller_spec.rb b/spec/requests/spree/api/transfer_items_controller_spec.rb
new file mode 100644
index 0000000..d1f3b3d
--- /dev/null
+++ b/spec/requests/spree/api/transfer_items_controller_spec.rb
@@ -0,0 +1,150 @@
+require 'spec_helper'
+
+module Spree
+ describe Api::TransferItemsController do
+
+ let!(:stock_transfer) { create(:stock_transfer_with_items) }
+ let(:transfer_item) { stock_transfer.transfer_items.first }
+
+ before do
+ stub_authentication!
+ end
+
+ context "as a normal user" do
+ describe "#create" do
+ it "cannot create a transfer item" do
+ post spree.api_stock_transfer_transfer_items_path(stock_transfer)
+ expect(response.status).to eq 401
+ end
+ end
+
+ describe "#update" do
+ it "cannot update a transfer item" do
+ put spree.api_stock_transfer_transfer_item_path(stock_transfer, transfer_item)
+ expect(response.status).to eq 401
+ end
+ end
+
+ describe "#destroy" do
+ it "cannot delete a transfer item" do
+ delete spree.api_stock_transfer_transfer_item_path(stock_transfer, transfer_item)
+ expect(response.status).to eq 401
+ end
+ end
+ end
+
+ context "as an admin" do
+ sign_in_as_admin!
+
+ describe "#create" do
+ subject do
+ create_params = {
+ transfer_item: {
+ variant_id: variant_id,
+ expected_quantity: 1
+ }
+ }
+ post spree.api_stock_transfer_transfer_items_path(stock_transfer), params: create_params
+ end
+
+ context "valid parameters" do
+ let(:variant) { create(:variant) }
+ let(:variant_id) { variant.id }
+
+ context "variant is available" do
+ before do
+ variant.stock_items.update_all(count_on_hand: 1)
+ end
+
+ it "can create a transfer item" do
+ subject
+ expect(response.status).to eq 201
+ end
+
+ it "creates a transfer item" do
+ expect { subject }.to change { Spree::TransferItem.count }.by(1)
+ end
+ end
+
+ context "variant is not available" do
+ before do
+ variant.stock_items.update_all(count_on_hand: 0)
+ end
+
+ it "returns an error status" do
+ subject
+ expect(response.status).to eq 422
+ end
+
+ it "does not create a transfer item" do
+ expect { subject }.to_not change { Spree::TransferItem.count }
+ end
+ end
+ end
+ end
+
+ describe "#update" do
+ subject do
+ update_params = { transfer_item: { received_quantity: received_quantity } }
+ put spree.api_stock_transfer_transfer_item_path(stock_transfer, transfer_item), params: update_params
+ end
+
+ context "valid parameters" do
+ let(:received_quantity) { 2 }
+
+ it "can update a transfer item" do
+ subject
+ expect(response.status).to eq 200
+ end
+
+ it "updates the transfer item" do
+ expect { subject }.to change { transfer_item.reload.received_quantity }.to(2)
+ end
+ end
+
+ context "invalid parameters" do
+ let(:received_quantity) { -5 }
+
+ it "returns a 422" do
+ subject
+ expect(response.status).to eq 422
+ end
+
+ it "does not update the transfer item" do
+ expect { subject }.to_not change { transfer_item.reload.received_quantity }
+ end
+ end
+ end
+
+ describe "#destroy" do
+ subject { delete spree.api_stock_transfer_transfer_item_path(stock_transfer, transfer_item) }
+
+ context "hasn't been finalized" do
+ it "can delete a transfer item" do
+ subject
+ expect(response.status).to eq 200
+ end
+
+ it "deletes the transfer item" do
+ expect { subject }.to change { Spree::TransferItem.count }.by(-1)
+ end
+ end
+
+ context "has been finalized" do
+ before do
+ stock_transfer.update_attributes(finalized_at: Time.current)
+ end
+
+ it "returns an error status code" do
+ subject
+ expect(response.status).to eq 422
+ end
+
+ it "does not delete the transfer item" do
+ expect { subject }.to_not change { Spree::TransferItem.count }
+ end
+ end
+ end
+ end
+ end
+end