"""Rezeptzutaten gegen den Bestand prüfen: Kann ich das kochen? Bewusst **ohne HTTP** – die Zutaten kommen als Wörterbücher herein, so wie Mealie sie liefert. Dadurch lässt sich der ganze Abgleich mit handgeschriebenen Zutaten prüfen, ohne eine Mealie-Instanz zu brauchen; das Netz steckt allein in ``services/mealie.py``. Je Zutat beantwortet das hier drei Fragen nacheinander: 1. **Welcher Artikel ist gemeint?** Erst eine gemerkte Zuordnung, sonst unscharf über Namen – gegen Gruppen und Artikel, denn „Milch" ist markenübergreifend gemeint und das ist genau, wofür es Gruppen gibt. 2. **Wie viel ist das in Gramm/Milliliter/Stück?** Über die Einheiten und die Hausmaße der Kategorie („2 EL Mehl"). 3. **Reicht der Bestand?** """ from __future__ import annotations from dataclasses import dataclass, field from sqlalchemy.orm import Session from ..models import Group, Product from .conversion import ConversionError, article_unit, group_min_context, to_base from .matching import match_score, normalisieren from .stock import current_stock, summe_bestand_gewichtet # Ab hier gilt ein Name als Treffer. Bewusst strenger als beim Kassenzettel: # Dort sieht ein Mensch die Vorschläge durch, hier soll nicht stillschweigend # das Falsche als „vorhanden" gelten. SCHWELLE = 60 @dataclass class Kandidat: art: str # "product" | "group" id: int name: str score: int @dataclass class ZutatPruefung: """Eine Zutat des Rezepts, auf den Bestand bezogen.""" text: str # wie das Rezept es schreibt menge: float | None = None # skalierte Menge in der Rezept-Einheit einheit: str = "" # Zuordnung art: str | None = None # "product" | "group" ziel_id: int | None = None ziel_name: str = "" kandidaten: list[Kandidat] = field(default_factory=list) # Mengen in Basiseinheiten benoetigt_base: float | None = None bestand_base: float | None = None fehlt_base: float | None = None basis_einheit: str = "" # vorhanden | zu_wenig | fehlt | menge_unbekannt | kein_treffer status: str = "kein_treffer" hinweis: str = "" # Für „Maß jetzt anlegen": welches Maß fehlt und für welche Kategorie. fehlendes_mass: str = "" kategorie_id: int | None = None def _zutat_text(zutat: dict) -> str: """Wie das Rezept die Zutat schreibt – für die Anzeige.""" for schluessel in ("display", "originalText", "original_text", "note"): wert = zutat.get(schluessel) if wert: return str(wert) essen = zutat.get("food") or {} return str(essen.get("name") or "Zutat") def _lebensmittel(zutat: dict) -> str: essen = zutat.get("food") or {} name = essen.get("name") or "" if name: return str(name) # Ohne verknüpftes Lebensmittel bleibt nur der freie Text („2 Eier"). return str(zutat.get("note") or zutat.get("display") or "") def _einheit(zutat: dict) -> str: einheit = zutat.get("unit") or {} if not isinstance(einheit, dict): return "" return str(einheit.get("abbreviation") or einheit.get("name") or "") def faktor_fuer_portionen(rezept: dict, portionen: float | None) -> tuple[float, str]: """Skalierungsfaktor und, falls nötig, ein Hinweis warum nicht skaliert wird.""" if not portionen or portionen <= 0: return 1.0, "" basis = rezept.get("recipeServings") or rezept.get("recipe_servings") or 0 if not basis: basis = rezept.get("recipeYieldQuantity") or rezept.get("recipe_yield_quantity") or 0 try: basis = float(basis) except (TypeError, ValueError): basis = 0.0 if basis <= 0: return 1.0, ( "Das Rezept nennt keine Portionszahl – die Mengen gelten deshalb " "unverändert." ) return portionen / basis, "" def _kandidaten(db: Session, name: str) -> list[Kandidat]: """Artikel und Gruppen nach Namensähnlichkeit, bester zuerst.""" if not normalisieren(name): return [] treffer: list[Kandidat] = [] for gruppe in db.query(Group).all(): treffer.append(Kandidat("group", gruppe.id, gruppe.name, match_score(name, gruppe.name))) for produkt in db.query(Product).all(): voll = f"{produkt.name} {produkt.brand or ''}".strip() treffer.append(Kandidat("product", produkt.id, produkt.name, match_score(name, voll))) treffer.sort(key=lambda k: (-k.score, k.name)) return [k for k in treffer if k.score > 0][:5] def _bestand(db: Session, art: str, ziel_id: int) -> tuple[float, str]: """Bestand in Basiseinheiten plus die Bezeichnung der Basiseinheit.""" if art == "product": produkt = db.get(Product, ziel_id) if produkt is None: return 0.0, "" return current_stock(db, produkt.id), produkt.base_unit.value gruppe = db.get(Group, ziel_id) if gruppe is None: return 0.0, "" ctx = group_min_context(gruppe) menge = summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren) return menge, (ctx.base_unit.value if ctx.base_unit else "") def _referenzartikel(db: Session, art: str, ziel_id: int) -> Product | None: """Der Artikel, dessen Kategorie die Hausmaße liefert. Bei einer Gruppe der erste zugehörige Artikel – „1 Tasse Milch" hängt an der Kategorie der Milch, nicht an der Gruppe selbst. """ if art == "product": return db.get(Product, ziel_id) gruppe = db.get(Group, ziel_id) if gruppe is None: return None ctx = group_min_context(gruppe) return ctx.matching[0] if ctx.matching else None def pruefe_zutaten( db: Session, zutaten: list[dict], *, faktor: float = 1.0, zuordnung: dict[str, tuple[str, int]] | None = None, ) -> list[ZutatPruefung]: """Alle Zutaten eines Rezepts prüfen. ``zuordnung`` sind gemerkte Entscheidungen („Mealie-Lebensmittel X ist bei uns Gruppe Y"), Schlüssel ist der normalisierte Name. """ zuordnung = zuordnung or {} ergebnis: list[ZutatPruefung] = [] for zutat in zutaten: pruefung = ZutatPruefung(text=_zutat_text(zutat)) name = _lebensmittel(zutat) pruefung.einheit = _einheit(zutat) rohmenge = zutat.get("quantity") if zutat.get("disableAmount") or zutat.get("disable_amount") or not rohmenge: pruefung.menge = None else: try: pruefung.menge = float(rohmenge) * faktor except (TypeError, ValueError): pruefung.menge = None # 1. Zuordnen gemerkt = zuordnung.get(normalisieren(name)) if gemerkt is not None: pruefung.art, pruefung.ziel_id = gemerkt else: pruefung.kandidaten = _kandidaten(db, name) bester = pruefung.kandidaten[0] if pruefung.kandidaten else None if bester is not None and bester.score >= SCHWELLE: pruefung.art, pruefung.ziel_id = bester.art, bester.id if pruefung.ziel_id is None: pruefung.status = "kein_treffer" pruefung.hinweis = f"'{name}' ist hier keinem Artikel zugeordnet." ergebnis.append(pruefung) continue referenz = _referenzartikel(db, pruefung.art or "product", pruefung.ziel_id) pruefung.ziel_name = ( referenz.name if pruefung.art == "product" and referenz is not None else (db.get(Group, pruefung.ziel_id).name if pruefung.art == "group" else "") ) if referenz is not None: pruefung.kategorie_id = referenz.category_id bestand, basis = _bestand(db, pruefung.art or "product", pruefung.ziel_id) pruefung.bestand_base = bestand pruefung.basis_einheit = basis # 2. Menge umrechnen if pruefung.menge is None: pruefung.status = "menge_unbekannt" pruefung.hinweis = "Das Rezept nennt keine Menge." elif referenz is None: pruefung.status = "menge_unbekannt" pruefung.hinweis = "Zu dieser Gruppe gibt es noch keinen Artikel." else: try: pruefung.benoetigt_base = to_base( db, referenz, pruefung.menge, pruefung.einheit or referenz.base_unit.value, hausmasse=True, ) except ConversionError as fehler: pruefung.status = "menge_unbekannt" pruefung.hinweis = str(fehler) if "kein Maß" in str(fehler): pruefung.fehlendes_mass = pruefung.einheit ergebnis.append(pruefung) continue # 3. Vergleichen if pruefung.benoetigt_base is not None: fehlt = pruefung.benoetigt_base - (pruefung.bestand_base or 0.0) pruefung.fehlt_base = max(0.0, fehlt) if fehlt <= 1e-9: pruefung.status = "vorhanden" elif (pruefung.bestand_base or 0.0) > 1e-9: pruefung.status = "zu_wenig" else: pruefung.status = "fehlt" elif pruefung.status == "menge_unbekannt" and (pruefung.bestand_base or 0.0) <= 1e-9: # Ohne Menge lässt sich nur sagen, ob überhaupt etwas da ist. pruefung.status = "fehlt" pruefung.hinweis = (pruefung.hinweis + " Vorrätig ist davon nichts.").strip() ergebnis.append(pruefung) return ergebnis def kochbar(pruefungen: list[ZutatPruefung]) -> bool: """Reicht alles? Unklare Mengen zählen bewusst als „nicht sicher".""" return all(p.status == "vorhanden" for p in pruefungen) def artikeleinheiten(db: Session, pruefung: ZutatPruefung) -> tuple[float, str]: """Fehlmenge als zählbares Gebinde („2 Packungen") statt in Gramm.""" if pruefung.fehlt_base is None or pruefung.art != "product" or pruefung.ziel_id is None: return 0.0, "" produkt = db.get(Product, pruefung.ziel_id) if produkt is None: return 0.0, "" faktor, label = article_unit(produkt) return (pruefung.fehlt_base / (faktor or 1.0)), label