Files
Vorrania/backend/app/routers/stock.py
Scarriffle 05b54dc7ac Backend: gleiche Chargen (Artikel+MHD+Ort) werden zusammengefasst
Nach dem Umlagern/Aufteilen entstanden Dubletten: zwei Chargen desselben Artikels
mit gleichem MHD am gleichen Lagerort, obwohl das fuer den Nutzer eine Charge ist.
Neu: consolidate_lot_group fasst solche Chargen zusammen (Mengen addieren,
Bewegungen auf die aeltere Charge umhaengen, Rest loeschen) - aufgerufen nach
split, bulk-location und update_lot. Bestand und Historie bleiben unveraendert.
Beim Start raeumt consolidate_duplicate_lots einmalig bestehende Dubletten auf.

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

436 lines
15 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,
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()