"""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 Category, CategoryTracking, 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 # Gesperrt wird über den Wortanker (siehe ``_hat_anker``), nicht über eine # Punktzahl. Eine Ähnlichkeitszahl ist als Sperre untauglich: „Vollmilch # Zimmertemperatur" gegen „H Vollmilch" landet nur bei etwa 40 Punkten, weil das # lange Beiwort den Durchschnitt zieht – obwohl es offensichtlich dasselbe ist. # Umgekehrt kam „Mehl" auf über 60 gegen „Mühlen Geschnetzeltes". Die Punkte # entscheiden deshalb nur noch die Reihenfolge unter den Kandidaten, die den # Anker schon bestanden haben. @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, "" # Umlaut-Reste zusammenziehen, damit „Apfel" auch „Äpfel" (= „aepfel") findet. # Nur fuer den Wortvergleich, nie fuer die Anzeige. _UMLAUT_REST = str.maketrans({"a": "a"}) def _stamm(text: str) -> str: roh = normalisieren(text) return roh.replace("ae", "a").replace("oe", "o").replace("ue", "u") def _woerter(text: str) -> list[str]: """Bedeutungstragende Wörter einer Zutat – sehr kurze fallen weg. „1 TL" und „oder" sollen keine Treffer begründen; „Mehl", „Apfel", „Vollmilch" sehr wohl. """ return [w for w in _stamm(text).split() if len(w) >= 4] def _hat_anker(zutat_woerter: list[str], kandidat: str) -> bool: """Teilen Zutat und Kandidat ein echtes Wort? Das ist die eigentliche Sperre. Reine Ähnlichkeitswerte halten „Eier" und „Reis" oder „Mehl" und „Mühlen Geschnetzeltes" für verwandt, weil sich Buchstaben überlappen – für einen Rezeptabgleich ist das unbrauchbar. Verlangt wird deshalb, dass ein ganzes Wort im anderen Namen steckt: „Mehl" in „Weissmehl Typ 550" ja, „Mehl" in „Mühlen Geschnetzeltes" nein. """ ziel = _stamm(kandidat) if not ziel: return False ziel_woerter = [w for w in ziel.split() if len(w) >= 4] for wort in zutat_woerter: if wort in ziel: return True # Umgekehrt auch: Artikel „Milch" passt zur Zutat „Buttermilch". for zw in ziel_woerter: if zw in wort: return True return False def _kategorien_zur_zutat(db: Session, zutat_woerter: list[str]) -> set[int]: """Kategorien, deren Name ein Wort mit der Zutat teilt – samt Unterkategorien. Damit wird zuerst dort gesucht, wo es hingehört: „Mehl Typ 405" in „Mehl & Zucker", nicht im ganzen Katalog. Erst wenn dort nichts passt, zählt der Rest. """ from ..routers.categories import descendant_ids passende: set[int] = set() for kategorie in db.query(Category).all(): if _hat_anker(zutat_woerter, kategorie.name): passende |= descendant_ids(db, kategorie.id) return passende def _kandidaten(db: Session, name: str) -> list[Kandidat]: """Artikel und Gruppen, die zur Zutat passen können – bester zuerst. Drei Stufen, in dieser Reihenfolge: 1. **Nur Lebensmittel.** Gegenstände bleiben außen vor, wie beim Kassenzettel-Abgleich. Ein Joystick ist kein Zucker, und eine Waffelplatte kein Apfel. 2. **Wortanker.** Ohne ein geteiltes ganzes Wort gibt es keinen Treffer, egal wie hoch die Buchstabenähnlichkeit wäre. 3. **Kategorie zuerst.** Passt der Zutatenname zu einer Kategorie, gilt nur was darin liegt – sonst der ganze (gefilterte) Katalog. """ woerter = _woerter(name) if not woerter: return [] # 1. Nur Lebensmittel, und keine Einzelstücke. from ..crud import product_tracking lebensmittel = [ p for p in db.query(Product).all() if not p.individual and product_tracking(db, p) != CategoryTracking.object.value ] # 3. Kategorie zuerst – aber nur, wenn darin auch etwas mit Anker liegt. kategorien = _kategorien_zur_zutat(db, woerter) if kategorien: in_kategorie = [ p for p in lebensmittel if p.category_id in kategorien and _hat_anker(woerter, f"{p.name} {p.brand or ''}") ] if in_kategorie: lebensmittel = in_kategorie treffer: list[Kandidat] = [] for gruppe in db.query(Group).all(): if _hat_anker(woerter, gruppe.name): treffer.append( Kandidat("group", gruppe.id, gruppe.name, match_score(name, gruppe.name)) ) for produkt in lebensmittel: voll = f"{produkt.name} {produkt.brand or ''}".strip() if _hat_anker(woerter, voll): treffer.append(Kandidat("product", produkt.id, produkt.name, match_score(name, voll))) # Gruppen bei Gleichstand vor Artikeln: „Milch" ist markenuebergreifend # gemeint, und genau das ist eine Gruppe. treffer.sort(key=lambda k: (-k.score, 0 if k.art == "group" else 1, k.name)) return treffer[: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: 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