feat(inventory): INV-007 Ausgabe/Rückgabe von Material und Leihgeräten
CI / backend-tests (push) Successful in 2m1s
CI / frontend-build (push) Successful in 18s

Personenbezogene Ausgabe ergänzt die reine Standort-/Lagerplatz-Sicht
um "wer hat das Ding gerade": neues Modell ausgabe (Material +
optional geraet_instanz_id), POST /ausgaben, GET /ausgaben (Filter
status/empfaenger), POST /ausgaben/{id}/rueckgabe mit Schutz vor
Doppel-Rueckgabe. Frontend als Admin-Tab "Ausgabe/Rückgabe" +
Command-Palette-Eintrag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-06 13:19:31 +02:00
co-authored by Claude Sonnet 5
parent 16a42c6b85
commit b6ada2b5a1
13 changed files with 495 additions and 7 deletions
+2
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter
from app.api.v1.endpoints import (
akte,
ausgabe,
auth,
benutzer,
dashboard,
@@ -44,3 +45,4 @@ api_router.include_router(dokument.router, tags=["dokument"])
api_router.include_router(permission.router, tags=["permission"])
api_router.include_router(akte.router, tags=["akte"])
api_router.include_router(lager.router, tags=["lager"])
api_router.include_router(ausgabe.router, tags=["ausgabe"])
+63
View File
@@ -0,0 +1,63 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.session import get_db
from app.models.ausgabe import Ausgabe, AusgabeStatus
from app.models.auth import Benutzer
from app.schemas.ausgabe import AusgabeCreate, AusgabeRead
from app.services.ausgabe import AusgabeBereitsZurueckError, ausgeben, zurueckgeben
router = APIRouter()
@router.get("/ausgaben", response_model=list[AusgabeRead])
async def liste_ausgaben(
status_filter: AusgabeStatus | None = None,
empfaenger_id: int | None = None,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
) -> list[Ausgabe]:
stmt = select(Ausgabe)
if status_filter is not None:
stmt = stmt.where(Ausgabe.status == status_filter)
if empfaenger_id is not None:
stmt = stmt.where(Ausgabe.empfaenger_id == empfaenger_id)
stmt = stmt.order_by(Ausgabe.ausgegeben_am.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
@router.post("/ausgaben", response_model=AusgabeRead, status_code=status.HTTP_201_CREATED)
async def erstelle_ausgabe(
payload: AusgabeCreate,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(get_current_user),
) -> Ausgabe:
return await ausgeben(
db,
material_id=payload.material_id,
geraet_instanz_id=payload.geraet_instanz_id,
menge=payload.menge,
empfaenger_id=payload.empfaenger_id,
ausgegeben_von=current_user.id,
zweck=payload.zweck,
)
@router.post("/ausgaben/{ausgabe_id}/rueckgabe", response_model=AusgabeRead)
async def route_rueckgabe(
ausgabe_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> Ausgabe:
ausgabe = await db.get(Ausgabe, ausgabe_id)
if ausgabe is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ausgabe nicht gefunden")
try:
return await zurueckgeben(db, ausgabe=ausgabe)
except AusgabeBereitsZurueckError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Ausgabe wurde bereits zurückgegeben"
) from exc
+3
View File
@@ -1,3 +1,4 @@
from app.models.ausgabe import Ausgabe, AusgabeStatus
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp
from app.models.dokument import Dokument
from app.models.eskalation import EskalationKonfiguration
@@ -26,6 +27,8 @@ from app.models.vorlage import Beladungsvorlage, VorlageStatus, Vorlagenposition
from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit
__all__ = [
"Ausgabe",
"AusgabeStatus",
"Benutzer",
"BenutzerRolle",
"RolleTyp",
+41
View File
@@ -0,0 +1,41 @@
import enum
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import ForeignKey, Numeric, String
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AusgabeStatus(str, enum.Enum):
offen = "offen"
zurueck = "zurueck"
ausgabe_status_pg = PgEnum(AusgabeStatus, name="ausgabe_status", create_type=False)
class Ausgabe(Base):
"""INV-007: personenbezogene Ausgabe/Rückgabe von Material/Leihgeräten -
ergänzt die reine Standort-/Lagerplatz-Sicht (Lagerbewegung/Materialbewegung)
um "wer hat das Ding gerade", solange die Ausgabe offen ist."""
__tablename__ = "ausgabe"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False)
geraet_instanz_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("geraet_instanz.id")
)
menge: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
empfaenger_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
ausgegeben_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
ausgegeben_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
rueckgabe_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
status: Mapped[AusgabeStatus] = mapped_column(
ausgabe_status_pg, nullable=False, default=AusgabeStatus.offen
)
zweck: Mapped[str | None] = mapped_column(String)
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
from app.models.ausgabe import AusgabeStatus
class AusgabeCreate(BaseModel):
material_id: int
geraet_instanz_id: uuid.UUID | None = None
menge: Decimal
empfaenger_id: int
zweck: str | None = None
class AusgabeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
material_id: int
geraet_instanz_id: uuid.UUID | None
menge: Decimal
empfaenger_id: int
ausgegeben_von: int
ausgegeben_am: datetime
rueckgabe_am: datetime | None
status: AusgabeStatus
zweck: str | None
+45
View File
@@ -0,0 +1,45 @@
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ausgabe import Ausgabe, AusgabeStatus
class AusgabeBereitsZurueckError(Exception):
"""Eine bereits zurückgegebene Ausgabe kann nicht erneut zurückgegeben werden."""
async def ausgeben(
db: AsyncSession,
*,
material_id: int,
geraet_instanz_id: UUID | None,
menge: Decimal,
empfaenger_id: int,
ausgegeben_von: int,
zweck: str | None,
) -> Ausgabe:
ausgabe = Ausgabe(
material_id=material_id,
geraet_instanz_id=geraet_instanz_id,
menge=menge,
empfaenger_id=empfaenger_id,
ausgegeben_von=ausgegeben_von,
ausgegeben_am=datetime.now(timezone.utc),
status=AusgabeStatus.offen,
zweck=zweck,
)
db.add(ausgabe)
await db.flush()
return ausgabe
async def zurueckgeben(db: AsyncSession, *, ausgabe: Ausgabe) -> Ausgabe:
if ausgabe.status == AusgabeStatus.zurueck:
raise AusgabeBereitsZurueckError
ausgabe.status = AusgabeStatus.zurueck
ausgabe.rueckgabe_am = datetime.now(timezone.utc)
await db.flush()
return ausgabe