mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-01-24 20:36:49 +00:00
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
380 lines
11 KiB
Ruby
380 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'ostruct'
|
|
|
|
module Spree
|
|
class Shipment < ApplicationRecord
|
|
self.belongs_to_required_by_default = false
|
|
self.ignored_columns += [:stock_location_id]
|
|
|
|
belongs_to :order, class_name: 'Spree::Order'
|
|
belongs_to :address, class_name: 'Spree::Address'
|
|
|
|
has_many :shipping_rates, dependent: :delete_all
|
|
has_many :shipping_methods, through: :shipping_rates
|
|
has_many :state_changes, as: :stateful, dependent: :destroy
|
|
has_many :inventory_units, dependent: :delete_all
|
|
has_many :adjustments, as: :adjustable, dependent: :destroy
|
|
|
|
before_create :generate_shipment_number
|
|
after_save :ensure_correct_adjustment, :update_adjustments
|
|
|
|
attr_accessor :special_instructions
|
|
|
|
alias_attribute :amount, :cost
|
|
|
|
accepts_nested_attributes_for :address
|
|
accepts_nested_attributes_for :inventory_units
|
|
|
|
make_permalink field: :number
|
|
|
|
scope :shipped, -> { with_state('shipped') }
|
|
scope :ready, -> { with_state('ready') }
|
|
scope :pending, -> { with_state('pending') }
|
|
scope :with_state, ->(*s) { where(state: s) }
|
|
scope :trackable, -> { where("tracking IS NOT NULL AND tracking != ''") }
|
|
|
|
# Shipment state machine
|
|
# See http://github.com/pluginaweek/state_machine/tree/master for details
|
|
state_machine initial: :pending, use_transactions: false do
|
|
event :ready do
|
|
transition from: :pending, to: :ready, if: lambda { |shipment|
|
|
# Fix for #2040
|
|
shipment.determine_state(shipment.order) == 'ready'
|
|
}
|
|
end
|
|
|
|
event :pend do
|
|
transition from: :ready, to: :pending
|
|
end
|
|
|
|
event :ship do
|
|
transition from: :ready, to: :shipped
|
|
end
|
|
after_transition to: :shipped, do: :after_ship
|
|
|
|
event :cancel do
|
|
transition to: :canceled, from: [:pending, :ready]
|
|
end
|
|
after_transition to: :canceled, do: :after_cancel
|
|
|
|
event :resume do
|
|
transition from: :canceled, to: :ready, if: lambda { |shipment|
|
|
shipment.determine_state(shipment.order) == :ready
|
|
}
|
|
transition from: :canceled, to: :pending, if: lambda { |shipment|
|
|
shipment.determine_state(shipment.order) == :ready
|
|
}
|
|
transition from: :canceled, to: :pending
|
|
end
|
|
after_transition from: :canceled, to: [:pending, :ready], do: :after_resume
|
|
end
|
|
|
|
def to_param
|
|
generate_shipment_number unless number
|
|
number.parameterize.upcase
|
|
end
|
|
|
|
def backordered?
|
|
inventory_units.any?(&:backordered?)
|
|
end
|
|
|
|
def shipped=(value)
|
|
return unless value == '1' && shipped_at.nil?
|
|
|
|
self.shipped_at = Time.zone.now
|
|
end
|
|
|
|
def shipping_method
|
|
method = selected_shipping_rate.try(:shipping_method)
|
|
method ||= shipping_rates.first.try(:shipping_method) unless order.manual_shipping_selection
|
|
method
|
|
end
|
|
|
|
def add_shipping_method(shipping_method, selected = false)
|
|
shipping_rates.create(shipping_method:, selected:)
|
|
end
|
|
|
|
def selected_shipping_rate
|
|
shipping_rates.find_by(selected: true)
|
|
end
|
|
|
|
def selected_shipping_rate_id
|
|
selected_shipping_rate.try(:id)
|
|
end
|
|
|
|
def selected_shipping_rate_id=(id)
|
|
shipping_rates.update_all(selected: false)
|
|
shipping_rates.update(id, selected: true)
|
|
save!
|
|
end
|
|
|
|
def tax_category
|
|
selected_shipping_rate.try(:shipping_method).try(:tax_category)
|
|
end
|
|
|
|
def refresh_rates
|
|
return shipping_rates if shipped?
|
|
|
|
# The call to Stock::Estimator below will replace the current shipping_method
|
|
original_shipping_method_id = shipping_method.try(:id)
|
|
|
|
estimator = OrderManagement::Stock::Estimator.new(order)
|
|
distributor_shipping_rates = estimator.shipping_rates(to_package)
|
|
|
|
if original_shipping_method_id.present? &&
|
|
distributor_shipping_rates.map(&:shipping_method_id)
|
|
.exclude?(original_shipping_method_id)
|
|
cost = estimator.calculate_cost(shipping_method, to_package)
|
|
unless cost.nil?
|
|
original_shipping_rate = shipping_method.shipping_rates.new(cost:)
|
|
self.shipping_rates = distributor_shipping_rates + [original_shipping_rate]
|
|
self.selected_shipping_rate_id = original_shipping_rate.id
|
|
end
|
|
else
|
|
self.shipping_rates = distributor_shipping_rates
|
|
end
|
|
|
|
keep_original_shipping_method_selection(original_shipping_method_id)
|
|
|
|
shipping_rates
|
|
end
|
|
|
|
def keep_original_shipping_method_selection(original_shipping_method_id)
|
|
return if shipping_method&.id == original_shipping_method_id
|
|
|
|
rate_for_original_shipping_method = find_shipping_rate_for(original_shipping_method_id)
|
|
if rate_for_original_shipping_method.present?
|
|
self.selected_shipping_rate_id = rate_for_original_shipping_method.id
|
|
else
|
|
# If there's no original ship method to keep, or if it cannot be found on the ship rates
|
|
# But there's a new ship method selected (first if clause in this method)
|
|
# We need to save the shipment so that callbacks are triggered
|
|
save!
|
|
end
|
|
end
|
|
|
|
def find_shipping_rate_for(shipping_method_id)
|
|
return unless shipping_method_id
|
|
|
|
shipping_rates.detect { |rate|
|
|
rate.shipping_method_id == shipping_method_id
|
|
}
|
|
end
|
|
|
|
def currency
|
|
order ? order.currency : CurrentConfig.get(:currency)
|
|
end
|
|
|
|
def display_cost
|
|
Spree::Money.new(cost, currency:)
|
|
end
|
|
|
|
alias_method :display_amount, :display_cost
|
|
|
|
def item_cost
|
|
line_items.map(&:amount).sum
|
|
end
|
|
|
|
def display_item_cost
|
|
Spree::Money.new(item_cost, currency:)
|
|
end
|
|
|
|
def update_amounts
|
|
return unless fee_adjustment&.amount != cost
|
|
|
|
update_columns(
|
|
cost: fee_adjustment&.amount || 0.0,
|
|
updated_at: Time.zone.now
|
|
)
|
|
recalculate_adjustments
|
|
end
|
|
|
|
def manifest
|
|
inventory_units.group_by(&:variant).map do |variant, units|
|
|
states = {}
|
|
units.group_by(&:state).each { |state, iu| states[state] = iu.count }
|
|
|
|
scoper.scope(variant)
|
|
|
|
OpenStruct.new(variant:, quantity: units.length, states:)
|
|
end
|
|
end
|
|
|
|
def scoper
|
|
@scoper ||= OpenFoodNetwork::ScopeVariantToHub.new(order.distributor)
|
|
end
|
|
|
|
def finalize!
|
|
InventoryUnit.finalize_units!(inventory_units)
|
|
manifest.each { |item| manifest_unstock(item) }
|
|
end
|
|
|
|
def after_cancel
|
|
manifest.each { |item| manifest_restock(item) } if order.restock_items
|
|
end
|
|
|
|
def after_resume
|
|
manifest.each { |item| manifest_unstock(item) }
|
|
end
|
|
|
|
# Updates various aspects of the Shipment while bypassing any callbacks.
|
|
# Note that this method takes an explicit reference to the Order object.
|
|
# This is necessary because the association actually has a stale (and unsaved) copy of the
|
|
# Order and so it will not yield the correct results.
|
|
def update!(order)
|
|
old_state = state
|
|
new_state = determine_state(order)
|
|
update_columns(
|
|
state: new_state,
|
|
updated_at: Time.zone.now
|
|
)
|
|
after_ship if new_state == 'shipped' && old_state != 'shipped'
|
|
end
|
|
|
|
# Determines the appropriate +state+ according to the following logic:
|
|
#
|
|
# pending unless order is complete and +order.payment_state+ is +paid+
|
|
# shipped if already shipped (ie. does not change the state)
|
|
# ready all other cases
|
|
def determine_state(order)
|
|
return 'canceled' if order.canceled?
|
|
return 'pending' unless order.can_ship?
|
|
return 'pending' if inventory_units.any?(&:backordered?)
|
|
return 'shipped' if state == 'shipped'
|
|
|
|
order.paid? ? 'ready' : 'pending'
|
|
end
|
|
|
|
def tracking_url
|
|
@tracking_url ||= shipping_method.build_tracking_url(tracking)
|
|
end
|
|
|
|
def contains?(variant)
|
|
inventory_units_for(variant).present?
|
|
end
|
|
|
|
def inventory_units_for(variant)
|
|
inventory_units.group_by(&:variant_id)[variant.id] || []
|
|
end
|
|
|
|
def to_package
|
|
package = OrderManagement::Stock::Package.new(order)
|
|
grouped_inventory_units = inventory_units.includes(:variant).group_by do |iu|
|
|
[iu.variant, iu.state_name]
|
|
end
|
|
grouped_inventory_units.each do |(variant, state_name), inventory_units|
|
|
package.add variant, inventory_units.count, state_name
|
|
end
|
|
package
|
|
end
|
|
|
|
def set_up_inventory(state, variant, order)
|
|
inventory_units.create(variant_id: variant.id, state:, order_id: order.id)
|
|
end
|
|
|
|
def fee_adjustment
|
|
@fee_adjustment ||= adjustments.shipping.first
|
|
end
|
|
|
|
def ensure_correct_adjustment
|
|
if fee_adjustment
|
|
fee_adjustment.originator = shipping_method
|
|
fee_adjustment.label = adjustment_label
|
|
fee_adjustment.amount = selected_shipping_rate.cost if fee_adjustment.open?
|
|
fee_adjustment.save!
|
|
fee_adjustment.reload
|
|
elsif shipping_method
|
|
shipping_method.create_adjustment(adjustment_label,
|
|
self,
|
|
true,
|
|
"open")
|
|
reload # ensure adjustment is present on later saves
|
|
end
|
|
|
|
update_amounts
|
|
end
|
|
|
|
def adjustment_label
|
|
I18n.t('shipping')
|
|
end
|
|
|
|
def can_modify?
|
|
!shipped? && !order.canceled?
|
|
end
|
|
|
|
private
|
|
|
|
def line_items
|
|
if order.complete?
|
|
inventory_unit_ids = inventory_units.pluck(:variant_id)
|
|
order.line_items.select { |li| inventory_unit_ids.include?(li.variant_id) }
|
|
else
|
|
order.line_items
|
|
end
|
|
end
|
|
|
|
def manifest_unstock(item)
|
|
item.variant.move(-1 * item.quantity)
|
|
end
|
|
|
|
def manifest_restock(item)
|
|
item.variant.move(item.quantity)
|
|
end
|
|
|
|
def generate_shipment_number
|
|
return number if number.present?
|
|
|
|
record = true
|
|
while record
|
|
random = "H#{Array.new(11) { rand(9) }.join}"
|
|
record = self.class.default_scoped.find_by(number: random)
|
|
end
|
|
self.number = random
|
|
end
|
|
|
|
def description_for_shipping_charge
|
|
"#{Spree.t(:shipping)} (#{shipping_method.name})"
|
|
end
|
|
|
|
def validate_shipping_method
|
|
return if shipping_method.nil?
|
|
|
|
return if shipping_method.include?(address)
|
|
|
|
errors.add :shipping_method, Spree.t(:is_not_available_to_shipment_address)
|
|
end
|
|
|
|
def after_ship
|
|
inventory_units.each(&:ship!)
|
|
fee_adjustment.finalize!
|
|
send_shipped_email if order.send_shipment_email
|
|
touch :shipped_at
|
|
update_order_shipment_state
|
|
end
|
|
|
|
def update_order_shipment_state
|
|
new_state = order.updater.update_shipment_state
|
|
order.update_columns(
|
|
shipment_state: new_state,
|
|
updated_at: Time.zone.now,
|
|
)
|
|
end
|
|
|
|
def send_shipped_email
|
|
delivery = !!shipping_method.require_ship_address
|
|
ShipmentMailer.shipped_email(id, delivery:).deliver_later
|
|
end
|
|
|
|
def update_adjustments
|
|
return unless cost_changed? && state != 'shipped'
|
|
|
|
recalculate_adjustments
|
|
end
|
|
|
|
def recalculate_adjustments
|
|
Spree::ItemAdjustments.new(self).update
|
|
end
|
|
end
|
|
end
|