-
-
Notifications
You must be signed in to change notification settings - Fork 730
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Place backorders for linked products via DFC integration #12856
Changes from all commits
260e4f7
98966f6
439f0ca
6c6927a
827e37c
caa6d28
a7a3889
f839452
c7fa3ff
3e0eb87
7b286ea
3849db7
3ec53a7
c0ae2ed
8f4f873
14c32c0
efe2b72
95bc0cc
c948efd
95e620a
283db8f
5ef85ae
2eec4c7
4303f0e
fb96f8f
070b93c
7f62b49
66f0802
9f43244
2465780
eece738
61fec65
e31e45b
49fd1dc
495634b
989a6d5
51b3770
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
# frozen_string_literal: true | ||
|
||
class BackorderJob < ApplicationJob | ||
# In the current FDC project, one shop wants to review and adjust orders | ||
# before finalising. They also run a market stall and need to adjust stock | ||
# levels after the market. This should be done within four hours. | ||
SALE_SESSION_DELAYS = { | ||
# https://openfoodnetwork.org.uk/handleyfarm/shop | ||
"https://openfoodnetwork.org.uk/api/dfc/enterprises/203468" => 4.hours, | ||
}.freeze | ||
|
||
queue_as :default | ||
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 | ||
|
||
linked_variants = variants_needing_stock.select do |variant| | ||
variant.semantic_links.present? | ||
end | ||
|
||
perform_later(order, linked_variants) if linked_variants.present? | ||
rescue StandardError => e | ||
# Errors here shouldn't affect the checkout. So let's report them | ||
# separately: | ||
Bugsnag.notify(e) do |payload| | ||
payload.add_metadata(:order, order) | ||
end | ||
end | ||
|
||
def perform(order, linked_variants) | ||
OrderLocker.lock_order_and_variants(order) do | ||
place_backorder(order, linked_variants) | ||
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 | ||
|
||
raise | ||
end | ||
|
||
def place_backorder(order, linked_variants) | ||
user = order.distributor.owner | ||
|
||
# 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) | ||
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 | ||
end | ||
|
||
place_order(user, order, orderer, backorder) | ||
|
||
linked_variants.each do |variant| | ||
variant.on_hand += ordered_quantities[variant] | ||
end | ||
end | ||
|
||
def add_item_to_backorder(variant, broker, backorder, orderer) | ||
needed_quantity = -1 * variant.on_hand | ||
solution = broker.best_offer(variant.semantic_links[0].semantic_id) | ||
|
||
# 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 = orderer.find_or_build_order_line(backorder, solution.offer) | ||
line.quantity = line.quantity.to_i + wholesale_quantity | ||
|
||
retail_quantity | ||
end | ||
|
||
def load_broker(user, urls) | ||
FdcOfferBroker.new(user, urls) | ||
end | ||
|
||
def place_order(user, order, orderer, backorder) | ||
placed_order = orderer.send_order(backorder) | ||
|
||
return unless orderer.new?(backorder) | ||
|
||
delay = SALE_SESSION_DELAYS.fetch(backorder.client, 1.minute) | ||
wait_until = order.order_cycle.orders_close_at + delay | ||
CompleteBackorderJob.set(wait_until:) | ||
.perform_later( | ||
user, order.distributor, order.order_cycle, placed_order.semanticId | ||
) | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
# frozen_string_literal: true | ||
|
||
# After an order cycle closed, we need to finalise open draft orders placed | ||
# to replenish stock. | ||
class CompleteBackorderJob < ApplicationJob | ||
sidekiq_options retry: 0 | ||
|
||
# Required parameters: | ||
# | ||
# * user: to authenticate DFC requests | ||
# * distributor: to reconile with its catalog | ||
# * order_cycle: to scope the catalog when looking up variants | ||
# Multiple variants can be linked to the same remote product. | ||
# To reduce ambiguity, we'll reconcile only with products | ||
# from the given distributor in a given order cycle for which | ||
# the remote backorder was placed. | ||
# * order_id: the remote semantic id of a draft order | ||
# Having the id makes sure that we don't accidentally finalise | ||
# someone else's order. | ||
def perform(user, distributor, order_cycle, order_id) | ||
order = FdcBackorderer.new(user, nil).find_order(order_id) | ||
urls = FdcUrlBuilder.new(order.lines[0].offer.offeredItem.semanticId) | ||
|
||
variants = order_cycle.variants_distributed_by(distributor) | ||
adjust_quantities(user, order, urls, variants) | ||
|
||
FdcBackorderer.new(user, urls).complete_order(order) | ||
rescue StandardError | ||
BackorderMailer.backorder_incomplete(user, distributor, order_cycle, order_id).deliver_later | ||
|
||
raise | ||
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(user, order, urls, variants) | ||
broker = FdcOfferBroker.new(user, urls) | ||
|
||
order.lines.each do |line| | ||
line.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) | ||
|
||
# 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 | ||
|
||
# Clean up empty lines: | ||
order.lines.reject! { |line| line.quantity.zero? } | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
# frozen_string_literal: true | ||
|
||
class BackorderMailer < ApplicationMailer | ||
include I18nHelper | ||
|
||
def backorder_failed(order, linked_variants) | ||
@order = order | ||
@linked_variants = linked_variants | ||
|
||
I18n.with_locale valid_locale(order.distributor.owner) do | ||
mail(to: order.distributor.owner.email) | ||
end | ||
end | ||
|
||
def backorder_incomplete(user, distributor, order_cycle, order_id) | ||
@distributor = distributor | ||
@order_cycle = order_cycle | ||
@order_id = order_id | ||
|
||
I18n.with_locale valid_locale(user) do | ||
mail(to: user.email) | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -170,6 +170,11 @@ def self.active(currency = nil) | |
select("spree_variants.id") }) | ||
end | ||
|
||
def self.linked_to(semantic_id) | ||
includes(:semantic_links).references(:semantic_links) | ||
.where(semantic_links: { semantic_id: }).first | ||
dacook marked this conversation as resolved.
Show resolved
Hide resolved
|
||
end | ||
|
||
def tax_category | ||
super || TaxCategory.find_by(is_default: true) | ||
end | ||
|
@@ -235,6 +240,8 @@ def set_cost_currency | |
end | ||
|
||
def create_stock_items | ||
return unless stock_items.empty? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not covered directly by spec, but not a big deal. |
||
|
||
StockLocation.find_each do |stock_location| | ||
stock_location.propagate_variant(self) | ||
end | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
# frozen_string_literal: true | ||
|
||
# Place and update orders based on missing stock. | ||
class FdcBackorderer | ||
attr_reader :user, :urls | ||
|
||
def initialize(user, urls) | ||
@user = user | ||
@urls = urls | ||
end | ||
|
||
def find_or_build_order(ofn_order) | ||
find_open_order || build_new_order(ofn_order) | ||
end | ||
|
||
def build_new_order(ofn_order) | ||
OrderBuilder.new_order(ofn_order, urls.orders_url).tap do |order| | ||
order.saleSession = build_sale_session(ofn_order) | ||
end | ||
end | ||
|
||
def find_open_order | ||
graph = import(urls.orders_url) | ||
open_orders = graph&.select do |o| | ||
o.semanticType == "dfc-b:Order" && o.orderStatus[:path] == "Held" | ||
end | ||
|
||
return if open_orders.blank? | ||
|
||
# If there are multiple open orders, we don't know which one to choose. | ||
# We want the order we placed for the same distributor in the same order | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok. I'm pretty sure the Shopify Orders endpoint will display old (completed orders), we should only have 1 Draft order though. The Shopify Hub app is storing the ID of the Order returned from the initial POST request, to utilise in future PUT requests. Could we do that ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could. And maybe we need to. I tried to get around it to have minimal local data. Local data structures increase maintenance and can become out of sync. And I was worried about longer dev times as well. But I agree that the current solution isn't great. |
||
# cycle before. So here are some assumptions for this to work: | ||
# | ||
# * We see only orders for our distributor. The endpoint URL contains the | ||
# the distributor name and is currently hardcoded. | ||
# * There's only one open order cycle at a time. Otherwise we may select | ||
# an order of an old order cycle. | ||
# * Orders are finalised when the order cycle closes. So _Held_ orders | ||
# always belong to an open order cycle. | ||
# * We see only our own orders. This assumption is wrong. The Shopify | ||
# integration places held orders as well and they are visible to us. | ||
# | ||
# Unfortunately, the endpoint doesn't tell who placed the order. | ||
# TODO: We need to remember the link to the order locally. | ||
# Or the API is updated to include the orderer. | ||
# | ||
# For now, we just guess: | ||
open_orders.last.tap do |order| | ||
# The DFC Connector doesn't recognise status values properly yet. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mkllnk could you elaborate on what you mean by "DFC Connector doesn't recognise status values properly"? 😟 I thought this was sorted & we haven't had any issues with the TS Connector. @lecoqlibre do we need to update the Ruby Connector? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, we can load the vocabulary with order states. The Connector doesn't have a built-in method for that but I did make it work in a separate branch. I could work on a pull request to make this part work with the importer. I was just shying away from the effort to meet the deadline. The current version is also not recognising the transformation links. That may just be the outdated context though. We are still on 1.8, I think. And I wasn't keen to follow the updates closely because you discovered bugs here and there. Happy to give it another go though. |
||
# So we are overriding the value with something that can be exported. | ||
order.orderStatus = "dfc-v:Held" | ||
end | ||
end | ||
|
||
def find_order(semantic_id) | ||
find_subject(import(semantic_id), "dfc-b:Order") | ||
end | ||
|
||
def find_or_build_order_line(order, offer) | ||
find_order_line(order, offer) || build_order_line(order, offer) | ||
end | ||
|
||
def build_order_line(order, offer) | ||
# Order lines are enumerated in the FDC API and we must assign a unique | ||
# semantic id. We need to look at current ids to avoid collisions. | ||
# existing_ids = order.lines.map do |line| | ||
# line.semanticId.match(/[0-9]+$/).to_s.to_i | ||
# end | ||
# next_id = existing_ids.max.to_i + 1 | ||
|
||
# Suggested by FDC team: | ||
next_id = order.lines.count + 1 | ||
|
||
OrderLineBuilder.build(offer, 0).tap do |line| | ||
line.semanticId = "#{order.semanticId}/OrderLines/#{next_id}" | ||
order.lines << line | ||
end | ||
end | ||
|
||
def find_order_line(order, offer) | ||
order.lines.find do |line| | ||
line.offer.offeredItem.semanticId == offer.offeredItem.semanticId | ||
end | ||
end | ||
|
||
def find_subject(object_or_graph, type) | ||
if object_or_graph.is_a?(Array) | ||
object_or_graph.find { |i| i.semanticType == type } | ||
else | ||
object_or_graph | ||
end | ||
end | ||
|
||
def import(url) | ||
api = DfcRequest.new(user) | ||
json = api.call(url) | ||
DfcIo.import(json) | ||
end | ||
|
||
def send_order(backorder) | ||
lines = backorder.lines | ||
offers = lines.map(&:offer) | ||
products = offers.map(&:offeredItem) | ||
sessions = [backorder.saleSession].compact | ||
json = DfcIo.export(backorder, *lines, *offers, *products, *sessions) | ||
|
||
api = DfcRequest.new(user) | ||
|
||
method = if new?(backorder) | ||
:post # -> create | ||
else | ||
:put # -> update | ||
end | ||
|
||
result = api.call(backorder.semanticId, json, method:) | ||
find_subject(DfcIo.import(result), "dfc-b:Order") | ||
end | ||
|
||
def complete_order(backorder) | ||
backorder.orderStatus = "dfc-v:Complete" | ||
send_order(backorder) | ||
end | ||
|
||
def new?(order) | ||
order.semanticId == urls.orders_url | ||
end | ||
|
||
def build_sale_session(order) | ||
SaleSessionBuilder.build(order.order_cycle).tap do |session| | ||
session.semanticId = urls.sale_session_url | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm happy with that assumption (for now 😉 ), and it will be true for the pilots we're running.