from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session, joinedload from ..crud import product_tracking, resolve_product from ..database import get_db from ..deps import get_current_user from ..models import CategoryTracking, Location, Lot, Movement, MovementType, Product, User from ..schemas import ( BatchCheckInRequest, BatchCheckInResponse, BulkLotLocation, CheckInRequest, CheckInResponse, CheckOutRequest, CheckOutResponse, LotOut, LotRow, LotSplit, LotUpdate, RelocateRequest, RemoveRequest, StockActionResponse, ) from ..services.conversion import ConversionError, display_unit_info from ..services.dates import clean_precision, normalize_best_before from ..services.stock import ( StockError, check_in, check_out, check_out_lot, consolidate_lot_group, 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.get("/lots/rows", response_model=list[LotRow]) def list_all_lots( db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[LotRow]: """Chargen der Charge-Artikel (Lebensmittel + Verbrauchsgegenstand) über alle Artikel – für die übergreifende Chargenliste. „Menge je Lagerort" und Einzelstücke gehören nicht hierher und werden übersprungen. Der Artikel wird eager geladen (kein N+1).""" lots = ( db.query(Lot) .options(joinedload(Lot.product).joinedload(Product.category)) .order_by(Lot.created_at.desc()) .all() ) rows: list[LotRow] = [] for lot in lots: p = lot.product tracking = product_tracking(db, p) # Nur Charge-Artikel: Lebensmittel (food) oder Verbrauchsgegenstand (bulk). food_like = tracking != CategoryTracking.object.value or bool(p.bulk) if not food_like: continue name, factor = display_unit_info(p) rows.append(LotRow( id=lot.id, product_id=p.id, product_name=p.name, product_brand=p.brand, tracking=CategoryTracking(tracking), quantity=lot.quantity, base_unit=p.base_unit, package_size=p.package_size, package_label=p.package_label, unit_name=name, unit_factor=factor, best_before=lot.best_before, best_before_precision=lot.best_before_precision, location_id=lot.location_id, created_at=lot.created_at, )) return rows @router.post("/lots/bulk-location", response_model=int) def bulk_lot_location( payload: BulkLotLocation, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> int: """Lagerort mehrerer Chargen auf einmal setzen (oder mit null leeren). Reine Ortsangabe – kein Umbuchen mit Bewegung. Gibt die Anzahl geänderter Zeilen.""" if not payload.lot_ids: return 0 if payload.location_id is not None and db.get(Location, payload.location_id) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") n = ( db.query(Lot) .filter(Lot.id.in_(payload.lot_ids)) .update({Lot.location_id: payload.location_id}, synchronize_session=False) ) db.flush() # Am Zielort entstandene Dubletten (gleicher Artikel + MHD) zusammenfassen. gruppen = ( db.query(Lot.product_id, Lot.best_before, Lot.best_before_precision) .filter(Lot.id.in_(payload.lot_ids)) .distinct() .all() ) for product_id, best_before, precision in gruppen: consolidate_lot_group(db, product_id, best_before, precision, payload.location_id) db.commit() return n @router.post("/lots/{lot_id}/split", response_model=LotOut) def split_lot( lot_id: int, payload: LotSplit, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> Lot: """Charge aufteilen: `quantity` (Basiseinheiten) abzweigen und als neue Charge mit gleichem MHD an einen anderen Lagerort legen. Der Rest bleibt in der alten Charge. Reine Umbuchung wie das Setzen des Lagerorts – kein Bewegungseintrag, der Gesamtbestand ändert sich nicht. Gibt die neu entstandene Charge zurück.""" lot = db.get(Lot, lot_id) if lot is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden") # Nur echt weniger als die Gesamtmenge lässt sich abteilen – die ganze Menge # zu verschieben ist Sache des Lagerort-Feldes, nicht des Aufteilens. if payload.quantity >= lot.quantity - 1e-9: raise HTTPException( status.HTTP_400_BAD_REQUEST, "So viel enthält die Charge nicht – zum kompletten Umlagern das " "Lagerort-Feld nutzen.", ) if (payload.location_id or None) == (lot.location_id or None): raise HTTPException( status.HTTP_400_BAD_REQUEST, "Bitte einen anderen Lagerort als den aktuellen wählen.", ) if payload.location_id is not None and db.get(Location, payload.location_id) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") lot.quantity -= payload.quantity neu = Lot( product_id=lot.product_id, quantity=payload.quantity, best_before=lot.best_before, best_before_precision=lot.best_before_precision, location_id=payload.location_id, ) db.add(neu) db.flush() # Liegt am Zielort schon eine Charge mit gleichem MHD, verschmelzen beide. survivor = consolidate_lot_group( db, lot.product_id, neu.best_before, neu.best_before_precision, payload.location_id ) db.commit() db.refresh(survivor) return survivor @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.flush() # Ist die Charge durch die Änderung (Lagerort/MHD) zur Dublette einer anderen # geworden, werden beide zusammengefasst. survivor = consolidate_lot_group( db, lot.product_id, lot.best_before, lot.best_before_precision, lot.location_id ) db.commit() db.refresh(survivor) return survivor @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()