diff --git a/backend/app/models.py b/backend/app/models.py index 39aa91a..13dd75b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -815,6 +815,42 @@ class IntegrationSecret(Base): ) +class MealieFoodMap(Base): + """Gemerkte Zuordnung: Mealie-Lebensmittel X ist bei uns Artikel/Gruppe Y. + + Der unscharfe Abgleich in ``services/rezepte.py`` raet, dieser Tisch weiss. + Jede Korrektur von Hand landet hier und gilt ab dann fuer jedes Rezept, in + dem dasselbe Lebensmittel vorkommt — eine Zutat wird also hoechstens einmal + im Leben zugeordnet. + + Schluessel ist der **normalisierte** Name (``matching.normalisieren``), damit + "Schlagsahne", "schlagsahne" und "SCHLAGSAHNE " dieselbe Zeile treffen. + Daneben steht der Name, wie Mealie ihn schreibt — nur zur Anzeige in der + Liste der gemerkten Zuordnungen. + + Genau eines von ``product_id`` und ``group_id`` ist gesetzt; geprueft wird + das beim Speichern (``services/zuordnung.py``), nicht in der Datenbank — + dasselbe Muster wie bei den Bedarfszeilen. Verschwindet das Ziel spaeter, + wird die Zeile beim Laden stillschweigend uebersprungen: Auf SQLite greift + kein Fremdschluessel, und eine Zuordnung auf einen geloeschten Artikel darf + den Rezeptabgleich nicht sprengen. + """ + + __tablename__ = "mealie_food_map" + + food_key: Mapped[str] = mapped_column(String(200), primary_key=True) + food_name: Mapped[str] = mapped_column(String(200), nullable=False) + product_id: Mapped[int | None] = mapped_column( + ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True + ) + group_id: Mapped[int | None] = mapped_column( + ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_now, onupdate=_now + ) + + class Shop(Base): """Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei"). diff --git a/backend/app/routers/recipes.py b/backend/app/routers/recipes.py index 5ed026a..3932518 100644 --- a/backend/app/routers/recipes.py +++ b/backend/app/routers/recipes.py @@ -12,16 +12,21 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user -from ..models import User +from ..models import Group, MealieFoodMap, Product, User from ..schemas import ( RecipeDetail, RecipePage, RecipeSummary, + ZielVorschlag, + ZuordnungIn, + ZuordnungOut, ZutatKandidat, ZutatOut, ) -from ..services import mealie, rezepte +from ..services import mealie, rezepte, zuordnung +from ..services.conversion import group_min_context from ..services.einkaufsliste_text import fehlmengen_text +from ..services.matching import match_score, normalisieren router = APIRouter(prefix="/recipes", tags=["recipes"]) @@ -79,6 +84,115 @@ def list_recipes( ) +# -------------------------------------------------------------------------- +# Zuordnungen von Hand +# +# ACHTUNG: Diese Routen stehen bewusst VOR ``/{slug}``. FastAPI probiert in +# Reihenfolge der Deklaration – stünden sie darunter, würde „zuordnungen" als +# Rezept-Slug gelesen und bei Mealie nachgefragt. ``test_zuordnung.py`` wacht +# darüber. +# -------------------------------------------------------------------------- +@router.get("/zuordnungen", response_model=list[ZuordnungOut]) +def list_zuordnungen( + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[ZuordnungOut]: + """Alle gemerkten Zuordnungen – zum Nachsehen und Widerrufen.""" + aus = [] + for key, (art, ziel_id) in sorted(zuordnung.lade(db).items()): + zeile = db.get(MealieFoodMap, key) + aus.append(ZuordnungOut( + food_key=key, + food_name=(zeile.food_name if zeile is not None else key), + art=art, ziel_id=ziel_id, + ziel_name=zuordnung.ziel_name(db, art, ziel_id), + )) + return aus + + +@router.get("/zuordnungen/ziele", response_model=list[ZielVorschlag]) +def zuordnung_ziele( + q: str | None = None, + limit: int = 40, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[ZielVorschlag]: + """Artikel und Gruppen zur Auswahl, nach Suchtext eingegrenzt. + + Anders als beim Raten wird hier **nicht** auf Lebensmittel eingeschränkt: + Die Heuristik soll keine Gegenstände vorschlagen, aber wer von Hand + zuordnet, weiss was er will – „Alufolie" steht in manchem Rezept. Nur + Einzelstücke fallen weg, die sind per Definition unverbrauchbar. + + Mitgeliefert wird die Basiseinheit, damit man vor dem Zuordnen sieht, ob + das Ziel in Gramm oder Milliliter geführt wird. + """ + teile = normalisieren(q or "").split() + + def passt(text: str) -> bool: + ziel = normalisieren(text) + return all(t in ziel for t in teile) + + vorschlaege: list[ZielVorschlag] = [] + for gruppe in db.query(Group).all(): + if not passt(gruppe.name): + continue + ctx = group_min_context(gruppe) + vorschlaege.append(ZielVorschlag( + art="group", id=gruppe.id, name=gruppe.name, zusatz="Gruppe", + basis_einheit=(ctx.base_unit.value if ctx.base_unit else ""), + )) + for produkt in db.query(Product).all(): + if produkt.individual: + continue + voll = f"{produkt.name} {produkt.brand or ''}" + if not passt(voll): + continue + vorschlaege.append(ZielVorschlag( + art="product", id=produkt.id, name=produkt.name, + zusatz=(produkt.brand or ""), + basis_einheit=produkt.base_unit.value, + )) + + if teile: + vorschlaege.sort(key=lambda v: (-match_score(q or "", f"{v.name} {v.zusatz}"), v.name)) + else: + vorschlaege.sort(key=lambda v: (0 if v.art == "group" else 1, v.name)) + return vorschlaege[: max(1, min(limit, 200))] + + +@router.put("/zuordnungen", response_model=ZuordnungOut) +def setze_zuordnung( + payload: ZuordnungIn, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> ZuordnungOut: + """Eine Zutat von Hand zuordnen – gilt ab dann für jedes Rezept.""" + try: + zeile = zuordnung.setze(db, payload.food, payload.art, payload.ziel_id) + except zuordnung.ZuordnungFehler as fehler: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler + return ZuordnungOut( + food_key=zeile.food_key, food_name=zeile.food_name, + art=payload.art, ziel_id=payload.ziel_id, + ziel_name=zuordnung.ziel_name(db, payload.art, payload.ziel_id), + ) + + +@router.delete("/zuordnungen", status_code=status.HTTP_204_NO_CONTENT) +def loesche_zuordnung( + food: str, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> None: + """Zuordnung vergessen; danach wird für diese Zutat wieder geraten. + + Der Name reist als Abfrageparameter, nicht im Pfad: Lebensmittelnamen + enthalten Schrägstriche („Öl/Fett") und würden den Pfad zerlegen. + """ + zuordnung.loesche(db, food) + + @router.get("/{slug}", response_model=RecipeDetail) def get_recipe( slug: str, @@ -94,7 +208,9 @@ def get_recipe( 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) + pruefungen = rezepte.pruefe_zutaten( + db, zutaten, faktor=faktor, zuordnung=zuordnung.lade(db), + ) basis_portionen = ( daten.get("recipeServings") or daten.get("recipe_servings") @@ -116,6 +232,7 @@ def get_recipe( def _zutat_out(db: Session, p: rezepte.ZutatPruefung) -> ZutatOut: return ZutatOut( text=p.text, menge=p.menge, einheit=p.einheit, + food=p.food, manuell=p.manuell, 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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index fb62436..16d0e02 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -756,6 +756,10 @@ class ZutatOut(BaseModel): text: str menge: float | None = None einheit: str = "" + # Reiner Lebensmittelname – damit die Oberfläche eine Zuordnung dafür merken kann. + food: str = "" + # True = so zugeordnet, weil es jemand einmal von Hand entschieden hat. + manuell: bool = False art: str | None = None ziel_id: int | None = None ziel_name: str = "" @@ -786,6 +790,33 @@ class RecipeDetail(BaseModel): ingredients: list[ZutatOut] = [] +# ---- Gemerkte Zutaten-Zuordnungen ---- +class ZuordnungIn(BaseModel): + """Eine Zutat von Hand auf einen Artikel oder eine Gruppe legen.""" + food: str = Field(min_length=1, max_length=200) + art: str = Field(pattern="^(product|group)$") + ziel_id: int + + +class ZuordnungOut(BaseModel): + """Eine gemerkte Zuordnung, wie sie in der Liste steht.""" + food_key: str + food_name: str + art: str + ziel_id: int + ziel_name: str = "" + + +class ZielVorschlag(BaseModel): + """Ein Artikel oder eine Gruppe als Ziel – die Auswahlliste beim Zuordnen.""" + art: str + id: int + name: str + # Zur Unterscheidung gleichnamiger Artikel: Marke bzw. Kategorie. + zusatz: str = "" + basis_einheit: str = "" + + # ---- Hausmaße ---- class MeasureIn(BaseModel): """Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel.""" diff --git a/backend/app/services/rezepte.py b/backend/app/services/rezepte.py index a955b71..c24d6d9 100644 --- a/backend/app/services/rezepte.py +++ b/backend/app/services/rezepte.py @@ -61,6 +61,11 @@ class ZutatPruefung: 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 @@ -349,6 +354,7 @@ def pruefe_zutaten( for zutat in zutaten: pruefung = ZutatPruefung(text=_zutat_text(zutat)) name = _lebensmittel(zutat) + pruefung.food = name pruefung.einheit = _einheit(zutat) rohmenge = zutat.get("quantity") @@ -364,6 +370,7 @@ def pruefe_zutaten( 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 diff --git a/backend/app/services/zuordnung.py b/backend/app/services/zuordnung.py new file mode 100644 index 0000000..dfef795 --- /dev/null +++ b/backend/app/services/zuordnung.py @@ -0,0 +1,94 @@ +"""Gemerkte Zuordnungen: Mealie-Lebensmittel → eigener Artikel oder Gruppe. + +Der unscharfe Abgleich in :mod:`services.rezepte` ist eine Heuristik und wird +immer Fälle haben, die er nicht erraten kann — „Lime Curd", „Ras el Hanout", +oder einfach ein Hausname. Hier liegt der Korrekturweg: Einmal von Hand +zugeordnet, gilt es für jedes Rezept, in dem dasselbe Lebensmittel vorkommt. + +Der Schlüssel ist der normalisierte Name, nicht die Mealie-ID. Mealie-IDs sind +UUIDs, die beim Neuanlegen eines Lebensmittels wechseln; der Name bleibt. Und +nur über den Namen greift die Zuordnung auch für Zutaten, die in Mealie gar +kein verknüpftes Lebensmittel haben (freier Text „2 Eier"). +""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from ..models import Group, MealieFoodMap, Product +from .matching import normalisieren + + +class ZuordnungFehler(ValueError): + """Unbrauchbare Zuordnung – der Router macht daraus eine 400/404.""" + + +def schluessel(name: str) -> str: + """Normalisierter Schlüssel. Leer heisst „damit lässt sich nichts merken".""" + return normalisieren(name)[:200] + + +def lade(db: Session) -> dict[str, tuple[str, int]]: + """Alle gültigen Zuordnungen als ``{schluessel: (art, id)}``. + + Zeilen, deren Ziel es nicht mehr gibt, fallen hier still heraus statt eine + Ausnahme zu werfen: Auf SQLite greift kein Fremdschlüssel, und ein + gelöschter Artikel darf nicht dazu führen, dass sich kein Rezept mehr + öffnen lässt. + """ + ergebnis: dict[str, tuple[str, int]] = {} + for zeile in db.query(MealieFoodMap).all(): + if zeile.product_id is not None: + if db.get(Product, zeile.product_id) is not None: + ergebnis[zeile.food_key] = ("product", zeile.product_id) + elif zeile.group_id is not None: + if db.get(Group, zeile.group_id) is not None: + ergebnis[zeile.food_key] = ("group", zeile.group_id) + return ergebnis + + +def ziel_name(db: Session, art: str, ziel_id: int) -> str: + if art == "product": + produkt = db.get(Product, ziel_id) + return produkt.name if produkt is not None else "" + gruppe = db.get(Group, ziel_id) + return gruppe.name if gruppe is not None else "" + + +def setze(db: Session, food: str, art: str, ziel_id: int) -> MealieFoodMap: + """Zuordnung anlegen oder ändern. Prüft, dass das Ziel existiert.""" + key = schluessel(food) + if not key: + raise ZuordnungFehler( + "Zu dieser Zutat steht kein Name da – ohne Namen lässt sich die " + "Zuordnung nicht merken." + ) + if art not in ("product", "group"): + raise ZuordnungFehler("Zugeordnet wird auf einen Artikel oder eine Gruppe.") + if art == "product" and db.get(Product, ziel_id) is None: + raise ZuordnungFehler("Diesen Artikel gibt es nicht.") + if art == "group" and db.get(Group, ziel_id) is None: + raise ZuordnungFehler("Diese Gruppe gibt es nicht.") + + zeile = db.get(MealieFoodMap, key) + if zeile is None: + zeile = MealieFoodMap(food_key=key) + db.add(zeile) + # Immer beide Felder setzen, damit ein Wechsel Artikel → Gruppe nicht + # beide gefüllt zurücklässt. + zeile.food_name = (food or "").strip()[:200] + zeile.product_id = ziel_id if art == "product" else None + zeile.group_id = ziel_id if art == "group" else None + db.commit() + db.refresh(zeile) + return zeile + + +def loesche(db: Session, food: str) -> bool: + """Zuordnung vergessen. ``False``, wenn es keine gab.""" + zeile = db.get(MealieFoodMap, schluessel(food)) + if zeile is None: + return False + db.delete(zeile) + db.commit() + return True diff --git a/backend/tests/test_zuordnung.py b/backend/tests/test_zuordnung.py new file mode 100644 index 0000000..f2b0689 --- /dev/null +++ b/backend/tests/test_zuordnung.py @@ -0,0 +1,305 @@ +"""Zutaten von Hand zuordnen – und dass sich Vorrania das merkt. + +Der unscharfe Abgleich wird immer Zutaten haben, die er nicht erraten kann +(„Lime Curd") oder falsch errät. Hier liegt der Korrekturweg: einmal von Hand +entschieden, gilt es für jedes Rezept. +""" + +import pytest +from fastapi import HTTPException + +from app.models import ( + BaseUnit, + Category, + CategoryTracking, + Group, + MealieFoodMap, + Product, + Role, + User, +) +from app.routers.recipes import ( + list_zuordnungen, + loesche_zuordnung, + router, + setze_zuordnung, + zuordnung_ziele, +) +from app.schemas import ZuordnungIn +from app.services import zuordnung +from app.services.rezepte import pruefe_zutaten +from app.services.stock import check_in + + +@pytest.fixture() +def nutzer(db): + person = User(username="koch", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +def _zutat(name, menge=None, einheit=None): + return { + "quantity": menge, + "unit": {"name": einheit, "abbreviation": einheit} if einheit else None, + "food": {"name": name}, + "display": f"{menge or ''} {einheit or ''} {name}".strip(), + } + + +def _kategorie(db, name, tracking=CategoryTracking.food): + cat = Category(name=name, tracking=tracking.value) + db.add(cat) + db.commit() + db.refresh(cat) + return cat + + +def _artikel(db, name, cat, base=BaseUnit.gram, bestand=0.0, **extra): + product = Product(name=name, base_unit=base, package_size=1000, + category_id=cat.id, source="manual", **extra) + db.add(product) + db.commit() + db.refresh(product) + if bestand: + check_in(db, product, bestand, base.value, None, None, None) + db.commit() + return product + + +# -------------------------------------------------------------------------- +# Die Zuordnung greift +# -------------------------------------------------------------------------- +def test_gemerkte_zuordnung_findet_was_das_raten_nicht_findet(db, nutzer): + """„Lime Curd" hat mit „Zitronencreme" kein Wort gemeinsam.""" + cat = _kategorie(db, "Aufstriche") + creme = _artikel(db, "Zitronencreme", cat, bestand=500) + + [vorher] = pruefe_zutaten(db, [_zutat("Lime Curd", 100, "g")]) + assert vorher.status == "kein_treffer" + + setze_zuordnung( + ZuordnungIn(food="Lime Curd", art="product", ziel_id=creme.id), + db=db, _=nutzer, + ) + + [nachher] = pruefe_zutaten( + db, [_zutat("Lime Curd", 100, "g")], zuordnung=zuordnung.lade(db), + ) + assert nachher.status == "vorhanden" + assert nachher.ziel_name == "Zitronencreme" + assert nachher.manuell is True + + +def test_zuordnung_schlaegt_einen_falschen_treffer(db, nutzer): + """Der Kern der Beschwerde: Sahne ist kein Kräuter-Sahne-Hähnchen.""" + cat = _kategorie(db, "Kuehlregal") + _artikel(db, "Kräuter Sahne Hähnchen", cat, bestand=2000) + sahne = _artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter, bestand=1000) + + setze_zuordnung( + ZuordnungIn(food="Sahne", art="product", ziel_id=sahne.id), + db=db, _=nutzer, + ) + + [zutat] = pruefe_zutaten( + db, [_zutat("Sahne", 200, "ml")], zuordnung=zuordnung.lade(db), + ) + assert zutat.ziel_id == sahne.id + assert zutat.status == "vorhanden" + + +def test_gross_und_kleinschreibung_treffen_dieselbe_zeile(db, nutzer): + cat = _kategorie(db, "Kuehlregal") + rahm = _artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter, bestand=1000) + setze_zuordnung( + ZuordnungIn(food="Schlagsahne", art="product", ziel_id=rahm.id), + db=db, _=nutzer, + ) + + [zutat] = pruefe_zutaten( + db, [_zutat(" SCHLAGSAHNE ", 200, "ml")], zuordnung=zuordnung.lade(db), + ) + assert zutat.ziel_id == rahm.id + + +def test_zuordnung_auf_eine_gruppe(db, nutzer): + """„Milch" ist markenübergreifend gemeint – dafür gibt es Gruppen.""" + cat = _kategorie(db, "Kuehlregal") + gruppe = Group(name="Trinkmilch") + db.add(gruppe) + db.commit() + db.refresh(gruppe) + _artikel(db, "H-Milch 3,5%", cat, base=BaseUnit.milliliter, + bestand=2000, group_id=gruppe.id) + + setze_zuordnung( + ZuordnungIn(food="Vollmilch", art="group", ziel_id=gruppe.id), + db=db, _=nutzer, + ) + + [zutat] = pruefe_zutaten( + db, [_zutat("Vollmilch", 500, "ml")], zuordnung=zuordnung.lade(db), + ) + assert zutat.art == "group" + assert zutat.ziel_id == gruppe.id + assert zutat.status == "vorhanden" + + +def test_wechsel_auf_eine_gruppe_laesst_den_artikel_nicht_stehen(db, nutzer): + """Sonst stünden beide Ziele in der Zeile und eines gewänne zufällig.""" + cat = _kategorie(db, "Kuehlregal") + milch = _artikel(db, "H-Milch", cat, base=BaseUnit.milliliter) + gruppe = Group(name="Trinkmilch") + db.add(gruppe) + db.commit() + db.refresh(gruppe) + + setze_zuordnung(ZuordnungIn(food="Milch", art="product", ziel_id=milch.id), + db=db, _=nutzer) + setze_zuordnung(ZuordnungIn(food="Milch", art="group", ziel_id=gruppe.id), + db=db, _=nutzer) + + zeile = db.get(MealieFoodMap, "milch") + assert zeile.product_id is None + assert zeile.group_id == gruppe.id + assert db.query(MealieFoodMap).count() == 1 + + +# -------------------------------------------------------------------------- +# Widerrufen und Aufräumen +# -------------------------------------------------------------------------- +def test_loeschen_laesst_wieder_raten(db, nutzer): + cat = _kategorie(db, "Trockenwaren") + mehl = _artikel(db, "Weissmehl", cat, bestand=1000) + setze_zuordnung(ZuordnungIn(food="Reis", art="product", ziel_id=mehl.id), + db=db, _=nutzer) + + loesche_zuordnung(food="Reis", db=db, _=nutzer) + + assert zuordnung.lade(db) == {} + [zutat] = pruefe_zutaten( + db, [_zutat("Reis", 100, "g")], zuordnung=zuordnung.lade(db), + ) + assert zutat.status == "kein_treffer" + + +def test_loeschen_was_es_nicht_gibt_ist_kein_fehler(db, nutzer): + loesche_zuordnung(food="Gibtsnicht", db=db, _=nutzer) + + +def test_zuordnung_auf_einen_geloeschten_artikel_wird_uebersprungen(db, nutzer): + """Auf SQLite greift kein Fremdschlüssel – das darf kein Rezept sprengen.""" + cat = _kategorie(db, "Trockenwaren") + mehl = _artikel(db, "Weissmehl", cat, bestand=1000) + setze_zuordnung(ZuordnungIn(food="Lime Curd", art="product", ziel_id=mehl.id), + db=db, _=nutzer) + + db.delete(mehl) + db.commit() + + assert zuordnung.lade(db) == {} + [zutat] = pruefe_zutaten( + db, [_zutat("Lime Curd", 100, "g")], zuordnung=zuordnung.lade(db), + ) + assert zutat.status == "kein_treffer" + + +def test_unbekanntes_ziel_wird_abgelehnt(db, nutzer): + with pytest.raises(HTTPException) as fehler: + setze_zuordnung(ZuordnungIn(food="Mehl", art="product", ziel_id=999), + db=db, _=nutzer) + + assert fehler.value.status_code == 400 + assert db.query(MealieFoodMap).count() == 0 + + +def test_zutat_ohne_namen_laesst_sich_nicht_merken(db, nutzer): + cat = _kategorie(db, "Trockenwaren") + mehl = _artikel(db, "Weissmehl", cat) + + with pytest.raises(zuordnung.ZuordnungFehler): + zuordnung.setze(db, " ", "product", mehl.id) + + +# -------------------------------------------------------------------------- +# Liste und Auswahl +# -------------------------------------------------------------------------- +def test_liste_nennt_das_ziel_mit_namen(db, nutzer): + cat = _kategorie(db, "Trockenwaren") + mehl = _artikel(db, "Weissmehl", cat) + setze_zuordnung(ZuordnungIn(food="Flour", art="product", ziel_id=mehl.id), + db=db, _=nutzer) + + [eintrag] = list_zuordnungen(db=db, _=nutzer) + + assert eintrag.food_name == "Flour" + assert eintrag.food_key == "flour" + assert eintrag.ziel_name == "Weissmehl" + + +def test_ziele_suchen_ueber_name_und_marke(db, nutzer): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl Typ 550", cat, brand="Mühle Nord") + _artikel(db, "Zucker", cat) + + namen = [z.name for z in zuordnung_ziele(q="mehl", db=db, _=nutzer)] + assert namen == ["Weissmehl Typ 550"] + + nach_marke = [z.name for z in zuordnung_ziele(q="mühle nord", db=db, _=nutzer)] + assert nach_marke == ["Weissmehl Typ 550"] + + +def test_ziele_nennen_die_basiseinheit(db, nutzer): + """Damit man vor dem Zuordnen sieht, ob ml oder g geführt wird.""" + cat = _kategorie(db, "Kuehlregal") + _artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter) + + [ziel] = zuordnung_ziele(q="schlagrahm", db=db, _=nutzer) + + assert ziel.basis_einheit == "milliliter" + + +def test_ziele_enthalten_gegenstaende_aber_keine_einzelstuecke(db, nutzer): + """Raten darf nur Lebensmittel; von Hand zuordnen darf alles Zählbare. + + Backpapier ist kein Lebensmittel und steht trotzdem in Rezepten. Ein + Einzelstück dagegen ist ein benanntes Objekt und nie eine Zutat. + """ + sachen = _kategorie(db, "Haushalt", tracking=CategoryTracking.object) + _artikel(db, "Backpapier", sachen, base=BaseUnit.piece) + _artikel(db, "Backpapier-Spender", sachen, base=BaseUnit.piece, individual=True) + + namen = [z.name for z in zuordnung_ziele(q="backpapier", db=db, _=nutzer)] + + assert namen == ["Backpapier"] + + +def test_gruppen_stehen_vor_den_artikeln(db, nutzer): + """Ohne Suchtext zuerst die Gruppen – sie sind die bessere Zuordnung.""" + cat = _kategorie(db, "Kuehlregal") + _artikel(db, "Alpenmilch", cat, base=BaseUnit.milliliter) + db.add(Group(name="Trinkmilch")) + db.commit() + + arten = [z.art for z in zuordnung_ziele(db=db, _=nutzer)] + + assert arten[0] == "group" + + +# -------------------------------------------------------------------------- +# Routenreihenfolge +# -------------------------------------------------------------------------- +def test_zuordnungs_routen_stehen_vor_dem_slug(): + """Sonst liest FastAPI „zuordnungen" als Rezept-Slug und fragt Mealie. + + Die Reihenfolge der Deklaration entscheidet das – ein Umsortieren der + Datei würde den Fehler sonst still einbauen. + """ + pfade = [r.path for r in router.routes] + slug = pfade.index("/recipes/{slug}") + + assert pfade.index("/recipes/zuordnungen") < slug + assert pfade.index("/recipes/zuordnungen/ziele") < slug diff --git a/web/src/api.js b/web/src/api.js index ba06022..cb05de6 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -459,6 +459,14 @@ export const api = { }, recipe: (slug, servings) => request(`/recipes/${encodeURIComponent(slug)}${servings ? `?servings=${servings}` : ""}`), + // Zutaten von Hand zuordnen. Der Zutatenname ist der Schluessel - er reist + // als Abfrageparameter, weil Lebensmittelnamen Schraegstriche enthalten. + zuordnungen: () => request("/recipes/zuordnungen"), + zuordnungsZiele: (q) => + request(`/recipes/zuordnungen/ziele${q ? `?q=${encodeURIComponent(q)}` : ""}`), + setzeZuordnung: (body) => request("/recipes/zuordnungen", { method: "PUT", body }), + loescheZuordnung: (food) => + request(`/recipes/zuordnungen?food=${encodeURIComponent(food)}`, { method: "DELETE" }), listSettings: () => request("/settings"), setSetting: (key, value) => request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }), diff --git a/web/src/components/ZuordnungDialog.jsx b/web/src/components/ZuordnungDialog.jsx new file mode 100644 index 0000000..db9ffee --- /dev/null +++ b/web/src/components/ZuordnungDialog.jsx @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from "react"; +import { api } from "../api"; +import Icon from "./Icon"; + +/** + * Eine Rezeptzutat von Hand einem Artikel oder einer Gruppe zuordnen. + * + * Nötig, weil der automatische Abgleich raten muss: „Lime Curd" hat mit + * „Zitronencreme" kein Wort gemeinsam, und manchmal liegt er daneben. Was hier + * entschieden wird, merkt sich der Server unter dem Zutatennamen – es gilt ab + * dann für jedes Rezept, in dem diese Zutat vorkommt. + * + * Oben die Vorschläge, die der Abgleich selbst gefunden hat (meist ist der + * Richtige darunter, nur nicht an erster Stelle), darunter die freie Suche über + * alles. Die Basiseinheit steht dabei – damit man nicht wieder Milliliter auf + * ein Gramm-Produkt legt. + */ + +const BASIS_KURZ = { gram: "g", milliliter: "ml", piece: "Stk" }; + +export default function ZuordnungDialog({ zutat, onClose, onDone, onError }) { + const [suche, setSuche] = useState(zutat.food || ""); + const [ziele, setZiele] = useState([]); + const [laedt, setLaedt] = useState(false); + const [busy, setBusy] = useState(false); + const letzte = useRef(0); + + // Tippen soll nicht je Buchstabe eine Anfrage auslösen; 250 ms reichen, um + // eine Eingabe zu Ende zu schreiben. + useEffect(() => { + const id = setTimeout(async () => { + const lauf = ++letzte.current; + setLaedt(true); + try { + const daten = await api.zuordnungsZiele(suche); + if (lauf === letzte.current) setZiele(daten); + } catch (err) { + onError?.(err.message); + } finally { + if (lauf === letzte.current) setLaedt(false); + } + }, 250); + return () => clearTimeout(id); + }, [suche]); + + async function waehle(art, zielId) { + if (busy) return; + setBusy(true); + try { + await api.setzeZuordnung({ food: zutat.food, art, ziel_id: zielId }); + onClose(); + await onDone(); + } catch (err) { + onError?.(err.message); + } finally { + setBusy(false); + } + } + + async function aufheben() { + if (busy) return; + setBusy(true); + try { + await api.loescheZuordnung(zutat.food); + onClose(); + await onDone(); + } catch (err) { + onError?.(err.message); + } finally { + setBusy(false); + } + } + + const vorschlaege = (zutat.kandidaten || []).filter((k) => k.id !== zutat.ziel_id); + + return ( +
e.target === e.currentTarget && onClose()}> +
+

Zutat zuordnen

+

+ „{zutat.food || zutat.text}" gehört bei dir zu welchem Artikel? Die + Antwort gilt künftig für jedes Rezept mit dieser Zutat. +

+ + {zutat.ziel_name && ( +
+ + Derzeit: {zutat.art === "group" ? "Gruppe " : ""}{zutat.ziel_name} + {zutat.manuell ? " (von Hand)" : " (geraten)"} + +
+ )} + + {vorschlaege.length > 0 && ( +
+
+ Gefundene Vorschläge +
+
+ {vorschlaege.map((k) => ( + + ))} +
+
+ )} + +
+ + {laedt && Wird gesucht…} + {!laedt && ziele.length === 0 && ( + Dazu findet sich nichts. + )} +
    + {ziele.map((z) => ( +
  • + +
  • + ))} +
+
+ +
+ {zutat.manuell && ( + + )} + +
+
+
+ ); +} diff --git a/web/src/pages/Rezepte.jsx b/web/src/pages/Rezepte.jsx index f244a5c..3e782ab 100644 --- a/web/src/pages/Rezepte.jsx +++ b/web/src/pages/Rezepte.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import Icon from "../components/Icon"; +import ZuordnungDialog from "../components/ZuordnungDialog"; import { fmt } from "../units"; /** @@ -38,6 +39,8 @@ export default function Rezepte() { const [portionen, setPortionen] = useState(""); const [fehler, setFehler] = useState(null); const [laedt, setLaedt] = useState(true); + // Welche Zutat gerade von Hand zugeordnet wird (null = keine). + const [zuordnen, setZuordnen] = useState(null); async function ladeListe(q = "") { setLaedt(true); @@ -61,6 +64,12 @@ export default function Rezepte() { } catch (err) { setFehler(err.message); } } + // Nach einer Zuordnung muss der Abgleich neu laufen: Die Zutat hat jetzt ein + // Ziel, einen Bestand und einen Zustand. + async function neuRechnen() { + if (slug) await oeffne(slug, Number(String(portionen).replace(",", ".")) || undefined); + } + function setzePortionen(wert) { setPortionen(wert); const zahl = Number(String(wert).replace(",", ".")); @@ -157,9 +166,18 @@ export default function Rezepte() { {z.ziel_name && (
{z.art === "group" ? "Gruppe " : ""}{z.ziel_name} + {z.manuell && " · von Hand zugeordnet"}
)} {z.hinweis &&
{z.hinweis}
} + {z.food && ( + + )} {z.menge != null ? `${fmt(z.menge)} ${z.einheit}`.trim() : "–"} @@ -193,6 +211,11 @@ export default function Rezepte() { )} + + {zuordnen && ( + setZuordnen(null)} + onDone={neuRechnen} onError={setFehler} /> + )} ); }