Merge pull request #5885 from luisramos0/variants

[Bye bye Spree] Bring models product, variant, price from spree_core
This commit is contained in:
Luis Ramos
2020-11-04 21:26:07 +00:00
committed by GitHub
23 changed files with 1593 additions and 601 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +0,0 @@
module Spree
OptionType.class_eval do
has_many :products, through: :product_option_types
end
end

View File

@@ -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

57
app/models/spree/price.rb Normal file
View File

@@ -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

View File

@@ -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

487
app/models/spree/product.rb Executable file
View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +0,0 @@
module Spree
ProductProperty.class_eval do
belongs_to :product, class_name: "Spree::Product", touch: true
end
end

301
app/models/spree/variant.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
FactoryBot.define do
factory :price, class: Spree::Price do
variant
amount 19.99
currency 'USD'
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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) }

View File

@@ -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