Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende: Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich ueber Open Products Facts. Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,181 @@ class StockError(ValueError):
|
||||
"""Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gegenstände (Non-Food): Menge je Lagerort statt Chargen mit MHD.
|
||||
#
|
||||
# Technisch wird dieselbe Lot-Tabelle genutzt – je (Produkt, Lagerort) genau
|
||||
# eine Zeile mit ``best_before = NULL``. So laufen Bestands-Summe, Bewegungslog
|
||||
# und Export unverändert weiter; nur MHD/FEFO entfällt. Die Lebensmittel-Logik
|
||||
# oben (check_in/check_out) bleibt davon unberührt.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _object_lot(db: Session, product_id: int, location_id: int | None) -> Lot | None:
|
||||
"""Die (einzige) Bestandszeile eines Gegenstands an einem Lagerort."""
|
||||
query = db.query(Lot).filter(
|
||||
Lot.product_id == product_id, Lot.best_before.is_(None)
|
||||
)
|
||||
if location_id is None:
|
||||
query = query.filter(Lot.location_id.is_(None))
|
||||
else:
|
||||
query = query.filter(Lot.location_id == location_id)
|
||||
return query.first()
|
||||
|
||||
|
||||
def object_add(
|
||||
db: Session,
|
||||
product: Product,
|
||||
quantity: float,
|
||||
location_id: int | None,
|
||||
user: User | None,
|
||||
note: str | None = None,
|
||||
) -> Lot:
|
||||
"""Erhöht die Menge eines Gegenstands an einem Lagerort."""
|
||||
lot = _object_lot(db, product.id, location_id)
|
||||
if lot is None:
|
||||
lot = Lot(
|
||||
product_id=product.id,
|
||||
quantity=0.0,
|
||||
best_before=None,
|
||||
best_before_precision=DatePrecision.day.value,
|
||||
location_id=location_id,
|
||||
)
|
||||
db.add(lot)
|
||||
db.flush()
|
||||
lot.quantity += quantity
|
||||
db.add(
|
||||
Movement(
|
||||
product_id=product.id,
|
||||
lot_id=lot.id,
|
||||
user_id=user.id if user else None,
|
||||
type=MovementType.in_,
|
||||
quantity=quantity,
|
||||
unit_used=product.base_unit.value,
|
||||
note=note,
|
||||
location_id=location_id,
|
||||
)
|
||||
)
|
||||
return lot
|
||||
|
||||
|
||||
def object_remove(
|
||||
db: Session,
|
||||
product: Product,
|
||||
quantity: float,
|
||||
location_id: int | None,
|
||||
reason: str,
|
||||
user: User | None,
|
||||
note: str | None = None,
|
||||
) -> None:
|
||||
"""Entfernt eine Menge mit Grund (verloren/kaputt/…) aus dem Bestand."""
|
||||
lot = _object_lot(db, product.id, location_id)
|
||||
have = lot.quantity if lot else 0.0
|
||||
if quantity > have + 1e-9:
|
||||
raise StockError(
|
||||
f"Am Lagerort sind nur {have:g} {product.base_unit.value} vorhanden "
|
||||
f"(benötigt {quantity:g})."
|
||||
)
|
||||
lot.quantity -= quantity
|
||||
db.add(
|
||||
Movement(
|
||||
product_id=product.id,
|
||||
lot_id=lot.id,
|
||||
user_id=user.id if user else None,
|
||||
type=MovementType.out,
|
||||
quantity=quantity,
|
||||
unit_used=product.base_unit.value,
|
||||
note=note,
|
||||
location_id=location_id,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
if lot.quantity <= 1e-9:
|
||||
db.delete(lot)
|
||||
|
||||
|
||||
def object_relocate(
|
||||
db: Session,
|
||||
product: Product,
|
||||
quantity: float,
|
||||
from_location_id: int | None,
|
||||
to_location_id: int | None,
|
||||
user: User | None,
|
||||
note: str | None = None,
|
||||
) -> None:
|
||||
"""Bucht eine Menge von einem Lagerort zum anderen um (ohne Grund)."""
|
||||
if from_location_id == to_location_id:
|
||||
raise StockError("Quell- und Ziel-Lagerort sind identisch.")
|
||||
src = _object_lot(db, product.id, from_location_id)
|
||||
have = src.quantity if src else 0.0
|
||||
if quantity > have + 1e-9:
|
||||
raise StockError(
|
||||
f"Am Quell-Lagerort sind nur {have:g} {product.base_unit.value} "
|
||||
f"vorhanden (benötigt {quantity:g})."
|
||||
)
|
||||
beleg = note or "Umlagerung"
|
||||
src.quantity -= quantity
|
||||
# Als neutrale Korrektur (adjust) protokollieren, damit Umlagerungen die
|
||||
# Ein-/Auslager-Statistiken nicht verfälschen.
|
||||
db.add(
|
||||
Movement(
|
||||
product_id=product.id,
|
||||
lot_id=src.id,
|
||||
user_id=user.id if user else None,
|
||||
type=MovementType.adjust,
|
||||
quantity=-quantity,
|
||||
unit_used=product.base_unit.value,
|
||||
note=beleg,
|
||||
location_id=from_location_id,
|
||||
)
|
||||
)
|
||||
if src.quantity <= 1e-9:
|
||||
db.delete(src)
|
||||
|
||||
dest = _object_lot(db, product.id, to_location_id)
|
||||
if dest is None:
|
||||
dest = Lot(
|
||||
product_id=product.id,
|
||||
quantity=0.0,
|
||||
best_before=None,
|
||||
best_before_precision=DatePrecision.day.value,
|
||||
location_id=to_location_id,
|
||||
)
|
||||
db.add(dest)
|
||||
db.flush()
|
||||
dest.quantity += quantity
|
||||
db.add(
|
||||
Movement(
|
||||
product_id=product.id,
|
||||
lot_id=dest.id,
|
||||
user_id=user.id if user else None,
|
||||
type=MovementType.adjust,
|
||||
quantity=quantity,
|
||||
unit_used=product.base_unit.value,
|
||||
note=beleg,
|
||||
location_id=to_location_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def removal_stats(db: Session, product_id: int) -> dict[str, dict]:
|
||||
"""Entnahmen je Grund summieren: {reason: {quantity, count}}."""
|
||||
rows = (
|
||||
db.query(Movement)
|
||||
.filter(
|
||||
Movement.product_id == product_id,
|
||||
Movement.type == MovementType.out,
|
||||
Movement.reason.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
stats: dict[str, dict] = {}
|
||||
for m in rows:
|
||||
eintrag = stats.setdefault(m.reason, {"quantity": 0.0, "count": 0})
|
||||
eintrag["quantity"] += m.quantity
|
||||
eintrag["count"] += 1
|
||||
return stats
|
||||
|
||||
|
||||
def current_stock(db: Session, product_id: int) -> float:
|
||||
"""Summe der Lot-Mengen eines Produkts (in Basiseinheiten)."""
|
||||
total = (
|
||||
|
||||
Reference in New Issue
Block a user