diff --git a/backend/app/main.py b/backend/app/main.py index c54a7de..8fe75f0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,6 +24,7 @@ from .routers import ( oidc, package_types, products, + recipes, settings as settings_router, shops, stock, @@ -327,6 +328,7 @@ app.include_router(categories.router) app.include_router(maintenance.router) app.include_router(measures.router) app.include_router(mealie.router) +app.include_router(recipes.router) app.include_router(dashboard.router) app.include_router(shops.router) app.include_router(field_definitions.router) diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 41896ef..d7f7b7c 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -55,45 +55,17 @@ from .settings import get_receipt_match_threshold from ..services.conversion import ConversionError, resolve_product_unit from ..services.fields import FieldError, apply_field_values from ..services.group_codes import detach as detach_group_code, sync as sync_group_code +from ..services.matching import match_score, normalisieren from ..services.min_stock import schreibe_ueberall from ..services.stock import removal_stats router = APIRouter(prefix="/products", tags=["products"]) -# --------------------------------------------------------------------------- -# Kassenzettel-Abgleich: eine OCR-Zeile den ähnlichsten Lebensmitteln zuordnen. -# --------------------------------------------------------------------------- -_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"}) - - -def _normalisieren(text: str) -> str: - """Klein, Umlaute aufgelöst, nur Buchstaben/Ziffern – Preise/Sonderzeichen weg.""" - text = (text or "").lower().translate(_UMLAUTE) - return re.sub(r"[^a-z0-9]+", " ", text).strip() - - -def _match_score(zeile: str, name: str) -> int: - """Ähnlichkeit 0–100 zwischen Kassenzeile und Produktname. - - Kombiniert die Gesamt-Ähnlichkeit mit einem Token-/Präfix-Abgleich, damit - abgekürzte Kassennamen ("MÜHLEN SCHNITZ") auf den vollen Namen passen. - """ - a, b = _normalisieren(zeile), _normalisieren(name) - if not a or not b: - return 0 - gesamt = difflib.SequenceMatcher(None, a, b).ratio() - a_tok, b_tok = a.split(), b.split() - bester = [] - for t in a_tok: - m = 0.0 - for u in b_tok: - if u.startswith(t) or t.startswith(u): - m = max(m, min(len(t), len(u)) / max(len(t), len(u))) - else: - m = max(m, difflib.SequenceMatcher(None, t, u).ratio()) - bester.append(m) - token = sum(bester) / len(bester) if bester else 0.0 - return round(100 * max(gesamt, token)) +# Kassenzettel-Abgleich: eine OCR-Zeile den aehnlichsten Lebensmitteln zuordnen. +# Die Heuristik liegt in services/matching.py, weil der Rezeptabgleich sie +# genauso braucht. +_normalisieren = normalisieren +_match_score = match_score @router.post("/match", response_model=list[MatchLine]) diff --git a/backend/app/routers/recipes.py b/backend/app/routers/recipes.py new file mode 100644 index 0000000..5ed026a --- /dev/null +++ b/backend/app/routers/recipes.py @@ -0,0 +1,129 @@ +"""Rezepte aus Mealie, gegen den eigenen Bestand geprüft. + +Die Seite beantwortet eine Frage: Kann ich das heute kochen, oder muss ich +vorher einkaufen? Das Rechnen steckt in ``services/rezepte.py`` (ohne Netz), +das Holen in ``services/mealie.py`` (ohne Rechnen). +""" + +# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py. + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user +from ..models import User +from ..schemas import ( + RecipeDetail, + RecipePage, + RecipeSummary, + ZutatKandidat, + ZutatOut, +) +from ..services import mealie, rezepte +from ..services.einkaufsliste_text import fehlmengen_text + +router = APIRouter(prefix="/recipes", tags=["recipes"]) + + +def _fehler(fehler: mealie.MealieFehler) -> HTTPException: + """Mealie-Kennung auf einen Statuscode abbilden. + + 503 heisst „hier ist nichts eingerichtet", 502 „Mealie antwortet nicht" – + die Oberfläche soll beides verschieden erklären können. + """ + code = ( + status.HTTP_503_SERVICE_UNAVAILABLE + if fehler.kennung == "nicht_eingerichtet" + else status.HTTP_502_BAD_GATEWAY + ) + return HTTPException(code, str(fehler)) + + +def _bild(eintrag: dict, basis: str) -> str | None: + """Mealie liefert Bilder unter /api/media/recipes//images/min-original.webp.""" + kennung = eintrag.get("id") + if not kennung or not basis: + return None + return f"{basis}/api/media/recipes/{kennung}/images/min-original.webp" + + +@router.get("", response_model=RecipePage) +def list_recipes( + page: int = 1, + per_page: int = 50, + q: str | None = None, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> RecipePage: + """Rezeptliste aus Mealie, blätterbar und durchsuchbar.""" + try: + daten = mealie.rezepte(db, seite=page, pro_seite=per_page, suche=q) + except mealie.MealieFehler as fehler: + raise _fehler(fehler) from fehler + basis = mealie.basis_adresse(db) + eintraege = [ + RecipeSummary( + slug=str(e.get("slug") or ""), + name=str(e.get("name") or e.get("slug") or "Ohne Titel"), + description=str(e.get("description") or "")[:300], + image_url=_bild(e, basis), + servings=e.get("recipeServings") or e.get("recipe_servings") or None, + ) + for e in (daten.get("items") or []) + if e.get("slug") + ] + return RecipePage( + items=eintraege, page=page, per_page=per_page, + total=int(daten.get("total") or len(eintraege)), + ) + + +@router.get("/{slug}", response_model=RecipeDetail) +def get_recipe( + slug: str, + servings: float | None = None, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> RecipeDetail: + """Ein Rezept mit allen Zutaten, auf ``servings`` Portionen hochgerechnet.""" + try: + daten = mealie.rezept(db, slug) + except mealie.MealieFehler as fehler: + raise _fehler(fehler) from fehler + + faktor, hinweis = rezepte.faktor_fuer_portionen(daten, servings) + zutaten = daten.get("recipeIngredient") or daten.get("recipe_ingredient") or [] + pruefungen = rezepte.pruefe_zutaten(db, zutaten, faktor=faktor) + + basis_portionen = ( + daten.get("recipeServings") or daten.get("recipe_servings") + or daten.get("recipeYieldQuantity") or daten.get("recipe_yield_quantity") or None + ) + return RecipeDetail( + slug=slug, + name=str(daten.get("name") or slug), + description=str(daten.get("description") or ""), + image_url=_bild(daten, mealie.basis_adresse(db)), + base_servings=basis_portionen, + servings=servings or basis_portionen, + skalierung_hinweis=hinweis, + kochbar=rezepte.kochbar(pruefungen), + ingredients=[_zutat_out(db, p) for p in pruefungen], + ) + + +def _zutat_out(db: Session, p: rezepte.ZutatPruefung) -> ZutatOut: + return ZutatOut( + text=p.text, menge=p.menge, einheit=p.einheit, + art=p.art, ziel_id=p.ziel_id, ziel_name=p.ziel_name, + kandidaten=[ + ZutatKandidat(art=k.art, id=k.id, name=k.name, score=k.score) + for k in p.kandidaten + ], + benoetigt_base=p.benoetigt_base, bestand_base=p.bestand_base, + fehlt_base=p.fehlt_base, basis_einheit=p.basis_einheit, + status=p.status, hinweis=p.hinweis, + fehlendes_mass=p.fehlendes_mass, kategorie_id=p.kategorie_id, + fehlt_text=fehlmengen_text(db, p), + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index eb622d0..fb62436 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -728,6 +728,64 @@ class MealieAvailable(BaseModel): available: bool = False +class RecipeSummary(BaseModel): + """Ein Rezept in der Liste.""" + slug: str + name: str + description: str = "" + image_url: str | None = None + servings: float | None = None + + +class RecipePage(BaseModel): + items: list[RecipeSummary] = [] + page: int = 1 + per_page: int = 50 + total: int = 0 + + +class ZutatKandidat(BaseModel): + art: str # product | group + id: int + name: str + score: int + + +class ZutatOut(BaseModel): + """Eine Zutat, auf den Bestand bezogen.""" + text: str + menge: float | None = None + einheit: str = "" + art: str | None = None + ziel_id: int | None = None + ziel_name: str = "" + kandidaten: list[ZutatKandidat] = [] + 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 = "" + fehlendes_mass: str = "" + kategorie_id: int | None = None + # Fehlmenge als zählbares Gebinde („2 Packungen") – wie in der Einkaufsliste. + fehlt_text: str = "" + + +class RecipeDetail(BaseModel): + slug: str + name: str + description: str = "" + image_url: str | None = None + # Portionen laut Rezept und die, für die gerechnet wurde. + base_servings: float | None = None + servings: float | None = None + skalierung_hinweis: str = "" + kochbar: bool = False + ingredients: list[ZutatOut] = [] + + # ---- Hausmaße ---- class MeasureIn(BaseModel): """Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel.""" diff --git a/backend/app/services/einkaufsliste_text.py b/backend/app/services/einkaufsliste_text.py new file mode 100644 index 0000000..fdf1aa1 --- /dev/null +++ b/backend/app/services/einkaufsliste_text.py @@ -0,0 +1,37 @@ +"""Fehlmenge als lesbare Angabe – „2 Packungen (1 l)" statt „1000 ml". + +Eigene kleine Datei, damit der Rezeptabgleich dieselbe Sprache spricht wie die +Einkaufsliste: Dort wird eine Fehlmenge ebenfalls als zählbares Gebinde +ausgegeben und aufgerundet, weil man nur ganze Packungen kauft. +""" + +from __future__ import annotations + +import math + +from sqlalchemy.orm import Session + +from ..models import Product +from .conversion import BASE_LABEL, BaseUnit, article_unit + + +def fehlmengen_text(db: Session, pruefung) -> str: + """„2 Packungen" bzw. „300 g" – leer, wenn nichts fehlt.""" + fehlt = getattr(pruefung, "fehlt_base", None) + if not fehlt or fehlt <= 1e-9: + return "" + if pruefung.art == "product" and pruefung.ziel_id is not None: + produkt = db.get(Product, pruefung.ziel_id) + if produkt is not None: + faktor, label = article_unit(produkt) + if produkt.package_size and produkt.package_size > 0: + # Ganze Gebinde: man kauft keine halbe Packung. + anzahl = math.ceil(fehlt / (faktor or 1.0) - 1e-9) + return f"{anzahl:g} {label}" + ("" if anzahl == 1 else "en" if label.endswith("ung") else "") + return f"{fehlt:g} {label}" + basis = pruefung.basis_einheit + try: + name = BASE_LABEL[BaseUnit(basis)] + except (ValueError, KeyError): + name = basis + return f"{fehlt:g} {name}" diff --git a/backend/app/services/matching.py b/backend/app/services/matching.py new file mode 100644 index 0000000..b71caa2 --- /dev/null +++ b/backend/app/services/matching.py @@ -0,0 +1,45 @@ +"""Unscharfer Namensabgleich – aus dem Kassenzettel-Scan, jetzt auch für Rezepte. + +Lag bis dahin in ``routers/products.py``. Hierher gezogen, weil der +Rezeptabgleich dieselbe Frage stellt: Welcher Artikel ist mit „Mehl" oder +„MÜHLEN SCHNITZ" gemeint? Zwei Kopien derselben Heuristik würden mit der Zeit +auseinanderlaufen. +""" + +from __future__ import annotations + +import difflib +import re + +_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"}) + + +def normalisieren(text: str) -> str: + """Klein, Umlaute aufgelöst, nur Buchstaben/Ziffern – Preise/Sonderzeichen weg.""" + text = (text or "").lower().translate(_UMLAUTE) + return re.sub(r"[^a-z0-9]+", " ", text).strip() + + +def match_score(zeile: str, name: str) -> int: + """Ähnlichkeit 0–100 zwischen gesuchtem Text und einem Namen. + + Kombiniert die Gesamt-Ähnlichkeit mit einem Token-/Präfix-Abgleich, damit + abgekürzte Kassennamen ("MÜHLEN SCHNITZ") auf den vollen Namen passen – + und damit eine Rezeptzutat „Mehl" den „Weissmehl Typ 550" findet. + """ + a, b = normalisieren(zeile), normalisieren(name) + if not a or not b: + return 0 + gesamt = difflib.SequenceMatcher(None, a, b).ratio() + a_tok, b_tok = a.split(), b.split() + bester = [] + for t in a_tok: + m = 0.0 + for u in b_tok: + if u.startswith(t) or t.startswith(u): + m = max(m, min(len(t), len(u)) / max(len(t), len(u))) + else: + m = max(m, difflib.SequenceMatcher(None, t, u).ratio()) + bester.append(m) + token = sum(bester) / len(bester) if bester else 0.0 + return round(100 * max(gesamt, token)) diff --git a/backend/app/services/rezepte.py b/backend/app/services/rezepte.py new file mode 100644 index 0000000..8a46fc6 --- /dev/null +++ b/backend/app/services/rezepte.py @@ -0,0 +1,268 @@ +"""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 diff --git a/backend/tests/test_rezept_abgleich.py b/backend/tests/test_rezept_abgleich.py new file mode 100644 index 0000000..626e70c --- /dev/null +++ b/backend/tests/test_rezept_abgleich.py @@ -0,0 +1,255 @@ +"""Rezeptzutaten gegen den Bestand: Kann ich das kochen? + +Ohne Mealie – die Zutaten stehen hier so im Test, wie Mealie sie liefern würde. +""" + +import pytest + +from app.models import ( + BaseUnit, + Category, + CategoryMeasure, + CategoryTracking, + Group, + Measure, + Product, +) +from app.services.rezepte import faktor_fuer_portionen, kochbar, pruefe_zutaten +from app.services.stock import check_in + + +def _zutat(name, menge=None, einheit=None, text=None): + """Eine Zutat im Format von Mealie.""" + return { + "quantity": menge, + "unit": {"name": einheit, "abbreviation": einheit} if einheit else None, + "food": {"name": name}, + "display": text or f"{menge or ''} {einheit or ''} {name}".strip(), + } + + +def _kategorie(db, name): + cat = Category(name=name, tracking=CategoryTracking.food.value) + db.add(cat) + db.commit() + db.refresh(cat) + return cat + + +def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000, bestand=0.0): + product = Product(name=name, base_unit=base, package_size=package_size, + category_id=cat.id, source="manual") + db.add(product) + db.commit() + db.refresh(product) + if bestand: + check_in(db, product, bestand, base.value, None, None, None) + db.commit() + return product + + +# -------------------------------------------------------------------------- +# Portionen +# -------------------------------------------------------------------------- +def test_portionen_skalieren_die_mengen(db): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl", cat, bestand=5000) + faktor, hinweis = faktor_fuer_portionen({"recipeServings": 4}, 6) + + assert faktor == 1.5 + assert hinweis == "" + + [zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")], faktor=faktor) + assert zutat.menge == 300 + assert zutat.benoetigt_base == 300 + + +def test_ohne_portionsangabe_wird_nicht_skaliert(db): + faktor, hinweis = faktor_fuer_portionen({"recipeServings": 0}, 6) + + assert faktor == 1.0 + assert "keine Portionszahl" in hinweis + + +def test_ohne_wunsch_bleibt_es_wie_im_rezept(db): + assert faktor_fuer_portionen({"recipeServings": 4}, None) == (1.0, "") + + +# -------------------------------------------------------------------------- +# Reicht der Bestand? +# -------------------------------------------------------------------------- +def test_genug_vorhanden(db): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl", cat, bestand=1000) + + [zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")]) + + assert zutat.status == "vorhanden" + assert zutat.fehlt_base == 0 + assert kochbar([zutat]) is True + + +def test_zu_wenig_meldet_die_fehlmenge(db): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl", cat, bestand=150) + + [zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 500, "g")]) + + assert zutat.status == "zu_wenig" + assert zutat.fehlt_base == 350 + assert kochbar([zutat]) is False + + +def test_gar_nichts_da(db): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl", cat, bestand=0) + + [zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 500, "g")]) + + assert zutat.status == "fehlt" + assert zutat.fehlt_base == 500 + + +def test_unbekannte_zutat_wird_nicht_still_als_vorhanden_gewertet(db): + _kategorie(db, "Trockenwaren") + + [zutat] = pruefe_zutaten(db, [_zutat("Safranfäden", 1, "g")]) + + assert zutat.status == "kein_treffer" + assert kochbar([zutat]) is False + + +def test_zutat_ohne_menge_ist_unklar(db): + cat = _kategorie(db, "Gewürze") + _artikel(db, "Salz", cat, bestand=500) + + [zutat] = pruefe_zutaten(db, [_zutat("Salz", None, None, text="Salz nach Geschmack")]) + + assert zutat.status == "menge_unbekannt" + assert kochbar([zutat]) is False + + +def test_ohne_menge_und_ohne_bestand_fehlt_es(db): + cat = _kategorie(db, "Gewürze") + _artikel(db, "Salz", cat, bestand=0) + + [zutat] = pruefe_zutaten(db, [_zutat("Salz", None, None)]) + + assert zutat.status == "fehlt" + + +# -------------------------------------------------------------------------- +# Zuordnung +# -------------------------------------------------------------------------- +def test_aehnlicher_name_wird_gefunden(db): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl Typ 550", cat, bestand=2000) + + [zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")]) + + assert zutat.art == "product" + assert zutat.ziel_name == "Weissmehl Typ 550" + + +def test_gruppe_zaehlt_marken_zusammen(db): + """„Milch" ist markenuebergreifend gemeint – dafuer gibt es Gruppen.""" + gruppe = Group(name="Milch") + db.add(gruppe) + db.commit() + cat = _kategorie(db, "Molkerei") + a = _artikel(db, "Valflora Vollmilch", cat, base=BaseUnit.milliliter, bestand=1000) + b = _artikel(db, "Emmi Vollmilch", cat, base=BaseUnit.milliliter, bestand=500) + a.group_id = gruppe.id + b.group_id = gruppe.id + db.commit() + + [zutat] = pruefe_zutaten(db, [_zutat("Milch", 1200, "ml")]) + + assert zutat.art == "group" + assert zutat.bestand_base == 1500 # beide Marken zusammen + assert zutat.status == "vorhanden" + + +def test_gemerkte_zuordnung_schlaegt_die_suche(db): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl", cat, bestand=1000) + richtig = _artikel(db, "Dinkelmehl", cat, bestand=2000) + + [zutat] = pruefe_zutaten( + db, [_zutat("Mehl", 100, "g")], + zuordnung={"mehl": ("product", richtig.id)}, + ) + + assert zutat.ziel_id == richtig.id + assert zutat.ziel_name == "Dinkelmehl" + + +def test_schwacher_treffer_liefert_kandidaten_statt_einer_annahme(db): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl", cat, bestand=1000) + + [zutat] = pruefe_zutaten(db, [_zutat("Kurkuma", 1, "g")]) + + assert zutat.status == "kein_treffer" + assert zutat.ziel_id is None + # Vorschlaege gibt es trotzdem – daraus baut die Oberflaeche die Korrektur. + assert zutat.kandidaten + + +# -------------------------------------------------------------------------- +# Hausmaße +# -------------------------------------------------------------------------- +def test_essloeffel_wird_umgerechnet(db): + cat = _kategorie(db, "Mehl") + mehl = _artikel(db, "Weissmehl", cat, bestand=1000) + el = db.query(Measure).filter(Measure.name == "Esslöffel").one() + db.add(CategoryMeasure(measure_id=el.id, category_id=cat.id, amount=10, + base=BaseUnit.gram.value)) + db.commit() + + [zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 2, "EL")]) + + assert zutat.benoetigt_base == 20 + assert zutat.status == "vorhanden" + + +def test_fehlendes_mass_wird_zum_anlegen_gemeldet(db): + """Daraus baut die Oberflaeche „Maß jetzt anlegen".""" + cat = _kategorie(db, "Hülsenfrüchte") + _artikel(db, "Berglinsen", cat, bestand=1000) + + [zutat] = pruefe_zutaten(db, [_zutat("Berglinsen", 1, "Tasse")]) + + assert zutat.status == "menge_unbekannt" + assert zutat.fehlendes_mass == "Tasse" + assert zutat.kategorie_id == cat.id + assert "kein Maß" in zutat.hinweis + + +def test_unbekannte_einheit_wird_benannt(db): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl", cat, bestand=1000) + + [zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 1, "furlong")]) + + assert zutat.status == "menge_unbekannt" + assert "furlong" in zutat.hinweis + + +# -------------------------------------------------------------------------- +# Ganzes Rezept +# -------------------------------------------------------------------------- +def test_ein_rezept_mit_allem(db): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl", cat, bestand=1000) + _artikel(db, "Zucker", cat, bestand=50) + + zutaten = [ + _zutat("Weissmehl", 200, "g"), # reicht + _zutat("Zucker", 100, "g"), # zu wenig + _zutat("Safran", 1, "g"), # kennt niemand + ] + ergebnis = pruefe_zutaten(db, zutaten) + + assert [z.status for z in ergebnis] == ["vorhanden", "zu_wenig", "kein_treffer"] + assert kochbar(ergebnis) is False diff --git a/web/src/App.jsx b/web/src/App.jsx index 66e512e..83fce71 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -26,6 +26,7 @@ import PackageTypes from "./pages/PackageTypes"; import Units from "./pages/Units"; import Users from "./pages/Users"; import ShoppingList from "./pages/ShoppingList"; +import Rezepte from "./pages/Rezepte"; import History from "./pages/History"; import Verliehen from "./pages/Verliehen"; import Transfer from "./pages/Transfer"; @@ -59,6 +60,9 @@ function Sidebar() { const navigate = useNavigate(); const { pathname } = useLocation(); const [dashboards, setDashboards] = useState([]); + // Der Rezept-Eintrag erscheint nur, wenn Mealie eingerichtet ist. Ein Fehler + // hier darf die Leiste nicht stoeren, deshalb still abfangen. + const [mealie, setMealie] = useState(false); // Zugeklappt als Vorgabe; der Zustand bleibt im Browser, wie die // Spalteneinstellungen der Tabellen. const [listenOffen, setListenOffen] = useState( @@ -88,6 +92,10 @@ function Sidebar() { return () => window.removeEventListener("dashboards-geaendert", laden); }, []); + useEffect(() => { + api.mealieVerfuegbar().then((a) => setMealie(!!a.available)).catch(() => {}); + }, []); + function handleLogout() { logout(); navigate("/login"); @@ -132,6 +140,7 @@ function Sidebar() { {e.label} ))} + {mealie && } @@ -237,6 +246,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein- und Auslagern, keine Stammdatenänderung. */} diff --git a/web/src/api.js b/web/src/api.js index ace51a5..ba06022 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -451,6 +451,14 @@ export const api = { mealieKonfigurieren: (body) => request("/mealie/config", { method: "PUT", body }), mealieToken: (body) => request("/mealie/token", { method: "PUT", body }), mealieTrennen: () => request("/mealie/config", { method: "DELETE" }), + // Rezepte aus Mealie, gegen den Bestand geprueft. + recipes: ({ q, page = 1, perPage = 50 } = {}) => { + const s = new URLSearchParams({ page: String(page), per_page: String(perPage) }); + if (q) s.set("q", q); + return request(`/recipes?${s.toString()}`); + }, + recipe: (slug, servings) => + request(`/recipes/${encodeURIComponent(slug)}${servings ? `?servings=${servings}` : ""}`), listSettings: () => request("/settings"), setSetting: (key, value) => request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }), diff --git a/web/src/pages/Rezepte.jsx b/web/src/pages/Rezepte.jsx new file mode 100644 index 0000000..f244a5c --- /dev/null +++ b/web/src/pages/Rezepte.jsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import Icon from "../components/Icon"; +import { fmt } from "../units"; + +/** + * Rezepte aus Mealie – mit der einen Frage, die zählt: Kann ich das heute + * kochen, oder muss ich vorher einkaufen? + * + * Links die Liste, rechts das gewählte Rezept mit allen Zutaten und ihrem + * Zustand. Die Portionenzahl lässt sich hochdrehen; der Server rechnet die + * Mengen dann entsprechend hoch, bevor er sie mit dem Bestand vergleicht. + */ + +// Die Basiseinheit kommt als technischer Wert („gram"); in der Tabelle soll +// stehen, was auf der Packung steht. +const BASIS_KURZ = { gram: "g", milliliter: "ml", piece: "Stk" }; +const kurz = (basis) => BASIS_KURZ[basis] || basis || ""; + +const ZUSTAND = { + vorhanden: { text: "vorhanden", klasse: "ok", icon: "check" }, + zu_wenig: { text: "zu wenig", klasse: "warn", icon: "alert" }, + fehlt: { text: "fehlt", klasse: "danger", icon: "alert" }, + menge_unbekannt: { text: "Menge unklar", klasse: "muted", icon: "alert" }, + kein_treffer: { text: "nicht zugeordnet", klasse: "muted", icon: "search" }, +}; + +function Zustand({ status }) { + const z = ZUSTAND[status] || ZUSTAND.kein_treffer; + return {z.text}; +} + +export default function Rezepte() { + const [liste, setListe] = useState([]); + const [suche, setSuche] = useState(""); + const [slug, setSlug] = useState(null); + const [rezept, setRezept] = useState(null); + const [portionen, setPortionen] = useState(""); + const [fehler, setFehler] = useState(null); + const [laedt, setLaedt] = useState(true); + + async function ladeListe(q = "") { + setLaedt(true); + try { + const daten = await api.recipes({ q: q || undefined }); + setListe(daten.items || []); + setFehler(null); + } catch (err) { setFehler(err.message); } finally { setLaedt(false); } + } + useEffect(() => { ladeListe(); }, []); + + async function oeffne(s, wunschPortionen) { + setSlug(s); + setRezept(null); + try { + const daten = await api.recipe(s, wunschPortionen); + setRezept(daten); + // Beim ersten Öffnen das Feld mit der Rezept-Portionszahl vorbelegen. + if (wunschPortionen == null) setPortionen(daten.servings ? String(daten.servings) : ""); + setFehler(null); + } catch (err) { setFehler(err.message); } + } + + function setzePortionen(wert) { + setPortionen(wert); + const zahl = Number(String(wert).replace(",", ".")); + if (slug && Number.isFinite(zahl) && zahl > 0) oeffne(slug, zahl); + } + + const fehlende = (rezept?.ingredients || []).filter( + (z) => z.status === "fehlt" || z.status === "zu_wenig", + ); + + return ( +
+
+
+

Rezepte

+
Aus Mealie – geprüft gegen deinen Bestand
+
+
+ + {fehler &&
{fehler}
} + +
+
{ e.preventDefault(); ladeListe(suche); }}> + setSuche(e.target.value)} /> + +
+
+ +
+
+

{liste.length} Rezepte

+ {laedt &&

Wird geladen…

} + {!laedt && liste.length === 0 && ( +

Mealie liefert keine Rezepte.

+ )} +
    + {liste.map((r) => ( +
  • + +
  • + ))} +
+
+ +
+ {!rezept &&

Links ein Rezept wählen.

} + {rezept && ( + <> +
+ +

{rezept.name}

+ + {rezept.kochbar ? "kochbar" : `${fehlende.length} fehlt`} + +
+ +
+ + {rezept.base_servings ? ( + + Rezept ist für {fmt(rezept.base_servings)} gedacht. + + ) : null} +
+ {rezept.skalierung_hinweis && ( +

{rezept.skalierung_hinweis}

+ )} + +
+ + + + + + {rezept.ingredients.map((z, i) => ( + + + + + + + ))} + +
ZutatGebrauchtIm HausZustand
+ {z.text} + {z.ziel_name && ( +
+ {z.art === "group" ? "Gruppe " : ""}{z.ziel_name} +
+ )} + {z.hinweis &&
{z.hinweis}
} +
+ {z.menge != null ? `${fmt(z.menge)} ${z.einheit}`.trim() : "–"} + {z.benoetigt_base != null && ( +
+ {fmt(z.benoetigt_base)} {kurz(z.basis_einheit)} +
+ )} +
+ {z.bestand_base != null + ? `${fmt(z.bestand_base)} ${kurz(z.basis_einheit)}`.trim() : "–"} + {z.fehlt_text && ( +
fehlt: {z.fehlt_text}
+ )} +
+
+ + {!rezept.kochbar && ( +

+ Was fehlt, steht oben rot. Der Knopf „Fehlende zur Einkaufsliste" + kommt, sobald die Einkaufsliste eigene Einträge speichern kann. +

+ )} + + )} +
+
+
+ ); +} diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index f487fc4..19b3416 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -198,6 +198,10 @@ function MealieCard() { const [mitToken, setMitToken] = useState(false); const [busy, setBusy] = useState(false); const [fehler, setFehler] = useState(null); + // Das Formular steht nur offen, solange nichts eingerichtet ist – oder wenn + // man es ausdruecklich wieder aufmacht. Ein Dauerzustand gehoert nicht in ein + // Eingabeformular, sonst sieht man nie, ob es geklappt hat. + const [aendern, setAendern] = useState(false); async function laden(pruefen = false) { try { @@ -208,8 +212,17 @@ function MealieCard() { } useEffect(() => { laden(); }, []); + const verbunden = !!status?.configured; + async function verbinden(e) { e.preventDefault(); + // Eigene Pruefung vor dem Absenden: Der Server antwortet sonst mit der + // rohen Schema-Meldung („String should have at least 1 character"), und + // die erklaert niemandem, welches Feld gemeint ist. + if (!url.trim()) { setFehler("Bitte die Adresse von Mealie eintragen."); return; } + if (mitToken && !token.trim()) { setFehler("Bitte das Token aus Mealie einfügen."); return; } + if (!mitToken && !benutzer.trim()) { setFehler("Bitte den Benutzernamen in Mealie eintragen."); return; } + if (!mitToken && !passwort) { setFehler("Bitte das Passwort eingeben."); return; } setBusy(true); setFehler(null); try { @@ -219,6 +232,7 @@ function MealieCard() { setStatus(s); setPasswort(""); // bewusst sofort leeren – es ist nirgends gespeichert setToken(""); + setAendern(false); // Formular zu, der Zustand oben sagt jetzt alles toast(s.reachable ? "Mit Mealie verbunden." : "Gespeichert – Mealie antwortet aber nicht."); } catch (err) { setFehler(err.message); } finally { setBusy(false); } } @@ -235,6 +249,7 @@ function MealieCard() { await api.mealieTrennen(); setStatus({ configured: false, base_url: "", token_set: false }); setUrl(""); setBenutzer(""); setPasswort(""); setToken(""); + setAendern(false); toast("Verbindung gelöst."); } catch (err) { setFehler(err.message); } } @@ -249,68 +264,82 @@ function MealieCard() { Dauertoken ausstellen und vergisst es.

- {status?.configured && ( -
- - - {status.base_url} - {status.version ? ` · Mealie ${status.version}` : ""} - {" · "} - {status.reachable === false - ? (status.error || "antwortet nicht") - : status.reachable === true ? "erreichbar" : "Token gesetzt"} - -
- )} {fehler &&
{fehler}
} - - - {!mitToken ? ( -
- - -
- ) : ( - - )} - - - -
- - {status?.configured && ( - <> + {verbunden && !aendern ? ( + <> +
+ + + {status.reachable === false + ? `Nicht erreichbar: ${status.error || "unbekannter Grund"}` + : "Verbunden"} + +
+
+
Adresse
{status.base_url}
+ {status.version && (<>
Mealie-Version
{status.version}
)} +
Zugang
+
Dauertoken hinterlegt – dein Passwort ist nicht gespeichert.
+
+
+ - - )} -
+
+ + ) : ( + <> + + + {!mitToken ? ( +
+ + +
+ ) : ( + + )} + + + +
+ + {verbunden && ( + + )} +
+ + )} ); }