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:
1 parent
9df0e38f52
commit
9835f5e69e
15 files changed
+879
-40
No files matched your search
+123
-8
@@ -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)
|
||||
Reference in new issue
Block a user