# frozen_string_literal: true require 'open_food_network/property_merge' require 'concerns/product_stock' # PRODUCTS # Products represent an entity for sale in a store. # Products can have variations, called variants # Products properties include description, permalink, availability, # shipping category, etc. that do not change by variant. # # MASTER VARIANT # Every product has one master variant, which stores master price and sku, size and weight, etc. # Price, SKU, size, weight, etc. are all delegated to the master variant. # Contains on_hand inventory levels only when there are no variants for the product. # # VARIANTS # All variants can access the product properties directly (via reverse delegation). # Inventory units are tied to Variant. # The master variant can have inventory units, but not option values. # All other variants have option values and may have inventory units. # Sum of on_hand each variant's inventory level determine "on_hand" level for the product. # module Spree class Product < ApplicationRecord include ProductStock acts_as_paranoid searchable_attributes :supplier_id, :primary_taxon_id, :meta_keywords, :sku searchable_associations :supplier, :properties, :primary_taxon, :variants searchable_scopes :active, :with_properties belongs_to :tax_category, class_name: 'Spree::TaxCategory' belongs_to :shipping_category, class_name: 'Spree::ShippingCategory' belongs_to :supplier, class_name: 'Enterprise', touch: true belongs_to :primary_taxon, class_name: 'Spree::Taxon', touch: true has_one :image, class_name: "Spree::Image", as: :viewable, dependent: :destroy has_many :product_properties, dependent: :destroy has_many :properties, through: :product_properties has_many :classifications, dependent: :delete_all has_many :taxons, through: :classifications has_many :variants, -> { order("spree_variants.position ASC") }, class_name: 'Spree::Variant', dependent: :destroy has_many :prices, -> { order('spree_variants.position, spree_variants.id, currency') }, through: :variants has_many :stock_items, through: :variants has_many :supplier_properties, through: :supplier, source: :properties has_many :variant_images, -> { order(:position) }, source: :images, through: :variants validates :name, presence: true validates :shipping_category, presence: true validates :supplier, presence: true validates :primary_taxon, presence: true validates :tax_category, presence: true, if: proc { Spree::Config[:products_require_tax_category] } validates :variant_unit, presence: true validates :unit_value, presence: { if: ->(p) { %w(weight volume).include?(p.variant_unit) && new_record? } } validates :variant_unit_scale, presence: { if: ->(p) { %w(weight volume).include? p.variant_unit } } validates :variant_unit_name, presence: { if: ->(p) { p.variant_unit == 'items' } } validate :validate_image accepts_nested_attributes_for :variants, allow_destroy: true accepts_nested_attributes_for :image accepts_nested_attributes_for :product_properties, allow_destroy: true, reject_if: lambda { |pp| pp[:property_name].blank? } # Transient attributes used temporarily when creating a new product, # these values are persisted on the product's variant attr_accessor :price, :display_as, :unit_value, :unit_description before_save :add_primary_taxon_to_taxons after_save :remove_previous_primary_taxon_from_taxons after_create :ensure_standard_variant after_save :update_units scope :with_properties, ->(*property_ids) { left_outer_joins(:product_properties). left_outer_joins(:supplier_properties). where(inherits_properties: true). where(producer_properties: { property_id: property_ids }). or( where(spree_product_properties: { property_id: property_ids }) ) } scope :with_order_cycles_outer, -> { joins(" LEFT OUTER JOIN spree_variants AS o_spree_variants ON (o_spree_variants.product_id = spree_products.id)"). joins(" LEFT OUTER JOIN exchange_variants AS o_exchange_variants ON (o_exchange_variants.variant_id = o_spree_variants.id)"). joins(" LEFT OUTER JOIN exchanges AS o_exchanges ON (o_exchanges.id = o_exchange_variants.exchange_id)"). joins(" LEFT OUTER JOIN order_cycles AS o_order_cycles ON (o_order_cycles.id = o_exchanges.order_cycle_id)") } scope :imported_on, lambda { |import_date| import_date = Time.zone.parse import_date if import_date.is_a? String import_date = import_date.to_date joins(:variants).merge(Spree::Variant. where(import_date: import_date.beginning_of_day..import_date.end_of_day)) } scope :with_order_cycles_inner, -> { joins(variants: { exchanges: :order_cycle }) } scope :visible_for, lambda { |enterprise| joins(' LEFT OUTER JOIN spree_variants AS o_spree_variants ON (o_spree_variants.product_id = spree_products.id)'). joins(' LEFT OUTER JOIN inventory_items AS o_inventory_items ON (o_spree_variants.id = o_inventory_items.variant_id)'). where('o_inventory_items.enterprise_id = (?) AND visible = (?)', enterprise, true). distinct } # -- Scopes scope :in_supplier, lambda { |supplier| where(supplier_id: supplier) } # Products distributed via the given distributor through an OC scope :in_distributor, lambda { |distributor| distributor = distributor.respond_to?(:id) ? distributor.id : distributor.to_i with_order_cycles_outer. where('(o_exchanges.incoming = ? AND o_exchanges.receiver_id = ?)', false, distributor). select('distinct spree_products.*') } scope :in_distributors, lambda { |distributors| with_order_cycles_outer. where('(o_exchanges.incoming = ? AND o_exchanges.receiver_id IN (?))', false, distributors). distinct } # Products supplied by a given enterprise or distributed via that enterprise through an OC scope :in_supplier_or_distributor, lambda { |enterprise| enterprise = enterprise.respond_to?(:id) ? enterprise.id : enterprise.to_i with_order_cycles_outer. where(" spree_products.supplier_id = ? OR (o_exchanges.incoming = ? AND o_exchanges.receiver_id = ?) ", enterprise, false, enterprise). select('distinct spree_products.*') } # Products distributed by the given order cycle scope :in_order_cycle, lambda { |order_cycle| with_order_cycles_inner. merge(Exchange.outgoing). where('order_cycles.id = ?', order_cycle) } scope :in_an_active_order_cycle, lambda { with_order_cycles_inner. merge(OrderCycle.active). merge(Exchange.outgoing). where('order_cycles.id IS NOT NULL') } scope :by_producer, -> { joins(:supplier).order('enterprises.name') } scope :by_name, -> { order('name') } scope :managed_by, lambda { |user| if user.has_spree_role?('admin') where(nil) else where('supplier_id IN (?)', user.enterprises.select("enterprises.id")) end } scope :stockable_by, lambda { |enterprise| return where('1=0') if enterprise.blank? permitted_producer_ids = EnterpriseRelationship.joins(:parent).permitting(enterprise.id) .with_permission(:add_to_order_cycle) .where(enterprises: { is_primary_producer: true }) .pluck(:parent_id) return where('spree_products.supplier_id IN (?)', [enterprise.id] | permitted_producer_ids) } scope :active, lambda { where("spree_products.deleted_at IS NULL") } def self.group_by_products_id group(column_names.map { |col_name| "#{table_name}.#{col_name}" }) end def tax_category if self[:tax_category_id].nil? TaxCategory.find_by(is_default: true) else TaxCategory.find(self[:tax_category_id]) end end # for adding products which are closely related to existing ones def duplicate duplicator = Spree::Core::ProductDuplicator.new(self) duplicator.duplicate end def self.like_any(fields, values) where fields.map { |field| values.map { |value| arel_table[field].matches("%#{value}%") }.inject(:or) }.inject(:or) end def property(property_name) return nil unless prop = properties.find_by(name: property_name) product_properties.find_by(property: prop).try(:value) end def set_property(property_name, property_value) ActiveRecord::Base.transaction do property = Property.where(name: property_name).first_or_create!(presentation: property_name) product_property = ProductProperty.where(product: self, property: property).first_or_initialize product_property.value = property_value product_property.save! end end def total_on_hand stock_items.sum(&:count_on_hand) end def properties_including_inherited # Product properties override producer properties ps = product_properties.all if inherits_properties ps = OpenFoodNetwork::PropertyMerge.merge(ps, supplier.producer_properties) end ps. sort_by(&:position). map { |pp| { id: pp.property.id, name: pp.property.presentation, value: pp.value } } end def in_distributor?(distributor) self.class.in_distributor(distributor).include? self end def in_order_cycle?(order_cycle) self.class.in_order_cycle(order_cycle).include? self end def variants_distributed_by(order_cycle, distributor) order_cycle.variants_distributed_by(distributor).where(product_id: self) end # Get the most recent import_date of a product's variants def import_date variants.map(&:import_date).compact.max end def destroy transaction do touch_distributors ExchangeVariant. where('exchange_variants.variant_id IN (?)', variants.with_deleted. select(:id)).destroy_all super end end private def update_units return unless saved_change_to_variant_unit? || saved_change_to_variant_unit_name? variants.each(&:update_units) end def touch_distributors Enterprise.distributing_products(id).each(&:touch) end def add_primary_taxon_to_taxons taxons << primary_taxon unless taxons.include? primary_taxon end def remove_previous_primary_taxon_from_taxons return unless saved_change_to_primary_taxon_id? && primary_taxon_id_before_last_save taxons.destroy(primary_taxon_id_before_last_save) end def ensure_standard_variant return unless variants.empty? variant = Spree::Variant.new variant.product = self variant.price = price variant.display_as = display_as variant.unit_value = unit_value variant.unit_description = unit_description variants << variant end def validate_image return if image.blank? || image.valid? errors.add(:base, I18n.t('spree.admin.products.image_not_processable')) end end end