diff --git a/arbeitskacheln/04_assets.md b/arbeitskacheln/04_assets.md index 4aa4f2d..4a06102 100644 --- a/arbeitskacheln/04_assets.md +++ b/arbeitskacheln/04_assets.md @@ -469,12 +469,15 @@ Details zu ASSET-001 … ASSET-010. ASSET-010 ergänzt am 2026-09-09 aus Open-So - **Tests:** Überschneidungstest (exakte Deckung, Teilüberschneidung, Randfall exakt aneinandergrenzend — grenzt NICHT als Konflikt, siehe Standard-Intervall-Semantik). -- **DoD:** offen. -- **Referenz (HiOrg-Server-Vergleich 2026-09-10):** HiOrg-Server bietet - Ressourcen-/Terminverwaltung für Material und Fahrzeuge — bewusst nur der - Verfügbarkeitsprüfungs-Teil übernommen, keine volle - Termin-/Veranstaltungsverwaltung (das wäre Scope-Creep über MABEAs - Materialmanagement-Kernauftrag hinaus). +- **DoD:** ✅ umgesetzt (2026-09-10). `ressourcenbuchung` (Migration 0049, + DB-CHECK `von < bis`), Überschneidungsprüfung in + `app/services/ressourcenbuchung.py` (Standard-Intervall-Overlap, + aneinandergrenzende Zeiträume sind KEIN Konflikt). `GET + /objekte/{id}/ressourcenbuchungen`, `POST /ressourcenbuchungen` (409 bei + Konflikt), `DELETE /ressourcenbuchungen/{id}`. Frontend: einfache Liste + (kein Kalender-Widget, wie in der Kachel als MVP-Ausreichend markiert) in + der Akte JEDES Objekts (nicht nur Fahrzeuge — Material-Sets/Geräte können + genauso gebucht werden). `backend/tests/test_ressourcenbuchung.py`. --- diff --git a/backend/alembic/versions/0049_ressourcenbuchung.py b/backend/alembic/versions/0049_ressourcenbuchung.py new file mode 100644 index 0000000..d989582 --- /dev/null +++ b/backend/alembic/versions/0049_ressourcenbuchung.py @@ -0,0 +1,37 @@ +"""ASSET-012: Ressourcen-Buchungskalender (reine Verfügbarkeitsprüfung). + +Revision ID: 0049_ressourcenbuchung +Revises: 0048_verleih +Create Date: 2026-09-10 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "0049_ressourcenbuchung" +down_revision: Union[str, None] = "0048_verleih" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE ressourcenbuchung ( + id UUID PRIMARY KEY, + objekt_id INTEGER NOT NULL REFERENCES objekt(id), + von TIMESTAMPTZ NOT NULL, + bis TIMESTAMPTZ NOT NULL, + bezeichnung TEXT NOT NULL, + gebucht_von_benutzer_id INTEGER NOT NULL REFERENCES benutzer(id), + erstellt_am TIMESTAMPTZ NOT NULL, + CHECK (von < bis) + ) + """ + ) + op.execute("CREATE INDEX ix_ressourcenbuchung_objekt ON ressourcenbuchung (objekt_id, von, bis)") + + +def downgrade() -> None: + op.execute("DROP TABLE ressourcenbuchung") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 1567504..5dcf6f7 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -27,6 +27,7 @@ from app.api.v1.endpoints import ( pruefung, readiness_regel, readiness_snapshot, + ressourcenbuchung, stammdaten, suche, tankbuch, @@ -73,6 +74,7 @@ api_router.include_router(wartung.router, tags=["wartung"]) api_router.include_router(suche.router, tags=["suche"]) api_router.include_router(tankbuch.router, tags=["tankbuch"]) api_router.include_router(verleih.router, tags=["verleih"]) +api_router.include_router(ressourcenbuchung.router, tags=["ressourcenbuchung"]) api_router.include_router(readiness_snapshot.router, tags=["readiness_snapshot"]) api_router.include_router(readiness_regel.router, tags=["readiness_regel"]) api_router.include_router(einsatz.router, tags=["einsatz"]) diff --git a/backend/app/api/v1/endpoints/ressourcenbuchung.py b/backend/app/api/v1/endpoints/ressourcenbuchung.py new file mode 100644 index 0000000..7cbb990 --- /dev/null +++ b/backend/app/api/v1/endpoints/ressourcenbuchung.py @@ -0,0 +1,76 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import require_roles +from app.db.session import get_db +from app.models.auth import Benutzer, RolleTyp +from app.models.objekt import Objekt +from app.models.ressourcenbuchung import Ressourcenbuchung +from app.schemas.ressourcenbuchung import RessourcenbuchungCreate, RessourcenbuchungRead +from app.services.ressourcenbuchung import ( + BuchungskonfliktError, + UngueltigerZeitraumError, + erstelle_buchung, + liste_buchungen_fuer_objekt, + loesche_buchung, +) + +router = APIRouter() + +_mitarbeiter_plus = require_roles( + RolleTyp.mitarbeiter, + RolleTyp.materialverantwortlicher, + RolleTyp.leitungsverantwortlicher, + RolleTyp.administration, +) +_materialverantwortliche = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlicher) + + +@router.get("/objekte/{objekt_id}/ressourcenbuchungen", response_model=list[RessourcenbuchungRead]) +async def hole_buchungen( + objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(_mitarbeiter_plus) +) -> list[Ressourcenbuchung]: + return await liste_buchungen_fuer_objekt(db, objekt_id) + + +@router.post("/ressourcenbuchungen", response_model=RessourcenbuchungRead, status_code=status.HTTP_201_CREATED) +async def erstelle_ressourcenbuchung( + payload: RessourcenbuchungCreate, + db: AsyncSession = Depends(get_db), + current_user: Benutzer = Depends(_mitarbeiter_plus), +) -> RessourcenbuchungRead: + if await db.get(Objekt, payload.objekt_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") + try: + return await erstelle_buchung( + db, + objekt_id=payload.objekt_id, + von=payload.von, + bis=payload.bis, + bezeichnung=payload.bezeichnung, + benutzer_id=current_user.id, + ) + except UngueltigerZeitraumError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="von muss vor bis liegen" + ) from exc + except BuchungskonfliktError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "Zeitraum überschneidet sich mit bestehender Buchung", + "konflikte": [str(k.id) for k in exc.konflikte], + }, + ) from exc + + +@router.delete("/ressourcenbuchungen/{buchung_id}", status_code=status.HTTP_204_NO_CONTENT) +async def entferne_ressourcenbuchung( + buchung_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche) +) -> None: + buchung = await db.get(Ressourcenbuchung, buchung_id) + if buchung is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Buchung nicht gefunden") + await loesche_buchung(db, buchung) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5219ceb..f80b132 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -21,6 +21,7 @@ from app.models.objektposition import Objektposition, ObjektpositionStatus from app.models.permission import Berechtigung, BenutzerRolleZuordnung, Rolle, RolleBerechtigung from app.models.pruefung import ObjekttypPruefart, Pruefart, Pruefung, PruefungErgebnis from app.models.readiness_regel import ReadinessRegel +from app.models.ressourcenbuchung import Ressourcenbuchung from app.models.readiness_snapshot import ReadinessSnapshot, ReadinessZustand from app.models.personal import ( BenutzerQualifikation, @@ -97,6 +98,7 @@ __all__ = [ "Pruefung", "PruefungErgebnis", "ReadinessRegel", + "Ressourcenbuchung", "ReadinessSnapshot", "ReadinessZustand", "BenutzerQualifikation", diff --git a/backend/app/models/ressourcenbuchung.py b/backend/app/models/ressourcenbuchung.py new file mode 100644 index 0000000..56ec75f --- /dev/null +++ b/backend/app/models/ressourcenbuchung.py @@ -0,0 +1,24 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Ressourcenbuchung(Base): + """ASSET-012: reine Verfügbarkeitsprüfung für Material/Fahrzeuge - KEINE + Personal-Dienstplanung (bewusste Abgrenzung, siehe Kachel-Dokumentation). + "ist Objekt X am Zeitraum Y schon verplant" statt Personaleinsatz.""" + + __tablename__ = "ressourcenbuchung" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False) + von: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + bis: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + bezeichnung: Mapped[str] = mapped_column(String, nullable=False) + gebucht_von_benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False) + erstellt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) diff --git a/backend/app/schemas/ressourcenbuchung.py b/backend/app/schemas/ressourcenbuchung.py new file mode 100644 index 0000000..8e8d0e7 --- /dev/null +++ b/backend/app/schemas/ressourcenbuchung.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class RessourcenbuchungCreate(BaseModel): + objekt_id: int + von: datetime + bis: datetime + bezeichnung: str + + +class RessourcenbuchungRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + objekt_id: int + von: datetime + bis: datetime + bezeichnung: str + gebucht_von_benutzer_id: int + erstellt_am: datetime diff --git a/backend/app/services/ressourcenbuchung.py b/backend/app/services/ressourcenbuchung.py new file mode 100644 index 0000000..4f45741 --- /dev/null +++ b/backend/app/services/ressourcenbuchung.py @@ -0,0 +1,74 @@ +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.ressourcenbuchung import Ressourcenbuchung + + +class UngueltigerZeitraumError(Exception): + pass + + +class BuchungskonfliktError(Exception): + def __init__(self, konflikte: list[Ressourcenbuchung]): + self.konflikte = konflikte + super().__init__(f"{len(konflikte)} überschneidende Buchung(en)") + + +async def _ueberschneidende_buchungen( + db: AsyncSession, *, objekt_id: int, von: datetime, bis: datetime +) -> list[Ressourcenbuchung]: + # Standard-Intervall-Overlap: von < bestehende.bis AND bis > bestehende.von + # - exakt aneinandergrenzende Zeiträume (bis == andere.von) gelten NICHT + # als Konflikt. + result = await db.execute( + select(Ressourcenbuchung).where( + Ressourcenbuchung.objekt_id == objekt_id, + Ressourcenbuchung.von < bis, + Ressourcenbuchung.bis > von, + ) + ) + return list(result.scalars().all()) + + +async def erstelle_buchung( + db: AsyncSession, + *, + objekt_id: int, + von: datetime, + bis: datetime, + bezeichnung: str, + benutzer_id: int, +) -> Ressourcenbuchung: + if von >= bis: + raise UngueltigerZeitraumError() + + konflikte = await _ueberschneidende_buchungen(db, objekt_id=objekt_id, von=von, bis=bis) + if konflikte: + raise BuchungskonfliktError(konflikte) + + buchung = Ressourcenbuchung( + objekt_id=objekt_id, + von=von, + bis=bis, + bezeichnung=bezeichnung, + gebucht_von_benutzer_id=benutzer_id, + erstellt_am=datetime.now(timezone.utc), + ) + db.add(buchung) + await db.commit() + await db.refresh(buchung) + return buchung + + +async def liste_buchungen_fuer_objekt(db: AsyncSession, objekt_id: int) -> list[Ressourcenbuchung]: + result = await db.execute( + select(Ressourcenbuchung).where(Ressourcenbuchung.objekt_id == objekt_id).order_by(Ressourcenbuchung.von) + ) + return list(result.scalars().all()) + + +async def loesche_buchung(db: AsyncSession, buchung: Ressourcenbuchung) -> None: + await db.delete(buchung) + await db.commit() diff --git a/backend/tests/test_ressourcenbuchung.py b/backend/tests/test_ressourcenbuchung.py new file mode 100644 index 0000000..4374688 --- /dev/null +++ b/backend/tests/test_ressourcenbuchung.py @@ -0,0 +1,70 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from tests.conftest import auth_header, login + + +def _zeit(stunden_ab_jetzt: int) -> str: + return (datetime.now(timezone.utc) + timedelta(hours=stunden_ab_jetzt)).isoformat() + + +@pytest.mark.asyncio +async def test_buchung_ohne_konflikt(client, db_session, mitarbeiter_user, standort_factory, objekt_factory): + standort = await standort_factory("Wache-Buchung") + objekt = await objekt_factory(name="RTW Buchung", code="BUCH-1", standort=standort) + token = await login(client, "mitarbeiter1") + + erste = await client.post( + "/api/v1/ressourcenbuchungen", + json={"objekt_id": objekt.id, "von": _zeit(0), "bis": _zeit(2), "bezeichnung": "Lehrgang A"}, + headers=auth_header(token), + ) + assert erste.status_code == 201 + + zweite = await client.post( + "/api/v1/ressourcenbuchungen", + json={"objekt_id": objekt.id, "von": _zeit(2), "bis": _zeit(4), "bezeichnung": "Lehrgang B"}, + headers=auth_header(token), + ) + # exakt aneinandergrenzend - kein Konflikt + assert zweite.status_code == 201 + + +@pytest.mark.asyncio +async def test_buchung_mit_konflikt_abgelehnt( + client, db_session, mitarbeiter_user, standort_factory, objekt_factory +): + standort = await standort_factory("Wache-Buchung2") + objekt = await objekt_factory(name="RTW Buchung2", code="BUCH-2", standort=standort) + token = await login(client, "mitarbeiter1") + + await client.post( + "/api/v1/ressourcenbuchungen", + json={"objekt_id": objekt.id, "von": _zeit(0), "bis": _zeit(4), "bezeichnung": "Lehrgang A"}, + headers=auth_header(token), + ) + + konflikt = await client.post( + "/api/v1/ressourcenbuchungen", + json={"objekt_id": objekt.id, "von": _zeit(2), "bis": _zeit(6), "bezeichnung": "Lehrgang B"}, + headers=auth_header(token), + ) + assert konflikt.status_code == 409 + + liste = await client.get(f"/api/v1/objekte/{objekt.id}/ressourcenbuchungen", headers=auth_header(token)) + assert len(liste.json()) == 1 + + +@pytest.mark.asyncio +async def test_buchung_ungueltiger_zeitraum(client, mitarbeiter_user, standort_factory, objekt_factory): + standort = await standort_factory("Wache-Buchung3") + objekt = await objekt_factory(name="RTW Buchung3", code="BUCH-3", standort=standort) + token = await login(client, "mitarbeiter1") + + response = await client.post( + "/api/v1/ressourcenbuchungen", + json={"objekt_id": objekt.id, "von": _zeit(4), "bis": _zeit(2), "bezeichnung": "Ungültig"}, + headers=auth_header(token), + ) + assert response.status_code == 422 diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c7049d3..2f3e629 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -479,6 +479,16 @@ export interface CustomRolle { berechtigung_ids: number[]; } +export interface Ressourcenbuchung { + id: string; + objekt_id: number; + von: string; + bis: string; + bezeichnung: string; + gebucht_von_benutzer_id: number; + erstellt_am: string; +} + export interface ExterneStelle { id: number; name: string; diff --git a/frontend/src/components/RessourcenbuchungPanel.tsx b/frontend/src/components/RessourcenbuchungPanel.tsx new file mode 100644 index 0000000..86dc715 --- /dev/null +++ b/frontend/src/components/RessourcenbuchungPanel.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react"; + +import { ApiError, apiRequest } from "../api/client"; +import type { Ressourcenbuchung } from "../api/types"; + +interface Props { + objektId: number; + onFehler: (text: string) => void; +} + +// ASSET-012: reine Verfügbarkeitsliste (kein Kalender-Widget im MVP) - +// verhindert Doppelbuchung, keine Personal-Dienstplanung. +export function RessourcenbuchungPanel({ objektId, onFehler }: Props) { + const [buchungen, setBuchungen] = useState([]); + const [laedt, setLaedt] = useState(true); + const [bezeichnung, setBezeichnung] = useState(""); + const [von, setVon] = useState(""); + const [bis, setBis] = useState(""); + const [wirdAngelegt, setWirdAngelegt] = useState(false); + + async function laden() { + setLaedt(true); + try { + setBuchungen(await apiRequest(`/objekte/${objektId}/ressourcenbuchungen`)); + } catch { + onFehler("Buchungen konnten nicht geladen werden."); + } finally { + setLaedt(false); + } + } + + useEffect(() => { + laden(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [objektId]); + + async function anlegen() { + if (!bezeichnung || !von || !bis) return; + setWirdAngelegt(true); + try { + await apiRequest("/ressourcenbuchungen", { + method: "POST", + body: { objekt_id: objektId, von: new Date(von).toISOString(), bis: new Date(bis).toISOString(), bezeichnung }, + }); + setBezeichnung(""); + setVon(""); + setBis(""); + await laden(); + } catch (fehler) { + if (fehler instanceof ApiError && fehler.status === 409) { + onFehler("Zeitraum überschneidet sich mit einer bestehenden Buchung."); + } else { + onFehler("Buchung konnte nicht angelegt werden."); + } + } finally { + setWirdAngelegt(false); + } + } + + async function entfernen(id: string) { + try { + await apiRequest(`/ressourcenbuchungen/${id}`, { method: "DELETE" }); + await laden(); + } catch { + onFehler("Buchung konnte nicht entfernt werden."); + } + } + + return ( +
+
+ setBezeichnung(e.target.value)} + /> + setVon(e.target.value)} /> + setBis(e.target.value)} /> + +
+ {laedt &&

Lade…

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

Keine Buchungen.

} + {!laedt && buchungen.length > 0 && ( +
    + {buchungen.map((b) => ( +
  • + + {b.bezeichnung} —{" "} + {new Date(b.von).toLocaleString("de-DE")} bis {new Date(b.bis).toLocaleString("de-DE")} + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/AktePage.tsx b/frontend/src/pages/AktePage.tsx index 756f4a8..97a1b63 100644 --- a/frontend/src/pages/AktePage.tsx +++ b/frontend/src/pages/AktePage.tsx @@ -9,6 +9,7 @@ import { EinsatzHistoriePanel } from "../components/EinsatzHistoriePanel"; import { HistorieIntegritaetBadge } from "../components/HistorieIntegritaetBadge"; import { NotizenPanel } from "../components/NotizenPanel"; import { ReadinessVerlaufPanel } from "../components/ReadinessVerlaufPanel"; +import { RessourcenbuchungPanel } from "../components/RessourcenbuchungPanel"; import { TableWrapper } from "../components/ui/table-wrapper"; import { TankbuchPanel } from "../components/TankbuchPanel"; import { WartungSection } from "../components/WartungSection"; @@ -313,6 +314,11 @@ export function AktePage() { +
+

Buchungen

+ +
+

Historie

diff --git a/frontend/src/styles/global/akte-grid.css b/frontend/src/styles/global/akte-grid.css index 7c4bb8f..039ffcf 100644 --- a/frontend/src/styles/global/akte-grid.css +++ b/frontend/src/styles/global/akte-grid.css @@ -14,6 +14,7 @@ "dokumente" "notizen" "readiness" + "ressourcenbuchung" "historie"; } @@ -30,4 +31,5 @@ .akte-tankbuch { grid-area: tankbuch; } .akte-einsatzhistorie { grid-area: einsatzhistorie; } .akte-readiness-verlauf { grid-area: readiness; } +.akte-ressourcenbuchung { grid-area: ressourcenbuchung; } .akte-historie { grid-area: historie; } diff --git a/frontend/src/styles/global/responsive.css b/frontend/src/styles/global/responsive.css index 85966f4..b57417c 100644 --- a/frontend/src/styles/global/responsive.css +++ b/frontend/src/styles/global/responsive.css @@ -23,6 +23,7 @@ "tankbuch tankbuch" "einsatzhistorie einsatzhistorie" "readiness readiness" + "ressourcenbuchung ressourcenbuchung" "historie historie"; } }