Verwaltbare Einheiten mit Umrechnung + Chargen bearbeiten + gezieltes Auslagern

Einheiten (neu):
- Tabelle units (name, kind=count|weight|volume, factor, is_builtin); eingebaut
  Stueck/Gramm/Kilogramm/Milliliter/Liter, Admin kann eigene anlegen (z.B. Pfund=500g).
- Bestaende bleiben intern in kanonischer Basis (Stueck/Gramm/Milliliter);
  Product.display_unit_id und Group.min_stock_unit_id als nullable FKs.
- Neuer Umrechnungs-Service (services/conversion.py) ersetzt die feste Einheitenlogik;
  Ein-/Auslagern und Gruppen-Mindestbestand rechnen ueber den Faktor.
- Gruppen-Mindestbestand mit Einheit; Gruppenbestand summiert nur Produkte
  passender Art. Neue Verwaltungsseite "Einheiten" (Admin).
- Schonende Migration beim Start: ADD COLUMN IF NOT EXISTS (Postgres), damit
  bestehende Installationen ihre Daten behalten.

Chargen:
- PATCH /lots/{id} und DELETE /lots/{id}: Menge/MHD korrigieren, Charge loeschen
  (wird als Korrektur-Bewegung protokolliert). Bearbeitung im Produktdetail.

Auslagern:
- Optionales lot_id: gezielt aus einer bestimmten Charge/MHD abbuchen statt FEFO;
  Auswahl-Dropdown in der Auslagern-Seite.

Sonstiges: Roadmap aktualisiert, Tests fuer die Umrechnung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 12:33:14 +02:00
parent d4e7ff8e3d
commit 0310cdfbd6
26 changed files with 1015 additions and 177 deletions

View File

@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from ..crud import resolve_product
from ..database import get_db
from ..deps import get_current_user
from ..models import Lot, User
from ..models import Lot, Movement, MovementType, User
from ..schemas import (
BatchCheckInRequest,
BatchCheckInResponse,
@@ -13,9 +13,16 @@ from ..schemas import (
CheckOutRequest,
CheckOutResponse,
LotOut,
LotUpdate,
)
from ..services.conversion import ConversionError
from ..services.stock import (
StockError,
check_in,
check_out,
check_out_lot,
current_stock,
)
from ..services.stock import StockError, check_in, check_out, current_stock
from ..services.units import UnitError
router = APIRouter(tags=["stock"])
@@ -38,7 +45,7 @@ def stock_checkin(
user=user,
note=payload.note,
)
except UnitError as exc:
except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
db.commit()
db.refresh(lot)
@@ -74,7 +81,7 @@ def stock_checkin_batch(
note=payload.note,
)
)
except UnitError as exc:
except ConversionError as exc:
db.rollback()
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
db.commit()
@@ -94,15 +101,31 @@ def stock_checkout(
) -> CheckOutResponse:
product = resolve_product(db, payload.product_id, payload.barcode)
try:
affected = check_out(
db,
product=product,
quantity=payload.quantity,
unit=payload.unit,
user=user,
note=payload.note,
)
except UnitError as exc:
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
@@ -122,3 +145,63 @@ def list_lots(
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)
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()