mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-01-24 20:36:49 +00:00
Merge pull request #12888 from mkllnk/dfc-stock
[DFC Orders] Backorder stock controlled products
This commit is contained in:
@@ -11,6 +11,8 @@ class CartController < BaseController
|
||||
order.cap_quantity_at_stock!
|
||||
order.recreate_all_fees!
|
||||
|
||||
StockSyncJob.sync_linked_catalogs(order)
|
||||
|
||||
render json: { error: false, stock_levels: stock_levels(order) }, status: :ok
|
||||
else
|
||||
render json: { error: cart_service.errors.full_messages.join(",") },
|
||||
|
||||
@@ -13,17 +13,9 @@ class BackorderJob < ApplicationJob
|
||||
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
|
||||
links = SemanticLink.where(variant_id: order.line_items.select(:variant_id))
|
||||
|
||||
linked_variants = variants_needing_stock.select do |variant|
|
||||
variant.semantic_links.present?
|
||||
end
|
||||
|
||||
perform_later(order, linked_variants) if linked_variants.present?
|
||||
perform_later(order) if links.exists?
|
||||
rescue StandardError => e
|
||||
# Errors here shouldn't affect the checkout. So let's report them
|
||||
# separately:
|
||||
@@ -32,44 +24,60 @@ class BackorderJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def perform(order, linked_variants)
|
||||
def perform(order)
|
||||
OrderLocker.lock_order_and_variants(order) do
|
||||
place_backorder(order, linked_variants)
|
||||
place_backorder(order)
|
||||
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
|
||||
BackorderMailer.backorder_failed(order).deliver_later
|
||||
|
||||
raise
|
||||
end
|
||||
|
||||
def place_backorder(order, linked_variants)
|
||||
def place_backorder(order)
|
||||
user = order.distributor.owner
|
||||
items = backorderable_items(order)
|
||||
|
||||
# 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)
|
||||
reference_link = items[0].variant.semantic_links[0].semantic_id
|
||||
urls = FdcUrlBuilder.new(reference_link)
|
||||
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
|
||||
items.each do |item|
|
||||
retail_quantity = add_item_to_backorder(item, broker, backorder, orderer)
|
||||
ordered_quantities[item] = retail_quantity
|
||||
end
|
||||
|
||||
place_order(user, order, orderer, backorder)
|
||||
|
||||
linked_variants.each do |variant|
|
||||
variant.on_hand += ordered_quantities[variant]
|
||||
items.each do |item|
|
||||
variant = item.variant
|
||||
variant.on_hand += ordered_quantities[item] if variant.on_demand
|
||||
end
|
||||
end
|
||||
|
||||
def add_item_to_backorder(variant, broker, backorder, orderer)
|
||||
needed_quantity = -1 * variant.on_hand
|
||||
# We look at linked variants which are either stock controlled or
|
||||
# are on demand with negative stock.
|
||||
def backorderable_items(order)
|
||||
order.line_items.select do |item|
|
||||
# TODO: scope variants to hub.
|
||||
# We are only supporting producer stock at the moment.
|
||||
variant = item.variant
|
||||
variant.semantic_links.present? &&
|
||||
(variant.on_demand == false || variant.on_hand&.negative?)
|
||||
end
|
||||
end
|
||||
|
||||
def add_item_to_backorder(line_item, broker, backorder, orderer)
|
||||
variant = line_item.variant
|
||||
needed_quantity = needed_quantity(line_item)
|
||||
solution = broker.best_offer(variant.semantic_links[0].semantic_id)
|
||||
|
||||
# The number of wholesale packs we need to order to fulfill the
|
||||
@@ -88,6 +96,26 @@ class BackorderJob < ApplicationJob
|
||||
retail_quantity
|
||||
end
|
||||
|
||||
# We have two different types of stock management:
|
||||
#
|
||||
# 1. on demand
|
||||
# We don't restrict sales but account for the quantity sold in our local
|
||||
# stock level. If it goes negative, we need more stock and trigger a
|
||||
# backorder.
|
||||
# 2. limited stock
|
||||
# The local stock level is a copy from another catalog. We limit sales
|
||||
# according to that stock level. Every order reduces the local stock level
|
||||
# and needs to trigger a backorder of the same quantity to stay in sync.
|
||||
def needed_quantity(line_item)
|
||||
variant = line_item.variant
|
||||
|
||||
if variant.on_demand
|
||||
-1 * variant.on_hand # on_hand is negative and we need to replenish it.
|
||||
else
|
||||
line_item.quantity # We need to order exactly what's we sold.
|
||||
end
|
||||
end
|
||||
|
||||
def load_broker(user, urls)
|
||||
FdcOfferBroker.new(user, urls)
|
||||
end
|
||||
|
||||
@@ -22,7 +22,7 @@ class CompleteBackorderJob < ApplicationJob
|
||||
urls = FdcUrlBuilder.new(order.lines[0].offer.offeredItem.semanticId)
|
||||
|
||||
variants = order_cycle.variants_distributed_by(distributor)
|
||||
adjust_quantities(user, order, urls, variants)
|
||||
adjust_quantities(order_cycle, user, order, urls, variants)
|
||||
|
||||
FdcBackorderer.new(user, urls).complete_order(order)
|
||||
rescue StandardError
|
||||
@@ -36,7 +36,7 @@ class CompleteBackorderJob < ApplicationJob
|
||||
# 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)
|
||||
def adjust_quantities(order_cycle, user, order, urls, variants)
|
||||
broker = FdcOfferBroker.new(user, urls)
|
||||
|
||||
order.lines.each do |line|
|
||||
@@ -45,18 +45,35 @@ class CompleteBackorderJob < ApplicationJob
|
||||
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
|
||||
# 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)
|
||||
end
|
||||
end
|
||||
|
||||
# Clean up empty lines:
|
||||
order.lines.reject! { |line| line.quantity.zero? }
|
||||
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
|
||||
end
|
||||
|
||||
def aggregate_final_quantities(order_cycle, line, variant, transformation)
|
||||
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
|
||||
|
||||
52
app/jobs/stock_sync_job.rb
Normal file
52
app/jobs/stock_sync_job.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StockSyncJob < ApplicationJob
|
||||
# No retry but stay as failed job:
|
||||
sidekiq_options retry: 0
|
||||
|
||||
# We synchronise stock of stock-controlled variants linked to a remote
|
||||
# product. These variants are rare though and we check first before we
|
||||
# enqueue a new job. That should save some time loading the order with
|
||||
# all the stock data to make this decision.
|
||||
def self.sync_linked_catalogs(order)
|
||||
stock_controlled_variants = order.variants.reject(&:on_demand)
|
||||
links = SemanticLink.where(variant_id: stock_controlled_variants.map(&:id))
|
||||
semantic_ids = links.pluck(:semantic_id)
|
||||
|
||||
return if semantic_ids.empty?
|
||||
|
||||
user = order.distributor.owner
|
||||
reference_id = semantic_ids.first # Assuming one catalog for now.
|
||||
perform_later(user, reference_id)
|
||||
rescue StandardError => e
|
||||
# Errors here shouldn't affect the shopping. So let's report them
|
||||
# separately:
|
||||
Bugsnag.notify(e) do |payload|
|
||||
payload.add_metadata(:order, order)
|
||||
end
|
||||
end
|
||||
|
||||
def perform(user, semantic_id)
|
||||
urls = FdcUrlBuilder.new(semantic_id)
|
||||
json_catalog = DfcRequest.new(user).call(urls.catalog_url)
|
||||
graph = DfcIo.import(json_catalog)
|
||||
|
||||
products = graph.select do |subject|
|
||||
subject.is_a? DataFoodConsortium::Connector::SuppliedProduct
|
||||
end
|
||||
products_by_id = products.index_by(&:semanticId)
|
||||
product_ids = products_by_id.keys
|
||||
variants = Spree::Variant.where(supplier: user.enterprises)
|
||||
.includes(:semantic_links).references(:semantic_links)
|
||||
.where(semantic_links: { semantic_id: product_ids })
|
||||
|
||||
variants.each do |variant|
|
||||
next if variant.on_demand
|
||||
|
||||
product = products_by_id[variant.semantic_links[0].semantic_id]
|
||||
catalog_item = product&.catalogItems&.first
|
||||
CatalogItemBuilder.apply_stock(catalog_item, variant)
|
||||
variant.stock_items[0].save!
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,9 +3,9 @@
|
||||
class BackorderMailer < ApplicationMailer
|
||||
include I18nHelper
|
||||
|
||||
def backorder_failed(order, linked_variants)
|
||||
def backorder_failed(order)
|
||||
@order = order
|
||||
@linked_variants = linked_variants
|
||||
@linked_variants = order.variants
|
||||
|
||||
I18n.with_locale valid_locale(order.distributor.owner) do
|
||||
mail(to: order.distributor.owner.email)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,11 +4,16 @@ require 'spec_helper'
|
||||
|
||||
RSpec.describe BackorderJob do
|
||||
let(:order) { create(:completed_order_with_totals) }
|
||||
let(:variant) { order.variants.first }
|
||||
let(:beans) { order.line_items.first.variant }
|
||||
let(:chia_seed) { chia_item.variant }
|
||||
let(:chia_item) { order.line_items.second }
|
||||
let(:user) { order.distributor.owner }
|
||||
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
|
||||
user.oidc_account = OidcAccount.new(
|
||||
@@ -25,16 +30,14 @@ RSpec.describe BackorderJob do
|
||||
}.not_to enqueue_job(BackorderJob)
|
||||
end
|
||||
|
||||
it "enqueues backorder", vcr: true do
|
||||
variant.on_demand = true
|
||||
variant.on_hand = -3
|
||||
variant.semantic_links << SemanticLink.new(
|
||||
it "enqueues backorder" do
|
||||
beans.semantic_links << SemanticLink.new(
|
||||
semantic_id: product_link
|
||||
)
|
||||
|
||||
expect {
|
||||
BackorderJob.check_stock(order)
|
||||
}.to enqueue_job(BackorderJob).with(order, [variant])
|
||||
}.to enqueue_job(BackorderJob).with(order)
|
||||
end
|
||||
|
||||
it "reports errors" do
|
||||
@@ -48,8 +51,9 @@ RSpec.describe BackorderJob do
|
||||
|
||||
describe "#peform" do
|
||||
it "notifies owner of errors" do
|
||||
incorrect_order = create(:order)
|
||||
expect {
|
||||
subject.perform(order, [])
|
||||
subject.perform(incorrect_order)
|
||||
}.to enqueue_mail(BackorderMailer, :backorder_failed)
|
||||
.and raise_error(NoMethodError)
|
||||
end
|
||||
@@ -60,21 +64,42 @@ RSpec.describe BackorderJob do
|
||||
order.order_cycle = create(
|
||||
:simple_order_cycle,
|
||||
distributors: [order.distributor],
|
||||
variants: [variant],
|
||||
variants: [beans],
|
||||
)
|
||||
completion_time = order.order_cycle.orders_close_at + 1.minute
|
||||
variant.on_demand = true
|
||||
variant.on_hand = -3
|
||||
variant.semantic_links << SemanticLink.new(
|
||||
beans.on_demand = true
|
||||
beans.on_hand = -3
|
||||
beans.semantic_links << SemanticLink.new(
|
||||
semantic_id: product_link
|
||||
)
|
||||
|
||||
chia_item.quantity = 5
|
||||
chia_seed.on_demand = false
|
||||
chia_seed.on_hand = 7
|
||||
chia_seed.semantic_links << SemanticLink.new(
|
||||
semantic_id: chia_seed_wholesale_link
|
||||
)
|
||||
|
||||
# Record the placed backorder:
|
||||
backorder = nil
|
||||
allow_any_instance_of(FdcBackorderer).to receive(:send_order)
|
||||
.and_wrap_original do |original_method, *args, &_block|
|
||||
backorder = args[0]
|
||||
original_method.call(*args)
|
||||
end
|
||||
|
||||
expect {
|
||||
subject.place_backorder(order, [variant])
|
||||
subject.place_backorder(order)
|
||||
}.to enqueue_job(CompleteBackorderJob).at(completion_time)
|
||||
|
||||
# We ordered a case of 12 cans: -3 + 12 = 9
|
||||
expect(variant.on_hand).to eq 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
|
||||
|
||||
# Clean up after ourselves:
|
||||
perform_enqueued_jobs(only: CompleteBackorderJob)
|
||||
|
||||
@@ -3,52 +3,62 @@
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe CompleteBackorderJob do
|
||||
let(:user) { build(:testdfc_user) }
|
||||
let(:catalog) {
|
||||
VCR.use_cassette(:fdc_catalog) { FdcOfferBroker.load_catalog(user, urls) }
|
||||
}
|
||||
let(:user) { create(:testdfc_user) }
|
||||
let(:urls) { FdcUrlBuilder.new(product_link) }
|
||||
let(:product_link) {
|
||||
"https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts/44519466467635"
|
||||
}
|
||||
let(:retail_product) {
|
||||
catalog.find { |item| item.semanticType == "dfc-b:SuppliedProduct" }
|
||||
}
|
||||
let(:wholesale_product) {
|
||||
flow = catalog.find { |item| item.semanticType == "dfc-b:AsPlannedProductionFlow" }
|
||||
catalog.find { |item| item.semanticId == flow.product }
|
||||
let(:chia_seed_retail_link) {
|
||||
"https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts/44519468400947"
|
||||
}
|
||||
let(:orderer) { FdcBackorderer.new(user, urls) }
|
||||
let(:order) {
|
||||
backorder = orderer.find_or_build_order(ofn_order)
|
||||
broker = FdcOfferBroker.new(user, urls)
|
||||
offer = broker.best_offer(retail_product.semanticId).offer
|
||||
line = orderer.find_or_build_order_line(backorder, offer)
|
||||
line.quantity = 3
|
||||
|
||||
bean_offer = broker.best_offer(product_link).offer
|
||||
bean_line = orderer.find_or_build_order_line(backorder, bean_offer)
|
||||
bean_line.quantity = 3
|
||||
|
||||
chia = broker.catalog_item(chia_seed_retail_link)
|
||||
chia_offer = broker.offer_of(chia)
|
||||
chia_line = orderer.find_or_build_order_line(backorder, chia_offer)
|
||||
chia_line.quantity = 5
|
||||
|
||||
orderer.send_order(backorder)
|
||||
}
|
||||
let(:ofn_order) { create(:completed_order_with_totals) }
|
||||
let(:distributor) { ofn_order.distributor }
|
||||
let(:order_cycle) { ofn_order.order_cycle }
|
||||
let(:variant) { ofn_order.variants[0] }
|
||||
let(:beans) { ofn_order.line_items[0].variant }
|
||||
let(:chia) { chia_item.variant }
|
||||
let(:chia_item) { ofn_order.line_items[1] }
|
||||
|
||||
describe "#perform" do
|
||||
before do
|
||||
variant.semantic_links << SemanticLink.new(
|
||||
semantic_id: retail_product.semanticId
|
||||
beans.semantic_links << SemanticLink.new(
|
||||
semantic_id: product_link
|
||||
)
|
||||
chia.semantic_links << SemanticLink.new(
|
||||
semantic_id: chia_seed_retail_link
|
||||
)
|
||||
ofn_order.order_cycle = create(
|
||||
:simple_order_cycle,
|
||||
distributors: [distributor],
|
||||
variants: [variant],
|
||||
variants: ofn_order.variants,
|
||||
)
|
||||
ofn_order.save!
|
||||
end
|
||||
|
||||
it "completes an order", vcr: true do
|
||||
# We are assuming 12 cans in a slab.
|
||||
# We got more stock than we need.
|
||||
variant.on_hand = 13
|
||||
beans.on_demand = true
|
||||
beans.on_hand = 13
|
||||
|
||||
chia.on_demand = false
|
||||
chia.on_hand = 17
|
||||
chia_item.update!(quantity: 7)
|
||||
|
||||
current_order = order
|
||||
|
||||
@@ -62,8 +72,11 @@ RSpec.describe CompleteBackorderJob do
|
||||
current_order.lines[0].quantity.to_i
|
||||
}.from(3).to(2)
|
||||
.and change {
|
||||
variant.on_hand
|
||||
beans.on_hand
|
||||
}.from(13).to(1)
|
||||
.and change {
|
||||
current_order.lines[1].quantity.to_i
|
||||
}.from(5).to(7)
|
||||
end
|
||||
|
||||
it "removes line items", vcr: true do
|
||||
@@ -71,7 +84,8 @@ RSpec.describe CompleteBackorderJob do
|
||||
# We backordered 3 slabs, which is 36 cans.
|
||||
# And now we would have more than 4 slabs (4*12 + 1 = 49)
|
||||
# We got more stock than we need.
|
||||
variant.on_hand = 49
|
||||
beans.on_demand = true
|
||||
beans.on_hand = 49
|
||||
|
||||
current_order = order
|
||||
|
||||
@@ -85,7 +99,7 @@ RSpec.describe CompleteBackorderJob do
|
||||
current_order.lines.count
|
||||
}.from(1).to(0)
|
||||
.and change {
|
||||
variant.on_hand
|
||||
beans.on_hand
|
||||
}.from(49).to(13) # minus 3 backordered slabs (3 * 12 = 36)
|
||||
end
|
||||
|
||||
|
||||
53
spec/jobs/stock_sync_job_spec.rb
Normal file
53
spec/jobs/stock_sync_job_spec.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe StockSyncJob do
|
||||
let(:order) { create(:order_with_totals, distributor:) }
|
||||
let(:distributor) { build(:enterprise, owner: user) }
|
||||
let(:user) { build(:testdfc_user) }
|
||||
let(:beans) { order.variants.first }
|
||||
let(:beans_retail_link) {
|
||||
"https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts/44519466467635"
|
||||
}
|
||||
|
||||
describe ".sync_linked_catalogs" do
|
||||
subject { StockSyncJob.sync_linked_catalogs(order) }
|
||||
it "ignores products without semantic link" do
|
||||
expect { subject }.not_to enqueue_job(StockSyncJob)
|
||||
end
|
||||
|
||||
it "enqueues backorder" do
|
||||
beans.semantic_links << SemanticLink.new(
|
||||
semantic_id: beans_retail_link
|
||||
)
|
||||
|
||||
expect { subject }.to enqueue_job(StockSyncJob)
|
||||
.with(user, beans_retail_link)
|
||||
end
|
||||
|
||||
it "reports errors" do
|
||||
expect(order).to receive(:variants).and_raise("test error")
|
||||
expect(Bugsnag).to receive(:notify).and_call_original
|
||||
|
||||
expect { subject }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
describe "#peform" do
|
||||
subject { StockSyncJob.perform_now(user, beans_retail_link) }
|
||||
|
||||
before do
|
||||
distributor.save!
|
||||
user.enterprises << distributor
|
||||
beans.update!(supplier: distributor)
|
||||
beans.semantic_links << SemanticLink.new(semantic_id: beans_retail_link)
|
||||
end
|
||||
|
||||
it "updates stock" do
|
||||
expect { VCR.use_cassette(:fdc_catalog) { subject } }.to change {
|
||||
beans.on_demand
|
||||
}.from(false).to(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,13 +4,12 @@ require 'spec_helper'
|
||||
|
||||
RSpec.describe BackorderMailer do
|
||||
let(:order) { create(:completed_order_with_totals) }
|
||||
let(:variants) { order.line_items.map(&:variant) }
|
||||
|
||||
describe "#backorder_failed" do
|
||||
it "notifies the owner" do
|
||||
order.distributor.owner.email = "jane@example.net"
|
||||
|
||||
BackorderMailer.backorder_failed(order, variants).deliver_now
|
||||
BackorderMailer.backorder_failed(order).deliver_now
|
||||
|
||||
mail = ActionMailer::Base.deliveries.first
|
||||
expect(mail.to).to eq ["jane@example.net"]
|
||||
|
||||
Reference in New Issue
Block a user