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>
414 lines
14 KiB
Python
414 lines
14 KiB
Python
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()
|