feat(inventory): INV-007 Ausgabe/Rückgabe von Material und Leihgeräten
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:
@@ -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,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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user