diff --git a/app/assets/stylesheets/bootstrap_and_overrides.css.less b/app/assets/stylesheets/bootstrap_and_overrides.css.less index 6ad25972..1a514396 100644 --- a/app/assets/stylesheets/bootstrap_and_overrides.css.less +++ b/app/assets/stylesheets/bootstrap_and_overrides.css.less @@ -212,6 +212,16 @@ tr.unavailable { } } +// editable article list can be more compact +.ordered-articles input { + margin-bottom: 0; +} + +// entering units +.units_delta { + width: 2em; +} + // ********* Tweaks & fixes // need more space for supplier&order information (in German, at least) diff --git a/app/controllers/finance/receive_controller.rb b/app/controllers/finance/receive_controller.rb new file mode 100644 index 00000000..2ecbd059 --- /dev/null +++ b/app/controllers/finance/receive_controller.rb @@ -0,0 +1,37 @@ +class Finance::ReceiveController < Finance::BaseController + + def edit + @order = Order.find(params[:id]) + @order_articles = @order.order_articles.ordered.includes(:article) + end + + def update + OrderArticle.transaction do + params[:order_articles].each do |oa_id, oa_params| + unless oa_params.blank? + oa = OrderArticle.find(oa_id) + # update attributes + oa.update_attributes!(oa_params) + # and process consequences + oa.redistribute(oa.units_received * oa.price.unit_quantity) unless oa.units_received.blank? + oa.save! + end + end + + flash[:notice] = I18n.t('receive.update.notice') + redirect_to finance_order_index_path + end + end + + # ajax add article + def add_article + @order = Order.find(params[:receive_id]) + @order_article = @order.order_articles.where(:article_id => params[:article_id]).includes(:article).first + # we need to create the order article if it's not part of the current order + if @order_article.nil? + @order_article = @order.order_articles.build({order: @order, article_id: params[:article_id]}) + @order_article.save! + end + end + +end diff --git a/app/helpers/finance/receive_helper.rb b/app/helpers/finance/receive_helper.rb new file mode 100644 index 00000000..15b28a66 --- /dev/null +++ b/app/helpers/finance/receive_helper.rb @@ -0,0 +1,9 @@ +# :encoding:utf-8: +module Finance::ReceiveHelper + # TODO currently duplicate a bit of DeliveriesHelper.articles_for_select2 + def articles_for_select2(supplier) + supplier.articles.undeleted.reorder('articles.name ASC').map do |a| + {:id => a.id, :text => "#{a.name} (#{a.unit_quantity}тип#{a.unit})"} + end + end +end diff --git a/app/models/group_order_article.rb b/app/models/group_order_article.rb index 9d4b8c82..4d7bff09 100644 --- a/app/models/group_order_article.rb +++ b/app/models/group_order_article.rb @@ -99,57 +99,63 @@ class GroupOrderArticle < ActiveRecord::Base # Returns a hash with three keys: :quantity / :tolerance / :total # # See description of the ordering algorithm in the general application documentation for details. - def calculate_result - @calculate_result ||= begin - quantity = tolerance = total_quantity = 0 + def calculate_result(total = nil) + # return memoized result unless a total is given + return @calculate_result if total.nil? and not @calculate_result.nil? - # Get total - if order_article.article.is_a?(StockArticle) - total = order_article.article.quantity - logger.debug "<#{order_article.article.name}> (stock) => #{total}" - else - total = order_article.units_to_order * order_article.price.unit_quantity - logger.debug "<#{order_article.article.name}> units_to_order #{order_article.units_to_order} => #{total}" + quantity = tolerance = total_quantity = 0 + + # Get total + if not total.nil? + logger.debug "<#{order_article.article.name}> => #{total} (given)" + elsif order_article.article.is_a?(StockArticle) + total = order_article.article.quantity + logger.debug "<#{order_article.article.name}> (stock) => #{total}" + else + total = order_article.units_to_order * order_article.price.unit_quantity + logger.debug "<#{order_article.article.name}> units_to_order #{order_article.units_to_order} => #{total}" + end + + if total > 0 + # In total there are enough units ordered. Now check the individual result for the ordergroup (group_order). + # + # Get all GroupOrderArticleQuantities for this OrderArticle... + order_quantities = GroupOrderArticleQuantity.all( + :conditions => ["group_order_article_id IN (?)", order_article.group_order_article_ids], :order => 'created_on') + logger.debug "GroupOrderArticleQuantity records found: #{order_quantities.size}" + + # Determine quantities to be ordered... + order_quantities.each do |goaq| + q = [goaq.quantity, total - total_quantity].min + total_quantity += q + if goaq.group_order_article_id == self.id + logger.debug "increasing quantity by #{q}" + quantity += q + end + break if total_quantity >= total end - if total > 0 - # In total there are enough units ordered. Now check the individual result for the ordergroup (group_order). - # - # Get all GroupOrderArticleQuantities for this OrderArticle... - order_quantities = GroupOrderArticleQuantity.all( - :conditions => ["group_order_article_id IN (?)", order_article.group_order_article_ids], :order => 'created_on') - logger.debug "GroupOrderArticleQuantity records found: #{order_quantities.size}" - - # Determine quantities to be ordered... + # Determine tolerance to be ordered... + if total_quantity < total + logger.debug "determining additional items to be ordered from tolerance" order_quantities.each do |goaq| - q = [goaq.quantity, total - total_quantity].min + q = [goaq.tolerance, total - total_quantity].min total_quantity += q if goaq.group_order_article_id == self.id - logger.debug "increasing quantity by #{q}" - quantity += q + logger.debug "increasing tolerance by #{q}" + tolerance += q end break if total_quantity >= total end - - # Determine tolerance to be ordered... - if total_quantity < total - logger.debug "determining additional items to be ordered from tolerance" - order_quantities.each do |goaq| - q = [goaq.tolerance, total - total_quantity].min - total_quantity += q - if goaq.group_order_article_id == self.id - logger.debug "increasing tolerance by #{q}" - tolerance += q - end - break if total_quantity >= total - end - end - - logger.debug "determined quantity/tolerance/total: #{quantity} / #{tolerance} / #{quantity + tolerance}" end - {:quantity => quantity, :tolerance => tolerance, :total => quantity + tolerance} + logger.debug "determined quantity/tolerance/total: #{quantity} / #{tolerance} / #{quantity + tolerance}" end + + # memoize result unless a total is given + r = {:quantity => quantity, :tolerance => tolerance, :total => quantity + tolerance} + @calculate_result = r if total.nil? + r end # Returns order result, @@ -160,8 +166,8 @@ class GroupOrderArticle < ActiveRecord::Base end # This is used during order.finish!. - def save_results! - self.update_attribute(:result, calculate_result[:total]) + def save_results!(article_total = nil) + self.update_attribute(:result, calculate_result(article_total)[:total]) end # Returns total price for this individual article diff --git a/app/models/order.rb b/app/models/order.rb index 057c0768..087bf450 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -166,6 +166,9 @@ class Order < ActiveRecord::Base goa.save_results! # Delete no longer required order-history (group_order_article_quantities) and # TODO: Do we need articles, which aren't ordered? (units_to_order == 0 ?) + # A: Yes, we do - for redistributing articles when the number of articles + # delivered changes, and for statistics on popular articles. Records + # with both tolerance and quantity zero can be deleted. #goa.group_order_article_quantities.clear end end diff --git a/app/models/order_article.rb b/app/models/order_article.rb index 3eb97aea..32211f24 100644 --- a/app/models/order_article.rb +++ b/app/models/order_article.rb @@ -12,8 +12,8 @@ class OrderArticle < ActiveRecord::Base validate :article_and_price_exist validates_uniqueness_of :article_id, scope: :order_id - scope :ordered, :conditions => "units_to_order > 0" - scope :ordered_or_member, -> { includes(:group_order_articles).where("units_to_order > 0 OR group_order_articles.result > 0") } + scope :ordered, -> { where("units_to_order > 0 OR units_billed > 0 OR units_received > 0") } + scope :ordered_or_member, -> { includes(:group_order_articles).where("units_to_order > 0 OR units_billed > 0 OR units_received > 0 OR group_order_articles.result > 0") } before_create :init_from_balancing after_destroy :update_ordergroup_prices @@ -34,7 +34,14 @@ class OrderArticle < ActiveRecord::Base def price article_price || article end - + + # latest information on available units + def units + return units_received unless units_received.nil? + return units_billed unless units_billed.nil? + units_to_order + end + # Count quantities of belonging group_orders. # In balancing this can differ from ordered (by supplier) quantity for this article. def group_orders_sum @@ -94,6 +101,18 @@ class OrderArticle < ActiveRecord::Base (units_to_order * price.unit_quantity) == group_orders_sum[:quantity] rescue false end + def redistribute(quantity) + # recompute + group_order_articles.each {|goa| goa.save_results! quantity } + + # Update GroupOrder prices & Ordergroup stats + # TODO only affected group_orders, and once after redistributing all articles + order.group_orders.each(&:update_price!) + order.ordergroups.each(&:update_stats!) + + # TODO notifications + end + # Updates order_article and belongings during balancing process def update_article_and_price!(order_article_attributes, article_attributes, price_attributes = nil) OrderArticle.transaction do diff --git a/app/views/finance/balancing/_orders.html.haml b/app/views/finance/balancing/_orders.html.haml index 6973aa32..3c54e340 100644 --- a/app/views/finance/balancing/_orders.html.haml +++ b/app/views/finance/balancing/_orders.html.haml @@ -19,6 +19,7 @@ %td= show_user(order.updated_by) %td - unless order.closed? + = link_to t('.receive'), edit_finance_receive_path(order), class: 'btn btn-mini' = link_to t('.clear'), new_finance_order_path(order_id: order.id), class: 'btn btn-mini btn-primary' = link_to t('.close'), close_direct_finance_order_path(order), :confirm => t('.confirm'), :method => :put, class: 'btn btn-mini' diff --git a/app/views/finance/receive/_edit_article.html.haml b/app/views/finance/receive/_edit_article.html.haml new file mode 100644 index 00000000..8be17a37 --- /dev/null +++ b/app/views/finance/receive/_edit_article.html.haml @@ -0,0 +1,16 @@ += fields_for 'order_articles', order_article, index: order_article.id do |form| + %tr{class: "#{cycle('even', 'odd', name: 'articles')} order-article", valign: "top"} + - order_title = [] + - order_title.append t('.manufacturer')+': ' + order_article.article.manufacturer unless order_article.article.manufacturer.to_s.empty? + - order_title.append t('.note')+': ' + order_article.article.note unless order_article.article.note.to_s.empty? + - units_expected = (order_article.units_billed or order_article.units_to_order) + %td= order_article.article.order_number + %td.name{title: order_title.join("\n")}= order_article.article.name + %td #{order_article.article.unit_quantity} × #{order_article.article.unit} + %td #{order_article.quantity} + #{order_article.tolerance} + %td= order_article.units_to_order + %td= order_article.units_billed + %td + = form.text_field :units_received, class: 'input-mini', data: {'units-expected' => units_expected} + / TODO add almost invisible text_field for entering single units + %td.units_delta diff --git a/app/views/finance/receive/_edit_articles.html.haml b/app/views/finance/receive/_edit_articles.html.haml new file mode 100644 index 00000000..afc71f12 --- /dev/null +++ b/app/views/finance/receive/_edit_articles.html.haml @@ -0,0 +1,76 @@ +- content_for :javascript do + :javascript + + function update_delta(input) { + var units = $(input).val(); + var expected = $(input).data('units-expected'); + var html; + + if (units.replace(/\s/g,"")=="") { + // no value + html = ''; + } else if (isNaN(units)) { + html = ''; + } else if (units == expected) { + // equal value + html = ''; + } else if (units < expected) { + html = '- '+(expected-units)+''; + } else /*if (units> expected)*/ { + html = '+ '+(units-expected)+''; + } + + $(input).closest('tr').find('.units_delta').html(html); + } + + $(document).on('change', 'input[data-units-expected]', function() { + update_delta(this); + }); + + $(function() { + $('input[data-units-expected]').each(function() { + update_delta(this); + }); + + $('#add_article').removeAttr('disabled').select2({ + placeholder: '#{t '.add_article'}', + data: #{articles_for_select2(@order).to_json}, + // TODO implement adding a new article, like in deliveries + }).on('change', function(e) { + var selectedArticle = $(e.currentTarget).select2('data'); + if(!selectedArticle) { + return false; + } + + $.ajax({ + url: '#{finance_receive_add_article_path(@order)}', + type: 'get', + data: {article_id: selectedArticle.id}, + contentType: 'application/json; charset=UTF-8' + }); + $('#add_article').select2('data', null); + return true; + }); + + }); + + +%table.ordered-articles.table.table-striped.stupidtable + %thead + %tr + %th.sort{:data => {:sort => 'string'}}= t('.number') + %th.sort{:data => {:sort => 'string'}}= t('.article') + %th= heading_helper GroupOrderArticle, :units + %th Members + %th Ordered + %th Invoice + %th Received + %th + %tbody#result_table + - @order_articles.each do |order_article| + = render :partial => 'edit_article', :locals => {:order_article => order_article} + %tfoot + %tr + %th{:colspan => 8} + %input#add_article{:style => 'width: 500px;'} + diff --git a/app/views/finance/receive/add_article.js.erb b/app/views/finance/receive/add_article.js.erb new file mode 100644 index 00000000..b73b6719 --- /dev/null +++ b/app/views/finance/receive/add_article.js.erb @@ -0,0 +1,14 @@ +$('div.container-fluid').prepend( + '<%= j(render(:partial => 'shared/alert_success', :locals => {:alert_message => t('.notice', :name => @order_article.article.name)})) %>' +); + +(function() { + $('.ordered-articles tr').removeClass('success'); + + var article_for_adding = $( + '<%= j(render(:partial => 'edit_article', :locals => {:order_article => @order_article})) %>' + ).addClass('success'); + + $('.ordered-articles tbody').append(article_for_adding); + updateSort('.ordered-articles'); +})(); diff --git a/app/views/finance/receive/edit.html.haml b/app/views/finance/receive/edit.html.haml new file mode 100644 index 00000000..4be57743 --- /dev/null +++ b/app/views/finance/receive/edit.html.haml @@ -0,0 +1,10 @@ +- title "Receiving #{@order.name}" + += form_tag(finance_receive_path(@order), :method => :put) do + %section#results + = render 'edit_articles' + .form-actions + = submit_tag t('.submit'), class: 'btn btn-primary' + = link_to t('ui.or_cancel'), finance_order_index_path + +%p= link_to_top diff --git a/config/locales/en.yml b/config/locales/en.yml index 5d3b1e06..efd2961e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -560,6 +560,7 @@ en: last_edited_by: Last edited by name: Supplier no_closed_orders: At the moment there are no closed orders. + receive: receive state: State summary: changed: Data was changed! diff --git a/config/routes.rb b/config/routes.rb index 655a1038..3a0b0608 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -144,6 +144,10 @@ Foodsoft::Application.routes.draw do resources :order_articles end + resources :receive do + get :add_article + end + resources :group_order_articles do member do put :update_result diff --git a/db/migrate/20130930132511_add_quantities_to_order_article.rb b/db/migrate/20130930132511_add_quantities_to_order_article.rb new file mode 100644 index 00000000..1477b194 --- /dev/null +++ b/db/migrate/20130930132511_add_quantities_to_order_article.rb @@ -0,0 +1,6 @@ +class AddQuantitiesToOrderArticle < ActiveRecord::Migration + def change + add_column :order_articles, :units_billed, :integer + add_column :order_articles, :units_received, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 4170d918..3093e9a1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended to check this file into your version control system. -ActiveRecord::Schema.define(:version => 20130920201529) do +ActiveRecord::Schema.define(:version => 20130930132511) do create_table "article_categories", :force => true do |t| t.string "name", :default => "", :null => false @@ -195,6 +195,8 @@ ActiveRecord::Schema.define(:version => 20130920201529) do t.integer "units_to_order", :default => 0, :null => false t.integer "lock_version", :default => 0, :null => false t.integer "article_price_id" + t.integer "units_billed" + t.integer "units_received" end add_index "order_articles", ["order_id", "article_id"], :name => "index_order_articles_on_order_id_and_article_id", :unique => true diff --git a/spec/factories/order.rb b/spec/factories/order.rb index 4bf63b00..ab89ff6e 100644 --- a/spec/factories/order.rb +++ b/spec/factories/order.rb @@ -24,8 +24,4 @@ FactoryGirl.define do end end - # requires order and article - factory :order_article do - end - end diff --git a/spec/models/order_article_spec.rb b/spec/models/order_article_spec.rb new file mode 100644 index 00000000..bc6dcc5c --- /dev/null +++ b/spec/models/order_article_spec.rb @@ -0,0 +1,106 @@ +require 'spec_helper' + +describe OrderArticle do + let(:order) { FactoryGirl.create :order, article_count: 1 } + let(:oa) { order.order_articles.first } + + it 'is not ordered by default' do + expect(OrderArticle.ordered.count).to eq 0 + end + + [:units_to_order, :units_billed, :units_received].each do |units| + + it "is ordered when there are #{units.to_s.gsub '_', ' '}" do + oa.update_attribute units, rand(1..99) + expect(OrderArticle.ordered.count).to eq 1 + end + + end + + it 'knows how many items there are' do + oa.units_to_order = rand(1..99) + expect(oa.units).to eq oa.units_to_order + oa.units_billed = rand(1..99) + expect(oa.units).to eq oa.units_billed + oa.units_received = rand(1..99) + expect(oa.units).to eq oa.units_received + + oa.units_billed = rand(1..99) + expect(oa.units).to eq oa.units_received + oa.units_to_order = rand(1..99) + expect(oa.units).to eq oa.units_received + oa.units_received = rand(1..99) + expect(oa.units).to eq oa.units_received + end + + describe 'redistribution' do + let(:admin) { FactoryGirl.create :user, groups:[FactoryGirl.create(:workgroup, role_finance: true)] } + let(:article) { FactoryGirl.create :article, unit_quantity: 3 } + let(:order) { FactoryGirl.create :order, article_ids: [article.id] } + let(:go1) { FactoryGirl.create :group_order, order: order } + let(:go2) { FactoryGirl.create :group_order, order: order } + let(:go3) { FactoryGirl.create :group_order, order: order } + let(:goa1) { FactoryGirl.create :group_order_article, group_order: go1, order_article: oa } + let(:goa2) { FactoryGirl.create :group_order_article, group_order: go2, order_article: oa } + let(:goa3) { FactoryGirl.create :group_order_article, group_order: go3, order_article: oa } + + # set quantities of group_order_articles + def set_quantities(q1, q2, q3) + goa1.update_quantities(*q1) + goa2.update_quantities(*q2) + goa3.update_quantities(*q3) + oa.update_results! + order.finish!(admin) + goa_reload + end + + # reload all group_order_articles + def goa_reload + [goa1, goa2, goa3].map(&:reload) + end + + it 'has expected units_to_order' do + set_quantities [3,2], [1,3], [1,0] + expect(oa.units*oa.article.unit_quantity).to eq 6 + expect([goa1, goa2, goa3].map(&:result)).to eq [4, 1, 1] + end + + it 'does nothing when nothing has changed' do + set_quantities [3,2], [1,3], [1,0] + oa.redistribute 6 + goa_reload + expect([goa1, goa2, goa3].map(&:result).map(&:to_i)).to eq [4, 1, 1] + end + + it 'works when there is nothing to distribute' do + set_quantities [3,2], [1,3], [1,0] + oa.redistribute 0 + goa_reload + expect([goa1, goa2, goa3].map(&:result)).to eq [0, 0, 0] + end + + it 'works when quantity needs to be reduced' do + set_quantities [3,2], [1,3], [1,0] + oa.redistribute 4 + goa_reload + expect([goa1, goa2, goa3].map(&:result)).to eq [3, 1, 0] + end + + it 'works when quantity is increased within quantity' do + set_quantities [3,0], [2,0], [2,0] + expect([goa1, goa2, goa3].map(&:result)).to eq [3, 2, 1] + oa.redistribute 7 + goa_reload + expect([goa1, goa2, goa3].map(&:result).map(&:to_i)).to eq [3, 2, 2] + end + + it 'works when there is just one for the first' do + set_quantities [3,2], [1,3], [1,0] + oa.redistribute 1 + goa_reload + expect([goa1, goa2, goa3].map(&:result)).to eq [1, 0, 0] + end + + end + +end