core/app/models/spree/product.rb
# 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 < Spree::Base
extend FriendlyId
include ProductScopes
include MultiStoreResource
include TranslatableResource
include TranslatableResourceSlug
include MemoizedData
include Metadata
if defined?(Spree::Webhooks::HasWebhooks)
include Spree::Webhooks::HasWebhooks
end
if defined?(Spree::VendorConcern)
include Spree::VendorConcern
end
MEMOIZED_METHODS = %w[total_on_hand taxonomy_ids taxon_and_ancestors category
default_variant_id tax_category default_variant
purchasable? in_stock? backorderable?]
TRANSLATABLE_FIELDS = %i[name description slug meta_description meta_keywords meta_title].freeze
translates(*TRANSLATABLE_FIELDS)
self::Translation.class_eval do
before_save :set_slug
acts_as_paranoid
# deleted translation values also need to be accessible for index views listing deleted resources
default_scope { unscope(where: :deleted_at) }
def set_slug
self.slug = generate_slug
end
private
def generate_slug
if name.blank? && slug.blank?
translated_model.name.to_url
elsif slug.blank?
name.to_url
else
slug.to_url
end
end
end
friendly_id :slug_candidates, use: [:history, :mobility]
acts_as_paranoid
auto_strip_attributes :name
# we need to have this callback before any dependent: :destroy associations
# https://github.com/rails/rails/issues/3458
before_destroy :ensure_not_in_complete_orders
has_many :product_option_types, dependent: :destroy, inverse_of: :product
has_many :option_types, through: :product_option_types
has_many :product_properties, dependent: :destroy, inverse_of: :product
has_many :properties, through: :product_properties
has_many :menu_items, as: :linked_resource
has_many :classifications, dependent: :delete_all, inverse_of: :product
has_many :taxons, through: :classifications, before_remove: :remove_taxon
has_many :product_promotion_rules, class_name: 'Spree::ProductPromotionRule'
has_many :promotion_rules, through: :product_promotion_rules, class_name: 'Spree::PromotionRule'
has_many :promotions, through: :promotion_rules, class_name: 'Spree::Promotion'
has_many :possible_promotions, -> { advertised.active }, through: :promotion_rules,
class_name: 'Spree::Promotion',
source: :promotion
belongs_to :tax_category, class_name: 'Spree::TaxCategory'
belongs_to :shipping_category, class_name: 'Spree::ShippingCategory', inverse_of: :products
has_one :master,
-> { where is_master: true },
inverse_of: :product,
class_name: 'Spree::Variant'
has_many :variants,
-> { where(is_master: false).order(:position) },
inverse_of: :product,
class_name: 'Spree::Variant'
has_many :variants_including_master,
-> { order(:position) },
inverse_of: :product,
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_including_master
has_many :line_items, through: :variants_including_master
has_many :orders, through: :line_items
has_many :variant_images, -> { order(:position) }, source: :images, through: :variants_including_master
has_many :variant_images_without_master, -> { order(:position) }, source: :images, through: :variants
has_many :store_products, class_name: 'Spree::StoreProduct'
has_many :stores, through: :store_products, class_name: 'Spree::Store'
has_many :digitals, through: :variants_including_master
after_create :add_associations_from_prototype
after_create :build_variants_from_option_values_hash, if: :option_values_hash
after_destroy :punch_slug
after_restore :update_slug_history
after_initialize :ensure_master
after_save :save_master
after_save :run_touch_callbacks, if: :anything_changed?
after_save :reset_nested_changes
after_touch :touch_taxons
before_validation :downcase_slug
before_validation :normalize_slug, on: :update
before_validation :validate_master
with_options length: { maximum: 255 }, allow_blank: true do
validates :meta_keywords
validates :meta_title
end
with_options presence: true do
validates :name
validates :shipping_category, if: :requires_shipping_category?
validates :price, if: :requires_price?
end
validates :slug, presence: true, uniqueness: { allow_blank: true, case_sensitive: true, scope: spree_base_uniqueness_scope }
validate :discontinue_on_must_be_later_than_make_active_at, if: -> { make_active_at && discontinue_on }
scope :for_store, ->(store) { joins(:store_products).where(StoreProduct.table_name => { store_id: store.id }) }
attr_accessor :option_values_hash
accepts_nested_attributes_for :product_properties, allow_destroy: true, reject_if: ->(pp) { pp[:property_name].blank? }
alias options product_option_types
self.whitelisted_ransackable_associations = %w[taxons stores variants_including_master master variants]
self.whitelisted_ransackable_attributes = %w[description name slug discontinue_on status]
self.whitelisted_ransackable_scopes = %w[not_discontinued search_by_name in_taxon price_between]
[
:sku, :barcode, :price, :currency, :weight, :height, :width, :depth, :is_master,
:cost_currency, :price_in, :amount_in, :cost_price, :compare_at_price, :compare_at_amount_in
].each do |method_name|
delegate method_name, :"#{method_name}=", to: :find_or_build_master
end
delegate :display_amount, :display_price, :has_default_price?,
:display_compare_at_price, :images, to: :find_or_build_master
alias master_images images
state_machine :status, initial: :draft do
event :activate do
transition to: :active
end
after_transition to: :active, do: :after_activate
event :archive do
transition to: :archived
end
after_transition to: :archived, do: :after_archive
event :draft do
transition to: :draft
end
after_transition to: :draft, do: :after_draft
end
# Can't use short form block syntax due to https://github.com/Netflix/fast_jsonapi/issues/259
def purchasable?
default_variant.purchasable? || variants.any?(&:purchasable?)
end
# Can't use short form block syntax due to https://github.com/Netflix/fast_jsonapi/issues/259
def in_stock?
default_variant.in_stock? || variants.any?(&:in_stock?)
end
# Can't use short form block syntax due to https://github.com/Netflix/fast_jsonapi/issues/259
def backorderable?
default_variant.backorderable? || variants.any?(&:backorderable?)
end
def find_or_build_master
master || build_master
end
# the master variant is not a member of the variants array
def has_variants?
variants.any?
end
# Returns default Variant for Product
# If `track_inventory_levels` is enabled it will try to find the first Variant
# in stock or backorderable, if there's none it will return first Variant sorted
# by `position` attribute
# If `track_inventory_levels` is disabled it will return first Variant sorted
# by `position` attribute
#
# @return [Spree::Variant]
def default_variant
@default_variant ||= Rails.cache.fetch(default_variant_cache_key) do
if Spree::Config[:track_inventory_levels] && available_variant = variants.detect(&:purchasable?)
available_variant
else
has_variants? ? variants.first : master
end
end
end
# Returns default Variant ID for Product
# @return [Integer]
def default_variant_id
@default_variant_id ||= default_variant.id
end
def tax_category
@tax_category ||= super || TaxCategory.find_by(is_default: true)
end
# Adding properties and option types on creation based on a chosen prototype
attr_accessor :prototype_id
# 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?
required_option_type_ids = option_values_hash.keys.map(&:to_i)
missing_option_type_ids = required_option_type_ids - option_type_ids
missing_option_type_ids.each do |id|
product_option_types.create(option_type_id: id)
end
end
# for adding products which are closely related to existing ones
# define "duplicate_extra" for site-specific actions, eg for additional fields
def duplicate
duplicator = 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
# determine if product is available.
# deleted products and products with status different than active
# are not available
def available?
active? && !deleted?
end
def discontinue!
self.discontinue_on = Time.current
self.status = 'archived'
save(validate: false)
end
def discontinued?
!!discontinue_on && discontinue_on <= Time.current
end
# determine if any variant (including master) can be supplied
def can_supply?
variants_including_master.any?(&:can_supply?)
end
# determine if any variant (including master) is out of stock and backorderable
def backordered?
variants_including_master.any?(&:backordered?)
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)
conditions = fields.product(values).map do |(field, value)|
arel_table[field].matches("%#{value}%")
end
where conditions.inject(:or)
end
# Suitable for displaying only variants that has at least one option value.
# There may be scenarios where an option type is removed and along with it
# all option values. At that point all variants associated with only those
# values should not be displayed to frontend users. Otherwise it breaks the
# idea of having variants
def variants_and_option_values(current_currency = nil)
variants.active(current_currency).joins(:option_value_variants)
end
def empty_option_values?
options.empty? || options.any? do |opt|
opt.option_type.option_values.empty?
end
end
def property(property_name)
product_properties.joins(:property).
join_translation_table(Property).
find_by(Property.translation_table_alias => { name: property_name }).try(:value)
end
def set_property(property_name, property_value, property_presentation = property_name)
ApplicationRecord.transaction do
# Manual first_or_create to work around Mobility bug
property = if Property.where(name: property_name).exists?
Property.where(name: property_name).first
else
Property.create(name: property_name, presentation: property_presentation)
end
product_property = if ProductProperty.where(product: self, property: property).exists?
ProductProperty.where(product: self, property: property).first
else
ProductProperty.create(product: self, property: property)
end
product_property.value = property_value
product_property.save!
end
end
def total_on_hand
@total_on_hand ||= Rails.cache.fetch(['product-total-on-hand', cache_key_with_version]) do
if any_variants_not_track_inventory?
BigDecimal::INFINITY
else
stock_items.sum(:count_on_hand)
end
end
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 brand
@brand ||= taxons.joins(:taxonomy).
join_translation_table(Taxonomy).
find_by(Taxonomy.translation_table_alias => { name: Spree.t(:taxonomy_brands_name) })
end
def category
@category ||= taxons.joins(:taxonomy).
join_translation_table(Taxonomy).
order(depth: :desc).
find_by(Taxonomy.translation_table_alias => { name: Spree.t(:taxonomy_categories_name) })
end
def taxons_for_store(store)
Rails.cache.fetch("#{cache_key_with_version}/taxons-per-store/#{store.id}") do
taxons.for_store(store)
end
end
def any_variant_in_stock_or_backorderable?
if variants.any?
variants_including_master.in_stock_or_backorderable.exists?
else
master.in_stock_or_backorderable?
end
end
def digital?
shipping_category&.name == I18n.t('spree.seed.shipping.categories.digital')
end
private
def add_associations_from_prototype
if prototype_id && prototype = Spree::Prototype.find_by(id: prototype_id)
prototype.properties.each do |property|
product_properties.create(property: property, value: 'Placeholder')
end
self.option_types = prototype.option_types
self.taxons = prototype.taxons
end
end
def any_variants_not_track_inventory?
return true unless Spree::Config.track_inventory_levels
if variants_including_master.loaded?
variants_including_master.any? { |v| !v.track_inventory? }
else
variants_including_master.where(track_inventory: false).exists?
end
end
# 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
def default_variant_cache_key
"spree/default-variant/#{cache_key_with_version}/#{Spree::Config[:track_inventory_levels]}"
end
def ensure_master
return unless new_record?
self.master ||= build_master
end
def normalize_slug
self.slug = normalize_friendly_id(slug)
end
def punch_slug
# punch slug with date prefix to allow reuse of original
return if frozen?
translations.with_deleted.each do |t|
t.update_column :slug, "#{Time.current.to_i}_#{t.slug}"[0..254]
end
end
def update_slug_history
save!
end
def anything_changed?
saved_changes? || @nested_changes
end
def reset_nested_changes
@nested_changes = false
end
def master_updated?
master && (
master.new_record? ||
master.changed? ||
(
master.default_price &&
(
master.default_price.new_record? ||
master.default_price.changed?
)
)
)
end
# there's a weird quirk with the delegate stuff that does not automatically save the delegate object
# when saving so we force a save using a hook
# Fix for issue #5306
def save_master
if master_updated?
master.save!
@nested_changes = true
end
end
# If the master cannot be saved, the Product object will get its errors
# and will be destroyed
def validate_master
# We call master.default_price here to ensure price is initialized.
# Required to avoid Variant#check_price validation failing on create.
unless master.default_price && master.valid?
master.errors.map { |error| { field: error.attribute, message: error&.message } }.each do |err|
next if err[:field].blank? || err[:message].blank?
errors.add(err[:field], err[:message])
end
end
end
# Try building a slug based on the following fields in increasing order of specificity.
def slug_candidates
[
:name,
[:name, :sku]
]
end
def run_touch_callbacks
run_callbacks(:touch)
end
def taxon_and_ancestors
@taxon_and_ancestors ||= taxons.map(&:self_and_ancestors).flatten.uniq
end
# Get the taxonomy ids of all taxons assigned to this product and their ancestors.
def taxonomy_ids
@taxonomy_ids ||= taxon_and_ancestors.map(&:taxonomy_id).flatten.uniq
end
# Iterate through this products taxons and taxonomies and touch their timestamps in a batch
def touch_taxons
Spree::Taxon.where(id: taxon_and_ancestors.map(&:id)).update_all(updated_at: Time.current)
Spree::Taxonomy.where(id: taxonomy_ids).update_all(updated_at: Time.current)
end
def ensure_not_in_complete_orders
if orders.complete.any?
errors.add(:base, :cannot_destroy_if_attached_to_line_items)
throw(:abort)
end
end
def remove_taxon(taxon)
removed_classifications = classifications.where(taxon: taxon)
removed_classifications.each &:remove_from_list
end
def discontinue_on_must_be_later_than_make_active_at
if discontinue_on < make_active_at
errors.add(:discontinue_on, :invalid_date_range)
end
end
def requires_price?
Spree::Config[:require_master_price]
end
def requires_shipping_category?
true
end
def downcase_slug
slug&.downcase!
end
def after_activate
# this method is prepended in api/ to queue Webhooks requests
end
def after_archive
# this method is prepended in api/ to queue Webhooks requests
end
def after_draft
# this method is prepended in api/ to queue Webhooks requests
end
end
end