This is a variation of an older question (linked below), that I phrased poorly so doesn't adequately address my problem.
BACKGROUND
Suppose there is an apple basket with apples, each of which is an object with a size attribute, and in this particular basket, we have 3 apples: [{size:'S'}, {size:'M'}, {size:'L'}]
In the shopping process, each time a customer adds an apple to the cart, they are given the option to select a size, but importantly, they do not have to select a size, it is an optional selector.
I'm trying to write a method, remaining_options that maximizes the maximum number of options a customer is shown when they add an apple to their cart, given their history of past selections. The fact that the size selection is optional is very important. Consider these 2 examples:
Example A: Customer selects an option
Customer adds 1st apple to cart
Customer sees prompt Please make a size selection (optional): [S, M, L]
Customer decides to select S
Customer adds 2nd apple to cart
Customer sees prompt Please make a size selection (optional): [M, L]
Example B: Customer does NOT select an option
Customer adds 1st apple to cart
Customer sees prompt Please make a size selection (optional): [S, M, L]
Customer skips this step
Customer adds 2nd apple to cart
Customer sees prompt Please make a size selection (optional): [S, M, L]
In Example B, because the customer did NOT select an option, the full set of options available is still shown. The code does not arbitrarily "remove" an apple from the option-set. In other words, this method remaining_options is not responsible for calculating remaining quantity, only remaining options, which it tries to maximize.
NOTE: Of course, even though this method doesn't calculate quantity, upon real world checkout, inventory is adjusted for the next customer, again via a different method. I.e., assuming that in the examples above, customer does not make a size selection for the 2nd apple, then upon checkout...
Example A, customer will receive 1 S apple and a 2nd apple that's either M or L, as randomly selected by other code. If customer receives [S,M], then the next customer that orders an apple will only be able to add 1 apple and remaining_options will only return [L]
Example B, customer will receive 2 random apples, could be [S,M], [M,L], [S,L], as randomly selected by other code. If customer receives [S,L], then the next customer that orders an apple will only be able to add 1 apple and remaining_options will only return [M]
COMPLEXITY
The challenge is I want remaining_options to work for any given number of possible attributes (size,color,price, etc.) on the apple object, and any number of options for such attributes. And since selections are optional, user can decline to select size,color,price,etc., or select 1 (e.g., only size) or 2 (e.g., size and price) and etc.
With this complexity, it's important that the code not treat the process of adding an apple to the cart as an independent process, where the remaining_options is calculated by options in basket minus the last option selected.
The code should instead look at all the apples in the customer's cart, whether or not an option has been selected for each, and then calculating the maximum number of remaining_options
The solutions in the previous posted question didn't work because they did the former, i.e., treated adding each apple as an independent process that removed an option from the basket.
Here's an example to clarify. Suppose the basket of 3 apples looks like this:
[
{size:'S', price:1},
{size:'M', price:2},
{size:'L', price:2},
]
Step 1 - adding the 1st apple
Let's say remaining_options takes an argument of the customer's existing. So when a customer adds their 1st apple to the cart, there's nothing in the cart existing, so all options are returned
basket.remaining_options([])
=> {size:['S','M','L'], price: [1,2]}
Step 2 - adding the 2nd apple
To finish up the 1st apple transaction, customer decided to select an apple with price:2. Then they add their 2nd apple to the cart. Since there's more than 1 apple with price:2, the customer's 1st choice created no capacity constraints, an again all options are shown.
basket.remaining_options([{price:2}])
=> {size:['S','M','L'], price: [1,2]}
NOTE: #obiruby astutely observed that although {size:['S','M','L'], price: [1,2]} is technically all the remaining options, they're not all selectable. For this second apple, if customer selects M, then the first apple has to be auto-assigned as L and therefore is no longer selectable. This is handled by yet another method that I already have working. So many methods... all to make this max-options optimization work!
Step 3 - adding the 3rd apple
To finish up the 2nd apple transaction, customer decided to, again, select an apple with price:2. Then they add their 3rd apple to the cart. Now it's tricky. If this code just subtracted options selected, then on face value, remaining_options might return this: {size:['S','M','L'], price: [1]}. But this is not accurate, because when all the price:2 apples are taken, so are all the M and L apples by default. This is why the right code needs to look collectively at existing cart. I.e., this should be the result:
basket.remaining_options([{price:2}, {price:2}])
=> {size:['S'], price: [1]}
Note also that because of how capacity constraints work, the following should also be returned correctly by the method:
basket.remaining_options([{price:2}, {size:'M'}])
=> {size:['S'], price: [1]}
# user's 1st ordered apple has a price request
# user's 2nd ordered apple has a size request
# COMBO of the 2 only work if...
# user's 1st ordered apple is the {price:2, size:'L'} apple in basket
# user's 2nd ordered apple is the {size:'M', price:'2'} apple in basket
# therefore if user is looking at remaining options for a 3rd apple, there's only the {size:'S', price:'1'} apple left in basket
So... yeah help greatly appreciated in writing this method! I have my non-working ideas in the old post. In looking at that proposed solution (which alas also doesn't look collectively at the existing cart), I realized that one solution could be to map out all the possible combinations of apples in basket that could be assigned to fulfill the requests of each apple in the cart, and picking the combination of assignments that yields the highest number of remaining options. But I feel like this solution would be wildly inefficient as baskets, carts, and possible options increased in size.
I think there are a couple of things that need to be considered before you're able to fully tackle this problem.
SKUs and stock
In an E-Commerce system, as was pointed out correctly in a comment, there's usually a concept of SKUs which uniquely describe a single product. So each of your unique apple types would be a separate SKU. In your simplified examples, the SKUs are for small, medium and large apples; in your "fully" example, you'd have separate SKUs for "small red apple", "medium red apple", "small green apple", "large green apple" etc. The fact that all of these are "apples" and the possible combinations of various attributes don't matter in terms of SKUs – these categorizations based on attributes is an arbitrary grouping criterion that is put on top of the list of all SKUs to make them more approachable to users of the system (be it customers, employees, tax advisors etc.).
Subsequently, each SKU has a quantity >= 0 in stock. Again, these quantities are completely independent of each other and the fact that you might say "we have 20 apples in stock" (where 5 of them are S, 11 M and 4 L) is a grouping approach that you put on top, not one that actually exists from a stock keeping perspective.
SKU/category assignment/locking
The 2nd issue to understand is the concept of article assignment/locking. That is, when someone adds an article to their cart, you temporarily mark the article as if it were already purchased so that it can't get sold twice. This approach is usually referred to as locking. Depending on order frequency, you then set a timeout of that lock, e.g. 30 minutes, meaning, if a purchase doesn't happen within 30 minutes, you release the lock and allow another customer to lock it (and ideally purchase it).
In a normal E-Commerce context, you would create such locks on a SKU level, but it sound like this is not the right approach in your case since you also allow users to add non-specific articles to their carts. So what you probably want is a kind of multi-level lock:
When a user adds a non-specific article to their cart, you lock the selected quantity of all categories that the article is assigned to.
When a user adds a specific SKU to their cart, you lock the selected quantity of the specific SKU for that user.
You add constraints that doesn't allow any category and any specific SKU to exceed their individual current stock.
Distribution
The last step then is actual distribution. Here, you go from most to least specific, i.e. you start by alotting the specific SKUs, followed by articles assigned to most categories all the way to the "I don't care, I just want any apple" customers.
I hope this makes sense to you. I'm aware that it's highly conceptual. But it's hard to give specific answers to a somewhat generic and (apparently) theoretical use case.
Your question seams like a NP-complete problem to me, I can't prove it though ^^
The following code is only a proof of concept for you to test.
Edit: What the code does is: Given the "abstract items" in the client's cart and the "real items" stock in the basket, generate all the combinations of "real items" (let's call these combinations "virtual carts") that satisfy both the client's cart and the stock in the basket. Then, for each "virtual cart", simulate the remaining stock in the basket. Finally, make the union of the remaining choices of each "virtual basket".
require 'set'
def extract_options(basket)
basket.each_with_object( {} ) do |item, options|
item.each do |attr,value|
options[attr] = Set.new unless options.has_key?(attr)
options[attr] << value
end
end
end
def expand_item(item, options)
expanded = [[]]
item.each do |attr,value|
opts = value.nil? ? options[attr] : [value]
expanded = expanded.each_with_object( [] ) do |path,exp|
opts.each { |val| exp << ( path.dup << [attr, val] ) }
end
end
expanded.map(&:to_h)
end
def remaining_options(basket, cart)
options = extract_options(basket)
return [options] if cart.empty?
remaining = []
f,*r = *cart.map { |item| expand_item(item, options) }
f.product(*r) do |cart|
b = cart.each_with_object( basket.dup ) do |item,bask|
break unless index = bask.index(item)
bask.delete_at(index)
end
remaining << extract_options(b) if b
end
remaining.uniq
end
def remaining_options_union(basket,cart)
remaining_options(basket,cart).each_with_object( {} ) do |remain,union|
remain.each do |attr,set|
union[attr] = Set.new unless union[attr]
union[attr] += set
end
end
end
Your test-case is small so the calculation is quick:
basket = [
{size:'S', price:1},
{size:'M', price:2},
{size:'L', price:2},
]
cart = []
remaining_options_union(basket,cart)
# => {:size=>#<Set: {"S", "M", "L"}>, :price=>#<Set: {1, 2}>}
cart << {size: nil, price: 2} # all attributes need to be populated
remaining_options(basket,cart)
# => [{:size=>#<Set: {"S", "L"}>, :price=>#<Set: {1, 2}>}, {:size=>#<Set: {"S", "M"}>, :price=>#<Set: {1, 2}>}]
remaining_options_union(basket,cart)
# => => {:size=>#<Set: {"S", "L", "M"}>, :price=>#<Set: {1, 2}>}
cart << {size: nil, price: 2}
remaining_options(basket,cart)
# => [{:size=>#<Set: {"S"}>, :price=>#<Set: {1}>}]
remaining_options_union(basket,cart)
# => {:size=>#<Set: {"S"}>, :price=>#<Set: {1}>}
Update: The following code keeps the previously calculated "virtual baskets" in memory for speeding up the addition of a new item to the cart.
require 'set'
class Cart
def self.extract_choices basket
basket.each_with_object( {} ) do |item, attrs|
item.each do |key,value|
raise "Null valued item attribute in basket" if value.nil?
attrs[key] = Set.new unless attrs.has_key?(key)
attrs[key] << value
end
end
end
attr_reader :remaining_options
def initialize basket
choices = self.class.extract_choices basket
#globs = choices # needed to unglob a null valued attribute in an item
#attrs = choices.keys # attributes found in the items of the basket
# The items in the basket shall have all the possible attributes populated and not null
basket.each do |item|
#attrs.each do |attr|
raise "Invalid item in basket" unless item[attr]
end
end
#virtual_baskets = [ basket ]
#remaining_options = choices
end
def <<(item)
# As we're using the 'Hash#==' method, each item must have all its
# attributes set (the same ones as in the basket), no more, no less.
item = #attrs.each_with_object( {} ) do |attr,obj|
obj[attr] = item[attr]
end
# NOTE: an item with globs will multiply the number of virtual baskets
update_virtual_baskets item
choices = #virtual_baskets.map{|b| self.class.extract_choices b}
#remaining_options = choices.each_with_object( {} ) do |choice, union|
choice.each do |attr,set|
union[attr] = Set.new unless union[attr]
union[attr] += set
end
end
end
private
def unglob item
expanded_items = [[]]
item.each do |attr, value|
choices = value.nil? ? #globs[attr] : [value]
expanded_items = expanded_items.each_with_object( [] ) do |pre, obj|
choices.each { |val| obj << ( pre.dup << [attr, val] ) }
end
end
expanded_items.map(&:to_h)
end
def update_virtual_baskets formated_item
expanded_items = unglob formated_item
valid_baskets = []
#virtual_baskets.each do |basket|
expanded_items.each do |item|
basket.each_index do |idx|
next unless basket[idx] == item
bsk = basket.dup
bsk.delete_at(idx)
valid_baskets << bsk
end
end
end
raise "Error while adding item to cart - no stock" if valid_baskets.empty?
#virtual_baskets = valid_baskets.uniq
end
end
You can use it like this:
basket = [
{origin:'IL', size:'S', color:'G', price:1},
{origin:'SP', size:'M', color:'G', price:2},
{origin:'SP', size:'M', color:'R', price:2},
{origin:'SP', size:'M', color:'Y', price:3},
{origin:'CA', size:'L', color:'G', price:1},
{origin:'SP', size:'L', color:'G', price:4},
{origin:'CA', size:'L', color:'R', price:4},
]
cart = Cart.new basket
cart << {price:2}
cart << {size:'M'}
cart << {origin:'SP'}
cart.remaining_options
# => {:origin=>#<Set: {"IL", "CA", "SP"}>, :size=>#<Set: {"S", "L", "M"}>, :color=>#<Set: {"G", "R", "Y"}>, :price=>#<Set: {1, 4, 3, 2}>}
cart << {price:2}
cart.remaining_options
# => {:origin=>#<Set: {"IL", "CA"}>, :size=>#<Set: {"S", "L"}>, :color=>#<Set: {"G", "R"}>, :price=>#<Set: {1, 4}>}
Possible improvements: By adding a "quantity" to the items in the basket you should be able to reduce the size and the number of "virtual baskets". Also, if the price is a mandatory field (which makes sense) then you can "kind of" split the problem by price and make the code faster.
Remark: Another way to phrase what you're looking for would be: For each possible choice in each attribute (ex. :size is an attribute and one of its possible choices is 'S'), does it exist a combination of items in the cart that doesn't deplete it ? Unless there exists a mathematical formula to find it quickly, you'll have to almost walk through all the possible combinations in order to be able to respond 'no' to that question...
To a certain extent, my second proposal that keeps in memory the valid "virtual baskets" of the previous iteration is a sensible way to tackle the problem.
I took a stab, please see below.
You can play around with the code by copying the entire code below into a single file (say, test.rb) and then running it from the command line (ie, ruby test.rb)
The design is based off the idea that the problem of "maximizing the options that remain in-basket" is logically equivalent to "removing options from the basket in order of most common to least common".
# test.rb
require 'minitest/autorun'
class Basket
attr_accessor :basket
def initialize(opts={})
#basket = opts[:basket]
end
def remaining_options(selected_options=[])
return {} if #basket.empty?
selected_options.each do |option_criteria|
select_option(option_criteria)
end
print_basket
end
def select_option(option_criteria)
selectable_options = #basket.select { |opt| matches?(opt, option_criteria) }
option_to_select = most_common_option(selectable_options)
remove_from_basket option_to_select
end
def most_common_option( selectable_options )
max_matches = 0
max_matches_idx = nil
selectable_options.each_with_index do |option, i|
option_matches = 0
option.keys.each do |k|
selectable_options.each do |opt, i|
option_matches += 1 if option[k] == opt[k]
end
if option_matches > max_matches
max_matches = option_matches
max_matches_idx = i
end
end
end
selectable_options[max_matches_idx]
end
def remove_from_basket( option_to_select )
idx_to_remove = #basket.index { |i| matches?(i, option_to_select)}
#basket.delete_at idx_to_remove
end
def matches?(opt, selected)
selected.keys.all? { |k| selected[k] == opt[k] }
end
def print_basket
hsh = Hash.new { |h,k| h[k] = [] }
#basket.each do |item_hsh|
item_hsh.each do |k, v|
hsh[k] << v unless hsh[k].include?(v)
end
end
hsh
end
end
class BasketTest < Minitest::Test
def setup_basket
Basket.new basket: [
{size:'S', price:1},
{size:'M', price:2},
{size:'L', price:2},
]
end
def test_initial_basket_case
basket = setup_basket
assert basket.remaining_options == {size:['S','M','L'], price: [1,2]}
end
def test_returns_empty_if_basket_empty
basket = Basket.new basket: []
assert basket.remaining_options([{price:2}]) == {}
end
def test_one_pick
basket = setup_basket
assert basket.remaining_options([{price:2}]) == {size:['S', 'L'], price: [1,2]}
end
def test_two_picks
basket = setup_basket
assert basket.remaining_options([{price:2}, {price:2}]) == {size:['S'], price: [1]}
end
def test_maximizes_options
larger_basket = Basket.new basket: [
{size:'S', price:1},
{size:'L', price:2},
{size:'M', price:2},
{size:'M', price:2}
]
assert larger_basket.remaining_options([{price:2}]) == {size:['S', 'L', 'M'], price: [1,2]}
end
def test_maximizes_options_complex
larger_basket = Basket.new basket: [
{size:'S', price:1, color: 'red'},
{size:'L', price:2, color: 'red'},
{size:'M', price:2, color: 'purple'},
{size:'M', price:2, color: 'green'},
{size:'M', price:2, color: 'green'},
{size:'M', price:2, color: 'green'}
]
assert larger_basket.remaining_options([{price:2}, {color: 'green'}]) == {size:['S', 'L', 'M'], price: [1,2], color: ['red', 'purple', 'green']}
end
end
The Use Case
If users haven't filled their box with products up to their credit limit (6 by default), a method is called on the box model which fills it for them.
Code guide
The number of credits in the box is given by box_credits, which loops through all products in the box and returns the total value of them. This seems to work.
The boolean method box_filled? checks if the box_credits method is equal to or greater than the number of credits available (the subscription credits).
The fill_once method should add products to the box until the box is filled (box_filled? returns true). This will happen when box_credits equals the number of credits available.
The Code
def fill_once
unless self.box_filled?
# Get a random product from the user's recommendations
product = self.subscription.user.recommended_product_records[rand(self.subscription.user.recommended_product_records.length - 1)]
# Make sure the product hasn't already been included in the box
unless self.added_product_ids.include? product.id
# If fresh, add the product to the box, size-dependently
unless product.sample_price_credits.nil?
product.add_to_box_credits(self.subscription, "sample")
else
unless product.full_price_credits.nil?
product.add_to_box_credits(self.subscription, "full")
end
end
self.save!
end
self.fill_once # Here's the recursion
end
end
The box_filled? method looks like this:
def box_filled?
subscription = self.subscription
if self.box_credits >= subscription.credits
return true
else
return false
end
end
box_credits are determined by this method:
def box_credits
count = 0
unless self.added_product_hashes.nil?
# Takes product hashes in the form {id, size, method}
self.added_product_hashes.each do |product_hash|
# Add credits to the count accordingly
if product_hash["method"] == "credits"
# Depending on the product size, add the corresponding amount of credits
if product_hash["size"] == "sample"
# Get the credit cost for a product sample
cost = Product.find(product_hash["id"].to_i).sample_price_credits
count += cost
elsif product_hash["size"] == "full"
# Get the credit cost for a full product
cost = Product.find(product_hash["id"].to_i).full_price_credits
count += cost
else
next
end
else
next
end
end
end
return count
end
The Problem
fill_once runs forever: it seems to ignore the unless self.box_filled? conditional.
Attempted solutions
I tried removing the recursive call to fill_once from the fill_once method, and split it into an until loop (until box_filled? ... fill_once ...), but no joy.
Update
Multiple identical products are being added, too. I believe the issue is that the updated record isn't being operated on – only the original instance. E.g. unless self.added_product_ids.include? product.id checks against the original box instance, not the updated record, sees no products in the added_product_ids, and chucks in every product it finds.
Solution
OK, this is solved. As suspected, the updated record wasn't being passed into the iterator. Here's how I solved it:
# Add one random user recommended product to the box
def fill_once(box=self)
unless box.box_filled?
# Get a random product from the user's recommendations
product = box.subscription.user.recommended_product_records[rand(box.subscription.user.recommended_product_records.length - 1)]
# Make sure the product hasn't already been included in the box
unless box.added_product_ids.include? product.id
# If fresh, add the product to the box, size-dependently
unless product.sample_price_credits.nil?
box = product.add_to_box_credits(box.subscription, "sample")
else
unless product.full_price_credits.nil?
box = product.add_to_box_credits(box.subscription, "full")
end
end
end
fill_once(box)
end
end
Using Ruby's default arguments with a default of self, but the option to use the updated record instead, allows me to pass the record through the flow as many times as needed.
unless self.added_product_ids.include? product.id means no duplicate product will be add to box. So if all products recommend is add to box but the total credits is less than box_credits , may cause infinite loop. I'm not sure, but it could be the reason.
You could add
puts "Box credits #{self.box_credits} vs. credits: #{self.subscription.credits} "
before
self.fill_once # Here's the recursion
to see if this happens.
Solution
OK, this is solved. As suspected, the updated record wasn't being passed into the iterator. Here's how I solved it:
# Add one random user recommended product to the box
def fill_once(box=self)
unless box.box_filled?
# Get a random product from the user's recommendations
product = box.subscription.user.recommended_product_records[rand(box.subscription.user.recommended_product_records.length - 1)]
# Make sure the product hasn't already been included in the box
unless box.added_product_ids.include? product.id
# If fresh, add the product to the box, size-dependently
unless product.sample_price_credits.nil?
box = product.add_to_box_credits(box.subscription, "sample")
else
unless product.full_price_credits.nil?
box = product.add_to_box_credits(box.subscription, "full")
end
end
end
fill_once(box)
end
end
Using Ruby's default arguments with a default of self, but the option to use the updated record instead, allows me to pass the record through the flow as many times as needed.