# 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 # 2 not to be persisted attributes to store preferences. # Values to be set via the UI that can be passed by back to UI # in a not yet persisted variant. Setters are below. attr_reader :on_hand_desired, :on_demand_desired acts_as_paranoid acts_as_taggable searchable_attributes :sku, :display_as, :display_name, :primary_taxon_id, :supplier_id searchable_associations :product, :default_price, :primary_taxon, :supplier, :tags 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 variants_supplier_name).join('_or_')}_cont".freeze belongs_to :product, -> { with_deleted }, touch: true, class_name: 'Spree::Product', optional: false, inverse_of: :variants 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 belongs_to :supplier, class_name: 'Enterprise', optional: false, touch: true 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 :images, -> { order(:position) }, as: :viewable, dependent: :destroy, class_name: "Spree::Image", inverse_of: :viewable accepts_nested_attributes_for :images has_one :default_price, -> { with_deleted.where(currency: CurrentConfig.get(:currency)) }, class_name: 'Spree::Price', inverse_of: :variant, 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, as: :subject, dependent: :delete_all has_many :supplier_properties, through: :supplier, source: :properties 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 :variant_unit, presence: true validates :unit_value, presence: true, if: ->(variant) { %w(weight volume).include?(variant.variant_unit) } validates :unit_value, numericality: { greater_than: 0 }, allow_blank: true validates :unit_description, presence: true, if: ->(variant) { variant.variant_unit.present? && variant.unit_value.nil? } validates :variant_unit_scale, presence: true, if: ->(variant) { %w(weight volume).include?(variant.variant_unit) } validates :variant_unit_name, presence: true, if: ->(variant) { variant.variant_unit == 'items' } before_validation :set_cost_currency before_validation :ensure_shipping_category before_validation :ensure_unit_value before_validation :update_weight_from_unit_value before_validation :convert_variant_weight_to_decimal 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 after_save :update_units, if: -> { saved_change_to_variant_unit? || saved_change_to_variant_unit_name? } # default variant scope only lists non-deleted variants scope :deleted, -> { 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 :with_properties, lambda { |property_ids| left_outer_joins(:supplier_properties). where(producer_properties: { property_id: property_ids }) } # 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 self.linked_to(semantic_id) includes(:semantic_links).references(:semantic_links) .where(semantic_links: { semantic_id: }).first 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 # Format as per WeightsAndMeasures def variant_unit_with_scale # Our code is based upon English based number formatting with a period `.` scale_clean = ActiveSupport::NumberHelper.number_to_rounded(variant_unit_scale, precision: nil, significant: false, strip_insignificant_zeros: true, locale: :en) [variant_unit, scale_clean].compact_blank.join("_") end def variant_unit_with_scale=(variant_unit_with_scale) values = variant_unit_with_scale.split("_") assign_attributes( variant_unit: values[0], variant_unit_scale: values[1] || nil ) end # aiming to deal with UI that deals with 0/"0"/1/"1" def on_demand_desired=(val) @on_demand_desired = ActiveModel::Type::Boolean.new.cast(val) end def on_hand_desired=(val) @on_hand_desired = ActiveModel::Type::Integer.new.cast(val) end private 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 return unless stock_items.empty? stock_items.create! end def update_weight_from_unit_value return unless variant_unit == 'weight' && unit_value.present? self.weight = weight_from_unit_value end def destruction transaction do # Even tough Enterprise will touch associated variant distributors when touched, # the variant will be removed from the exchange by the time it's triggered, # so it won't be able to find the deleted variant's distributors. # This why we do it here touch_distributors exchange_variants.reload.destroy_all yield end end def ensure_unit_value Alert.raise("Trying to set unit_value to NaN") if unit_value&.nan? return unless (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 def touch_distributors Enterprise.distributing_variants(id).each(&:touch) end end end