diff --git a/app/controllers/admin/configs_controller.rb b/app/controllers/admin/configs_controller.rb index b3b51daa..fa27d6fa 100644 --- a/app/controllers/admin/configs_controller.rb +++ b/app/controllers/admin/configs_controller.rb @@ -40,7 +40,7 @@ class Admin::ConfigsController < Admin::BaseController # turn recurring rules into something palatable def parse_recurring_selects!(config) if config - for k in [:pickup, :ends] do + for k in [:pickup, :boxfill, :ends] do if config[k] # allow clearing it using dummy value '{}' ('' would break recurring_select) if config[k][:recurr].present? && config[k][:recurr] != '{}' diff --git a/app/helpers/admin/configs_helper.rb b/app/helpers/admin/configs_helper.rb index 6981913c..fcf60c29 100644 --- a/app/helpers/admin/configs_helper.rb +++ b/app/helpers/admin/configs_helper.rb @@ -54,7 +54,8 @@ module Admin::ConfigsHelper checked_value = options.delete(:checked_value) || 'true' unchecked_value = options.delete(:unchecked_value) || 'false' options[:checked] = 'checked' if v=options.delete(:value) && v!='false' - form.hidden_field(key, value: unchecked_value, as: :hidden) + form.check_box(key, options, checked_value, false) + # different key for hidden field so that allow clocking on label focuses the control + form.hidden_field(key, id: "#{key}_", value: unchecked_value, as: :hidden) + form.check_box(key, options, checked_value, false) elsif options[:as] == :select_recurring options[:value] = FoodsoftDateUtil.rule_from(options[:value]) options[:rules] ||= [] @@ -111,6 +112,13 @@ module Admin::ConfigsHelper end end + # @return [String] Tooltip element (span) + # @param form [ActionView::Helpers::FormBuilder] Form object. + # @param key [Symbol, String] Configuration key of a boolean (e.g. +use_messages+). + def config_tooltip(form, key, options={}, &block) + content_tag :span, config_input_tooltip_options(form, key, options), &block + end + private def config_input_tooltip_options(form, key, options) diff --git a/app/models/group_order.rb b/app/models/group_order.rb index 5bfba726..b9cdcc4d 100644 --- a/app/models/group_order.rb +++ b/app/models/group_order.rb @@ -28,7 +28,7 @@ class GroupOrder < ActiveRecord::Base data[:order_articles] = {} order.articles_grouped_by_category.each do |article_category, order_articles| order_articles.each do |order_article| - + # Get the result of last time ordering, if possible goa = group_order_articles.detect { |goa| goa.order_article_id == order_article.id } @@ -58,8 +58,10 @@ class GroupOrder < ActiveRecord::Base group_order_article = group_order_articles.where(order_article_id: order_article.id).first_or_create # Get ordered quantities and update group_order_articles/_quantities... - quantities = group_order_articles_attributes.fetch(order_article.id.to_s, {:quantity => 0, :tolerance => 0}) - group_order_article.update_quantities(quantities[:quantity].to_i, quantities[:tolerance].to_i) + if group_order_articles_attributes + quantities = group_order_articles_attributes.fetch(order_article.id.to_s, {:quantity => 0, :tolerance => 0}) + group_order_article.update_quantities(quantities[:quantity].to_i, quantities[:tolerance].to_i) + end # Also update results for the order_article logger.debug "[save_group_order_articles] update order_article.results!" @@ -86,4 +88,3 @@ class GroupOrder < ActiveRecord::Base end end - diff --git a/app/models/group_order_article.rb b/app/models/group_order_article.rb index 6dd55445..3563a328 100644 --- a/app/models/group_order_article.rb +++ b/app/models/group_order_article.rb @@ -27,9 +27,9 @@ class GroupOrderArticle < ActiveRecord::Base group_order.try!(:ordergroup_id) end - # Updates the quantity/tolerance for this GroupOrderArticle by updating both GroupOrderArticle properties + # Updates the quantity/tolerance for this GroupOrderArticle by updating both GroupOrderArticle properties # and the associated GroupOrderArticleQuantities chronologically. - # + # # See description of the ordering algorithm in the general application documentation for details. def update_quantities(quantity, tolerance) logger.debug("GroupOrderArticle[#{id}].update_quantities(#{quantity}, #{tolerance})") @@ -104,7 +104,7 @@ class GroupOrderArticle < ActiveRecord::Base # Determines how many items of this article the Ordergroup receives. # 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(total = nil) # return memoized result unless a total is given @@ -199,5 +199,3 @@ class GroupOrderArticle < ActiveRecord::Base result != result_computed unless result.nil? end end - - diff --git a/app/models/order.rb b/app/models/order.rb index 69bfaa73..ff2dbf38 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -35,7 +35,7 @@ class Order < ActiveRecord::Base # Allow separate inputs for date and time # with workaround for https://github.com/einzige/date_time_attribute/issues/14 include DateTimeAttributeValidate - date_time_attribute :starts, :ends + date_time_attribute :starts, :boxfill, :ends def stockit? supplier_id == 0 @@ -92,8 +92,16 @@ class Order < ActiveRecord::Base state == "closed" end + def boxfill? + FoodsoftConfig[:use_boxfill] && open? && boxfill.present? && boxfill < Time.now + end + + def is_boxfill_useful? + FoodsoftConfig[:use_boxfill] && supplier.try(:has_tolerance?) + end + def expired? - !ends.nil? && ends < Time.now + ends.present? && ends < Time.now end # sets up first guess of dates when initializing a new object @@ -105,7 +113,8 @@ class Order < ActiveRecord::Base last = (DateTime.parse(FoodsoftConfig[:order_schedule][:initial]) rescue nil) last ||= Order.finished.reorder(:starts).first.try(:starts) last ||= self.starts - # adjust end date + # adjust boxfill and end date + self.boxfill ||= FoodsoftDateUtil.next_occurrence last, self.starts, FoodsoftConfig[:order_schedule][:boxfill] if is_boxfill_useful? self.ends ||= FoodsoftDateUtil.next_occurrence last, self.starts, FoodsoftConfig[:order_schedule][:ends] end self @@ -251,7 +260,9 @@ class Order < ActiveRecord::Base def starts_before_ends delta = Rails.env.test? ? 1 : 0 # since Rails 4.2 tests appear to have time differences, with this validation failing - errors.add(:ends, I18n.t('orders.model.error_starts_before_ends')) if (ends && starts && ends <= (starts-delta)) + errors.add(:ends, I18n.t('orders.model.error_starts_before_ends')) if ends && starts && ends <= (starts-delta) + errors.add(:ends, I18n.t('orders.model.error_boxfill_before_ends')) if ends && boxfill && ends <= (boxfill-delta) + errors.add(:boxfill, I18n.t('orders.model.error_starts_before_boxfill')) if boxfill && starts && boxfill <= (starts-delta) end def include_articles @@ -288,4 +299,3 @@ class Order < ActiveRecord::Base end end - diff --git a/app/models/order_article.rb b/app/models/order_article.rb index ef894166..8ea7adfd 100644 --- a/app/models/order_article.rb +++ b/app/models/order_article.rb @@ -32,7 +32,7 @@ class OrderArticle < ActiveRecord::Base units_to_order end - # Count quantities of belonging group_orders. + # Count quantities of belonging group_orders. # In balancing this can differ from ordered (by supplier) quantity for this article. def group_orders_sum quantity = group_order_articles.collect(&:result).sum @@ -42,10 +42,11 @@ class OrderArticle < ActiveRecord::Base # Update quantity/tolerance/units_to_order from group_order_articles def update_results! if order.open? - quantity = group_order_articles.collect(&:quantity).sum - tolerance = group_order_articles.collect(&:tolerance).sum - update_attributes(:quantity => quantity, :tolerance => tolerance, - :units_to_order => calculate_units_to_order(quantity, tolerance)) + self.quantity = group_order_articles.collect(&:quantity).sum + self.tolerance = group_order_articles.collect(&:tolerance).sum + self.units_to_order = calculate_units_to_order(quantity, tolerance) + enforce_boxfill if order.boxfill? + save! elsif order.finished? update_attribute(:units_to_order, group_order_articles.collect(&:result).sum) end @@ -186,19 +187,20 @@ class OrderArticle < ActiveRecord::Base # @return [Number] Units missing for the last +unit_quantity+ of the article. def missing_units - units = price.unit_quantity - ((quantity % price.unit_quantity) + tolerance) - units = 0 if units < 0 - units = 0 if units == price.unit_quantity - units + _missing_units(price.unit_quantity, quantity, tolerance) end - + + def missing_units_was + _missing_units(price.unit_quantity, quantity_was, tolerance_was) + end + # Check if the result of any associated GroupOrderArticle was overridden manually def result_manually_changed? group_order_articles.any? {|goa| goa.result_manually_changed?} end private - + def article_and_price_exist errors.add(:article, I18n.t('model.order_article.error_price')) if !(article = Article.find(article_id)) || article.fc_price.nil? rescue @@ -219,5 +221,26 @@ class OrderArticle < ActiveRecord::Base order.group_orders.each(&:update_price!) end -end + # Throws an exception when the changed article decreases the amount of filled boxes. + def enforce_boxfill + # Either nothing changes, or + # missing_units becomes less and the amount doesn't decrease, or + # tolerance was moved to quantity. Only then are changes allowed in the boxfill phase. + delta_q = quantity - quantity_was + delta_t = tolerance - tolerance_was + delta_mis = missing_units - missing_units_was + delta_box = units_to_order - units_to_order_was + unless (delta_q == 0 && delta_t == 0) || + (delta_mis < 0 && delta_box >= 0 && delta_t >= 0) || + (delta_q > 0 && delta_q == -delta_t) + raise ActiveRecord::RecordNotSaved.new("Change not acceptable in boxfill phase, sorry.", self) + end + end + def _missing_units(unit_quantity, quantity, tolerance) + units = unit_quantity - ((quantity % unit_quantity) + tolerance) + units = 0 if units < 0 + units = 0 if units == unit_quantity + units + end +end diff --git a/app/models/supplier.rb b/app/models/supplier.rb index 94db5c94..dbd012c8 100644 --- a/app/models/supplier.rb +++ b/app/models/supplier.rb @@ -116,6 +116,11 @@ class Supplier < ActiveRecord::Base end end + # @return [Boolean] Whether there are articles that would use tolerance (unit_quantity > 1) + def has_tolerance? + articles.where('articles.unit_quantity > 1').any? + end + protected # make sure the shared_sync_method is allowed for the shared supplier diff --git a/app/views/admin/configs/_tab_payment.html.haml b/app/views/admin/configs/_tab_payment.html.haml index 0dae9e1e..bbfa1845 100644 --- a/app/views/admin/configs/_tab_payment.html.haml +++ b/app/views/admin/configs/_tab_payment.html.haml @@ -11,8 +11,18 @@ %span.add-on= t 'number.currency.format.unit' = config_input_field form, :minimum_balance, as: :decimal, class: 'input-small' +%h4= t '.schedule_title' = form.simple_fields_for :order_schedule do |fields| + #boxfill-schedule.collapse{class: ('in' if FoodsoftConfig[:use_boxfill])} + = fields.simple_fields_for 'boxfill' do |fields| + .fold-line + = config_input fields, 'recurr', as: :select_recurring, input_html: {class: 'input-xlarge'} + = config_input fields, 'time', input_html: {class: 'input-mini'} = fields.simple_fields_for 'ends' do |fields| .fold-line = config_input fields, 'recurr', as: :select_recurring, input_html: {class: 'input-xlarge'}, allow_blank: true = config_input fields, 'time', input_html: {class: 'input-mini'} + -# can't use collapse and tooltip on same element :/ + = config_input form, :use_boxfill, as: :boolean do + = config_tooltip form, :use_boxfill do + = config_input_field form, :use_boxfill, as: :boolean, title: '', data: {toggle: 'collapse', target: '#boxfill-schedule'} diff --git a/app/views/orders/_form.html.haml b/app/views/orders/_form.html.haml index 6ed381e7..95fdebf3 100644 --- a/app/views/orders/_form.html.haml +++ b/app/views/orders/_form.html.haml @@ -2,6 +2,7 @@ = f.hidden_field :supplier_id .fold-line = f.input :starts, as: :date_picker_time + = f.input :boxfill, as: :date_picker_time if @order.is_boxfill_useful? = f.input :ends, as: :date_picker_time = f.input :note, input_html: {rows: 2, class: 'input-xxlarge'} diff --git a/config/locales/en.yml b/config/locales/en.yml index 544088d2..a08bb189 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -71,6 +71,7 @@ en: sent_to_all: Send to all members subject: Subject order: + boxfill: Fill boxes after closed_by: Settled by created_by: Created by ends: Ends at @@ -237,6 +238,8 @@ en: pdf_title: PDF documents tab_messages: emails_title: Sending email + tab_payment: + schedule_title: Ordering schedule tab_tasks: periodic_title: Periodic tasks tabs: @@ -478,6 +481,9 @@ en: ends: recurr: Schedule for default order closing date. time: Default time when orders are closed. + boxfill: + recurr: Schedule for when the box-fill phase starts by default. + time: Default time when the box-fill phase of the ordering starts. initial: Schedule starts at this date. page_footer: Shown on each page at the bottom. Enter "blank" to disable the footer completely. pdf_add_page_breaks: @@ -492,6 +498,7 @@ en: tax_default: Default VAT percentage for new articles. tolerance_is_costly: Order as much of the member tolerance as possible (compared to only as much needed to fill the last box). Enabling this also includes the tolerance in the total price of the open member order. use_apple_points: When the apple point system is enabled, members are required to do some tasks to be able to keep ordering. + use_boxfill: When enabled, near end of an order, members are only able to change their order when increases the total amount ordered. This helps to fill any remaining boxes. You still need to set a box-fill date for the orders. use_messages: Allow members to communicate with each other within Foodsoft. use_nick: Show and use nicknames instead of real names. When enabling this, please check that each user has a nickname. use_wiki: Enable editable wiki pages. @@ -523,6 +530,9 @@ en: ends: recurr: Order ends time: time + boxfill: + recurr: Box fill after + time: time initial: Schedule start page_footer: Page footer pdf_add_page_breaks: Page breaks @@ -536,6 +546,7 @@ en: time_zone: Time zone tolerance_is_costly: Tolerance is costly use_apple_points: Apple points + use_boxfill: Box-fill phase use_messages: Messages use_nick: Use nicknames use_wiki: Enable wiki @@ -1279,6 +1290,8 @@ en: close_direct_message: Order settled without charging member accounts. error_closed: Order was already settled error_nosel: At least one article must be selected. You may want to delete the order instead? + error_boxfill_before_ends: must be after the box-fill date (or remain empty) + error_starts_before_boxfill: must be after the start date (or remain empty) error_starts_before_ends: must be after the start date (or remain empty) notice_close: 'Order: %{name}, until %{ends}' stock: Stock diff --git a/db/migrate/20150923190747_add_boxfill_to_order.rb b/db/migrate/20150923190747_add_boxfill_to_order.rb new file mode 100644 index 00000000..1b4e0c36 --- /dev/null +++ b/db/migrate/20150923190747_add_boxfill_to_order.rb @@ -0,0 +1,5 @@ +class AddBoxfillToOrder < ActiveRecord::Migration + def change + add_column :orders, :boxfill, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 2fb34ce8..3987c4bf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150301000000) do +ActiveRecord::Schema.define(version: 20150923190747) do create_table "article_categories", force: :cascade do |t| t.string "name", limit: 255, default: "", null: false @@ -37,7 +37,7 @@ ActiveRecord::Schema.define(version: 20150301000000) do t.integer "article_category_id", limit: 4, default: 0, null: false t.string "unit", limit: 255, default: "", null: false t.string "note", limit: 255 - t.boolean "availability", limit: 1, default: true, null: false + t.boolean "availability", default: true, null: false t.string "manufacturer", limit: 255 t.string "origin", limit: 255 t.datetime "shared_updated_on" @@ -61,7 +61,7 @@ ActiveRecord::Schema.define(version: 20150301000000) do create_table "assignments", force: :cascade do |t| t.integer "user_id", limit: 4, default: 0, null: false t.integer "task_id", limit: 4, default: 0, null: false - t.boolean "accepted", limit: 1, default: false + t.boolean "accepted", default: false end add_index "assignments", ["user_id", "task_id"], name: "index_assignments_on_user_id_and_task_id", unique: true, using: :btree @@ -127,18 +127,18 @@ ActiveRecord::Schema.define(version: 20150301000000) do t.string "description", limit: 255 t.decimal "account_balance", precision: 12, scale: 2, default: 0, null: false t.datetime "created_on", null: false - t.boolean "role_admin", limit: 1, default: false, null: false - t.boolean "role_suppliers", limit: 1, default: false, null: false - t.boolean "role_article_meta", limit: 1, default: false, null: false - t.boolean "role_finance", limit: 1, default: false, null: false - t.boolean "role_orders", limit: 1, default: false, null: false + t.boolean "role_admin", default: false, null: false + t.boolean "role_suppliers", default: false, null: false + t.boolean "role_article_meta", default: false, null: false + t.boolean "role_finance", default: false, null: false + t.boolean "role_orders", default: false, null: false t.datetime "deleted_at" t.string "contact_person", limit: 255 t.string "contact_phone", limit: 255 t.string "contact_address", limit: 255 t.text "stats", limit: 65535 t.integer "next_weekly_tasks_number", limit: 4, default: 8 - t.boolean "ignore_apple_restriction", limit: 1, default: false + t.boolean "ignore_apple_restriction", default: false end add_index "groups", ["name"], name: "index_groups_on_name", unique: true, using: :btree @@ -184,7 +184,7 @@ ActiveRecord::Schema.define(version: 20150301000000) do t.string "subject", limit: 255, null: false t.text "body", limit: 65535 t.integer "email_state", limit: 4, default: 0, null: false - t.boolean "private", limit: 1, default: false + t.boolean "private", default: false t.datetime "created_at" t.integer "reply_to", limit: 4 t.integer "group_id", limit: 4 @@ -224,6 +224,7 @@ ActiveRecord::Schema.define(version: 20150301000000) do t.integer "updated_by_user_id", limit: 4 t.decimal "foodcoop_result", precision: 8, scale: 2 t.integer "created_by_user_id", limit: 4 + t.datetime "boxfill" end add_index "orders", ["state"], name: "index_orders_on_state", using: :btree @@ -316,7 +317,7 @@ ActiveRecord::Schema.define(version: 20150301000000) do t.string "name", limit: 255, default: "", null: false t.string "description", limit: 255 t.date "due_date" - t.boolean "done", limit: 1, default: false + t.boolean "done", default: false t.integer "workgroup_id", limit: 4 t.datetime "created_on", null: false t.datetime "updated_on", null: false diff --git a/spec/models/order_article_spec.rb b/spec/models/order_article_spec.rb index c46d49ea..edd6c910 100644 --- a/spec/models/order_article_spec.rb +++ b/spec/models/order_article_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe OrderArticle do - let(:order) { FactoryGirl.create :order, article_count: 1 } + let(:order) { create :order, article_count: 1 } let(:oa) { order.order_articles.first } it 'is not ordered by default' do @@ -19,7 +19,7 @@ describe OrderArticle do it 'knows how many items there are' do oa.units_to_order = rand(1..99) - expect(oa.units).to eq oa.units_to_order + 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) @@ -34,15 +34,15 @@ describe OrderArticle do 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 } + let(:admin) { create :user, groups:[create(:workgroup, role_finance: true)] } + let(:article) { create :article, unit_quantity: 3 } + let(:order) { create :order, article_ids: [article.id] } + let(:go1) { create :group_order, order: order } + let(:go2) { create :group_order, order: order } + let(:go3) { create :group_order, order: order } + let(:goa1) { create :group_order_article, group_order: go1, order_article: oa } + let(:goa2) { create :group_order_article, group_order: go2, order_article: oa } + let(:goa3) { create :group_order_article, group_order: go3, order_article: oa } # set quantities of group_order_articles def set_quantities(q1, q2, q3) @@ -117,4 +117,80 @@ describe OrderArticle do end + describe 'boxfill' do + before { FoodsoftConfig[:use_boxfill] = true } + let(:article) { create :article, unit_quantity: 6 } + let(:order) { create :order, article_ids: [article.id], starts: 1.week.ago } + let(:oa) { order.order_articles.first } + let(:go) { create :group_order, order: order } + let(:goa) { create :group_order_article, group_order: go, order_article: oa } + + shared_examples "boxfill" do |success, q| + # initial situation + before do + goa.update_quantities *q.keys[0] + oa.update_results!; oa.reload + end + + # check starting condition + it '(before)' do + expect([oa.quantity, oa.tolerance, oa.missing_units]).to eq q.keys[1] + end + + # actual test + it (success ? 'succeeds' : 'fails') do + order.update_attributes(boxfill: boxfill_from) + + r = proc { + goa.update_quantities *q.values[0] + oa.update_results! + } + if success + r.call + else + expect(r).to raise_error(ActiveRecord::RecordNotSaved) + end + + oa.reload + expect([oa.quantity, oa.tolerance, oa.missing_units]).to eq q.values[1] + end + end + + context 'before the date' do + let(:boxfill_from) { 1.hour.from_now } + context 'decreasing the missing units' do + include_examples "boxfill", true, [6,0]=>[5,0], [6,0,0]=>[5,0,1] + end + context 'decreasing the tolerance' do + include_examples "boxfill", true, [1,2]=>[1,1], [1,2,3]=>[1,1,4] + end + end + + context 'after the date' do + let(:boxfill_from) { 1.second.ago } + context 'changing nothing in particular' do + include_examples "boxfill", true, [4,1]=>[4,1], [4,1,1]=>[4,1,1] + end + context 'increasing missing units' do + include_examples "boxfill", false, [3,0]=>[2,0], [3,0,3]=>[3,0,3] + end + context 'increasing tolerance' do + include_examples "boxfill", true, [2,1]=>[2,2], [2,1,3]=>[2,2,2] + end + context 'decreasing quantity to fix missing units' do + include_examples "boxfill", true, [7,0]=>[6,0], [7,0,5]=>[6,0,0] + end + context 'decreasing quantity keeping missing units equal' do + include_examples "boxfill", false, [7,0]=>[1,0], [7,0,5]=>[7,0,5] + end + context 'moving tolerance to quantity' do + include_examples "boxfill", true, [4,2]=>[6,0], [4,2,0]=>[6,0,0] + end + # @todo enable test when tolerance doesn't count in missing_units + #context 'decreasing tolerance' do + # include_examples "boxfill", false, [0,2]=>[0,0], [0,2,0]=>[0,2,0] + #end + end + end + end