Files
openfoodnetwork/app/models/order_cycle.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

374 lines
12 KiB
Ruby

# frozen_string_literal: true
require 'open_food_network/scope_variant_to_hub'
class OrderCycle < ApplicationRecord
searchable_attributes :orders_open_at, :orders_close_at, :coordinator_id
searchable_scopes :active, :inactive, :active_or_complete, :upcoming, :closed, :not_closed,
:dated, :undated, :soonest_opening, :soonest_closing, :most_recently_closed
belongs_to :coordinator, class_name: 'Enterprise'
has_many :coordinator_fee_refs, class_name: 'CoordinatorFee', dependent: :destroy
has_many :coordinator_fees, through: :coordinator_fee_refs, source: :enterprise_fee,
dependent: :destroy
has_many :exchanges, dependent: :destroy
# These scope names are prepended with "cached_" because there are existing accessor methods
# :incoming_exchanges and :outgoing_exchanges.
has_many :cached_incoming_exchanges, -> {
where incoming: true
}, class_name: "Exchange", inverse_of: :order_cycle,
dependent: :destroy
has_many :cached_outgoing_exchanges, -> {
where incoming: false
}, class_name: "Exchange", inverse_of: :order_cycle,
dependent: :destroy
has_many :orders, class_name: 'Spree::Order', dependent: :restrict_with_exception
has_many :suppliers, -> { distinct }, source: :sender, through: :cached_incoming_exchanges
has_many :distributors, -> { distinct }, source: :receiver, through: :cached_outgoing_exchanges
has_many :order_cycle_schedules, dependent: :destroy
has_many :schedules, through: :order_cycle_schedules
has_and_belongs_to_many :selected_distributor_payment_methods,
class_name: 'DistributorPaymentMethod',
join_table: 'order_cycles_distributor_payment_methods'
has_and_belongs_to_many :selected_distributor_shipping_methods,
class_name: 'DistributorShippingMethod',
join_table: 'order_cycles_distributor_shipping_methods'
has_paper_trail meta: { custom_data: proc { |order_cycle| order_cycle.schedule_ids.to_s } }
attr_accessor :incoming_exchanges, :outgoing_exchanges
before_update :reset_opened_at, if: :will_save_change_to_orders_open_at?
before_update :reset_processed_at, if: :will_save_change_to_orders_close_at?
after_save :sync_subscriptions, if: :opening?
validates :name, presence: true
validate :orders_close_at_after_orders_open_at?
preference :product_selection_from_coordinator_inventory_only, :boolean, default: false
scope :active, lambda {
where('order_cycles.orders_open_at <= ? AND order_cycles.orders_close_at >= ?',
Time.zone.now,
Time.zone.now)
}
scope :active_or_complete, lambda { where(order_cycles: { orders_open_at: ..Time.zone.now }) }
scope :inactive, lambda {
where('order_cycles.orders_open_at > ? OR order_cycles.orders_close_at < ?',
Time.zone.now,
Time.zone.now)
}
scope :upcoming, lambda { where('order_cycles.orders_open_at > ?', Time.zone.now) }
scope :not_closed, lambda {
where('order_cycles.orders_close_at > ? OR order_cycles.orders_close_at IS NULL', Time.zone.now)
}
scope :closed, lambda {
where(order_cycles: { orders_close_at: ...Time.zone.now })
.order("order_cycles.orders_close_at DESC")
}
scope :unprocessed, -> { where(processed_at: nil) }
scope :undated, -> { where('order_cycles.orders_open_at IS NULL OR orders_close_at IS NULL') }
scope :dated, -> { where('orders_open_at IS NOT NULL AND orders_close_at IS NOT NULL') }
scope :soonest_closing, lambda { active.order('order_cycles.orders_close_at ASC') }
# This scope returns all the closed orders
scope :most_recently_closed, lambda { closed.order('order_cycles.orders_close_at DESC') }
scope :soonest_opening, lambda { upcoming.order('order_cycles.orders_open_at ASC') }
scope :by_name, -> { order('name') }
scope :with_distributor, lambda { |distributor|
joins(:exchanges).merge(Exchange.outgoing).merge(Exchange.to_enterprise(distributor))
}
scope :managed_by, lambda { |user|
if user.admin?
where(nil)
else
where(coordinator_id: user.enterprises.to_a)
end
}
# Return order cycles that user coordinates, sends to or receives from
scope :visible_by, lambda { |user|
if user.admin?
where(nil)
else
with_exchanging_enterprises_outer.
where('order_cycles.coordinator_id IN (?) OR enterprises.id IN (?)',
user.enterprises.map(&:id),
user.enterprises.map(&:id)).
select('DISTINCT order_cycles.*')
end
}
scope :with_exchanging_enterprises_outer, lambda {
joins("LEFT OUTER JOIN exchanges ON (exchanges.order_cycle_id = order_cycles.id)").
joins("LEFT OUTER JOIN enterprises
ON (enterprises.id = exchanges.sender_id OR enterprises.id = exchanges.receiver_id)")
}
scope :involving_managed_distributors_of, lambda { |user|
enterprises = Enterprise.managed_by(user)
# Order cycles where I managed an enterprise at either end of an outgoing exchange
# ie. coordinator or distributor
joins(:exchanges).merge(Exchange.outgoing).
where('exchanges.receiver_id IN (?) OR exchanges.sender_id IN (?)',
enterprises.pluck(:id),
enterprises.pluck(:id)).
select('DISTINCT order_cycles.*')
}
scope :involving_managed_producers_of, lambda { |user|
enterprises = Enterprise.managed_by(user)
# Order cycles where I managed an enterprise at either end of an incoming exchange
# ie. coordinator or producer
joins(:exchanges).merge(Exchange.incoming).
where('exchanges.receiver_id IN (?) OR exchanges.sender_id IN (?)',
enterprises.pluck(:id),
enterprises.pluck(:id)).
select('DISTINCT order_cycles.*')
}
def self.first_opening_for(distributor)
with_distributor(distributor).soonest_opening.first
end
def self.first_closing_for(distributor)
with_distributor(distributor).soonest_closing.first
end
def self.most_recently_closed_for(distributor)
with_distributor(distributor).most_recently_closed.first
end
# Find the earliest closing times for each distributor in an active order cycle, and return
# them in the format {distributor_id => closing_time, ...}
#
# Optionally, specify some distributor_ids as a parameter to scope the results
def self.earliest_closing_times(distributor_ids = nil)
cycles = Exchange.
outgoing.
joins(:order_cycle).
merge(OrderCycle.active).
group('exchanges.receiver_id')
cycles = cycles.where(receiver_id: distributor_ids) if distributor_ids.present?
cycles.pluck("exchanges.receiver_id AS receiver_id",
"MIN(order_cycles.orders_close_at) AS earliest_close_at")
.to_h
end
def attachable_distributor_payment_methods
DistributorPaymentMethod.joins(:payment_method).
merge(Spree::PaymentMethod.available).
where(distributor_id: distributor_ids)
end
def attachable_distributor_shipping_methods
DistributorShippingMethod.joins(:shipping_method).
merge(Spree::ShippingMethod.frontend).
where(distributor_id: distributor_ids)
end
def clone!
OrderCycles::CloneService.new(self).create
end
def variants
Spree::Variant.
joins(:exchanges).
merge(Exchange.in_order_cycle(self)).
select('DISTINCT spree_variants.*').
to_a # http://stackoverflow.com/q/15110166
end
def supplied_variants
exchanges.incoming.map(&:variants).flatten.uniq.reject(&:deleted?)
end
def distributed_variants
exchanges.outgoing.map(&:variants).flatten.uniq.reject(&:deleted?)
end
def variants_distributed_by(distributor)
return Spree::Variant.where("1=0") if distributor.blank?
Spree::Variant.
joins(:exchanges).
merge(distributor.inventory_variants).
merge(Exchange.in_order_cycle(self)).
merge(Exchange.outgoing).
merge(Exchange.to_enterprise(distributor))
end
def products_distributed_by(distributor)
variants_distributed_by(distributor).map(&:product).uniq
end
def products
variants.map(&:product).uniq
end
def has_distributor?(distributor)
distributors.include? distributor
end
def has_variant?(variant)
variants.include? variant
end
def dated?
!undated?
end
def undated?
orders_open_at.nil? || orders_close_at.nil?
end
def upcoming?
orders_open_at && Time.zone.now < orders_open_at
end
def open?
orders_open_at && orders_close_at &&
Time.zone.now > orders_open_at && Time.zone.now < orders_close_at
end
def closed?
orders_close_at && Time.zone.now > orders_close_at
end
def exchange_for_distributor(distributor)
exchanges.outgoing.to_enterprises([distributor]).first
end
def exchange_for_supplier(supplier)
exchanges.incoming.from_enterprises([supplier]).first
end
def receival_instructions_for(supplier)
exchange_for_supplier(supplier)&.receival_instructions
end
def pickup_time_for(distributor)
exchange_for_distributor(distributor)&.pickup_time
end
def pickup_instructions_for(distributor)
exchange_for_distributor(distributor)&.pickup_instructions
end
def exchanges_carrying(variant, distributor)
exchanges.supplying_to(distributor).with_variant(variant)
end
def exchanges_supplying(order)
variant_ids_relation = Spree::LineItem.in_orders(order).select(:variant_id)
exchanges.supplying_to(order.distributor).with_any_variant(variant_ids_relation)
end
def coordinated_by?(user)
coordinator.users.include? user
end
def items_bought_by_user(user, distributor)
# The Spree::Order.complete scope only checks for completed_at date
# it does not ensure state is "complete"
orders = Spree::Order.complete.where(state: "complete",
user_id: user,
distributor_id: distributor,
order_cycle_id: self)
items = Spree::LineItem.includes(:variant).joins(:order).merge(orders)
scoper = OpenFoodNetwork::ScopeVariantToHub.new(distributor)
items.each { |li| scoper.scope(li.variant) }
items
end
def distributor_payment_methods
if simple? || selected_distributor_payment_methods.none?
attachable_distributor_payment_methods
else
attachable_distributor_payment_methods.where(
"distributors_payment_methods.id IN (?) OR distributor_id NOT IN (?)",
selected_distributor_payment_methods.map(&:id),
selected_distributor_payment_methods.map(&:distributor_id)
)
end
end
def distributor_shipping_methods
if simple? || selected_distributor_shipping_methods.none?
attachable_distributor_shipping_methods
else
attachable_distributor_shipping_methods.where(
"distributors_shipping_methods.id IN (?) OR distributor_id NOT IN (?)",
selected_distributor_shipping_methods.map(&:id),
selected_distributor_shipping_methods.map(&:distributor_id)
)
end
end
def simple?
coordinator.sells == 'own'
end
def same_datetime_value(attribute, string)
return true if self[attribute].blank? && string.blank?
return false if self[attribute].blank? || string.blank?
DateTime.parse(string).to_fs(:short) == self[attribute]&.to_fs(:short)
end
private
def opening?
(open? || upcoming?) && saved_change_to_orders_close_at? && was_closed?
end
def was_closed?
orders_close_at_previously_was.blank? || Time.zone.now > orders_close_at_previously_was
end
def sync_subscriptions
return unless schedule_ids.any?
OrderManagement::Subscriptions::ProxyOrderSyncer.new(
Subscription.where(schedule_id: schedule_ids)
).sync!
end
def orders_close_at_after_orders_open_at?
return false if orders_open_at.blank? || orders_close_at.blank?
return false if orders_close_at > orders_open_at
errors.add(:orders_close_at, :after_orders_open_at)
end
def reset_opened_at
# Reset only if order cycle is opening again at a later date
return unless orders_open_at.present? && orders_open_at_was.present?
return unless orders_open_at > orders_open_at_was
self.opened_at = nil
end
def reset_processed_at
return unless orders_close_at.present? && orders_close_at_was.present?
return unless orders_close_at > orders_close_at_was
self.processed_at = nil
self.mails_sent = false
end
end