dodil/erp-platform

templates/brick-example/src/brick_example/domain/quote.py

"""The Quote aggregate — the rules, and nothing else.

No database, no HTTP, no identity: a rule here is true whoever calls it and however it is stored.
That is what lets these run in milliseconds, and what lets a person who is not a programmer read
the file and check it against how their business actually works.
"""
from dataclasses import dataclass, field
from decimal import Decimal

DRAFT, SENT, ACCEPTED = "draft", "sent", "accepted"


class RuleBroken(Exception):
    """A business rule said no. The message is written for the person who tried."""


@dataclass
class Line:
    line_no: int
    description: str
    quantity: int
    unit_price: Decimal

    @property
    def amount(self) -> Decimal:
        return self.unit_price * self.quantity


@dataclass
class Quote:
    quote_id: str
    customer: str
    owner: str
    currency: str = "EUR"
    status: str = DRAFT
    lines: list = field(default_factory=list)

    @property
    def total(self) -> Decimal:
        return sum((ln.amount for ln in self.lines), Decimal("0.00"))

    def add_line(self, description: str, quantity: int, unit_price: Decimal) -> Line:
        self._editable()
        if quantity <= 0:
            raise RuleBroken("a line needs a quantity of at least 1")
        if unit_price < 0:
            raise RuleBroken("a price cannot be negative")
        line = Line(max((ln.line_no for ln in self.lines), default=0) + 1,
                    description, quantity, Decimal(unit_price).quantize(Decimal("0.01")))
        self.lines.append(line)
        return line

    def remove_line(self, line_no: int) -> None:
        self._editable()
        if not any(ln.line_no == line_no for ln in self.lines):
            raise RuleBroken(f"there is no line {line_no}")
        self.lines = [ln for ln in self.lines if ln.line_no != line_no]

    def send(self) -> None:
        self._editable()
        if not self.lines:
            raise RuleBroken("an empty quote cannot be sent")
        self.status = SENT

    def accept(self) -> None:
        if self.status == ACCEPTED:
            return                                  # accepting twice changes nothing
        if self.status != SENT:
            raise RuleBroken("only a sent quote can be accepted")
        self.status = ACCEPTED

    def _editable(self) -> None:
        if self.status != DRAFT:
            raise RuleBroken(f"a {self.status} quote cannot be changed — copy it into a new one")