dodil/erp-brick-pos

DODIL ERP brick: point of sale

src/brick_pos/domain/recipe.py

"""Recipes (POS-15): what one product consumes when it is sold - the beans, the milk, a cup and a lid of a latte - instead of the
finished drink itself. A recipe is a DATED version: it changes only by a new version that starts after today, and the version a sale
used is frozen on the receipt line with the components it consumed, so a later change never changes a past sale's consumption.

A modifier (MOD-OAT) either ADDS a component or REPLACES one (oat milk for whole milk). The components are the recipe's quantities times
the quantity sold, each in the component's own issue unit; inventory converts exactly to the base unit or refuses (units_exact).
pos keeps its own recipes when manufacturing is absent and reads manufacturing's usage-sale bill of materials when it is installed and
the setting `recipe_source` says so (infrastructure/seams.py explode)."""
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Optional

from brick_pos.domain.errors import RuleBroken


@dataclass(frozen=True)
class RecipeLine:
    line_no: int
    component_id: str
    quantity: Decimal
    unit_code: str
    modifier_id: str = ""            # "" for the base recipe; else the modifier that adds or replaces
    replaces: str = ""               # a modifier line: the base component it takes the place of ("" adds)


@dataclass
class Recipe:
    product_id: str
    version: int
    valid_from: date
    lines: list = field(default_factory=list)
    created_by: str = ""

    def modifiers(self) -> list:
        return sorted({ln.modifier_id for ln in self.lines if ln.modifier_id})


def pick(versions: list, on: date) -> Optional[Recipe]:
    """The version in force on a business date: the latest one that started on or before it."""
    live = [v for v in versions if v.valid_from <= on]
    return max(live, key=lambda v: v.version) if live else None


def explode(recipe: Recipe, quantity: Decimal, modifiers: list) -> list:
    """[(component_id, quantity, unit_code)] for `quantity` of the product with the chosen modifiers. A modifier the recipe does not
    define is refused (never silently ignored: the stock would be wrong)."""
    unknown = sorted(set(modifiers) - set(recipe.modifiers()))
    if unknown:
        raise RuleBroken(f"recipe {recipe.product_id} v{recipe.version} has no modifier {', '.join(unknown)}", gate="modifier_unknown",
                         fix=f"use one of: {', '.join(recipe.modifiers()) or 'none'}")
    base = [ln for ln in recipe.lines if not ln.modifier_id]
    for m in sorted(set(modifiers)):
        mine = [ln for ln in recipe.lines if ln.modifier_id == m]
        gone = {ln.replaces for ln in mine if ln.replaces}
        base = [ln for ln in base if ln.component_id not in gone] + mine
    return [(ln.component_id, ln.quantity * quantity, ln.unit_code) for ln in base]


def same_content(a: Recipe, b: Recipe) -> bool:
    key = lambda r: sorted((ln.component_id, ln.quantity, ln.unit_code, ln.modifier_id, ln.replaces) for ln in r.lines)  # noqa: E731
    return key(a) == key(b)