Ruby Invoice
September 2017 · 10 minute read
I have recently challenged my skills building a small program to process an order and generate an itemized invoice which calculates the optimum pack sizes to make up a given quantity for the best price.
The completed project can be viewed at https://github.com/SelenaSmall/ruby_invoice
Project Description
A fresh food supplier sells product items to customers in packs. The bigger the pack, the cheaper the cost per item.
- The supplier currently sells the following products
Product Packs
----------------------------------
Watermelons 3 pack @ $6.99
5 pack @ $8.99
Pineapples 2 pack @ $9.95
5 pack @ $16.95
8 pack @ $24.95
Rockmelons 3 pack @ $5.95
5 pack @ $9.95
9 pack @ $16.99
- Your task is to build a system that can take a customer order…
For example, something like:
10 Watermelons 14 Pineapples 13 Rockmelons
- And generate an invoice for the order…
For example, something like:
10 Watermelons $17.98
- 2 x 5 pack @ $8.99
14 Pineapples $54.80
- 1 x 8 pack @ $24.95
- 3 x 2 pack @ $9.95
13 Rockmelons $25.85
- 2 x 5 pack @ $9.95
- 1 x 3 pack @ $5.95
-----------------------------
TOTAL $98.63
- Note that the system has determined the optimal packs to fill the order. You can assume that bigger packs will always have a cheaper cost per unit price.
Planing
The first step in any new project, of course is consider your requirements and make a plan. It’s likely the plan will change later, but it’s a start point.
Why did I choose ruby?
I’m most familiar to me so I will get the product out in a reasonable timeframe.
Just worked out a great way to practice TDD for ruby apps using Travis-CI.
I can see this product being object oriented therefore ruby seems like a reasonable fit.
Objects
I’m probably going to split the compnents up into individual objects as follows:
item(item_name, pack)
pack(qty, price)
- child of item (subClass)
order(items = [], basket)
basket(current_order=nil)
order_line(qty, item)
- calculate optimal packs required to make up the qty
- calculate total price of packs per product
invoice(order)
order.items.each do | item | puts item.get_receipt_line end
Input
This is my planned perception of how the program will work:
Would you like to LIST available products, SHOP, VIEW basket, EXIT without placing an order?
$_ LIST
list_products & packs
$_ SHOP
place your order:
$_ 10 watermelons
$_ VIEW
10 Watermelons $17.98
- 2 x 5 pack @ $8.99
——————————————————————————————
TOTAL $17.98
Would you like to complete your order and checkout now?
$_ yes
> Your order has been placed, thank you. Goodbye!
$_ EXIT
OR
> Your order has not been placed, are you sure you want to leave?
$_ yes
> Goodbye!
Assumptions
- If someone orders a quantity of product which does not equal a quantity made up of packs, they will be charged for the the nearest quantity above what they have ordered.
ie 11 Watermelons = 2x 5 packs + 1x 3pack
Build Process
Initialize_repo
Set up Travis-CI and blank app to get started
https://github.com/SelenaSmall/ruby_invoice/commit/d16526d753b67aef99035c957471433a09548fa6
1. Create items and packs
Item class
Pack class
Watermelon class
I think for scalability, Watermelon should be a subclass of Item - I’m not quite sure yet how this is going to work. For now, I will just focus on getting the base of the app working.
Each sub-item will have a range of Packs available with pre-determined item qty’s and prices.
https://github.com/SelenaSmall/ruby_invoice/commit/f2812ece9a4d347417e0651a794e5d972708801d
2. Handle user inputs
- Handle_input class
I put this in next because it makes me feel better to know what the end result will connect to. At this stage, the only thing being checked is that a command is valid.
https://github.com/SelenaSmall/ruby_invoice/commit/828dc25d386207223dc6f67111d8f3427766c5a3
3. Define orders
- Basket class
Need an empty basket to put the orders into
- Order class
To put the items into the basket
https://github.com/SelenaSmall/ruby_invoice/commit/925b83c8fe0578ec24bc40e2e4e179a30fe842c2
4. Define each line in the order
- Order_line class
The quantity and name of the item requested to be ordered by customer input
https://github.com/SelenaSmall/ruby_invoice/commit/ffba1411bafd814d9057501ade50c5c722d0a4c7
5. Determine optimal packs for each order line
Given what I have so far, there is enough to make the SHOP and LIST actions work with my HandleInput class.
SHOP should allow user to input an order_line in the format “3 watermelon”
- OrderLine needs to determine the optimal # of packs to fill the order:
packs = []
left_over_qty = order_qty
pack.each (starting from largest value) do |p|
left_over_qty / p
for each whole result, packs << p
left_over_qty = remainder
next
end
https://github.com/SelenaSmall/ruby_invoice/commit/424c2d597de3150800030276ab9faff66701f70d https://github.com/SelenaSmall/ruby_invoice/commit/120a507f8be82fdb81753abc9a6b00aa838535f3
6. Add orderline packs to Order
- OrderLine needs to be added to the overall Order
Both Basket and Order probably aren’t required at this point
I’ll start by initialising the Order object in app entry and update it with every additional item added when ‘shopping’
https://github.com/SelenaSmall/ruby_invoice/commit/ddd08f537eff38792eeb2b5e222d9f6daba064cb
7. Review and refactor
Got a bunch of code working and doing it’s job. Time for a tidy up!
- Refactor the code and revise tests
Although there is no duplicate code, there is certainly room for improvements and methods can be broken down into smaller easier-to-work-with components
https://github.com/SelenaSmall/ruby_invoice/commit/e7df573d38fd1178cc567e3d0b70d65406d17b5a
8. Define invoice
- Invoice class
VIEW should output the itemised invoice of the full order
https://github.com/SelenaSmall/ruby_invoice/commit/f232c0bf3bcbbc054b6c69d2fe1b01bdd50a0d18
9. Define additional products
Create additional fruit items
Reorganise code and review tests again.
Refactor shop_menu into it’s own method
https://github.com/SelenaSmall/ruby_invoice/commit/56b658717a5ee41287142733e5cd7e73c1f24e81
10. Define LIST action
LIST should output a list of available items with their pack size and quantity. I probably should have listed the options first, but I was on a roll with SHOP and now that I’ve gotten this far, it seems less relevant. Still it will tie the app nicely to be able to view all available products and pack sizes.
11. Break down HandleInput methods
The handle_input class is getting too heavy, especially considering the size of the app. I’ve just taken this opportunity to also move the shop methods out into their own Shop object
https://github.com/SelenaSmall/ruby_invoice/commit/4b96f158a107d02dcc69d58fff9ce34d742f3f35
12. Review OrderLine
I’m still pretty unhappy with the OrderLine methods. They’re chunky and painful to look at, which leads me to believe there must be a better way.
It also looks like I’ve made a mistake in selecting optimal pack sizes. I focussed too much on trying to make up qty’s which would not be an exact product of pack sizes available and in the process overlooked searching for an exact match first.
What I actually should have done was check if an exact match is possible first.
if order_qty === any sum of pack sizes
make up order with those
(using lowest price combo)
else
go get whole/left_over_pieces to make up closest qty packs
OR
if qty does not add up, ignore it.
end
This is actually trickier than I initially thought, but the best way to approach it is to break it right down and play around with a simple array in a seperate window. Here’s the test I ended up writing to get the whole optimal selection part working.
class ArrayCheck
attr_reader :pack_qtys
def initialize
@pack_qtys = [[2, 6], [5, 9], [8, 16]]
end
def optimal(qty)
# Get Exact matches
exact_matches = []
pack_qtys.sort { |a, b| b <=> a }.each do |p, v|
next unless (qty / p) * p == qty
exact_matches << [qty / p, p, v]
end
# puts exact_matches
# Check for the optimim price
price_check = []
exact_matches.each do |x, y, z|
val = (x * z)
price_check << [x, y, z, val]
end
# puts price_check
numbers = price_check.select { |x| x[3] }.map
# Get Part matches
partial_matches = []
pack_qtys.sort { |a, b| b <=> a }.each do |p, v|
partial_matches << [qty / p, p, v]
end
# puts partial_matches
# Top up part matches
exact_partial = []
partial_matches.each do |x, y, z|
val = qty - (x * y)
pack_qtys.detect do |a|
if a.include?(val)
exact_partial << [x, y, z]
exact_partial << [val / a[0], a[0], a[1]]
end
end
end
# puts exact_partial
# Check for the optimim partial price
partial_price_check = []
exact_partial.each do |x, y, z|
val = (x * z)
partial_price_check << [x, y, z, val]
end
# puts partial_price_check
partial_numbers = partial_price_check.select { |x| x[3] }.map
partial_price_array = []
partial_numbers.each do |f|
partial_price_array << f
end
# puts partial_price_array
puts "Exact Match: #{exact_matches}"
# Array for bext price line: 1x 9pk @16 = $16
puts "Best Exact Price: #{numbers.min}"
puts "Partial Match: #{exact_partial}"
puts "Best Partial Price: #{partial_price_array}"
calculate_best(numbers.min, partial_price_array)
end
def calculate_best(exact, partial)
sub_total = []
partial.each do |_x, _y, _z, val|
sub_total << val
end
# Find total cost of sub_items
line_total = sub_total.inject(:+)
# Return array of the cheapest line
puts "\nThis is it #{partial}" if [exact[3], line_total].min == line_total
puts "\nThis is it #{exact}" if [exact[3], line_total].min == exact
end
end
array = ArrayCheck.new
array.optimal(14)
Transfer the test code into my project and wallah! The pack selections that are meant to be made on orders are now being made!!
It’s late now, so before I call it a day I’m just going to make sure my existing tests pass. Although this is now working, it’s certainly not finished. I’ll break down the code in cleaner methods and write the tests fro them tomorrow.
https://github.com/SelenaSmall/ruby_invoice/commit/ace50ed589c4e0a3bb1038e716633324d54e3b63
13. Optimise OrderLine
Same process. Now that the system is actually working the way it should, I can go through and do a clean up.
a) Ensure all items are returned in the same format
Optimal method responses as determined by calculate_best method - I want everything to be returned as an Enumerator.
b) Split optimal sections out into individual methods
This will thin down the optimal method and make it easier to see what’s going on. By adding doc blocks, I will also be able to see clearly any duplicate methods.
def exact_match(pack_qtys, exact_matches)
pack_qtys.sort { |a, b| b <=> a }.each do |p, v|
next unless (order_qty / p) * p == order_qty
exact_matches << [order_qty / p, p, v]
end
exact_matches
end
def price_check(exact_matches, price_check)
exact_matches.each do |x, y, z|
val = (x * z)
price_check << [x, y, z, val]
end
price_check
end
def partial_matches(pack_qtys, partial_matches)
pack_qtys.sort { |a, b| b <=> a }.each do |p, v|
partial_matches << [order_qty / p, p, v]
end
partial_matches
end
def exact_partial(pack_qtys, partial_matches, exact_partial)
partial_matches.each do |x, y, z|
val = order_qty - (x * y)
puts "VAL: #{val}"
pack_qtys.detect do |a|
# To be optimised: a.include?(val) does not cooperate with Money
if a[0] == val || a[0] * 3 == val
exact_partial << [x, y, z]
exact_partial << [val / a[0], a[0], a[1]]
end
end
end
exact_partial
end
def partial_price_check(exact_partial, partial_price_check)
exact_partial.each do |x, y, z|
val = (x * z)
partial_price_check << [x, y, z, val]
end
partial_price_check
end
def partial_price_array(partial_numbers, partial_price_array)
partial_numbers.each do |f|
partial_price_array << f
end
partial_price_array
end
c) Review those methods
Look to see where duplicate code can be cut out and find methods which are not required.
There are two identical methods - I only need this code once:
price_check
partial_price_check
Additionally, two methods are called currently to return one object. The difference is one extra param - these can be combined into one method.
partial_matches
exact_partial
There are now two identical lines in the optimal method which means they can be abstracted out into their own method.
exact_match_prices = exact_price_check.select { |x| x[3] }.map
partial_numbers = partial_price_check.select { |x| x[3] }.map
And they will become:
def match_prices(array)
array.select { |x| x[3] }.map
end
I’ve also got a completely unnecessary method, since I’ll be handling Enumerators instead of Arrays, I won’t need this:
- partial_price_array
d) Clean up calculate_best method
e) Optimal method check
- Return unless there is an optimal match for packs (ignore the order item).
https://github.com/SelenaSmall/ruby_invoice/commit/1d97d008f8fd21ba852eb89d12920803f6b80ea7 https://github.com/SelenaSmall/ruby_invoice/commit/b3c7b73cc5483b71b9247df037a4eacd0b433235
Documentation & Final Review
Of course, no good code is complete without doc blocks and no project is complete without a README. This is also a good chance to review and add additional tests which might have been overlooked. - Finished up with tests passing a 99% code coverage.
https://github.com/SelenaSmall/ruby_invoice/commit/199b7b4cde020a4b89fc6762e052604e47008923