from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from ..crud import product_tracking, resolve_product from ..database import get_db from ..deps import get_current_user from ..models import CategoryTracking, Lot, Movement, MovementType, User from ..schemas import ( BatchCheckInRequest, BatchCheckInResponse, CheckInRequest, CheckInResponse, CheckOutRequest, CheckOutResponse, LotOut, LotUpdate, RelocateRequest, RemoveRequest, StockActionResponse, ) from ..services.conversion import ConversionError from ..services.dates import clean_precision, normalize_best_before from ..services.stock import ( StockError, check_in, check_out, check_out_lot, current_stock, object_add, object_relocate, object_remove, ) router = APIRouter(tags=["stock"]) OBJECT = CategoryTracking.object.value @router.post("/stock/checkin", response_model=CheckInResponse) def stock_checkin( payload: CheckInRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> CheckInResponse: product = resolve_product(db, payload.product_id, payload.barcode) if product_tracking(db, product) == OBJECT: # Gegenstände: Menge am Lagerort erhöhen, kein MHD, keine Charge-Auswahl. lot = object_add( db, product=product, quantity=payload.quantity, location_id=payload.location_id, user=user, note=payload.note, ) db.commit() db.refresh(lot) return CheckInResponse( lot=LotOut.model_validate(lot), product_stock=current_stock(db, product.id) ) try: lot = check_in( db, product=product, quantity=payload.quantity, unit=payload.unit, best_before=payload.best_before, best_before_precision=payload.best_before_precision.value, location_id=payload.location_id, user=user, note=payload.note, ) except ConversionError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc db.commit() db.refresh(lot) return CheckInResponse( lot=LotOut.model_validate(lot), product_stock=current_stock(db, product.id) ) @router.post("/stock/checkin/batch", response_model=BatchCheckInResponse) def stock_checkin_batch( payload: BatchCheckInRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> BatchCheckInResponse: """Mehrere Chargen desselben Produkts in einem Vorgang einlagern. Jede Zeile erzeugt eine eigene Charge mit eigener Menge und eigenem MHD – z.B. 5 Gläser mit unterschiedlichen Mindesthaltbarkeitsdaten. """ product = resolve_product(db, payload.product_id, payload.barcode) lots: list[Lot] = [] try: for line in payload.lines: lots.append( check_in( db, product=product, quantity=line.quantity, unit=payload.unit, best_before=line.best_before, best_before_precision=line.best_before_precision.value, location_id=line.location_id, user=user, note=payload.note, ) ) except ConversionError as exc: db.rollback() raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc db.commit() for lot in lots: db.refresh(lot) return BatchCheckInResponse( lots=[LotOut.model_validate(lot) for lot in lots], product_stock=current_stock(db, product.id), ) @router.post("/stock/checkout", response_model=CheckOutResponse) def stock_checkout( payload: CheckOutRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> CheckOutResponse: product = resolve_product(db, payload.product_id, payload.barcode) if product_tracking(db, product) == OBJECT: raise HTTPException( status.HTTP_400_BAD_REQUEST, "Gegenstände werden über „Entfernen“ (mit Grund) oder „Umlagern“ gebucht, " "nicht ausgecheckt.", ) try: if payload.lot_id is not None: lot = db.get(Lot, payload.lot_id) if lot is None or lot.product_id != product.id: raise HTTPException( status.HTTP_404_NOT_FOUND, "Charge gehört nicht zu diesem Produkt" ) affected = check_out_lot( db, product=product, lot=lot, quantity=payload.quantity, unit=payload.unit, user=user, note=payload.note, ) else: affected = check_out( db, product=product, quantity=payload.quantity, unit=payload.unit, user=user, note=payload.note, ) except ConversionError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc except StockError as exc: raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc db.commit() return CheckOutResponse( affected_lots=affected, product_stock=current_stock(db, product.id) ) @router.post("/stock/relocate", response_model=StockActionResponse) def stock_relocate( payload: RelocateRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> StockActionResponse: """Gegenstands-Menge von einem Lagerort zum anderen umbuchen (ohne Grund).""" product = resolve_product(db, payload.product_id, payload.barcode) if product_tracking(db, product) != OBJECT: raise HTTPException( status.HTTP_400_BAD_REQUEST, "Umlagern gibt es nur für Gegenstände." ) try: object_relocate( db, product=product, quantity=payload.quantity, from_location_id=payload.from_location_id, to_location_id=payload.to_location_id, user=user, note=payload.note, ) except StockError as exc: raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc db.commit() return StockActionResponse(product_stock=current_stock(db, product.id)) @router.post("/stock/remove", response_model=StockActionResponse) def stock_remove( payload: RemoveRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> StockActionResponse: """Gegenstands-Menge mit Pflicht-Grund aus dem Bestand entfernen.""" product = resolve_product(db, payload.product_id, payload.barcode) if product_tracking(db, product) != OBJECT: raise HTTPException( status.HTTP_400_BAD_REQUEST, "Entfernen mit Grund gibt es nur für Gegenstände.", ) try: object_remove( db, product=product, quantity=payload.quantity, location_id=payload.location_id, reason=payload.reason.value, user=user, note=payload.note, ) except StockError as exc: raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc db.commit() return StockActionResponse(product_stock=current_stock(db, product.id)) @router.get("/lots", response_model=list[LotOut]) def list_lots( product_id: int | None = None, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[Lot]: query = db.query(Lot) if product_id is not None: query = query.filter(Lot.product_id == product_id) return query.order_by(Lot.best_before.is_(None), Lot.best_before).all() @router.patch("/lots/{lot_id}", response_model=LotOut) def update_lot( lot_id: int, payload: LotUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> Lot: """Korrigiert eine Charge (Menge in Basiseinheiten, MHD, Lagerort).""" lot = db.get(Lot, lot_id) if lot is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden") data = payload.model_dump(exclude_unset=True) # MHD und Genauigkeit hängen zusammen: Wird eines von beiden angefasst, muss # der gespeicherte Tag neu bestimmt werden (Monatsangabe = Monatsletzter). if "best_before" in data or "best_before_precision" in data: precision = ( clean_precision(data["best_before_precision"]) if "best_before_precision" in data else lot.best_before_precision ) best_before = data["best_before"] if "best_before" in data else lot.best_before data["best_before"] = normalize_best_before(best_before, precision) data["best_before_precision"] = precision old_quantity = lot.quantity for field, value in data.items(): setattr(lot, field, value) delta = lot.quantity - old_quantity if abs(delta) > 1e-9: db.add( Movement( product_id=lot.product_id, lot_id=lot.id, user_id=user.id, type=MovementType.adjust, quantity=delta, unit_used="base", note="Charge korrigiert", ) ) db.commit() db.refresh(lot) return lot @router.delete("/lots/{lot_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_lot( lot_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> None: """Entfernt eine Charge komplett aus dem Bestand.""" lot = db.get(Lot, lot_id) if lot is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden") db.add( Movement( product_id=lot.product_id, lot_id=None, user_id=user.id, type=MovementType.adjust, quantity=-lot.quantity, unit_used="base", note="Charge gelöscht", ) ) db.delete(lot) db.commit()