Files
openfoodnetwork/app/models/spree/line_item.rb
Gaetan Craig-Riou b7f969eed9 Move the inventory feature check to ScopeVariantToHub
Per review, the check is done on the same enterprise as the one use to
initialize ScopeVariantToHub. So it makes sense to move the actual
feature check to ScopeVariantToHub#scope
2025-07-09 13:43:12 +10:00

299 lines
8.6 KiB
Ruby

# frozen_string_literal: true
require 'open_food_network/scope_variant_to_hub'
module Spree
class LineItem < ApplicationRecord
include VariantUnits::VariantAndLineItemNaming
searchable_attributes :price, :quantity, :order_id, :variant_id, :tax_category_id
searchable_associations :order, :order_cycle, :variant, :product, :supplier, :tax_category
searchable_scopes :with_tax, :without_tax
belongs_to :order, class_name: "Spree::Order", inverse_of: :line_items
has_one :order_cycle, through: :order
belongs_to :variant, -> { with_deleted }, class_name: "Spree::Variant", inverse_of: :line_items
has_one :product, through: :variant
has_one :supplier, through: :variant
belongs_to :tax_category, class_name: "Spree::TaxCategory", optional: true
has_many :adjustments, as: :adjustable, dependent: :destroy
before_validation :adjust_quantity
before_validation :copy_price
before_validation :copy_tax_category
before_validation :copy_dimensions
validates :quantity, numericality: {
only_integer: true,
greater_than: -1,
message: Spree.t('validation.must_be_int')
}
validates :price, numericality: true
validates_with Stock::AvailabilityValidator
before_save :update_inventory
before_save :calculate_final_weight_volume, if: :quantity_changed?,
unless: :final_weight_volume_changed?
before_save :assign_units, if: ->(line_item) {
line_item.new_record? || line_item.final_weight_volume_changed?
}
before_destroy :update_inventory_before_destroy
after_destroy :update_order
after_save :update_order
delegate :product, :variant_unit, :unit_description, :display_name, :display_as,
:variant_unit_scale, :variant_unit_name, to: :variant
# Allows manual skipping of Stock::AvailabilityValidator
attr_accessor :skip_stock_check, :target_shipment
attribute :restock_item, type: :boolean, default: true
# -- Scopes
scope :managed_by, lambda { |user|
if user.admin?
where(nil)
else
# Find line items that are from orders distributed by the user or supplied by the user
joins(variant: :product).
joins(:order).
where('spree_orders.distributor_id IN (?) OR spree_products.supplier_id IN (?)',
user.enterprises, user.enterprises).
select('spree_line_items.*')
end
}
scope :in_orders, lambda { |orders|
where(order_id: orders)
}
# Find line items that are from order sorted by variant name and unit value
scope :sorted_by_name_and_unit_value, -> {
joins(variant: :product).
reorder(Arel.sql("
lower(spree_products.name) asc,
lower(spree_variants.display_name) asc,
spree_variants.unit_value asc"))
}
scope :from_order_cycle, lambda { |order_cycle|
joins(order: :order_cycle).
where(order_cycles: { id: order_cycle })
}
# Here we are simply joining the line item to its variant
# We dont use joins here to avoid the default scopes, and with that, include deleted variants
scope :supplied_by_any, lambda { |enterprises|
variant_ids = Spree::Variant.unscoped.where(supplier: enterprises).select(:id)
where(variant_id: variant_ids)
}
scope :with_tax, -> {
joins(:adjustments).
where(spree_adjustments: { originator_type: 'Spree::TaxRate' }).
select('DISTINCT spree_line_items.*')
}
# Line items without a Spree::TaxRate-originated adjustment
scope :without_tax, -> {
joins("
LEFT OUTER JOIN spree_adjustments
ON (spree_adjustments.adjustable_id=spree_line_items.id
AND spree_adjustments.adjustable_type = 'Spree::LineItem'
AND spree_adjustments.originator_type='Spree::TaxRate')").
where(spree_adjustments: { id: nil })
}
scope :editable_by_producers, ->(enterprises_ids) {
joins(variant: :supplier, order: :distributor).where(
distributor: { enable_producers_to_edit_orders: true },
spree_variants: { supplier_id: enterprises_ids }
)
}
def copy_price
return unless variant
self.price = variant.price if price.nil?
self.currency = variant.currency if currency.nil?
end
def copy_tax_category
return unless variant
self.tax_category = variant.tax_category
end
def copy_dimensions
return unless variant
self.weight ||= computed_weight_from_variant
self.height ||= variant.height
self.width ||= variant.width
self.depth ||= variant.depth
end
def amount
price * quantity
end
alias total amount
def single_money
Spree::Money.new(price, currency:)
end
alias single_display_amount single_money
def money
Spree::Money.new(amount, currency:)
end
alias display_total money
alias display_amount money
def adjust_quantity
self.quantity = 0 if quantity.nil? || quantity < 0
end
# Here we skip stock check if skip_stock_check flag is active,
# we skip stock check if requested quantity is zero or negative,
# and we scope variants to hub and thus acivate variant overrides.
def sufficient_stock?
return true if skip_stock_check
return true if quantity <= 0
scoper.scope(variant)
variant.can_supply?(quantity)
end
def insufficient_stock?
!sufficient_stock?
end
def assign_stock_changes_to=(shipment)
@preferred_shipment = shipment
end
def cap_quantity_at_stock!
scoper.scope(variant)
return if variant.on_demand
update!(quantity: variant.on_hand) if quantity > variant.on_hand
end
def has_tax?
adjustments.tax.any?
end
def included_tax
adjustments.tax.inclusive.sum(:amount)
end
def added_tax
adjustments.tax.additional.sum(:amount)
end
# Some of the tax rates may not be applicable depending to the order's tax zone
def tax_rates
variant&.tax_category&.tax_rates || []
end
def price_with_adjustments
# EnterpriseFee#create_adjustment applies adjustments on line items to their parent order,
# so line_item.adjustments returns an empty array
return 0 if quantity.zero?
fees = enterprise_fee_adjustments.sum(:amount)
(price + (fees / quantity)).round(2)
end
def single_display_amount_with_adjustments
Spree::Money.new(price_with_adjustments, currency:)
end
def amount_with_adjustments
# We calculate from price_with_adjustments here rather than building our own value because
# rounding errors can produce discrepencies of $0.01.
price_with_adjustments * quantity
end
def display_amount_with_adjustments
Spree::Money.new(amount_with_adjustments, currency:)
end
def display_included_tax
Spree::Money.new(included_tax, currency:)
end
def unit_value
return variant.unit_value if quantity == 0 || !final_weight_volume
final_weight_volume / quantity
end
def unit_price
unit_price = UnitPrice.new(variant)
{
amount: price_with_adjustments / unit_price.denominator,
unit: unit_price.unit,
}
end
def scoper
@scoper ||= OpenFoodNetwork::ScopeVariantToHub.new(order.distributor)
end
def enterprise_fee_adjustments
adjustments.enterprise_fee
end
private
def computed_weight_from_variant
if variant.product.variant_unit == "weight"
variant.unit_value / variant.product.variant_unit_scale
else
variant.weight
end
end
def update_inventory
return unless changed?
scoper.scope(variant)
Spree::OrderInventory.new(order).verify(self, target_shipment)
end
def update_order
return unless saved_changes.present? || destroyed?
# update the order totals, etc.
order.create_tax_charge!
end
def update_inventory_before_destroy
# This is necessary before destroying the line item
# so that update_inventory will restore stock to the variant
self.quantity = 0
update_inventory
# This is necessary after updating inventory
# because update_inventory may delete the last shipment in the order
# and that makes update_order fail if we don't reload the shipments
order.shipments.reload
end
def calculate_final_weight_volume
if final_weight_volume.present? && quantity_was > 0
self.final_weight_volume = final_weight_volume * quantity / quantity_was
elsif variant&.unit_value.present?
self.final_weight_volume = variant&.unit_value&.* quantity
end
end
end
end