feat(assets): ASSET-012 Ressourcen-Buchungskalender (Verfügbarkeitsprüfung)
CI / backend-tests (push) Successful in 2m59s
CI / frontend-build (push) Successful in 18s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 16:51:17 +02:00
1 parent 1ad693073e
commit 3d596ab456
14 files changed
+438 -6

No files matched your search

+9 -6
View File
@@ -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`.
---
@@ -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")
+2
View File
@@ -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"])
@@ -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)
+2
View File
@@ -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",
+24
View File
@@ -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)
+22
View File
@@ -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
+74
View File
@@ -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()
+70
View File
@@ -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
+10
View File
@@ -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;
@@ -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<Ressourcenbuchung[]>([]);
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<Ressourcenbuchung[]>(`/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 (
<div className="stack">
<div className="row" style={{ flexWrap: "wrap" }}>
<input
className="input"
placeholder="Bezeichnung (z.B. Lehrgang X)"
value={bezeichnung}
onChange={(e) => setBezeichnung(e.target.value)}
/>
<input type="datetime-local" className="input" value={von} onChange={(e) => setVon(e.target.value)} />
<input type="datetime-local" className="input" value={bis} onChange={(e) => setBis(e.target.value)} />
<button className="btn btn-primary" onClick={anlegen} disabled={!bezeichnung || !von || !bis || wirdAngelegt}>
Buchen
</button>
</div>
{laedt && <p className="text-muted">Lade…</p>}
{!laedt && buchungen.length === 0 && <p className="text-muted">Keine Buchungen.</p>}
{!laedt && buchungen.length > 0 && (
<ul className="card-list">
{buchungen.map((b) => (
<li key={b.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
{b.bezeichnung} —{" "}
{new Date(b.von).toLocaleString("de-DE")} bis {new Date(b.bis).toLocaleString("de-DE")}
</span>
<button className="btn btn-secondary" onClick={() => entfernen(b.id)}>
Entfernen
</button>
</li>
))}
</ul>
)}
</div>
);
}
+6
View File
@@ -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() {
<ReadinessVerlaufPanel objektId={objekt.id} />
</div>
<div className="akte-ressourcenbuchung card">
<h3 style={{ marginTop: 0 }}>Buchungen</h3>
<RessourcenbuchungPanel objektId={objekt.id} onFehler={setFehler} />
</div>
<div className="akte-historie card">
<div className="row-between" style={{ alignItems: "baseline" }}>
<h3 style={{ marginTop: 0 }}>Historie</h3>
+2
View File
@@ -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; }
@@ -23,6 +23,7 @@
"tankbuch tankbuch"
"einsatzhistorie einsatzhistorie"
"readiness readiness"
"ressourcenbuchung ressourcenbuchung"
"historie historie";
}
}