openfoodfoundation/openfoodnetwork

View on GitHub
app/models/spree/variant.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

require 'open_food_network/enterprise_fee_calculator'
require 'spree/localized_number'

module Spree
  class Variant < ApplicationRecord
    extend Spree::LocalizedNumber
    include VariantUnits::VariantAndLineItemNaming
    include VariantStock

    self.belongs_to_required_by_default = false

    acts_as_paranoid

    searchable_attributes :sku, :display_as, :display_name, :primary_taxon_id
    searchable_associations :product, :default_price, :primary_taxon
    searchable_scopes :active, :deleted

    NAME_FIELDS = ["display_name", "display_as", "weight", "unit_value", "unit_description"].freeze

    SEARCH_KEY = "#{%w(name
                       meta_keywords
                       variants_display_as
                       variants_display_name
                       supplier_name).join('_or_')}_cont".freeze

    belongs_to :product, -> { with_deleted }, touch: true, class_name: 'Spree::Product'
    belongs_to :tax_category, class_name: 'Spree::TaxCategory'
    belongs_to :shipping_category, class_name: 'Spree::ShippingCategory', optional: false
    belongs_to :primary_taxon, class_name: 'Spree::Taxon', touch: true, optional: false

    delegate :name, :name=, :description, :description=, :meta_keywords, to: :product

    has_many :inventory_units, inverse_of: :variant, dependent: nil
    has_many :line_items, inverse_of: :variant, dependent: nil

    has_many :stock_items, dependent: :destroy, inverse_of: :variant
    has_many :stock_locations, through: :stock_items
    has_many :images, -> { order(:position) }, as: :viewable,
                                               dependent: :destroy,
                                               class_name: "Spree::Image"
    accepts_nested_attributes_for :images

    has_one :default_price,
            -> { with_deleted.where(currency: CurrentConfig.get(:currency)) },
            class_name: 'Spree::Price',
            dependent: :destroy
    has_many :prices,
             class_name: 'Spree::Price',
             dependent: :destroy
    delegate :display_price, :display_amount, :price, :price=,
             :currency, :currency=,
             to: :find_or_build_default_price

    has_many :exchange_variants, dependent: nil
    has_many :exchanges, through: :exchange_variants
    has_many :variant_overrides, dependent: :destroy
    has_many :inventory_items, dependent: :destroy
    has_many :semantic_links, dependent: :delete_all

    localize_number :price, :weight

    validates_lengths_from_database
    validate :check_currency
    validates :price, numericality: { greater_than_or_equal_to: 0 }, presence: true
    validates :tax_category, presence: true,
                             if: proc { Spree::Config[:products_require_tax_category] }

    validates :unit_value, presence: true, if: ->(variant) {
      %w(weight volume).include?(variant.product&.variant_unit)
    }

    validates :unit_value, numericality: { greater_than: 0 }, allow_blank: true
    validates :price, numericality: { greater_than_or_equal_to: 0 }

    validates :unit_description, presence: true, if: ->(variant) {
      variant.product&.variant_unit.present? && variant.unit_value.nil?
    }

    before_validation :set_cost_currency
    before_validation :ensure_shipping_category
    before_validation :ensure_unit_value
    before_validation :update_weight_from_unit_value, if: ->(v) { v.product.present? }
    before_validation :convert_variant_weight_to_decimal
    before_validation :assign_related_taxon, if: ->(v) { v.primary_taxon.blank? }

    before_save :assign_units, if: ->(variant) {
      variant.new_record? || variant.changed_attributes.keys.intersection(NAME_FIELDS).any?
    }

    after_create :create_stock_items
    around_destroy :destruction
    after_save :save_default_price

    # default variant scope only lists non-deleted variants
    scope :deleted, lambda { where.not(deleted_at: nil) }

    scope :with_order_cycles_inner, -> { joins(exchanges: :order_cycle) }

    scope :in_order_cycle, lambda { |order_cycle|
      with_order_cycles_inner.
        merge(Exchange.outgoing).
        where(order_cycles: { id: order_cycle }).
        select('DISTINCT spree_variants.*')
    }

    scope :in_schedule, lambda { |schedule|
      joins(exchanges: { order_cycle: :schedules }).
        merge(Exchange.outgoing).
        where(schedules: { id: schedule }).
        select('DISTINCT spree_variants.*')
    }

    scope :for_distribution, lambda { |order_cycle, distributor|
      where(spree_variants: { id: order_cycle.variants_distributed_by(distributor).
        select(&:id) })
    }

    scope :visible_for, lambda { |enterprise|
      joins(:inventory_items).
        where(
          'inventory_items.enterprise_id = (?) AND inventory_items.visible = (?)',
          enterprise,
          true
        )
    }

    scope :not_hidden_for, lambda { |enterprise|
      enterprise_id = enterprise&.id.to_i
      return none if enterprise_id < 1

      joins("
        LEFT OUTER JOIN (SELECT *
                           FROM inventory_items
                           WHERE enterprise_id = #{enterprise_id})
          AS o_inventory_items
          ON o_inventory_items.variant_id = spree_variants.id")
        .where("o_inventory_items.id IS NULL OR o_inventory_items.visible = (?)", true)
    }

    scope :stockable_by, lambda { |enterprise|
      return where("1=0") if enterprise.blank?

      joins(:product).
        where(spree_products: { id: Spree::Product.stockable_by(enterprise).pluck(:id) })
    }

    # Define sope as class method to allow chaining with other scopes filtering id.
    # In Rails 3, merging two scopes on the same column will consider only the last scope.
    def self.in_distributor(distributor)
      where(id: ExchangeVariant.select(:variant_id).
                joins(:exchange).
                where('exchanges.incoming = ? AND exchanges.receiver_id = ?', false, distributor))
    end

    def self.indexed
      where(nil).index_by(&:id)
    end

    def self.active(currency = nil)
      # "where(id:" is necessary so that the returned relation has no includes
      # The relation without includes will not be readonly and allow updates on it
      where(spree_variants: { id: joins(:prices).
                                          where(deleted_at: nil).
                                          where('spree_prices.currency' =>
                                            currency || CurrentConfig.get(:currency)).
                                          where.not(spree_prices: { amount: nil }).
                                          select("spree_variants.id") })
    end

    def tax_category
      super || TaxCategory.find_by(is_default: true)
    end

    def price_with_fees(distributor, order_cycle)
      price + fees_for(distributor, order_cycle)
    end

    def fees_for(distributor, order_cycle)
      OpenFoodNetwork::EnterpriseFeeCalculator.new(distributor, order_cycle).fees_for self
    end

    def fees_by_type_for(distributor, order_cycle)
      OpenFoodNetwork::EnterpriseFeeCalculator.new(distributor, order_cycle).fees_by_type_for self
    end

    def fees_name_by_type_for(distributor, order_cycle)
      OpenFoodNetwork::EnterpriseFeeCalculator.new(distributor,
                                                   order_cycle).fees_name_by_type_for self
    end

    def price_in(currency)
      prices.select{ |price| price.currency == currency }.first ||
        Spree::Price.new(variant_id: id, currency:)
    end

    def amount_in(currency)
      price_in(currency).try(:amount)
    end

    def changed?
      # We consider the variant changed if associated price is changed (it is saved after_save)
      super || default_price.changed?
    end

    # can_supply? is implemented in VariantStock
    def in_stock?(quantity = 1)
      can_supply?(quantity)
    end

    def total_on_hand
      Spree::Stock::Quantifier.new(self).total_on_hand
    end

    private

    def assign_related_taxon
      self.primary_taxon ||= product.variants.last&.primary_taxon
    end

    def check_currency
      return unless currency.nil?

      self.currency = CurrentConfig.get(:currency)
    end

    def save_default_price
      default_price.save if default_price && (default_price.changed? || default_price.new_record?)
    end

    def find_or_build_default_price
      default_price || build_default_price
    end

    def set_cost_currency
      self.cost_currency = CurrentConfig.get(:currency) if cost_currency.blank?
    end

    def create_stock_items
      StockLocation.all.find_each do |stock_location|
        stock_location.propagate_variant(self)
      end
    end

    def update_weight_from_unit_value
      return unless product.variant_unit == 'weight' && unit_value.present?

      self.weight = weight_from_unit_value
    end

    def destruction
      exchange_variants.reload.destroy_all
      yield
    end

    def ensure_unit_value
      Bugsnag.notify("Trying to set unit_value to NaN") if unit_value&.nan?
      return unless (product&.variant_unit == "items" && unit_value.nil?) || unit_value&.nan?

      self.unit_value = 1.0
    end

    def ensure_shipping_category
      self.shipping_category ||= DefaultShippingCategory.find_or_create
    end

    def convert_variant_weight_to_decimal
      self.weight = weight.to_d
    end
  end
end