Der unscharfe Abgleich muss raten und wird das immer tun: "Lime Curd" hat
mit "Zitronencreme" kein Wort gemeinsam, und manchmal liegt er daneben.
Bisher war das eine Sackgasse - die Zutat blieb unzugeordnet, bei jedem
Aufruf neu.
Jetzt traegt jede Zutatenzeile einen Knopf "Zuordnen". Entschieden wird
gegen Artikel UND Gruppen, gemerkt unter dem normalisierten Namen des
Lebensmittels: "Schlagsahne", "schlagsahne" und "SCHLAGSAHNE " treffen
dieselbe Zeile. Ab dann gilt die Entscheidung fuer jedes Rezept, in dem
diese Zutat vorkommt - eine Zutat wird hoechstens einmal zugeordnet.
Schluessel ist der Name, nicht die Mealie-ID: IDs sind UUIDs, die beim
Neuanlegen eines Lebensmittels wechseln, und nur ueber den Namen greift
die Zuordnung auch fuer Zutaten ohne verknuepftes Lebensmittel ("2 Eier").
Im Auswahldialog stehen oben die Vorschlaege, die der Abgleich selbst
gefunden hat - meist ist der Richtige darunter, nur nicht an erster
Stelle -, darunter die freie Suche ueber alles. Die Basiseinheit steht
dabei, damit nicht wieder Milliliter auf ein Gramm-Produkt wandern.
Beim Raten bleiben Gegenstaende aussen vor; von Hand darf alles gewaehlt
werden ausser Einzelstuecken. Backpapier ist kein Lebensmittel und steht
trotzdem in Rezepten, waehrend ein Einzelstueck nie eine Zutat ist.
Zeigt das Ziel ins Leere, weil der Artikel geloescht wurde, faellt die
Zeile beim Laden still heraus: Auf SQLite greift kein Fremdschluessel,
und ein geloeschter Artikel darf kein Rezept mehr sprengen.
Die Zuordnungs-Routen stehen bewusst vor /recipes/{slug}, sonst liest
FastAPI "zuordnungen" als Rezept-Slug und fragt Mealie danach. Ein Test
wacht ueber die Reihenfolge, weil ein Umsortieren der Datei den Fehler
sonst still einbauen wuerde.
16 neue Tests, Suite bei 487.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
464 lines
18 KiB
Python
464 lines
18 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 Category, CategoryTracking, Group, Product
|
||
from .conversion import (
|
||
BASE_OF_KIND,
|
||
ConversionError,
|
||
article_unit,
|
||
find_unit,
|
||
group_min_context,
|
||
kind_of_product,
|
||
to_base,
|
||
zweit_faktor,
|
||
)
|
||
from .matching import match_score, normalisieren
|
||
from .stock import current_stock, summe_bestand_gewichtet
|
||
|
||
# Gesperrt wird über den Wortanker (siehe ``_hat_anker``), nicht über eine
|
||
# Punktzahl. Eine Ähnlichkeitszahl ist als Sperre untauglich: „Vollmilch
|
||
# Zimmertemperatur" gegen „H Vollmilch" landet nur bei etwa 40 Punkten, weil das
|
||
# lange Beiwort den Durchschnitt zieht – obwohl es offensichtlich dasselbe ist.
|
||
# Umgekehrt kam „Mehl" auf über 60 gegen „Mühlen Geschnetzeltes". Die Punkte
|
||
# entscheiden deshalb nur noch die Reihenfolge unter den Kandidaten, die den
|
||
# Anker schon bestanden haben.
|
||
|
||
|
||
@dataclass
|
||
class Kandidat:
|
||
art: str # "product" | "group"
|
||
id: int
|
||
name: str
|
||
score: int
|
||
# Laesst sich die Rezept-Einheit fuer diesen Kandidaten umrechnen? Passt sie
|
||
# nicht, ist es meist das falsche Produkt (siehe ``_einheit_passt``).
|
||
einheit_passt: bool = True
|
||
|
||
|
||
@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 = ""
|
||
# Der reine Lebensmittelname aus Mealie – Schluessel fuer die gemerkte
|
||
# Zuordnung von Hand (``services/zuordnung.py``).
|
||
food: str = ""
|
||
# True = die Zuordnung kommt aus einer gemerkten Entscheidung, nicht vom Raten.
|
||
manuell: bool = False
|
||
# 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, ""
|
||
|
||
|
||
# Umlaut-Reste zusammenziehen, damit „Apfel" auch „Äpfel" (= „aepfel") findet.
|
||
# Nur fuer den Wortvergleich, nie fuer die Anzeige.
|
||
_UMLAUT_REST = str.maketrans({"a": "a"})
|
||
|
||
|
||
def _stamm(text: str) -> str:
|
||
roh = normalisieren(text)
|
||
return roh.replace("ae", "a").replace("oe", "o").replace("ue", "u")
|
||
|
||
|
||
# Wörter, die eine Zutat BESCHREIBEN, aber nicht benennen. Sie dürfen allein
|
||
# keinen Treffer begründen, sonst passt jedes vegane Produkt auf jede vegane
|
||
# Zutat: „vegane Butter" landete so auf „veganes Mühlen Knusper Bleu", und
|
||
# „veganer Lime Curd" auf „Vegane Mühlen-Schnitzel". Geteilt wird dort nur die
|
||
# Eigenschaft, nicht die Sache.
|
||
#
|
||
# Als Wortanfänge aufgeführt, weil Deutsch beugt: „vegan", „vegane", „veganer",
|
||
# „veganes" sollen alle gemeint sein. Verglichen wird gegen die zusammengezogene
|
||
# Form aus ``_stamm`` (ae/oe/ue schon aufgelöst).
|
||
_NUR_EIGENSCHAFT = (
|
||
"vegan", "vegetar", "bio", "biologisch", "frisch", "roh",
|
||
"gekocht", "gebacken", "geschmolz", "gesalz", "ungesalz", "gezuckert",
|
||
"getrock", "gefror", "gerieb", "gewurf", "geschnitt", "geschal", "gehackt",
|
||
"gemahl", "gepresst", "zimmertemperatur", "lauwarm",
|
||
"klein", "gross", "mittel", "fein", "grob", "ganz",
|
||
"oder", "sowie", "etwa", "circa", "nach", "ohne", "optional", "evtl",
|
||
"block", "stueck", "stuck", "gehauft", "gestrich",
|
||
)
|
||
|
||
|
||
def _nur_eigenschaft(wort: str) -> bool:
|
||
return wort.isdigit() or wort.startswith(_NUR_EIGENSCHAFT)
|
||
|
||
|
||
def _woerter(text: str) -> list[str]:
|
||
"""Bedeutungstragende Wörter einer Zutat.
|
||
|
||
Weg fallen sehr kurze („1 TL"), reine Zahlen und alles, was die Zutat nur
|
||
beschreibt (siehe ``_NUR_EIGENSCHAFT``). Übrig bleibt, was sie IST: „Mehl",
|
||
„Apfel", „Butter", „Joghurt".
|
||
"""
|
||
return [
|
||
w for w in _stamm(text).split()
|
||
if len(w) >= 4 and not _nur_eigenschaft(w)
|
||
]
|
||
|
||
|
||
def _hat_anker(zutat_woerter: list[str], kandidat: str) -> bool:
|
||
"""Teilen Zutat und Kandidat ein echtes Wort?
|
||
|
||
Das ist die eigentliche Sperre. Reine Ähnlichkeitswerte halten „Eier" und
|
||
„Reis" oder „Mehl" und „Mühlen Geschnetzeltes" für verwandt, weil sich
|
||
Buchstaben überlappen – für einen Rezeptabgleich ist das unbrauchbar.
|
||
Verlangt wird deshalb, dass ein ganzes Wort im anderen Namen steckt:
|
||
„Mehl" in „Weissmehl Typ 550" ja, „Mehl" in „Mühlen Geschnetzeltes" nein.
|
||
"""
|
||
ziel = _stamm(kandidat)
|
||
if not ziel:
|
||
return False
|
||
ziel_woerter = [
|
||
w for w in ziel.split() if len(w) >= 4 and not _nur_eigenschaft(w)
|
||
]
|
||
# Auch der Teilstring-Vergleich darf nicht über eine Eigenschaft laufen:
|
||
# „vegan" steckt sonst weiter in „veganes Mühlen Knusper Bleu".
|
||
ziel_ohne = " ".join(ziel_woerter)
|
||
for wort in zutat_woerter:
|
||
if wort in ziel_ohne:
|
||
return True
|
||
# Umgekehrt auch: Artikel „Milch" passt zur Zutat „Buttermilch".
|
||
for zw in ziel_woerter:
|
||
if zw in wort:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _kategorien_zur_zutat(db: Session, zutat_woerter: list[str]) -> set[int]:
|
||
"""Kategorien, deren Name ein Wort mit der Zutat teilt – samt Unterkategorien.
|
||
|
||
Damit wird zuerst dort gesucht, wo es hingehört: „Mehl Typ 405" in „Mehl &
|
||
Zucker", nicht im ganzen Katalog. Erst wenn dort nichts passt, zählt der
|
||
Rest.
|
||
"""
|
||
from ..routers.categories import descendant_ids
|
||
|
||
passende: set[int] = set()
|
||
for kategorie in db.query(Category).all():
|
||
if _hat_anker(zutat_woerter, kategorie.name):
|
||
passende |= descendant_ids(db, kategorie.id)
|
||
return passende
|
||
|
||
|
||
def _einheit_passt(db: Session, produkt: Product, token: str) -> bool:
|
||
"""Lässt sich die Rezept-Einheit für diesen Artikel überhaupt umrechnen?
|
||
|
||
Das ist ein überraschend gutes Zeichen für „falsches Produkt": Sagt das
|
||
Rezept Milliliter und der Kandidat wird in Gramm geführt, ist es meist
|
||
etwas anderes – „200 ml Schlagsahne" ist kein „Kräuter Sahne Hähnchen".
|
||
Eine Zweiteinheit am Artikel („3 Stück ≙ 250 g") gilt weiterhin als
|
||
passend, dafür ist sie da.
|
||
|
||
Entscheidend ist, WARUM eine Umrechnung scheitert – nur einer der Gründe
|
||
sagt etwas über den Artikel:
|
||
|
||
* **Bekannte Einheit falscher Art, ohne Brücke** → spricht gegen den
|
||
Artikel. Milliliter an einem Gramm-Produkt ist ein anderes Produkt.
|
||
* **Unbekanntes Wort oder nicht hinterlegtes Hausmaß** („1 Tasse Linsen")
|
||
→ sagt gar nichts. Der Artikel kann völlig richtig sein, es fehlt nur die
|
||
Maßangabe – die lässt sich nachtragen.
|
||
* **Keine Einheit** (etwa „3 Eier") → passt immer.
|
||
"""
|
||
if not token:
|
||
return True
|
||
unit = find_unit(db, token)
|
||
if unit is None:
|
||
# Unbekannt oder Hausmass: kein Urteil ueber den Artikel.
|
||
return True
|
||
if unit.kind == kind_of_product(produkt):
|
||
return True
|
||
return zweit_faktor(produkt, BASE_OF_KIND[unit.kind]) is not None
|
||
|
||
|
||
def _kandidaten(db: Session, name: str, einheit: str = "") -> list[Kandidat]:
|
||
"""Artikel und Gruppen, die zur Zutat passen können – bester zuerst.
|
||
|
||
Drei Stufen, in dieser Reihenfolge:
|
||
|
||
1. **Nur Lebensmittel.** Gegenstände bleiben außen vor, wie beim
|
||
Kassenzettel-Abgleich. Ein Joystick ist kein Zucker, und eine
|
||
Waffelplatte kein Apfel.
|
||
2. **Wortanker.** Ohne ein geteiltes ganzes Wort gibt es keinen Treffer,
|
||
egal wie hoch die Buchstabenähnlichkeit wäre.
|
||
3. **Kategorie zuerst.** Passt der Zutatenname zu einer Kategorie, gilt nur
|
||
was darin liegt – sonst der ganze (gefilterte) Katalog.
|
||
"""
|
||
woerter = _woerter(name)
|
||
if not woerter:
|
||
return []
|
||
|
||
# 1. Nur Lebensmittel, und keine Einzelstücke.
|
||
from ..crud import product_tracking
|
||
|
||
lebensmittel = [
|
||
p for p in db.query(Product).all()
|
||
if not p.individual and product_tracking(db, p) != CategoryTracking.object.value
|
||
]
|
||
|
||
# 3. Kategorie zuerst – aber nur, wenn darin auch etwas mit Anker liegt.
|
||
kategorien = _kategorien_zur_zutat(db, woerter)
|
||
if kategorien:
|
||
in_kategorie = [
|
||
p for p in lebensmittel
|
||
if p.category_id in kategorien and _hat_anker(woerter, f"{p.name} {p.brand or ''}")
|
||
]
|
||
if in_kategorie:
|
||
lebensmittel = in_kategorie
|
||
|
||
treffer: list[Kandidat] = []
|
||
for gruppe in db.query(Group).all():
|
||
if _hat_anker(woerter, gruppe.name):
|
||
treffer.append(
|
||
Kandidat("group", gruppe.id, gruppe.name, match_score(name, gruppe.name))
|
||
)
|
||
for produkt in lebensmittel:
|
||
voll = f"{produkt.name} {produkt.brand or ''}".strip()
|
||
if _hat_anker(woerter, voll):
|
||
k = Kandidat("product", produkt.id, produkt.name, match_score(name, voll))
|
||
k.einheit_passt = _einheit_passt(db, produkt, einheit)
|
||
treffer.append(k)
|
||
|
||
# Reihenfolge: passende Einheit zuerst, dann Punkte; Gruppen bei Gleichstand
|
||
# vor Artikeln, weil „Milch" markenuebergreifend gemeint ist.
|
||
treffer.sort(key=lambda k: (
|
||
0 if k.einheit_passt else 1, -k.score, 0 if k.art == "group" else 1, k.name,
|
||
))
|
||
return treffer[: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.food = name
|
||
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
|
||
pruefung.manuell = True
|
||
else:
|
||
pruefung.kandidaten = _kandidaten(db, name, pruefung.einheit)
|
||
bester = pruefung.kandidaten[0] if pruefung.kandidaten else None
|
||
# Passt die Einheit beim besten Kandidaten nicht, wird NICHT
|
||
# zugeordnet: Sagt das Rezept Milliliter und der Artikel wird in
|
||
# Gramm gefuehrt, ist es meist etwas anderes. Lieber offen lassen
|
||
# und von Hand zuordnen lassen, als etwas Falsches behaupten.
|
||
if bester is not None and bester.einheit_passt:
|
||
pruefung.art, pruefung.ziel_id = bester.art, bester.id
|
||
elif bester is not None:
|
||
pruefung.hinweis = (
|
||
f"Nichts Passendes gefunden: '{bester.name}' würde zum Namen "
|
||
f"passen, wird aber nicht in '{pruefung.einheit}' geführt."
|
||
)
|
||
|
||
if pruefung.ziel_id is None:
|
||
pruefung.status = "kein_treffer"
|
||
if not pruefung.hinweis:
|
||
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
|