HiveBrain v1.2.0
Get Started
← Back to all entries
patternMajor

Return total balance from unsorted purchase items that may have specials

Submitted by: @import:stackexchange-codereview··
0
Viewed 0 times
totalmayreturnunsortedbalanceitemsthatspecialspurchasefrom

Problem

This is in reference to the following kata:

Item   Unit      Special
         Price     Price
  --------------------------
    A     50       3 for 130
    B     30       2 for 45
    C     20
    D     15


I am still trying to learn F#.
Thus, I have the following code:

module Checkout

(*Types*)
type Type = A | B | C | D
type Total = { UnitPrice:int ; Qty:int }
             member x.Price() = x.UnitPrice * x.Qty

type Special =
    | None
    | ThreeForOneThirty
    | TwoForFourtyFive

type Item = 
    { Type:Type
      Total:Total
      Special:Special } 

      member x.Price() =
        match x.Special with
        | ThreeForOneThirty -> 
            if x.Total.Qty / 3 > 0
            then (x.Total.Qty / 3) * 130
            else x.Total.Price()

        | TwoForFourtyFive ->
            if x.Total.Qty / 2 > 0
            then (x.Total.Qty / 2) * 45
            else x.Total.Price()

        | None -> x.Total.Price()

(*Private Functions*)
let private getTypeQty itemsOfType = 
    itemsOfType |> Seq.sumBy(fun x -> x.Total.Qty)

let private consolidate group acc =

    let first = group |> Seq.head
    { Type    =  first.Type; 
      Total   = { Qty=group |> getTypeQty; UnitPrice=first.Total.UnitPrice }
      Special = first.Special; } :: acc 

(*Tests*)
open FsUnit
open NUnit.Framework

[]
let ``buying (2) A units, (1) B unit, (1) A unit = $160`` () =

    // Setup
    let a2 = { Type=A; Total={UnitPrice=50; Qty=2}; Special=ThreeForOneThirty }
    let b =  { Type=B; Total={UnitPrice=30; Qty=1}; Special=TwoForFourtyFive  }
    let a =  { Type=A; Total={UnitPrice=50; Qty=1}; Special=ThreeForOneThirty }

    seq [a2; b; a] |> Seq.groupBy (fun item -> item.Type)
                   |> Seq.map snd
                   |> Seq.fold(fun consolidated group -> consolidate group consolidated) []
                   |> Seq.sumBy (fun item -> item.Price())
                   |> should equal 160


I don't like the way I coupled the Specials DU to an item. Thus, my

Solution

Paraphrasing Kent Beck (IIRC), I'd design my types so that concerns that change together are kept together, whereas concerns that change separately should be kept as separate as possible.

With that in mind, I'd start defining shopping basket items as simply as possible:

type Good = { SKU : string; Price : int }


This also enables you to define the price table, independently of the discount rules:

let prices = [
    { SKU = "A"; Price = 50 }
    { SKU = "B"; Price = 30 }
    { SKU = "C"; Price = 20 }
    { SKU = "D"; Price = 15 } ]

// string -> Good option
let scan sku = prices |> List.tryFind (fun x -> x.SKU = sku)


This code snippet also defines a simple scan function that can be used to look up a string in the price table in order to turn it into a Good value.

I don't think modelling the product as a discriminated union (e.g. A | B | C | D) is a good idea, because that's going to make it hard if you want to add an E product, or if you want to remove the C product.

The same type of argument goes for price rules. List prices change according to business decisions, and the same goes for discounts, but at different rates. Discriminated unions define a finite set of options, but I don't think a set of discounts ought to be a finite set of hard-coded options. You should be able to add or remove discount rules according to business decisions.

Instead, I'd be inclined to define a discount with the type Good list -> int * Good list. The idea here is that you input a list of Good values, and you get back a price (int) and the remaining Good values where a price has yet to be calculated.

You can, for example, implement the rule about the A products like this:

// Good list -> int * Good list
let aRule items =
    let xs, others = List.partition (fun x -> x.SKU = "A") items
    let hits, rest =
        xs |> List.chunkBySize 3 |> List.partition (fun l -> l.Length = 3)
    let xTotal = hits.Length * 130
    xTotal, rest |> List.concat |> List.append others


This looks, perhaps, a bit complicated, but it works like this:

  • First, it partitions all items into A items (xs) and all other items (others).



  • It then divides xs into chunks of 3. This produces a list of lists, where most of the lists have the length 3. There may, however, be a residual list with one or two items, so these are partitioned into the rest value.



  • The total for all the hits is calculated. Each hit is a list of three A items, which has the special discount price 130. You may have three, six, nine, etc. A items, so the special price 130 is multiplied with the length of hits, which is a list of lists.



  • Finally, xTotal is returned, together with all the items that didn't trigger the discount. These include all non-A items, but also residual A items.



Likewise, the discount for B items can be defined in the same way:

// Good list -> int * Good list
let bRule items =
    let xs, others = List.partition (fun x -> x.SKU = "B") items
    let hits, rest =
        xs |> List.chunkBySize 2 |> List.partition (fun l -> l.Length = 2)
    let xTotal = hits.Length * 45
    xTotal, rest |> List.concat |> List.append others


This function is almost identical to aRule, so is a candidate for some refactoring. Here, however, I decided to apply the rule of three, so I left them as is.

Apart from these discount rules, you'll also need a 'default' price calculation rule:

// Good list -> int * 'a list
let defaultRule items = List.sumBy (fun x -> x.Price) items, []


Notice how Good list -> int 'a list also fits the desired type of Good list -> int Good list. This enables you to define a set of rules as a list:

// (Good list -> int * Good list) list
let rules = [aRule; bRule; defaultRule]


Calculating a total price is now easy:

// ('a -> int * 'a) list -> 'a -> int
let total rules items = 
    let acc (tot, rest) f =
        let tot', rest' = f rest
        tot + tot', rest'
    rules |> List.fold acc (0, items) |> fst


This function performs a left fold over rules, while accumulating the total so far, and the rest of the items.

Tests

This implementation passes all the tests in the original kata formulation:

open Xunit
open Swensen.Unquote

[]
[]
[]
[]
[]

[]
[]
[]
[]
[]

[]
[]
[]
[]

// Incremental
[]
[]
[]
[]
[]
let ``total returns correct result`` (items : string) expected =
    let actual =
        items |> Seq.choose (string >> scan) |> Seq.toList |> total rules
    expected =! actual


Flexibility

Is this the simplest possible implementation? Most likely not, but it's fairly flexible.

Imagine, for example, that you're being asked to implement a new 'bundle' discount where you get A, B, and C for 85 if you buy them together. You can implement this without changing any of the existing code:

```
// f:('a -> bool) -> ('a list -> 'a option * 'a list)
let tryFindFirst f =
let acc (found, others) x =
match fou

Code Snippets

type Good = { SKU : string; Price : int }
let prices = [
    { SKU = "A"; Price = 50 }
    { SKU = "B"; Price = 30 }
    { SKU = "C"; Price = 20 }
    { SKU = "D"; Price = 15 } ]

// string -> Good option
let scan sku = prices |> List.tryFind (fun x -> x.SKU = sku)
// Good list -> int * Good list
let aRule items =
    let xs, others = List.partition (fun x -> x.SKU = "A") items
    let hits, rest =
        xs |> List.chunkBySize 3 |> List.partition (fun l -> l.Length = 3)
    let xTotal = hits.Length * 130
    xTotal, rest |> List.concat |> List.append others
// Good list -> int * Good list
let bRule items =
    let xs, others = List.partition (fun x -> x.SKU = "B") items
    let hits, rest =
        xs |> List.chunkBySize 2 |> List.partition (fun l -> l.Length = 2)
    let xTotal = hits.Length * 45
    xTotal, rest |> List.concat |> List.append others
// Good list -> int * 'a list
let defaultRule items = List.sumBy (fun x -> x.Price) items, []

Context

StackExchange Code Review Q#139578, answer score: 23

Revisions (0)

No revisions yet.