class Order < ApplicationRecord attr_accessor :ignore_warnings, :transport_distribution # Associations has_many :order_articles, dependent: :destroy has_many :articles, through: :order_articles has_many :group_orders, dependent: :destroy has_many :ordergroups, through: :group_orders has_many :users_ordered, through: :ordergroups, source: :users has_many :comments, -> { order('created_at') }, class_name: 'OrderComment' has_many :stock_changes belongs_to :invoice, optional: true belongs_to :supplier, optional: true belongs_to :updated_by, class_name: 'User', foreign_key: 'updated_by_user_id' belongs_to :created_by, class_name: 'User', foreign_key: 'created_by_user_id' enum end_action: { no_end_action: 0, auto_close: 1, auto_close_and_send: 2, auto_close_and_send_min_quantity: 3 } enum transport_distribution: { skip: 0, ordergroup: 1, price: 2, articles: 3 } # Validations validates :starts, presence: true validate :starts_before_ends, :include_articles validate :keep_ordered_articles before_validation :distribute_transport # Callbacks after_save :save_order_articles, :update_price_of_group_orders! # Finders scope :started, -> { where('starts <= ?', Time.now) } scope :closed, -> { where(state: 'closed').order(ends: :desc) } scope :stockit, -> { where(supplier_id: nil).order(ends: :desc) } scope :recent, -> { order(starts: :desc).limit(10) } scope :stock_group_order, -> { group_orders.where(ordergroup_id: nil).first } scope :with_invoice, -> { where.not(invoice: nil) } # State related finders # Diagram for `Order.state` looks like this: # * -> open -> finished (-> received) -> closed # So orders can # 1. ...only transition in one direction (e.g. an order that has been `finished` currently cannot be reopened) # 2. ...be set to `closed` when having the `finished` state. (`received` is optional) scope :open, -> { where(state: 'open').order(ends: :desc) } scope :finished, -> { where(state: %w[finished received closed]).order(ends: :desc) } scope :finished_not_closed, -> { where(state: %w[finished received]).order(ends: :desc) } # 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, :boxfill, :ends def self.ransackable_attributes(_auth_object = nil) %w[id state supplier_id starts boxfill ends pickup] end def self.ransackable_associations(_auth_object = nil) %w[supplier articles order_articles] end def stockit? supplier_id.nil? end def name stockit? ? I18n.t('orders.model.stock') : supplier.name end def articles_for_ordering if stockit? # make sure to include those articles which are no longer available # but which have already been ordered in this stock order StockArticle.available.includes(:article_category) .order('article_categories.name', 'articles.name').reject do |a| a.quantity_available <= 0 && !a.ordered_in_order?(self) end.group_by { |a| a.article_category.name } else supplier.articles.available.group_by { |a| a.article_category.name } end end def supplier_articles if stockit? StockArticle.undeleted.reorder('articles.name') else supplier.articles.undeleted.reorder('articles.name') end end # Save ids, and create/delete order_articles after successfully saved the order attr_writer :article_ids def article_ids @article_ids ||= order_articles.map { |a| a.article_id.to_s } end # Returns an array of article ids that lead to a validation error. def erroneous_article_ids @erroneous_article_ids ||= [] end def open? state == 'open' end def finished? state == 'finished' || state == 'received' end def received? state == 'received' end def closed? 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.present? && ends < Time.now end # sets up first guess of dates when initializing a new object # I guess `def initialize` would work, but it's tricky http://stackoverflow.com/questions/1186400 def init_dates self.starts ||= Time.now if FoodsoftConfig[:order_schedule] # try to be smart when picking a reference day last = begin DateTime.parse(FoodsoftConfig[:order_schedule][:initial]) rescue StandardError nil end last ||= Order.finished.reorder(:starts).first.try(:starts) last ||= self.starts # adjust boxfill and end date if is_boxfill_useful? self.boxfill ||= FoodsoftDateUtil.next_occurrence last, self.starts, FoodsoftConfig[:order_schedule][:boxfill] end self.ends ||= FoodsoftDateUtil.next_occurrence last, self.starts, FoodsoftConfig[:order_schedule][:ends] end self end # fetch current Order scope's records and map the current user's GroupOrders in (if any) # (performance enhancement as opposed to fetching each GroupOrder separately from the view) def self.ordergroup_group_orders_map(ordergroup) orders = includes(:supplier) group_orders = GroupOrder.where(ordergroup_id: ordergroup.id, order_id: orders.map(&:id)) group_orders_hash = group_orders.index_by { |go| go.order_id } orders.map do |order| { order: order, group_order: group_orders_hash[order.id] } end end # search GroupOrder of given Ordergroup def group_order(ordergroup) group_orders.where(ordergroup_id: ordergroup.id).first end def stock_group_order group_orders.where(ordergroup_id: nil).first end # Returns OrderArticles in a nested Array, grouped by category and ordered by article name. # The array has the following form: # e.g: [["drugs",[teethpaste, toiletpaper]], ["fruits" => [apple, banana, lemon]]] def articles_grouped_by_category @articles_grouped_by_category ||= order_articles .includes([:article_price, :group_order_articles, { article: :article_category }]) .order('articles.name') .group_by { |a| a.article.article_category.name } .sort { |a, b| a[0] <=> b[0] } end def articles_sort_by_category order_articles.includes(:article).order('articles.name').sort do |a, b| a.article.article_category.name <=> b.article.article_category.name end end # Returns the defecit/benefit for the foodcoop # Requires a valid invoice, belonging to this order # FIXME: Consider order.foodcoop_result def profit(options = {}) markup = options[:without_markup] || false return unless invoice groups_sum = markup ? sum(:groups_without_markup) : sum(:groups) groups_sum - invoice.net_amount end # Returns the all round price of a finished order # :groups returns the sum of all GroupOrders # :clear returns the price without tax, deposit and markup # :gross includes tax and deposit. this amount should be equal to suppliers bill # :fc, guess what... def sum(type = :gross) total = 0 if %i[net gross net_deposit gross_without_deposit fc_deposit deposit fc].include?(type) for oa in order_articles.ordered.includes(:article, :article_price) quantity = oa.units * oa.price.unit_quantity case type when :net total += quantity * oa.price.price when :gross total += quantity * oa.price.gross_price when :gross_without_deposit total += quantity * oa.price.gross_price_without_deposit when :fc total += quantity * oa.price.fc_price when :net_deposit total += quantity * oa.price.net_deposit_price when :fc_deposit total += quantity * oa.price.fc_deposit_price when :deposit total += quantity * oa.price.deposit end end elsif %i[groups groups_without_markup].include?(type) for go in group_orders.includes(group_order_articles: { order_article: %i[article article_price] }) for goa in go.group_order_articles case type when :groups total += goa.result * goa.order_article.price.fc_price when :groups_without_markup total += goa.result * goa.order_article.price.gross_price end end end end total end # Finishes this order. This will set the order state to "finish" and the end property to the current time. # Ignored if the order is already finished. def finish!(user) return if finished? Order.transaction do # set new order state (needed by notify_order_finished) update!(state: 'finished', ends: Time.now, updated_by: user) # Update order_articles. Save the current article_price to keep price consistency # Also save results for each group_order_result # Clean up order_articles.includes(:article).find_each do |oa| oa.update_attribute(:article_price, oa.article.article_prices.first) oa.group_order_articles.each do |goa| 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 # Update GroupOrder prices group_orders.each(&:update_price!) # Stats ordergroups.each(&:update_stats!) # Notifications NotifyFinishedOrderJob.perform_later(self) end end # Sets order.status to 'close' and updates all Ordergroup.account_balances def close!(user, transaction_type = nil) raise I18n.t('orders.model.error_closed') if closed? update_price_of_group_orders! transaction do # Start updating account balances charge_group_orders!(user, transaction_type) if stockit? # Decreases the quantity of stock_articles for oa in order_articles.includes(:article) oa.update_results! # Update units_to_order of order_article stock_changes.create! stock_article: oa.article, quantity: oa.units_to_order * -1 end end update!(state: 'closed', updated_by: user, foodcoop_result: profit) end end # Close the order directly, without automaticly updating ordergroups account balances def close_direct!(user) raise I18n.t('orders.model.error_closed') if closed? unless FoodsoftConfig[:charge_members_manually] comments.create(user: user, text: I18n.t('orders.model.close_direct_message')) end update!(state: 'closed', updated_by: user) end def send_to_supplier!(user) Mailer.deliver_now_with_default_locale do Mailer.order_result_supplier(user, self) end update!(last_sent_mail: Time.now) end def do_end_action! if auto_close? finish!(created_by) elsif auto_close_and_send? finish!(created_by) send_to_supplier!(created_by) elsif auto_close_and_send_min_quantity? finish!(created_by) send_to_supplier!(created_by) if sum >= supplier.min_order_quantity.to_r end end def self.finish_ended! orders = Order.where.not(end_action: Order.end_actions[:no_end_action]).where(state: 'open').where('ends <= ?', DateTime.now) orders.each do |order| order.do_end_action! rescue StandardError => e ExceptionNotifier.notify_exception(e, data: { foodcoop: FoodsoftConfig.scope, order_id: order.id }) end end protected 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_boxfill_before_ends')) if ends && boxfill && ends <= (boxfill - delta) return unless boxfill && starts && boxfill <= (starts - delta) errors.add(:boxfill, I18n.t('orders.model.error_starts_before_boxfill')) end def include_articles errors.add(:articles, I18n.t('orders.model.error_nosel')) if article_ids.empty? end def keep_ordered_articles chosen_order_articles = order_articles.where(article_id: article_ids) to_be_removed = order_articles - chosen_order_articles to_be_removed_but_ordered = to_be_removed.select { |a| a.quantity > 0 || a.tolerance > 0 } return if to_be_removed_but_ordered.empty? || ignore_warnings errors.add(:articles, I18n.t(stockit? ? 'orders.model.warning_ordered_stock' : 'orders.model.warning_ordered')) @erroneous_article_ids = to_be_removed_but_ordered.map { |a| a.article_id } end def save_order_articles # fetch selected articles articles_list = Article.find(article_ids) # create new order_articles (articles_list - articles).each { |article| order_articles.create(article: article) } # delete old order_articles articles.reject { |article| articles_list.include?(article) }.each do |article| order_articles.detect { |order_article| order_article.article_id == article.id }.destroy end end private def distribute_transport return unless group_orders.any? case transport_distribution.try(&:to_i) when Order.transport_distributions[:ordergroup] amount = transport / group_orders.size group_orders.each do |go| go.transport = amount.ceil(2) end when Order.transport_distributions[:price] amount = transport / group_orders.sum(:price) group_orders.each do |go| go.transport = (amount * go.price).ceil(2) end when Order.transport_distributions[:articles] amount = transport / group_orders.includes(:group_order_articles).sum(:result) group_orders.each do |go| go.transport = (amount * go.group_order_articles.sum(:result)).ceil(2) end end end # Updates the "price" attribute of GroupOrders or GroupOrderResults # This will be either the maximum value of a current order or the actual order value of a finished order. def update_price_of_group_orders! group_orders.each(&:update_price!) end def charge_group_orders!(user, transaction_type = nil) note = transaction_note group_orders.includes(:ordergroup).find_each do |group_order| if group_order.ordergroup price = group_order.total * -1 # decrease! account balance group_order.ordergroup.add_financial_transaction!(price, note, user, transaction_type, nil, group_order) end end end def transaction_note I18n.t('orders.model.notice_close', name: name, ends: ends.strftime(I18n.t('date.formats.default'))) end end