Add optional boxfill phase to orders

This commit is contained in:
wvengen 2015-09-23 22:38:20 +02:00
parent c1413ff817
commit a03789e048
13 changed files with 201 additions and 50 deletions

View File

@ -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] != '{}'

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'}

View File

@ -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'}

View File

@ -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

View File

@ -0,0 +1,5 @@
class AddBoxfillToOrder < ActiveRecord::Migration
def change
add_column :orders, :boxfill, :datetime
end
end

View File

@ -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

View File

@ -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