diff --git a/app/models/spree/option_type.rb b/app/models/spree/option_type.rb index 5246243809..075c597481 100644 --- a/app/models/spree/option_type.rb +++ b/app/models/spree/option_type.rb @@ -1,5 +1,6 @@ module Spree class OptionType < ActiveRecord::Base + has_many :products, through: :product_option_types has_many :option_values, -> { order(:position) }, dependent: :destroy has_many :product_option_types, dependent: :destroy has_and_belongs_to_many :prototypes, join_table: 'spree_option_types_prototypes' diff --git a/app/models/spree/option_type_decorator.rb b/app/models/spree/option_type_decorator.rb deleted file mode 100644 index 1ef46caa4e..0000000000 --- a/app/models/spree/option_type_decorator.rb +++ /dev/null @@ -1,5 +0,0 @@ -module Spree - OptionType.class_eval do - has_many :products, through: :product_option_types - end -end diff --git a/app/models/spree/price.rb b/app/models/spree/price.rb index ed85b5675e..878c689873 100644 --- a/app/models/spree/price.rb +++ b/app/models/spree/price.rb @@ -1,5 +1,7 @@ module Spree class Price < ActiveRecord::Base + acts_as_paranoid without_default_scope: true + belongs_to :variant, class_name: 'Spree::Variant' validate :check_price @@ -22,10 +24,14 @@ module Spree self[:amount] = parse_price(price) end - private - def check_price - raise "Price must belong to a variant" if variant.nil? + # Allow prices to access associated soft-deleted variants. + def variant + Spree::Variant.unscoped { super } + end + private + + def check_price if currency.nil? self.currency = Spree::Config[:currency] end diff --git a/app/models/spree/price_decorator.rb b/app/models/spree/price_decorator.rb deleted file mode 100644 index 6c67ca5949..0000000000 --- a/app/models/spree/price_decorator.rb +++ /dev/null @@ -1,18 +0,0 @@ -module Spree - Price.class_eval do - acts_as_paranoid without_default_scope: true - - # Allow prices to access associated soft-deleted variants. - def variant - Spree::Variant.unscoped { super } - end - - private - - def check_price - if currency.nil? - self.currency = Spree::Config[:currency] - end - end - end -end diff --git a/app/models/spree/product.rb b/app/models/spree/product.rb index 14e01acfa1..f165b539f7 100755 --- a/app/models/spree/product.rb +++ b/app/models/spree/product.rb @@ -1,3 +1,7 @@ +require 'open_food_network/permalink_generator' +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 @@ -17,12 +21,18 @@ # 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 < ActiveRecord::Base + include PermalinkGenerator + include ProductStock + acts_as_paranoid + has_many :product_option_types, dependent: :destroy - has_many :option_types, through: :product_option_types + # We have an after_destroy callback on Spree::ProductOptionType. However, if we + # don't specify dependent => destroy on this association, it is not called. See: + # https://github.com/rails/rails/issues/7618 + has_many :option_types, through: :product_option_types, dependent: :destroy has_many :product_properties, dependent: :destroy has_many :properties, through: :product_properties @@ -32,6 +42,8 @@ module Spree 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 :master, -> { where is_master: true }, @@ -51,8 +63,9 @@ module Spree has_many :stock_items, through: :variants - delegate_belongs_to :master, :sku, :price, :currency, :display_amount, :display_price, :weight, :height, :width, :depth, :is_master, :has_default_price?, :cost_currency, :price_in, :amount_in + delegate_belongs_to :master, :sku, :price, :currency, :display_amount, :display_price, :weight, :height, :width, :depth, :is_master, :has_default_price?, :cost_currency, :price_in, :amount_in, :unit_value, :unit_description delegate_belongs_to :master, :cost_price if Variant.table_exists? && Variant.column_names.include?('cost_price') + delegate :images_attributes=, :display_as=, to: :master after_create :set_master_variant_defaults after_create :add_properties_and_option_types_from_prototype @@ -71,6 +84,16 @@ module Spree validates :price, presence: true, if: proc { Spree::Config[:require_master_price] } validates :shipping_category_id, presence: true + validates :supplier, presence: true + validates :primary_taxon, presence: true + validates :tax_category_id, presence: true, if: "Spree::Config.products_require_tax_category" + + validates :variant_unit, presence: true + 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' } } + attr_accessor :option_values_hash accepts_nested_attributes_for :product_properties, allow_destroy: true, reject_if: lambda { |pp| pp[:property_name].blank? } @@ -80,9 +103,111 @@ module Spree alias :options :product_option_types after_initialize :ensure_master + after_initialize :set_available_on_to_now, if: :new_record? + + before_validation :sanitize_permalink + before_save :add_primary_taxon_to_taxons + after_save :remove_previous_primary_taxon_from_taxons + after_save :ensure_standard_variant + after_save :update_units before_destroy :punch_permalink + # -- Joins + 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_including_master: { 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) + } + + # -- 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). + uniq + } + + # 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) + } + def to_param permalink.present? ? permalink : (permalink_was || name.to_s.to_url) end @@ -122,6 +247,12 @@ module Spree duplicator.duplicate end + # Called by Spree::Product::duplicate before saving. + def duplicate_extra(_parent) + # Spree sets the SKU to "COPY OF #{parent sku}". + master.sku = '' + end + # use deleted? rather than checking the attribute directly. this # allows extensions to override deleted? if they want to provide # their own definition. @@ -199,6 +330,60 @@ module Spree super || variants_including_master.with_deleted.where(is_master: true).first 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 variant_unit_option_type + if variant_unit.present? + option_type_name = "unit_#{variant_unit}" + option_type_presentation = variant_unit.capitalize + + Spree::OptionType.find_by(name: option_type_name) || + Spree::OptionType.create!(name: option_type_name, + presentation: option_type_presentation) + end + end + + def destroy_with_delete_from_order_cycles + transaction do + touch_distributors + + ExchangeVariant. + where('exchange_variants.variant_id IN (?)', variants_including_master.with_deleted. + select(:id)).destroy_all + + destroy_without_delete_from_order_cycles + end + end + alias_method_chain :destroy, :delete_from_order_cycles + private # Builds variants from a hash of option types & values @@ -230,10 +415,26 @@ module Spree master.is_master = true end - # there's a weird quirk with the delegate stuff that does not automatically save the delegate object - # when saving so we force a save using a hook. + # This fixes any problems arising from failing master saves, without the need for a validates_associated on + # master, while giving us more specific errors as to why saving failed def save_master - master.save if master && (master.changed? || master.new_record? || (master.default_price && (master.default_price.changed || master.default_price.new_record))) + if master && ( + master.changed? || master.new_record? || ( + master.default_price && ( + master.default_price.changed? || master.default_price.new_record? + ) + ) + ) + master.save! + end + + # If the master cannot be saved, the Product object will get its errors + # and will be destroyed + rescue ActiveRecord::RecordInvalid + master.errors.each do |att, error| + errors.add(att, error) + end + raise end def ensure_master @@ -244,6 +445,53 @@ module Spree def punch_permalink update_attribute :permalink, "#{Time.now.to_i}_#{permalink}" # punch permalink with date prefix end + + def set_available_on_to_now + self.available_on ||= Time.zone.now + end + + def update_units + if variant_unit_changed? + option_types.delete self.class.all_variant_unit_option_types + option_types << variant_unit_option_type if variant_unit.present? + variants_including_master.each(&:update_units) + end + 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 primary_taxon_id_changed? && primary_taxon_id_was + + taxons.destroy(primary_taxon_id_was) + end + + def self.all_variant_unit_option_types + Spree::OptionType.where('name LIKE ?', 'unit_%%') + end + + def ensure_standard_variant + if master.valid? && variants.empty? + variant = master.dup + variant.product = self + variant.is_master = false + variants << variant + end + end + + # Spree creates a permalink already but our implementation fixes an edge case. + def sanitize_permalink + if permalink.blank? || permalink_changed? + requested = permalink.presence || permalink_was.presence || name.presence || 'product' + self.permalink = create_unique_permalink(requested.parameterize) + end + end end end diff --git a/app/models/spree/product_decorator.rb b/app/models/spree/product_decorator.rb deleted file mode 100644 index 554309f687..0000000000 --- a/app/models/spree/product_decorator.rb +++ /dev/null @@ -1,265 +0,0 @@ -require 'open_food_network/permalink_generator' -require 'open_food_network/property_merge' -require 'concerns/product_stock' - -Spree::Product.class_eval do - include PermalinkGenerator - include ProductStock - - # We have an after_destroy callback on Spree::ProductOptionType. However, if we - # don't specify dependent => destroy on this association, it is not called. See: - # https://github.com/rails/rails/issues/7618 - has_many :option_types, through: :product_option_types, dependent: :destroy - - belongs_to :supplier, class_name: 'Enterprise', touch: true - belongs_to :primary_taxon, class_name: 'Spree::Taxon', touch: true - - delegate_belongs_to :master, :unit_value, :unit_description - delegate :images_attributes=, :display_as=, to: :master - - validates :supplier, presence: true - validates :primary_taxon, presence: true - validates :tax_category_id, presence: true, if: "Spree::Config.products_require_tax_category" - - validates :variant_unit, presence: true - 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' } } - - after_initialize :set_available_on_to_now, if: :new_record? - before_validation :sanitize_permalink - before_save :add_primary_taxon_to_taxons - after_save :remove_previous_primary_taxon_from_taxons - after_save :ensure_standard_variant - after_save :update_units - - # -- Joins - 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_including_master: { 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) - } - - # -- 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). - uniq - } - - # 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) - } - - # -- Methods - - # Called by Spree::Product::duplicate before saving. - def duplicate_extra(_parent) - # Spree sets the SKU to "COPY OF #{parent sku}". - master.sku = '' - 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 variant_unit_option_type - if variant_unit.present? - option_type_name = "unit_#{variant_unit}" - option_type_presentation = variant_unit.capitalize - - Spree::OptionType.find_by(name: option_type_name) || - Spree::OptionType.create!(name: option_type_name, - presentation: option_type_presentation) - end - end - - def destroy_with_delete_from_order_cycles - transaction do - touch_distributors - - ExchangeVariant. - where('exchange_variants.variant_id IN (?)', variants_including_master.with_deleted. - select(:id)).destroy_all - - destroy_without_delete_from_order_cycles - end - end - alias_method_chain :destroy, :delete_from_order_cycles - - private - - def set_available_on_to_now - self.available_on ||= Time.zone.now - end - - def update_units - if variant_unit_changed? - option_types.delete self.class.all_variant_unit_option_types - option_types << variant_unit_option_type if variant_unit.present? - variants_including_master.each(&:update_units) - end - 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 primary_taxon_id_changed? && primary_taxon_id_was - - taxons.destroy(primary_taxon_id_was) - end - - def self.all_variant_unit_option_types - Spree::OptionType.where('name LIKE ?', 'unit_%%') - end - - def ensure_standard_variant - if master.valid? && variants.empty? - variant = master.dup - variant.product = self - variant.is_master = false - variants << variant - end - end - - # Override Spree's old save_master method and replace it with the most recent method from spree repository - # This fixes any problems arising from failing master saves, without the need for a validates_associated on - # master, while giving us more specific errors as to why saving failed - def save_master - if master && ( - master.changed? || master.new_record? || ( - master.default_price && ( - master.default_price.changed? || master.default_price.new_record? - ) - ) - ) - master.save! - end - - # If the master cannot be saved, the Product object will get its errors - # and will be destroyed - rescue ActiveRecord::RecordInvalid - master.errors.each do |att, error| - errors.add(att, error) - end - raise - end - - # Spree creates a permalink already but our implementation fixes an edge case. - def sanitize_permalink - if permalink.blank? || permalink_changed? - requested = permalink.presence || permalink_was.presence || name.presence || 'product' - self.permalink = create_unique_permalink(requested.parameterize) - end - end -end diff --git a/app/models/spree/product_option_type.rb b/app/models/spree/product_option_type.rb index 2a63434c48..47f438c7b4 100644 --- a/app/models/spree/product_option_type.rb +++ b/app/models/spree/product_option_type.rb @@ -1,7 +1,16 @@ module Spree class ProductOptionType < ActiveRecord::Base + after_destroy :remove_option_values + belongs_to :product, class_name: 'Spree::Product' belongs_to :option_type, class_name: 'Spree::OptionType' acts_as_list scope: :product + + def remove_option_values + product.variants_including_master.each do |variant| + option_values = variant.option_values.where(option_type_id: option_type) + variant.option_values.destroy(*option_values) + end + end end end diff --git a/app/models/spree/product_option_type_decorator.rb b/app/models/spree/product_option_type_decorator.rb deleted file mode 100644 index 8e5a909058..0000000000 --- a/app/models/spree/product_option_type_decorator.rb +++ /dev/null @@ -1,10 +0,0 @@ -Spree::ProductOptionType.class_eval do - after_destroy :remove_option_values - - def remove_option_values - product.variants_including_master.each do |variant| - option_values = variant.option_values.where(option_type_id: option_type) - variant.option_values.destroy(*option_values) - end - end -end diff --git a/app/models/spree/product_property.rb b/app/models/spree/product_property.rb index aa947d878d..a62ea5d7ee 100644 --- a/app/models/spree/product_property.rb +++ b/app/models/spree/product_property.rb @@ -1,6 +1,6 @@ module Spree class ProductProperty < ActiveRecord::Base - belongs_to :product, class_name: 'Spree::Product' + belongs_to :product, class_name: "Spree::Product", touch: true belongs_to :property, class_name: 'Spree::Property' validates :property, presence: true diff --git a/app/models/spree/product_property_decorator.rb b/app/models/spree/product_property_decorator.rb deleted file mode 100644 index 58ee8fb1ad..0000000000 --- a/app/models/spree/product_property_decorator.rb +++ /dev/null @@ -1,5 +0,0 @@ -module Spree - ProductProperty.class_eval do - belongs_to :product, class_name: "Spree::Product", touch: true - end -end diff --git a/app/models/spree/variant.rb b/app/models/spree/variant.rb index 9b9fb1f329..f23e54c9a9 100644 --- a/app/models/spree/variant.rb +++ b/app/models/spree/variant.rb @@ -1,9 +1,16 @@ +require 'open_food_network/enterprise_fee_calculator' +require 'variant_units/variant_and_line_item_naming' +require 'concerns/variant_stock' + module Spree class Variant < ActiveRecord::Base + extend Spree::LocalizedNumber + include VariantUnits::VariantAndLineItemNaming + include VariantStock + acts_as_paranoid belongs_to :product, touch: true, class_name: 'Spree::Product' - delegate_belongs_to :product, :name, :description, :permalink, :available_on, :tax_category_id, :shipping_category_id, :meta_description, :meta_keywords, :tax_category, :shipping_category @@ -16,54 +23,150 @@ module Spree has_many :stock_movements has_and_belongs_to_many :option_values, join_table: :spree_option_values_variants + has_many :images, -> { order(:position) }, as: :viewable, dependent: :destroy, class_name: "Spree::Image" + accepts_nested_attributes_for :images has_one :default_price, -> { where currency: Spree::Config[:currency] }, class_name: 'Spree::Price', dependent: :destroy - - delegate_belongs_to :default_price, :display_price, :display_amount, :price, :price=, :currency if Spree::Price.table_exists? - has_many :prices, class_name: 'Spree::Price', dependent: :destroy + delegate_belongs_to :default_price, :display_price, :display_amount, :price, :price=, :currency if Spree::Price.table_exists? + + has_many :exchange_variants + has_many :exchanges, through: :exchange_variants + has_many :variant_overrides + has_many :inventory_items + + localize_number :price, :cost_price, :weight validate :check_price validates :price, numericality: { greater_than_or_equal_to: 0 }, presence: true, if: proc { Spree::Config[:require_master_price] } validates :cost_price, numericality: { greater_than_or_equal_to: 0, allow_nil: true } if self.table_exists? && self.column_names.include?('cost_price') + validates :unit_value, presence: true, if: ->(variant) { + %w(weight volume).include?(variant.product.andand.variant_unit) + } + + validates :unit_description, presence: true, if: ->(variant) { + variant.product.andand.variant_unit.present? && variant.unit_value.nil? + } + before_validation :set_cost_currency + before_validation :update_weight_from_unit_value, if: ->(v) { v.product.present? } + after_save :save_default_price + after_save :update_units + after_create :create_stock_items after_create :set_position + around_destroy :destruction + # default variant scope only lists non-deleted variants scope :deleted, lambda { where('deleted_at IS NOT NULL') } + scope :with_order_cycles_inner, -> { joins(exchanges: :order_cycle) } + + scope :not_master, -> { where(is_master: false) } + 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 IN (?)', 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| + return where("1=0") if enterprise.blank? + + joins(" + LEFT OUTER JOIN (SELECT * + FROM inventory_items + WHERE enterprise_id = #{sanitize enterprise.andand.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 + scoped.index_by(&:id) + end + def self.active(currency = nil) - joins(:prices).where(deleted_at: nil).where('spree_prices.currency' => currency || Spree::Config[:currency]).where('spree_prices.amount IS NOT NULL') + # "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 in (?)", joins(:prices). + where(deleted_at: nil). + where('spree_prices.currency' => + currency || Spree::Config[:currency]). + where('spree_prices.amount IS NOT NULL'). + select("spree_variants.id")) end def cost_price=(price) self[:cost_price] = parse_price(price) if price.present? end + # Allow variant to access associated soft-deleted prices. + def default_price + Spree::Price.unscoped { super } + 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 + # returns number of units currently on backorder for this variant. def on_backorder inventory_units.with_state('backordered').size end - def options_text - values = self.option_values.joins(:option_type).order("#{Spree::OptionType.table_name}.position asc") - - values.map! do |ov| - "#{ov.option_type.presentation}: #{ov.presentation}" - end - - values.to_sentence({ words_connector: ", ", two_words_connector: ", " }) - end - def gross_profit cost_price.nil? ? 0 : (price - cost_price) end @@ -133,8 +236,9 @@ module Spree Spree::Product.unscoped { super } end - def in_stock?(quantity=1) - Spree::Stock::Quantifier.new(self).can_supply?(quantity) + # can_supply? is implemented in VariantStock + def in_stock?(quantity = 1) + can_supply?(quantity) end def total_on_hand @@ -183,6 +287,15 @@ module Spree def set_position self.update_column(:position, product.variants.maximum(:position).to_i + 1) end + + def update_weight_from_unit_value + self.weight = weight_from_unit_value if product.variant_unit == 'weight' && unit_value.present? + end + + def destruction + exchange_variants(:reload).destroy_all + yield + end end end diff --git a/app/models/spree/variant_decorator.rb b/app/models/spree/variant_decorator.rb deleted file mode 100644 index 737b2a8da4..0000000000 --- a/app/models/spree/variant_decorator.rb +++ /dev/null @@ -1,141 +0,0 @@ -require 'open_food_network/enterprise_fee_calculator' -require 'variant_units/variant_and_line_item_naming' -require 'concerns/variant_stock' - -Spree::Variant.class_eval do - extend Spree::LocalizedNumber - # Remove method From Spree, so method from the naming module is used instead - # This file may be double-loaded in delayed job environment, so we check before - # removing the Spree method to prevent error. - remove_method :options_text if instance_methods(false).include? :options_text - include VariantUnits::VariantAndLineItemNaming - include VariantStock - - has_many :exchange_variants - has_many :exchanges, through: :exchange_variants - has_many :variant_overrides - has_many :inventory_items - - accepts_nested_attributes_for :images - - validates :unit_value, presence: true, if: ->(variant) { - %w(weight volume).include?(variant.product.andand.variant_unit) - } - - validates :unit_description, presence: true, if: ->(variant) { - variant.product.andand.variant_unit.present? && variant.unit_value.nil? - } - - before_validation :update_weight_from_unit_value, if: ->(v) { v.product.present? } - after_save :update_units - around_destroy :destruction - - scope :with_order_cycles_inner, -> { joins(exchanges: :order_cycle) } - - scope :not_master, -> { where(is_master: false) } - 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 IN (?)', 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| - return where("1=0") if enterprise.blank? - - joins(" - LEFT OUTER JOIN (SELECT * - FROM inventory_items - WHERE enterprise_id = #{sanitize enterprise.andand.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) - } - - localize_number :price, :cost_price, :weight - - 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 - scoped.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 in (?)", joins(:prices). - where(deleted_at: nil). - where('spree_prices.currency' => - currency || Spree::Config[:currency]). - where('spree_prices.amount IS NOT NULL'). - select("spree_variants.id")) - end - - # We override in_stock? to avoid depending - # on the non-overridable method Spree::Stock::Quantifier.can_supply? - # VariantStock implements can_supply? itself which depends on overridable methods - def in_stock?(quantity = 1) - can_supply?(quantity) - end - - # Allow variant to access associated soft-deleted prices. - def default_price - Spree::Price.unscoped { super } - 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 - - private - - def update_weight_from_unit_value - self.weight = weight_from_unit_value if product.variant_unit == 'weight' && unit_value.present? - end - - def destruction - exchange_variants(:reload).destroy_all - yield - end -end