mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-01-24 20:36:49 +00:00
Merge pull request #5885 from luisramos0/variants
[Bye bye Spree] Bring models product, variant, price from spree_core
This commit is contained in:
@@ -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
|
||||
|
||||
18
app/models/spree/option_type.rb
Normal file
18
app/models/spree/option_type.rb
Normal 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
|
||||
@@ -1,5 +0,0 @@
|
||||
module Spree
|
||||
OptionType.class_eval do
|
||||
has_many :products, through: :product_option_types
|
||||
end
|
||||
end
|
||||
12
app/models/spree/option_value.rb
Normal file
12
app/models/spree/option_value.rb
Normal 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
57
app/models/spree/price.rb
Normal 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
|
||||
@@ -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
487
app/models/spree/product.rb
Executable 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'
|
||||
@@ -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
|
||||
18
app/models/spree/product_option_type.rb
Normal file
18
app/models/spree/product_option_type.rb
Normal 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
|
||||
@@ -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
|
||||
27
app/models/spree/product_property.rb
Normal file
27
app/models/spree/product_property.rb
Normal 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
|
||||
@@ -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
301
app/models/spree/variant.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
63
lib/spree/core/product_duplicator.rb
Normal file
63
lib/spree/core/product_duplicator.rb
Normal 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
|
||||
@@ -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
|
||||
7
spec/factories/price_factory.rb
Normal file
7
spec/factories/price_factory.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
FactoryBot.define do
|
||||
factory :price, class: Spree::Price do
|
||||
variant
|
||||
amount 19.99
|
||||
currency 'USD'
|
||||
end
|
||||
end
|
||||
84
spec/lib/spree/core/product_duplicator_spec.rb
Normal file
84
spec/lib/spree/core/product_duplicator_spec.rb
Normal 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
|
||||
@@ -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
|
||||
13
spec/models/spree/product_property_spec.rb
Normal file
13
spec/models/spree/product_property_spec.rb
Normal 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
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user