Schritt 1: Fundament der Lebensmittel-Lagerverwaltung (Pantry)

Backend (FastAPI + PostgreSQL): Chargen mit MHD, FEFO-Auslagern,
Einheiten-Umrechnung (Stueck/g/ml + Packungen), Open-Food-Facts-Lookup
mit lokalem Fallback, JWT-Auth mit Rollen (Admin/Nutzer), erster Admin
beim Setup, Einkaufsliste, Ablaufwarnung, Lagerorte, pytest fuer FEFO.

Web-UI (React/Vite): Login, Dashboard, Ein-/Auslagern, Produkte,
Lagerorte, Benutzerverwaltung, Einkaufsliste - rollenabhaengig.

Deploy: docker-compose + install.sh (Docker-Autoinstall, Secrets),
README und Roadmap fuer Schritt 2 (iOS) und Schritt 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 09:14:14 +02:00
commit 639126468f
57 changed files with 3460 additions and 0 deletions

View File

View File

@@ -0,0 +1,125 @@
"""Chargen-Logik: Einlagern erzeugt Lots, Auslagern bucht per FEFO ab."""
from __future__ import annotations
from datetime import date
from sqlalchemy import asc
from sqlalchemy.orm import Session
from ..models import Lot, Movement, MovementType, Product, User
from .units import to_base_quantity
class StockError(ValueError):
"""Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand)."""
def current_stock(db: Session, product_id: int) -> float:
"""Summe der Lot-Mengen eines Produkts (in Basiseinheiten)."""
total = (
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
)
return float(sum(q for (q,) in total))
def check_in(
db: Session,
product: Product,
quantity: float,
unit: str,
best_before: date | None,
location_id: int | None,
user: User | None,
note: str | None = None,
) -> Lot:
"""Legt eine neue Charge an und protokolliert die Bewegung."""
quantity_base = to_base_quantity(product, quantity, unit)
lot = Lot(
product_id=product.id,
quantity=quantity_base,
best_before=best_before,
location_id=location_id,
)
db.add(lot)
db.flush() # lot.id verfügbar machen
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.in_,
quantity=quantity_base,
unit_used=unit,
note=note,
)
)
return lot
def _fefo_lots(db: Session, product_id: int) -> list[Lot]:
"""Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter."""
lots = (
db.query(Lot)
.filter(Lot.product_id == product_id, Lot.quantity > 0)
.order_by(asc(Lot.created_at))
.all()
)
# NULL-best_before ans Ende (nach Datum aufsteigend). In Python sortieren, damit
# es über SQLite und Postgres identisch funktioniert.
return sorted(
lots,
key=lambda lot: (lot.best_before is None, lot.best_before or date.max, lot.id),
)
def check_out(
db: Session,
product: Product,
quantity: float,
unit: str,
user: User | None,
note: str | None = None,
) -> list[dict]:
"""Bucht ``quantity`` (in ``unit``) per FEFO von den Chargen ab.
Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück.
Wirft StockError, wenn der Gesamtbestand nicht ausreicht.
"""
needed = to_base_quantity(product, quantity, unit)
available = current_stock(db, product.id)
if needed > available + 1e-9:
raise StockError(
f"Nicht genug Bestand: benötigt {needed:g}, verfügbar {available:g} "
f"{product.base_unit.value}"
)
affected: list[dict] = []
remaining = needed
for lot in _fefo_lots(db, product.id):
if remaining <= 1e-9:
break
take = min(lot.quantity, remaining)
lot.quantity -= take
remaining -= take
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.out,
quantity=take,
unit_used=unit,
note=note,
)
)
affected.append({"lot_id": lot.id, "quantity": take})
# Leere Charge entfernen, damit das "MHD-Array" sauber bleibt.
if lot.quantity <= 1e-9:
db.delete(lot)
return affected

View File

@@ -0,0 +1,64 @@
"""Einheiten-Umrechnung.
Der Bestand einer Charge (Lot) wird immer in der Basiseinheit des Produkts
gespeichert (Stück, Gramm oder Milliliter). Nutzer geben Mengen entweder in der
Basiseinheit oder in "Packungen" (package) an. Eine Packung entspricht
``product.package_size`` Basiseinheiten.
"""
from __future__ import annotations
from ..models import BaseUnit, Product
# Aliase, die als "Basiseinheit" akzeptiert werden.
_BASE_ALIASES: dict[str, BaseUnit] = {
"piece": BaseUnit.piece,
"pieces": BaseUnit.piece,
"stück": BaseUnit.piece,
"stueck": BaseUnit.piece,
"st": BaseUnit.piece,
"g": BaseUnit.gram,
"gram": BaseUnit.gram,
"gramm": BaseUnit.gram,
"ml": BaseUnit.milliliter,
"milliliter": BaseUnit.milliliter,
}
PACKAGE_UNITS = {"package", "packung", "pkg", "pack"}
class UnitError(ValueError):
"""Wird geworfen, wenn eine Einheit nicht zum Produkt passt."""
def to_base_quantity(product: Product, quantity: float, unit: str) -> float:
"""Rechnet eine vom Nutzer angegebene Menge in Basiseinheiten um."""
if quantity <= 0:
raise UnitError("Menge muss größer als 0 sein")
unit_norm = unit.strip().lower()
if unit_norm in PACKAGE_UNITS:
if not product.package_size or product.package_size <= 0:
raise UnitError(
"Für dieses Produkt ist keine Packungsgröße hinterlegt, "
"Packungen können nicht umgerechnet werden"
)
return quantity * product.package_size
mapped = _BASE_ALIASES.get(unit_norm)
if mapped is None:
raise UnitError(f"Unbekannte Einheit: {unit}")
if mapped != product.base_unit:
raise UnitError(
f"Einheit '{unit}' passt nicht zur Basiseinheit "
f"'{product.base_unit.value}' des Produkts"
)
return float(quantity)
def base_to_packages(product: Product, quantity_base: float) -> float | None:
"""Rechnet Basiseinheiten in (ggf. gebrochene) Packungen um, falls möglich."""
if not product.package_size or product.package_size <= 0:
return None
return quantity_base / product.package_size