Merge pull request #12888 from mkllnk/dfc-stock

[DFC Orders] Backorder stock controlled products
This commit is contained in:
Maikel
2024-10-08 10:57:59 +11:00
committed by GitHub
11 changed files with 402 additions and 368 deletions

View File

@@ -11,6 +11,8 @@ class CartController < BaseController
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(",") },

View File

@@ -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:
@@ -32,44 +24,60 @@ class BackorderJob < ApplicationJob
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?)
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
@@ -88,6 +96,26 @@ class BackorderJob < ApplicationJob
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

View File

@@ -22,7 +22,7 @@ class CompleteBackorderJob < ApplicationJob
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
@@ -36,7 +36,7 @@ class CompleteBackorderJob < ApplicationJob
# 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|
@@ -45,18 +45,35 @@ class CompleteBackorderJob < ApplicationJob
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

View File

@@ -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

View File

@@ -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
I18n.with_locale valid_locale(order.distributor.owner) do
mail(to: order.distributor.owner.email)