Move backorder update code to re-usable class

This commit is contained in:
Maikel Linke
2024-11-27 13:25:28 +11:00
parent d16cd8c84e
commit 4fa4eb1b4e
4 changed files with 256 additions and 126 deletions

View File

@@ -1,7 +1,5 @@
# frozen_string_literal: true
require 'open_food_network/order_cycle_permissions'
# When orders are created, adjusted or cancelled, we need to amend
# an existing backorder as well.
class AmendBackorderJob < ApplicationJob
@@ -13,125 +11,11 @@ class AmendBackorderJob < ApplicationJob
end
end
# 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)
variants = distributed_linked_variants(order)
return unless variants.any?
backorder = BackorderUpdater.new.amend_backorder(order)
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 = 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)
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
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
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 = managed_variants.linked_to(transformation.retail_product_id)
if linked_variant.nil?
transformation.factor = 1
linked_variant = managed_variants.linked_to(wholesale_product_id)
end
# 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
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 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
line.quantity = wholesale_quantity
urls = nil # Not needed to send order. The backorder id is the URL.
FdcBackorderer.new(user, urls).send_order(backorder) if backorder
end
end

View File

@@ -0,0 +1,135 @@
# frozen_string_literal: true
require 'open_food_network/order_cycle_permissions'
# Update a backorder to reflect all local orders and stock levels
# connected to the associated order cycle.
class BackorderUpdater
# Given an OFN order was created, changed or cancelled,
# we re-calculate how much to order in for every variant.
def amend_backorder(order)
variants = distributed_linked_variants(order)
# Temporary code: once we don't need a variant link to look up the
# backorder, we don't need this check anymore.
# Then we can adjust the backorder even though there are no linked variants
# in the order cycle right now. Some variants may have been in the order
# cycle before and got ordered before being removed from the order cycle.
return unless variants.any?
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 = 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)
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? }
backorder
end
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
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 = managed_variants.linked_to(transformation.retail_product_id)
if linked_variant.nil?
transformation.factor = 1
linked_variant = managed_variants.linked_to(wholesale_product_id)
end
# 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
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 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
line.quantity = wholesale_quantity
end
end

View File

@@ -108,11 +108,4 @@ RSpec.describe AmendBackorderJob do
.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

View File

@@ -0,0 +1,118 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BackorderUpdater do
let(:order) { create(:completed_order_with_totals) }
let(:distributor) { order.distributor }
let(:beans) { beans_item.variant }
let(:beans_item) { order.line_items[0] }
let(:chia_seed) { chia_item.variant }
let(:chia_item) { order.line_items[1] }
let(:user) { order.distributor.owner }
let(:catalog_json) { file_fixture("fdc-catalog.json").read }
let(:catalog_url) {
"https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts"
}
let(:product_link) {
"https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts/44519466467635"
}
let(:chia_seed_wholesale_link) {
"https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts/44519468433715"
}
before do
# This ensures that callbacks adjust stock correctly.
# See: https://github.com/openfoodfoundation/openfoodnetwork/pull/12938
order.reload
user.oidc_account = build(:testdfc_account)
beans.semantic_links << SemanticLink.new(
semantic_id: product_link
)
chia_seed.semantic_links << SemanticLink.new(
semantic_id: chia_seed_wholesale_link
)
order.order_cycle = create(
:simple_order_cycle,
distributors: [distributor],
variants: order.variants,
)
order.save!
beans.on_demand = true
beans_item.update!(quantity: 6)
beans.on_hand = -3
chia_item.update!(quantity: 5)
chia_seed.on_demand = false
chia_seed.on_hand = 7
end
describe "#amend_backorder" do
it "updates an order" do
stub_request(:get, catalog_url).to_return(body: catalog_json)
# Record the placed backorder:
backorder = nil
allow_any_instance_of(FdcBackorderer).to receive(:find_order) do |*_args|
backorder
end
allow_any_instance_of(FdcBackorderer).to receive(:find_open_order) do |*_args|
backorder
end
allow_any_instance_of(FdcBackorderer).to receive(:send_order) do |*args|
backorder = args[1]
end
BackorderJob.new.place_backorder(order)
# We ordered a case of 12 cans: -3 + 12 = 9
expect(beans.on_hand).to eq 9
# Stock controlled items don't change stock in backorder:
expect(chia_seed.on_hand).to eq 7
expect(backorder.lines[0].quantity).to eq 1 # beans
expect(backorder.lines[1].quantity).to eq 5 # chia
# Without any change, the backorder shouldn't get changed either:
subject.amend_backorder(order)
# Same as before:
expect(beans.on_hand).to eq 9
expect(chia_seed.on_hand).to eq 7
expect(backorder.lines[0].quantity).to eq 1 # beans
expect(backorder.lines[1].quantity).to eq 5 # chia
# 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 { 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