Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions typescript/src/model/Bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {Product} from "./Product"

export class Bundle {
constructor(public readonly products: Product[]) {
}

containsProduct(product: Product): boolean {
return this.products.some(p => p.name === product.name);
}

getProducts(): Product[] {
return [...this.products];
}
}
13 changes: 13 additions & 0 deletions typescript/src/model/BundleOffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {Bundle} from "./Bundle"
import {SpecialOfferType} from "./SpecialOfferType"

export class BundleOffer {
public constructor(public readonly offerType: SpecialOfferType,
public readonly bundle: Bundle,
public readonly discountPercentage: number) {
}

getBundle(): Bundle {
return this.bundle;
}
}
39 changes: 39 additions & 0 deletions typescript/src/model/ShoppingCart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {Discount} from "./Discount"
import {Receipt} from "./Receipt"
import {Offer} from "./Offer"
import {SpecialOfferType} from "./SpecialOfferType"
import {BundleOffer} from "./BundleOffer"

type ProductQuantities = { [productName: string]: ProductQuantity }
export type OffersByProduct = {[productName: string]: Offer};
Expand Down Expand Up @@ -88,4 +89,42 @@ export class ShoppingCart {

}
}

handleBundleOffers(receipt: Receipt, bundleOffers: BundleOffer[], catalog: SupermarketCatalog): void {
for (const bundleOffer of bundleOffers) {
const bundle = bundleOffer.getBundle();
const bundleProducts = bundle.getProducts();

// Calculate how many complete bundles we can make
let maxCompleteBundle = Number.MAX_SAFE_INTEGER;

for (const bundleProduct of bundleProducts) {
const productQuantity = this._productQuantities[bundleProduct.name];
if (!productQuantity) {
maxCompleteBundle = 0;
break;
}
maxCompleteBundle = Math.min(maxCompleteBundle, Math.floor(productQuantity.quantity));
}

if (maxCompleteBundle > 0) {
// Calculate discount for complete bundles
let bundleTotal = 0;
for (const bundleProduct of bundleProducts) {
const unitPrice = catalog.getUnitPrice(bundleProduct);
bundleTotal += unitPrice * maxCompleteBundle;
}

const discountAmount = bundleTotal * (bundleOffer.discountPercentage / 100.0);

// Create a discount with the first product in the bundle as representative
const discount = new Discount(
bundleProducts[0],
`Bundle discount (${bundleOffer.discountPercentage}% off)`,
discountAmount
);
receipt.addDiscount(discount);
}
}
}
}
2 changes: 1 addition & 1 deletion typescript/src/model/SpecialOfferType.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@

export enum SpecialOfferType {
ThreeForTwo, TenPercentDiscount, TwoForAmount, FiveForAmount
ThreeForTwo, TenPercentDiscount, TwoForAmount, FiveForAmount, Bundle
}
10 changes: 10 additions & 0 deletions typescript/src/model/Teller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import {Product} from "./Product"
import {Receipt} from "./Receipt"
import {Offer} from "./Offer"
import {SpecialOfferType} from "./SpecialOfferType"
import {BundleOffer} from "./BundleOffer"
import {Bundle} from "./Bundle"

export class Teller {

private offers: OffersByProduct = {};
private bundleOffers: BundleOffer[] = [];

public constructor(private readonly catalog: SupermarketCatalog ) {
}
Expand All @@ -16,6 +19,12 @@ export class Teller {
this.offers[product.name] = new Offer(offerType, product, argument);
}

public addBundleOffer(products: Product[], discountPercentage: number = 10): void {
const bundle = new Bundle(products);
const bundleOffer = new BundleOffer(SpecialOfferType.Bundle, bundle, discountPercentage);
this.bundleOffers.push(bundleOffer);
}

public checksOutArticlesFrom(theCart: ShoppingCart): Receipt {
const receipt = new Receipt();
const productQuantities = theCart.getItems();
Expand All @@ -27,6 +36,7 @@ export class Teller {
receipt.addProduct(p, quantity, unitPrice, price);
}
theCart.handleOffers(receipt, this.offers, this.catalog);
theCart.handleBundleOffers(receipt, this.bundleOffers, this.catalog);

return receipt;
}
Expand Down
146 changes: 146 additions & 0 deletions typescript/test/Supermarket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,150 @@ describe('Supermarket', () => {
assert.approximately(receiptItem.totalPrice, 2.5*1.99, 0.01);
assert.equal(receiptItem.quantity, 2.5);
});

it('Bundle discount - complete bundle', () => {
// ARRANGE
const catalog: SupermarketCatalog = new FakeCatalog();
const toothbrush: Product = new Product("toothbrush", ProductUnit.Each);
catalog.addProduct(toothbrush, 0.99);
const toothpaste: Product = new Product("toothpaste", ProductUnit.Each);
catalog.addProduct(toothpaste, 1.79);

const teller: Teller = new Teller(catalog);
teller.addBundleOffer([toothbrush, toothpaste], 10);

const cart: ShoppingCart = new ShoppingCart();
cart.addItemQuantity(toothbrush, 1);
cart.addItemQuantity(toothpaste, 1);

// ACT
const receipt: Receipt = teller.checksOutArticlesFrom(cart);

// ASSERT
const totalPrice = 0.99 + 1.79; // 2.78
const expectedDiscount = totalPrice * 0.1; // 0.278
const expectedTotal = totalPrice - expectedDiscount; // 2.502

assert.approximately(receipt.getTotalPrice(), expectedTotal, 0.01);
assert.equal(receipt.getDiscounts().length, 1);
assert.approximately(receipt.getDiscounts()[0].discountAmount, expectedDiscount, 0.01);
assert.equal(receipt.getItems().length, 2);
});

it('Bundle discount - partial bundle gets no discount', () => {
// ARRANGE
const catalog: SupermarketCatalog = new FakeCatalog();
const toothbrush: Product = new Product("toothbrush", ProductUnit.Each);
catalog.addProduct(toothbrush, 0.99);
const toothpaste: Product = new Product("toothpaste", ProductUnit.Each);
catalog.addProduct(toothpaste, 1.79);

const teller: Teller = new Teller(catalog);
teller.addBundleOffer([toothbrush, toothpaste], 10);

const cart: ShoppingCart = new ShoppingCart();
cart.addItemQuantity(toothbrush, 1);
// No toothpaste - incomplete bundle

// ACT
const receipt: Receipt = teller.checksOutArticlesFrom(cart);

// ASSERT
assert.approximately(receipt.getTotalPrice(), 0.99, 0.01);
assert.isEmpty(receipt.getDiscounts());
assert.equal(receipt.getItems().length, 1);
});

it('Bundle discount - only complete bundles are discounted', () => {
// ARRANGE
const catalog: SupermarketCatalog = new FakeCatalog();
const toothbrush: Product = new Product("toothbrush", ProductUnit.Each);
catalog.addProduct(toothbrush, 0.99);
const toothpaste: Product = new Product("toothpaste", ProductUnit.Each);
catalog.addProduct(toothpaste, 1.79);

const teller: Teller = new Teller(catalog);
teller.addBundleOffer([toothbrush, toothpaste], 10);

const cart: ShoppingCart = new ShoppingCart();
cart.addItemQuantity(toothbrush, 2);
cart.addItemQuantity(toothpaste, 1);

// ACT
const receipt: Receipt = teller.checksOutArticlesFrom(cart);

// ASSERT
// Only 1 complete bundle (1 toothbrush + 1 toothpaste), extra toothbrush gets no discount
const bundlePrice = 0.99 + 1.79; // 2.78
const bundleDiscount = bundlePrice * 0.1; // 0.278
const extraToothbrushPrice = 0.99;
const expectedTotal = bundlePrice - bundleDiscount + extraToothbrushPrice; // 2.502 + 0.99 = 3.492

assert.approximately(receipt.getTotalPrice(), expectedTotal, 0.01);
assert.equal(receipt.getDiscounts().length, 1);
assert.approximately(receipt.getDiscounts()[0].discountAmount, bundleDiscount, 0.01);
assert.equal(receipt.getItems().length, 2);
});

it('Bundle discount - multiple complete bundles', () => {
// ARRANGE
const catalog: SupermarketCatalog = new FakeCatalog();
const toothbrush: Product = new Product("toothbrush", ProductUnit.Each);
catalog.addProduct(toothbrush, 0.99);
const toothpaste: Product = new Product("toothpaste", ProductUnit.Each);
catalog.addProduct(toothpaste, 1.79);

const teller: Teller = new Teller(catalog);
teller.addBundleOffer([toothbrush, toothpaste], 10);

const cart: ShoppingCart = new ShoppingCart();
cart.addItemQuantity(toothbrush, 2);
cart.addItemQuantity(toothpaste, 2);

// ACT
const receipt: Receipt = teller.checksOutArticlesFrom(cart);

// ASSERT
// 2 complete bundles
const bundlePrice = 0.99 + 1.79; // 2.78
const totalBundlePrice = bundlePrice * 2; // 5.56
const bundleDiscount = totalBundlePrice * 0.1; // 0.556
const expectedTotal = totalBundlePrice - bundleDiscount; // 5.004

assert.approximately(receipt.getTotalPrice(), expectedTotal, 0.01);
assert.equal(receipt.getDiscounts().length, 1);
assert.approximately(receipt.getDiscounts()[0].discountAmount, bundleDiscount, 0.01);
assert.equal(receipt.getItems().length, 2);
});

it('Bundle discount - fractional quantities only count whole items', () => {
// ARRANGE
const catalog: SupermarketCatalog = new FakeCatalog();
const toothbrush: Product = new Product("toothbrush", ProductUnit.Each);
catalog.addProduct(toothbrush, 0.99);
const toothpaste: Product = new Product("toothpaste", ProductUnit.Each);
catalog.addProduct(toothpaste, 1.79);

const teller: Teller = new Teller(catalog);
teller.addBundleOffer([toothbrush, toothpaste], 10);

const cart: ShoppingCart = new ShoppingCart();
cart.addItemQuantity(toothbrush, 1.5);
cart.addItemQuantity(toothpaste, 1.2);

// ACT
const receipt: Receipt = teller.checksOutArticlesFrom(cart);

// ASSERT
// Only 1 complete bundle can be formed from floor(1.5) = 1 and floor(1.2) = 1
const bundlePrice = 0.99 + 1.79; // 2.78
const bundleDiscount = bundlePrice * 0.1; // 0.278
const totalPrice = 0.99 * 1.5 + 1.79 * 1.2; // 1.485 + 2.148 = 3.633
const expectedTotal = totalPrice - bundleDiscount; // 3.633 - 0.278 = 3.355

assert.approximately(receipt.getTotalPrice(), expectedTotal, 0.01);
assert.equal(receipt.getDiscounts().length, 1);
assert.approximately(receipt.getDiscounts()[0].discountAmount, bundleDiscount, 0.01);
assert.equal(receipt.getItems().length, 2);
});
});