Skip to content

Instantly share code, notes, and snippets.

@hchood
Created November 24, 2013 23:00
Show Gist options
  • Save hchood/7633627 to your computer and use it in GitHub Desktop.
Save hchood/7633627 to your computer and use it in GitHub Desktop.
Ruby Fundamentals 4: Cashier challenge #4 (worked with Janet)
# Ruby Fundamentals 4: Challenge
require 'csv'
require 'date'
require 'table_print'
# DATA
products = []
CSV.foreach('products.csv', headers: true) do |row|
products << { name: row[0], sku: row[1], price: row[2], wholesale_price: row[3] }
end
prices = []
products.each do |product|
prices << [product[:name], product[:price], product[:wholesale_price]]
end
# prices = [["light", "5.0", "2.5"], ["medium", "7.5", "4"], ["bold", "9.75", "5"]]
# HELPER METHODS
def is_valid_action?(input)
/\A[12]\z/.match(input)
end
def is_valid_item?(input, products)
n = products.length
/\A[1-n]\z/.match(input) # how to deal with CSV of 10 or more products?
end
def is_valid_currency?(input)
/\A[0-9]+\.[0-9]{2}$/.match(input)
end
def is_valid_date?(input)
/\A[0-9]{2}\/[0-9]{2}\/[0-9]{4}$/.match(input)
end
# PROGRAM
puts "Welcome to James's coffee emporium!"
puts "1) Sell coffee to a customer"
puts "2) Get a report of past sales"
puts "What would you like to do today?"
action = gets.chomp
until is_valid_action?(action)
puts "What would you like to do today?"
action = gets.chomp
end
if action == "1"
puts "Now we are selling coffee!"
products.each_with_index do |product, index|
puts "#{index + 1}) Add item - $#{sprintf('%.2f' % product[:price])} - #{product[:name].capitalize} Bag"
end
puts "#{products.length + 1}) Complete Sale"
quantities = Array.new(products.length,0)
sale = [Time.new]
quantities.each do |quantity|
sale << quantity
end
def calc_subtotal(quantities, prices)
subtotal = 0
quantities.each_with_index do |quantity, index|
subtotal += quantity * prices[index][1].to_f
end
subtotal
end
def ask_item(products)
puts "What item is being purchased?"
item = gets.chomp
until is_valid_item?(item, products)
puts "What item is being purchased?"
item = gets.chomp
end
item
end
item = ask_item(products)
num_items = products.length
until item == (num_items + 1).to_s
puts "How many bags?"
quantity = gets.chomp.to_i
sale[item.to_i] += quantity
quantities[item.to_i - 1] += quantity
current_subtotal = calc_subtotal(quantities, prices)
puts 'Subtotal: $' + sprintf('%.2f' % current_subtotal)
item = ask_item(products)
end
puts '===Sale Complete==='
def output_item_subtotals(quantities, prices)
quantities.each_with_index do |quantity, index|
item_subtotal = quantity * prices[index][1].to_f
puts "$#{sprintf('%.2f' % item_subtotal)} - #{quantity} #{prices[index][0].capitalize}" unless quantity == 0
end
end
output_item_subtotals(quantities, prices)
puts "\nTotal: $" + sprintf('%.2f' % current_subtotal)
# calculate the change due, as before
puts 'What is the amount tendered?'
tendered = gets.chomp
unless is_valid_currency?(tendered)
puts 'WARNING: Invalid currency detected! Exiting...'
else
change = tendered.to_f - current_subtotal
if change < 0
print 'WARNING: Customer still owes $' + sprintf('%.2f' % change.abs)
puts '! Exiting...'
else
CSV.open('sales.csv', 'a') do |csv|
csv << sale
end
puts "\n===Thank You!==="
puts 'The total change due is $' + sprintf('%.2f' % change)
puts Time.now.strftime('%D %r')
puts '================'
end
end
else
# REPORTING SECTION
sales = CSV.read('sales.csv')
puts "Enter date (in MM/DD/YYYY format):"
user_date = gets.chomp
until is_valid_date?(user_date)
puts "Enter date (in MM/DD/YYYY format):"
user_date = gets.chomp
end
date_array = user_date.split('/')
date_requested = Time.new(date_array[2],date_array[0],date_array[1]).inspect[0..9]
current_date = Time.new.inspect[0..9]
daily_sales = sales.select { |sale| sale[0][0..9] == date_requested }
if date_requested > current_date
puts "Sorry, you've requested a date in the future."
elsif daily_sales.length == 0
puts "There were no sales on that date."
else
# Reporting method definitions
def items_in_sale(sale)
quantities = sale[1..-1]
num_items = quantities.map { |quant| quant.to_i }.reduce {|a,e| a + e}
end
def total_items(sales)
array_items = sales.map { |sale| items_in_sale(sale) }
array_items.reduce {|a,e| a + e }
end
def gross_per_sale(sale, prices)
quantities = sale[1..-1]
gross = 0
quantities.each_with_index do |quant, index|
gross += quant.to_i * prices[index][1].to_f
end
gross
end
def total_sales(sales, prices)
array_gross = sales.map { |sale| gross_per_sale(sale, prices) }
array_gross.reduce {|a,e| a + e }
end
def cost_per_sale(sale, prices)
quantities = sale[1..-1]
cost = 0
quantities.each_with_index do |quant, index|
cost += quant.to_i * prices[index][2].to_f
end
cost
end
def total_costs(sales, prices)
array_costs = sales.map { |sale| cost_per_sale(sale, prices) }
array_costs.reduce {|a,e| a + e }
end
# Creating and outputting report
items_summary = total_items(daily_sales)
sales_summary = total_sales(daily_sales, prices)
profit_summary = sales_summary - total_costs(daily_sales, prices)
puts '======================'
puts "Summary for #{ date_requested }"
puts '======================'
puts "Total items: #{ items_summary }"
puts 'Gross sales: $' + sprintf('%.2f' % sales_summary)
puts 'Net profit: $' + sprintf('%.2f' % profit_summary)
sales_table = []
daily_sales.each_with_index do |sale, index|
row = {"Date" => sale[0][0..9],
"Time" => sale[0][11,5],
"\# items" => items_in_sale(sale),
"Gross sales" => sprintf('%.2f' % gross_per_sale(sale, prices)),
"Cost" => sprintf('%.2f' % cost_per_sale(sale,prices))}
sales_table << row
end
puts
tp sales_table
end
end
@atsheehan
Copy link

This looks great! Nice use of methods for validating input. They have intuitive names and make the program easier to follow. I have a few suggestions which I'll leave in additional comments on this gist.

@atsheehan
Copy link

When working with dates and parsing them from user input you can use the strptime method on the Date class:

require 'date'

today = Date.strptime('10/24/2013', '%m/%d/%Y')
# => #<Date: 2013-10-24 ((2456590j,0s,0n),+0s,2299161j)> 

The first argument to strptime is the string containing the date (e.g. from the user's input or the CSV file) and the second argument is the format that you're expecting the date to be in (i.e. "month/day/year"). Converting to a Date object makes working with dates significantly easier rather than just checking if the strings are equal (e.g. you can compare whether another date comes before or after this one if you wanted to check for ranges).

Since we only care about the date portion of the timestamp, checking two date objects for equality will return true if the days are the same (it doesn't check the times):

a = Date.parse('2013-10-24 13:10:04')
b = Date.parse('2013-10-24 06:45:00')

a == b
# => true

This can be used when filtering for sales on a given day:

date_requested = Date.strptime(user_input, '%m/%d/%Y')
daily_sales = sales.select { |sale| Date.parse(sale) == date_requested }

The Date.parse is a simplified version of strptime that parses strings in UTC date format (YYYY-MM-DD HH:MM:SS) which is how they're saved in the CSV.

@atsheehan
Copy link

When deciding how to store your data it's important to think about what information you have now and how it might change over time. In this case the sales are stored in a CSV format where each column represents a quantity sold for a particular product:

2013-11-24 21:37:09 -0500,0,2,1

This represents a sale with 2 units of the second product and 1 unit of the third product. The problem is that the user can add more products by editing products.csv which means you'll need additional columns in sales.csv to represent those products, even if none are actually sold:

2013-11-24 21:37:09 -0500,0,2,1
2013-11-24 21:39:29 -0500,0,0,0,2

For a few products it is not a big deal but if you can imagine supporting thousands of products then each row in sales.csv will have to store thousands of quantities even if a customer just purchased a single product. Also, since the quantities are based on position the user wouldn't be able to remove or rearrange entries in products.csv without affecting how sales.csv is interpreted.

A more flexible approach might be to store each item type purchased on its own row:

purchase_time,item_sku,quantity
2013-11-24 21:37:09 -0500,2,2
2013-11-24 21:37:09 -0500,3,1
2013-11-24 21:39:29 -0500,4,2

Here a single order can span multiple lines if a customer purchased multiple types of coffee but we won't need to add additional columns to support those multiple types. We can also tell which items were purchased in the same order since the purchase_time should be the same for all of the items in a single order.

In addition, if a customer only purchases a single item then we can represent that by storing that item's SKU and quantity without having to record all of the other item quantities. This means that the amount of space required to record a sale does not grow as more products are added to products.csv.

@hchood
Copy link
Author

hchood commented Nov 25, 2013

That's super helpful. Thanks Adam!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment