diff --git a/backend/app/services/rezepte.py b/backend/app/services/rezepte.py index b69dfed..a955b71 100644 --- a/backend/app/services/rezepte.py +++ b/backend/app/services/rezepte.py @@ -21,7 +21,16 @@ from dataclasses import dataclass, field from sqlalchemy.orm import Session from ..models import Category, CategoryTracking, Group, Product -from .conversion import ConversionError, article_unit, group_min_context, to_base +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 @@ -40,6 +49,9 @@ class Kandidat: 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 @@ -203,7 +215,37 @@ def _kategorien_zur_zutat(db: Session, zutat_woerter: list[str]) -> set[int]: return passende -def _kandidaten(db: Session, name: str) -> list[Kandidat]: +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: @@ -247,11 +289,15 @@ def _kandidaten(db: Session, name: str) -> list[Kandidat]: for produkt in lebensmittel: voll = f"{produkt.name} {produkt.brand or ''}".strip() if _hat_anker(woerter, voll): - treffer.append(Kandidat("product", produkt.id, produkt.name, match_score(name, voll))) + k = Kandidat("product", produkt.id, produkt.name, match_score(name, voll)) + k.einheit_passt = _einheit_passt(db, produkt, einheit) + treffer.append(k) - # Gruppen bei Gleichstand vor Artikeln: „Milch" ist markenuebergreifend - # gemeint, und genau das ist eine Gruppe. - treffer.sort(key=lambda k: (-k.score, 0 if k.art == "group" else 1, k.name)) + # 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] @@ -319,14 +365,24 @@ def pruefe_zutaten( if gemerkt is not None: pruefung.art, pruefung.ziel_id = gemerkt else: - pruefung.kandidaten = _kandidaten(db, name) + pruefung.kandidaten = _kandidaten(db, name, pruefung.einheit) bester = pruefung.kandidaten[0] if pruefung.kandidaten else None - if bester is not 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" - pruefung.hinweis = f"'{name}' ist hier keinem Artikel zugeordnet." + if not pruefung.hinweis: + pruefung.hinweis = f"'{name}' ist hier keinem Artikel zugeordnet." ergebnis.append(pruefung) continue diff --git a/backend/tests/test_rezept_abgleich.py b/backend/tests/test_rezept_abgleich.py index 9939f4c..1516f0e 100644 --- a/backend/tests/test_rezept_abgleich.py +++ b/backend/tests/test_rezept_abgleich.py @@ -310,6 +310,57 @@ def test_beiwoerter_stoeren_den_echten_treffer_nicht(db): assert zutat.ziel_id == aepfel.id +def test_falsche_einheit_spricht_gegen_den_artikel(db): + """„200 ml Schlagsahne" ist kein „Kräuter Sahne Hähnchen".""" + fleisch = _kategorie(db, "Fleisch & Fisch") + _artikel(db, "Kräuter Sahne Hähnchen", fleisch, base=BaseUnit.gram, bestand=800) + + [zutat] = pruefe_zutaten(db, [_zutat("Schlagsahne", 200, "ml")]) + + assert zutat.status == "kein_treffer" + assert "nicht in 'ml' geführt" in zutat.hinweis + + +def test_passende_einheit_gewinnt_gegen_hoehere_punktzahl(db): + molk = _kategorie(db, "Molkereiprodukte") + fleisch = _kategorie(db, "Fleisch & Fisch") + _artikel(db, "Sahne Hähnchen", fleisch, base=BaseUnit.gram, bestand=800) + richtig = _artikel(db, "Schlagsahne Rahm", molk, base=BaseUnit.milliliter, bestand=500) + + [zutat] = pruefe_zutaten(db, [_zutat("Schlagsahne", 200, "ml")]) + + assert zutat.ziel_id == richtig.id + + +def test_fehlendes_hausmass_spricht_nicht_gegen_den_artikel(db): + """Nur eine BEKANNTE Einheit falscher Art ist ein Hinweis. + + „1 Tasse Linsen" heisst nicht, dass die Linsen falsch sind – es fehlt bloss + die Maßangabe, und die laesst sich nachtragen. + """ + cat = _kategorie(db, "Hülsenfrüchte") + linsen = _artikel(db, "Berglinsen", cat, bestand=1000) + + [zutat] = pruefe_zutaten(db, [_zutat("Berglinsen", 1, "Tasse")]) + + assert zutat.ziel_id == linsen.id + assert zutat.status == "menge_unbekannt" + + +def test_zweiteinheit_laesst_die_fremde_einheit_zu(db): + cat = _kategorie(db, "Backwaren") + broetchen = _artikel(db, "Brötchen", cat, base=BaseUnit.piece, package_size=6, + bestand=12) + broetchen.secondary_base = BaseUnit.gram.value + broetchen.secondary_count = 1 + broetchen.secondary_amount = 50 + db.commit() + + [zutat] = pruefe_zutaten(db, [_zutat("Brötchen", 100, "g")]) + + assert zutat.ziel_id == broetchen.id + + def test_umlaut_und_mehrzahl_finden_sich(db): """Rezept schreibt „Apfel", im Katalog stehen „Äpfel".""" cat = _kategorie(db, "Obst & Gemüse")