mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-03-21 05:09:15 +00:00
Actually, the variant factory is still adding an extra save. We should refactor Variant to avoid that.. but the afternoon slump has got me.
359 lines
13 KiB
Ruby
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
|