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
+41
View File
@@ -0,0 +1,41 @@
"""INV-007: personenbezogene Ausgabe/Rueckgabe von Material/Leihgeraeten.
Revision ID: 0021_ausgabe
Revises: 0020_sollmenge_vorlage
Create Date: 2026-09-06
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0021_ausgabe"
down_revision: Union[str, None] = "0020_sollmenge_vorlage"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE TYPE ausgabe_status AS ENUM ('offen', 'zurueck')")
op.execute(
"""
CREATE TABLE ausgabe (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
material_id INTEGER NOT NULL REFERENCES material(id),
geraet_instanz_id UUID REFERENCES geraet_instanz(id),
menge NUMERIC NOT NULL,
empfaenger_id INTEGER NOT NULL REFERENCES benutzer(id),
ausgegeben_von INTEGER NOT NULL REFERENCES benutzer(id),
ausgegeben_am TIMESTAMPTZ NOT NULL,
rueckgabe_am TIMESTAMPTZ,
status ausgabe_status NOT NULL DEFAULT 'offen',
zweck TEXT
)
"""
)
op.execute("CREATE INDEX idx_ausgabe_empfaenger ON ausgabe (empfaenger_id)")
op.execute("CREATE INDEX idx_ausgabe_status ON ausgabe (status)")
def downgrade() -> None:
op.execute("DROP TABLE ausgabe")
op.execute("DROP TYPE ausgabe_status")
+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
+53
View File
@@ -0,0 +1,53 @@
import pytest
from tests.conftest import auth_header, login
@pytest.mark.asyncio
async def test_ausgabe_rueckgabe_zyklus(client, admin_user, materialverantwortlicher_user, material_factory):
token = await login(client, "admin1")
material = await material_factory("Funkgerät")
ausgabe = await client.post(
"/api/v1/ausgaben",
json={
"material_id": material.id,
"menge": "1",
"empfaenger_id": materialverantwortlicher_user.id,
"zweck": "Übung",
},
headers=auth_header(token),
)
assert ausgabe.status_code == 201
daten = ausgabe.json()
assert daten["status"] == "offen"
assert daten["rueckgabe_am"] is None
assert daten["ausgegeben_von"] == admin_user.id
offene = await client.get("/api/v1/ausgaben?status_filter=offen", headers=auth_header(token))
assert len(offene.json()) == 1
rueckgabe = await client.post(
f"/api/v1/ausgaben/{daten['id']}/rueckgabe", headers=auth_header(token)
)
assert rueckgabe.status_code == 200
assert rueckgabe.json()["status"] == "zurueck"
assert rueckgabe.json()["rueckgabe_am"] is not None
doppelt = await client.post(
f"/api/v1/ausgaben/{daten['id']}/rueckgabe", headers=auth_header(token)
)
assert doppelt.status_code == 409
offene2 = await client.get("/api/v1/ausgaben?status_filter=offen", headers=auth_header(token))
assert len(offene2.json()) == 0
@pytest.mark.asyncio
async def test_rueckgabe_unbekannte_ausgabe_404(client, admin_user):
token = await login(client, "admin1")
response = await client.post(
"/api/v1/ausgaben/00000000-0000-0000-0000-000000000000/rueckgabe",
headers=auth_header(token),
)
assert response.status_code == 404