Files
openfoodnetwork/app/models/spree/variant.rb
David Cook c165ade4ba Avoid unnecessary save
Actually, the variant factory is still adding an extra save. We should refactor Variant to avoid that.. but the afternoon slump has got me.
2026-03-11 11:09:12 +11:00

359 lines
13 KiB
Ruby

# frozen_string_literal: true
require 'open_food_network/enterprise_fee_calculator'
require 'spree/localized_number'
module Spree
class Variant < ApplicationRecord
extend Spree::LocalizedNumber
include VariantUnits::VariantAndLineItemNaming
include VariantStock
self.belongs_to_required_by_default = false
# 2 not to be persisted attributes to store preferences.
# Values to be set via the UI that can be passed by back to UI
# in a not yet persisted variant. Setters are below.
attr_reader :on_hand_desired, :on_demand_desired
acts_as_paranoid
acts_as_taggable
searchable_attributes :sku, :display_as, :display_name, :primary_taxon_id, :supplier_id
searchable_associations :product, :default_price, :primary_taxon, :supplier, :tags
searchable_scopes :active, :deleted
NAME_FIELDS = ["display_name", "display_as", "weight", "unit_value", "unit_description"].freeze
SEARCH_KEY = "#{%w(name
meta_keywords
variants_display_as
variants_display_name
variants_supplier_name).join('_or_')}_cont".freeze
belongs_to :product, -> {
with_deleted
}, touch: true, class_name: 'Spree::Product', optional: false,
inverse_of: :variants
belongs_to :tax_category, class_name: 'Spree::TaxCategory'
belongs_to :shipping_category, class_name: 'Spree::ShippingCategory', optional: false
belongs_to :primary_taxon, class_name: 'Spree::Taxon', touch: true, optional: false
belongs_to :supplier, class_name: 'Enterprise', optional: false, touch: true
belongs_to :owner, class_name: 'Enterprise', optional: true
delegate :name, :name=, :description, :description=, :meta_keywords, to: :product
has_many :inventory_units, inverse_of: :variant, dependent: nil
has_many :line_items, inverse_of: :variant, dependent: nil
has_many :stock_items, dependent: :destroy, inverse_of: :variant
has_many :images, -> { order(:position) }, as: :viewable,
dependent: :destroy,
class_name: "Spree::Image",
inverse_of: :viewable
accepts_nested_attributes_for :images
has_one :default_price,
-> { with_deleted.where(currency: CurrentConfig.get(:currency)) },
class_name: 'Spree::Price',
inverse_of: :variant,
dependent: :destroy
has_many :prices,
class_name: 'Spree::Price',
dependent: :destroy
delegate :display_price, :display_amount, :price, :price=,
:currency, :currency=,
to: :find_or_build_default_price
has_many :exchange_variants, dependent: nil
has_many :exchanges, through: :exchange_variants
has_many :variant_overrides, dependent: :destroy
has_many :inventory_items, dependent: :destroy
has_many :semantic_links, as: :subject, dependent: :delete_all
has_many :supplier_properties, through: :supplier, source: :properties
# Linked variants: I may have one or many sources.
has_many :variant_links_as_target, class_name: 'VariantLink', foreign_key: :target_variant_id,
dependent: :delete_all, inverse_of: :target_variant
has_many :source_variants, through: :variant_links_as_target, source: :source_variant
# I may also have one more many targets.
has_many :variant_links_as_source, class_name: 'VariantLink', foreign_key: :source_variant_id,
dependent: :delete_all, inverse_of: :source_variant
has_many :target_variants, through: :variant_links_as_source, source: :target_variant
localize_number :price, :weight
validates_lengths_from_database
validate :check_currency
validates :price, numericality: { greater_than_or_equal_to: 0 }, presence: true
validates :tax_category, presence: true,
if: proc { Spree::Config.products_require_tax_category }
validates :variant_unit, presence: true
validates :unit_value, presence: true, if: ->(variant) {
%w(weight volume).include?(variant.variant_unit)
}
validates :unit_value, numericality: { greater_than: 0 }, allow_blank: true
validates :unit_description, presence: true, if: ->(variant) {
variant.variant_unit.present? && variant.unit_value.nil?
}
validates :variant_unit_scale, presence: true, if: ->(variant) {
%w(weight volume).include?(variant.variant_unit)
}
validates :variant_unit_name, presence: true, if: ->(variant) {
variant.variant_unit == 'items'
}
before_validation :set_cost_currency
before_validation :ensure_shipping_category
before_validation :ensure_unit_value
before_validation :update_weight_from_unit_value
before_validation :convert_variant_weight_to_decimal
before_save :assign_units, if: ->(variant) {
variant.new_record? || variant.changed_attributes.keys.intersection(NAME_FIELDS).any?
}
after_create :create_stock_items
around_destroy :destruction
after_save :save_default_price
after_save :update_units, if: -> {
saved_change_to_variant_unit? || saved_change_to_variant_unit_name?
}
# default variant scope only lists non-deleted variants
scope :deleted, -> { where.not(deleted_at: nil) }
scope :with_order_cycles_inner, -> { joins(exchanges: :order_cycle) }
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: 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|
enterprise_id = enterprise&.id.to_i
return none if enterprise_id < 1
joins("
LEFT OUTER JOIN (SELECT *
FROM inventory_items
WHERE enterprise_id = #{enterprise_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 :with_properties, lambda { |property_ids|
left_outer_joins(:supplier_properties).
where(producer_properties: { property_id: property_ids })
}
# 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
where(nil).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: joins(:prices).
where(deleted_at: nil).
where('spree_prices.currency' =>
currency || CurrentConfig.get(:currency)).
where.not(spree_prices: { amount: nil }).
select("spree_variants.id") })
end
def self.linked_to(semantic_id)
includes(:semantic_links).references(:semantic_links)
.where(semantic_links: { semantic_id: }).first
end
def tax_category
super || TaxCategory.find_by(is_default: true)
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
def fees_name_by_type_for(distributor, order_cycle)
OpenFoodNetwork::EnterpriseFeeCalculator.new(distributor,
order_cycle).fees_name_by_type_for self
end
def price_in(currency)
prices.select{ |price| price.currency == currency }.first ||
Spree::Price.new(variant_id: id, currency:)
end
def amount_in(currency)
price_in(currency).try(:amount)
end
def changed?
# We consider the variant changed if associated price is changed (it is saved after_save)
super || default_price.changed?
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
# Format as per WeightsAndMeasures
def variant_unit_with_scale
# Our code is based upon English based number formatting with a period `.`
scale_clean = ActiveSupport::NumberHelper.number_to_rounded(variant_unit_scale,
precision: nil,
significant: false,
strip_insignificant_zeros: true,
locale: :en)
[variant_unit, scale_clean].compact_blank.join("_")
end
def variant_unit_with_scale=(variant_unit_with_scale)
values = variant_unit_with_scale.split("_")
assign_attributes(
variant_unit: values[0],
variant_unit_scale: values[1] || nil
)
end
# aiming to deal with UI that deals with 0/"0"/1/"1"
def on_demand_desired=(val)
@on_demand_desired = ActiveModel::Type::Boolean.new.cast(val)
end
def on_hand_desired=(val)
@on_hand_desired = ActiveModel::Type::Integer.new.cast(val)
end
# Clone this variant, retaining a 'source' link to it
def create_sourced_variant(user)
# Owner is my enterprise which has permission to create sourced variants from that supplier
owner_id = EnterpriseRelationship.permitted_by(supplier).permitting(user.enterprises)
.with_permission(:create_sourced_variants)
.pick(:child_id)
dup.tap do |variant|
variant.price = price
variant.source_variants = [self]
variant.stock_items << Spree::StockItem.new(variant:)
variant.owner_id = owner_id
variant.on_demand = on_demand
variant.on_hand = on_hand
variant.save!
end
end
private
def check_currency
return unless currency.nil?
self.currency = CurrentConfig.get(:currency)
end
def save_default_price
default_price.save if default_price && (default_price.changed? || default_price.new_record?)
end
def find_or_build_default_price
default_price || build_default_price
end
def set_cost_currency
self.cost_currency = CurrentConfig.get(:currency) if cost_currency.blank?
end
def create_stock_items
return unless stock_items.empty?
stock_items.create!
end
def update_weight_from_unit_value
return unless variant_unit == 'weight' && unit_value.present?
self.weight = weight_from_unit_value
end
def destruction
transaction do
# Even tough Enterprise will touch associated variant distributors when touched,
# the variant will be removed from the exchange by the time it's triggered,
# so it won't be able to find the deleted variant's distributors.
# This why we do it here
touch_distributors
exchange_variants.reload.destroy_all
yield
end
end
def ensure_unit_value
Alert.raise("Trying to set unit_value to NaN") if unit_value&.nan?
return unless (variant_unit == "items" && unit_value.nil?) || unit_value&.nan?
self.unit_value = 1.0
end
def ensure_shipping_category
self.shipping_category ||= DefaultShippingCategory.find_or_create
end
def convert_variant_weight_to_decimal
self.weight = weight.to_d
end
def touch_distributors
Enterprise.distributing_variants(id).each(&:touch)
end
end
end