Capstone: a sales report from records
Everything in the course in one script: a record, a list of them, a sort by one field, a group by another, and formatted lines printed by the function that builds them.
The data
The report is built from one record and one list of them: item, team, amount. That is the whole model, and it is enough for every line that follows. Everything the program does is a walk over the list — sort it, total it, group it — so the shape of the data is the shape of the code.
The only other piece of machinery is line, a function from a sale to a string. One place builds a row, so changing the columns is one edit rather than five, and the printing below never learns what a column is.
Rows, biggest first
List.sortBy (fun s -> -s.Amount) sorts by the negation of the amount, which is descending order written as an ascending sort. List.iter then runs a printing function once per row: a pipe out of the sort, through a composition of line and printfn, and nothing is collected along the way because the only thing wanted was the output.
List.sumBy totals the amount over the same list in one pass, and that number is the sum of the column above it, which is the sort of agreement a reader can check by eye.
Sections, one per team
The team part is two helpers doing the work: List.groupBy makes one pair per team, holding the team name and its rows, and List.sortBy fst puts the sections in a fixed order so the report does not reorder itself when the data does. For each pair the body totals the amounts inside the group and counts the rows.
Notice that the inner total is the same sumBy as the outer one, applied one level down, and that the lambda takes its pair apart in the parameter: fun (team, rows) ->. Records, groups, sorts and format strings — the last eleven chapters, arranged for a reader.
One row, one section
let line sale =
sprintf "%-5s %-6s %4d" sale.Item sale.Team sale.Amount
sales
|> List.groupBy (fun s -> s.Team)
|> List.sortBy fst
|> List.iter (fun (team, rows) ->
printfn "%s: %d" team (List.sumBy (fun s -> s.Amount) rows))Five sales, one report
Run it and check the north total by hand. Then add a sale for the `west` team and predict both where its row lands and where its section lands before you run it again.
Two ideas did the work here: a record that carries its fields by name, and a list walked once per question. A report is those two arranged for a reader, which is why the whole program fits on one screen.
Each stage answers a value
The report is built as a chain of answers rather than a sequence of instructions. The list of sales is the input; sorting answers a new list, grouping answers a list of pairs, and the final List.iter is the first point where anything is printed. Nothing earlier has a side effect, so each stage can be read and checked on its own.
That is also why the row printer is a function. line turns a sale into a string, and the report chooses when to call it. The ordering stage does not know about columns, and the column function does not know about teams, which keeps a change to either one local.
Every stage before the final print is pure, which is what lets the report be tested without capturing output: call the sort, compare the first record, call the group, check the pair count. The printing stage is the only one that needs the console, and it is one line long.
The row printer fixes the width of each column, so a longer item name pushes the rest of the line rather than being cut. %-5s is a minimum, and the report is checked against the longest name in the data; if a new sale has a longer one, the header and the rows no longer agree and the columns drift.
Averages and section order
An average is a sum divided by a count, and both are ints here. Without converting first, the division would drop the fraction and turn 111.67 into 111. float on each side before the slash is the whole fix, and %8.2f then decides how much of the answer the reader sees.
List.groupBy answers groups in the order their keys first appeared, which is a property of the data rather than a promise about the report. List.sortBy fst after it makes the section order explicit, so adding a sale cannot reshuffle the page. The same reasoning applies inside a section: sort by the key the reader expects, not by the order the rows happened to arrive.
Integer division truncates, so convert before averaging. A width that is too small does not truncate, it shifts every later column, so measure the longest value. And List.iter returns unit, so binding its result and expecting a list is a type error rather than an empty list.
Try these next
- Add a
bestcolumn to each section usingList.maxon the group, and align it with the others. - Compute the average sale for the whole list and compare it with the average of the section averages.
- Add a sale with a team that appears only once and check where its section lands.
A second report with averages
Run it and check the north average by hand. Then add a fourth sale to the north team, and predict both the new average and the new best before running.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
What does List.sortBy (fun s -> -s.Amount) sales do?
