From d16cd8c84eaba8424a8ffefb431fb6d913e3616b Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 22 Nov 2024 16:54:58 +1100 Subject: [PATCH] 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. --- app/jobs/amend_backorder_job.rb | 139 +++++++++++++++++--------- spec/jobs/amend_backorder_job_spec.rb | 30 +++++- 2 files changed, 114 insertions(+), 55 deletions(-) diff --git a/app/jobs/amend_backorder_job.rb b/app/jobs/amend_backorder_job.rb index 1e6ffd7854..8e5687249b 100644 --- a/app/jobs/amend_backorder_job.rb +++ b/app/jobs/amend_backorder_job.rb @@ -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 diff --git a/spec/jobs/amend_backorder_job_spec.rb b/spec/jobs/amend_backorder_job_spec.rb index 9f7ba52615..94b6ca65ee 100644 --- a/spec/jobs/amend_backorder_job_spec.rb +++ b/spec/jobs/amend_backorder_job_spec.rb @@ -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