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:
Scarriffle
2026-10-02 13:10:04 +02:00
co-authored by Claude Opus 5
parent 7d8a54a3fc
commit 2247c8cf69
12 changed files with 1100 additions and 89 deletions
+255
View File
@@ -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