From 795e233a1b2095dc76bf5f1ce7f8b89b5f0cf068 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 2 Oct 2026 14:30:00 +0200 Subject: [PATCH] Einkaufsliste bekommt ein Gedaechtnis - und den Knopf aus dem Rezept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Einkaufsliste war vollstaendig GERECHNET: Artikel unter ihrem Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt, es ist die Staerke der Liste. Was fehlte, war ein Ort fuer "Milch gehoert drauf, weil ich das sage" - und damit auch fuer "diese Zutaten fehlen mir fuer das Rezept". Der Knopf konnte nirgends hinschreiben. Zwei Tabellen dafuer, und die Trennung ist der Kern: shopping_entries haelt, was jemand bewusst draufgesetzt hat. Genau eines von Artikel, Gruppe oder Freitext - der Freitext ist unverzichtbar, denn eine Rezeptzutat ohne Artikel ("Ras el Hanout") kommt nur so auf den Zettel, und gerade die unbekannte Zutat ist die, die man vergisst. shopping_checks haelt nur den HAKEN an einem gerechneten Posten, nicht den Posten. Die gerechneten Vorschlaege duerfen keine Zeile bekommen: Sie aendern sich mit jedem Einlagern, eine materialisierte Kopie waere sofort falsch. Abhaken ging deshalb bisher nur im Browser und war nach dem Neuladen weg - mitten im Supermarkt. Aus einem Rezept uebernommen wird die FEHLMENGE, nicht die Rezeptmenge: Wer 250 g braucht und 100 g hat, kauft 150 g. Gerechnet wird frisch mit der Portionszahl, die auf dem Schirm steht. Je Rezept und Ziel gibt es hoechstens einen offenen Eintrag - ein zweiter Druck nach dem Hochdrehen der Portionen rechnet die Menge neu, statt die Liste zu verdoppeln. Ueber verschiedene Rezepte hinweg bleiben es getrennte Zeilen, denn derselbe Artikel kann zweimal fehlen. Ein abgehakter Eintrag blockiert nichts - abgehakt heisst gekauft. Mengen formatiert dieselbe Funktion wie beim Rezeptabgleich, damit die Liste eine Sprache spricht: "2 Packungen" statt "1500 ml", aufgerundet, weil man keine halbe Packung kauft. In der Oberfläche eine eigene Karte "Selbst draufgesetzt" mit Eingabefeld, Herkunftsangabe ("fuer Pfannkuchen") und "Erledigte entfernen", das beide Quellen zusammen aufraeumt - fuer den Nutzer ist es eine Liste. Das Abhaken wandert sofort und speichert im Hintergrund; schlaegt es fehl, springt der Haken zurueck. Die Seite holt jetzt alles mit einem Aufruf statt mit drei. Von Hand geprueft gegen eine Mealie-Attrappe: Pfannkuchen fuer 48 Portionen ergibt 2 Packungen Mehl, 2 Packungen Milch, 3 Packungen Eier und "Ras el Hanout" als Notiz; derselbe Knopf bei 96 Portionen aktualisiert diese vier statt acht daraus zu machen; ein Haken ueberlebt das Neuladen. 17 neue Tests, Suite bei 512. Co-Authored-By: Claude Opus 5 --- backend/app/main.py | 4 + backend/app/models.py | 85 +++++ backend/app/routers/recipes.py | 41 ++- backend/app/routers/shopping.py | 150 +++++++++ backend/app/routers/views.py | 15 +- backend/app/schemas.py | 53 +++- backend/app/services/einkaufsliste.py | 287 +++++++++++++++++ backend/tests/test_einkaufsliste_eintraege.py | 290 ++++++++++++++++++ web/src/api.js | 21 ++ web/src/pages/Rezepte.jsx | 47 ++- web/src/pages/ShoppingList.jsx | 149 ++++++++- 11 files changed, 1121 insertions(+), 21 deletions(-) create mode 100644 backend/app/routers/shopping.py create mode 100644 backend/app/services/einkaufsliste.py create mode 100644 backend/tests/test_einkaufsliste_eintraege.py diff --git a/backend/app/main.py b/backend/app/main.py index 8fe75f0..a0a6581 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,6 +26,7 @@ from .routers import ( products, recipes, settings as settings_router, + shopping, shops, stock, transfer, @@ -320,6 +321,9 @@ app.include_router(groups.router) app.include_router(units.router) app.include_router(package_types.router) app.include_router(views.router) +# Nach views.router: Die gerechnete Liste steht dort, die gespeicherten +# Eintraege und Haken hier - gleicher Pfadanfang /shopping-list. +app.include_router(shopping.router) app.include_router(transfer.router) app.include_router(api_tokens.router) app.include_router(settings_router.router) diff --git a/backend/app/models.py b/backend/app/models.py index 13dd75b..a013e2d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -815,6 +815,91 @@ class IntegrationSecret(Base): ) +class ShoppingEntry(Base): + """Ein Eintrag, den jemand bewusst auf die Einkaufsliste gesetzt hat. + + Die Einkaufsliste war bis hierher vollstaendig **gerechnet**: Artikel unter + ihrem Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt + die Staerke der Liste und wird nicht ersetzt. Was fehlte, war ein Ort fuer + "Milch gehoert drauf, weil ich das sage" - und damit auch fuer "diese + Zutaten fehlen mir fuer das Rezept". + + Genau EINES von ``product_id``, ``group_id`` und ``free_text`` ist gesetzt. + Geprueft wird das beim Anlegen (services/einkaufsliste.py), nicht in der + Datenbank: Als CHECK-Bedingung waere es ueber drei nullbare Spalten + unleserlich, und die Fehlermeldung waere keine, die man jemandem zeigen + kann. Der Freitext ist unverzichtbar - fuer eine Rezeptzutat, die hier + keinem Artikel entspricht ("Ras el Hanout"), ist er die einzige Form, in + der sie auf den Zettel kommt. + + ``quantity_base`` steht in Basiseinheiten, wie alle Mengen im Projekt. Bei + einem Freitext bleibt sie leer: "etwas Hefe" hat keine Zahl. + + ``recipe_slug``/``recipe_name`` halten die Herkunft fest. Sie sind mehr als + Zierde: Derselbe Artikel kann fuer zwei Rezepte fehlen, und dann braucht man + ihn zweimal. Je Rezept und Ziel gibt es deshalb hoechstens einen offenen + Eintrag - ein zweiter Druck auf "fehlende Zutaten hinzufuegen" rechnet die + Menge neu, statt die Liste zu verdoppeln. + """ + + __tablename__ = "shopping_entries" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + 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 + ) + free_text: Mapped[str | None] = mapped_column(String(120), nullable=True) + quantity_base: Mapped[float | None] = mapped_column(Float, nullable=True) + # BaseUnit-Wert (piece/gram/milliliter) als String - wie ``Product + # .secondary_base``, damit kein Postgres-Enumtyp nachzuziehen ist. + base: Mapped[str | None] = mapped_column(String(16), nullable=True) + # "manual" | "recipe" + source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") + recipe_slug: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + recipe_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + done_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_by: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_now, nullable=False + ) + + product: Mapped[Product | None] = relationship() + group: Mapped[Group | None] = relationship() + + +class ShoppingCheck(Base): + """Ein abgehakter Posten der GERECHNETEN Einkaufsliste. + + Die gerechneten Vorschlaege haben keine Zeile in der Datenbank - sie + entstehen bei jedem Aufruf neu aus den Mindestbestaenden. Abhaken liess sich + deshalb nur im Browser merken und war nach dem Neuladen weg, mitten im + Supermarkt. + + Gemerkt wird hier nur der Schluessel des Postens, nicht der Posten selbst: + ``p12`` (Artikel), ``g3`` (Gruppe), ``l KUECHE p12`` (Artikel an einem + Lagerort) - dieselben Schluessel, die die Oberflaeche ohnehin bildet. Damit + bleibt die Liste gerechnet, und das Abhaken haengt sich nur daran. Sinkt der + Bestand spaeter wieder, taucht der Posten mit seinem alten Haken auf; das + Aufraeumen erledigt "Erledigte entfernen". + """ + + __tablename__ = "shopping_checks" + + key: Mapped[str] = mapped_column(String(80), primary_key=True) + checked_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_now, nullable=False + ) + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + + class MealieFoodMap(Base): """Gemerkte Zuordnung: Mealie-Lebensmittel X ist bei uns Artikel/Gruppe Y. diff --git a/backend/app/routers/recipes.py b/backend/app/routers/recipes.py index 3932518..c05096b 100644 --- a/backend/app/routers/recipes.py +++ b/backend/app/routers/recipes.py @@ -17,15 +17,17 @@ from ..schemas import ( RecipeDetail, RecipePage, RecipeSummary, + ShoppingFromRecipe, ZielVorschlag, ZuordnungIn, ZuordnungOut, ZutatKandidat, ZutatOut, ) -from ..services import mealie, rezepte, zuordnung +from ..services import einkaufsliste, mealie, rezepte, zuordnung from ..services.conversion import group_min_context from ..services.einkaufsliste_text import fehlmengen_text +from .shopping import eintrag_out from ..services.matching import match_score, normalisieren router = APIRouter(prefix="/recipes", tags=["recipes"]) @@ -229,6 +231,43 @@ def get_recipe( ) +@router.post("/{slug}/to-shopping-list", response_model=ShoppingFromRecipe) +def recipe_to_shopping_list( + slug: str, + servings: float | None = None, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> ShoppingFromRecipe: + """Was für dieses Rezept fehlt, auf die Einkaufsliste setzen. + + Gerechnet wird dabei frisch — mit derselben Portionszahl, die auf dem + Bildschirm steht. Sonst landete die Menge für vier Portionen auf der Liste, + während die Seite zwölf anzeigt. + + Übernommen wird die **Fehlmenge**, nicht die Rezeptmenge: Wer 250 g braucht + und 100 g hat, kauft 150 g. Zutaten ohne Zuordnung kommen als Freitext drauf + — gerade die unbekannte Zutat ist die, die man vergisst. + """ + try: + daten = mealie.rezept(db, slug) + except mealie.MealieFehler as fehler: + raise _fehler(fehler) from fehler + + faktor, _ = rezepte.faktor_fuer_portionen(daten, servings) + zutaten = daten.get("recipeIngredient") or daten.get("recipe_ingredient") or [] + pruefungen = rezepte.pruefe_zutaten( + db, zutaten, faktor=faktor, zuordnung=zuordnung.lade(db), + ) + eintraege, aktualisiert = einkaufsliste.aus_rezept( + db, pruefungen, slug=slug, name=str(daten.get("name") or slug), user=user, + ) + return ShoppingFromRecipe( + entries=[eintrag_out(db, e) for e in eintraege], + aktualisiert=aktualisiert, + freitext=sum(1 for e in eintraege if e.free_text), + ) + + def _zutat_out(db: Session, p: rezepte.ZutatPruefung) -> ZutatOut: return ZutatOut( text=p.text, menge=p.menge, einheit=p.einheit, diff --git a/backend/app/routers/shopping.py b/backend/app/routers/shopping.py new file mode 100644 index 0000000..b86787d --- /dev/null +++ b/backend/app/routers/shopping.py @@ -0,0 +1,150 @@ +"""Die gespeicherte Seite der Einkaufsliste: eigene Einträge und Haken. + +Die gerechnete Liste steht in ``routers/views.py`` und bleibt dort. Dieser +Router verwaltet nur, was jemand selbst draufgesetzt hat — und das Abhaken, das +bisher im Browser verfiel und nach dem Neuladen mitten im Supermarkt weg war. +""" + +# 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 ShoppingEntry, User +from ..schemas import ShoppingEntryIn, ShoppingEntryOut, ShoppingEntryPatch +from ..services import einkaufsliste + +router = APIRouter(prefix="/shopping-list", tags=["shopping"]) + + +def eintrag_out(db: Session, eintrag: ShoppingEntry) -> ShoppingEntryOut: + art = ( + "product" if eintrag.product_id is not None + else "group" if eintrag.group_id is not None + else "text" + ) + return ShoppingEntryOut( + id=eintrag.id, art=art, + product_id=eintrag.product_id, group_id=eintrag.group_id, + free_text=eintrag.free_text, + name=einkaufsliste.bezeichnung(db, eintrag), + quantity_base=eintrag.quantity_base, base=eintrag.base, + menge_text=einkaufsliste.mengen_text(db, eintrag), + source=eintrag.source, recipe_slug=eintrag.recipe_slug, + recipe_name=eintrag.recipe_name, done=eintrag.done, + created_at=eintrag.created_at, + ) + + +@router.get("/entries", response_model=list[ShoppingEntryOut]) +def list_entries( + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[ShoppingEntryOut]: + """Die eigenen Einträge, offene zuerst.""" + return [eintrag_out(db, e) for e in einkaufsliste.eintraege(db)] + + +@router.post("/entries", response_model=ShoppingEntryOut, + status_code=status.HTTP_201_CREATED) +def create_entry( + payload: ShoppingEntryIn, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> ShoppingEntryOut: + """Etwas auf die Liste setzen – Artikel, Gruppe oder einfach Text.""" + try: + eintrag = einkaufsliste.anlegen( + db, product_id=payload.product_id, group_id=payload.group_id, + free_text=payload.free_text, quantity_base=payload.quantity_base, + base=(payload.base.value if payload.base is not None else None), + user=user, + ) + except einkaufsliste.EintragFehler as fehler: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler + return eintrag_out(db, eintrag) + + +@router.patch("/entries/{entry_id}", response_model=ShoppingEntryOut) +def update_entry( + entry_id: int, + payload: ShoppingEntryPatch, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> ShoppingEntryOut: + """Abhaken oder die Menge korrigieren.""" + eintrag = db.get(ShoppingEntry, entry_id) + if eintrag is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden") + if payload.quantity_base is not None: + eintrag.quantity_base = payload.quantity_base + if payload.free_text is not None: + if eintrag.free_text is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Dieser Eintrag zeigt auf einen Artikel – sein Text lässt sich " + "nicht ändern.", + ) + eintrag.free_text = payload.free_text.strip()[:120] + if payload.done is not None: + einkaufsliste.abhaken(db, eintrag, payload.done) + else: + db.commit() + db.refresh(eintrag) + return eintrag_out(db, eintrag) + + +@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_entry( + entry_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> None: + eintrag = db.get(ShoppingEntry, entry_id) + if eintrag is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden") + einkaufsliste.loeschen(db, eintrag) + + +@router.post("/clear-done", response_model=int) +def clear_done( + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> int: + """Nach dem Einkauf aufräumen: alles Abgehakte weg. + + Bewusst ein POST und kein DELETE auf einer Sammlung: Es löscht zwei + verschiedene Dinge (eigene Einträge und Haken an gerechneten Posten), und + das ist eine Handlung, keine Ressource. + """ + return einkaufsliste.erledigte_entfernen(db) + + +@router.put("/checks/{key}", status_code=status.HTTP_204_NO_CONTENT) +def set_check( + key: str, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> None: + """Einen gerechneten Posten abhaken – dauerhaft. + + Der Schlüssel kommt aus der Oberfläche („p12", „g3", „lKUECHEp12"). Der + Server prüft ihn nicht gegen die gerechnete Liste: Sie ändert sich mit jedem + Einlagern, und ein Haken auf einen Posten, der gerade nicht mehr fehlt, + schadet nichts — er verschwindet mit „Erledigte entfernen". + """ + try: + einkaufsliste.haken_setzen(db, key, user) + except einkaufsliste.EintragFehler as fehler: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler + + +@router.delete("/checks/{key}", status_code=status.HTTP_204_NO_CONTENT) +def clear_check( + key: str, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> None: + einkaufsliste.haken_loesen(db, key) diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index c30ea4a..5b73b4f 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -42,7 +42,9 @@ from ..services.conversion import ( display_unit_info, group_min_context, ) +from ..services import einkaufsliste from ..services import gruppen as gruppen_graph +from .shopping import eintrag_out from ..services.stock import ( current_stock, descendant_location_ids, @@ -469,13 +471,20 @@ def shopping_list_by_location( def shopping_list_all( db: Session = Depends(get_db), user: User = Depends(get_current_user) ) -> ShoppingListAll: - """Komplette Einkaufsliste in einem Aufruf: Produkte, Gruppen und Bedarfe je - Lagerort – genau die drei Quellen, die auch die Oberfläche zusammenführt. - Erspart drei getrennte Abfragen.""" + """Komplette Einkaufsliste in einem Aufruf: die gerechneten Bedarfe + (Produkte, Gruppen, je Lagerort), die gespeicherten eigenen Einträge und die + dauerhaft gesetzten Haken. Erspart der Oberfläche fünf Abfragen. + + Gerechnet und gespeichert stehen bewusst nebeneinander: Die Bedarfe aus den + Mindestbeständen entstehen bei jedem Aufruf neu, die eigenen Einträge + bleiben liegen, bis sie abgehakt sind. + """ return ShoppingListAll( products=shopping_list(db, user), groups=group_shopping_list(db, user), by_location=shopping_list_by_location(db, user), + entries=[eintrag_out(db, e) for e in einkaufsliste.eintraege(db)], + checked=einkaufsliste.abgehakte(db), ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 16d0e02..417f4de 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1254,12 +1254,61 @@ class LocationNeeds(BaseModel): groups: list[LocationNeedGroup] = [] +# ---- Gespeicherte Einkaufsliste ---- +class ShoppingEntryIn(BaseModel): + """Ein eigener Eintrag. Genau eines von Artikel, Gruppe, Freitext.""" + product_id: int | None = None + group_id: int | None = None + free_text: str | None = Field(default=None, max_length=120) + quantity_base: float | None = Field(default=None, gt=0) + base: BaseUnit | None = None + + +class ShoppingEntryPatch(BaseModel): + """Abhaken oder die Menge korrigieren.""" + done: bool | None = None + quantity_base: float | None = Field(default=None, gt=0) + free_text: str | None = Field(default=None, max_length=120) + + +class ShoppingEntryOut(BaseModel): + """Ein gespeicherter Eintrag, fertig für die Liste.""" + id: int + art: str # product | group | text + product_id: int | None = None + group_id: int | None = None + free_text: str | None = None + name: str = "" + quantity_base: float | None = None + base: BaseUnit | None = None + # „2 Packungen" bzw. „150 g" – dieselbe Sprache wie die gerechneten Posten. + menge_text: str = "" + source: str = "manual" # manual | recipe + recipe_slug: str | None = None + recipe_name: str | None = None + done: bool = False + created_at: datetime + + +class ShoppingFromRecipe(BaseModel): + """Was die Übernahme eines Rezepts bewirkt hat.""" + entries: list[ShoppingEntryOut] = [] + # Wie viele davon schon dastanden und nur neu gerechnet wurden. + aktualisiert: int = 0 + # Zutaten ohne Zuordnung, die als Freitext draufkamen. + freitext: int = 0 + + class ShoppingListAll(BaseModel): - """Komplette Einkaufsliste in einem Aufruf – dieselben drei Quellen, die auch - die Oberfläche zusammenführt.""" + """Komplette Einkaufsliste in einem Aufruf – die gerechneten Quellen, die + eigenen Einträge und die dauerhaft gesetzten Haken.""" products: list[ShoppingItem] = [] groups: list[GroupShoppingItem] = [] by_location: list[LocationNeeds] = [] + # Eigene und aus Rezepten übernommene Einträge. + entries: list[ShoppingEntryOut] = [] + # Schlüssel der abgehakten GERECHNETEN Posten („p12", „g3", „lKUECHEp12"). + checked: list[str] = [] class LocationContentEntry(BaseModel): diff --git a/backend/app/services/einkaufsliste.py b/backend/app/services/einkaufsliste.py new file mode 100644 index 0000000..2951cf5 --- /dev/null +++ b/backend/app/services/einkaufsliste.py @@ -0,0 +1,287 @@ +"""Die gespeicherte Seite der Einkaufsliste. + +Gerechnet wird die Liste in ``routers/views.py`` – Artikel unter ihrem +Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt so. Hier +liegt daneben das, was jemand **bewusst** draufgesetzt hat: eigene Einträge, +übernommene Rezeptzutaten, und das dauerhafte Abhaken der gerechneten +Vorschläge. + +Beides getrennt zu halten ist Absicht. Die gerechneten Posten dürfen keine +Zeilen in der Datenbank bekommen: Sie ändern sich mit jedem Einlagern, und eine +materialisierte Kopie würde sofort von der Wirklichkeit abweichen. Was gemerkt +wird, ist nur der Haken daran. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from ..models import ( + Group, + Product, + ShoppingCheck, + ShoppingEntry, + User, +) +from .einkaufsliste_text import fehlmengen_text + + +class EintragFehler(ValueError): + """Unbrauchbarer Eintrag – der Router macht daraus eine 400.""" + + +def _jetzt() -> datetime: + return datetime.now(timezone.utc) + + +# -------------------------------------------------------------------------- +# Eigene Einträge +# -------------------------------------------------------------------------- +def eintraege(db: Session, *, mit_erledigten: bool = True) -> list[ShoppingEntry]: + """Alle gespeicherten Einträge, offene zuerst. + + Zeigt ein Eintrag auf einen Artikel oder eine Gruppe, die es nicht mehr + gibt, fällt er hier still heraus: Auf SQLite greift kein Fremdschlüssel, und + einen gelöschten Artikel kann man nicht einkaufen. + """ + reihen = db.query(ShoppingEntry).order_by( + ShoppingEntry.done, ShoppingEntry.created_at + ).all() + gueltig = [e for e in reihen if _ziel_existiert(db, e)] + if mit_erledigten: + return gueltig + return [e for e in gueltig if not e.done] + + +def _ziel_existiert(db: Session, eintrag: ShoppingEntry) -> bool: + if eintrag.product_id is not None: + return db.get(Product, eintrag.product_id) is not None + if eintrag.group_id is not None: + return db.get(Group, eintrag.group_id) is not None + return bool(eintrag.free_text) + + +def bezeichnung(db: Session, eintrag: ShoppingEntry) -> str: + """Was auf dem Zettel steht.""" + if eintrag.product_id is not None: + produkt = db.get(Product, eintrag.product_id) + return produkt.name if produkt is not None else "" + if eintrag.group_id is not None: + gruppe = db.get(Group, eintrag.group_id) + return gruppe.name if gruppe is not None else "" + return eintrag.free_text or "" + + +@dataclass +class _Menge: + """Die Form, die ``fehlmengen_text`` erwartet. + + Umweg mit Absicht: So formatiert die Einkaufsliste ihre Mengen mit genau + derselben Funktion wie der Rezeptabgleich („2 Packungen" statt „1000 ml", + aufgerundet auf ganze Gebinde). Zwei Formatierungen wuerden auseinander + laufen. + """ + + art: str + ziel_id: int | None + fehlt_base: float | None + basis_einheit: str + + +def mengen_text(db: Session, eintrag: ShoppingEntry) -> str: + """„2 Packungen" bzw. „150 g" – leer, wenn keine Menge dransteht.""" + if not eintrag.quantity_base: + return "" + art = "product" if eintrag.product_id is not None else "group" + return fehlmengen_text(db, _Menge( + art=art, ziel_id=eintrag.product_id or eintrag.group_id, + fehlt_base=eintrag.quantity_base, basis_einheit=eintrag.base or "", + )) + + +def anlegen( + db: Session, + *, + product_id: int | None = None, + group_id: int | None = None, + free_text: str | None = None, + quantity_base: float | None = None, + base: str | None = None, + source: str = "manual", + recipe_slug: str | None = None, + recipe_name: str | None = None, + user: User | None = None, +) -> ShoppingEntry: + """Einen Eintrag anlegen. Genau ein Ziel, und es muss existieren.""" + text = (free_text or "").strip() or None + ziele = [z for z in (product_id, group_id, text) if z is not None] + if len(ziele) != 1: + raise EintragFehler( + "Ein Eintrag zeigt auf genau eines: einen Artikel, eine Gruppe oder " + "einen freien Text." + ) + if product_id is not None and db.get(Product, product_id) is None: + raise EintragFehler("Diesen Artikel gibt es nicht.") + if group_id is not None and db.get(Group, group_id) is None: + raise EintragFehler("Diese Gruppe gibt es nicht.") + if quantity_base is not None and quantity_base <= 0: + raise EintragFehler("Eine Menge muss größer als 0 sein.") + + eintrag = ShoppingEntry( + product_id=product_id, group_id=group_id, free_text=text, + quantity_base=quantity_base, base=base, source=source, + recipe_slug=recipe_slug, recipe_name=recipe_name, + created_by=(user.id if user is not None else None), + ) + db.add(eintrag) + db.commit() + db.refresh(eintrag) + return eintrag + + +def abhaken(db: Session, eintrag: ShoppingEntry, done: bool) -> ShoppingEntry: + eintrag.done = done + eintrag.done_at = _jetzt() if done else None + db.commit() + db.refresh(eintrag) + return eintrag + + +def loeschen(db: Session, eintrag: ShoppingEntry) -> None: + db.delete(eintrag) + db.commit() + + +def erledigte_entfernen(db: Session) -> int: + """Alles Abgehakte weg – eigene Einträge und die Haken an den gerechneten. + + Ein Durchgang nach dem Einkauf. Bewusst beides zusammen: Für den Nutzer ist + es eine Liste, auch wenn sie aus zwei Quellen kommt. + """ + anzahl = 0 + for eintrag in db.query(ShoppingEntry).filter(ShoppingEntry.done.is_(True)).all(): + db.delete(eintrag) + anzahl += 1 + for haken in db.query(ShoppingCheck).all(): + db.delete(haken) + anzahl += 1 + db.commit() + return anzahl + + +# -------------------------------------------------------------------------- +# Haken an den gerechneten Posten +# -------------------------------------------------------------------------- +def abgehakte(db: Session) -> list[str]: + return [h.key for h in db.query(ShoppingCheck).all()] + + +def haken_setzen(db: Session, key: str, user: User | None = None) -> None: + sauber = (key or "").strip()[:80] + if not sauber: + raise EintragFehler("Ohne Kennung lässt sich kein Haken merken.") + if db.get(ShoppingCheck, sauber) is None: + db.add(ShoppingCheck( + key=sauber, user_id=(user.id if user is not None else None), + )) + db.commit() + + +def haken_loesen(db: Session, key: str) -> None: + haken = db.get(ShoppingCheck, (key or "").strip()[:80]) + if haken is not None: + db.delete(haken) + db.commit() + + +# -------------------------------------------------------------------------- +# Aus einem Rezept übernehmen +# -------------------------------------------------------------------------- +def aus_rezept( + db: Session, + pruefungen: list, + *, + slug: str, + name: str, + user: User | None = None, +) -> tuple[list[ShoppingEntry], int]: + """Was für dieses Rezept fehlt, auf die Liste setzen. + + Übernommen wird, was fehlt oder zu wenig ist — mit der **Fehlmenge**, nicht + der Rezeptmenge: Wer 250 g braucht und 100 g hat, kauft 150 g. + + Zutaten ohne Zuordnung kommen als Freitext drauf. Das ist der wichtigere + Teil, als es klingt: Gerade die unbekannte Zutat ist die, die man vergisst. + + Je Rezept und Ziel gibt es höchstens einen offenen Eintrag. Ein zweiter + Druck auf den Knopf — etwa nach dem Hochdrehen der Portionen — rechnet die + Menge neu, statt die Liste zu verdoppeln. Über verschiedene Rezepte hinweg + bleiben es getrennte Zeilen, denn derselbe Artikel kann zweimal fehlen. + + Rückgabe: die betroffenen Einträge und wie viele davon aktualisiert (statt + neu angelegt) wurden. + """ + betroffen: list[ShoppingEntry] = [] + aktualisiert = 0 + + for p in pruefungen: + if p.status in ("fehlt", "zu_wenig"): + menge = p.fehlt_base if p.fehlt_base else None + vorhanden = _offener_eintrag( + db, slug, product_id=(p.ziel_id if p.art == "product" else None), + group_id=(p.ziel_id if p.art == "group" else None), + ) + if vorhanden is not None: + vorhanden.quantity_base = menge + vorhanden.base = p.basis_einheit or None + aktualisiert += 1 + betroffen.append(vorhanden) + continue + betroffen.append(anlegen( + db, + product_id=(p.ziel_id if p.art == "product" else None), + group_id=(p.ziel_id if p.art == "group" else None), + quantity_base=menge, base=p.basis_einheit or None, + source="recipe", recipe_slug=slug, recipe_name=name, user=user, + )) + elif p.status == "kein_treffer": + text = (p.food or p.text or "").strip()[:120] + if not text: + continue + vorhanden = _offener_eintrag(db, slug, free_text=text) + if vorhanden is not None: + aktualisiert += 1 + betroffen.append(vorhanden) + continue + betroffen.append(anlegen( + db, free_text=text, source="recipe", + recipe_slug=slug, recipe_name=name, user=user, + )) + + db.commit() + return betroffen, aktualisiert + + +def _offener_eintrag( + db: Session, + slug: str, + *, + product_id: int | None = None, + group_id: int | None = None, + free_text: str | None = None, +) -> ShoppingEntry | None: + """Ein noch nicht abgehakter Eintrag dieses Rezepts für dasselbe Ziel.""" + frage = db.query(ShoppingEntry).filter( + ShoppingEntry.recipe_slug == slug, + ShoppingEntry.done.is_(False), + ) + if product_id is not None: + frage = frage.filter(ShoppingEntry.product_id == product_id) + elif group_id is not None: + frage = frage.filter(ShoppingEntry.group_id == group_id) + else: + frage = frage.filter(ShoppingEntry.free_text == free_text) + return frage.first() diff --git a/backend/tests/test_einkaufsliste_eintraege.py b/backend/tests/test_einkaufsliste_eintraege.py new file mode 100644 index 0000000..a611f5a --- /dev/null +++ b/backend/tests/test_einkaufsliste_eintraege.py @@ -0,0 +1,290 @@ +"""Die Einkaufsliste bekommt ein Gedächtnis: eigene Einträge, Haken, Rezepte. + +Bisher war die Liste vollständig gerechnet. Was hier geprüft wird, ist das +Daneben: Dinge, die jemand selbst draufgesetzt hat, das dauerhafte Abhaken, und +die Übernahme der fehlenden Zutaten eines Rezepts — ohne Mealie, die Zutaten +stehen direkt im Test. +""" + +import pytest +from fastapi import HTTPException + +from app.models import ( + BaseUnit, + Category, + CategoryTracking, + Product, + ProductLocationMinStock, + Role, + ShoppingCheck, + ShoppingEntry, + User, +) +from app.routers.shopping import ( + clear_check, + clear_done, + create_entry, + delete_entry, + list_entries, + set_check, + update_entry, +) +from app.routers.views import shopping_list_all +from app.schemas import ShoppingEntryIn, ShoppingEntryPatch +from app.services import einkaufsliste +from app.services.rezepte import pruefe_zutaten +from app.services.stock import check_in + + +@pytest.fixture() +def nutzer(db): + person = User(username="einkaeufer", 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="Vorrat"): + cat = Category(name=name, tracking=CategoryTracking.food.value) + db.add(cat) + db.commit() + db.refresh(cat) + return cat + + +def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000, bestand=0.0): + produkt = Product(name=name, base_unit=base, package_size=package_size, + category_id=cat.id, source="manual") + db.add(produkt) + db.commit() + db.refresh(produkt) + if bestand: + check_in(db, produkt, bestand, base.value, None, None, None) + db.commit() + return produkt + + +def _uebernehmen(db, zutaten, nutzer, *, slug="pfannkuchen", name="Pfannkuchen"): + """Wie der Endpunkt, nur ohne Mealie davor.""" + pruefungen = pruefe_zutaten(db, zutaten) + return einkaufsliste.aus_rezept(db, pruefungen, slug=slug, name=name, user=nutzer) + + +# -------------------------------------------------------------------------- +# Eigene Einträge +# -------------------------------------------------------------------------- +def test_freitext_landet_auf_der_liste(db, nutzer): + """Der Fall, für den es bisher keinen Ort gab: 'Milch, weil ich das sage'.""" + create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer) + + [eintrag] = list_entries(db=db, _=nutzer) + + assert eintrag.art == "text" + assert eintrag.name == "Backpapier" + assert eintrag.done is False + + +def test_artikel_eintrag_nennt_die_menge_lesbar(db, nutzer): + cat = _kategorie(db) + mehl = _artikel(db, "Weissmehl", cat) + + eintrag = create_entry( + ShoppingEntryIn(product_id=mehl.id, quantity_base=1500, base=BaseUnit.gram), + db=db, user=nutzer, + ) + + assert eintrag.name == "Weissmehl" + # Man kauft keine halbe Packung - dieselbe Sprache wie die gerechnete Liste. + assert eintrag.menge_text == "2 Packungen" + + +def test_genau_ein_ziel_ist_pflicht(db, nutzer): + cat = _kategorie(db) + mehl = _artikel(db, "Weissmehl", cat) + + with pytest.raises(HTTPException) as leer: + create_entry(ShoppingEntryIn(), db=db, user=nutzer) + assert leer.value.status_code == 400 + + with pytest.raises(HTTPException) as doppelt: + create_entry( + ShoppingEntryIn(product_id=mehl.id, free_text="Mehl"), db=db, user=nutzer, + ) + assert doppelt.value.status_code == 400 + assert db.query(ShoppingEntry).count() == 0 + + +def test_abhaken_haelt_das_neuladen_aus(db, nutzer): + """Der eigentliche Punkt: Im Supermarkt wird die Seite neu geladen.""" + eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer) + + update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer) + + [frisch] = list_entries(db=db, _=nutzer) + assert frisch.done is True + assert db.get(ShoppingEntry, eintrag.id).done_at is not None + + +def test_text_eines_artikeleintrags_laesst_sich_nicht_umschreiben(db, nutzer): + """Sonst stünde im Eintrag ein Name, der nichts mit dem Artikel zu tun hat.""" + cat = _kategorie(db) + mehl = _artikel(db, "Weissmehl", cat) + eintrag = create_entry(ShoppingEntryIn(product_id=mehl.id), db=db, user=nutzer) + + with pytest.raises(HTTPException) as fehler: + update_entry(eintrag.id, ShoppingEntryPatch(free_text="Zucker"), db=db, _=nutzer) + + assert fehler.value.status_code == 400 + + +def test_eintrag_auf_einen_geloeschten_artikel_faellt_heraus(db, nutzer): + cat = _kategorie(db) + mehl = _artikel(db, "Weissmehl", cat) + create_entry(ShoppingEntryIn(product_id=mehl.id), db=db, user=nutzer) + + db.delete(mehl) + db.commit() + + assert list_entries(db=db, _=nutzer) == [] + + +def test_loeschen_entfernt_den_eintrag(db, nutzer): + eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer) + + delete_entry(eintrag.id, db=db, _=nutzer) + + assert db.query(ShoppingEntry).count() == 0 + + +# -------------------------------------------------------------------------- +# Haken an den gerechneten Posten +# -------------------------------------------------------------------------- +def test_haken_am_gerechneten_posten_bleibt(db, nutzer): + """Gerechnete Posten haben keine Zeile - gemerkt wird nur der Haken.""" + set_check("p12", db=db, user=nutzer) + + assert einkaufsliste.abgehakte(db) == ["p12"] + + clear_check("p12", db=db, _=nutzer) + assert einkaufsliste.abgehakte(db) == [] + + +def test_derselbe_haken_zweimal_ist_kein_fehler(db, nutzer): + set_check("g3", db=db, user=nutzer) + set_check("g3", db=db, user=nutzer) + + assert db.query(ShoppingCheck).count() == 1 + + +def test_erledigte_entfernen_raeumt_beide_quellen(db, nutzer): + eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer) + update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer) + offen = create_entry(ShoppingEntryIn(free_text="Salz"), db=db, user=nutzer) + set_check("p12", db=db, user=nutzer) + + anzahl = clear_done(db=db, _=nutzer) + + assert anzahl == 2 + assert [e.id for e in list_entries(db=db, _=nutzer)] == [offen.id] + assert einkaufsliste.abgehakte(db) == [] + + +def test_die_gebuendelte_liste_fuehrt_beides(db, nutzer): + cat = _kategorie(db) + mehl = _artikel(db, "Weissmehl", cat, bestand=200) + db.add(ProductLocationMinStock(product_id=mehl.id, location_id=None, min_stock=1000)) + db.commit() + create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer) + set_check(f"p{mehl.id}", db=db, user=nutzer) + + alles = shopping_list_all(db=db, user=nutzer) + + assert [i.name for i in alles.products] == ["Weissmehl"] + assert [e.name for e in alles.entries] == ["Backpapier"] + assert alles.checked == [f"p{mehl.id}"] + + +# -------------------------------------------------------------------------- +# Aus einem Rezept übernehmen +# -------------------------------------------------------------------------- +def test_uebernommen_wird_die_fehlmenge_nicht_die_rezeptmenge(db, nutzer): + """Wer 250 g braucht und 100 g hat, kauft 150 g.""" + cat = _kategorie(db) + _artikel(db, "Weissmehl", cat, bestand=100) + + eintraege, aktualisiert = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + + assert aktualisiert == 0 + [eintrag] = eintraege + assert eintrag.quantity_base == 150 + assert eintrag.source == "recipe" + assert eintrag.recipe_name == "Pfannkuchen" + + +def test_vorhandene_zutaten_kommen_nicht_drauf(db, nutzer): + cat = _kategorie(db) + _artikel(db, "Weissmehl", cat, bestand=5000) + + eintraege, _ = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + + assert eintraege == [] + assert db.query(ShoppingEntry).count() == 0 + + +def test_zutat_ohne_zuordnung_kommt_als_freitext_drauf(db, nutzer): + """Gerade die unbekannte Zutat ist die, die man vergisst.""" + _kategorie(db) + + eintraege, _ = _uebernehmen(db, [_zutat("Ras el Hanout", 1, "Prise")], nutzer) + + [eintrag] = eintraege + assert eintrag.free_text == "Ras el Hanout" + assert eintrag.product_id is None + + +def test_zweimal_uebernehmen_verdoppelt_die_liste_nicht(db, nutzer): + """Nach dem Hochdrehen der Portionen drückt man den Knopf noch einmal.""" + cat = _kategorie(db) + _artikel(db, "Weissmehl", cat, bestand=100) + + _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + eintraege, aktualisiert = _uebernehmen(db, [_zutat("Weissmehl", 500, "g")], nutzer) + + assert aktualisiert == 1 + assert db.query(ShoppingEntry).count() == 1 + assert eintraege[0].quantity_base == 400 # 500 gebraucht, 100 da + + +def test_zwei_rezepte_brauchen_denselben_artikel_zweimal(db, nutzer): + cat = _kategorie(db) + _artikel(db, "Weissmehl", cat, bestand=0) + + _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + _uebernehmen(db, [_zutat("Weissmehl", 300, "g")], nutzer, + slug="brot", name="Brot") + + zeilen = db.query(ShoppingEntry).all() + assert len(zeilen) == 2 + assert {z.recipe_name for z in zeilen} == {"Pfannkuchen", "Brot"} + + +def test_ein_abgehakter_eintrag_blockiert_die_neue_uebernahme_nicht(db, nutzer): + """Abgehakt heisst gekauft – braucht man es wieder, ist es eine neue Zeile.""" + cat = _kategorie(db) + _artikel(db, "Weissmehl", cat, bestand=0) + eintraege, _ = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + einkaufsliste.abhaken(db, db.get(ShoppingEntry, eintraege[0].id), True) + + _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer) + + assert db.query(ShoppingEntry).count() == 2 diff --git a/web/src/api.js b/web/src/api.js index e72fe91..cd878bb 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -307,6 +307,21 @@ export const api = { // Views shoppingList: () => request("/shopping-list"), + // Alles in einem Aufruf: die gerechneten Bedarfe, die gespeicherten Eintraege + // und die dauerhaft gesetzten Haken. + shoppingAll: () => request("/shopping-list/all"), + addShoppingEntry: (body) => request("/shopping-list/entries", { method: "POST", body }), + updateShoppingEntry: (id, body) => + request(`/shopping-list/entries/${id}`, { method: "PATCH", body }), + deleteShoppingEntry: (id) => + request(`/shopping-list/entries/${id}`, { method: "DELETE" }), + clearDoneShopping: () => request("/shopping-list/clear-done", { method: "POST" }), + // Haken an einem GERECHNETEN Posten. Der Schluessel kommt von hier ("p12", + // "g3", "lKUECHEp12") - der Server haengt ihn nur daran. + setShoppingCheck: (key) => + request(`/shopping-list/checks/${encodeURIComponent(key)}`, { method: "PUT" }), + clearShoppingCheck: (key) => + request(`/shopping-list/checks/${encodeURIComponent(key)}`, { method: "DELETE" }), groupShoppingList: () => request("/shopping-list/groups"), shoppingByLocation: () => request("/shopping-list/by-location"), expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`), @@ -475,6 +490,12 @@ export const api = { zuordnungsZiele: (q) => request(`/recipes/zuordnungen/ziele${q ? `?q=${encodeURIComponent(q)}` : ""}`), setzeZuordnung: (body) => request("/recipes/zuordnungen", { method: "PUT", body }), + // Fehlende Zutaten uebernehmen - mit der Portionszahl, die auf dem Schirm steht. + rezeptAufEinkaufsliste: (slug, servings) => + request( + `/recipes/${encodeURIComponent(slug)}/to-shopping-list${servings ? `?servings=${servings}` : ""}`, + { method: "POST" }, + ), loescheZuordnung: (food) => request(`/recipes/zuordnungen?food=${encodeURIComponent(food)}`, { method: "DELETE" }), listSettings: () => request("/settings"), diff --git a/web/src/pages/Rezepte.jsx b/web/src/pages/Rezepte.jsx index a6e5429..0e0367e 100644 --- a/web/src/pages/Rezepte.jsx +++ b/web/src/pages/Rezepte.jsx @@ -3,6 +3,7 @@ import { api } from "../api"; import Icon from "../components/Icon"; import { Skeleton } from "../components/Skeleton"; import ZuordnungDialog from "../components/ZuordnungDialog"; +import { useToast } from "../toast"; import { fmt } from "../units"; /** @@ -53,6 +54,8 @@ export default function Rezepte() { // Antworten in falscher Reihenfolge eintreffen - nur die neueste zaehlt. const anfrage = useRef(0); const tippTimer = useRef(null); + const [uebernimmt, setUebernimmt] = useState(false); + const toast = useToast(); async function ladeListe(q = "") { setLaedt(true); @@ -114,6 +117,32 @@ export default function Rezepte() { const fehlende = (rezept?.ingredients || []).filter( (z) => z.status === "fehlt" || z.status === "zu_wenig", ); + // Auch die nicht zugeordneten Zutaten gehoeren auf den Zettel - gerade die + // unbekannte Zutat ist die, die man vergisst. + const offene = (rezept?.ingredients || []).filter( + (z) => z.status === "fehlt" || z.status === "zu_wenig" || z.status === "kein_treffer", + ); + + async function aufEinkaufsliste() { + if (!slug || uebernimmt) return; + setUebernimmt(true); + try { + const zahl = Number(String(portionen).replace(",", ".")); + const antwort = await api.rezeptAufEinkaufsliste( + slug, Number.isFinite(zahl) && zahl > 0 ? zahl : undefined, + ); + const n = antwort.entries.length; + const teile = [`${n} Posten auf der Einkaufsliste`]; + if (antwort.aktualisiert) teile.push(`${antwort.aktualisiert} davon neu gerechnet`); + if (antwort.freitext) teile.push(`${antwort.freitext} als Notiz ohne Artikel`); + toast(n === 0 ? "Es fehlt nichts – nichts hinzugefügt." : `${teile.join(", ")}.`); + setFehler(null); + } catch (err) { + setFehler(err.message); + } finally { + setUebernimmt(false); + } + } return (
@@ -260,11 +289,19 @@ export default function Rezepte() {
- {!rezept.kochbar && ( -

- Was fehlt, steht oben rot. Der Knopf „Fehlende zur Einkaufsliste" - kommt, sobald die Einkaufsliste eigene Einträge speichern kann. -

+ {offene.length > 0 && ( +
+ + + {offene.length === 1 ? "1 Posten" : `${offene.length} Posten`} – mit der + Fehlmenge für {fmt(Number(String(portionen).replace(",", ".")) || rezept.base_servings || 1)} Portionen. + Nicht zugeordnete Zutaten kommen als Notiz drauf. + +
)} )} diff --git a/web/src/pages/ShoppingList.jsx b/web/src/pages/ShoppingList.jsx index 0680ed8..c6253b6 100644 --- a/web/src/pages/ShoppingList.jsx +++ b/web/src/pages/ShoppingList.jsx @@ -2,25 +2,97 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import Icon from "../components/Icon"; import ShoppingNeedText from "../components/ShoppingNeedText"; +import { useToast } from "../toast"; import { anzahlWort } from "../units"; +/** + * Einkaufsliste aus zwei Quellen. + * + * **Gerechnet**: Artikel und Gruppen unter ihrem Mindestbestand, Bedarfe je + * Lagerort. Diese Posten haben keine Zeile in der Datenbank — sie entstehen bei + * jedem Aufruf neu. Gemerkt wird nur der Haken daran, sonst wäre er nach dem + * Neuladen weg, mitten im Supermarkt. + * + * **Gespeichert**: was jemand selbst draufgesetzt hat, und die fehlenden + * Zutaten eines Rezepts. Die bleiben liegen, bis sie abgehakt sind. + */ export default function ShoppingList() { const [items, setItems] = useState([]); const [groups, setGroups] = useState([]); const [byLocation, setByLocation] = useState([]); + const [entries, setEntries] = useState([]); const [checked, setChecked] = useState({}); + const [neu, setNeu] = useState(""); const [error, setError] = useState(null); + const toast = useToast(); - useEffect(() => { - Promise.all([api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation()]) - .then(([p, g, l]) => { setItems(p); setGroups(g); setByLocation(l); }) - .catch((e) => setError(e.message)); - }, []); + async function laden() { + try { + const alles = await api.shoppingAll(); + setItems(alles.products || []); + setGroups(alles.groups || []); + setByLocation(alles.by_location || []); + setEntries(alles.entries || []); + setChecked(Object.fromEntries((alles.checked || []).map((k) => [k, true]))); + setError(null); + } catch (e) { setError(e.message); } + } + useEffect(() => { laden(); }, []); + // Bezieht sich nur auf die GERECHNETEN Posten: Die eigenen Eintraege stehen in + // ihrer eigenen Karte. Zaehlte man sie hier mit, zeigte die untere Karte eine + // leere Liste statt "alles erreicht". const empty = items.length === 0 && groups.length === 0; + const erledigt = entries.filter((e) => e.done).length + + Object.values(checked).filter(Boolean).length; - function toggle(key, val) { + // Der Haken wandert sofort und wird im Hintergrund gespeichert: Im Laden soll + // niemand auf eine Antwort warten. Schlaegt es fehl, springt er zurueck. + async function toggle(key, val) { setChecked((c) => ({ ...c, [key]: val })); + try { + if (val) await api.setShoppingCheck(key); + else await api.clearShoppingCheck(key); + } catch (e) { + setChecked((c) => ({ ...c, [key]: !val })); + setError(e.message); + } + } + + async function eintragAbhaken(eintrag, val) { + setEntries((es) => es.map((e) => (e.id === eintrag.id ? { ...e, done: val } : e))); + try { + await api.updateShoppingEntry(eintrag.id, { done: val }); + } catch (e) { + setEntries((es) => es.map((x) => (x.id === eintrag.id ? { ...x, done: !val } : x))); + setError(e.message); + } + } + + async function eintragLoeschen(eintrag) { + try { + await api.deleteShoppingEntry(eintrag.id); + setEntries((es) => es.filter((e) => e.id !== eintrag.id)); + } catch (e) { setError(e.message); } + } + + async function hinzufuegen(ev) { + ev.preventDefault(); + const text = neu.trim(); + if (!text) return; + try { + const eintrag = await api.addShoppingEntry({ free_text: text }); + setEntries((es) => [...es, eintrag]); + setNeu(""); + } catch (e) { setError(e.message); } + } + + async function erledigteEntfernen() { + try { + const n = await api.clearDoneShopping(); + await laden(); + toast(n > 0 ? `${n} erledigte Posten entfernt.` : "Nichts abgehakt."); + } catch (e) { setError(e.message); } } return ( @@ -28,7 +100,9 @@ export default function ShoppingList() {

Einkaufsliste

-
Produkte und Gruppen unter ihrem Mindestbestand
+
+ Eigene Einträge und was unter dem Mindestbestand liegt +
{error &&
{error}
} @@ -37,6 +111,54 @@ export default function ShoppingList() {
Überall (egal wo)
)} + {/* Dieser Abschnitt steht auch leer da - er ist das Eingabefeld fuer + eigene Eintraege und muss deshalb auffindbar sein. */} +
+

Selbst draufgesetzt

+
+ setNeu(e.target.value)} /> + +
+ {entries.length === 0 ? ( +

+ Hier stehen eigene Einträge und die fehlenden Zutaten, die du aus + einem Rezept übernimmst. Anders als die gerechneten Posten unten + bleiben sie liegen, bis du sie abhakst. +

+ ) : ( +
    + {entries.map((e) => ( +
  • + + + {e.menge_text} + + +
  • + ))} +
+ )} +
+
{empty ? (
Alle Mindestbestände erreicht.
@@ -126,9 +248,16 @@ export default function ShoppingList() {
))} -

- Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert. -

+
+ + + Das Abhaken wird gespeichert – ein Neuladen im Laden verliert nichts. + +

Bedarfe sind gegeneinander verrechnet: Was du für einen Lagerort oder eine Untergruppe kaufst, deckt „Überall“ bzw. die Obergruppe mit ab. Hängt