# 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