<NME> shipment.rb
<BEF> # frozen_string_literal: true

require 'ostruct'

module Spree
  class Shipment < ApplicationRecord
    belongs_to :order, class_name: 'Spree::Order'
    belongs_to :address, class_name: 'Spree::Address'
    belongs_to :stock_location, class_name: 'Spree::StockLocation'

    has_many :shipping_rates, dependent: :delete_all
    has_many :shipping_methods, through: :shipping_rates
    has_many :state_changes, as: :stateful
    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: shipping_method, selected: 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)
      self.shipping_rates = OrderManagement::Stock::Estimator.new(order).shipping_rates(to_package)

      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 : Spree::Config[:currency]
    end

    def display_cost
      Spree::Money.new(cost, currency: 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: 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: variant, quantity: units.length, states: states)
      end
    end


    def line_items
      if order.complete?
        order.line_items.select { |li| inventory_units.pluck(:variant_id).include?(li.variant_id) }
      else
        order.line_items
      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 include?(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(stock_location, 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: 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)
      stock_location.unstock item.variant, item.quantity, self
    end

    def manifest_restock(item)
      stock_location.restock item.variant, item.quantity, self
    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
      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: 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

<MSG> Don't pluck variant_ids multiple times (once for each line item)

<DFF> @@ -185,7 +185,8 @@ module Spree
 
     def line_items
       if order.complete?
-        order.line_items.select { |li| inventory_units.pluck(:variant_id).include?(li.variant_id) }
+        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

{'commit': 'diff --git a/app/models/spree/shipment.rb b/app/models/spree/shipment.rb\nindex ac88f4397..1a750dc20 100644\n--- a/app/models/spree/shipment.rb\n+++ b/app/models/spree/shipment.rb\n@@ -185,7 +185,8 @@ module Spree\n \n     def line_items\n       if order.complete?\n-        order.line_items.select { |li| inventory_units.pluck(:variant_id).include?(li.variant_id) }\n+        inventory_unit_ids = inventory_units.pluck(:variant_id)\n+        order.line_items.select { |li| inventory_unit_ids.include?(li.variant_id) }\n       else\n         order.line_items\n       end\n', 'commit_subject': "Don't pluck variant_ids multiple times (once for each line item)", 'repo_name': 'openfoodfoundation/openfoodnetwork', 'committer': {'name': 'Matt-Yorkley', 'email': '52dd53e39aabc3ad9893bf4bdc49ac9095b2e246@users.noreply.github.com', 'time_sec': '1622823050', 'tz_offset': '60', 'date': {'seconds': '1622823050'}}, 'commit_tree': '3ea5a59c7a02df99c80586e0c02bee442f12411e', 'commit_message': "Don't pluck variant_ids multiple times (once for each line item)\n", 'language_name': 'JavaScript', 'license': 'agpl-3.0', 'hunks': '@@ -185,7 +185,8 @@ module Spree\n \n     def line_items\n       if order.complete?\n-        order.line_items.select { |li| inventory_units.pluck(:variant_id).include?(li.variant_id) }\n+        inventory_unit_ids = inventory_units.pluck(:variant_id)\n+        order.line_items.select { |li| inventory_unit_ids.include?(li.variant_id) }\n       else\n         order.line_items\n       end\n', 'addition_count': 2, 'deletion_count': 1, 'src_file': 'a/app/models/spree/shipment.rb', 'tgt_file': 'b/app/models/spree/shipment.rb', 'file_extension': '.rb', 'before_file': '# frozen_string_literal: true\n\nrequire \'ostruct\'\n\nmodule Spree\n  class Shipment < ApplicationRecord\n    belongs_to :order, class_name: \'Spree::Order\'\n    belongs_to :address, class_name: \'Spree::Address\'\n    belongs_to :stock_location, class_name: \'Spree::StockLocation\'\n\n    has_many :shipping_rates, dependent: :delete_all\n    has_many :shipping_methods, through: :shipping_rates\n    has_many :state_changes, as: :stateful\n    has_many :inventory_units, dependent: :delete_all\n    has_many :adjustments, as: :adjustable, dependent: :destroy\n\n    before_create :generate_shipment_number\n    after_save :ensure_correct_adjustment, :update_adjustments\n\n    attr_accessor :special_instructions\n\n    alias_attribute :amount, :cost\n\n    accepts_nested_attributes_for :address\n    accepts_nested_attributes_for :inventory_units\n\n    make_permalink field: :number\n\n    scope :shipped, -> { with_state(\'shipped\') }\n    scope :ready,   -> { with_state(\'ready\') }\n    scope :pending, -> { with_state(\'pending\') }\n    scope :with_state, ->(*s) { where(state: s) }\n    scope :trackable, -> { where("tracking IS NOT NULL AND tracking != \'\'") }\n\n    # Shipment state machine\n    # See http://github.com/pluginaweek/state_machine/tree/master for details\n    state_machine initial: :pending, use_transactions: false do\n      event :ready do\n        transition from: :pending, to: :ready, if: lambda { |shipment|\n          # Fix for #2040\n          shipment.determine_state(shipment.order) == \'ready\'\n        }\n      end\n\n      event :pend do\n        transition from: :ready, to: :pending\n      end\n\n      event :ship do\n        transition from: :ready, to: :shipped\n      end\n      after_transition to: :shipped, do: :after_ship\n\n      event :cancel do\n        transition to: :canceled, from: [:pending, :ready]\n      end\n      after_transition to: :canceled, do: :after_cancel\n\n      event :resume do\n        transition from: :canceled, to: :ready, if: lambda { |shipment|\n          shipment.determine_state(shipment.order) == :ready\n        }\n        transition from: :canceled, to: :pending, if: lambda { |shipment|\n          shipment.determine_state(shipment.order) == :ready\n        }\n        transition from: :canceled, to: :pending\n      end\n      after_transition from: :canceled, to: [:pending, :ready], do: :after_resume\n    end\n\n    def to_param\n      generate_shipment_number unless number\n      number.parameterize.upcase\n    end\n\n    def backordered?\n      inventory_units.any?(&:backordered?)\n    end\n\n    def shipped=(value)\n      return unless value == \'1\' && shipped_at.nil?\n\n      self.shipped_at = Time.zone.now\n    end\n\n    def shipping_method\n      method = selected_shipping_rate.try(:shipping_method)\n      method ||= shipping_rates.first.try(:shipping_method) unless order.manual_shipping_selection\n      method\n    end\n\n    def add_shipping_method(shipping_method, selected = false)\n      shipping_rates.create(shipping_method: shipping_method, selected: selected)\n    end\n\n    def selected_shipping_rate\n      shipping_rates.find_by(selected: true)\n    end\n\n    def selected_shipping_rate_id\n      selected_shipping_rate.try(:id)\n    end\n\n    def selected_shipping_rate_id=(id)\n      shipping_rates.update_all(selected: false)\n      shipping_rates.update(id, selected: true)\n      save!\n    end\n\n    def tax_category\n      selected_shipping_rate.try(:shipping_method).try(:tax_category)\n    end\n\n    def refresh_rates\n      return shipping_rates if shipped?\n\n      # The call to Stock::Estimator below will replace the current shipping_method\n      original_shipping_method_id = shipping_method.try(:id)\n      self.shipping_rates = OrderManagement::Stock::Estimator.new(order).shipping_rates(to_package)\n\n      keep_original_shipping_method_selection(original_shipping_method_id)\n\n      shipping_rates\n    end\n\n    def keep_original_shipping_method_selection(original_shipping_method_id)\n      return if shipping_method&.id == original_shipping_method_id\n\n      rate_for_original_shipping_method = find_shipping_rate_for(original_shipping_method_id)\n      if rate_for_original_shipping_method.present?\n        self.selected_shipping_rate_id = rate_for_original_shipping_method.id\n      else\n        # If there\'s no original ship method to keep, or if it cannot be found on the ship rates\n        #   But there\'s a new ship method selected (first if clause in this method)\n        #   We need to save the shipment so that callbacks are triggered\n        save!\n      end\n    end\n\n    def find_shipping_rate_for(shipping_method_id)\n      return unless shipping_method_id\n\n      shipping_rates.detect { |rate|\n        rate.shipping_method_id == shipping_method_id\n      }\n    end\n\n    def currency\n      order ? order.currency : Spree::Config[:currency]\n    end\n\n    def display_cost\n      Spree::Money.new(cost, currency: currency)\n    end\n\n    alias_method :display_amount, :display_cost\n\n    def item_cost\n      line_items.map(&:amount).sum\n    end\n\n    def display_item_cost\n      Spree::Money.new(item_cost, currency: currency)\n    end\n\n    def update_amounts\n      return unless fee_adjustment&.amount != cost\n\n      update_columns(\n        cost: fee_adjustment&.amount || 0.0,\n        updated_at: Time.zone.now\n      )\n      recalculate_adjustments\n    end\n\n    def manifest\n      inventory_units.group_by(&:variant).map do |variant, units|\n        states = {}\n        units.group_by(&:state).each { |state, iu| states[state] = iu.count }\n        scoper.scope(variant)\n        OpenStruct.new(variant: variant, quantity: units.length, states: states)\n      end\n    end\n\n\n    def line_items\n      if order.complete?\n        order.line_items.select { |li| inventory_units.pluck(:variant_id).include?(li.variant_id) }\n      else\n        order.line_items\n      end\n\n    def after_cancel\n      manifest.each { |item| manifest_restock(item) } if order.restock_items\n    end\n\n    def after_resume\n      manifest.each { |item| manifest_unstock(item) }\n    end\n\n    # Updates various aspects of the Shipment while bypassing any callbacks.\n    #   Note that this method takes an explicit reference to the Order object.\n    #   This is necessary because the association actually has a stale (and unsaved) copy of the\n    #     Order and so it will not yield the correct results.\n    def update!(order)\n      old_state = state\n      new_state = determine_state(order)\n      update_columns(\n        state: new_state,\n        updated_at: Time.zone.now\n      )\n      after_ship if new_state == \'shipped\' && old_state != \'shipped\'\n    end\n\n    # Determines the appropriate +state+ according to the following logic:\n    #\n    # pending    unless order is complete and +order.payment_state+ is +paid+\n    # shipped    if already shipped (ie. does not change the state)\n    # ready      all other cases\n    def determine_state(order)\n      return \'canceled\' if order.canceled?\n      return \'pending\' unless order.can_ship?\n      return \'pending\' if inventory_units.any?(&:backordered?)\n      return \'shipped\' if state == \'shipped\'\n\n      order.paid? ? \'ready\' : \'pending\'\n    end\n\n    def tracking_url\n      @tracking_url ||= shipping_method.build_tracking_url(tracking)\n    end\n\n    def include?(variant)\n      inventory_units_for(variant).present?\n    end\n\n    def inventory_units_for(variant)\n      inventory_units.group_by(&:variant_id)[variant.id] || []\n    end\n\n    def to_package\n      package = OrderManagement::Stock::Package.new(stock_location, order)\n      grouped_inventory_units = inventory_units.includes(:variant).group_by do |iu|\n        [iu.variant, iu.state_name]\n      end\n      grouped_inventory_units.each do |(variant, state_name), inventory_units|\n        package.add variant, inventory_units.count, state_name\n      end\n      package\n    end\n\n    def set_up_inventory(state, variant, order)\n      inventory_units.create(variant_id: variant.id, state: state, order_id: order.id)\n    end\n\n    def fee_adjustment\n      @fee_adjustment ||= adjustments.shipping.first\n    end\n\n    def ensure_correct_adjustment\n      if fee_adjustment\n        fee_adjustment.originator = shipping_method\n        fee_adjustment.label = adjustment_label\n        fee_adjustment.amount = selected_shipping_rate.cost if fee_adjustment.open?\n        fee_adjustment.save!\n        fee_adjustment.reload\n      elsif shipping_method\n        shipping_method.create_adjustment(adjustment_label,\n                                          self,\n                                          true,\n                                          "open")\n        reload # ensure adjustment is present on later saves\n      end\n\n      update_amounts\n    end\n\n    def adjustment_label\n      I18n.t(\'shipping\')\n    end\n\n    def can_modify?\n      !shipped? && !order.canceled?\n    end\n\n    private\n\n    def line_items\n      if order.complete?\n        inventory_unit_ids = inventory_units.pluck(:variant_id)\n        order.line_items.select { |li| inventory_unit_ids.include?(li.variant_id) }\n      else\n        order.line_items\n      end\n    end\n\n    def manifest_unstock(item)\n      stock_location.unstock item.variant, item.quantity, self\n    end\n\n    def manifest_restock(item)\n      stock_location.restock item.variant, item.quantity, self\n    end\n\n    def generate_shipment_number\n      return number if number.present?\n\n      record = true\n      while record\n        random = "H#{Array.new(11) { rand(9) }.join}"\n        record = self.class.default_scoped.find_by(number: random)\n      end\n      self.number = random\n    end\n\n    def description_for_shipping_charge\n      "#{Spree.t(:shipping)} (#{shipping_method.name})"\n    end\n\n    def validate_shipping_method\n      return if shipping_method.nil?\n\n      return if shipping_method.include?(address)\n\n      errors.add :shipping_method, Spree.t(:is_not_available_to_shipment_address)\n    end\n\n    def after_ship\n      inventory_units.each(&:ship!)\n      fee_adjustment.finalize!\n      send_shipped_email\n      touch :shipped_at\n      update_order_shipment_state\n    end\n\n    def update_order_shipment_state\n      new_state = order.updater.update_shipment_state\n      order.update_columns(\n        shipment_state: new_state,\n        updated_at: Time.zone.now,\n      )\n    end\n\n    def send_shipped_email\n      delivery = !!shipping_method.require_ship_address\n      ShipmentMailer.shipped_email(id, delivery: delivery).deliver_later\n    end\n\n    def update_adjustments\n      return unless cost_changed? && state != \'shipped\'\n\n      recalculate_adjustments\n    end\n\n    def recalculate_adjustments\n      Spree::ItemAdjustments.new(self).update\n    end\n  end\nend\n', 'message': "Don't pluck variant_ids multiple times (once for each line item)\n", 'author_name': 'Matt-Yorkley', 'lang': 'rb'}