"""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