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)