diff --git a/app/assets/javascripts/spree/backend/stock_transfers/count_update_forms.coffee b/app/assets/javascripts/spree/backend/stock_transfers/count_update_forms.coffee new file mode 100644 index 0000000..d232113 --- /dev/null +++ b/app/assets/javascripts/spree/backend/stock_transfers/count_update_forms.coffee @@ -0,0 +1,44 @@ +class CountUpdateForms + @beginListening: (isReceiving) -> + # Edit + $('body').on 'click', '#listing_transfer_items [data-action="edit"]', (ev) => + ev.preventDefault() + transferItemId = $(ev.currentTarget).data('id') + Spree.NumberFieldUpdater.hideReadOnly(transferItemId) + Spree.NumberFieldUpdater.showForm(transferItemId) + + # Cancel + $('body').on 'click', '#listing_transfer_items [data-action="cancel"]', (ev) => + ev.preventDefault() + transferItemId = $(ev.currentTarget).data('id') + Spree.NumberFieldUpdater.hideForm(transferItemId) + Spree.NumberFieldUpdater.showReadOnly(transferItemId) + + # Submit + $('body').on 'click', '#listing_transfer_items [data-action="save"]', (ev) => + ev.preventDefault() + transferItemId = $(ev.currentTarget).data('id') + stockTransferNumber = $("#stock_transfer_number").val() + quantity = parseInt($("#number-update-#{transferItemId} input[type='number']").val(), 10) + + itemAttributes = + id: transferItemId + stockTransferNumber: stockTransferNumber + quantityKey = if isReceiving then 'receivedQuantity' else 'expectedQuantity' + itemAttributes[quantityKey] = quantity + transferItem = new Spree.TransferItem(itemAttributes) + transferItem.update(successHandler, errorHandler) + + successHandler = (transferItem) => + if $('#received-transfer-items').length > 0 + Spree.NumberFieldUpdater.successHandler(transferItem.id, transferItem.received_quantity) + Spree.StockTransfers.ReceivedCounter.updateTotal() + else + Spree.NumberFieldUpdater.successHandler(transferItem.id, transferItem.expected_quantity) + show_flash("success", Spree.translations.updated_successfully) + + errorHandler = (errorData) => + show_flash("error", errorData.responseText) + +Spree.StockTransfers ?= {} +Spree.StockTransfers.CountUpdateForms = CountUpdateForms diff --git a/app/assets/javascripts/spree/backend/stock_transfers/edit.coffee b/app/assets/javascripts/spree/backend/stock_transfers/edit.coffee new file mode 100644 index 0000000..2065fb2 --- /dev/null +++ b/app/assets/javascripts/spree/backend/stock_transfers/edit.coffee @@ -0,0 +1,6 @@ +Spree.ready -> + if $('#stock-transfer-transfer-items').length > 0 + Spree.StockTransfers.VariantForm.initializeForm(true) + Spree.StockTransfers.VariantForm.beginListeningForAdd() + Spree.StockTransfers.CountUpdateForms.beginListening(false) + Spree.StockTransfers.TransferItemDeleting.beginListening() diff --git a/app/assets/javascripts/spree/backend/stock_transfers/receive.coffee b/app/assets/javascripts/spree/backend/stock_transfers/receive.coffee new file mode 100644 index 0000000..7e310ae --- /dev/null +++ b/app/assets/javascripts/spree/backend/stock_transfers/receive.coffee @@ -0,0 +1,5 @@ +Spree.ready -> + if $('#received-transfer-items').length > 0 + Spree.StockTransfers.VariantForm.initializeForm(false) + Spree.StockTransfers.VariantForm.beginListeningForReceive() + Spree.StockTransfers.CountUpdateForms.beginListening(true) diff --git a/app/assets/javascripts/spree/backend/stock_transfers/received_counter.coffee b/app/assets/javascripts/spree/backend/stock_transfers/received_counter.coffee new file mode 100644 index 0000000..28db3c3 --- /dev/null +++ b/app/assets/javascripts/spree/backend/stock_transfers/received_counter.coffee @@ -0,0 +1,9 @@ +class ReceivedCounter + @updateTotal: -> + newTotal = _.reduce($('.js-number-update-text'), (memo, el) -> + memo + parseInt($(el).text().trim(), 10) + , 0) + $('#total-received-quantity').text(newTotal) + +Spree.StockTransfers ?= {} +Spree.StockTransfers.ReceivedCounter = ReceivedCounter diff --git a/app/assets/javascripts/spree/backend/stock_transfers/stock_transfer.coffee b/app/assets/javascripts/spree/backend/stock_transfers/stock_transfer.coffee new file mode 100644 index 0000000..b135220 --- /dev/null +++ b/app/assets/javascripts/spree/backend/stock_transfers/stock_transfer.coffee @@ -0,0 +1,17 @@ +class StockTransfer + constructor: (options = {}) -> + @number = options.number + @transferItems = options.transferItems + + receive: (variantId, successHandler, errorHandler) -> + Spree.ajax + url: Spree.routes.receive_stock_transfer_api(@number) + type: "POST" + data: + variant_id: variantId + success: (stockTransfer) => + successHandler(stockTransfer, variantId) + error: (errorData) -> + errorHandler(errorData) + +Spree.StockTransfer = StockTransfer diff --git a/app/assets/javascripts/spree/backend/stock_transfers/transfer_item.coffee b/app/assets/javascripts/spree/backend/stock_transfers/transfer_item.coffee new file mode 100644 index 0000000..9fb335f --- /dev/null +++ b/app/assets/javascripts/spree/backend/stock_transfers/transfer_item.coffee @@ -0,0 +1,48 @@ +class TransferItem + constructor: (options = {}) -> + @id = options.id + @variantId = options.variantId + @receivedQuantity = options.receivedQuantity + @expectedQuantity = options.expectedQuantity + @stockTransferNumber = options.stockTransferNumber + + create: (successHandler, errorHandler) -> + Spree.ajax + url: Spree.routes.create_transfer_items_api(@stockTransferNumber) + type: "POST" + data: + transfer_item: + variant_id: @variantId + expected_quantity: @expectedQuantity + success: (transferItem) -> + successHandler(transferItem) + error: (errorData) -> + errorHandler(errorData) + + update: (successHandler, errorHandler) -> + itemAttrs = if @receivedQuantity? + { received_quantity: @receivedQuantity } + else if @expectedQuantity? + { expected_quantity: @expectedQuantity } + else + {} + Spree.ajax + url: Spree.routes.update_transfer_items_api(@stockTransferNumber, @id) + type: "PUT" + data: + transfer_item: itemAttrs + success: (transferItem) -> + successHandler(transferItem) + error: (errorData) -> + errorHandler(errorData) + + destroy: (successHandler, errorHandler) -> + Spree.ajax + url: Spree.routes.update_transfer_items_api(@stockTransferNumber, @id) + type: "DELETE" + success: (transferItem) -> + successHandler(transferItem) + error: (errorData) -> + errorHandler(errorData) + +Spree.TransferItem = TransferItem diff --git a/app/assets/javascripts/spree/backend/stock_transfers/transfer_item_deleting.coffee b/app/assets/javascripts/spree/backend/stock_transfers/transfer_item_deleting.coffee new file mode 100644 index 0000000..6c436b9 --- /dev/null +++ b/app/assets/javascripts/spree/backend/stock_transfers/transfer_item_deleting.coffee @@ -0,0 +1,22 @@ +class TransferItemDeleting + @beginListening: -> + $('body').on 'click', '#listing_transfer_items [data-action="remove"]', (ev) => + ev.preventDefault() + if confirm(Spree.translations.are_you_sure_delete) + transferItemId = $(ev.currentTarget).data('id') + stockTransferNumber = $("#stock_transfer_number").val() + + transferItem = new Spree.TransferItem + id: transferItemId + stockTransferNumber: stockTransferNumber + transferItem.destroy(successHandler, errorHandler) + + successHandler = (transferItem) => + $("[data-transfer-item-id='#{transferItem.id}']").remove() + show_flash("success", Spree.translations.deleted_successfully) + + errorHandler = (errorData) => + show_flash("error", errorData.responseText) + +Spree.StockTransfers ?= {} +Spree.StockTransfers.TransferItemDeleting = TransferItemDeleting diff --git a/app/assets/javascripts/spree/backend/stock_transfers/variant_form.coffee b/app/assets/javascripts/spree/backend/stock_transfers/variant_form.coffee new file mode 100644 index 0000000..e4e4043 --- /dev/null +++ b/app/assets/javascripts/spree/backend/stock_transfers/variant_form.coffee @@ -0,0 +1,128 @@ +class VariantForm + @initializeForm: (isBuilding) -> + autoCompleteEl().variantAutocomplete({ in_stock_only: isBuilding }) + resetVariantAutocomplete() + + @beginListeningForReceive: -> + variantSelector = autoCompleteEl() + # Search result selected + variantSelector.on 'select2-selecting', (ev) => + ev.preventDefault() + receiveTransferItem(ev.val) + # Search results came back from the server + variantSelector.on 'select2-loaded', (ev) => + if ev.items.results.length == 1 + receiveTransferItem(ev.items.results[0].id) + + @beginListeningForAdd: -> + variantSelector = autoCompleteEl() + # Search result selected + variantSelector.on 'select2-selecting', (ev) => + ev.preventDefault() + createTransferItem(ev.val) + # Search results came back from the server + variantSelector.on 'select2-loaded', (ev) => + if ev.items.results.length == 1 + createTransferItem(ev.items.results[0].id) + + autoCompleteEl = -> + @variantAutocomplete ?= $('[data-hook="transfer_item_selection"]').find('.variant_autocomplete') + @variantAutocomplete + + resetVariantAutocomplete = -> + autoCompleteEl().select2('val', '').trigger('change') + + createTransferItem = (variantId) -> + stockTransferNumber = $("#stock_transfer_number").val() + $(".select2-results").html("
  • #{Spree.translations.adding_match}
  • ") + transferItemRow = $("[data-variant-id='#{variantId}']") + if transferItemRow.length > 0 + transferItemId = transferItemRow.parents('tr:first').data('transfer-item-id') + expectedQuantity = parseInt($("#number-update-#{transferItemId}").find('.js-number-update-text').text().trim(), 10) + transferItem = new Spree.TransferItem + id: transferItemId + stockTransferNumber: stockTransferNumber + expectedQuantity: expectedQuantity + 1 + transferItem.update(updateSuccessHandler, errorHandler) + else + transferItem = new Spree.TransferItem + stockTransferNumber: stockTransferNumber + variantId: variantId + expectedQuantity: 1 + transferItem.create(createSuccessHandler, errorHandler) + + receiveTransferItem = (variantId) -> + stockTransferNumber = $("#stock_transfer_number").val() + $(".select2-results").html("
  • #{Spree.translations.receiving_match}
  • ") + stockTransfer = new Spree.StockTransfer + number: stockTransferNumber + stockTransfer.receive(variantId, receiveSuccessHandler, errorHandler) + + createSuccessHandler = (transferItem) => + successHandler(transferItem, false) + show_flash('success', Spree.translations.created_successfully) + + updateSuccessHandler = (transferItem) => + successHandler(transferItem, false) + show_flash('success', Spree.translations.updated_successfully) + + receiveSuccessHandler = (stockTransfer, variantId) => + receivedItem = + id: stockTransfer.received_item.id + variant: stockTransfer.received_item.variant + received_quantity: stockTransfer.received_item.received_quantity + successHandler(receivedItem, true) + Spree.StockTransfers.ReceivedCounter.updateTotal() + show_flash('success', Spree.translations.received_successfully) + + successHandler = (transferItem, isReceiving) => + resetVariantAutocomplete() + rowTemplate = HandlebarsTemplates['stock_transfers/transfer_item'] + templateAttributes = + id: transferItem.id + isReceiving: isReceiving + variantId: transferItem.variant.id + variantDisplayAttributes: formatVariantDisplayAttributes(transferItem.variant) + variantOptions: formatVariantOptionValues(transferItem.variant) + variantImage: transferItem.variant.images[0] + + if isReceiving + templateAttributes["receivedQuantity"] = transferItem.received_quantity + else + templateAttributes["expectedQuantity"] = transferItem.expected_quantity + + htmlOutput = rowTemplate(templateAttributes) + $("tr[data-transfer-item-id='#{transferItem.id}']").remove() + if $("#listing_transfer_items tbody tr:first").length > 0 + $("#listing_transfer_items tbody tr:first").before(htmlOutput) + else + $("#listing_transfer_items tbody").html(htmlOutput) + $("#listing_transfer_items").prop('hidden', false) + $(".no-objects-found").prop('hidden', true) + $("tr[data-transfer-item-id='#{transferItem.id}']").fadeIn() + + errorHandler = (errorData) -> + resetVariantAutocomplete() + errorMessage = if errorData.responseJSON?.error? and !errorData.responseJSON.errors? + errorData.responseJSON.error + else + errorData.responseText + show_flash('error', errorMessage) + + formatVariantDisplayAttributes = (variant) -> + displayAttributes = JSON.parse($("#variant_display_attributes").val()) + _.map(displayAttributes, (attribute) => + label: Spree.translations[attribute.translation_key] + value: variant[attribute.attr_name] + ) + + formatVariantOptionValues = (variant) -> + optionValues = variant.option_values + optionValues = _.sortBy(optionValues, 'option_type_presentation') + _.map(optionValues, (optionValue) -> + option_type: optionValue.option_type_presentation + option_value: optionValue.presentation + ) + +Spree.StockTransfers ?= {} +Spree.StockTransfers.VariantForm = VariantForm diff --git a/app/assets/javascripts/spree/backend/templates/stock_transfers/transfer_item.hbs b/app/assets/javascripts/spree/backend/templates/stock_transfers/transfer_item.hbs new file mode 100644 index 0000000..29d6cd2 --- /dev/null +++ b/app/assets/javascripts/spree/backend/templates/stock_transfers/transfer_item.hbs @@ -0,0 +1,48 @@ + + +
    +
    + {{> _image image=variantImage size="small" alt=variantName }} +
    +
    + + + {{#each variantDisplayAttributes}} + + + + + {{/each}} + +
    {{this.label}}{{this.value}}
    +
    +
    + + + + {{#each variantOptions}} + + + + + {{/each}} +
    {{this.option_type}}{{this.option_value}}
    + + + {{#if isReceiving }} + {{receivedQuantity}} + + {{ else }} + {{expectedQuantity}} + + {{/if}} + + + + + + {{#unless isReceiving }} + + {{/unless}} + + diff --git a/app/assets/stylesheets/spree/backend/sections/_stock_transfers.scss b/app/assets/stylesheets/spree/backend/sections/_stock_transfers.scss new file mode 100644 index 0000000..0a9b362 --- /dev/null +++ b/app/assets/stylesheets/spree/backend/sections/_stock_transfers.scss @@ -0,0 +1,7 @@ +#stock-transfer-edit-variants { + margin-top: 60px; +} + +#stock-transfer-transfer-items { + margin-top: 80px; +} diff --git a/app/assets/stylesheets/spree/backend/sections/_transfer_items.scss b/app/assets/stylesheets/spree/backend/sections/_transfer_items.scss new file mode 100644 index 0000000..bf9135a --- /dev/null +++ b/app/assets/stylesheets/spree/backend/sections/_transfer_items.scss @@ -0,0 +1,49 @@ +#stock_transfer_summary { + margin: 20px 0px; + display: inline-block; + + .location, + .count-summary { + float: left; + font-size: 15px; + } + .location { + width: 70%; + } + .count-summary { + width: 30%; + text-align: right; + &:after { + content: ""; + clear: both; + display: table; + } + } + .summary-field { + font-weight: 600; + font-size: 105%; + } + .arrow { + margin: 0px 5px; + } +} + +#received-transfer-items, +#stock-transfer-transfer-items { + margin-top: 70px; + .new-row { + display: none; + } +} + +#listing_transfer_items { + > tbody { + tr { + &:hover { + td { + background-color: $color-1; + } + } + } + } +} diff --git a/app/controllers/spree/admin/stock_transfers_controller.rb b/app/controllers/spree/admin/stock_transfers_controller.rb new file mode 100644 index 0000000..c61543b --- /dev/null +++ b/app/controllers/spree/admin/stock_transfers_controller.rb @@ -0,0 +1,141 @@ +module Spree + module Admin + class StockTransfersController < ResourceController + helper 'spree/admin/stock_locations' + + class_attribute :variant_display_attributes + self.variant_display_attributes = [ + { translation_key: :sku, attr_name: :sku }, + { translation_key: :name, attr_name: :name } + ] + + before_action :load_viewable_stock_locations, only: :index + before_action :load_variant_display_attributes, only: [:receive, :edit, :show, :tracking_info] + before_action :load_source_stock_locations, only: :new + before_action :load_destination_stock_locations, only: :edit + before_action :ensure_access_to_stock_location, only: :create + before_action :ensure_receivable_stock_transfer, only: :receive + + create.before :authorize_transfer_attributes! + + def receive + @received_items = @stock_transfer.transfer_items.received + end + + def finalize + if @stock_transfer.finalize(try_spree_current_user) + redirect_to tracking_info_admin_stock_transfer_path(@stock_transfer) + else + flash[:error] = @stock_transfer.errors.full_messages.join(", ") + redirect_to edit_admin_stock_transfer_path(@stock_transfer) + end + end + + def close + Spree::StockTransfer.transaction do + if @stock_transfer.close(try_spree_current_user) + adjust_inventory + redirect_to admin_stock_transfers_path + else + flash[:error] = @stock_transfer.errors.full_messages.join(", ") + redirect_to receive_admin_stock_transfer_path(@stock_transfer) + end + end + end + + def ship + if @stock_transfer.transfer + @stock_transfer.ship(shipped_at: DateTime.current) + flash[:success] = t('spree.stock_transfer_complete') + redirect_to admin_stock_transfers_path + else + flash[:error] = @stock_transfer.errors.full_messages.join(", ") + redirect_to tracking_info_admin_stock_transfer_path(@stock_transfer) + end + end + + private + + def collection + params[:q] = params[:q] || {} + @show_only_open = if params[:q][:closed_at_null].present? + params[:q][:closed_at_null] == '1' + else + true + end + params[:q].delete(:closed_at_null) unless @show_only_open + @search = super.ransack(params[:q]) + @search.sorts = 'created_at desc' + @search.result. + page(params[:page]). + per(params[:per_page] || Spree::Config[:orders_per_page]) + end + + def permitted_resource_params + resource_params = super + if action == :create + resource_params[:created_by] = try_spree_current_user + end + resource_params + end + + def find_resource + model_class.find_by(number: params[:id]) + end + + def render_after_create_error + load_source_stock_locations + super + end + + def location_after_save + if action == :create + edit_admin_stock_transfer_path(@stock_transfer) + else + # redirect back, or to fallback if referer not provided + request.headers["Referer"] || admin_stock_transfers_path + end + end + + def authorize_transfer_attributes! + duplicate = @object.dup + duplicate.assign_attributes(permitted_resource_params) + authorize! :create, duplicate + end + + def load_viewable_stock_locations + @stock_locations = Spree::StockLocation.accessible_by(current_ability, :read) + end + + def load_source_stock_locations + @source_stock_locations ||= Spree::StockLocation.accessible_by(current_ability, :transfer_from) + end + + def load_destination_stock_locations + @destination_stock_locations ||= Spree::StockLocation.accessible_by(current_ability, :transfer_to).where.not(id: @stock_transfer.source_location_id) + end + + def load_variant_display_attributes + @variant_display_attributes = self.class.variant_display_attributes + end + + def ensure_receivable_stock_transfer + unless @stock_transfer.receivable? + flash[:error] = t('spree.stock_transfer_must_be_receivable') + redirect_to(admin_stock_transfers_path) && return + end + end + + def ensure_access_to_stock_location + return unless permitted_resource_params[:source_location_id].present? + authorize! :read, Spree::StockLocation.find(permitted_resource_params[:source_location_id]) + end + + def adjust_inventory + @stock_movements = @stock_transfer.transfer_items.received.map do |transfer_item| + @stock_transfer.destination_location.move(transfer_item.variant, transfer_item.received_quantity, @stock_transfer) + end + end + end + end +end diff --git a/app/controllers/spree/api/stock_transfers_controller.rb b/app/controllers/spree/api/stock_transfers_controller.rb new file mode 100644 index 0000000..f57d501 --- /dev/null +++ b/app/controllers/spree/api/stock_transfers_controller.rb @@ -0,0 +1,20 @@ +module Spree + module Api + class StockTransfersController < Spree::Api::BaseController + def receive + authorize! :update, TransferItem + @stock_transfer = Spree::StockTransfer.accessible_by(current_ability, :update).find_by!(number: params[:id]) + variant = Spree::Variant.accessible_by(current_ability, :show).find(params[:variant_id]) + @transfer_item = @stock_transfer.transfer_items.find_by(variant: variant) + if @transfer_item.nil? + logger.error("variant_not_in_stock_transfer") + render "spree/api/errors/variant_not_in_stock_transfer", status: 422 + elsif @transfer_item.update_attributes(received_quantity: @transfer_item.received_quantity + 1) + render 'spree/api/stock_transfers/receive', status: 200 + else + invalid_resource!(@transfer_item) + end + end + end + end +end diff --git a/app/controllers/spree/api/transfer_items_controller.rb b/app/controllers/spree/api/transfer_items_controller.rb new file mode 100644 index 0000000..af0f9e7 --- /dev/null +++ b/app/controllers/spree/api/transfer_items_controller.rb @@ -0,0 +1,42 @@ +module Spree + module Api + class TransferItemsController < Spree::Api::BaseController + def create + authorize! :create, TransferItem + stock_transfer = Spree::StockTransfer.accessible_by(current_ability, :update).find_by(number: params[:stock_transfer_id]) + @transfer_item = stock_transfer.transfer_items.build(transfer_item_params) + if @transfer_item.save + respond_with(@transfer_item, status: 201, default_template: :show) + else + invalid_resource!(@transfer_item) + end + end + + def update + authorize! :update, TransferItem + @transfer_item = Spree::TransferItem.accessible_by(current_ability, :update).find(params[:id]) + if @transfer_item.update_attributes(transfer_item_params) + respond_with(@transfer_item, status: 200, default_template: :show) + else + invalid_resource!(@transfer_item) + end + end + + def destroy + authorize! :destroy, TransferItem + @transfer_item = Spree::TransferItem.accessible_by(current_ability, :destroy).find(params[:id]) + if @transfer_item.destroy + respond_with(@transfer_item, status: 200, default_template: :show) + else + invalid_resource!(@transfer_item) + end + end + + private + + def transfer_item_params + params.require(:transfer_item).permit(permitted_transfer_item_attributes) + end + end + end +end diff --git a/app/helpers/spree/admin/stock_transfers_helper.rb b/app/helpers/spree/admin/stock_transfers_helper.rb new file mode 100644 index 0000000..4e0ff93 --- /dev/null +++ b/app/helpers/spree/admin/stock_transfers_helper.rb @@ -0,0 +1,35 @@ +module Spree + module Admin + module StockTransfersHelper + def handle_stock_transfer(stock_transfer) + if can?(:show, stock_transfer) + link_to stock_transfer.number, admin_stock_transfer_path(stock_transfer) + else + stock_transfer.number + end + end + + def stock_transfer_edit_or_ship_path(stock_transfer) + if stock_transfer.finalized? + tracking_info_admin_stock_transfer_path(stock_transfer) + else + edit_admin_stock_transfer_path(stock_transfer) + end + end + + def deleted_variant_admin_hint(variant) + newer_variant_with_same_sku = Spree::Variant.find_by(sku: variant.sku) + hint_type = newer_variant_with_same_sku ? :deleted_explanation_with_replacement : :deleted_explanation + hint_text = I18n.t( + hint_type, + scope: [:spree, :hints, "spree/variant"], + date: variant.deleted_at + ) + admin_hint( + I18n.t(:deleted, scope: [:spree, :hints, "spree/variant"]), + hint_text + ) + end + end + end +end diff --git a/app/models/spree/stock_transfer.rb b/app/models/spree/stock_transfer.rb new file mode 100644 index 0000000..636d1e5 --- /dev/null +++ b/app/models/spree/stock_transfer.rb @@ -0,0 +1,110 @@ +module Spree + class StockTransfer < Spree::Base + class InvalidTransferMovement < StandardError; end + + acts_as_paranoid + + has_many :stock_movements, as: :originator + has_many :transfer_items, inverse_of: :stock_transfer + + belongs_to :created_by, class_name: Spree::UserClassHandle.new + belongs_to :finalized_by, class_name: Spree::UserClassHandle.new + belongs_to :closed_by, class_name: Spree::UserClassHandle.new + belongs_to :source_location, class_name: 'Spree::StockLocation' + belongs_to :destination_location, class_name: 'Spree::StockLocation' + + validates_presence_of :source_location + validates_presence_of :destination_location, if: :finalized? + + make_permalink field: :number, prefix: 'T' + + before_destroy :ensure_not_finalized + + self.whitelisted_ransackable_attributes = %w[source_location_id destination_location_id closed_at created_at number] + + def to_param + number + end + + def finalized? + finalized_at.present? + end + + def closed? + closed_at.present? + end + + def shipped? + shipped_at.present? + end + + def finalizable? + !finalized? && !shipped? && !closed? + end + + def receivable? + finalized? && shipped? && !closed? + end + + def ship(tracking_number: self.tracking_number, shipped_at: nil) + update_attributes!(tracking_number: tracking_number, shipped_at: shipped_at) + end + + def received_item_count + transfer_items.sum(:received_quantity) + end + + def expected_item_count + transfer_items.sum(:expected_quantity) + end + + def source_movements + stock_movements.joins(:stock_item) + .where('spree_stock_items.stock_location_id' => source_location_id) + end + + def destination_movements + stock_movements.joins(:stock_item) + .where('spree_stock_items.stock_location_id' => destination_location_id) + end + + def finalize(finalized_by) + if finalizable? + update_attributes({ finalized_at: Time.current, finalized_by: finalized_by }) + else + errors.add(:base, I18n.t('spree.stock_transfer_cannot_be_finalized')) + false + end + end + + def transfer + transaction do + transfer_items.each do |item| + raise InvalidTransferMovement unless item.valid? + source_location.unstock(item.variant, item.expected_quantity, self) + end + end + rescue InvalidTransferMovement + errors.add(:base, I18n.t('spree.not_enough_stock')) + false + end + + def close(closed_by) + if receivable? + update_attributes({ closed_at: Time.current, closed_by: closed_by }) + else + errors.add(:base, I18n.t('spree.stock_transfer_must_be_receivable')) + false + end + end + + private + + def ensure_not_finalized + if finalized? + errors.add(:base, I18n.t('spree.errors.messages.cannot_delete_finalized_stock_transfer')) + throw :abort + end + end + end +end diff --git a/app/models/spree/transfer_item.rb b/app/models/spree/transfer_item.rb new file mode 100644 index 0000000..dcc497a --- /dev/null +++ b/app/models/spree/transfer_item.rb @@ -0,0 +1,54 @@ +module Spree + class TransferItem < Spree::Base + acts_as_paranoid + belongs_to :stock_transfer, inverse_of: :transfer_items + belongs_to :variant, -> { with_deleted } + + validate :stock_availability, if: :check_stock? + validates :stock_transfer, :variant, presence: true + validates :variant_id, uniqueness: { scope: :stock_transfer_id }, allow_blank: true + validates :expected_quantity, numericality: { greater_than: 0 } + validates :received_quantity, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: :expected_quantity } + + scope :received, -> { where('received_quantity > 0') } + scope :fully_received, -> { where('expected_quantity = received_quantity') } + scope :partially_received, -> { received.where('expected_quantity > received_quantity') } + + before_destroy :ensure_stock_transfer_not_finalized + before_validation :ensure_stock_transfer_not_closed + before_update :prevent_expected_quantity_update_stock_transfer_finalized + + private + + def ensure_stock_transfer_not_closed + if stock_transfer.closed? + errors.add(:base, I18n.t('spree.errors.messages.cannot_modify_transfer_item_closed_stock_transfer')) + end + end + + def ensure_stock_transfer_not_finalized + unless stock_transfer.finalizable? + errors.add(:base, I18n.t('spree.errors.messages.cannot_delete_transfer_item_with_finalized_stock_transfer')) + throw :abort + end + end + + def prevent_expected_quantity_update_stock_transfer_finalized + if expected_quantity_changed? && stock_transfer.finalized? + errors.add(:base, I18n.t('spree.errors.messages.cannot_update_expected_transfer_item_with_finalized_stock_transfer')) + throw :abort + end + end + + def stock_availability + stock_item = variant.stock_items.find_by(stock_location: stock_transfer.source_location) + if stock_item.nil? || stock_item.count_on_hand < expected_quantity + errors.add(:base, I18n.t('spree.errors.messages.transfer_item_insufficient_stock')) + end + end + + def check_stock? + !stock_transfer.shipped? && stock_transfer.source_location.check_stock_on_transfer? + end + end +end diff --git a/app/views/spree/admin/stock_transfers/_location.html.erb b/app/views/spree/admin/stock_transfers/_location.html.erb new file mode 100644 index 0000000..8d80ac6 --- /dev/null +++ b/app/views/spree/admin/stock_transfers/_location.html.erb @@ -0,0 +1,7 @@ +
    +
    + <%= admin_stock_location_display_name(stock_transfer.source_location) %> + + <%= admin_stock_location_display_name(stock_transfer.destination_location) if stock_transfer.destination_location %> +
    +
    diff --git a/app/views/spree/admin/stock_transfers/_search.html.erb b/app/views/spree/admin/stock_transfers/_search.html.erb new file mode 100644 index 0000000..6b221d7 --- /dev/null +++ b/app/views/spree/admin/stock_transfers/_search.html.erb @@ -0,0 +1,51 @@ +<% content_for :table_filter do %> +
    + <%= search_form_for [:admin, @search] do |f| %> +
    +
    + <%= f.label nil, Spree::StockLocation.model_name.human %> + <%= f.select :source_location_id_or_destination_location_id_eq, options_from_collection_for_select(@stock_locations, :id, :name, params[:q][:source_location_id_or_destination_location_id_eq]), {include_blank: true}, {class: 'select2 fullwidth'} %> +
    +
    + +
    +
    + <%= f.label nil, t('spree.date_range') %> +
    + <%= f.text_field :created_at_gt, class: 'datepicker datepicker-from', include_blank: true, value: params[:q][:created_at_gt], placeholder: t('spree.start') %> + + + + + + <%= f.text_field :created_at_lt, class: 'datepicker datepicker-to', include_blank: true, value: params[:q][:created_at_lt], placeholder: t('spree.stop') %> +
    +
    +
    + +
    +
    + <%= f.label nil, Spree::StockTransfer.human_attribute_name(:number) %> + <%= f.text_field :number_cont, value: params[:q][:number_cont] %> +
    +
    + +
    +
    + +
    +
    + +
    + +
    +
    + <%= button t('spree.filter_results') %> +
    +
    + <% end %> +
    +<% end %> diff --git a/app/views/spree/admin/stock_transfers/_stock_transfer_table.html.erb b/app/views/spree/admin/stock_transfers/_stock_transfer_table.html.erb new file mode 100644 index 0000000..6176498 --- /dev/null +++ b/app/views/spree/admin/stock_transfers/_stock_transfer_table.html.erb @@ -0,0 +1,50 @@ +<%= render 'search' %> + +<% if @stock_transfers.any? %> + + + + + + + + + + + + + + + <% @stock_transfers.each do |stock_transfer| %> + + + + + + + + + + + <% end %> + +
    <%= sort_link @search, :number, t('spree.number') %><%= sort_link @search, :from, t('spree.from') %><%= sort_link @search, :to, t('spree.to') %><%= sort_link @search, :expected_items, t('spree.expected') %><%= sort_link @search, :received_items, t('spree.received') %><%= sort_link @search, :shipped_at, t('spree.shipped') %><%= sort_link @search, :status, t('spree.status') %>
    <%= handle_stock_transfer(stock_transfer) %><%= stock_transfer.source_location.name %><%= stock_transfer.destination_location.try(:name) %><%= stock_transfer.expected_item_count %><%= stock_transfer.received_item_count %><%= stock_transfer.shipped_at.try(:to_date) %> + + <%= t(stock_transfer.closed? ? :closed : :open, scope: 'spree') %> + + + <% if stock_transfer.receivable? && can?(:edit, stock_transfer) %> + <%= link_to_with_icon 'download', t('spree.actions.receive'), receive_admin_stock_transfer_path(stock_transfer), no_text: true, data: { action: 'green' } %> + <% elsif !stock_transfer.closed? && can?(:edit, stock_transfer) %> + <%= link_to_with_icon 'edit', t('spree.actions.edit'), stock_transfer_edit_or_ship_path(stock_transfer), no_text: true, data: { action: 'edit' } %> + <% elsif can?(:show, stock_transfer) %> + <%= link_to_with_icon 'eye', t('spree.show'), admin_stock_transfer_path(stock_transfer), no_text: true, data: { action: 'green' } %> + <% end %> +
    +<% else %> +
    + <%= render 'spree/admin/shared/no_objects_found', + resource: Spree::StockTransfer, + new_resource_url: new_object_url %> +
    +<% end %> diff --git a/app/views/spree/admin/stock_transfers/_transfer_item_actions.html.erb b/app/views/spree/admin/stock_transfers/_transfer_item_actions.html.erb new file mode 100644 index 0000000..372f72c --- /dev/null +++ b/app/views/spree/admin/stock_transfers/_transfer_item_actions.html.erb @@ -0,0 +1,9 @@ +<% if show_actions %> + <%= render partial: 'spree/admin/shared/number_field_update_cell', locals: { resource_id: item.id, field_tag: "#{quantity_type}_quantity", number_value: item.send("#{quantity_type}_quantity") } %> + + <%= render partial: 'spree/admin/shared/number_field_update_actions', locals: { resource: item, update_data: {} } %> + <%= link_to_with_icon 'trash', t('spree.actions.delete'), '#', no_text: true, data: { action: 'remove', id: item.id } if quantity_type == 'expected' && can?(:destroy, item) %> + +<% else %> + <%= item.send("#{quantity_type}_quantity") %> +<% end %> diff --git a/app/views/spree/admin/stock_transfers/_transfer_item_table.html.erb b/app/views/spree/admin/stock_transfers/_transfer_item_table.html.erb new file mode 100644 index 0000000..2686765 --- /dev/null +++ b/app/views/spree/admin/stock_transfers/_transfer_item_table.html.erb @@ -0,0 +1,72 @@ + id="listing_transfer_items"> + + + + + + + + + + + + <% if show_expected %><% end %> + <% if show_received %><% end %> + <% if show_actions %><% end %> + + + + <% transfer_items.each do |item| %> + <%- variant = item.variant %> + + + + <% if show_expected %><%= render partial: 'transfer_item_actions', locals: { item: item, show_actions: show_actions, quantity_type: 'expected'} %><% end %> + <% if show_received %><%= render partial: 'transfer_item_actions', locals: { item: item, show_actions: show_actions, quantity_type: 'received'} %><% end %> + + <% end %> + +
    <%= t('spree.item') %><%= t('spree.options') %><%= t('spree.expected') %><%= t('spree.received') %>
    +
    +
    + <%= render 'spree/admin/shared/image', image: variant.display_image(fallback: false), size: :small %> +
    +
    + + + + <% if variant.deleted? %> + + + + <% end %> + <% @variant_display_attributes.each do |display_attribute| %> + + + + + <% end %> + +
    + <%= I18n.t(:deleted, scope: [:spree, :hints, "spree/variant"]) %> + <%= deleted_variant_admin_hint(variant) %> +
    <%= t(display_attribute[:translation_key], scope: 'spree') %> + <%= variant.send(display_attribute[:attr_name]) %> +
    +
    +
    +
    + + <% variant.option_values.sort_by(&:option_type_name).each do |option_value| %> + + + + + <% end %> +
    + <%= option_value.option_type_presentation %> + <%= option_value.presentation %>
    +
    +
    > + <%= t('spree.no_resource', resource: plural_resource_name(Spree::TransferItem)) %> +
    diff --git a/app/views/spree/admin/stock_transfers/edit.html.erb b/app/views/spree/admin/stock_transfers/edit.html.erb new file mode 100644 index 0000000..b60d908 --- /dev/null +++ b/app/views/spree/admin/stock_transfers/edit.html.erb @@ -0,0 +1,52 @@ +<% admin_breadcrumb(link_to t('spree.stock'), spree.admin_stock_items_path) %> +<% admin_breadcrumb(link_to plural_resource_name(Spree::StockTransfer), spree.admin_stock_transfers_path) %> +<% admin_breadcrumb(@stock_transfer.number) %> + + +<% content_for :page_actions do %> +
  • +
  • +
  • + <%= button_link_to t('spree.ready_to_ship'), + finalize_admin_stock_transfer_path(@stock_transfer), + method: 'put', + data: { confirm: t('spree.finalize_stock_transfer.confirm') } + %> +
  • +<% end %> + +<%= form_for [:admin, @stock_transfer] do |f| %> +
    +
    + +

    <%= @stock_transfer.created_by.email %>

    +
    + <%= f.field_container :description do %> + <%= f.label :description %> + <%= f.text_field :description, value: @stock_transfer.description, maxlength: 255, size: 0, class: 'fullwidth' %> + <%= f.error_message_on :description %> + <% end %> + <%= f.field_container :destination_location do %> + <%= f.label :destination_location_id %> + <%= f.select :destination_location_id, options_from_collection_for_select(@destination_stock_locations, :id, :name, @stock_transfer.destination_location_id), {include_blank: true}, {class: 'custom-select fullwidth', "data-placeholder" => t('spree.select_a_stock_location')} %> + <%= f.error_message_on :destination_location %> + <% end %> +
    + <%= button t('spree.actions.save') %> +
    +
    +<% end %> + +
    + <%= t('spree.variant_to_add') %> +
    + <%= hidden_field_tag :stock_transfer_number, @stock_transfer.number %> + <%= hidden_field_tag :variant_display_attributes, @variant_display_attributes.to_json %> + <%= text_field_tag :transfer_item_variant_id, "", class: "variant_autocomplete fullwidth" %> +
    +
    + +
    + <%= t('spree.added') %> + <%= render partial: 'transfer_item_table', locals: { transfer_items: @stock_transfer.transfer_items, show_expected: true, show_received: false, show_actions: true } %> +
    diff --git a/app/views/spree/admin/stock_transfers/index.html.erb b/app/views/spree/admin/stock_transfers/index.html.erb new file mode 100644 index 0000000..e035fe3 --- /dev/null +++ b/app/views/spree/admin/stock_transfers/index.html.erb @@ -0,0 +1,27 @@ +<% admin_breadcrumb(link_to t('spree.stock'), spree.admin_stock_items_path) %> +<% admin_breadcrumb(plural_resource_name(Spree::StockTransfer)) %> + +<% content_for :page_actions do %> + <% if can? :create, Spree::StockTransfer %> +
  • + <%= button_link_to t('spree.new_stock_transfer'), new_admin_stock_transfer_path %> +
  • + <% end %> +<% end %> + +<% content_for :table_filter_title do %> + <%= t('spree.search') %> +<% end %> + +<%= paginate @stock_transfers, theme: "solidus_admin" %> + +<% if @stock_locations.size >= 2 %> + <%= render 'stock_transfer_table' %> +<% else %> +
    + <%= t('spree.admin.stock_transfers.no_stock_locations_found') %> + <%= link_to t('spree.admin.stock_transfers.create_additional_stock_location'), new_admin_stock_location_path %> +
    +<% end %> + +<%= paginate @stock_transfers, theme: "solidus_admin" %> diff --git a/app/views/spree/admin/stock_transfers/new.html.erb b/app/views/spree/admin/stock_transfers/new.html.erb new file mode 100644 index 0000000..56261d4 --- /dev/null +++ b/app/views/spree/admin/stock_transfers/new.html.erb @@ -0,0 +1,26 @@ +<% admin_breadcrumb(link_to t('spree.stock'), spree.admin_stock_items_path) %> +<% admin_breadcrumb(link_to plural_resource_name(Spree::StockTransfer), spree.admin_stock_transfers_path) %> +<% admin_breadcrumb(t('spree.new_stock_transfer')) %> + + +<% content_for :page_actions do %> +<% end %> + +<%= form_for [:admin, @stock_transfer] do |f| %> +
    + <%= f.field_container :source_location do %> + <%= f.label :source_location_id %> + <%= f.select :source_location_id, options_from_collection_for_select(@source_stock_locations, :id, :name), {include_blank: true}, {class: 'custom-select fullwidth', "data-placeholder" => t('spree.select_a_stock_location')} %> + <%= f.error_message_on :source_location %> + <% end %> + <%= f.field_container :description do %> + <%= f.label :description %> + <%= f.text_field :description, maxlength: 255, size: 0, class: 'fullwidth' %> + <%= f.error_message_on :description %> + <% end %> +
    + <%= button t('spree.continue') %> + <%= link_to t('spree.actions.cancel'), admin_stock_transfers_path, class: 'button' %> +
    +
    +<% end %> diff --git a/app/views/spree/admin/stock_transfers/receive.html.erb b/app/views/spree/admin/stock_transfers/receive.html.erb new file mode 100644 index 0000000..c0a2762 --- /dev/null +++ b/app/views/spree/admin/stock_transfers/receive.html.erb @@ -0,0 +1,38 @@ +<% admin_breadcrumb("#{t('spree.receiving')} #{@stock_transfer.number}") %> + + +<% content_for :page_actions do %> +
  • + <%= button_link_to t('spree.back_to_stock_transfers_list'), admin_stock_transfers_path %> +
  • +
  • + <%= button_link_to t('spree.close'), + close_admin_stock_transfer_path(@stock_transfer), + method: 'put', + data: { confirm: t('spree.close_stock_transfer.confirm') } %> +
  • +<% end %> + +
    + <%= render 'spree/admin/stock_transfers/location', stock_transfer: @stock_transfer %> +
    + <%= @stock_transfer.received_item_count %> + / + <%= @stock_transfer.expected_item_count %> + <%= t('spree.received') %> +
    +
    + +
    + <%= t('spree.variant_to_be_received') %> +
    + <%= hidden_field_tag :stock_transfer_number, @stock_transfer.number %> + <%= hidden_field_tag :variant_display_attributes, @variant_display_attributes.to_json %> + <%= text_field_tag :transfer_item_variant_id, "", class: "variant_autocomplete fullwidth" %> +
    +
    + +
    + <%= t('spree.received_items') %> + <%= render partial: 'transfer_item_table', locals: { transfer_items: @received_items, show_expected: false, show_received: true, show_actions: true } %> +
    diff --git a/app/views/spree/admin/stock_transfers/show.html.erb b/app/views/spree/admin/stock_transfers/show.html.erb new file mode 100644 index 0000000..5340024 --- /dev/null +++ b/app/views/spree/admin/stock_transfers/show.html.erb @@ -0,0 +1,50 @@ +<% admin_breadcrumb(link_to t('spree.stock'), spree.admin_stock_items_path) %> +<% admin_breadcrumb(Spree::StockTransfer.model_name.human, @stock_transfer.number) %> + + +<% content_for :page_actions do %> +
  • + <%= button_link_to t('spree.back_to_stock_transfers_list'), admin_stock_transfers_path %> +
  • +<% end %> + +
    + <%= render 'spree/admin/stock_transfers/location', stock_transfer: @stock_transfer %> + +
    + +

    <%= @stock_transfer.description %>

    +
    + +
    +
    + +

    <%= @stock_transfer.created_by.email %>

    +
    + +
    + +

    <%= @stock_transfer.created_at.try(:to_date) %>

    +
    + +
    + +

    <%= @stock_transfer.finalized_at.try(:to_date) %>

    +
    + +
    + +

    <%= @stock_transfer.finalized_by.try(:email) %>

    +
    + +
    + +

    <%= @stock_transfer.shipped_at.try(:to_date) %>

    +
    +
    +
    + +
    + <%= plural_resource_name(Spree::TransferItem) %> + <%= render partial: 'transfer_item_table', locals: { transfer_items: @stock_transfer.transfer_items, show_expected: true, show_received: true, show_actions: false } %> +
    diff --git a/app/views/spree/admin/stock_transfers/tracking_info.html.erb b/app/views/spree/admin/stock_transfers/tracking_info.html.erb new file mode 100644 index 0000000..9e788b0 --- /dev/null +++ b/app/views/spree/admin/stock_transfers/tracking_info.html.erb @@ -0,0 +1,46 @@ +<% admin_breadcrumb("#{t('spree.actions.ship')} #{Spree::StockTransfer.model_name.human} \##{@stock_transfer.number}") %> + + +<% content_for :page_actions do %> +
  • + <%= button_link_to t('spree.back_to_stock_transfers_list'), admin_stock_transfers_path %> +
  • +
  • + <%= button_link_to t('spree.actions.ship'), + ship_admin_stock_transfer_path(@stock_transfer), + method: 'put', + data: { confirm: t('spree.ship_stock_transfer.confirm') } + %> +
  • +<% end %> + +
    + <%= render 'spree/admin/stock_transfers/location', stock_transfer: @stock_transfer %> +
    +
    <%= Spree::StockTransfer.human_attribute_name(:created_by) %>: <%= @stock_transfer.created_by.email %>
    +
    <%= @stock_transfer.description %>
    +
    +
    + +
    + <%= t('spree.tracking_info') %> + <%= form_for [:admin, @stock_transfer] do |f| %> +
    +
    + <%= f.label :tracking_number %> + <%= f.text_field :tracking_number, value: @stock_transfer.tracking_number, maxlength: 255, size: 0, class: 'fullwidth' %> +
    +
    + +
    + +
    + <%= f.submit t('spree.actions.save') %> +
    + <% end %> +
    + +
    + <%= plural_resource_name(Spree::TransferItem) %> + <%= render partial: 'transfer_item_table', locals: { transfer_items: @stock_transfer.transfer_items, show_expected: true, show_received: false, show_actions: false } %> +
    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