Vorrat: Mindestbestand, Sortierung nach Laden, Rezeptsammlung mit Vorschlägen

- Mindestbestand pro Liste/Produkt/Einheit (Tabelle stock_minimums): fällt
  der Vorrat darunter, füllt der Server die Einkaufsliste genau bis zur
  fehlenden Menge auf (nach Ändern, Aufgebraucht, Löschen, Setzen)
- Laden-Kategorien (kategorien.py): automatische Zuordnung über Stichwörter
  (längstes Stichwort gewinnt), änderbar in der Zeile und im
  Vorschläge-Dialog; Einkaufsliste "A–Z" oder "Nach Laden" gruppiert;
  Reihenfolge der Abteilungen pro Liste
- Rezeptsammlung (Tabelle recipes): "☆ Merken" beim Abgleich, "Was kann ich
  kochen?" sortiert gemerkte Rezepte nach vorhandenen Zutaten (Salz & Co.
  zählen nicht, nur sichtbare Produkte), vollständiger Abgleich per Klick
- Migration Schritt 3 (Kategorien für bestehende Produkte); _add_column für
  Tabellen, die SCHEMA in alten Datenbanken schon neu angelegt hat
- Tests in test_vorrat_extras.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 19:17:52 +02:00
1 parent 9df0e38f52
commit 9835f5e69e
15 files changed
+879 -40

No files matched your search

+123 -8
View File
@@ -6,6 +6,7 @@ Wer eine Liste anlegt, legt fest, ob alle Konten sie sehen oder nur ausgewählte
(Anzahl, kg, l) nur einmal; erneutes Hinzufügen in derselben Einheit erhöht die Menge. Neue Produktnamen landen im Katalog und werden als
Vorschläge angeboten – "milch" wird dabei automatisch zum bereits bekannten "Milch".
"""
import json
import math
from fastapi import APIRouter, Body, Depends, HTTPException
@@ -13,6 +14,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException
from .auth import current_user
from .common import ValidationError, clean_text, now_iso, user_list
from .db import transaction
from .kategorien import CATEGORIES, KEYS as CATEGORY_KEYS, guess
router = APIRouter()
@@ -34,6 +36,7 @@ def _item(row) -> dict:
"quantity": _out_quantity(row["quantity"], row["unit"]),
"unit": row["unit"],
"checked": bool(row["checked"]),
"category": row["category"] or guess(row["name"]),
"updatedAt": row["updated_at"],
"updatedBy": {"id": row["updated_by"], "name": row["updated_by_name"]} if row["updated_by"] else None,
}
@@ -93,7 +96,10 @@ def _canonical_name(conn, name: str, list_id=None) -> str:
raise ValidationError("Der Produktkatalog ist voll. Bitte alte Vorschläge löschen.")
# Neue Produkte mit großem Anfangsbuchstaben, damit "milch" und "Milch" nicht doppelt entstehen
name = name[0].upper() + name[1:]
conn.execute("INSERT INTO products (key, name, uses, last_used) VALUES (?, ?, 1, ?)", (_key(name), name, now_iso()))
conn.execute(
"INSERT INTO products (key, name, uses, last_used, category) VALUES (?, ?, 1, ?, ?)",
(_key(name), name, now_iso(), guess(name)),
)
if list_id is not None:
conn.execute(
"""INSERT INTO list_products (list_id, key, uses, last_used) VALUES (?, ?, 1, ?)
@@ -103,16 +109,20 @@ def _canonical_name(conn, name: str, list_id=None) -> str:
return name
def visible_products(conn, user: dict) -> list:
def _visible_product_rows(conn, user: dict) -> list:
"""Produkte aus den Listen, die das Konto sehen darf – häufig genutzte zuerst."""
rows = conn.execute(
f"""SELECT p.name, SUM(lp.uses) AS uses FROM list_products lp
f"""SELECT p.name, p.category, SUM(lp.uses) AS uses FROM list_products lp
JOIN products p ON p.key = lp.key
JOIN shopping_lists l ON l.id = lp.list_id
WHERE {_VISIBLE} GROUP BY p.key""",
_who(user),
).fetchall()
return [r["name"] for r in sorted(rows, key=lambda r: (-r["uses"], _sort_key(r["name"])))]
return sorted(rows, key=lambda r: (-r["uses"], _sort_key(r["name"])))
def visible_products(conn, user: dict) -> list:
return [r["name"] for r in _visible_product_rows(conn, user)]
def _add(conn, name: str, list_: str, list_id: int, quantity, unit: str, user_id: int) -> None:
@@ -176,9 +186,19 @@ def _shopping_list(conn, row, user: dict) -> dict:
"members": members,
"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"]),
}
def _category_order(value) -> list:
"""Gespeicherte Reihenfolge, ergänzt um neu hinzugekommene Kategorien (am Ende)."""
try:
order = [k for k in json.loads(value) if k in CATEGORY_KEYS] if value else []
except ValueError:
order = []
return order + [k for k in CATEGORY_KEYS if k not in order]
def _list_fields(conn, data: dict, user: dict, owner_id) -> tuple:
"""Name, "für alle" und Mitglieder prüfen. Der Ersteller sieht seine Liste immer und steht nicht bei den Mitgliedern."""
name = clean_text(data.get("name"), "Name der Liste", MAX_LIST_NAME)
@@ -212,20 +232,53 @@ def _get_item(conn, item_id: int, user: dict):
def _state(conn, user: dict) -> dict:
lists = _visible_lists(conn, user)
ids = [r["id"] for r in lists]
marks = ",".join("?" * len(ids))
items = conn.execute(
f"""SELECT i.*, u.display_name AS updated_by_name FROM household_items i
f"""SELECT i.*, u.display_name AS updated_by_name, p.category FROM household_items i
LEFT JOIN users u ON u.id = i.updated_by
WHERE i.list_id IN ({','.join('?' * len(ids))})""",
LEFT JOIN products p ON p.key = i.key
WHERE i.list_id IN ({marks})""",
ids,
).fetchall()
minimums = conn.execute(f"SELECT * FROM stock_minimums WHERE list_id IN ({marks}) ORDER BY name", ids).fetchall()
products = _visible_product_rows(conn, user)
return {
"items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))],
"lists": [_shopping_list(conn, r, user) for r in lists],
"users": user_list(conn),
"products": visible_products(conn, user),
"products": [r["name"] for r in products],
"productCategories": {r["name"]: r["category"] or guess(r["name"]) for r in products},
"categories": [{"key": k, "title": t} for k, t in CATEGORIES],
"minimums": [
{"listId": r["list_id"], "key": r["key"], "name": r["name"], "unit": r["unit"], "minimum": _out_quantity(r["minimum"], r["unit"])}
for r in minimums
],
}
# ---------- Mindestbestand ----------
def _refill(conn, list_id: int, key: str, unit: str, user_id: int) -> None:
"""Liegt der Vorrat unter dem Mindestbestand, die Einkaufsliste bis zur fehlenden Menge auffüllen."""
minimum = conn.execute(
"SELECT name, minimum FROM stock_minimums WHERE list_id = ? AND key = ? AND unit = ?", (list_id, key, unit)
).fetchone()
if not minimum:
return
def amount(list_):
row = conn.execute(
"SELECT quantity FROM household_items WHERE list_id = ? AND key = ? AND list = ? AND unit = ?", (list_id, key, list_, unit)
).fetchone()
return row["quantity"] if row else 0
needed = minimum["minimum"] - amount("vorrat") - amount("einkauf")
if unit == "stk":
needed = math.ceil(needed)
needed = round(needed, 2)
if needed > 0:
_add(conn, minimum["name"], "einkauf", list_id, min(needed, MAX_QUANTITY), unit, user_id)
@router.get("/api/haushalt")
def get_household(user: dict = Depends(current_user)):
with transaction() as conn:
@@ -280,6 +333,9 @@ def update_item(item_id: int, data: dict = Body(...), user: dict = Depends(curre
"UPDATE household_items SET quantity = ?, unit = ?, updated_at = ?, updated_by = ? WHERE id = ?",
(quantity, unit, now_iso(), user["id"], item_id),
)
if row["list"] == "vorrat":
for u in {row["unit"], unit}: # bei Einheitenwechsel beide Einheiten prüfen
_refill(conn, row["list_id"], row["key"], u, user["id"])
return _state(conn, user)
@@ -293,6 +349,7 @@ def move_item(item_id: int, user: dict = Depends(current_user)):
quantity = row["quantity"] if target == "vorrat" else 1
conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,))
_add(conn, row["name"], target, row["list_id"], quantity, row["unit"], user["id"])
_refill(conn, row["list_id"], row["key"], row["unit"], user["id"])
return _state(conn, user)
@@ -315,8 +372,10 @@ def finish_shopping(list_id: int, user: dict = Depends(current_user)):
@router.delete("/api/haushalt/items/{item_id}")
def delete_item(item_id: int, user: dict = Depends(current_user)):
with transaction() as conn:
_get_item(conn, item_id, user)
row = _get_item(conn, item_id, user)
conn.execute("DELETE FROM household_items WHERE id = ?", (item_id,))
if row["list"] == "vorrat":
_refill(conn, row["list_id"], row["key"], row["unit"], user["id"])
return _state(conn, user)
@@ -373,3 +432,59 @@ def delete_product(name: str, user: dict = Depends(current_user)):
"DELETE FROM products WHERE key = ? AND NOT EXISTS (SELECT 1 FROM list_products WHERE key = ?)", (_key(name), _key(name))
)
return _state(conn, user)
@router.put("/api/haushalt/listen/{list_id}/mindestbestand")
def set_minimum(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
"""Mindestbestand setzen ({name, unit, minimum}); minimum 0 oder null entfernt ihn."""
name = _clean_name(data.get("name"))
unit = _unit(data.get("unit"))
with transaction() as conn:
_get_list(conn, list_id, user)
if data.get("minimum") in (None, 0):
conn.execute("DELETE FROM stock_minimums WHERE list_id = ? AND key = ? AND unit = ?", (list_id, _key(name), unit))
return _state(conn, user)
minimum = _quantity(data.get("minimum"), unit)
name = _canonical_name(conn, name, list_id)
conn.execute(
"""INSERT INTO stock_minimums (list_id, key, name, unit, minimum) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (list_id, key, unit) DO UPDATE SET minimum = excluded.minimum, name = excluded.name""",
(list_id, _key(name), name, unit, minimum),
)
_refill(conn, list_id, _key(name), unit, user["id"])
return _state(conn, user)
# ---------- Laden-Kategorien ----------
@router.put("/api/haushalt/produkte/kategorie")
def set_category(data: dict = Body(...), user: dict = Depends(current_user)):
"""Kategorie eines Produkts ändern – nur für Produkte aus Listen, die das Konto sieht."""
if data.get("category") not in CATEGORY_KEYS:
raise ValidationError("Unbekannte Kategorie.")
with transaction() as conn:
ids = [r["id"] for r in _visible_lists(conn, user)]
key = _key(_clean_name(data.get("name")))
visible = conn.execute(
f"SELECT 1 FROM household_items WHERE key = ? AND list_id IN ({','.join('?' * len(ids))}) "
f"UNION SELECT 1 FROM list_products WHERE key = ? AND list_id IN ({','.join('?' * len(ids))})",
(key, *ids, key, *ids),
).fetchone()
if not visible:
raise HTTPException(404, "Produkt nicht gefunden.")
conn.execute("UPDATE products SET category = ? WHERE key = ?", (data["category"], key))
return _state(conn, user)
@router.put("/api/haushalt/listen/{list_id}/reihenfolge")
def set_category_order(list_id: int, data: dict = Body(...), user: dict = Depends(current_user)):
"""Reihenfolge der Kategorien für den Laden dieser Liste (alle, die die Liste sehen, dürfen sie anpassen)."""
order = data.get("order")
if not isinstance(order, list) or sorted(order) != sorted(CATEGORY_KEYS):
raise ValidationError("Die Reihenfolge muss jede Kategorie genau einmal enthalten.")
with transaction() as conn:
_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)