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}
}
+
+
+
+
+
+
+
+
{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}
+ )}
+
+
+
+
+ | Zutat | Gebraucht | Im Haus | Zustand |
+
+
+ {rezept.ingredients.map((z, i) => (
+
+ |
+ {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 && (
+
+ )}
+
+ >
+ )}
);
}