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
+2
View File
@@ -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)
+6 -34
View File
@@ -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])
+129
View File
@@ -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),
)
+58
View File
@@ -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}"
+45
View File
@@ -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))
+268
View File
@@ -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