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:
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
125
backend/app/services/stock.py
Normal file
125
backend/app/services/stock.py
Normal 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
|
||||
64
backend/app/services/units.py
Normal file
64
backend/app/services/units.py
Normal 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
|
||||
Reference in New Issue
Block a user