Files
openfoodnetwork/app/models/spree/product.rb
Maikel Linke 516759062f Import variants for the same product group
When importing another catalog, it's probably referring to external
product groups. Storing the external link allows us to group several
variants and replicate the same structure within OFN.
2025-02-18 13:08:48 +11:00

306 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 ProductStock
include LogDestroyPerformer
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
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',
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