diff --git a/app/models/concerns/product_stock.rb b/app/models/concerns/product_stock.rb index 4f01b3069f..60e98d7791 100644 --- a/app/models/concerns/product_stock.rb +++ b/app/models/concerns/product_stock.rb @@ -4,7 +4,7 @@ module ProductStock extend ActiveSupport::Concern def on_demand - if has_variants? + if variants? raise 'Cannot determine product on_demand value of product with multiple variants' if variants.size > 1 variants.first.on_demand @@ -14,7 +14,7 @@ module ProductStock end def on_hand - if has_variants? + if variants? variants.map(&:on_hand).reduce(:+) else master.on_hand diff --git a/app/models/spree/option_type.rb b/app/models/spree/option_type.rb new file mode 100644 index 0000000000..91b65c97bb --- /dev/null +++ b/app/models/spree/option_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +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 + + validates :name, :presentation, presence: true + default_scope -> { order("#{table_name}.position") } + + accepts_nested_attributes_for :option_values, + reject_if: lambda { |ov| + ov[:name].blank? || ov[:presentation].blank? + }, + allow_destroy: true + end +end 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/option_value.rb b/app/models/spree/option_value.rb new file mode 100644 index 0000000000..b21eed19ba --- /dev/null +++ b/app/models/spree/option_value.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Spree + class OptionValue < ActiveRecord::Base + belongs_to :option_type + acts_as_list scope: :option_type + has_and_belongs_to_many :variants, join_table: 'spree_option_values_variants', + class_name: "Spree::Variant" + + validates :name, :presentation, presence: true + end +end diff --git a/app/models/spree/price.rb b/app/models/spree/price.rb new file mode 100644 index 0000000000..47c8eb2134 --- /dev/null +++ b/app/models/spree/price.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Spree + class Price < ActiveRecord::Base + acts_as_paranoid without_default_scope: true + + belongs_to :variant, class_name: 'Spree::Variant' + + validate :check_price + validates :amount, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + + def display_amount + money + end + alias :display_price :display_amount + + def money + Spree::Money.new(amount || 0, currency: currency) + end + + def price + amount + end + + def price=(price) + self[:amount] = parse_price(price) + end + + # Allow prices to access associated soft-deleted variants. + def variant + Spree::Variant.unscoped { super } + end + + private + + def check_price + return unless currency.nil? + + self.currency = Spree::Config[:currency] + end + + # strips all non-price-like characters from the price, taking into account locale settings + def parse_price(price) + return price unless price.is_a?(String) + + separator, _delimiter = I18n.t([:'number.currency.format.separator', + :'number.currency.format.delimiter']) + non_price_characters = /[^0-9\-#{separator}]/ + # Strip everything else first + price.gsub!(non_price_characters, '') + # Then replace the locale-specific decimal separator with the standard separator if necessary + price.gsub!(separator, '.') unless separator == '.' + + price.to_d + end + end +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 new file mode 100755 index 0000000000..25c9340473 --- /dev/null +++ b/app/models/spree/product.rb @@ -0,0 +1,487 @@ +# frozen_string_literal: true + +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 +# 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. +# The master variant does not have option values associated with it. +# 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 < ActiveRecord::Base + include PermalinkGenerator + include ProductStock + + acts_as_paranoid + + has_many :product_option_types, dependent: :destroy + # 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 + + has_many :classifications, dependent: :delete_all + has_many :taxons, through: :classifications + + 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 }, + class_name: 'Spree::Variant', + dependent: :destroy + + has_many :variants, -> { + where(is_master: false).order("#{::Spree::Variant.quoted_table_name}.position ASC") + }, class_name: 'Spree::Variant' + + has_many :variants_including_master, + -> { order("#{::Spree::Variant.quoted_table_name}.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 + + delegate_belongs_to :master, :sku, :price, :currency, :display_amount, :display_price, :weight, + :height, :width, :depth, :is_master, :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 :build_variants_from_option_values_hash, if: :option_values_hash + after_save :save_master + + delegate :images, to: :master, prefix: true + alias_method :images, :master_images + + has_many :variant_images, -> { order(:position) }, source: :images, + through: :variants_including_master + + accepts_nested_attributes_for :variants, allow_destroy: true + + validates :name, presence: true + validates :permalink, presence: true + 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? } + + make_permalink order: :name + + 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) + } + + scope :active, lambda { + where("spree_products.deleted_at IS NULL AND spree_products.available_on <= ?", Time.zone.now) + } + + def self.group_by_products_id + group(column_names.map { |col_name| "#{table_name}.#{col_name}" }) + end + + def to_param + permalink.present? ? permalink : (permalink_was || name.to_s.to_url) + end + + # the master variant is not a member of the variants array + def variants? + variants.any? + 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 + + # Ensures option_types and product_option_types exist for keys in option_values_hash + def ensure_option_types_exist_for_values_hash + return if option_values_hash.nil? + + option_values_hash.keys.map(&:to_i).each do |id| + option_type_ids << id unless option_type_ids.include?(id) + unless product_option_types.pluck(:option_type_id).include?(id) + product_option_types.create(option_type_id: id) + end + end + end + + # for adding products which are closely related to existing ones + def duplicate + duplicator = Spree::Core::ProductDuplicator.new(self) + duplicator.duplicate + end + + # use deleted? rather than checking the attribute directly. this + # allows extensions to override deleted? if they want to provide + # their own definition. + def deleted? + !!deleted_at + end + + def available? + !(available_on.nil? || available_on.future?) + end + + # split variants list into hash which shows mapping of opt value onto matching variants + # eg categorise_variants_from_option(color) => {"red" -> [...], "blue" -> [...]} + def categorise_variants_from_option(opt_type) + return {} unless option_types.include?(opt_type) + + variants.active.group_by { |v| v.option_values.detect { |o| o.option_type == opt_type } } + 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 empty_option_values? + options.empty? || options.any? do |opt| + opt.option_type.option_values.empty? + end + 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 + + # Master variant may be deleted (i.e. when the product is deleted) + # which would make AR's default finder return nil. + # This is a stopgap for that little problem. + def master + super || variants_including_master.with_deleted.find_by(is_master: true) + 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 + return if variant_unit.blank? + + 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 + + def self.all_variant_unit_option_types + Spree::OptionType.where('name LIKE ?', 'unit_%%') + 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 + def build_variants_from_option_values_hash + ensure_option_types_exist_for_values_hash + values = option_values_hash.values + values = values.inject(values.shift) { |memo, value| memo.product(value).map(&:flatten) } + + values.each do |ids| + variants.create( + option_value_ids: ids, + price: master.price + ) + end + save + end + + # ensures the master variant is flagged as such + def set_master_variant_defaults + master.is_master = true + end + + # Here we rescue errors when saving master variants (without the need for a + # validates_associated on master) and we get more specific data about the errors + 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 + + def ensure_master + return unless new_record? + + self.master ||= Variant.new + end + + def punch_permalink + # Punch permalink with date prefix + update_attribute :permalink, "#{Time.now.to_i}_#{permalink}" + end + + def set_available_on_to_now + self.available_on ||= Time.zone.now + end + + def update_units + return unless 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 + + 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 ensure_standard_variant + return unless master.valid? && variants.empty? + + variant = master.dup + variant.product = self + variant.is_master = false + variants << variant + end + + # Spree creates a permalink already but our implementation fixes an edge case. + def sanitize_permalink + return unless permalink.blank? || permalink_changed? + + requested = permalink.presence || permalink_was.presence || name.presence || 'product' + self.permalink = create_unique_permalink(requested.parameterize) + end + end +end + +require_dependency 'spree/product/scopes' 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 new file mode 100644 index 0000000000..b02db6a440 --- /dev/null +++ b/app/models/spree/product_option_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +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 new file mode 100644 index 0000000000..a86a12f1a9 --- /dev/null +++ b/app/models/spree/product_property.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Spree + class ProductProperty < ActiveRecord::Base + belongs_to :product, class_name: "Spree::Product", touch: true + belongs_to :property, class_name: 'Spree::Property' + + validates :property, presence: true + validates :value, length: { maximum: 255 } + + default_scope -> { order("#{table_name}.position") } + + # virtual attributes for use with AJAX completion stuff + def property_name + property&.name + end + + def property_name=(name) + return if name.blank? + + unless property = Property.find_by(name: name) + property = Property.create(name: name, presentation: name) + end + self.property = property + end + end +end 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 new file mode 100644 index 0000000000..9b8fe730ca --- /dev/null +++ b/app/models/spree/variant.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +require 'open_food_network/enterprise_fee_calculator' +require 'variant_units/variant_and_line_item_naming' +require 'concerns/variant_stock' +require 'spree/localized_number' + +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 + + has_many :inventory_units + has_many :line_items + + has_many :stock_items, dependent: :destroy + has_many :stock_locations, through: :stock_items + 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 + has_many :prices, + class_name: 'Spree::Price', + dependent: :destroy + delegate_belongs_to :default_price, :display_price, :display_amount, + :price, :price=, :currency + + 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 } + + 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) + # "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 + + # 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 gross_profit + cost_price.nil? ? 0 : (price - cost_price) + end + + # use deleted? rather than checking the attribute directly. this + # allows extensions to override deleted? if they want to provide + # their own definition. + def deleted? + deleted_at + end + + def set_option_value(opt_name, opt_value) + # no option values on master + return if is_master + + option_type = Spree::OptionType.where(name: opt_name).first_or_initialize do |o| + o.presentation = opt_name + o.save! + end + + current_value = option_values.detect { |o| o.option_type.name == opt_name } + + if current_value.nil? + # then we have to check to make sure that the product has the option type + unless product.option_types.include? option_type + product.option_types << option_type + product.save + end + else + return if current_value.name == opt_value + + option_values.delete(current_value) + end + + option_value = Spree::OptionValue.where(option_type_id: option_type.id, + name: opt_value).first_or_initialize do |o| + o.presentation = opt_value + o.save! + end + + option_values << option_value + save + end + + def option_value(opt_name) + option_values.detect { |o| o.option_type.name == opt_name }.try(:presentation) + end + + def default_price? + !default_price.nil? + end + + def price_in(currency) + prices.select{ |price| price.currency == currency }.first || + Spree::Price.new(variant_id: id, currency: currency) + end + + def amount_in(currency) + price_in(currency).try(:amount) + end + + def name_and_sku + "#{name} - #{sku}" + end + + # Product may be created with deleted_at already set, + # which would make AR's default finder return nil. + # This is a stopgap for that little problem. + def product + Spree::Product.unscoped { super } + 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 + + # Ensures a new variant takes the product master price when price is not supplied + def check_price + if price.nil? && Spree::Config[:require_master_price] + raise 'No master variant found to infer price' unless product&.master + raise 'Must supply price for variant or master.price for product.' if self == product.master + + self.price = product.master.price + end + + return unless currency.nil? + + self.currency = Spree::Config[:currency] + end + + def save_default_price + default_price.save if default_price && (default_price.changed? || default_price.new_record?) + end + + def set_cost_currency + self.cost_currency = Spree::Config[: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 set_position + update_column(:position, product.variants.maximum(:position).to_i + 1) + 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 + 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 diff --git a/config/locales/en.yml b/config/locales/en.yml index cf4260e055..0074661090 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3232,7 +3232,6 @@ See the %{link} to find out more about %{sitename}'s features and to start using index: inherits_properties_checkbox_hint: "Inherit properties from %{supplier}? (unless overridden above)" add_product_properties: "Add Product Properties" - select_from_prototype: "Select From Prototype" properties: index: properties: "Properties" diff --git a/lib/spree/core/product_duplicator.rb b/lib/spree/core/product_duplicator.rb new file mode 100644 index 0000000000..f4b778432f --- /dev/null +++ b/lib/spree/core/product_duplicator.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Spree + module Core + class ProductDuplicator + attr_accessor :product + + def initialize(product) + @product = product + end + + def duplicate + new_product = duplicate_product + + # don't dup the actual variants, just the characterising types + new_product.option_types = product.option_types if product.variants? + + new_product.save! + new_product + end + + protected + + def duplicate_product + product.dup.tap do |new_product| + new_product.name = "COPY OF #{product.name}" + new_product.taxons = product.taxons + new_product.created_at = nil + new_product.deleted_at = nil + new_product.updated_at = nil + new_product.product_properties = reset_properties + new_product.master = duplicate_master + end + end + + def duplicate_master + master = product.master + master.dup.tap do |new_master| + new_master.sku = "" + new_master.deleted_at = nil + new_master.images = master.images.map { |image| duplicate_image image } + new_master.price = master.price + new_master.currency = master.currency + end + end + + def duplicate_image(image) + new_image = image.dup + new_image.assign_attributes(attachment: image.attachment.clone) + new_image + end + + def reset_properties + product.product_properties.map do |prop| + prop.dup.tap do |new_prop| + new_prop.created_at = nil + new_prop.updated_at = nil + end + end + end + end + end +end diff --git a/lib/spree/product_duplicator.rb b/lib/spree/product_duplicator.rb deleted file mode 100644 index 8866f85fa5..0000000000 --- a/lib/spree/product_duplicator.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -module Spree - class ProductDuplicator - attr_accessor :product - - def initialize(product) - @product = product - end - - def duplicate - new_product = duplicate_product - - # don't dup the actual variants, just the characterising types - new_product.option_types = product.option_types if product.has_variants? - - # allow site to do some customization - new_product.__send__(:duplicate_extra, product) if new_product.respond_to?(:duplicate_extra) - new_product.save! - new_product - end - - protected - - def duplicate_product - product.dup.tap do |new_product| - new_product.name = "COPY OF #{product.name}" - new_product.taxons = product.taxons - new_product.created_at = nil - new_product.deleted_at = nil - new_product.updated_at = nil - new_product.product_properties = reset_properties - new_product.master = duplicate_master - end - end - - def duplicate_master - master = product.master - master.dup.tap do |new_master| - new_master.sku = "COPY OF #{master.sku}" - new_master.deleted_at = nil - new_master.images = master.images.map { |image| duplicate_image image } - new_master.price = master.price - new_master.currency = master.currency - end - end - - def duplicate_image(image) - new_image = image.dup - new_image.assign_attributes(attachment: image.attachment.clone) - new_image - end - - def reset_properties - product.product_properties.map do |prop| - prop.dup.tap do |new_prop| - new_prop.created_at = nil - new_prop.updated_at = nil - end - end - end - end -end diff --git a/spec/factories/price_factory.rb b/spec/factories/price_factory.rb new file mode 100644 index 0000000000..fe84e5c8f5 --- /dev/null +++ b/spec/factories/price_factory.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :price, class: Spree::Price do + variant + amount 19.99 + currency 'USD' + end +end diff --git a/spec/lib/spree/core/product_duplicator_spec.rb b/spec/lib/spree/core/product_duplicator_spec.rb new file mode 100644 index 0000000000..4026c01c8f --- /dev/null +++ b/spec/lib/spree/core/product_duplicator_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Spree::Core::ProductDuplicator do + let(:product) do + double 'Product', + name: "foo", + taxons: [], + product_properties: [property], + master: variant, + variants?: false + end + + let(:new_product) do + double 'New Product', + save!: true + end + + let(:property) do + double 'Property' + end + + let(:new_property) do + double 'New Property' + end + + let(:variant) do + double 'Variant', + sku: "12345", + price: 19.99, + currency: "AUD", + images: [image] + end + + let(:new_variant) do + double 'New Variant', + sku: "12345" + end + + let(:image) do + double 'Image', + attachment: double('Attachment') + end + + let(:new_image) do + double 'New Image' + end + + before do + expect(product).to receive(:dup).and_return(new_product) + expect(variant).to receive(:dup).and_return(new_variant) + expect(image).to receive(:dup).and_return(new_image) + expect(property).to receive(:dup).and_return(new_property) + end + + it "can duplicate a product" do + duplicator = Spree::Core::ProductDuplicator.new(product) + expect(new_product).to receive(:name=).with("COPY OF foo") + expect(new_product).to receive(:taxons=).with([]) + expect(new_product).to receive(:product_properties=).with([new_property]) + expect(new_product).to receive(:created_at=).with(nil) + expect(new_product).to receive(:updated_at=).with(nil) + expect(new_product).to receive(:deleted_at=).with(nil) + expect(new_product).to receive(:master=).with(new_variant) + + expect(new_variant).to receive(:sku=).with("") + expect(new_variant).to receive(:deleted_at=).with(nil) + expect(new_variant).to receive(:images=).with([new_image]) + expect(new_variant).to receive(:price=).with(variant.price) + expect(new_variant).to receive(:currency=).with(variant.currency) + + expect(image.attachment).to receive(:clone).and_return(image.attachment) + + expect(new_image).to receive(:assign_attributes). + with(attachment: image.attachment). + and_return(new_image) + + expect(new_property).to receive(:created_at=).with(nil) + expect(new_property).to receive(:updated_at=).with(nil) + + duplicator.duplicate + end +end diff --git a/spec/lib/spree/product_duplicator_spec.rb b/spec/lib/spree/product_duplicator_spec.rb deleted file mode 100644 index 7978465a5e..0000000000 --- a/spec/lib/spree/product_duplicator_spec.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Spree - describe Spree::ProductDuplicator do - let(:product) do - double 'Product', - name: "foo", - taxons: [], - product_properties: [property], - master: variant, - has_variants?: false - end - - let(:new_product) do - double 'New Product', - save!: true - end - - let(:property) do - double 'Property' - end - - let(:new_property) do - double 'New Property' - end - - let(:variant) do - double 'Variant', - sku: "12345", - price: 19.99, - currency: "AUD", - images: [image] - end - - let(:new_variant) do - double 'New Variant', - sku: "12345" - end - - let(:image) do - double 'Image', - attachment: double('Attachment') - end - - let(:new_image) do - double 'New Image' - end - - before do - expect(product).to receive(:dup).and_return(new_product) - expect(variant).to receive(:dup).and_return(new_variant) - expect(image).to receive(:dup).and_return(new_image) - expect(property).to receive(:dup).and_return(new_property) - end - - it "can duplicate a product" do - duplicator = Spree::ProductDuplicator.new(product) - expect(new_product).to receive(:name=).with("COPY OF foo") - expect(new_product).to receive(:taxons=).with([]) - expect(new_product).to receive(:product_properties=).with([new_property]) - expect(new_product).to receive(:created_at=).with(nil) - expect(new_product).to receive(:updated_at=).with(nil) - expect(new_product).to receive(:deleted_at=).with(nil) - expect(new_product).to receive(:master=).with(new_variant) - - expect(new_variant).to receive(:sku=).with("COPY OF 12345") - expect(new_variant).to receive(:deleted_at=).with(nil) - expect(new_variant).to receive(:images=).with([new_image]) - expect(new_variant).to receive(:price=).with(variant.price) - expect(new_variant).to receive(:currency=).with(variant.currency) - - expect(image.attachment).to receive(:clone).and_return(image.attachment) - - expect(new_image).to receive(:assign_attributes). - with(attachment: image.attachment). - and_return(new_image) - - expect(new_property).to receive(:created_at=).with(nil) - expect(new_property).to receive(:updated_at=).with(nil) - - duplicator.duplicate - end - end -end diff --git a/spec/models/spree/product_property_spec.rb b/spec/models/spree/product_property_spec.rb new file mode 100644 index 0000000000..4109db924e --- /dev/null +++ b/spec/models/spree/product_property_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Spree::ProductProperty do + context "validations" do + it "should validate length of value" do + pp = create(:product_property) + pp.value = "x" * 256 + expect(pp).to_not be_valid + end + end +end diff --git a/spec/models/spree/product_spec.rb b/spec/models/spree/product_spec.rb index 6bae30417c..16c7006795 100644 --- a/spec/models/spree/product_spec.rb +++ b/spec/models/spree/product_spec.rb @@ -2,6 +2,236 @@ require 'spec_helper' module Spree describe Product do + context 'product instance' do + let(:product) { create(:product) } + + context '#duplicate' do + before do + allow(product).to receive_messages taxons: [create(:taxon)] + end + + it 'duplicates product' do + clone = product.duplicate + expect(clone.name).to eq 'COPY OF ' + product.name + expect(clone.master.sku).to eq '' + expect(clone.images.size).to eq product.images.size + end + end + + context "product has no variants" do + context "#destroy" do + it "should set deleted_at value" do + product.destroy + expect(product.deleted_at).to_not be_nil + expect(product.master.deleted_at).to_not be_nil + end + end + end + + context "product has variants" do + before do + create(:variant, product: product) + end + + context "#destroy" do + it "should set deleted_at value" do + product.destroy + expect(product.deleted_at).to_not be_nil + expect(product.variants_including_master.all? { |v| !v.deleted_at.nil? }).to be_truthy + end + end + end + + context "#price" do + # Regression test for Spree #1173 + it 'strips non-price characters' do + product.price = "$10" + expect(product.price).to eq 10.0 + end + end + + context "#display_price" do + before { product.price = 10.55 } + + context "with display_currency set to true" do + before { Spree::Config[:display_currency] = true } + + it "shows the currency" do + expect(product.display_price.to_s).to eq "$10.55 #{Spree::Config[:currency]}" + end + end + + context "with display_currency set to false" do + before { Spree::Config[:display_currency] = false } + + it "does not include the currency" do + expect(product.display_price.to_s).to eq "$10.55" + end + end + + context "with currency set to JPY" do + before do + product.master.default_price.currency = 'JPY' + product.master.default_price.save! + Spree::Config[:currency] = 'JPY' + end + + it "displays the currency in yen" do + expect(product.display_price.to_s).to eq "¥11" + end + end + end + + context "#available?" do + it "should be available if date is in the past" do + product.available_on = 1.day.ago + expect(product).to be_available + end + + it "should not be available if date is nil or in the future" do + product.available_on = nil + expect(product).to_not be_available + + product.available_on = 1.day.from_now + expect(product).to_not be_available + end + end + + describe 'Variants sorting' do + context 'without master variant' do + it 'sorts variants by position' do + expect(product.variants.to_sql).to match(/ORDER BY (\`|\")spree_variants(\`|\").position ASC/) + end + end + + context 'with master variant' do + it 'sorts variants by position' do + expect(product.variants_including_master.to_sql).to match(/ORDER BY (\`|\")spree_variants(\`|\").position ASC/) + end + end + end + end + + context "permalink" do + context "build product with similar name" do + let!(:other) { create(:product, name: 'foo bar') } + let(:product) { build(:product, name: 'foo') } + + before { product.valid? } + + it "increments name" do + expect(product.permalink).to eq 'foo-1' + end + end + + context "build permalink with quotes" do + it "does not save quotes" do + product = create(:product, name: "Joe's", permalink: "joe's") + expect(product.permalink).to eq "joe-s" + end + end + + context "permalinks must be unique" do + before do + @product1 = create(:product, name: 'foo') + end + + it "cannot create another product with the same permalink" do + pending '[Spree build] Failing spec' + @product2 = create(:product, name: 'foo') + expect do + @product2.update(permalink: @product1.permalink) + end.to raise_error(ActiveRecord::RecordNotUnique) + end + end + + it "supports Chinese" do + expect(create(:product, name: "你好").permalink).to eq "ni-hao" + end + + context "manual permalink override" do + let(:product) { create(:product, name: "foo") } + + it "calling save_permalink with a parameter" do + product.name = "foobar" + product.save + expect(product.permalink).to eq "foo" + + product.save_permalink(product.name) + expect(product.permalink).to eq "foobar" + end + end + + context "override permalink of deleted product" do + let(:product) { create(:product, name: "foo") } + + it "should create product with same permalink from name like deleted product" do + expect(product.permalink).to eq "foo" + product.destroy + + new_product = create(:product, name: "foo") + expect(new_product.permalink).to eq "foo" + end + end + end + + context "properties" do + let(:product) { create(:product) } + + it "should properly assign properties" do + product.set_property('the_prop', 'value1') + expect(product.property('the_prop')).to eq 'value1' + + product.set_property('the_prop', 'value2') + expect(product.property('the_prop')).to eq 'value2' + end + + it "should not create duplicate properties when set_property is called" do + expect { + product.set_property('the_prop', 'value2') + product.save + product.reload + }.not_to change(product.properties, :length) + + expect { + product.set_property('the_prop_new', 'value') + product.save + product.reload + expect(product.property('the_prop_new')).to eq 'value' + }.to change { product.properties.length }.by(1) + end + + # Regression test for #2455 + it "should not overwrite properties' presentation names" do + Spree::Property.where(name: 'foo').first_or_create!(presentation: "Foo's Presentation Name") + product.set_property('foo', 'value1') + product.set_property('bar', 'value2') + expect(Spree::Property.where(name: 'foo').first.presentation).to eq "Foo's Presentation Name" + expect(Spree::Property.where(name: 'bar').first.presentation).to eq "bar" + end + end + + # Regression tests for Spree #2352 + context "classifications and taxons" do + it "is joined through classifications" do + reflection = Spree::Product.reflect_on_association(:taxons) + reflection.options[:through] = :classifications + end + + it "will delete all classifications" do + reflection = Spree::Product.reflect_on_association(:classifications) + reflection.options[:dependent] = :delete_all + end + end + + describe '#total_on_hand' do + it 'returns sum of stock items count_on_hand' do + product = build(:product) + allow(product).to receive_messages stock_items: [double(Spree::StockItem, count_on_hand: 5)] + expect(product.total_on_hand).to eql(5) + end + end + describe "associations" do it { is_expected.to belong_to(:supplier) } it { is_expected.to belong_to(:primary_taxon) } diff --git a/spec/models/spree/variant_spec.rb b/spec/models/spree/variant_spec.rb index 3b68572281..333b039f39 100644 --- a/spec/models/spree/variant_spec.rb +++ b/spec/models/spree/variant_spec.rb @@ -1,13 +1,282 @@ require 'spec_helper' require 'variant_units/option_value_namer' +require 'spree/localized_number' module Spree describe Variant do - describe "double loading" do - # app/models/spree/variant_decorator.rb may be double-loaded in delayed job environment, - # so we need to be able to do so without error. - it "succeeds without error" do - load "#{Rails.root}/app/models/spree/variant_decorator.rb" + let!(:variant) { create(:variant) } + + context "validations" do + it "should validate price is greater than 0" do + variant.price = -1 + expect(variant).to be_invalid + end + + it "should validate price is 0" do + variant.price = 0 + expect(variant).to be_valid + end + end + + context "product has other variants" do + describe "option value accessors" do + before { + @multi_variant = create(:variant, product: variant.product) + variant.product.reload + } + + let(:multi_variant) { @multi_variant } + + it "should set option value" do + expect(multi_variant.option_value('media_type')).to be_nil + + multi_variant.set_option_value('media_type', 'DVD') + expect(multi_variant.option_value('media_type')).to eq 'DVD' + + multi_variant.set_option_value('media_type', 'CD') + expect(multi_variant.option_value('media_type')).to eq 'CD' + end + + it "should not duplicate associated option values when set multiple times" do + multi_variant.set_option_value('media_type', 'CD') + + expect { + multi_variant.set_option_value('media_type', 'DVD') + }.to_not change(multi_variant.option_values, :count) + + expect { + multi_variant.set_option_value('coolness_type', 'awesome') + }.to change(multi_variant.option_values, :count).by(1) + end + end + + context "product has other variants" do + describe "option value accessors" do + before { + @multi_variant = create(:variant, product: variant.product) + variant.product.reload + } + + let(:multi_variant) { @multi_variant } + + it "should set option value" do + expect(multi_variant.option_value('media_type')).to be_nil + + multi_variant.set_option_value('media_type', 'DVD') + expect(multi_variant.option_value('media_type')).to eq 'DVD' + + multi_variant.set_option_value('media_type', 'CD') + expect(multi_variant.option_value('media_type')).to eq 'CD' + end + + it "should not duplicate associated option values when set multiple times" do + multi_variant.set_option_value('media_type', 'CD') + + expect { + multi_variant.set_option_value('media_type', 'DVD') + }.to_not change(multi_variant.option_values, :count) + + expect { + multi_variant.set_option_value('coolness_type', 'awesome') + }.to change(multi_variant.option_values, :count).by(1) + end + end + end + end + + context "price parsing" do + before(:each) do + I18n.locale = I18n.default_locale + I18n.backend.store_translations(:de, { number: { currency: { format: { delimiter: '.', separator: ',' } } } }) + end + + after do + I18n.locale = I18n.default_locale + end + + context "price=" do + context "with decimal point" do + it "captures the proper amount for a formatted price" do + variant.price = '1,599.99' + expect(variant.price).to eq 1599.99 + end + end + + context "with decimal comma" do + it "captures the proper amount for a formatted price" do + I18n.locale = :es + variant.price = '1.599,99' + expect(variant.price).to eq 1599.99 + end + end + + context "with a numeric price" do + it "uses the price as is" do + I18n.locale = :es + variant.price = 1599.99 + expect(variant.price).to eq 1599.99 + end + end + end + end + + context "#currency" do + it "returns the globally configured currency" do + expect(variant.currency).to eq Spree::Config[:currency] + end + end + + context "#display_amount" do + it "returns a Spree::Money" do + variant.price = 21.22 + expect(variant.display_amount.to_s).to eq "$21.22" + end + end + + context "#cost_currency" do + context "when cost currency is nil" do + before { variant.cost_currency = nil } + + it "populates cost currency with the default value on save" do + variant.save! + expect(variant.cost_currency).to eq Spree::Config[:currency] + end + end + end + + describe '.price_in' do + before do + variant.prices << create(:price, variant: variant, currency: "EUR", amount: 33.33) + end + subject { variant.price_in(currency).display_amount } + + context "when currency is not specified" do + let(:currency) { nil } + + it "returns 0" do + expect(subject.to_s).to eq "$0.00" + end + end + + context "when currency is EUR" do + let(:currency) { 'EUR' } + + it "returns the value in EUR" do + expect(subject.to_s).to eq "€33.33" + end + end + + context "when currency is AUD" do + let(:currency) { 'AUD' } + + it "returns the value in AUD" do + expect(subject.to_s).to eq "$19.99" + end + end + end + + describe '.amount_in' do + before do + variant.prices << create(:price, variant: variant, currency: "EUR", amount: 33.33) + end + + subject { variant.amount_in(currency) } + + context "when currency is not specified" do + let(:currency) { nil } + + it "returns nil" do + expect(subject).to be_nil + end + end + + context "when currency is EUR" do + let(:currency) { 'EUR' } + + it "returns the value in EUR" do + expect(subject).to eq 33.33 + end + end + + context "when currency is AUD" do + let(:currency) { 'AUD' } + + it "returns the value in AUD" do + expect(subject).to eq 19.99 + end + end + end + + # Regression test for #2744 + describe "set_position" do + it "sets variant position after creation" do + variant = create(:variant) + expect(variant.position).to_not be_nil + end + end + + describe '#in_stock?' do + context 'when stock_items are not backorderable' do + before do + allow_any_instance_of(Spree::StockItem).to receive_messages(backorderable: false) + end + + context 'when stock_items in stock' do + before do + allow_any_instance_of(Spree::StockItem).to receive_messages(count_on_hand: 10) + end + + it 'returns true if stock_items in stock' do + expect(variant.in_stock?).to be_truthy + end + end + + context 'when stock_items out of stock' do + before do + allow_any_instance_of(Spree::StockItem).to receive_messages(backorderable: false) + allow_any_instance_of(Spree::StockItem).to receive_messages(count_on_hand: 0) + end + + it 'return false if stock_items out of stock' do + expect(variant.in_stock?).to be_falsy + end + end + + context 'when providing quantity param' do + before do + variant.stock_items.first.update_attribute(:count_on_hand, 10) + end + + it 'returns correct value' do + expect(variant.in_stock?).to be_truthy + expect(variant.in_stock?(2)).to be_truthy + expect(variant.in_stock?(10)).to be_truthy + expect(variant.in_stock?(11)).to be_falsy + end + end + end + + context 'when stock_items are backorderable' do + before do + allow_any_instance_of(Spree::StockItem).to receive_messages(backorderable?: true) + end + + context 'when stock_items out of stock' do + before do + allow_any_instance_of(Spree::StockItem).to receive_messages(count_on_hand: 0) + end + + it 'returns true if stock_items in stock' do + expect(variant.in_stock?).to be_truthy + end + end + end + end + + describe '#total_on_hand' do + it 'matches quantifier total_on_hand' do + variant = build(:variant) + expect(variant.total_on_hand).to eq(Spree::Stock::Quantifier.new(variant).total_on_hand) end end