| @@ -0,0 +1,85 @@ | ||
| <div data-hook="admin_stock_locations_form_fields" class="row"> | ||
| <div class="row"> | ||
| <div class="alpha twelve columns"> | ||
| <%= f.field_container :name do %> | ||
| <%= f.label :name, t(:name) %> | ||
| <%= f.text_field :name, :class => 'fullwidth' %> | ||
| <% end %> | ||
| </div> | ||
| <div class="omega four columns"> | ||
| <%= f.field_container :active do %> | ||
| <label for="active"><%= t(:state) %></label> | ||
| <ul> | ||
| <li> | ||
| <%= f.label :active, t(:active) + ':' %> | ||
| <%= f.check_box :active %> | ||
| </li> | ||
| </ul> | ||
| <% end %> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="row"> | ||
| <div class="alpha four columns"> | ||
| <div class="field "> | ||
| <%= f.label :address1, t(:street_address) + ':' %> | ||
| <%= f.text_field :address1, :class => 'fullwidth' %> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="four columns"> | ||
| <div class="field "> | ||
| <%= f.label :address2, t(:street_address_2) + ':' %> | ||
| <%= f.text_field :address2, :class => 'fullwidth' %> | ||
| </div> | ||
| </div> | ||
| <div class="four columns"> | ||
| <div class="field "> | ||
| <%= f.label :city, t(:city) + ':' %> | ||
| <%= f.text_field :city, :class => 'fullwidth' %> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="omega four columns"> | ||
| <div class="field "> | ||
| <%= f.label :zipcode, t(:zip) + ':' %> | ||
| <%= f.text_field :zipcode, :class => 'fullwidth' %> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="row"> | ||
| <div class="alpha eight columns"> | ||
| <div class="field "> | ||
| <%= f.label :country_id, t(:country) + ':' %> | ||
| <span id="country"><%= f.collection_select :country_id, available_countries, :id, :name, {}, {:class => 'select2 fullwidth'} %></span> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="four columns"> | ||
| <div class="field "> | ||
| <%= f.label :state_id, t(:state) + ':' %> | ||
| <span id="state" class="region"> | ||
| <%= f.text_field :state_name, :style => "display: #{f.object.country.states.empty? ? 'block' : 'none' };", :disabled => !f.object.country.states.empty?, :class => 'fullwidth state_name' %> | ||
| <%= f.collection_select :state_id, f.object.country.states.sort, :id, :name, {:include_blank => true}, {:class => 'select2 fullwidth', :style => "display: #{f.object.country.states.empty? ? 'none' : 'block' };", :disabled => f.object.country.states.empty?} %> | ||
| </span> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="omega four columns"> | ||
| <div class="field "> | ||
| <%= f.label :phone, t(:phone) + ':' %> | ||
| <%= f.phone_field :phone, :class => 'fullwidth' %> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <% content_for :head do %> | ||
| <%= javascript_include_tag 'admin/address_states.js' %> | ||
| <%= javascript_tag do -%> | ||
| $(document).ready(function(){ | ||
| $('span#country .select2').on('change', function() { update_state(''); }); | ||
| }); | ||
| <% end -%> | ||
| <% end %> |
| @@ -0,0 +1,16 @@ | ||
| <% content_for :page_title do %> | ||
| <%= t(:editing_stock_location) %> <i class="icon-arrow-right"></i> <%= @stock_location.name %> | ||
| <% end %> | ||
| <% content_for :page_actions do %> | ||
| <li><%= link_to_with_icon 'icon-arrow-left', t(:back_to_stock_locations_list), admin_stock_locations_path, :class => 'button' %></li> | ||
| <% end %> | ||
| <%= render :partial => 'spree/shared/error_messages', :locals => { :target => @stock_location } %> | ||
| <%= form_for [:admin, @stock_location] do |f| %> | ||
| <fieldset class="no-border-top"> | ||
| <%= render :partial => 'form', :locals => { :f => f } %> | ||
| <%= render :partial => 'spree/admin/shared/edit_resource_links' %> | ||
| </fieldset> | ||
| <% end %> |
| @@ -0,0 +1,46 @@ | ||
| <%= render :partial => 'spree/admin/shared/configuration_menu' %> | ||
| <% content_for :page_title do %> | ||
| <%= t(:stock_locations) %> | ||
| <% end %> | ||
| <% content_for :page_actions do %> | ||
| <ul class="actions inline-menu"> | ||
| <li> | ||
| <%= button_link_to t(:new_stock_location), new_object_url, { :icon => 'icon-plus', :id => 'admin_new_stock_location' } %> | ||
| </li> | ||
| </ul> | ||
| <% end %> | ||
|
|
||
| <table class="index" id='listing_stock_locations' data-hook> | ||
| <colgroup> | ||
| <col style="width: 50%" /> | ||
| <col style="width: 15%" /> | ||
| <col style="width: 20%" /> | ||
| <col style="width: 15%" /> | ||
| </colgroup> | ||
| <thead> | ||
| <tr data-hook="stock_locations_header"> | ||
| <th><%= t(:name) %></th> | ||
| <th><%= t(:state) %></th> | ||
| <th><%= t(:stock_movements) %></th> | ||
| <th class="actions"></th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| <% @stock_locations.each do |stock_location| | ||
| @edit_url = edit_admin_stock_location_path(stock_location) | ||
| @delete_url = admin_stock_location_path(stock_location) | ||
| %> | ||
| <tr id="<%= spree_dom_id stock_location %>" data-hook="stock_location_row" class="<%= cycle('odd', 'even')%>"> | ||
| <td><%= stock_location.name %></td> | ||
| <td><span class="state <%= stock_location.active? ? 'active' : 'inactive' %>"><%= stock_location.active? ? 'active' : 'inactive' %></span></td> | ||
| <td><%= link_to t(:stock_movements), admin_stock_location_stock_movements_path(stock_location.id) %> </td> | ||
| <td class="actions"> | ||
| <%= link_to_edit stock_location, :no_text => true %> | ||
| <%= link_to_delete stock_location, :no_text => true %> | ||
| </td> | ||
| </tr> | ||
| <% end %> | ||
| </tbody> | ||
| </table> |
| @@ -0,0 +1,16 @@ | ||
| <% content_for :page_title do %> | ||
| <%= t(:new_stock_location) %> | ||
| <% end %> | ||
| <% content_for :page_actions do %> | ||
| <li><%= link_to_with_icon 'icon-arrow-left', t(:back_to_stock_locations_list), admin_stock_locations_path, :class => 'button' %></li> | ||
| <% end %> | ||
| <%= render :partial => 'spree/shared/error_messages', :locals => { :target => @stock_locations } %> | ||
| <% @stock_location.country = Spree::Country.find(Spree::Config[:default_country_id]) %> | ||
| <%= form_for [:admin, @stock_location] do |f| %> | ||
| <fieldset class="no-border-top"> | ||
| <%= render :partial => 'form', :locals => { :f => f } %> | ||
| <%= render :partial => 'spree/admin/shared/new_resource_links' %> | ||
| </fieldset> | ||
| <% end %> |
| @@ -0,0 +1,13 @@ | ||
| <div data-hook="admin_stock_movements_form_fields" class="row"> | ||
| <div class="alpha four columns"> | ||
| <%= f.field_container :quantity do %> | ||
| <%= f.label :quantity, t(:quantity) %> | ||
| <%= f.text_field :quantity, :class => 'fullwidth' %> | ||
| <% end %> | ||
| <%= f.field_container :stock_item_id do %> | ||
| <%= f.label :stock_item_id, t(:stock_item_id) %> | ||
| <%= collection_select 'stock_movement', 'stock_item_id', stock_location.stock_items, :id, :variant_name, | ||
| { selected: @stock_movement.stock_item_id }, class: 'select2 fullwidth' %> | ||
| <% end %> | ||
| </div> | ||
| </div> |
| @@ -0,0 +1,16 @@ | ||
| <% content_for :page_title do %> | ||
| <%= t(:editing_stock_movement) %> | ||
| <% end %> | ||
| <% content_for :page_actions do %> | ||
| <li><%= link_to_with_icon 'icon-arrow-left', t(:back_to_stock_movements_list), admin_stock_location_stock_movements_path(stock_location), :class => 'button' %></li> | ||
| <% end %> | ||
| <%= render :partial => 'spree/shared/error_messages', :locals => { :target => @stock_movement } %> | ||
| <%= form_for [:admin, stock_location, @stock_movement] do |f| %> | ||
| <fieldset class="no-border-top"> | ||
| <%= render :partial => 'form', :locals => { :f => f } %> | ||
| <%= render :partial => 'spree/admin/shared/new_resource_links', locals: { collection_url: admin_stock_location_stock_movements_path(stock_location) } %> | ||
| </fieldset> | ||
| <% end %> |
| @@ -0,0 +1,41 @@ | ||
| <%= render :partial => 'spree/admin/shared/configuration_menu' %> | ||
| <% content_for :page_title do %> | ||
| <%= t(:stock_movements_for_stock_location, stock_location_name: @stock_location.name) %> | ||
| <% end %> | ||
| <% content_for :page_actions do %> | ||
| <li> | ||
| <%= button_link_to t(:new_stock_movement), new_admin_stock_location_stock_movement_path(@stock_location), icon: 'icon-plus', id: 'admin_new_stock_movement_link' %> | ||
| </li> | ||
| <% end %> | ||
|
|
||
| <table class="index" id='listing_stock_movements'> | ||
| <colgroup> | ||
| <col style="width: 35%"> | ||
| <col style="width: 20%"> | ||
| <col style="width: 45%"> | ||
| </colgroup> | ||
| <thead> | ||
| <tr data-hook="admin_stock_movements_index_headers"> | ||
| <th><%= t(:stock_item) %> | ||
| <th><%= t(:quantity) %></th> | ||
| <th><%= t(:action) %></th> | ||
| <th data-hook="admin_stock_movements_index_header_actions" class="actions"></th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| <% @stock_movements.each do |stock_movement|%> | ||
| <tr id="<%= spree_dom_id stock_movement %>" data-hook="admin_stock_movements_index_rows" class="<%= cycle('odd', 'even')%>"> | ||
| <td class="align-center"><%= stock_movement.stock_item.variant_name %></td> | ||
| <td class="align-center"><%= stock_movement.quantity %></td> | ||
| <td class="align-center"><%= stock_movement.action %></td> | ||
| <td data-hook="admin_stock_movements_index_row_actions" class="actions"> | ||
| <%= link_to_with_icon 'icon-edit', t(:edit), edit_admin_stock_location_stock_movement_path(@stock_location, stock_movement), data: { action: 'edit' }, no_text: true %> | ||
| <%= link_to_with_icon 'icon-trash', t(:delete), admin_stock_location_stock_movement_path(@stock_location, stock_movement), method: :delete, | ||
| data: { confirm: t(:are_you_sure), action: 'remove' }, no_text: true %> | ||
| </td> | ||
| </tr> | ||
| <% end %> | ||
| </tbody> | ||
| </table> |
| @@ -0,0 +1,16 @@ | ||
| <% content_for :page_title do %> | ||
| <%= t(:new_stock_movement) %> | ||
| <% end %> | ||
| <% content_for :page_actions do %> | ||
| <li><%= link_to_with_icon 'icon-arrow-left', t(:back_to_stock_movements_list), admin_stock_location_stock_movements_path(stock_location), :class => 'button' %></li> | ||
| <% end %> | ||
| <%= render :partial => 'spree/shared/error_messages', :locals => { :target => @stock_movement } %> | ||
| <%= form_for [:admin, stock_location, @stock_movement] do |f| %> | ||
| <fieldset class="no-border-top"> | ||
| <%= render :partial => 'form', :locals => { :f => f } %> | ||
| <%= render :partial => 'spree/admin/shared/new_resource_links', locals: { collection_url: admin_stock_location_stock_movements_path(stock_location) } %> | ||
| </fieldset> | ||
| <% end %> |
| @@ -0,0 +1,99 @@ | ||
| require 'spec_helper' | ||
|
|
||
| describe "Stock Management" do | ||
| stub_authorization! | ||
|
|
||
| context "as admin user" do | ||
| before(:each) do | ||
| visit spree.admin_path | ||
| end | ||
|
|
||
| context "given a product with a variant and a stock location" do | ||
| before do | ||
| create(:stock_location, name: 'Default') | ||
| @product = create(:product, name: 'apache baseball cap', price: 10) | ||
| v = @product.variants.create!(sku: 'FOOBAR') | ||
| v.stock_items.first.update_column(:count_on_hand, 10) | ||
|
|
||
| click_link "Products" | ||
| within_row(1) do | ||
| click_icon :edit | ||
| end | ||
| end | ||
|
|
||
| it "can view count on hand for the variant" do | ||
| click_link "Stock Management" | ||
|
|
||
| within_row(1) do | ||
| page.should have_content('Count On Hand') | ||
| within(:css, '.stock_location_info') do | ||
| column_text(2).should have_content('10') | ||
| end | ||
| end | ||
| end | ||
|
|
||
| it "can toggle backorderable for a variant's stock item", js: true do | ||
| click_link "Stock Management" | ||
|
|
||
| backorderable = find "#stock_item_backorderable" | ||
| backorderable.should be_checked | ||
|
|
||
| backorderable.set(false) | ||
| visit current_path | ||
|
|
||
| backorderable.should_not be_checked | ||
| end | ||
|
|
||
| it "can create a new stock movement", js: true do | ||
| click_link "Stock Management" | ||
|
|
||
| fill_in "stock_movement_quantity", with: 5 | ||
| select2 "default", from: "Stock Location" | ||
| click_button "Add Stock" | ||
|
|
||
| page.should have_content('successfully created') | ||
|
|
||
| within(:css, '.stock_location_info table') do | ||
| column_text(2).should eq '15' | ||
| end | ||
| end | ||
|
|
||
| it "can create a new negative stock movement", js: true do | ||
| click_link "Stock Management" | ||
|
|
||
| fill_in "stock_movement_quantity", with: -5 | ||
| select2 "default", from: "Stock Location" | ||
| click_button "Add Stock" | ||
|
|
||
| page.should have_content('successfully created') | ||
|
|
||
| within(:css, '.stock_location_info table') do | ||
| column_text(2).should eq '5' | ||
| end | ||
| end | ||
|
|
||
| context "with multiple variants" do | ||
| before do | ||
| v = @product.variants.create!(sku: 'SPREEC') | ||
| v.stock_items.first.update_column(:count_on_hand, 30) | ||
| end | ||
|
|
||
| it "can create a new stock movement for the specified variant", js: true do | ||
| click_link "Stock Management" | ||
| fill_in "stock_movement_quantity", with: 10 | ||
| select2 "SPREEC", from: "Variant" | ||
| click_button "Add Stock" | ||
|
|
||
| page.should have_content('successfully created') | ||
|
|
||
| within_row(2) do | ||
| page.should have_content("SPREEC") | ||
| within(:css, '.stock_location_info table') do | ||
| column_text(2).should eq '40' | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -31,19 +31,7 @@ def link_to_cart(text = nil) | ||
|
|
||
| # human readable list of variant options | ||
| def variant_options(v, options={}) | ||
| v.options_text | ||
| end | ||
|
|
||
| def meta_data_tags | ||
| @@ -0,0 +1,21 @@ | ||
| require_dependency 'spree/shipping_calculator' | ||
|
|
||
| module Spree | ||
| module Calculator::Shipping | ||
| class FlatPercentItemTotal < ShippingCalculator | ||
| preference :flat_percent, :decimal, :default => 0 | ||
| attr_accessible :preferred_flat_percent | ||
|
|
||
| def self.description | ||
| I18n.t(:flat_percent) | ||
| end | ||
|
|
||
| def compute_package(package) | ||
| content_items = package.contents | ||
| item_total = total(content_items) | ||
| value = item_total * BigDecimal(self.preferred_flat_percent.to_s) / 100.0 | ||
| (value * 100).round.to_f / 100 | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,20 @@ | ||
| require_dependency 'spree/shipping_calculator' | ||
|
|
||
| module Spree | ||
| module Calculator::Shipping | ||
| class FlatRate < ShippingCalculator | ||
| preference :amount, :decimal, :default => 0 | ||
| preference :currency, :string, :default => Spree::Config[:currency] | ||
|
|
||
| attr_accessible :preferred_amount, :preferred_currency | ||
|
|
||
| def self.description | ||
| I18n.t(:shipping_flat_rate_per_order) | ||
| end | ||
|
|
||
| def compute_package(package) | ||
| self.preferred_amount | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,38 @@ | ||
| require_dependency 'spree/shipping_calculator' | ||
|
|
||
| module Spree | ||
| module Calculator::Shipping | ||
| class FlexiRate < ShippingCalculator | ||
| preference :first_item, :decimal, :default => 0.0 | ||
| preference :additional_item, :decimal, :default => 0.0 | ||
| preference :max_items, :integer, :default => 0 | ||
| preference :currency, :string, :default => Spree::Config[:currency] | ||
|
|
||
| attr_accessible :preferred_first_item, | ||
| :preferred_additional_item, | ||
| :preferred_max_items, | ||
| :preferred_currency | ||
|
|
||
| def self.description | ||
| I18n.t(:shipping_flexible_rate) | ||
| end | ||
|
|
||
| def compute_package(package) | ||
| content_items = package.contents | ||
| sum = 0 | ||
| max = self.preferred_max_items.to_i | ||
| items_count = content_items.map(&:quantity).sum | ||
| items_count.times do |i| | ||
| # check max value to avoid divide by 0 errors | ||
| if (max == 0 && i == 0) || (max > 0) && (i % max == 0) | ||
| sum += self.preferred_first_item.to_f | ||
| else | ||
| sum += self.preferred_additional_item.to_f | ||
| end | ||
| end | ||
|
|
||
| sum | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,20 @@ | ||
| require_dependency 'spree/shipping_calculator' | ||
|
|
||
| module Spree | ||
| module Calculator::Shipping | ||
| class PerItem < ShippingCalculator | ||
| preference :amount, :decimal, :default => 0 | ||
| preference :currency, :string, :default => Spree::Config[:currency] | ||
| attr_accessible :preferred_amount, :preferred_currency | ||
|
|
||
| def self.description | ||
| I18n.t(:shipping_flat_rate_per_item) | ||
| end | ||
|
|
||
| def compute_package(package) | ||
| content_items = package.contents | ||
| self.preferred_amount * content_items.sum { |item| item.quantity } | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,32 @@ | ||
| require_dependency 'spree/shipping_calculator' | ||
| # For #to_d method on Ruby 1.8 | ||
| require 'bigdecimal/util' | ||
|
|
||
| module Spree | ||
| module Calculator::Shipping | ||
| class PriceSack < ShippingCalculator | ||
| preference :minimal_amount, :decimal, :default => 0 | ||
| preference :normal_amount, :decimal, :default => 0 | ||
| preference :discount_amount, :decimal, :default => 0 | ||
| preference :currency, :string, :default => Spree::Config[:currency] | ||
|
|
||
| attr_accessible :preferred_minimal_amount, | ||
| :preferred_normal_amount, | ||
| :preferred_discount_amount, | ||
| :preferred_currency | ||
|
|
||
| def self.description | ||
| I18n.t(:shipping_price_sack) | ||
| end | ||
|
|
||
| def compute_package(package) | ||
| content_items = package.contents | ||
| if total(content_items) < self.preferred_minimal_amount | ||
| self.preferred_normal_amount | ||
| else | ||
| self.preferred_discount_amount | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,69 @@ | ||
| module Spree | ||
| class OrderContents | ||
| attr_accessor :order, :currency | ||
|
|
||
| def initialize(order) | ||
| @order = order | ||
| end | ||
|
|
||
| def add(variant, quantity, shipment=nil) | ||
| #get current line item for variant if exists | ||
| line_item = order.find_line_item_by_variant(variant) | ||
|
|
||
| #add variant qty to line_item | ||
| add_to_line_item(line_item, variant, quantity, shipment) | ||
| end | ||
|
|
||
| def remove(variant, quantity, shipment=nil) | ||
| #get current line item for variant | ||
| line_item = order.find_line_item_by_variant(variant) | ||
|
|
||
| #TODO raise exception if line_item is nil | ||
|
|
||
| #remove variant qty from line_item | ||
| remove_from_line_item(line_item, variant, quantity, shipment) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def add_to_line_item(line_item, variant, quantity, shipment=nil) | ||
| if line_item | ||
| line_item.target_shipment = shipment | ||
| line_item.quantity += quantity | ||
| line_item.currency = currency unless currency.nil? | ||
| line_item.save | ||
| else | ||
| line_item = LineItem.new(:quantity => quantity) | ||
| line_item.target_shipment = shipment | ||
| line_item.variant = variant | ||
| if currency | ||
| line_item.currency = currency unless currency.nil? | ||
| line_item.price = variant.price_in(currency).amount | ||
| else | ||
| line_item.price = variant.price | ||
| end | ||
| order.line_items << line_item | ||
| line_item | ||
| end | ||
|
|
||
| order.reload | ||
| line_item | ||
| end | ||
|
|
||
| def remove_from_line_item(line_item, variant, quantity, shipment=nil) | ||
| line_item.quantity += -quantity | ||
| line_item.target_shipment= shipment | ||
|
|
||
| if line_item.quantity == 0 | ||
| Spree::OrderInventory.new(order).verify(line_item, shipment) | ||
| line_item.destroy | ||
| else | ||
| line_item.save! | ||
| end | ||
|
|
||
| order.reload | ||
| line_item | ||
| end | ||
|
|
||
| end | ||
| end |
| @@ -0,0 +1,96 @@ | ||
| module Spree | ||
| class OrderInventory | ||
| attr_accessor :order | ||
|
|
||
| def initialize(order) | ||
| @order = order | ||
| end | ||
|
|
||
| def verify(line_item, shipment=nil) | ||
| return true unless order.completed? | ||
|
|
||
| variant_units = inventory_units_for(line_item.variant) | ||
|
|
||
| if variant_units.size < line_item.quantity | ||
| #add | ||
|
|
||
| quantity = line_item.quantity - variant_units.size | ||
|
|
||
| shipment ||= order.shipments.detect { |shipment| (shipment.ready? || shipment.pending?) && shipment.include?(line_item.variant) } | ||
| shipment ||= order.shipments.detect { |shipment| (shipment.ready? || shipment.pending?) && line_item.variant.stock_location_ids.include?(shipment.stock_location_id) } | ||
|
|
||
| add_to_shipment(shipment,line_item.variant, quantity) | ||
|
|
||
| elsif variant_units.size > line_item.quantity | ||
| #remove | ||
|
|
||
| quantity = variant_units.size - line_item.quantity | ||
|
|
||
| order.shipments.each do |shipment| | ||
| break if quantity == 0 | ||
|
|
||
| quantity -= remove_from_shipment(shipment, line_item.variant, quantity) | ||
| end | ||
|
|
||
| end | ||
| end | ||
|
|
||
| def inventory_units_for(variant) | ||
| return [] unless order.completed? | ||
|
|
||
| units = order.shipments.collect{|s| s.inventory_units.all}.flatten | ||
| units.group_by(&:variant_id)[variant.id] || [] | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def add_to_shipment(shipment, variant, quantity) | ||
| #create inventory_units | ||
| on_hand, back_order = shipment.stock_location.fill_status(variant, quantity) | ||
|
|
||
| on_hand.times do | ||
| shipment.inventory_units.create({:variant_id => variant.id, | ||
| :state => 'on_hand'}, :without_protection => true) | ||
| end | ||
|
|
||
| back_order.times do | ||
| shipment.inventory_units.create({:variant_id => variant.id, | ||
| :state => 'backordered'}, :without_protection => true) | ||
| end | ||
|
|
||
|
|
||
| # adding to this shipment, and removing from stock_location | ||
| shipment.stock_location.unstock variant, quantity, shipment | ||
|
|
||
| # return quantity added | ||
| quantity | ||
| end | ||
|
|
||
| def remove_from_shipment(shipment, variant, quantity) | ||
| return 0 if quantity == 0 || shipment.shipped? | ||
|
|
||
| shipment_units = shipment.inventory_units_for(variant).reject do |variant_unit| | ||
| variant_unit.state == 'shipped' | ||
| end.sort_by(&:state) | ||
|
|
||
| removed_quantity = 0 | ||
|
|
||
| shipment_units.each do |inventory_unit| | ||
| break if removed_quantity == quantity | ||
| inventory_unit.destroy | ||
| removed_quantity += 1 | ||
| end | ||
|
|
||
| if shipment.inventory_units.count == 0 | ||
| shipment.destroy | ||
| end | ||
|
|
||
| # removing this from shipment, and adding to stock_location | ||
| shipment.stock_location.restock variant, removed_quantity, shipment | ||
|
|
||
| # return quantity removed | ||
| removed_quantity | ||
| end | ||
|
|
||
| end | ||
| end |
| @@ -0,0 +1,24 @@ | ||
| module Spree | ||
| class ShippingCalculator < Calculator | ||
| belongs_to :calculable, :polymorphic => true | ||
|
|
||
| def compute(package_or_shipment) | ||
| package = package_or_shipment.respond_to?(:to_package) ? | ||
| package_or_shipment.to_package : package_or_shipment | ||
| compute_package package | ||
| end | ||
|
|
||
| def compute_package(package) | ||
| raise(NotImplementedError, 'please use concrete calculator') | ||
| end | ||
|
|
||
| def available?(package) | ||
| true | ||
| end | ||
|
|
||
| private | ||
| def total(content_items) | ||
| content_items.sum { |item| item.quantity * item.variant.price } | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,6 @@ | ||
| module Spree | ||
| class ShippingMethodCategory < ActiveRecord::Base | ||
| belongs_to :shipping_method | ||
| belongs_to :shipping_category | ||
| end | ||
| end |
| @@ -0,0 +1,28 @@ | ||
| # Used by Prioritizer to adjust item quantities | ||
| # see prioritizer_spec for use cases | ||
| module Spree | ||
| module Stock | ||
| class Adjuster | ||
| attr_accessor :variant, :need, :status | ||
|
|
||
| def initialize(variant, quantity, status) | ||
| @variant = variant | ||
| @need = quantity | ||
| @status = status | ||
| end | ||
|
|
||
| def adjust(item) | ||
| if item.quantity >= need | ||
| item.quantity = need | ||
| @need = 0 | ||
| elsif item.quantity < need | ||
| @need -= item.quantity | ||
| end | ||
| end | ||
|
|
||
| def fulfilled? | ||
| @need == 0 | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,15 @@ | ||
| module Spree | ||
| module Stock | ||
| class AvailabilityValidator < ActiveModel::Validator | ||
|
|
||
| def validate(line_item) | ||
| quantifier = Stock::Quantifier.new(line_item.variant_id) | ||
|
|
||
| unless quantifier.can_supply? line_item.quantity | ||
| line_item.errors[:quantity] << I18n.t('validation.exceeds_available_stock') | ||
| end | ||
|
|
||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,49 @@ | ||
| module Spree | ||
| module Stock | ||
| class Coordinator | ||
| attr_reader :order | ||
|
|
||
| def initialize(order) | ||
| @order = order | ||
| end | ||
|
|
||
| def packages | ||
| packages = Array.new | ||
| packages = build_packages(packages) | ||
| packages = prioritize_packages(packages) | ||
| packages = estimate_packages(packages) | ||
| end | ||
|
|
||
| private | ||
| def build_packages(packages) | ||
| StockLocation.active.each do |stock_location| | ||
| packer = build_packer(stock_location, order) | ||
| packages += packer.packages | ||
| end | ||
| packages | ||
| end | ||
|
|
||
| def prioritize_packages(packages) | ||
| prioritizer = Prioritizer.new(order, packages) | ||
| prioritizer.prioritized_packages | ||
| end | ||
|
|
||
| def estimate_packages(packages) | ||
| estimator = Estimator.new(order) | ||
| packages.each do |package| | ||
| package.shipping_rates = estimator.shipping_rates(package) | ||
| end | ||
| packages | ||
| end | ||
|
|
||
| def build_packer(stock_location, order) | ||
| Packer.new(stock_location, order, splitters(stock_location)) | ||
| end | ||
|
|
||
| def splitters(stock_location) | ||
| # extension point to return custom splitters for a location | ||
| Rails.application.config.spree.stock_splitters | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,44 @@ | ||
| module Spree | ||
| module Stock | ||
| class Differentiator | ||
| attr_reader :missing, :packed, :required, :packages, :order | ||
|
|
||
| def initialize(order, packages) | ||
| @order = order | ||
| @packages = packages | ||
| build_packed | ||
| build_required | ||
| build_missing | ||
| end | ||
|
|
||
| def missing? | ||
| missing.values.any? { |v| v > 0 } | ||
| end | ||
|
|
||
| private | ||
| def build_missing | ||
| @missing = Hash.new(0) | ||
| required.keys.each do |variant| | ||
| missing = required[variant] - packed[variant] | ||
| @missing[variant] = missing if missing > 0 | ||
| end | ||
| end | ||
|
|
||
| def build_packed | ||
| @packed = Hash.new(0) | ||
| packages.each do |package| | ||
| package.contents.each do |content_item| | ||
| @packed[content_item.variant] += content_item.quantity | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def build_required | ||
| @required = Hash.new(0) | ||
| order.line_items.each do |line_item| | ||
| @required[line_item.variant] = line_item.quantity | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,38 @@ | ||
| module Spree | ||
| module Stock | ||
| class Estimator | ||
| attr_reader :order, :currency | ||
|
|
||
| def initialize(order) | ||
| @order = order | ||
| @currency = order.currency | ||
| end | ||
|
|
||
| def shipping_rates(package) | ||
| shipping_rates = Array.new | ||
| shipping_methods = shipping_methods(package) | ||
| return [] unless shipping_methods | ||
| shipping_methods.each do |shipping_method| | ||
| cost = calculate_cost(shipping_method, package) | ||
|
|
||
| shipping_rates << ShippingRate.new( :shipping_method => shipping_method, | ||
| :cost => cost) | ||
| end | ||
| shipping_rates | ||
| end | ||
|
|
||
| private | ||
| def shipping_methods(package) | ||
| shipping_methods = package.shipping_methods | ||
| shipping_methods.delete_if { |ship_method| !ship_method.calculator.available?(package.contents) } | ||
| shipping_methods.delete_if { |ship_method| !ship_method.include?(order.ship_address) } | ||
| shipping_methods.delete_if { |ship_method| !(ship_method.calculator.preferences[:currency].nil? || ship_method.calculator.preferences[:currency] == currency) } | ||
| shipping_methods | ||
| end | ||
|
|
||
| def calculate_cost(shipping_method, package) | ||
| shipping_method.calculator.compute(package) | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,54 @@ | ||
| module Spree | ||
| module Stock | ||
| class OrderCounter | ||
| attr_reader :order | ||
|
|
||
| def initialize(order) | ||
| @order = order | ||
| @ordered_counts = count_line_items | ||
| @assigned_counts = count_inventory_units | ||
| end | ||
|
|
||
| def variants | ||
| @ordered_counts.keys | ||
| end | ||
|
|
||
| def variants_with_remaining | ||
| variants.select { |variant| remaining(variant) > 0 } | ||
| end | ||
|
|
||
| def remaining? | ||
| not variants_with_remaining.empty? | ||
| end | ||
|
|
||
| def ordered(variant) | ||
| @ordered_counts[variant] | ||
| end | ||
|
|
||
| def assigned(variant) | ||
| @assigned_counts[variant] | ||
| end | ||
|
|
||
| def remaining(variant) | ||
| @ordered_counts[variant] - @assigned_counts[variant] | ||
| end | ||
|
|
||
| private | ||
| def count_line_items | ||
| counts = Hash.new(0) | ||
| order.line_items.each do |line_item| | ||
| counts[line_item.variant] += line_item.quantity | ||
| end | ||
| counts | ||
| end | ||
|
|
||
| def count_inventory_units | ||
| counts = Hash.new(0) | ||
| order.inventory_units.each do |inventory_unit| | ||
| counts[inventory_unit.variant] += 1 | ||
| end | ||
| counts | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,116 @@ | ||
| module Spree | ||
| module Stock | ||
| class Package | ||
| ContentItem = Struct.new(:variant, :quantity, :state) | ||
|
|
||
| attr_reader :stock_location, :order, :contents | ||
| attr_accessor :shipping_rates | ||
|
|
||
| def initialize(stock_location, order, contents=[]) | ||
| @stock_location = stock_location | ||
| @order = order | ||
| @contents = contents | ||
| @shipping_rates = Array.new | ||
| end | ||
|
|
||
| def add(variant, quantity, state=:on_hand) | ||
| contents << ContentItem.new(variant, quantity, state) | ||
| end | ||
|
|
||
| def weight | ||
| contents.sum { |item| item.variant.weight * item.quantity } | ||
| end | ||
|
|
||
| def on_hand | ||
| contents.select { |item| item.state == :on_hand } | ||
| end | ||
|
|
||
| def backordered | ||
| contents.select { |item| item.state == :backordered } | ||
| end | ||
|
|
||
| def find_item(variant, state=:on_hand) | ||
| contents.select do |item| | ||
| item.variant == variant && | ||
| item.state == state | ||
| end.first | ||
| end | ||
|
|
||
| def quantity(state=nil) | ||
| case state | ||
| when :on_hand | ||
| on_hand.sum { |item| item.quantity } | ||
| when :backordered | ||
| backordered.sum { |item| item.quantity } | ||
| else | ||
| contents.sum { |item| item.quantity } | ||
| end | ||
| end | ||
|
|
||
| def empty? | ||
| quantity == 0 | ||
| end | ||
|
|
||
| def flattened | ||
| flat = [] | ||
| contents.each do |item| | ||
| item.quantity.times do | ||
| flat << ContentItem.new(item.variant, 1, item.state) | ||
| end | ||
| end | ||
| flat | ||
| end | ||
|
|
||
| def flattened=(flattened) | ||
| contents.clear | ||
| flattened.each do |item| | ||
| current_item = find_item(item.variant, item.state) | ||
| if current_item | ||
| current_item.quantity += 1 | ||
| else | ||
| add(item.variant, item.quantity, item.state) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def currency | ||
| #TODO calculate from first variant? | ||
| end | ||
|
|
||
| def shipping_categories | ||
| contents.map { |item| item.variant.shipping_category }.uniq | ||
| end | ||
|
|
||
| def shipping_methods | ||
| shipping_categories.map { |sc| sc.shipping_methods }.flatten.uniq | ||
| end | ||
|
|
||
| def inspect | ||
| out = "#{order} - " | ||
| out << contents.map do |content_item| | ||
| "#{content_item.variant.name} #{content_item.quantity} #{content_item.state}" | ||
| end.join('/') | ||
| out | ||
| end | ||
|
|
||
| def to_shipment | ||
| shipment = Spree::Shipment.new | ||
| shipment.order = order | ||
| shipment.stock_location = stock_location | ||
| shipment.shipping_rates = shipping_rates | ||
|
|
||
| contents.each do |item| | ||
| item.quantity.times do |n| | ||
| unit = shipment.inventory_units.build | ||
| unit.pending = true | ||
| unit.order = order | ||
| unit.variant = item.variant | ||
| unit.state = item.state.to_s | ||
| end | ||
| end | ||
|
|
||
| shipment | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,36 @@ | ||
| module Spree | ||
| module Stock | ||
| class Packer | ||
| attr_reader :stock_location, :order, :splitters | ||
|
|
||
| def initialize(stock_location, order, splitters=[Splitter::Base]) | ||
| @stock_location = stock_location | ||
| @order = order | ||
| @splitters = splitters | ||
| end | ||
|
|
||
| def packages | ||
| build_splitter.split [default_package] | ||
| end | ||
|
|
||
| def default_package | ||
| package = Package.new(stock_location, order) | ||
| order.line_items.each do |line_item| | ||
| on_hand, backordered = stock_location.fill_status(line_item.variant, line_item.quantity) | ||
| package.add line_item.variant, on_hand, :on_hand if on_hand > 0 | ||
| package.add line_item.variant, backordered, :backordered if backordered > 0 | ||
| end | ||
| package | ||
| end | ||
|
|
||
| private | ||
| def build_splitter | ||
| splitter = nil | ||
| splitters.reverse.each do |klass| | ||
| splitter = klass.new(self, splitter) | ||
| end | ||
| splitter | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,47 @@ | ||
| module Spree | ||
| module Stock | ||
| class Prioritizer | ||
| attr_reader :packages, :order | ||
|
|
||
| def initialize(order, packages, adjuster_class=Adjuster) | ||
| @order = order | ||
| @packages = packages | ||
| @adjuster_class = adjuster_class | ||
| end | ||
|
|
||
| def prioritized_packages | ||
| sort_packages | ||
| adjust_packages | ||
| prune_packages | ||
| packages | ||
| end | ||
|
|
||
| private | ||
| def adjust_packages | ||
| order.line_items.each do |line_item| | ||
| adjuster = @adjuster_class.new(line_item.variant, line_item.quantity, :on_hand) | ||
|
|
||
| visit_packages(adjuster) | ||
|
|
||
| adjuster.status = :backordered | ||
| visit_packages(adjuster) | ||
| end | ||
| end | ||
|
|
||
| def visit_packages(adjuster) | ||
| packages.each do |package| | ||
| item = package.find_item adjuster.variant, adjuster.status | ||
| adjuster.adjust(item) if item | ||
| end | ||
| end | ||
|
|
||
| def sort_packages | ||
| # order packages by preferred stock_locations | ||
| end | ||
|
|
||
| def prune_packages | ||
| packages.reject! { |pkg| pkg.empty? } | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,28 @@ | ||
| module Spree | ||
| module Stock | ||
| class Quantifier | ||
| attr_reader :stock_items | ||
|
|
||
| def initialize(variant) | ||
| @variant = variant | ||
| @stock_items = Spree::StockItem.joins(:stock_location).where(:variant_id => @variant, Spree::StockLocation.table_name =>{ :active => true}) | ||
| end | ||
|
|
||
| def total_on_hand | ||
| if Spree::Config.track_inventory_levels | ||
| stock_items.sum(&:count_on_hand) | ||
| else | ||
| Float::INFINITY | ||
| end | ||
| end | ||
|
|
||
| def backorderable? | ||
| stock_items.any?(&:backorderable) | ||
| end | ||
|
|
||
| def can_supply?(required) | ||
| total_on_hand >= required || backorderable? | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,22 @@ | ||
| module Spree | ||
| module Stock | ||
| class RemainingPacker < Packer | ||
| attr_reader :order_counter | ||
|
|
||
| def initialize(stock_location, order, order_counter=nil) | ||
| super | ||
| @order_counter = order_counter || Stock::OrderCounter.new(order) | ||
| end | ||
|
|
||
| def default_package | ||
| package = Package.new(stock_location, order) | ||
| order_counter.variants_with_remaining.each do |variant| | ||
| on_hand, backordered = stock_status(variant, order_counter.remaining(variant)) | ||
| package.add variant, on_hand, :on_hand if on_hand > 0 | ||
| package.add variant, backordered, :backordered if backordered > 0 | ||
| end | ||
| package | ||
| end | ||
| end | ||
| end | ||
| end |
| @@ -0,0 +1,23 @@ | ||
| module Spree | ||
| module Stock | ||
| module Splitter | ||
| class Backordered < Base | ||
|
|
||
| def split(packages) | ||
| split_packages = [] | ||
| packages.each do |package| | ||
| if package.on_hand.count > 0 | ||
| split_packages << build_package(package.on_hand) | ||
| end | ||
|
|
||
| if package.backordered.count > 0 | ||
| split_packages << build_package(package.backordered) | ||
| end | ||
| end | ||
| return_next split_packages | ||
| end | ||
|
|
||
| end | ||
| end | ||
| end | ||
| end |