From 4fa4eb1b4e7f6e98cb1e0beeb2936881282b4c09 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 27 Nov 2024 13:25:28 +1100 Subject: [PATCH] Move backorder update code to re-usable class --- app/jobs/amend_backorder_job.rb | 122 +-------------------- app/services/backorder_updater.rb | 135 ++++++++++++++++++++++++ spec/jobs/amend_backorder_job_spec.rb | 7 -- spec/services/backorder_updater_spec.rb | 118 +++++++++++++++++++++ 4 files changed, 256 insertions(+), 126 deletions(-) create mode 100644 app/services/backorder_updater.rb create mode 100644 spec/services/backorder_updater_spec.rb diff --git a/app/jobs/amend_backorder_job.rb b/app/jobs/amend_backorder_job.rb index 8e5687249b..02429b6ef2 100644 --- a/app/jobs/amend_backorder_job.rb +++ b/app/jobs/amend_backorder_job.rb @@ -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 diff --git a/app/services/backorder_updater.rb b/app/services/backorder_updater.rb new file mode 100644 index 0000000000..d8425a3ea7 --- /dev/null +++ b/app/services/backorder_updater.rb @@ -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 diff --git a/spec/jobs/amend_backorder_job_spec.rb b/spec/jobs/amend_backorder_job_spec.rb index 94b6ca65ee..9229db6d6c 100644 --- a/spec/jobs/amend_backorder_job_spec.rb +++ b/spec/jobs/amend_backorder_job_spec.rb @@ -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 diff --git a/spec/services/backorder_updater_spec.rb b/spec/services/backorder_updater_spec.rb new file mode 100644 index 0000000000..da5e4aa6d2 --- /dev/null +++ b/spec/services/backorder_updater_spec.rb @@ -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