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:
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
|
||||
Reference in New Issue
Block a user