Bug report (python repo "ledger"):
Our warehouse ran out of a part during the supplier lead time even though a reorder point was configured for it.

Investigating, the SKU sat at exactly its reorder point for two days and ReorderPolicy.suggest() returned nothing for it the whole time. Fix it.

Repository files (containing the defects):
README.md: # ledger

A small event-sourced inventory ledger.

Everything that happens to stock is recorded as an immutable event in the
`EventStore`. Current state is never stored directly; it is derived by folding
the event log through a *projection*.

    from ledger import EventStore, StockProjection

    store = EventStore()
    store.receive("widget", qty=100, unit_cost=2.50)
    store.ship("widget", qty=30)

    stock = StockProjection()
    stock.catch_up(store)
    stock.on_hand("widget")   # 70

## Layout

| module | role |
|---|---|
| `events.py` | immutable event types |
| `store.py` | append-only log, owns sequence numbering |
| `projections.py` | `StockProjection` (units) and `Valuation` (FIFO cost) |
| `policies.py` | reorder suggestions and multi-warehouse allocation |
| `errors.py` | exception hierarchy |

## Resuming a projection

Projections are incremental. Each remembers the last sequence number it
applied and asks the store only for events after it, so catching up a long
ledger does not mean replaying it from the beginning.

ledger/__init__.py: """A small event-sourced inventory ledger."""
from .errors import LedgerError, UnknownSku, InsufficientStock
from .events import Event, Received, Shipped, Adjusted
from .store import EventStore
from .projections import StockProjection, Valuation
from .policies import ReorderPolicy, allocate

__all__ = [
    "LedgerError", "UnknownSku", "InsufficientStock",
    "Event", "Received", "Shipped", "Adjusted",
    "EventStore", "StockProjection", "Valuation",
    "ReorderPolicy", "allocate",
]

ledger/errors.py: """Exception types raised across the ledger."""


class LedgerError(Exception):
    """Base class for every error this package raises."""


class UnknownSku(LedgerError):
    """A SKU was referenced that the ledger has never seen."""

    def __init__(self, sku: str):
        super().__init__(f"unknown sku: {sku}")
        self.sku = sku


class InsufficientStock(LedgerError):
    """A shipment was requested for more units than are on hand."""

    def __init__(self, sku: str, wanted: int, available: int):
        super().__init__(
            f"cannot ship {wanted} of {sku}: only {available} on hand")
        self.sku = sku
        self.wanted = wanted
        self.available = available


class SequenceError(LedgerError):
    """Events were presented to a projection out of order."""

ledger/events.py: """Event types.

Every event carries a monotonically increasing sequence number assigned by the
store, plus the SKU it concerns. Events are immutable once created: a ledger
that can be rewritten after the fact is not a ledger.
"""
from dataclasses import dataclass


@dataclass(frozen=True)
class Event:
    """Base event. `seq` is assigned by the store, never by the caller."""
    seq: int
    ts: float
    sku: str

    @property
    def kind(self) -> str:
        return type(self).__name__.lower()

    def describe(self) -> str:
        return f"[{self.seq}] {self.kind} {self.sku}"


@dataclass(frozen=True)
class Received(Event):
    """Stock arrived into the warehouse at a known unit cost."""
    qty: int
    unit_cost: float

    def describe(self) -> str:
        return f"{super().describe()} +{self.qty} @ {self.unit_cost:.2f}"


@dataclass(frozen=True)
class Shipped(Event):
    """Stock left the warehouse."""
    qty: int

    def describe(self) -> str:
        return f"{super().describe()} -{self.qty}"


@dataclass(frozen=True)
class Adjusted(Event):
    """A stocktake correction. `delta` may be negative."""
    delta: int
    reason: str

    def describe(self) -> str:
        return f"{super().describe()} {self.delta:+d} ({self.reason})"

ledger/policies.py: """Decisions taken on top of the read models.

Nothing here touches the event log directly. Policies read a projection and
return a plan; committing that plan is the caller's business.
"""
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple

from .errors import InsufficientStock, UnknownSku
from .projections import StockProjection


@dataclass(frozen=True)
class Warehouse:
    """A stocking location, with a priority used to break ties."""
    name: str
    priority: int
    on_hand: int


@dataclass(frozen=True)
class ReorderSuggestion:
    sku: str
    order_qty: int
    reason: str


class ReorderPolicy:
    """Suggests replenishment when a SKU falls to or below its threshold.

    The comparison is deliberately inclusive: a SKU sitting exactly on its
    reorder point has reached it, and waiting for it to drop further is how a
    warehouse runs out during the lead time.
    """

    def __init__(self, thresholds: Dict[str, int], target: Dict[str, int]):
        self.thresholds = dict(thresholds)
        self.target = dict(target)

    def threshold_for(self, sku: str) -> int:
        if sku not in self.thresholds:
            raise UnknownSku(sku)
        return self.thresholds[sku]

    def suggest(self, stock: StockProjection) -> List[ReorderSuggestion]:
        """One suggestion per SKU at or below its reorder point."""
        out: List[ReorderSuggestion] = []
        for sku in sorted(self.thresholds):
            level = stock.levels.get(sku, 0)
            threshold = self.thresholds[sku]
            if level < threshold:
                target = self.target.get(sku, threshold * 2)
                qty = target - level
                if qty > 0:
                    out.append(ReorderSuggestion(
                        sku=sku, order_qty=qty,
                        reason=f"level {level} at or below threshold {threshold}"))
        return out


def allocate(demand: int, warehouses: Sequence[Warehouse]) -> List[Tuple[str, int]]:
    """Split `demand` across warehouses, highest priority first.

    Ties on priority are broken by name so the result is deterministic. The
    input sequence is never mutated -- callers reuse their warehouse list
    across several allocations and would otherwise see it quietly emptied.
    """
    if demand <= 0:
        raise ValueError("demand must be positive")

    ordered = sorted(warehouses, key=lambda w: (-w.priority, w.name))
    available = sum(w.on_hand for w in ordered)
    if available < demand:
        raise InsufficientStock("<multiple>", demand, available)

    plan: List[Tuple[str, int]] = []
    remaining = demand
    for warehouse in ordered:
        if remaining <= 0:
            break
        take = min(warehouse.on_hand, remaining)
        if take > 0:
            plan.append((warehouse.name, take))
            remaining -= take
    return plan

ledger/projections.py: """Read models built by folding the event log.

A projection is resumable: it remembers the last sequence number it applied
and asks the store only for what came after. That is what keeps a long ledger
cheap to query, and it is why the store's `since` boundary matters so much.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple

from .errors import SequenceError, UnknownSku
from .events import Adjusted, Event, Received, Shipped
from .store import EventStore


class StockProjection:
    """Units on hand per SKU."""

    def __init__(self) -> None:
        self.levels: Dict[str, int] = {}
        self.last_seq: int = 0

    def apply(self, event: Event) -> None:
        """Fold a single event into the running totals."""
        if event.seq <= self.last_seq:
            raise SequenceError(
                f"event {event.seq} already applied (at {self.last_seq})")
        current = self.levels.get(event.sku, 0)
        if isinstance(event, Received):
            current += event.qty
        elif isinstance(event, Shipped):
            current -= event.qty
        elif isinstance(event, Adjusted):
            current += event.delta
        self.levels[event.sku] = current
        self.last_seq = event.seq

    def catch_up(self, store: EventStore) -> int:
        """Apply everything appended since the last call. Returns the count."""
        pending = store.since(self.last_seq)
        for event in pending:
            self.apply(event)
        return len(pending)

    def on_hand(self, sku: str) -> int:
        if sku not in self.levels:
            raise UnknownSku(sku)
        return self.levels[sku]

    def negative_skus(self) -> List[str]:
        """SKUs the log says we hold a negative quantity of."""
        return sorted(s for s, n in self.levels.items() if n < 0)


@dataclass
class Lot:
    """One receipt still carrying unconsumed units."""
    seq: int
    qty: int
    unit_cost: float


class Valuation:
    """Inventory value under first-in, first-out cost basis."""

    def __init__(self) -> None:
        self.lots: Dict[str, List[Lot]] = {}
        self.last_seq: int = 0
        self.cost_of_goods_sold: float = 0.0

    def apply(self, event: Event) -> None:
        lots = self.lots.setdefault(event.sku, [])
        if isinstance(event, Received):
            lots.append(Lot(seq=event.seq, qty=event.qty,
                            unit_cost=event.unit_cost))
        elif isinstance(event, Shipped):
            self._consume(lots, event.qty)
        elif isinstance(event, Adjusted):
            if event.delta > 0:
                unit_cost = lots[-1].unit_cost if lots else 0.0
                lots.append(Lot(seq=event.seq, qty=event.delta,
                                unit_cost=unit_cost))
            else:
                self._consume(lots, -event.delta)
        self.last_seq = event.seq

    def _consume(self, lots: List[Lot], qty: int) -> None:
        """Draw `qty` units from the oldest lots first."""
        remaining = qty
        while remaining > 0 and lots:
            oldest = lots[0]
            take = min(oldest.qty, remaining)
            self.cost_of_goods_sold += take * oldest.unit_cost
            oldest.qty -= take
            remaining -= take
            if oldest.qty == 0:
                lots.pop(0)

    def catch_up(self, store: EventStore) -> int:
        pending = store.since(self.last_seq)
        for event in pending:
            self.apply(event)
        return len(pending)

    def value_of(self, sku: str) -> float:
        """Total carrying value of the units still on hand for one SKU."""
        if sku not in self.lots:
            raise UnknownSku(sku)
        return sum(lot.qty * lot.unit_cost for lot in self.lots[sku])

    def average_cost(self, sku: str) -> float:
        """Weighted average cost of the units still on hand.

        Zero when nothing is on hand, rather than a division error -- callers
        display this per SKU and a depleted line is normal.
        """
        if sku not in self.lots:
            raise UnknownSku(sku)
        units = sum(lot.qty for lot in self.lots[sku])
        if units == 0:
            return 0.0
        return self.value_of(sku) / units

    def total_value(self) -> float:
        return sum(lot.qty * lot.unit_cost
                   for lots in self.lots.values() for lot in lots)

ledger/store.py: """Append-only event store with snapshot support.

The store owns sequence numbering. Nothing else in the package is allowed to
assign a sequence number, because the ordering guarantee that projections rely
on is only as good as the single place that hands the numbers out.
"""
from typing import Dict, Iterable, Iterator, List, Optional

from .errors import SequenceError
from .events import Adjusted, Event, Received, Shipped


class EventStore:
    """Holds every event ever appended, in order."""

    def __init__(self) -> None:
        self._events: List[Event] = []
        self._next_seq: int = 1
        self._clock: float = 0.0

    # -- writing ---------------------------------------------------------

    def _tick(self) -> float:
        self._clock += 1.0
        return self._clock

    def _append(self, event: Event) -> Event:
        if event.seq != self._next_seq:
            raise SequenceError(
                f"expected seq {self._next_seq}, got {event.seq}")
        self._events.append(event)
        self._next_seq += 1
        return event

    def receive(self, sku: str, qty: int, unit_cost: float) -> Received:
        """Record stock arriving."""
        if qty <= 0:
            raise ValueError("received quantity must be positive")
        return self._append(
            Received(seq=self._next_seq, ts=self._tick(), sku=sku,
                     qty=qty, unit_cost=unit_cost))

    def ship(self, sku: str, qty: int) -> Shipped:
        """Record stock leaving. Does not itself check availability."""
        if qty <= 0:
            raise ValueError("shipped quantity must be positive")
        return self._append(
            Shipped(seq=self._next_seq, ts=self._tick(), sku=sku, qty=qty))

    def adjust(self, sku: str, delta: int, reason: str) -> Adjusted:
        """Record a stocktake correction."""
        if delta == 0:
            raise ValueError("adjustment delta must not be zero")
        return self._append(
            Adjusted(seq=self._next_seq, ts=self._tick(), sku=sku,
                     delta=delta, reason=reason))

    # -- reading ---------------------------------------------------------

    def __len__(self) -> int:
        return len(self._events)

    def __iter__(self) -> Iterator[Event]:
        return iter(self._events)

    @property
    def head(self) -> int:
        """Sequence number of the most recent event, or 0 when empty."""
        return self._events[-1].seq if self._events else 0

    def since(self, seq: int) -> List[Event]:
        """Every event appended strictly after `seq`.

        A projection resumes by passing the sequence number it last applied, so
        this must be exclusive at the lower bound. Making it inclusive would
        replay one already-applied event on every resume.
        """
        return [e for e in self._events if e.seq > seq]

    def between(self, low: int, high: int) -> List[Event]:
        """Events with low < seq <= high."""
        return [e for e in self._events if low < e.seq <= high]

    def for_sku(self, sku: str) -> List[Event]:
        return [e for e in self._events if e.sku == sku]

    def skus(self) -> List[str]:
        seen: Dict[str, None] = {}
        for event in self._events:
            seen.setdefault(event.sku, None)
        return list(seen)

    def replay(self, events: Optional[Iterable[Event]] = None) -> List[Event]:
        """Materialise an event iterable, defaulting to the whole log."""
        return list(self._events if events is None else events)


Hidden tests:
tests/test_reorder.py: import unittest

from ledger import EventStore, ReorderPolicy, StockProjection


def stock_at(levels):
    store = EventStore()
    for sku, qty in levels.items():
        if qty > 0:
            store.receive(sku, qty, 1.0)
    projection = StockProjection()
    projection.catch_up(store)
    for sku in levels:
        projection.levels.setdefault(sku, 0)
    return projection


class TestReorderBoundary(unittest.TestCase):
    def test_sku_exactly_at_threshold_is_suggested(self):
        policy = ReorderPolicy({"widget": 10}, {"widget": 40})
        out = policy.suggest(stock_at({"widget": 10}))
        self.assertEqual([s.sku for s in out], ["widget"])
        self.assertEqual(out[0].order_qty, 30)

    def test_sku_below_threshold_is_still_suggested(self):
        policy = ReorderPolicy({"widget": 10}, {"widget": 40})
        out = policy.suggest(stock_at({"widget": 3}))
        self.assertEqual([s.sku for s in out], ["widget"])
        self.assertEqual(out[0].order_qty, 37)

    def test_sku_above_threshold_is_not_suggested(self):
        policy = ReorderPolicy({"widget": 10}, {"widget": 40})
        self.assertEqual(policy.suggest(stock_at({"widget": 11})), [])

    def test_target_already_reached_yields_nothing(self):
        policy = ReorderPolicy({"widget": 40}, {"widget": 40})
        self.assertEqual(policy.suggest(stock_at({"widget": 40})), [])

    def test_multiple_skus_reported_in_sorted_order(self):
        policy = ReorderPolicy({"b": 5, "a": 5}, {"b": 10, "a": 10})
        out = policy.suggest(stock_at({"a": 5, "b": 5}))
        self.assertEqual([s.sku for s in out], ["a", "b"])


Reference fix:
README.md: # ledger

A small event-sourced inventory ledger.

Everything that happens to stock is recorded as an immutable event in the
`EventStore`. Current state is never stored directly; it is derived by folding
the event log through a *projection*.

    from ledger import EventStore, StockProjection

    store = EventStore()
    store.receive("widget", qty=100, unit_cost=2.50)
    store.ship("widget", qty=30)

    stock = StockProjection()
    stock.catch_up(store)
    stock.on_hand("widget")   # 70

## Layout

| module | role |
|---|---|
| `events.py` | immutable event types |
| `store.py` | append-only log, owns sequence numbering |
| `projections.py` | `StockProjection` (units) and `Valuation` (FIFO cost) |
| `policies.py` | reorder suggestions and multi-warehouse allocation |
| `errors.py` | exception hierarchy |

## Resuming a projection

Projections are incremental. Each remembers the last sequence number it
applied and asks the store only for events after it, so catching up a long
ledger does not mean replaying it from the beginning.

ledger/__init__.py: """A small event-sourced inventory ledger."""
from .errors import LedgerError, UnknownSku, InsufficientStock
from .events import Event, Received, Shipped, Adjusted
from .store import EventStore
from .projections import StockProjection, Valuation
from .policies import ReorderPolicy, allocate

__all__ = [
    "LedgerError", "UnknownSku", "InsufficientStock",
    "Event", "Received", "Shipped", "Adjusted",
    "EventStore", "StockProjection", "Valuation",
    "ReorderPolicy", "allocate",
]

ledger/errors.py: """Exception types raised across the ledger."""


class LedgerError(Exception):
    """Base class for every error this package raises."""


class UnknownSku(LedgerError):
    """A SKU was referenced that the ledger has never seen."""

    def __init__(self, sku: str):
        super().__init__(f"unknown sku: {sku}")
        self.sku = sku


class InsufficientStock(LedgerError):
    """A shipment was requested for more units than are on hand."""

    def __init__(self, sku: str, wanted: int, available: int):
        super().__init__(
            f"cannot ship {wanted} of {sku}: only {available} on hand")
        self.sku = sku
        self.wanted = wanted
        self.available = available


class SequenceError(LedgerError):
    """Events were presented to a projection out of order."""

ledger/events.py: """Event types.

Every event carries a monotonically increasing sequence number assigned by the
store, plus the SKU it concerns. Events are immutable once created: a ledger
that can be rewritten after the fact is not a ledger.
"""
from dataclasses import dataclass


@dataclass(frozen=True)
class Event:
    """Base event. `seq` is assigned by the store, never by the caller."""
    seq: int
    ts: float
    sku: str

    @property
    def kind(self) -> str:
        return type(self).__name__.lower()

    def describe(self) -> str:
        return f"[{self.seq}] {self.kind} {self.sku}"


@dataclass(frozen=True)
class Received(Event):
    """Stock arrived into the warehouse at a known unit cost."""
    qty: int
    unit_cost: float

    def describe(self) -> str:
        return f"{super().describe()} +{self.qty} @ {self.unit_cost:.2f}"


@dataclass(frozen=True)
class Shipped(Event):
    """Stock left the warehouse."""
    qty: int

    def describe(self) -> str:
        return f"{super().describe()} -{self.qty}"


@dataclass(frozen=True)
class Adjusted(Event):
    """A stocktake correction. `delta` may be negative."""
    delta: int
    reason: str

    def describe(self) -> str:
        return f"{super().describe()} {self.delta:+d} ({self.reason})"

ledger/policies.py: """Decisions taken on top of the read models.

Nothing here touches the event log directly. Policies read a projection and
return a plan; committing that plan is the caller's business.
"""
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple

from .errors import InsufficientStock, UnknownSku
from .projections import StockProjection


@dataclass(frozen=True)
class Warehouse:
    """A stocking location, with a priority used to break ties."""
    name: str
    priority: int
    on_hand: int


@dataclass(frozen=True)
class ReorderSuggestion:
    sku: str
    order_qty: int
    reason: str


class ReorderPolicy:
    """Suggests replenishment when a SKU falls to or below its threshold.

    The comparison is deliberately inclusive: a SKU sitting exactly on its
    reorder point has reached it, and waiting for it to drop further is how a
    warehouse runs out during the lead time.
    """

    def __init__(self, thresholds: Dict[str, int], target: Dict[str, int]):
        self.thresholds = dict(thresholds)
        self.target = dict(target)

    def threshold_for(self, sku: str) -> int:
        if sku not in self.thresholds:
            raise UnknownSku(sku)
        return self.thresholds[sku]

    def suggest(self, stock: StockProjection) -> List[ReorderSuggestion]:
        """One suggestion per SKU at or below its reorder point."""
        out: List[ReorderSuggestion] = []
        for sku in sorted(self.thresholds):
            level = stock.levels.get(sku, 0)
            threshold = self.thresholds[sku]
            if level <= threshold:
                target = self.target.get(sku, threshold * 2)
                qty = target - level
                if qty > 0:
                    out.append(ReorderSuggestion(
                        sku=sku, order_qty=qty,
                        reason=f"level {level} at or below threshold {threshold}"))
        return out


def allocate(demand: int, warehouses: Sequence[Warehouse]) -> List[Tuple[str, int]]:
    """Split `demand` across warehouses, highest priority first.

    Ties on priority are broken by name so the result is deterministic. The
    input sequence is never mutated -- callers reuse their warehouse list
    across several allocations and would otherwise see it quietly emptied.
    """
    if demand <= 0:
        raise ValueError("demand must be positive")

    ordered = sorted(warehouses, key=lambda w: (-w.priority, w.name))
    available = sum(w.on_hand for w in ordered)
    if available < demand:
        raise InsufficientStock("<multiple>", demand, available)

    plan: List[Tuple[str, int]] = []
    remaining = demand
    for warehouse in ordered:
        if remaining <= 0:
            break
        take = min(warehouse.on_hand, remaining)
        if take > 0:
            plan.append((warehouse.name, take))
            remaining -= take
    return plan

ledger/projections.py: """Read models built by folding the event log.

A projection is resumable: it remembers the last sequence number it applied
and asks the store only for what came after. That is what keeps a long ledger
cheap to query, and it is why the store's `since` boundary matters so much.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple

from .errors import SequenceError, UnknownSku
from .events import Adjusted, Event, Received, Shipped
from .store import EventStore


class StockProjection:
    """Units on hand per SKU."""

    def __init__(self) -> None:
        self.levels: Dict[str, int] = {}
        self.last_seq: int = 0

    def apply(self, event: Event) -> None:
        """Fold a single event into the running totals."""
        if event.seq <= self.last_seq:
            raise SequenceError(
                f"event {event.seq} already applied (at {self.last_seq})")
        current = self.levels.get(event.sku, 0)
        if isinstance(event, Received):
            current += event.qty
        elif isinstance(event, Shipped):
            current -= event.qty
        elif isinstance(event, Adjusted):
            current += event.delta
        self.levels[event.sku] = current
        self.last_seq = event.seq

    def catch_up(self, store: EventStore) -> int:
        """Apply everything appended since the last call. Returns the count."""
        pending = store.since(self.last_seq)
        for event in pending:
            self.apply(event)
        return len(pending)

    def on_hand(self, sku: str) -> int:
        if sku not in self.levels:
            raise UnknownSku(sku)
        return self.levels[sku]

    def negative_skus(self) -> List[str]:
        """SKUs the log says we hold a negative quantity of."""
        return sorted(s for s, n in self.levels.items() if n < 0)


@dataclass
class Lot:
    """One receipt still carrying unconsumed units."""
    seq: int
    qty: int
    unit_cost: float


class Valuation:
    """Inventory value under first-in, first-out cost basis."""

    def __init__(self) -> None:
        self.lots: Dict[str, List[Lot]] = {}
        self.last_seq: int = 0
        self.cost_of_goods_sold: float = 0.0

    def apply(self, event: Event) -> None:
        lots = self.lots.setdefault(event.sku, [])
        if isinstance(event, Received):
            lots.append(Lot(seq=event.seq, qty=event.qty,
                            unit_cost=event.unit_cost))
        elif isinstance(event, Shipped):
            self._consume(lots, event.qty)
        elif isinstance(event, Adjusted):
            if event.delta > 0:
                unit_cost = lots[-1].unit_cost if lots else 0.0
                lots.append(Lot(seq=event.seq, qty=event.delta,
                                unit_cost=unit_cost))
            else:
                self._consume(lots, -event.delta)
        self.last_seq = event.seq

    def _consume(self, lots: List[Lot], qty: int) -> None:
        """Draw `qty` units from the oldest lots first."""
        remaining = qty
        while remaining > 0 and lots:
            oldest = lots[0]
            take = min(oldest.qty, remaining)
            self.cost_of_goods_sold += take * oldest.unit_cost
            oldest.qty -= take
            remaining -= take
            if oldest.qty == 0:
                lots.pop(0)

    def catch_up(self, store: EventStore) -> int:
        pending = store.since(self.last_seq)
        for event in pending:
            self.apply(event)
        return len(pending)

    def value_of(self, sku: str) -> float:
        """Total carrying value of the units still on hand for one SKU."""
        if sku not in self.lots:
            raise UnknownSku(sku)
        return sum(lot.qty * lot.unit_cost for lot in self.lots[sku])

    def average_cost(self, sku: str) -> float:
        """Weighted average cost of the units still on hand.

        Zero when nothing is on hand, rather than a division error -- callers
        display this per SKU and a depleted line is normal.
        """
        if sku not in self.lots:
            raise UnknownSku(sku)
        units = sum(lot.qty for lot in self.lots[sku])
        if units == 0:
            return 0.0
        return self.value_of(sku) / units

    def total_value(self) -> float:
        return sum(lot.qty * lot.unit_cost
                   for lots in self.lots.values() for lot in lots)

ledger/store.py: """Append-only event store with snapshot support.

The store owns sequence numbering. Nothing else in the package is allowed to
assign a sequence number, because the ordering guarantee that projections rely
on is only as good as the single place that hands the numbers out.
"""
from typing import Dict, Iterable, Iterator, List, Optional

from .errors import SequenceError
from .events import Adjusted, Event, Received, Shipped


class EventStore:
    """Holds every event ever appended, in order."""

    def __init__(self) -> None:
        self._events: List[Event] = []
        self._next_seq: int = 1
        self._clock: float = 0.0

    # -- writing ---------------------------------------------------------

    def _tick(self) -> float:
        self._clock += 1.0
        return self._clock

    def _append(self, event: Event) -> Event:
        if event.seq != self._next_seq:
            raise SequenceError(
                f"expected seq {self._next_seq}, got {event.seq}")
        self._events.append(event)
        self._next_seq += 1
        return event

    def receive(self, sku: str, qty: int, unit_cost: float) -> Received:
        """Record stock arriving."""
        if qty <= 0:
            raise ValueError("received quantity must be positive")
        return self._append(
            Received(seq=self._next_seq, ts=self._tick(), sku=sku,
                     qty=qty, unit_cost=unit_cost))

    def ship(self, sku: str, qty: int) -> Shipped:
        """Record stock leaving. Does not itself check availability."""
        if qty <= 0:
            raise ValueError("shipped quantity must be positive")
        return self._append(
            Shipped(seq=self._next_seq, ts=self._tick(), sku=sku, qty=qty))

    def adjust(self, sku: str, delta: int, reason: str) -> Adjusted:
        """Record a stocktake correction."""
        if delta == 0:
            raise ValueError("adjustment delta must not be zero")
        return self._append(
            Adjusted(seq=self._next_seq, ts=self._tick(), sku=sku,
                     delta=delta, reason=reason))

    # -- reading ---------------------------------------------------------

    def __len__(self) -> int:
        return len(self._events)

    def __iter__(self) -> Iterator[Event]:
        return iter(self._events)

    @property
    def head(self) -> int:
        """Sequence number of the most recent event, or 0 when empty."""
        return self._events[-1].seq if self._events else 0

    def since(self, seq: int) -> List[Event]:
        """Every event appended strictly after `seq`.

        A projection resumes by passing the sequence number it last applied, so
        this must be exclusive at the lower bound. Making it inclusive would
        replay one already-applied event on every resume.
        """
        return [e for e in self._events if e.seq > seq]

    def between(self, low: int, high: int) -> List[Event]:
        """Events with low < seq <= high."""
        return [e for e in self._events if low < e.seq <= high]

    def for_sku(self, sku: str) -> List[Event]:
        return [e for e in self._events if e.sku == sku]

    def skus(self) -> List[str]:
        seen: Dict[str, None] = {}
        for event in self._events:
            seen.setdefault(event.sku, None)
        return list(seen)

    def replay(self, events: Optional[Iterable[Event]] = None) -> List[Event]:
        """Materialise an event iterable, defaulting to the whole log."""
        return list(self._events if events is None else events)