Rezepte aus Mealie gegen den Bestand pruefen
Neue Seite "Rezepte" (nur sichtbar, wenn Mealie eingerichtet ist): links
die Rezeptliste mit Suche, rechts das gewaehlte Rezept mit allen Zutaten
und der einen Frage, die zaehlt - kann ich das heute kochen, oder muss ich
vorher einkaufen? Je Zutat steht der Zustand: vorhanden, zu wenig, fehlt,
Menge unklar oder nicht zugeordnet; Fehlendes ist rot.
Die Portionenzahl laesst sich hochdrehen. Vorrania rechnet die Mengen dann
hoch, bevor es sie mit dem Bestand vergleicht - ein Rezept fuer 4, das man
fuer 8 kocht, braucht die doppelte Menge, und genau danach soll der
Abgleich gehen. Nennt das Rezept keine Portionszahl, wird nicht skaliert
und die Seite sagt das auch.
Drei Dinge entscheidet der Abgleich nacheinander (services/rezepte.py,
bewusst ohne HTTP, damit sich alles mit erfundenen Zutaten pruefen laesst):
1. Welcher Artikel ist gemeint? Unscharf ueber den Namen, gegen Artikel
UND Gruppen - "Milch" ist markenuebergreifend gemeint, und genau dafuer
gibt es Gruppen. Die Schwelle liegt hoeher als beim Kassenzettel: Dort
sieht ein Mensch die Vorschlaege durch, hier darf nicht stillschweigend
das Falsche als vorhanden gelten. Unter der Schwelle bleibt es bei
"nicht zugeordnet" samt Kandidatenliste.
2. Wie viel ist das in Gramm? Ueber die Einheiten und die Hausmasse der
Kategorie - "2 EL Zucker" wird zu 24 g. Fehlt das Mass fuer diese
Kategorie, meldet die Zutat genau das zurueck (Massname und
Kategorie-ID), damit daraus ein "Mass jetzt anlegen" werden kann.
3. Reicht der Bestand? Bei Gruppen ueber dieselbe gewichtete Summe wie in
der Einkaufsliste, damit Untergruppen und Zweiteinheiten gleich zaehlen.
Die Fuzzy-Heuristik lag in routers/products.py und ist nach
services/matching.py gezogen: Der Rezeptabgleich stellt dieselbe Frage, und
zwei Kopien waeren mit der Zeit auseinandergelaufen. Der Kassenzettel-Scan
nutzt sie unveraendert weiter, test_receipt_match.py bleibt gruen.
Dazu zwei Dinge aus deiner Rueckmeldung zur Einstellungs-Karte:
- Der verbundene Zustand bleibt jetzt stehen, statt nur als Meldung
aufzublitzen: Adresse, Version und "Dauertoken hinterlegt" samt den
Knoepfen Pruefen / Zugang aendern / Trennen. Das Formular erscheint nur,
solange nichts eingerichtet ist - ein Dauerzustand gehoert nicht in ein
Eingabefeld, sonst sieht man nie, ob es geklappt hat.
- Leere Felder melden sich auf Deutsch ("Bitte das Passwort eingeben.")
statt mit der rohen Schema-Meldung "String should have at least 1
character", die niemandem sagt, welches Feld gemeint ist.
Der Knopf "Fehlende zur Einkaufsliste" fehlt noch: Vorranias Einkaufsliste
wird heute vollstaendig aus Mindestbestaenden gerechnet und hat keinen Ort
fuer einen eigenen Eintrag. Das ist der naechste Schritt; die Seite sagt es
auch so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7d8a54a3fc
commit
2247c8cf69
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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/<id>/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),
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}"
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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() {
|
||||
<span className="label">{e.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{mealie && <NavItem to="/rezepte" icon="food" label="Rezepte" />}
|
||||
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
|
||||
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
|
||||
<NavItem to="/verliehen" icon="loan" label="Verliehen" />
|
||||
@@ -237,6 +246,7 @@ export default function App() {
|
||||
<Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} />
|
||||
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
|
||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||
<Route path="/rezepte" element={<Protected wide><Rezepte /></Protected>} />
|
||||
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
|
||||
{/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein-
|
||||
und Auslagern, keine Stammdatenänderung. */}
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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 <span className={`badge ${z.klasse}`}>{z.text}</span>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Rezepte</h1>
|
||||
<div className="sub">Aus Mealie – geprüft gegen deinen Bestand</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
|
||||
|
||||
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
|
||||
<form className="field-inline" style={{ marginBottom: 0 }}
|
||||
onSubmit={(e) => { e.preventDefault(); ladeListe(suche); }}>
|
||||
<input value={suche} placeholder="Rezept suchen…"
|
||||
onChange={(e) => setSuche(e.target.value)} />
|
||||
<button className="btn"><Icon name="search" size={16} />Suchen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "var(--sp-4)",
|
||||
gridTemplateColumns: "minmax(240px, 320px) 1fr" }}>
|
||||
<div className="card">
|
||||
<div className="card-head"><Icon name="food" /><h2>{liste.length} Rezepte</h2></div>
|
||||
{laedt && <p className="muted small">Wird geladen…</p>}
|
||||
{!laedt && liste.length === 0 && (
|
||||
<p className="muted small">Mealie liefert keine Rezepte.</p>
|
||||
)}
|
||||
<ul className="clean-list">
|
||||
{liste.map((r) => (
|
||||
<li key={r.slug}>
|
||||
<button type="button"
|
||||
className={`btn sm ghost${r.slug === slug ? " primary" : ""}`}
|
||||
style={{ width: "100%", justifyContent: "flex-start" }}
|
||||
onClick={() => oeffne(r.slug, null)}>
|
||||
{r.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{!rezept && <p className="muted">Links ein Rezept wählen.</p>}
|
||||
{rezept && (
|
||||
<>
|
||||
<div className="card-head">
|
||||
<Icon name={rezept.kochbar ? "check" : "cart"} />
|
||||
<h2>{rezept.name}</h2>
|
||||
<span className={`badge ${rezept.kochbar ? "ok" : "warn"}`}
|
||||
style={{ marginLeft: "auto" }}>
|
||||
{rezept.kochbar ? "kochbar" : `${fehlende.length} fehlt`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="field-inline" style={{ flexWrap: "wrap" }}>
|
||||
<label style={{ margin: 0, maxWidth: 160 }}>
|
||||
Portionen
|
||||
<input type="number" min="0.5" step="0.5" value={portionen}
|
||||
onChange={(e) => setzePortionen(e.target.value)} />
|
||||
</label>
|
||||
{rezept.base_servings ? (
|
||||
<span className="muted small">
|
||||
Rezept ist für {fmt(rezept.base_servings)} gedacht.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{rezept.skalierung_hinweis && (
|
||||
<p className="muted small mt-0">{rezept.skalierung_hinweis}</p>
|
||||
)}
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Zutat</th><th>Gebraucht</th><th>Im Haus</th><th>Zustand</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rezept.ingredients.map((z, i) => (
|
||||
<tr key={i} className={
|
||||
z.status === "fehlt" ? "row-danger"
|
||||
: z.status === "zu_wenig" ? "row-warn" : ""}>
|
||||
<td data-label="Zutat">
|
||||
<span className="strong">{z.text}</span>
|
||||
{z.ziel_name && (
|
||||
<div className="muted small">
|
||||
{z.art === "group" ? "Gruppe " : ""}{z.ziel_name}
|
||||
</div>
|
||||
)}
|
||||
{z.hinweis && <div className="muted small">{z.hinweis}</div>}
|
||||
</td>
|
||||
<td data-label="Gebraucht">
|
||||
{z.menge != null ? `${fmt(z.menge)} ${z.einheit}`.trim() : "–"}
|
||||
{z.benoetigt_base != null && (
|
||||
<div className="muted small">
|
||||
{fmt(z.benoetigt_base)} {kurz(z.basis_einheit)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Im Haus">
|
||||
{z.bestand_base != null
|
||||
? `${fmt(z.bestand_base)} ${kurz(z.basis_einheit)}`.trim() : "–"}
|
||||
{z.fehlt_text && (
|
||||
<div className="muted small">fehlt: {z.fehlt_text}</div>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Zustand"><Zustand status={z.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!rezept.kochbar && (
|
||||
<p className="muted small">
|
||||
Was fehlt, steht oben rot. Der Knopf „Fehlende zur Einkaufsliste"
|
||||
kommt, sobald die Einkaufsliste eigene Einträge speichern kann.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+47
-18
@@ -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,21 +264,38 @@ function MealieCard() {
|
||||
Dauertoken ausstellen und vergisst es.
|
||||
</p>
|
||||
|
||||
{status?.configured && (
|
||||
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
|
||||
|
||||
{verbunden && !aendern ? (
|
||||
<>
|
||||
<div className={`alert ${status.reachable === false ? "error" : "ok"}`}>
|
||||
<Icon name={status.reachable === false ? "alert" : "check"} size={16} />
|
||||
<span>
|
||||
{status.base_url}
|
||||
{status.version ? ` · Mealie ${status.version}` : ""}
|
||||
{" · "}
|
||||
{status.reachable === false
|
||||
? (status.error || "antwortet nicht")
|
||||
: status.reachable === true ? "erreichbar" : "Token gesetzt"}
|
||||
? `Nicht erreichbar: ${status.error || "unbekannter Grund"}`
|
||||
: "Verbunden"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
|
||||
|
||||
<dl className="kv">
|
||||
<dt>Adresse</dt><dd>{status.base_url}</dd>
|
||||
{status.version && (<><dt>Mealie-Version</dt><dd>{status.version}</dd></>)}
|
||||
<dt>Zugang</dt>
|
||||
<dd>Dauertoken hinterlegt – dein Passwort ist nicht gespeichert.</dd>
|
||||
</dl>
|
||||
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: 0 }}>
|
||||
<button type="button" className="btn" onClick={() => laden(true)}>
|
||||
<Icon name="check" size={16} />Verbindung prüfen
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => setAendern(true)}>
|
||||
<Icon name="edit" size={16} />Zugang ändern
|
||||
</button>
|
||||
<button type="button" className="btn danger" onClick={trennen}>
|
||||
Trennen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
Adresse
|
||||
<input value={url} placeholder="https://mealie.example.com"
|
||||
@@ -297,20 +329,17 @@ function MealieCard() {
|
||||
</label>
|
||||
|
||||
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: 0 }}>
|
||||
<button className="btn primary" disabled={busy || !url}>
|
||||
<button className="btn primary" disabled={busy}>
|
||||
<Icon name="check" size={16} />{busy ? "Verbinde…" : "Verbinden"}
|
||||
</button>
|
||||
{status?.configured && (
|
||||
<>
|
||||
<button type="button" className="btn" onClick={() => laden(true)}>
|
||||
Verbindung prüfen
|
||||
{verbunden && (
|
||||
<button type="button" className="btn" onClick={() => { setAendern(false); setFehler(null); }}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="button" className="btn danger" onClick={trennen}>
|
||||
Trennen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user