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
I have a seed file that generates numerous objects, including accounts and account transactions. After the accounts are created, a loop generates 80 transactions for each account (there are 150 accounts).
Everything works well, except I need the account's balance column to be updated to reflect the change caused by the transaction. I've tried this in 100 different ways. Here is my most recent approach:
from seeds.rb
accounts.each do |account|
80.times do
type = types.sample
case (type)
when 1
description = descriptions_ATM_Withdrawal.sample
amount = (atm_amounts.sample) *-1
when 2
description = descriptions_Check.sample
amount = ((500.0 - 5.0) * rand() + 5) *-1
when 3
description = descriptions_Deposit.sample
amount = (2000.0 - 5.0) * rand() + 5
when 4
description = descriptions_AutoDraft.sample
amount = ((350.0 - 5.0) * rand() + 5) *-1
when 5
description = descriptions_POS.sample
amount = ((150.0 - 5.0) * rand() + 5) *-1
when 6
description = descriptions_Transfer
amount = (500.0 - 5.0) * rand() + 5
when 7
description = descriptions_Withdrawal
amount = ((500.0 - 5.0) * rand() + 5) *-1
when 99
description = descriptions_Miscellaneous
amount = ((500.0 - 5.0) * rand() + 5) *-1
end
AcctTransaction.create do |transaction|
transaction.id = SecureRandom.random_number(99999999999999)
transaction.account_id = account.id
transaction.transaction_type_id = type
transaction.description = description
transaction.amount = amount
transaction.adjusted_bal = account.balance + transaction.amount
# keep transaction in chronological order unless it's the first one
unless AcctTransaction.exists?(account_id: transaction.account_id)
transaction.date = rand(account.date_opened..Time.now)
else
transaction.date = rand(AcctTransaction.where(account_id: transaction.account_id).last.date..Time.now)
end
end
Account.find(AcctTransaction.last.account_id).update_column(:balance, AcctTransaction.last.adjusted_bal)
end
end
Trying to update with the last line before the "ends". I've tried update, update_attribute, as well as just "=". Nothing seems to work. The account's "balance" field MUST be updated after each transaction in order to provide an accurate basis for calculation in the next iteration of the account transaction creation loop.
Any suggestions will be considered. This can't be that difficult. Again, the whole thing would work just fine if the balance gets updated like it should.
Rails 4.1.8 / Ruby 2.1.5
Please help.. THANK YOU!
EDIT
account.rb:
class Account < ActiveRecord::Base
belongs_to :customer
belongs_to :user
has_one :acct_type
has_many :acct_transactions, :dependent => :destroy
accepts_nested_attributes_for :acct_type
accepts_nested_attributes_for :acct_transactions
validates :acct_type_id, presence: true
end
acct_transaction.rb
class AcctTransaction < ActiveRecord::Base
belongs_to :account
has_one :transaction_type
accepts_nested_attributes_for :transaction_type, :allow_destroy => false
end
screen shot of (wrong) results
As you can see, what happens is the original balance (seen in header if you zoom in), persists. Every transaction, therefore, is calculated based on this amount.
methods in acct_transactions_controller that actually work to perform these updates when the app is live. Trying to replicate this functionality when creating the nested seeds:
public
def modify_acct_balance
account = Account.find(#acct_transaction.account_id)
case #acct_transaction.transaction_type_id
when 1,2,4,5,7
account.update(balance: account.balance - #acct_transaction.amount)
when 3
account.update(balance: account.balance + #acct_transaction.amount)
end
end
def adjust_balance
case #acct_transaction.transaction_type_id
when 2,4,5,7
#acct_transaction.adjusted_bal = Account.find(#acct_transaction.account_id).balance - #acct_transaction.amount
when 3
#acct_transaction.adjusted_bal = Account.find(#acct_transaction.account_id).balance + #acct_transaction.amount
end
end
Please note that the above methods calculate differently based on the transaction type (+ or -) - user always supplies positive value. Works though.
How to reproduce this kind of functionality in the above seed file?
Thanks
There are many ways to do this, for sure, but at first glance it looks like your approach would work. My first thought is that the you have some life-cycle hooks or counter-cache configuration on the models that's conflicting with your logic. (You may want to show us your model definitions)
If that's not the case, I'd simply skip updating the balance after every transaction and calculate the balance at the end of the transaction-creation loop.
In pseudo:
accounts.each do |account|
80.times do
# create transactions
end
account.reload
account.balance = account.transactions.sum(:adjusted_bal)
account.save
end
Or even faster, build and cache the balance locally during transaction-creation:
accounts.each do |account|
balance = 0
80.times do
# create transaction
balance += transaction.amount
end
account.balance = balance
account.save
end
To make this work, I had to do 2 things:
Change the syntax of update method (last line of seeds.rb) to this : account.update(balance: transaction.adjusted_bal)
Add a ! (bang) to the create method (AcctTransaction.create! do |transaction|)
The create method probably works fine without the bang, but I'm leaving it in because everything works great now. Also, I'm not sure why using update only works with this syntax. I tried other variants and other methods to update this column and none seemed to work. Almost seems like seeds.rb only works with "bare bones" Rails methods. Thanks to the Deeno and all others who've helped me work this out.