Amend backorder completely

Update every single order line to reflect local orders and stock levels.

New cases supported:

* Add lines for orders created by an admin.
* Create backorder line after increase of local line item quantity.
* Adjust local stock after variant has been removed from order cycle.
This commit is contained in:
Maikel Linke
2024-11-22 16:54:58 +11:00
parent 9870abfb1c
commit d16cd8c84e
2 changed files with 114 additions and 55 deletions

View File

@@ -1,8 +1,9 @@
# frozen_string_literal: true
# When orders are cancelled, we need to amend
require 'open_food_network/order_cycle_permissions'
# When orders are created, adjusted or cancelled, we need to amend
# an existing backorder as well.
# We're not dealing with line item changes just yet.
class AmendBackorderJob < ApplicationJob
sidekiq_options retry: 0
@@ -15,81 +16,119 @@ class AmendBackorderJob < ApplicationJob
# The following is a mix of the BackorderJob and the CompleteBackorderJob.
# TODO: Move the common code into a re-usable service class.
def amend_backorder(order)
order_cycle = order.order_cycle
distributor = order.distributor
user = distributor.owner
items = backorderable_items(order)
variants = distributed_linked_variants(order)
return unless variants.any?
return if items.empty?
user = order.distributor.owner
order_cycle = order.order_cycle
# We are assuming that all variants are linked to the same wholesale
# shop and its catalog:
reference_link = items[0].variant.semantic_links[0].semantic_id
reference_link = variants[0].semantic_links[0].semantic_id
urls = FdcUrlBuilder.new(reference_link)
orderer = FdcBackorderer.new(user, urls)
broker = FdcOfferBroker.new(user, urls)
backorder = orderer.find_open_order(order)
variants = order_cycle.variants_distributed_by(distributor)
adjust_quantities(order_cycle, user, backorder, urls, variants)
updated_lines = update_order_lines(backorder, order_cycle, variants, broker, orderer)
unprocessed_lines = backorder.lines.to_set - updated_lines
cancel_stale_lines(unprocessed_lines, order, broker)
# Clean up empty lines:
backorder.lines.reject! { |line| line.quantity.zero? }
FdcBackorderer.new(user, urls).send_order(backorder)
end
# Check if we have enough stock to reduce the backorder.
#
# 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(order_cycle, user, order, urls, variants)
broker = FdcOfferBroker.new(user, urls)
def update_order_lines(backorder, order_cycle, variants, broker, orderer)
variants.map do |variant|
link = variant.semantic_links[0].semantic_id
solution = broker.best_offer(link)
line = orderer.find_or_build_order_line(backorder, solution.offer)
if variant.on_demand
adjust_stock(variant, solution, line)
else
aggregate_final_quantities(order_cycle, line, variant, solution)
end
order.lines.each do |line|
line.quantity = line.quantity.to_i
line
end
end
def cancel_stale_lines(unprocessed_lines, order, broker)
managed_variants = managed_linked_variants(order)
unprocessed_lines.each do |line|
wholesale_quantity = line.quantity.to_i
wholesale_product_id = line.offer.offeredItem.semanticId
transformation = broker.wholesale_to_retail(wholesale_product_id)
linked_variant = variants.linked_to(transformation.retail_product_id)
linked_variant = managed_variants.linked_to(transformation.retail_product_id)
# Assumption: If a transformation is present then we only sell the retail
# variant. If that can't be found, it was deleted and we'll ignore that
# for now.
next if linked_variant.nil?
if linked_variant.nil?
transformation.factor = 1
linked_variant = managed_variants.linked_to(wholesale_product_id)
end
# 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)
# Adjust stock level back, we're not going to order this one.
if linked_variant&.on_demand
retail_quantity = wholesale_quantity * transformation.factor
linked_variant.on_hand -= retail_quantity
end
# We don't have any active orders for this
line.quantity = 0
end
end
def adjust_stock(variant, solution, line)
if variant.on_hand.negative?
needed_quantity = -1 * variant.on_hand # We need to replenish it.
# The number of wholesale packs we need to order to fulfill the
# needed quantity.
# For example, we order 2 packs of 12 cans if we need 15 cans.
wholesale_quantity = (needed_quantity.to_f / solution.factor).ceil
# The number of individual retail items we get with the wholesale order.
# For example, if we order 2 packs of 12 cans, we will get 24 cans
# and we'll account for that in our stock levels.
retail_quantity = wholesale_quantity * solution.factor
line.quantity = line.quantity.to_i + wholesale_quantity
variant.on_hand += retail_quantity
else
# Note that a division of integers dismisses the remainder, like `floor`:
wholesale_items_contained_in_stock = variant.on_hand / solution.factor
# But maybe we didn't actually order that much:
deductable_quantity = [line.quantity, wholesale_items_contained_in_stock].min
if deductable_quantity.positive?
line.quantity -= deductable_quantity
retail_stock_change = deductable_quantity * solution.factor
variant.on_hand -= retail_stock_change
end
end
# Clean up empty lines:
order.lines.reject! { |line| line.quantity.zero? }
end
# We look at all linked variants.
def backorderable_items(order)
order.line_items.select do |item|
# TODO: scope variants to hub.
# We are only supporting producer stock at the moment.
item.variant.semantic_links.present?
end
def managed_linked_variants(order)
user = order.distributor.owner
order_cycle = order.order_cycle
# These permissions may be too complex. Here may be scope to optimise.
permissions = OpenFoodNetwork::OrderCyclePermissions.new(user, order_cycle)
permissions.visible_variants_for_outgoing_exchanges_to(order.distributor)
.where.associated(:semantic_links)
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
def distributed_linked_variants(order)
order.order_cycle.variants_distributed_by(order.distributor)
.where.associated(:semantic_links)
end
def aggregate_final_quantities(order_cycle, line, variant, transformation)
# We may want to query all these quantities in one go instead of this n+1.
orders = order_cycle.orders.invoiceable
quantity = Spree::LineItem.where(order: orders, variant:).sum(:quantity)
wholesale_quantity = (quantity.to_f / transformation.factor).ceil

View File

@@ -86,13 +86,33 @@ RSpec.describe AmendBackorderJob do
expect(backorder.lines[0].quantity).to eq 1 # beans
expect(backorder.lines[1].quantity).to eq 5 # chia
# We cancel the only order and that should reduce the order lines to 0.
expect { order.cancel! }
.to change { beans.reload.on_hand }.from(9).to(15)
.and change { chia_seed.reload.on_hand }.from(7).to(12)
# We increase quantities which should be reflected in the backorder:
beans.on_hand = -1
chia_item.quantity += 3
chia_item.save!
expect { subject.amend_backorder(order) }
.to change { backorder.lines.count }.from(2).to(0)
.to change { beans.on_hand }.from(-1).to(11)
.and change { backorder.lines[0].quantity }.from(1).to(2)
.and change { backorder.lines[1].quantity }.from(5).to(8)
# We cancel the only order.
expect { order.cancel! }
.to change { beans.reload.on_hand }.from(11).to(17)
.and change { chia_seed.reload.on_hand }.from(4).to(12)
# But we decreased the stock of beans outside of orders above.
# So only the chia seeds are cancelled. The beans still need replenishing.
expect { subject.amend_backorder(order) }
.to change { backorder.lines.count }.from(2).to(1)
.and change { beans.reload.on_hand }.by(-12)
end
end
describe "#distributed_linked_variants" do
it "selects available variants with semantic links" do
variants = subject.distributed_linked_variants(order)
expect(variants).to match_array [beans, chia_seed]
end
end
end