From f10a1535d9410a027605e00e2f190f1ffe340e97 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 28 Jun 2019 12:29:00 +0100 Subject: [PATCH 1/3] strip everything down to just some stub tests --- model.py | 70 ------------------------------------------------ test_allocate.py | 47 -------------------------------- test_batches.py | 55 ------------------------------------- test_model.py | 32 ++++++++++++++++++++++ 4 files changed, 32 insertions(+), 172 deletions(-) delete mode 100644 test_allocate.py delete mode 100644 test_batches.py create mode 100644 test_model.py diff --git a/model.py b/model.py index 44f766a8..e69de29b 100644 --- a/model.py +++ b/model.py @@ -1,70 +0,0 @@ -from __future__ import annotations -from dataclasses import dataclass -from datetime import date -from typing import Optional, List, Set - - -class OutOfStock(Exception): - pass - - -def allocate(line: OrderLine, batches: List[Batch]) -> str: - try: - batch = next(b for b in sorted(batches) if b.can_allocate(line)) - batch.allocate(line) - return batch.reference - except StopIteration: - raise OutOfStock(f"Out of stock for sku {line.sku}") - - -@dataclass(frozen=True) -class OrderLine: - orderid: str - sku: str - qty: int - - -class Batch: - def __init__(self, ref: str, sku: str, qty: int, eta: Optional[date]): - self.reference = ref - self.sku = sku - self.eta = eta - self._purchased_quantity = qty - self._allocations = set() # type: Set[OrderLine] - - def __repr__(self): - return f"" - - def __eq__(self, other): - if not isinstance(other, Batch): - return False - return other.reference == self.reference - - def __hash__(self): - return hash(self.reference) - - def __gt__(self, other): - if self.eta is None: - return False - if other.eta is None: - return True - return self.eta > other.eta - - def allocate(self, line: OrderLine): - if self.can_allocate(line): - self._allocations.add(line) - - def deallocate(self, line: OrderLine): - if line in self._allocations: - self._allocations.remove(line) - - @property - def allocated_quantity(self) -> int: - return sum(line.qty for line in self._allocations) - - @property - def available_quantity(self) -> int: - return self._purchased_quantity - self.allocated_quantity - - def can_allocate(self, line: OrderLine) -> bool: - return self.sku == line.sku and self.available_quantity >= line.qty diff --git a/test_allocate.py b/test_allocate.py deleted file mode 100644 index 7e189307..00000000 --- a/test_allocate.py +++ /dev/null @@ -1,47 +0,0 @@ -from datetime import date, timedelta -import pytest -from model import allocate, OrderLine, Batch, OutOfStock - -today = date.today() -tomorrow = today + timedelta(days=1) -later = tomorrow + timedelta(days=10) - - -def test_prefers_current_stock_batches_to_shipments(): - in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None) - shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow) - line = OrderLine("oref", "RETRO-CLOCK", 10) - - allocate(line, [in_stock_batch, shipment_batch]) - - assert in_stock_batch.available_quantity == 90 - assert shipment_batch.available_quantity == 100 - - -def test_prefers_earlier_batches(): - earliest = Batch("speedy-batch", "MINIMALIST-SPOON", 100, eta=today) - medium = Batch("normal-batch", "MINIMALIST-SPOON", 100, eta=tomorrow) - latest = Batch("slow-batch", "MINIMALIST-SPOON", 100, eta=later) - line = OrderLine("order1", "MINIMALIST-SPOON", 10) - - allocate(line, [medium, earliest, latest]) - - assert earliest.available_quantity == 90 - assert medium.available_quantity == 100 - assert latest.available_quantity == 100 - - -def test_returns_allocated_batch_ref(): - in_stock_batch = Batch("in-stock-batch-ref", "HIGHBROW-POSTER", 100, eta=None) - shipment_batch = Batch("shipment-batch-ref", "HIGHBROW-POSTER", 100, eta=tomorrow) - line = OrderLine("oref", "HIGHBROW-POSTER", 10) - allocation = allocate(line, [in_stock_batch, shipment_batch]) - assert allocation == in_stock_batch.reference - - -def test_raises_out_of_stock_exception_if_cannot_allocate(): - batch = Batch("batch1", "SMALL-FORK", 10, eta=today) - allocate(OrderLine("order1", "SMALL-FORK", 10), [batch]) - - with pytest.raises(OutOfStock, match="SMALL-FORK"): - allocate(OrderLine("order2", "SMALL-FORK", 1), [batch]) diff --git a/test_batches.py b/test_batches.py deleted file mode 100644 index 709afb55..00000000 --- a/test_batches.py +++ /dev/null @@ -1,55 +0,0 @@ -from datetime import date -from model import Batch, OrderLine - - -def test_allocating_to_a_batch_reduces_the_available_quantity(): - batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=date.today()) - line = OrderLine("order-ref", "SMALL-TABLE", 2) - - batch.allocate(line) - - assert batch.available_quantity == 18 - - -def make_batch_and_line(sku, batch_qty, line_qty): - return ( - Batch("batch-001", sku, batch_qty, eta=date.today()), - OrderLine("order-123", sku, line_qty), - ) - -def test_can_allocate_if_available_greater_than_required(): - large_batch, small_line = make_batch_and_line("ELEGANT-LAMP", 20, 2) - assert large_batch.can_allocate(small_line) - -def test_cannot_allocate_if_available_smaller_than_required(): - small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20) - assert small_batch.can_allocate(large_line) is False - -def test_can_allocate_if_available_equal_to_required(): - batch, line = make_batch_and_line("ELEGANT-LAMP", 2, 2) - assert batch.can_allocate(line) - -def test_cannot_allocate_if_skus_do_not_match(): - batch = Batch("batch-001", "UNCOMFORTABLE-CHAIR", 100, eta=None) - different_sku_line = OrderLine("order-123", "EXPENSIVE-TOASTER", 10) - assert batch.can_allocate(different_sku_line) is False - - -def test_allocation_is_idempotent(): - batch, line = make_batch_and_line("ANGULAR-DESK", 20, 2) - batch.allocate(line) - batch.allocate(line) - assert batch.available_quantity == 18 - - -def test_deallocate(): - batch, line = make_batch_and_line("EXPENSIVE-FOOTSTOOL", 20, 2) - batch.allocate(line) - batch.deallocate(line) - assert batch.available_quantity == 20 - - -def test_can_only_deallocate_allocated_lines(): - batch, unallocated_line = make_batch_and_line("DECORATIVE-TRINKET", 20, 2) - batch.deallocate(unallocated_line) - assert batch.available_quantity == 20 diff --git a/test_model.py b/test_model.py new file mode 100644 index 00000000..ea561528 --- /dev/null +++ b/test_model.py @@ -0,0 +1,32 @@ +from datetime import date, timedelta +import pytest + +# from model import ... + +today = date.today() +tomorrow = today + timedelta(days=1) +later = tomorrow + timedelta(days=10) + + +def test_allocating_to_a_batch_reduces_the_available_quantity(): + pytest.fail("todo") + + +def test_can_allocate_if_available_greater_than_required(): + pytest.fail("todo") + + +def test_cannot_allocate_if_available_smaller_than_required(): + pytest.fail("todo") + + +def test_can_allocate_if_available_equal_to_required(): + pytest.fail("todo") + + +def test_prefers_warehouse_batches_to_shipments(): + pytest.fail("todo") + + +def test_prefers_earlier_batches(): + pytest.fail("todo") From 11dba13b453fe3350e8428acfca4611b197ff0a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Mr=C3=A1z?= Date: Sat, 30 Aug 2025 22:06:01 +0200 Subject: [PATCH 2/3] Add domain classes and unit tests --- model.py | 86 ++++++++++++++++++++++++++++++++++++++++++ test_model.py | 101 ++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 180 insertions(+), 7 deletions(-) diff --git a/model.py b/model.py index e69de29b..31d1db82 100644 --- a/model.py +++ b/model.py @@ -0,0 +1,86 @@ +from datetime import date, timedelta +from dataclasses import dataclass, field +from typing import NewType +import queue + +OrderReference = NewType('OrderReference', str) + +@dataclass(frozen=True) +class OrderLine: + order_reference: OrderReference + sku: str + qty: int + + +@dataclass +class Batch: + def __init__(self, reference: str, sku: str, qty: int, eta: date|None = None): + self.reference = reference + self.sku = sku + self.qty = qty + self.order_lines: set[OrderLine] = set() + self.eta = eta + + def allocate(self, order_line: OrderLine) -> bool: + if order_line.sku != self.sku: + return False + if order_line.qty > self.available_qty: + return False + if order_line in self.order_lines: + return False + self.order_lines.add(order_line) + return True + + def deallocate(self, order_line: OrderLine) -> bool: + if not self.can_deallocate(order_line): + return False + self.order_lines.remove(order_line) + return True + + def can_allocate(self, order_line: OrderLine) -> bool: + return order_line.sku == self.sku and order_line not in self.order_lines and order_line.qty <= self.available_qty + + def can_deallocate(self, order_line: OrderLine) -> bool: + return order_line in self.order_lines + + @property + def allocated_qty(self) -> int: + return sum(line.qty for line in self.order_lines) + + @property + def available_qty(self) -> int: + return self.qty - self.allocated_qty + + def __hash__(self): + return hash(self.reference) + + def __eq__(self, value) -> bool: + if not isinstance(value, Batch): + return False + return value.reference == self.reference + + +class AllocationService: + def __init__(self): + self.available_stocks: dict[str, queue.PriorityQueue[Batch]] = dict() + self.exhausted_stocks: dict[str, set[Batch]] = dict() + + def notify(self, batch: Batch): + if batch.eta is not None: + self.available_stocks.setdefault(batch.sku, queue.PriorityQueue()).put((1, batch.eta, batch)) + else: + self.available_stocks.setdefault(batch.sku, queue.PriorityQueue()).put((0, None, batch)) + + def put_order(self, order_line: OrderLine) -> bool: + if order_line.sku not in self.available_stocks: + return False + if self.available_stocks.setdefault(order_line.sku, queue.PriorityQueue()).empty(): + return False + _, _, earliest_batch = self.available_stocks[order_line.sku].get() + success = earliest_batch.allocate(order_line) + if success: + if earliest_batch.available_qty == 0: + self.exhausted_stocks.setdefault(earliest_batch.sku, set()).add(earliest_batch) + else: + self.notify(earliest_batch) + return success \ No newline at end of file diff --git a/test_model.py b/test_model.py index ea561528..bb879f4e 100644 --- a/test_model.py +++ b/test_model.py @@ -1,32 +1,119 @@ +import time from datetime import date, timedelta import pytest -# from model import ... +from model import Batch, OrderLine, AllocationService, OrderReference today = date.today() tomorrow = today + timedelta(days=1) later = tomorrow + timedelta(days=10) +def make_batch_and_line(sku: str, batch_qty: int, line_qty: int) -> tuple[Batch, OrderLine]: + return Batch(f'batch_{time.time()}', sku, batch_qty), OrderLine(OrderReference('order001'), sku, line_qty) + def test_allocating_to_a_batch_reduces_the_available_quantity(): - pytest.fail("todo") + batch, line = make_batch_and_line('small-red-table', 20, 5) + + assert batch.can_allocate(line) is True def test_can_allocate_if_available_greater_than_required(): - pytest.fail("todo") + batch, line = make_batch_and_line('small-red-table', 10, 5) + + assert batch.can_allocate(line) is True def test_cannot_allocate_if_available_smaller_than_required(): - pytest.fail("todo") + batch, line = make_batch_and_line('small-red-table', 5, 9) + + assert batch.can_allocate(line) is False def test_can_allocate_if_available_equal_to_required(): - pytest.fail("todo") + batch, line = make_batch_and_line('small-red-table', 5, 5) + + assert batch.can_allocate(line) is True def test_prefers_warehouse_batches_to_shipments(): - pytest.fail("todo") + allocation = AllocationService() + batch_wh = Batch('batch_warehouse', 'small-red', 5, None) + batch_tomo = Batch('batch001', 'small-red', 10, tomorrow) + batch_later = Batch('batch002', 'small-red', 5, later) + + allocation.notify(batch_later) + allocation.notify(batch_tomo) + allocation.notify(batch_wh) + + order_line = OrderLine(OrderReference('order002'), 'small-red', 3) + + allocation.put_order(order_line) + + assert batch_wh.available_qty == 2 + assert batch_tomo.available_qty == batch_tomo.qty + assert batch_later.available_qty == batch_later.qty def test_prefers_earlier_batches(): - pytest.fail("todo") + allocation = AllocationService() + batch_tomo = Batch('batch001', 'small-red', 10, tomorrow) + batch_later = Batch('batch002', 'small-red', 5, later) + + allocation.notify(batch_tomo) + allocation.notify(batch_later) + + order_line = OrderLine(OrderReference('order002'), 'small-red', 2) + + allocation.put_order(order_line) + + assert batch_tomo.available_qty == 8 + assert batch_later.available_qty == batch_later.qty + +def test_allocates_same_line_only_once(): + batch, line = make_batch_and_line('small-red', 10, 2) + + batch.allocate(line) + + assert batch.can_allocate(line) is False + +def test_batch_rejects_line_with_different_sku(): + batch = Batch('batch001', 'small-red', 10) + order_line = OrderLine(OrderReference('order002'), 'small-blue', 2) + + assert batch.can_allocate(order_line) is False + +def test_allocation_rejects_already_allocated_line(): + allocation = AllocationService() + batch = Batch('batch002', 'small-red', 10) + + allocation.notify(batch) + + order_line = OrderLine(OrderReference('order002'), 'small-red', 2) + + allocation.put_order(order_line) + + assert allocation.put_order(order_line) == False + +def test_allocation_fails_when_batches_are_exhausted(): + batch = Batch('batch001', 'small-red', 10) + allocation = AllocationService() + + allocation.notify(batch) + + order_line1 = OrderLine(OrderReference('order002'), 'small-red', 10) + + assert allocation.put_order(order_line1) == True + + order_line2 = OrderLine(OrderReference('order002'), 'small-red', 2) + + assert allocation.put_order(order_line2) == False + +def test_can_deallocate_only_allocated_lines(): + batch, line = make_batch_and_line('small-red', 10, 2) + + assert batch.can_deallocate(line) is False + + batch.allocate(line) + + assert batch.can_deallocate(line) is True \ No newline at end of file From fa2205a9c72b4f5f09a1ecc13cadade3ff05b028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Mr=C3=A1z?= Date: Sat, 30 Aug 2025 22:11:41 +0200 Subject: [PATCH 3/3] map domain models to tables and add repository classes --- model.py | 2 +- orm.py | 32 +++++++++++++++++++++++++++++ repository.py | 41 +++++++++++++++++++++++++++++++++++++ test_repository.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 orm.py create mode 100644 repository.py create mode 100644 test_repository.py diff --git a/model.py b/model.py index 31d1db82..54c761e7 100644 --- a/model.py +++ b/model.py @@ -5,7 +5,7 @@ OrderReference = NewType('OrderReference', str) -@dataclass(frozen=True) +@dataclass(unsafe_hash=True) class OrderLine: order_reference: OrderReference sku: str diff --git a/orm.py b/orm.py new file mode 100644 index 00000000..96826589 --- /dev/null +++ b/orm.py @@ -0,0 +1,32 @@ +from sqlalchemy import Table, Column, Integer, String, ForeignKey, Date +from sqlalchemy.orm import relationship, registry + +import model + +mapper_registry = registry() + +batches = Table( + 'batches', + mapper_registry.metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("reference", String(255)), + Column("sku", String(255)), + Column("qty", Integer, nullable=False), + Column("eta", Date, nullable=True), +) + +order_lines = Table( + "order_lines", + mapper_registry.metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("sku", String(255)), + Column("qty", Integer, nullable=False), + Column("order_reference", String(255)), + Column("batch_id", ForeignKey('batches.id')) +) + +mapper_registry.map_imperatively(model.OrderLine, order_lines) +mapper_registry.map_imperatively(model.Batch, batches, + properties={ + "order_lines": relationship(model.OrderLine, collection_class=set) + }) diff --git a/repository.py b/repository.py new file mode 100644 index 00000000..ba8e76f7 --- /dev/null +++ b/repository.py @@ -0,0 +1,41 @@ +import abc + +from sqlalchemy import select +from sqlalchemy.orm import Session + +import model +import orm + +class AbstractRepository(abc.ABC): + @abc.abstractmethod + def add(self, batch: model.Batch): + raise NotImplementedError + + @abc.abstractmethod + def get(self, reference) -> model.Batch | None: + raise NotImplementedError + + +class SqlAlchemyRepository(AbstractRepository): + def __init__(self, session: Session): + self.session = session + + def add(self, batch: model.Batch): + self.session.add(batch) + + def get(self, reference: str) -> model.Batch | None: + return self.session.scalar(select(model.Batch).where(model.Batch.reference == reference)) + + +class FakeRepository(AbstractRepository): + def __init__(self, batches): + self._batches = set(batches) + + def add(self, batch): + self._batches.add(batch) + + def get(self, reference): + return next(b for b in self._batches if b.reference == reference) + + def list(self): + return list(self._batches) \ No newline at end of file diff --git a/test_repository.py b/test_repository.py new file mode 100644 index 00000000..86c2dfd6 --- /dev/null +++ b/test_repository.py @@ -0,0 +1,51 @@ +from sqlalchemy import create_engine, text, select +from sqlalchemy.orm import Session +import pytest + +import orm +import repository +import model + +@pytest.fixture +def engine(): + return create_engine('sqlite:///') + +@pytest.fixture +def session(engine): + orm.mapper_registry.metadata.create_all(engine) + return Session(engine) + +def insert_batch(batch_ref: str, sku: str, qty: int, sess: Session): + batch = model.Batch(batch_ref, sku, qty, None) + sess.add(batch) + sess.commit() + + +def insert_order_line(batch_ref: str, order_ref: str, sku: str, qty: int, sess: Session): + batch_id = sess.scalar(select(model.Batch.id).where(model.Batch.reference == batch_ref)) + sess.execute(text('insert into order_lines (sku, qty, order_reference, batch_id) values (:sku, :qty, :order_ref, :batch_id)') + .bindparams(sku=sku, qty=qty, order_ref=order_ref, batch_id=batch_id)) + sess.commit() + +def test_repository_can_save_a_batch(session): + batch = model.Batch('batch1', 'small-red', 100, None) + + repo = repository.SqlAlchemyRepository(session) + repo.add(batch) + session.commit() + + rows = session.execute(text('select reference, sku, qty, eta from batches')) + assert list(rows) == [('batch1', 'small-red', 100, None)] + +def test_repository_can_retrieve_a_batch_with_allocations(session): + insert_batch('batch1', 'small-red', 50, session) + insert_order_line('batch1', 'order1', 'small-red', 5, session) + + repo = repository.SqlAlchemyRepository(session) + batch = repo.get('batch1') + expected = model.Batch('batch1', 'small-red', 50, None) + + assert batch == expected + assert batch.order_lines == {model.OrderLine('order1', 'small-red', 5)} + assert batch.sku == expected.sku + assert batch.eta == expected.eta \ No newline at end of file