Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DFC Orders] Backorder stock controlled products #12888

Merged
merged 6 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/controllers/cart_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ def populate
order.cap_quantity_at_stock!
order.recreate_all_fees!

StockSyncJob.sync_linked_catalogs(order)

render json: { error: false, stock_levels: stock_levels(order) }, status: :ok
else
render json: { error: cart_service.errors.full_messages.join(",") },
Expand Down
72 changes: 50 additions & 22 deletions app/jobs/backorder_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,9 @@ class BackorderJob < ApplicationJob
sidekiq_options retry: 0

def self.check_stock(order)
variants_needing_stock = order.variants.select do |variant|
# TODO: scope variants to hub.
# We are only supporting producer stock at the moment.
variant.on_hand&.negative?
end
links = SemanticLink.where(variant_id: order.line_items.select(:variant_id))

linked_variants = variants_needing_stock.select do |variant|
variant.semantic_links.present?
end

perform_later(order, linked_variants) if linked_variants.present?
perform_later(order) if links.exists?
rescue StandardError => e
# Errors here shouldn't affect the checkout. So let's report them
# separately:
Expand All @@ -32,44 +24,60 @@ def self.check_stock(order)
end
end

def perform(order, linked_variants)
def perform(order)
OrderLocker.lock_order_and_variants(order) do
place_backorder(order, linked_variants)
place_backorder(order)
end
rescue StandardError
# If the backordering fails, we need to tell the shop owner because they
# need to organgise more stock.
BackorderMailer.backorder_failed(order, linked_variants).deliver_later
BackorderMailer.backorder_failed(order).deliver_later

raise
end

def place_backorder(order, linked_variants)
def place_backorder(order)
user = order.distributor.owner
items = backorderable_items(order)

# We are assuming that all variants are linked to the same wholesale
# shop and its catalog:
urls = FdcUrlBuilder.new(linked_variants[0].semantic_links[0].semantic_id)
reference_link = items[0].variant.semantic_links[0].semantic_id
urls = FdcUrlBuilder.new(reference_link)
orderer = FdcBackorderer.new(user, urls)

backorder = orderer.find_or_build_order(order)
broker = load_broker(order.distributor.owner, urls)
ordered_quantities = {}

linked_variants.each do |variant|
retail_quantity = add_item_to_backorder(variant, broker, backorder, orderer)
ordered_quantities[variant] = retail_quantity
items.each do |item|
retail_quantity = add_item_to_backorder(item, broker, backorder, orderer)
ordered_quantities[item] = retail_quantity
end

place_order(user, order, orderer, backorder)

linked_variants.each do |variant|
variant.on_hand += ordered_quantities[variant]
items.each do |item|
variant = item.variant
variant.on_hand += ordered_quantities[item] if variant.on_demand
end
end

def add_item_to_backorder(variant, broker, backorder, orderer)
needed_quantity = -1 * variant.on_hand
# We look at linked variants which are either stock controlled or
# are on demand with negative stock.
def backorderable_items(order)
order.line_items.select do |item|
# TODO: scope variants to hub.
# We are only supporting producer stock at the moment.
variant = item.variant
variant.semantic_links.present? &&
(variant.on_demand == false || variant.on_hand&.negative?)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the model (or a module like VariantStock) should be deciding what is backorderable? Perhaps it can even be a scope.

end
end

def add_item_to_backorder(line_item, broker, backorder, orderer)
variant = line_item.variant
needed_quantity = needed_quantity(line_item)
solution = broker.best_offer(variant.semantic_links[0].semantic_id)

# The number of wholesale packs we need to order to fulfill the
Expand All @@ -88,6 +96,26 @@ def add_item_to_backorder(variant, broker, backorder, orderer)
retail_quantity
end

# We have two different types of stock management:
#
# 1. on demand
# We don't restrict sales but account for the quantity sold in our local
# stock level. If it goes negative, we need more stock and trigger a
# backorder.
# 2. limited stock
# The local stock level is a copy from another catalog. We limit sales
# according to that stock level. Every order reduces the local stock level
# and needs to trigger a backorder of the same quantity to stay in sync.
def needed_quantity(line_item)
variant = line_item.variant

if variant.on_demand
-1 * variant.on_hand # on_hand is negative and we need to replenish it.
else
line_item.quantity # We need to order exactly what's we sold.
end
end

def load_broker(user, urls)
FdcOfferBroker.new(user, urls)
end
Expand Down
39 changes: 28 additions & 11 deletions app/jobs/complete_backorder_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def perform(user, distributor, order_cycle, order_id)
urls = FdcUrlBuilder.new(order.lines[0].offer.offeredItem.semanticId)

variants = order_cycle.variants_distributed_by(distributor)
adjust_quantities(user, order, urls, variants)
adjust_quantities(order_cycle, user, order, urls, variants)

FdcBackorderer.new(user, urls).complete_order(order)
rescue StandardError
Expand All @@ -36,7 +36,7 @@ def perform(user, distributor, order_cycle, order_id)
# Our local stock can increase when users cancel their orders.
# But stock levels could also have been adjusted manually. So we review all
# quantities before finalising the order.
def adjust_quantities(user, order, urls, variants)
def adjust_quantities(order_cycle, user, order, urls, variants)
broker = FdcOfferBroker.new(user, urls)

order.lines.each do |line|
Expand All @@ -45,18 +45,35 @@ def adjust_quantities(user, order, urls, variants)
transformation = broker.wholesale_to_retail(wholesale_product_id)
linked_variant = variants.linked_to(transformation.retail_product_id)

# Note that a division of integers dismisses the remainder, like `floor`:
wholesale_items_contained_in_stock = linked_variant.on_hand / transformation.factor

# But maybe we didn't actually order that much:
deductable_quantity = [line.quantity, wholesale_items_contained_in_stock].min
line.quantity -= deductable_quantity

retail_stock_changes = deductable_quantity * transformation.factor
linked_variant.on_hand -= retail_stock_changes
# Find all line items for this order cycle
# Update quantity accordingly
if linked_variant.on_demand
release_superfluous_stock(line, linked_variant, transformation)
else
aggregate_final_quantities(order_cycle, line, linked_variant, transformation)
end
end

# Clean up empty lines:
order.lines.reject! { |line| line.quantity.zero? }
end

def release_superfluous_stock(line, linked_variant, transformation)
# Note that a division of integers dismisses the remainder, like `floor`:
wholesale_items_contained_in_stock = linked_variant.on_hand / transformation.factor

# But maybe we didn't actually order that much:
deductable_quantity = [line.quantity, wholesale_items_contained_in_stock].min
line.quantity -= deductable_quantity

retail_stock_changes = deductable_quantity * transformation.factor
linked_variant.on_hand -= retail_stock_changes
end

def aggregate_final_quantities(order_cycle, line, variant, transformation)
orders = order_cycle.orders.invoiceable
quantity = Spree::LineItem.where(order: orders, variant:).sum(:quantity)
wholesale_quantity = (quantity.to_f / transformation.factor).ceil
line.quantity = wholesale_quantity
end
end
52 changes: 52 additions & 0 deletions app/jobs/stock_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

class StockSyncJob < ApplicationJob
# No retry but stay as failed job:
sidekiq_options retry: 0

# We synchronise stock of stock-controlled variants linked to a remote
# product. These variants are rare though and we check first before we
# enqueue a new job. That should save some time loading the order with
# all the stock data to make this decision.
def self.sync_linked_catalogs(order)
stock_controlled_variants = order.variants.reject(&:on_demand)
links = SemanticLink.where(variant_id: stock_controlled_variants.map(&:id))
semantic_ids = links.pluck(:semantic_id)

return if semantic_ids.empty?

user = order.distributor.owner
reference_id = semantic_ids.first # Assuming one catalog for now.
perform_later(user, reference_id)
rescue StandardError => e
# Errors here shouldn't affect the shopping. So let's report them
# separately:
Bugsnag.notify(e) do |payload|
payload.add_metadata(:order, order)
end
end

def perform(user, semantic_id)
urls = FdcUrlBuilder.new(semantic_id)
json_catalog = DfcRequest.new(user).call(urls.catalog_url)
graph = DfcIo.import(json_catalog)

products = graph.select do |subject|
subject.is_a? DataFoodConsortium::Connector::SuppliedProduct
end
products_by_id = products.index_by(&:semanticId)
product_ids = products_by_id.keys
variants = Spree::Variant.where(supplier: user.enterprises)
.includes(:semantic_links).references(:semantic_links)
.where(semantic_links: { semantic_id: product_ids })

variants.each do |variant|
next if variant.on_demand

product = products_by_id[variant.semantic_links[0].semantic_id]
catalog_item = product&.catalogItems&.first
CatalogItemBuilder.apply_stock(catalog_item, variant)
variant.stock_items[0].save!
end
end
end
4 changes: 2 additions & 2 deletions app/mailers/backorder_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
class BackorderMailer < ApplicationMailer
include I18nHelper

def backorder_failed(order, linked_variants)
def backorder_failed(order)
@order = order
@linked_variants = linked_variants
@linked_variants = order.variants
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now lists all variants associated with an order, whether they're linked or not. Hmm, regardless we probably only want to know about variants that had errors. I'll wait to see where the PR goes..

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we don't actually know what users want. We have no data on that. In the pilots, I think, all variants will be linked. Looking at errors could be good but also another source for errors. I think that we should wait until this actually happens and what people say about it.


I18n.with_locale valid_locale(order.distributor.owner) do
mail(to: order.distributor.owner.email)
Expand Down
Loading
Loading