Files
openfoodnetwork/app/models/spree/product.rb

308 lines
11 KiB
Ruby
Executable File

# frozen_string_literal: true
require 'open_food_network/property_merge'
# PRODUCTS
# Products represent an entity for sale in a store.
# Products can have variations, called variants
# Products properties include description, meta_keywork, etc. that do not change by variant.
#
# VARIANTS
# Every product has at least one variant (standard variant), which stores price and availability,
# shipping category, sku, size and weight, etc.
# All variants can access the product name, description, and meta_keyword directly (via reverse
# delegation).
# Inventory units are tied to Variant.
# All 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 < ApplicationRecord
include LogDestroyPerformer
include ProductSortByStocks
self.belongs_to_required_by_default = false
# These columns have been moved to variant. Currently this is only for documentation purposes,
# because they are declared as attr_accessor below, declaring them as ignored columns has no
# effect
self.ignored_columns += [
:supplier_id, :primary_taxon_id, :variant_unit, :variant_unit_scale, :variant_unit_name
]
acts_as_paranoid
searchable_attributes :meta_keywords, :sku, :on_hand, :backorderable_priority,
:backorderable_name
searchable_associations :properties, :variants
searchable_scopes :active, :with_properties
has_one :image, class_name: "Spree::Image", as: :viewable, dependent: :destroy
has_one :semantic_link, as: :subject, dependent: :delete
has_many :product_properties, dependent: :destroy
has_many :properties, through: :product_properties
has_many :variants, -> { order("spree_variants.id ASC") }, class_name: 'Spree::Variant',
inverse_of: :product,
dependent: :destroy
has_many :prices, -> { order('spree_variants.id, currency') }, through: :variants
has_many :stock_items, through: :variants
has_many :variant_images, -> { order(:position) }, source: :images,
through: :variants
validates_lengths_from_database
validates :name, presence: true
validate :validate_image
validates :price, numericality: { greater_than_or_equal_to: 0, if: ->{ new_record? } }
# These validators are used to make sure the standard variant created via
# `ensure_standard_variant` will be valid. The are only used when creating a new product
with_options on: :create_and_create_standard_variant do
validates :supplier_id, presence: true
validates :primary_taxon_id, presence: true
validates :variant_unit, presence: true
validates :unit_value, presence: true, if: ->(product) {
%w(weight volume).include?(product.variant_unit)
}
validates :unit_value, numericality: { greater_than: 0 }, allow_blank: true
validates :unit_description, presence: true, if: ->(product) {
product.variant_unit.present? && product.unit_value.nil?
}
validates :variant_unit_scale, presence: true, if: ->(product) {
%w(weight volume).include?(product.variant_unit)
}
validates :variant_unit_name, presence: true, if: ->(product) {
product.variant_unit == 'items'
}
end
accepts_nested_attributes_for :image
accepts_nested_attributes_for :product_properties,
allow_destroy: true,
reject_if: ->(pp) { pp[:property_name].blank? }
# Transient attributes used temporarily when creating a new product,
# these values are persisted on the product's variant
attr_accessor :price, :display_as, :unit_value, :unit_description, :variant_unit,
:variant_unit_name, :variant_unit_scale, :tax_category_id, :shipping_category_id,
:primary_taxon_id, :supplier_id
after_create :ensure_standard_variant
around_destroy :destruction
after_touch :touch_supplier
# -- Scopes
scope :with_properties, ->(*property_ids) {
left_outer_joins(:product_properties).
where(inherits_properties: true).
where(spree_product_properties: { property_id: property_ids })
}
scope :with_order_cycles_outer, lambda {
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.all_day))
}
scope :with_order_cycles_inner, -> { joins(variants: { 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).
distinct
}
scope :in_supplier, lambda { |supplier|
distinct.joins(:variants).where(spree_variants: { 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).
distinct
}
# 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.not(order_cycles: { id: nil })
}
scope :by_producer, -> { joins(variants: :supplier).order('enterprises.name') }
scope :by_name, -> { order('spree_products.name') }
scope :managed_by, lambda { |user|
if user.admin?
where(nil)
else
in_supplier(user.enterprises)
end
}
scope :active, lambda { where(spree_products: { deleted_at: nil }) }
def self.group_by_products_id
group(column_names.map { |col_name| "#{table_name}.#{col_name}" })
end
# for adding products which are closely related to existing ones
def duplicate
duplicator = Spree::Core::ProductDuplicator.new(self)
duplicator.duplicate
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 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:).first_or_initialize
product_property.value = property_value
product_property.save!
end
end
def properties_including_inherited
# Product properties override producer properties
ps = product_properties.all
if inherits_properties
# NOTE: Set the supplier as the first variant supplier. If variants have different supplier,
# result might not be correct
supplier = variants.first.supplier
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 destruction
transaction do
ExchangeVariant.
where(exchange_variants: { variant_id: variants.with_deleted.
select(:id) }).destroy_all
yield
end
end
# rubocop:disable Metrics/AbcSize
def ensure_standard_variant
return unless variants.empty?
variant = Spree::Variant.new
variant.product = self
variant.price = price
variant.display_as = display_as
variant.unit_value = unit_value
variant.unit_description = unit_description
variant.variant_unit = variant_unit
variant.variant_unit_name = variant_unit_name
variant.variant_unit_scale = variant_unit_scale
variant.tax_category_id = tax_category_id
variant.shipping_category_id = shipping_category_id
variant.primary_taxon_id = primary_taxon_id
variant.supplier_id = supplier_id
variants << variant
end
# rubocop:enable Metrics/AbcSize
# Remove any unsupported HTML.
def description=(html)
super(HtmlSanitizer.sanitize(html))
end
private
def touch_supplier
return if variants.empty?
# Assume the product supplier is the supplier of the first variant
# Will breack if product has mutiple variants with different supplier
first_variant = variants.first
# The variant is invalid if no supplier is present, but this method can be triggered when
# importing product. In this scenario the variant has not been updated with the supplier yet
# hence the check.
first_variant.supplier.touch if first_variant.supplier.present?
end
def validate_image
return if image.blank? || !image.changed? || image.valid?
errors.add(:base, I18n.t('spree.admin.products.image_not_processable'))
end
end
end