core/app/models/spree/shipment.rb

Summary

Maintainability
C
1 day
Test Coverage
A
96%
require 'ostruct'

module Spree
  class Shipment < Spree::Base
    include Spree::Core::NumberGenerator.new(prefix: 'H', length: 11)
    include Spree::NumberIdentifier
    include Spree::NumberAsParam
    include Spree::Metadata
    if defined?(Spree::Webhooks::HasWebhooks)
      include Spree::Webhooks::HasWebhooks
    end
    if defined?(Spree::Security::Shipments)
      include Spree::Security::Shipments
    end

    with_options inverse_of: :shipments do
      belongs_to :address, class_name: 'Spree::Address'
      belongs_to :order, class_name: 'Spree::Order', touch: true
    end
    belongs_to :stock_location, class_name: 'Spree::StockLocation'

    with_options dependent: :delete_all do
      has_many :adjustments, as: :adjustable
      has_many :inventory_units, inverse_of: :shipment
      has_many :shipping_rates, -> { order(:cost) }
      has_many :state_changes, as: :stateful
    end
    has_many :shipping_methods, through: :shipping_rates
    has_one :selected_shipping_rate, -> { where(selected: true).order(:cost) }, class_name: Spree::ShippingRate.to_s

    after_save :update_adjustments

    before_validation :set_cost_zero_when_nil

    validates :stock_location, presence: true

    attr_accessor :special_instructions

    accepts_nested_attributes_for :address
    accepts_nested_attributes_for :inventory_units

    scope :pending, -> { with_state('pending') }
    scope :ready,   -> { with_state('ready') }
    scope :shipped, -> { with_state('shipped') }
    scope :trackable, -> { where("tracking IS NOT NULL AND tracking != ''") }
    scope :with_state, ->(*s) { where(state: s) }
    # sort by most recent shipped_at, falling back to created_at. add "id desc" to make specs that involve this scope more deterministic.
    scope :reverse_chronological, -> { order(Arel.sql('coalesce(spree_shipments.shipped_at, spree_shipments.created_at) desc'), id: :desc) }
    scope :valid, -> { where.not(state: :canceled) }

    delegate :store, :currency, to: :order

    # 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: %i(ready canceled), to: :shipped
      end
      after_transition to: :shipped, do: :after_ship

      event :cancel do
        transition to: :canceled, from: %i(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
      end
      after_transition from: :canceled, to: %i(pending ready shipped), do: :after_resume

      after_transition do |shipment, transition|
        shipment.state_changes.create!(
          previous_state: transition.from,
          next_state: transition.to,
          name: 'shipment'
        )
      end
    end

    self.whitelisted_ransackable_attributes = ['number']

    extend DisplayMoney
    money_methods :cost, :discounted_cost, :final_price, :item_cost
    alias display_amount display_cost

    def add_shipping_method(shipping_method, selected = false)
      shipping_rates.create(shipping_method: shipping_method, selected: selected, cost: cost)
    end

    def after_cancel
      manifest.each { |item| manifest_restock(item) }
    end

    def after_resume
      manifest.each { |item| manifest_unstock(item) }
    end

    def backordered?
      inventory_units.any?(&:backordered?)
    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 shipped?

      order.paid? || Spree::Config[:auto_capture_on_dispatch] ? 'ready' : 'pending'
    end

    def discounted_cost
      cost + promo_total
    end
    alias discounted_amount discounted_cost

    def final_price
      cost + adjustment_total
    end

    def final_price_with_items
      item_cost + final_price
    end

    def free?
      return true if final_price == BigDecimal(0)

      adjustments.promotion.any? { |p| p.source.type == 'Spree::Promotion::Actions::FreeShipping' }
    end

    def finalize!
      inventory_units.finalize_units!
      after_resume
    end

    def include?(variant)
      inventory_units_for(variant).present?
    end

    def inventory_units_for(variant)
      inventory_units.where(variant_id: variant.id)
    end

    def inventory_units_for_item(line_item, variant = nil)
      inventory_units.where(line_item_id: line_item.id, variant_id: line_item.variant_id || variant.id)
    end

    def item_cost
      manifest.map { |m| (m.line_item.price + (m.line_item.adjustment_total / m.line_item.quantity)) * m.quantity }.sum
    end

    def line_items
      inventory_units.includes(:line_item).map(&:line_item).uniq
    end

    ManifestItem = Struct.new(:line_item, :variant, :quantity, :states)

    def manifest
      # Grouping by the ID means that we don't have to call out to the association accessor
      # This makes the grouping by faster because it results in less SQL cache hits.
      inventory_units.group_by(&:variant_id).map do |_variant_id, units|
        units.group_by(&:line_item_id).map do |_line_item_id, units|
          states = {}
          units.group_by(&:state).each { |state, iu| states[state] = iu.sum(&:quantity) }

          line_item = units.first.line_item
          variant = units.first.variant
          ManifestItem.new(line_item, variant, units.sum(&:quantity), states)
        end
      end.flatten
    end

    def process_order_payments
      pending_payments = order.pending_payments.
                         sort_by(&:uncaptured_amount).reverse

      shipment_to_pay = final_price_with_items
      payments_amount = 0

      payments_pool = pending_payments.each_with_object([]) do |payment, pool|
        break if payments_amount >= shipment_to_pay

        payments_amount += payment.uncaptured_amount
        pool << payment
      end

      payments_pool.each do |payment|
        capturable_amount = if payment.amount >= shipment_to_pay
                              shipment_to_pay
                            else
                              payment.amount
                            end

        cents = (capturable_amount * 100).to_i
        payment.capture!(cents)
        shipment_to_pay -= capturable_amount
      end
    end

    def ready_or_pending?
      ready? || pending?
    end

    def refresh_rates(shipping_method_filter = ShippingMethod::DISPLAY_ON_FRONT_END)
      return shipping_rates if shipped?
      return [] unless can_get_rates?

      # StockEstimator.new assignment below will replace the current shipping_method
      original_shipping_method_id = shipping_method.try(:id)

      self.shipping_rates = Stock::Estimator.new(order).
                            shipping_rates(to_package, shipping_method_filter)

      if shipping_method
        selected_rate = shipping_rates.detect do |rate|
          if original_shipping_method_id
            rate.shipping_method_id == original_shipping_method_id
          else
            rate.selected
          end
        end
        save!
        self.selected_shipping_rate_id = selected_rate.id if selected_rate
        reload
      end

      shipping_rates
    end

    def selected_shipping_rate_id
      selected_shipping_rate.try(:id)
    end

    def selected_shipping_rate_id=(id)
      # Explicitly updates the timestamp in order to bust cache dependent on "updated_at"
      shipping_rates.update_all(selected: false, updated_at: Time.current)
      shipping_rates.update(id, selected: true)
      save!
    end

    def set_up_inventory(state, variant, order, line_item, quantity = 1)
      return if quantity <= 0

      inventory_units.create(
        state: state,
        variant_id: variant.id,
        order_id: order.id,
        line_item_id: line_item.id,
        quantity: quantity
      )
    end

    def shipped=(value)
      return unless value == '1' && shipped_at.nil?

      self.shipped_at = Time.current
    end

    def shipping_method
      selected_shipping_rate&.shipping_method || shipping_rates.first&.shipping_method
    end

    def tax_category
      selected_shipping_rate.try(:tax_rate).try(:tax_category)
    end

    # Only one of either included_tax_total or additional_tax_total is set
    # This method returns the total of the two. Saves having to check if
    # tax is included or additional.
    def tax_total
      included_tax_total + additional_tax_total
    end

    def to_package
      package = Stock::Package.new(stock_location)
      inventory_units.includes(:variant).joins(:variant).group_by(&:state).each do |state, state_inventory_units|
        package.add_multiple state_inventory_units, state.to_sym
      end
      package
    end

    def tracking_url
      @tracking_url ||= shipping_method&.build_tracking_url(tracking)
    end

    def update_amounts
      if selected_shipping_rate
        update_columns(
          cost: selected_shipping_rate.cost,
          adjustment_total: adjustments.additional.map(&:update!).compact.sum,
          updated_at: Time.current
        )
      end
    end

    def update_attributes_and_order(params = {})
      Shipments::Update.call(shipment: self, shipment_attributes: params).success?
    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.current
      )
      after_ship if new_state == 'shipped' && old_state != 'shipped'
    end

    def transfer_to_location(variant, quantity, stock_location)
      transfer_to_shipment(
        variant,
        quantity,
        order.shipments.build(stock_location: stock_location)
      )
    end

    def transfer_to_shipment(variant, quantity, shipment_to_transfer_to)
      Spree::FulfilmentChanger.new(
        current_stock_location: stock_location,
        desired_stock_location: shipment_to_transfer_to.stock_location,
        current_shipment: self,
        desired_shipment: shipment_to_transfer_to,
        variant: variant,
        quantity: quantity
      )
    end

    private

    def after_ship
      ShipmentHandler.factory(self).perform
    end

    def can_get_rates?
      order.ship_address&.valid?
    end

    def manifest_restock(item)
      if item.states['on_hand'].to_i.positive?
        stock_location.restock item.variant, item.states['on_hand'], self
      end

      if item.states['backordered'].to_i.positive?
        stock_location.restock_backordered item.variant, item.states['backordered']
      end
    end

    def manifest_unstock(item)
      stock_location.unstock item.variant, item.quantity, self
    end

    def recalculate_adjustments
      Adjustable::AdjustmentsUpdater.update(self)
    end

    def set_cost_zero_when_nil
      self.cost = 0 unless cost
    end

    def update_adjustments
      recalculate_adjustments if saved_change_to_cost? && state != 'shipped'
    end
  end
end