diff --git a/README.md b/README.md index 5bc3726..e9d50b9 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,11 @@ und einen eigenen Einkaufszettel. Kühlregal …). Neue Produkte bekommen ihre Kategorie automatisch (Stichwörter, `backend/app/kategorien.py`), ändern lässt sie sich direkt in der Zeile oder unter „Vorschläge verwalten“. „⇅ Reihenfolge“ legt pro Liste fest, in welcher Reihenfolge man durch seinen Laden geht. +- **Läden:** In der Ansicht „Nach Laden“ unter „🏪 Läden“ eigene Läden anlegen (z. B. Aldi, dm), umbenennen, + sortieren, löschen. Jede Zeile hat eine Laden-Auswahl; die Zuordnung gilt pro Liste und bleibt gemerkt. + Die Einkaufsliste ist dann nach Laden gruppiert (darin nach Abteilung, je Laden eigene Reihenfolge + über „⇅ Abteilungen“). Der Filter „Alle | Aldi | dm | Ohne Laden“ zeigt nur einen Laden – was man dort + hinzufügt, wird gleich diesem Laden zugeordnet. - **Rezeptsammlung:** Ein abgeglichenes Rezept lässt sich mit „☆ Merken“ speichern (für alle Konten). „Was kann ich kochen?“ zeigt die gemerkten Rezepte, sortiert danach, wie viele Zutaten im Vorrat der gewählten Liste sind, und was noch fehlt; ein Klick gleicht das Rezept vollständig ab. diff --git a/backend/app/db.py b/backend/app/db.py index 0bb5011..fb828f5 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -148,6 +148,24 @@ CREATE TABLE IF NOT EXISTS products ( category TEXT -- Laden-Kategorie (kategorien.py); NULL = noch nicht zugeordnet ); +-- Läden einer Liste (z. B. Aldi, dm): Produkte lassen sich einem Laden zuordnen, die Einkaufsliste +-- gruppiert danach; jeder Laden kann eine eigene Reihenfolge der Abteilungen haben +CREATE TABLE IF NOT EXISTS stores ( + id INTEGER PRIMARY KEY, + list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, + name TEXT NOT NULL, + position REAL NOT NULL, + category_order TEXT -- JSON wie bei shopping_lists; NULL = Reihenfolge der Liste +); + +-- In welchem Laden ein Produkt (pro Liste) gekauft wird +CREATE TABLE IF NOT EXISTS store_products ( + list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, + key TEXT NOT NULL, + store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, + PRIMARY KEY (list_id, key) +); + -- Mindestbestand: fällt der Vorrat einer Liste darunter, kommt das Produkt auf ihre Einkaufsliste CREATE TABLE IF NOT EXISTS stock_minimums ( list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, diff --git a/backend/app/haushalt.py b/backend/app/haushalt.py index f281595..535e9af 100644 --- a/backend/app/haushalt.py +++ b/backend/app/haushalt.py @@ -25,6 +25,8 @@ MAX_QUANTITY = 999 MAX_PRODUCTS = 2000 MAX_LIST_NAME = 40 MAX_LISTS = 50 +MAX_STORES = 30 +MAX_STORE_NAME = 40 def _item(row) -> dict: @@ -37,6 +39,7 @@ def _item(row) -> dict: "unit": row["unit"], "checked": bool(row["checked"]), "category": row["category"] or guess(row["name"]), + "storeId": row["store_id"], "updatedAt": row["updated_at"], "updatedBy": {"id": row["updated_by"], "name": row["updated_by_name"]} if row["updated_by"] else None, } @@ -187,6 +190,10 @@ def _shopping_list(conn, row, user: dict) -> dict: "createdBy": {"id": row["created_by"], "name": row["created_by_name"]} if row["created_by"] else None, "canManage": _can_manage(row, user), "categoryOrder": _category_order(row["category_order"]), + "stores": [ + {"id": s["id"], "name": s["name"], "categoryOrder": _category_order(s["category_order"] or row["category_order"])} + for s in conn.execute("SELECT * FROM stores WHERE list_id = ? ORDER BY position, id", (row["id"],)) + ], } @@ -234,9 +241,10 @@ def _state(conn, user: dict) -> dict: ids = [r["id"] for r in lists] marks = ",".join("?" * len(ids)) items = conn.execute( - f"""SELECT i.*, u.display_name AS updated_by_name, p.category FROM household_items i + f"""SELECT i.*, u.display_name AS updated_by_name, p.category, sp.store_id FROM household_items i LEFT JOIN users u ON u.id = i.updated_by LEFT JOIN products p ON p.key = i.key + LEFT JOIN store_products sp ON sp.list_id = i.list_id AND sp.key = i.key WHERE i.list_id IN ({marks})""", ids, ).fetchall() @@ -295,7 +303,11 @@ def add_item(data: dict = Body(...), user: dict = Depends(current_user)): quantity = _quantity(data.get("quantity", 1), unit) with transaction() as conn: list_id = _get_list(conn, data.get("listId"), user)["id"] - _add(conn, _canonical_name(conn, name, list_id), list_, list_id, quantity, unit, user["id"]) + name = _canonical_name(conn, name, list_id) + _add(conn, name, list_, list_id, quantity, unit, user["id"]) + # Im Laden-Filter hinzugefügt: Produkt gleich diesem Laden zuordnen + if data.get("storeId") is not None: + _assign_store(conn, list_id, _key(name), _get_store(conn, data["storeId"], user, list_id)["id"]) return _state(conn, user) @@ -488,3 +500,101 @@ def set_category_order(list_id: int, data: dict = Body(...), user: dict = Depend _get_list(conn, list_id, user) conn.execute("UPDATE shopping_lists SET category_order = ? WHERE id = ?", (json.dumps(order), list_id)) return _state(conn, user) + + +# ---------- Läden ---------- + + +def _get_store(conn, store_id, user: dict, list_id=None): + """Laden einer Liste, die das Konto sieht (optional: muss zu list_id gehören).""" + if isinstance(store_id, bool) or not isinstance(store_id, int): + raise ValidationError("Ungültiger Laden.") + row = conn.execute("SELECT * FROM stores WHERE id = ?", (store_id,)).fetchone() + if not row or (list_id is not None and row["list_id"] != list_id): + raise HTTPException(404, "Laden nicht gefunden.") + _get_list(conn, row["list_id"], user) + return row + + +def _assign_store(conn, list_id: int, key: str, store_id) -> None: + if store_id is None: + conn.execute("DELETE FROM store_products WHERE list_id = ? AND key = ?", (list_id, key)) + else: + conn.execute( + """INSERT INTO store_products (list_id, key, store_id) VALUES (?, ?, ?) + ON CONFLICT (list_id, key) DO UPDATE SET store_id = excluded.store_id""", + (list_id, key, store_id), + ) + + +@router.post("/api/haushalt/listen/{list_id}/laeden", status_code=201) +def create_store(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Laden anlegen (alle, die die Liste sehen, dürfen Läden verwalten).""" + name = clean_text(data.get("name"), "Name des Ladens", MAX_STORE_NAME) + with transaction() as conn: + _get_list(conn, list_id, user) + if conn.execute("SELECT COUNT(*) FROM stores WHERE list_id = ?", (list_id,)).fetchone()[0] >= MAX_STORES: + raise ValidationError(f"Maximal {MAX_STORES} Läden pro Liste.") + if conn.execute("SELECT 1 FROM stores WHERE list_id = ? AND name = ? COLLATE NOCASE", (list_id, name)).fetchone(): + raise ValidationError("Diesen Laden gibt es in der Liste schon.") + position = conn.execute("SELECT COALESCE(MAX(position), 0) + 1 FROM stores WHERE list_id = ?", (list_id,)).fetchone()[0] + store_id = conn.execute( + "INSERT INTO stores (list_id, name, position) VALUES (?, ?, ?)", (list_id, name, position) + ).lastrowid + return {**_state(conn, user), "created": store_id} + + +@router.patch("/api/haushalt/laeden/{store_id}") +def update_store(store_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Laden umbenennen ({name}) oder seine Abteilungs-Reihenfolge setzen ({categoryOrder}, null = wie die Liste).""" + with transaction() as conn: + row = _get_store(conn, store_id, user) + if "name" in data: + name = clean_text(data.get("name"), "Name des Ladens", MAX_STORE_NAME) + if conn.execute( + "SELECT 1 FROM stores WHERE list_id = ? AND name = ? COLLATE NOCASE AND id != ?", (row["list_id"], name, store_id) + ).fetchone(): + raise ValidationError("Diesen Laden gibt es in der Liste schon.") + conn.execute("UPDATE stores SET name = ? WHERE id = ?", (name, store_id)) + if "categoryOrder" in data: + order = data["categoryOrder"] + if order is not None and (not isinstance(order, list) or sorted(order) != sorted(CATEGORY_KEYS)): + raise ValidationError("Die Reihenfolge muss jede Kategorie genau einmal enthalten.") + conn.execute("UPDATE stores SET category_order = ? WHERE id = ?", (json.dumps(order) if order else None, store_id)) + return _state(conn, user) + + +@router.put("/api/haushalt/listen/{list_id}/laeden-reihenfolge") +def order_stores(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Reihenfolge der Läden ({order: [ids]}) – so erscheinen sie in der Einkaufsliste.""" + with transaction() as conn: + _get_list(conn, list_id, user) + ids = [r["id"] for r in conn.execute("SELECT id FROM stores WHERE list_id = ?", (list_id,))] + order = data.get("order") + if not isinstance(order, list) or sorted(order) != sorted(ids): + raise ValidationError("Die Reihenfolge muss jeden Laden genau einmal enthalten.") + conn.executemany("UPDATE stores SET position = ? WHERE id = ?", [(i, s) for i, s in enumerate(order)]) + return _state(conn, user) + + +@router.delete("/api/haushalt/laeden/{store_id}") +def delete_store(store_id: int, user: dict = Depends(current_user)): + """Laden löschen; seine Produkte bleiben auf der Liste, nur ohne Laden.""" + with transaction() as conn: + _get_store(conn, store_id, user) + conn.execute("DELETE FROM stores WHERE id = ?", (store_id,)) + return _state(conn, user) + + +@router.put("/api/haushalt/listen/{list_id}/produkt-laden") +def set_product_store(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)): + """Produkt einem Laden zuordnen ({name, storeId}); storeId null = kein Laden. Gilt für diese Liste.""" + name = _clean_name(data.get("name")) + with transaction() as conn: + _get_list(conn, list_id, user) + store_id = data.get("storeId") + if store_id is not None: + store_id = _get_store(conn, store_id, user, list_id)["id"] + _assign_store(conn, list_id, _key(name), store_id) + return _state(conn, user) + diff --git a/backend/tests/test_vorrat_extras.py b/backend/tests/test_vorrat_extras.py index c157de8..d2cbdb4 100644 --- a/backend/tests/test_vorrat_extras.py +++ b/backend/tests/test_vorrat_extras.py @@ -131,3 +131,67 @@ def test_rezept_vorschlag_verraet_keine_privaten_produkte(login): save(ben, "Test", ["2 Kondom"]) res = ben.get("/api/haushalt/rezepte", params={"listId": home(ben)}).json() assert res[0]["missing"] == ["Kondom"] # nicht Annas Schreibweise "Kondome" + + +# ---------- Läden ---------- + + +def new_store(client, list_id, name): + res = client.post(f"/api/haushalt/listen/{list_id}/laeden", json={"name": name}) + assert res.status_code == 201, res.text + return res.json()["created"] + + +def stores(data, list_id): + return next(l for l in data["lists"] if l["id"] == list_id)["stores"] + + +def test_laeden_anlegen_sortieren_umbenennen_loeschen(login): + anna = login("anna") + lid = home(anna) + aldi, dm = new_store(anna, lid, "Aldi"), new_store(anna, lid, "dm") + assert anna.post(f"/api/haushalt/listen/{lid}/laeden", json={"name": "aldi"}).status_code == 400 # doppelt + data = anna.put(f"/api/haushalt/listen/{lid}/laeden-reihenfolge", json={"order": [dm, aldi]}).json() + assert [s["name"] for s in stores(data, lid)] == ["dm", "Aldi"] + data = anna.patch(f"/api/haushalt/laeden/{aldi}", json={"name": "Aldi Süd", "categoryOrder": list(reversed(KEYS))}).json() + s = next(x for x in stores(data, lid) if x["id"] == aldi) + assert s["name"] == "Aldi Süd" and s["categoryOrder"] == list(reversed(KEYS)) + assert next(x for x in stores(data, lid) if x["id"] == dm)["categoryOrder"] == KEYS # Standard der Liste + data = anna.delete(f"/api/haushalt/laeden/{dm}").json() + assert [s["name"] for s in stores(data, lid)] == ["Aldi Süd"] + + +def test_produkt_laden_zuordnen_und_behalten(login): + anna = login("anna") + lid = home(anna) + dm = new_store(anna, lid, "dm") + milch = items(add(anna, lid, "Zahnpasta").json(), lid)[0] + assert milch["storeId"] is None + data = anna.put(f"/api/haushalt/listen/{lid}/produkt-laden", json={"name": "Zahnpasta", "storeId": dm}).json() + assert items(data, lid)[0]["storeId"] == dm + # Gekauft und später wieder auf die Liste: Laden bleibt gemerkt + anna.patch(f"/api/haushalt/items/{milch['id']}", json={"checked": True}) + anna.post(f"/api/haushalt/listen/{lid}/einkauf-beenden") + data = add(anna, lid, "Zahnpasta").json() + assert [i["storeId"] for i in items(data, lid, "einkauf")] == [dm] + # Laden gelöscht → Produkt ohne Laden, bleibt aber auf der Liste + data = anna.delete(f"/api/haushalt/laeden/{dm}").json() + assert [i["storeId"] for i in items(data, lid, "einkauf")] == [None] + + +def test_im_laden_hinzufuegen(login): + anna = login("anna") + lid = home(anna) + aldi = new_store(anna, lid, "Aldi") + data = anna.post("/api/haushalt/items", json={"list": "einkauf", "name": "Brot", "listId": lid, "storeId": aldi}).json() + assert items(data, lid)[0]["storeId"] == aldi + + +def test_laden_fremder_liste_nicht_nutzbar(login): + anna, ben = login("anna"), login("ben") + privat = new_list(anna, "Privat", everyone=False) + fremd = new_store(anna, privat, "Geheimladen") + lid = home(ben) + assert ben.patch(f"/api/haushalt/laeden/{fremd}", json={"name": "x"}).status_code == 404 + assert ben.put(f"/api/haushalt/listen/{lid}/produkt-laden", json={"name": "Brot", "storeId": fremd}).status_code == 404 + assert ben.post("/api/haushalt/items", json={"list": "einkauf", "name": "Brot", "listId": lid, "storeId": fremd}).status_code == 404 diff --git a/frontend/src/components/haushalt/CategoryOrderDialog.jsx b/frontend/src/components/haushalt/CategoryOrderDialog.jsx index 510194c..c5319aa 100644 --- a/frontend/src/components/haushalt/CategoryOrderDialog.jsx +++ b/frontend/src/components/haushalt/CategoryOrderDialog.jsx @@ -1,7 +1,7 @@ import { useState } from 'react'; // Reihenfolge der Abteilungen so, wie man durch den eigenen Laden läuft (gilt für die gewählte Liste) -export default function CategoryOrderDialog({ ref, categories, order, onSave, busy }) { +export default function CategoryOrderDialog({ ref, categories, order, title: heading, onSave, busy }) { const [current, setCurrent] = useState(order); const title = Object.fromEntries(categories.map((c) => [c.key, c.title])); const move = (index, delta) => setCurrent((list) => { @@ -17,8 +17,8 @@ export default function CategoryOrderDialog({ ref, categories, order, onSave, bu return (
-

Reihenfolge im Laden

-

So sortiert „Nach Laden“ die Einkaufsliste – am besten in der Reihenfolge, in der du durch den Markt gehst.

+

{heading || 'Reihenfolge der Abteilungen'}

+

{heading ? 'Gilt für diesen Laden.' : 'Gilt für Produkte ohne Laden und für Läden ohne eigene Reihenfolge.'} Am besten so, wie du durch den Markt gehst.

    {current.map((key, i) => (
  1. diff --git a/frontend/src/components/haushalt/ListPanel.jsx b/frontend/src/components/haushalt/ListPanel.jsx index e91be71..f983126 100644 --- a/frontend/src/components/haushalt/ListPanel.jsx +++ b/frontend/src/components/haushalt/ListPanel.jsx @@ -13,8 +13,9 @@ export const LISTS = { const same = (a, b) => a.toLocaleLowerCase('de-DE') === b.toLocaleLowerCase('de-DE'); /** - * extras: { minimums, categories, categoryOrder, sortMode, onSortMode, onEditOrder, onEditMinimums, onCategory } - * – Mindestbestand (Vorrat) und Laden-Sortierung nach Kategorien (Einkauf). + * extras: { minimums, categories, categoryOrder, stores, storeFilter, sortMode, onSortMode, onStoreFilter, onEditOrder, + * onEditStores, onEditMinimums, onCategory, onStore } + * – Mindestbestand (Vorrat); Einkauf "Nach Laden": nach Laden gruppiert (falls angelegt), darin nach Abteilung. */ export default function ListPanel({ list, items, products, find, actions, busy, hidden, extras }) { const { title, lead, empty } = LISTS[list]; @@ -25,12 +26,23 @@ export default function ListPanel({ list, items, products, find, actions, busy, const byShop = list === 'einkauf' && extras.sortMode === 'laden'; const minimumOf = (item) => extras.minimums.find((m) => same(m.name, item.name) && m.unit === item.unit); const categoryTitle = Object.fromEntries(extras.categories.map((c) => [c.key, c.title])); - // Laden-Modus: nach Kategorien in der Reihenfolge dieser Liste gruppieren - const groups = byShop - ? extras.categoryOrder - .map((key) => ({ key, title: categoryTitle[key], items: open.filter((i) => i.category === key) })) - .filter((g) => g.items.length) - : [{ key: 'alle', items: open }]; + // Laden-Modus: erst nach Laden (falls welche angelegt sind), darin nach Abteilung in der Reihenfolge des Ladens + const stores = extras.stores; + const byCategory = (list, order) => order + .map((key) => ({ key, title: categoryTitle[key], items: list.filter((i) => i.category === key) })) + .filter((g) => g.items.length); + const known = new Set(stores.map((s) => s.id)); + const sections = !byShop + ? [{ key: 'alle', groups: [{ key: 'alle', items: open }] }] + : !stores.length + ? [{ key: 'alle', groups: byCategory(open, extras.categoryOrder) }] + : [ + ...stores.map((s) => ({ key: `s${s.id}`, id: s.id, title: s.name, groups: byCategory(open.filter((i) => i.storeId === s.id), s.categoryOrder) })), + { key: 'ohne', id: 'ohne', title: 'Ohne Laden', groups: byCategory(open.filter((i) => !known.has(i.storeId)), extras.categoryOrder) }, + ] + .filter((sec) => extras.storeFilter === 'alle' || extras.storeFilter === sec.id) + .filter((sec) => sec.groups.length); + const count = (id) => open.filter((i) => (id === 'ohne' ? !known.has(i.storeId) : i.storeId === id)).length; const row = (item) => ( extras.onEditMinimums(item)} categories={byShop ? extras.categories : null} onCategory={extras.onCategory} + stores={byShop && stores.length ? stores : null} + onStore={extras.onStore} /> ); @@ -82,9 +96,25 @@ export default function ListPanel({ list, items, products, find, actions, busy, ))}
- {byShop && } + {byShop && ( + + + + + )} )} + {byShop && stores.length > 0 && ( + + )} @@ -92,9 +122,12 @@ export default function ListPanel({ list, items, products, find, actions, busy,

{empty}

) : (