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>
269 lines
9.9 KiB
Python
269 lines
9.9 KiB
Python
"""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
|