foodcoops/foodsoft

View on GitHub
app/models/group_order_article.rb

Summary

Maintainability
D
1 day
Test Coverage
# A GroupOrderArticle stores the sum of how many items of an OrderArticle are ordered as part of a GroupOrder.
# The chronologically order of the Ordergroup - activity are stored in GroupOrderArticleQuantity
#
class GroupOrderArticle < ApplicationRecord
  include LocalizeInput

  belongs_to :group_order
  belongs_to :order_article
  has_many   :group_order_article_quantities, dependent: :destroy

  validates :group_order, :order_article, presence: true
  validates :order_article_id, uniqueness: { scope: :group_order_id } # just once an article per group order
  validate :check_order_not_closed # don't allow changes to closed (aka settled) orders
  validates :quantity, :tolerance, numericality: { only_integer: true, greater_than_or_equal_to: 0 }

  scope :ordered, -> { includes(group_order: :ordergroup).order('groups.name') }

  localize_input_of :result

  def self.ransackable_attributes(_auth_object = nil)
    %w[id quantity tolerance result]
  end

  def self.ransackable_associations(_auth_object = nil)
    %w[order_article group_order]
  end

  # Setter used in group_order_article#new
  # We have to create an group_order, if the ordergroup wasn't involved in the order yet
  def ordergroup_id=(id)
    self.group_order = GroupOrder.where(order_id: order_article.order_id, ordergroup_id: id).first_or_initialize
  end

  def ordergroup_id
    group_order&.ordergroup_id
  end

  # 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})")
    logger.debug("Current quantity = #{self.quantity}, tolerance = #{self.tolerance}")

    # When quantity and tolerance are zero, we don't serve any purpose
    if quantity == 0 && tolerance == 0
      logger.debug('Self-destructing since requested quantity and tolerance are zero')
      destroy!
      return
    end

    # Get quantities ordered with the newest item first.
    quantities = group_order_article_quantities.order('created_on DESC').to_a
    logger.debug("GroupOrderArticleQuantity items found: #{quantities.size}")

    if quantities.size == 0
      # There is no GroupOrderArticleQuantity item yet, just insert with desired quantities...
      logger.debug('No quantities entry at all, inserting a new one with the desired quantities')
      quantities.push(GroupOrderArticleQuantity.new(group_order_article: self, quantity: quantity,
                                                    tolerance: tolerance))
      self.quantity = quantity
      self.tolerance = tolerance
    else
      # Decrease quantity/tolerance if necessary by going through the existing items and decreasing their values...
      i = 0
      while i < quantities.size && (quantity < self.quantity || tolerance < self.tolerance)
        logger.debug("Need to decrease quantities for GroupOrderArticleQuantity[#{quantities[i].id}]")
        if quantity < self.quantity && quantities[i].quantity > 0
          delta = self.quantity - quantity
          delta = [delta, quantities[i].quantity].min
          logger.debug("Decreasing quantity by #{delta}")
          quantities[i].quantity -= delta
          self.quantity -= delta
        end
        if tolerance < self.tolerance && quantities[i].tolerance > 0
          delta = self.tolerance - tolerance
          delta = [delta, quantities[i].tolerance].min
          logger.debug("Decreasing tolerance by #{delta}")
          quantities[i].tolerance -= delta
          self.tolerance -= delta
        end
        i += 1
      end
      # If there is at least one increased value: insert a new GroupOrderArticleQuantity object
      if quantity > self.quantity || tolerance > self.tolerance
        logger.debug('Inserting a new GroupOrderArticleQuantity')
        quantities.insert(0, GroupOrderArticleQuantity.new(
                               group_order_article: self,
                               quantity: (quantity > self.quantity ? quantity - self.quantity : 0),
                               tolerance: (tolerance > self.tolerance ? tolerance - self.tolerance : 0)
                             ))
        # Recalc totals:
        self.quantity += quantities[0].quantity
        self.tolerance += quantities[0].tolerance
      end
    end

    # Check if something went terribly wrong and quantites have not been adjusted as desired.
    if self.quantity != quantity || self.tolerance != tolerance
      raise ActiveRecord::RecordNotSaved.new('Unable to update GroupOrderArticle/-Quantities to desired quantities!',
                                             self)
    end

    # Remove zero-only items.
    quantities = quantities.reject { |q| q.quantity == 0 && q.tolerance == 0 }

    # Save
    transaction do
      quantities.each { |i| i.save! }
      self.group_order_article_quantities = quantities
      save!
    end
  end

  # 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
    return @calculate_result if total.nil? && !@calculate_result.nil?

    quantity = tolerance = total_quantity = 0

    # Get total
    if !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.where(group_order_article_id: order_article.group_order_article_ids).order('created_on')
      logger.debug "GroupOrderArticleQuantity records found: #{order_quantities.size}"

      first_order_first_serve = (FoodsoftConfig[:distribution_strategy] == FoodsoftConfig::DistributionStrategy::FIRST_ORDER_FIRST_SERVE)

      # Determine quantities to be ordered...
      order_quantities.each do |goaq|
        q = goaq.quantity
        q = [q, total - total_quantity].min if first_order_first_serve
        total_quantity += q
        if goaq.group_order_article_id == id
          logger.debug "increasing quantity by #{q}"
          quantity += q
        end
        break if total_quantity >= total && first_order_first_serve
      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 == 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

    # 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,
  # either calcualted on the fly or fetched from result attribute
  # Result is set when finishing the order.
  def result(type = :total)
    self[:result] || calculate_result[type]
  end

  # This is used for automatic distribution, e.g., in order.finish! or when receiving orders
  def save_results!(article_total = nil)
    new_result = calculate_result(article_total)[:total]
    update_attribute(:result_computed, new_result)
    update_attribute(:result, new_result)
  end

  # Returns total price for this individual article
  # Until the order is finished this will be the maximum price or
  # the minimum price depending on configuration. When the order is finished it
  # will be the value depending of the article results.
  def total_price(order_article = self.order_article)
    if order_article.order.open?
      if FoodsoftConfig[:tolerance_is_costly]
        order_article.article.fc_price * (quantity + tolerance)
      else
        order_article.article.fc_price * quantity
      end
    else
      order_article.price.fc_price * result
    end
  end

  # Check if the result deviates from the result_computed
  def result_manually_changed?
    result != result_computed unless result.nil?
  end

  private

  def check_order_not_closed
    return unless order_article.order.closed?

    errors.add(:order_article, I18n.t('model.group_order_article.order_closed'))
  end
end