Files
Vorrania/backend/app/routers/stock.py
Scarriffle 231d86895f Charge aufteilen: Teilmenge mit gleichem MHD an anderen Lagerort umlagern
Bisher liess sich nur die ganze Charge umlagern. Neu: POST /lots/{id}/split zweigt
eine Teilmenge (Basiseinheiten) als neue Charge mit gleichem MHD an einen anderen
Lagerort ab; der Rest bleibt. Reine Umbuchung ohne Bewegungseintrag, Gesamtbestand
unveraendert. Schutz gegen zu grosse Menge und gleichen Zielort.

Web: gemeinsamer SplitLotDialog (Menge in Artikeleinheit + Zielort, zeigt Rest),
Aufteilen-Knopf auf der Chargen-Seite und in der Chargen-Tabelle der Artikelseite.
Neues Split-Icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 08:43:16 +02:00

414 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
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.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.commit()
db.refresh(neu)
return neu
@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()