diff --git a/arbeitskacheln/06_inventory.md b/arbeitskacheln/06_inventory.md index 21da6ec..ccd2231 100644 --- a/arbeitskacheln/06_inventory.md +++ b/arbeitskacheln/06_inventory.md @@ -175,13 +175,19 @@ Details zu INV-001 … INV-007 (vollständig). - **Akzeptanzkriterien:** Ausgabe erzeugt offenen Vorgang, Rückgabe schließt ihn, überfällige Rückgaben erkennbar. - **Tests:** Ausgabe-/Rückgabe-Test, Überfällig-Test. -- **DoD:** mind. ein vollständiger Ausgabe-Rückgabe-Zyklus getestet. +- **DoD:** umgesetzt (2026-09-06). Modell `ausgabe` (Material + optional + `geraet_instanz_id`), `POST /ausgaben`, `GET /ausgaben?status_filter=&empfaenger_id=`, + `POST /ausgaben/{id}/rueckgabe`. Frontend als Admin-Tab "Ausgabe/Rückgabe" + (Empfänger-Auswahl braucht die Benutzerliste, die nur Verantwortliche laden + dürfen - Backend selbst erlaubt jedem eingeloggten Nutzer Ausgabe/Rückgabe, + Feld-Erfassung durch Mitarbeiter wäre ein späterer Ausbauschritt mit eigener + Namenssuche statt vollem `/benutzer`-Endpunkt). Vollständiger + Ausgabe-Rückgabe-Zyklus + Doppel-Rückgabe-Schutz getestet. --- -**MABEA-Ist-Stand-Abgleich:** INV-001…003 sowie INV-005/006 vollständig vorhanden — INV-006 -(Fehlbestand+Mindermenge) ist sogar eines der am weitesten ausgereiften Module im gesamten -Projekt. **INV-004 (echter struktureller Umbau, siehe oben) und INV-007 (Ausgabe/Rückgabe) -fehlen komplett:** MABEA kennt nur Standortzuordnung von Objekten (Lagerbewegung), aber kein -personenbezogenes Ausleih-Konzept für Verbrauchsmaterial/Einzelteile — „wer hat gerade das -Funkgerät" ist aktuell nicht beantwortbar. +**MABEA-Ist-Stand-Abgleich (aktualisiert 2026-09-06):** INV-001…003 sowie INV-005/006/007 +vollständig vorhanden — INV-006 (Fehlbestand+Mindermenge) ist eines der am weitesten +ausgereiften Module im gesamten Projekt, INV-007 (Ausgabe/Rückgabe) beantwortet jetzt +"wer hat gerade das Funkgerät". **Nur INV-004 (echter struktureller Umbau, siehe oben) +fehlt noch.** diff --git a/backend/alembic/versions/0021_ausgabe.py b/backend/alembic/versions/0021_ausgabe.py new file mode 100644 index 0000000..683f8b7 --- /dev/null +++ b/backend/alembic/versions/0021_ausgabe.py @@ -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") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index c27097c..8e4ad65 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -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"]) diff --git a/backend/app/api/v1/endpoints/ausgabe.py b/backend/app/api/v1/endpoints/ausgabe.py new file mode 100644 index 0000000..4b6ca87 --- /dev/null +++ b/backend/app/api/v1/endpoints/ausgabe.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3c4144a..6557b0e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/ausgabe.py b/backend/app/models/ausgabe.py new file mode 100644 index 0000000..bf46874 --- /dev/null +++ b/backend/app/models/ausgabe.py @@ -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) diff --git a/backend/app/schemas/ausgabe.py b/backend/app/schemas/ausgabe.py new file mode 100644 index 0000000..c5e0259 --- /dev/null +++ b/backend/app/schemas/ausgabe.py @@ -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 diff --git a/backend/app/services/ausgabe.py b/backend/app/services/ausgabe.py new file mode 100644 index 0000000..458af76 --- /dev/null +++ b/backend/app/services/ausgabe.py @@ -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 diff --git a/backend/tests/test_ausgabe.py b/backend/tests/test_ausgabe.py new file mode 100644 index 0000000..1a7d257 --- /dev/null +++ b/backend/tests/test_ausgabe.py @@ -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 diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 5e95434..1a53a94 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -130,6 +130,19 @@ export interface Materialbewegung { grund: string | null; } +export interface Ausgabe { + id: string; + material_id: number; + geraet_instanz_id: string | null; + menge: string; + empfaenger_id: number; + ausgegeben_von: number; + ausgegeben_am: string; + rueckgabe_am: string | null; + status: "offen" | "zurueck"; + zweck: string | null; +} + export interface Fehlbestand { id: string; objekt_id: number; diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index eca61a1..a29fbd3 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -69,6 +69,7 @@ export function CommandPalette() { gehe: () => navigate("/admin?tab=lager"), }, { id: "nav-lager", label: "Lager", gehe: () => navigate("/admin?tab=lager") }, + { id: "nav-ausgabe", label: "Ausgabe/Rückgabe", gehe: () => navigate("/admin?tab=ausgabe") }, { id: "nav-personal", label: "Personal", gehe: () => navigate("/admin?tab=personal") }, { id: "nav-material", diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index d30a098..bbe5173 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { apiRequest } from "../api/client"; +import { AusgabeSection } from "./admin/AusgabeSection"; import { useAuth } from "../auth/AuthContext"; import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Fach, Kategorie, Material, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types"; import { BenutzerSection } from "./admin/BenutzerSection"; @@ -37,6 +38,7 @@ type Tab = | "personal" | "lagerbewegungen" | "lager" + | "ausgabe" | "rollen"; // Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte @@ -69,6 +71,11 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"], }, { key: "lager", label: "Lager", rollen: ["administration", "materialverantwortlicher"] }, + { + key: "ausgabe", + label: "Ausgabe/Rückgabe", + rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"], + }, { key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] }, ]; @@ -237,6 +244,9 @@ export function AdminPage() { )} {aktiverTab === "lager" && } + {aktiverTab === "ausgabe" && ( + + )} {aktiverTab === "rollen" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( diff --git a/frontend/src/pages/admin/AusgabeSection.tsx b/frontend/src/pages/admin/AusgabeSection.tsx new file mode 100644 index 0000000..01d97e6 --- /dev/null +++ b/frontend/src/pages/admin/AusgabeSection.tsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { Ausgabe, Benutzer, Material } from "../../api/types"; + +interface Props { + materialien: Material[]; + benutzer: Benutzer[]; + onFehler: (text: string) => void; +} + +// INV-007: personenbezogene Ausgabe/Rückgabe - ergänzt die reine Standort- +// Sicht (Lagerbewegung/Materialbewegung) um "wer hat das Ding gerade". +export function AusgabeSection({ materialien, benutzer, onFehler }: Props) { + const [ausgaben, setAusgaben] = useState([]); + const [nurOffene, setNurOffene] = useState(true); + const [laedt, setLaedt] = useState(true); + + const [neuMaterial, setNeuMaterial] = useState(""); + const [neuMenge, setNeuMenge] = useState("1"); + const [neuEmpfaenger, setNeuEmpfaenger] = useState(""); + const [neuZweck, setNeuZweck] = useState(""); + + function materialName(id: number): string { + const m = materialien.find((x) => x.id === id); + return m ? m.name : `#${id}`; + } + + function benutzerName(id: number): string { + const b = benutzer.find((x) => x.id === id); + return b ? b.name : `#${id}`; + } + + async function laden() { + setLaedt(true); + try { + const query = nurOffene ? "?status_filter=offen" : ""; + setAusgaben(await apiRequest(`/ausgaben${query}`)); + } catch { + onFehler("Ausgaben konnten nicht geladen werden."); + } finally { + setLaedt(false); + } + } + + useEffect(() => { + laden(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nurOffene]); + + async function ausgeben() { + if (neuMaterial === "" || neuEmpfaenger === "" || !neuMenge) { + onFehler("Material, Menge und Empfänger angeben."); + return; + } + try { + await apiRequest("/ausgaben", { + method: "POST", + body: { + material_id: neuMaterial, + menge: neuMenge, + empfaenger_id: neuEmpfaenger, + zweck: neuZweck || null, + }, + }); + setNeuMenge("1"); + setNeuZweck(""); + await laden(); + } catch { + onFehler("Ausgabe fehlgeschlagen."); + } + } + + async function rueckgabe(id: string) { + try { + await apiRequest(`/ausgaben/${id}/rueckgabe`, { method: "POST" }); + await laden(); + } catch { + onFehler("Rückgabe fehlgeschlagen."); + } + } + + return ( +
+

Ausgabe / Rückgabe

+ +
+

Neue Ausgabe

+
+ + setNeuMenge(e.target.value)} + /> + + setNeuZweck(e.target.value)} + /> + +
+
+ +
+
+

Ausgaben

+ +
+ + {laedt &&

Lade…

} + {!laedt && ausgaben.length === 0 &&

Keine Ausgaben.

} + + {!laedt && ausgaben.length > 0 && ( +
+ + + + + + + + + + + + + + {ausgaben.map((a) => ( + + + + + + + + + + ))} + +
Ausgegeben amMaterialMengeEmpfängerZweckStatus
{new Date(a.ausgegeben_am).toLocaleString("de-DE")}{materialName(a.material_id)}{a.menge}{benutzerName(a.empfaenger_id)}{a.zweck ?? "–"} + + {a.status === "offen" ? "offen" : "zurück"} + + + {a.status === "offen" && ( + + )} +
+
+ )} +
+
+ ); +}