diff --git a/arbeitskacheln/13_readiness.md b/arbeitskacheln/13_readiness.md index 6038994..b8930d0 100644 --- a/arbeitskacheln/13_readiness.md +++ b/arbeitskacheln/13_readiness.md @@ -25,12 +25,25 @@ Details zu READY-001 … READY-006. READY-001-005 vollständig, READY-006 ergän - **Akzeptanzkriterien:** Regeln iterierbar, Ergebnis = Liste erfüllter/nicht erfüllter Kriterien. - **Tests:** Regel-Iterations-Test. -- **DoD:** **HIGH RISK** (größte technische Unsicherheit im gesamten Backlog) — MABEA hat - stattdessen eine fest verdrahtete Funktion (`einsatzbereitschaft()` mit hartcodierten - Kriterien Fehlbestand/Ablauf/Prüfung/Sperre/Mangel/in_wartung/HU-UVV). Umbau zu einer - echten Regel-Engine ist ein substanzieller Umbau, kein Nachzug — vor Umsetzung klären, - ob der Konfigurierbarkeits-Bedarf real ist (bisher nur eine Organisation im Einsatz, ein - fester Kriterienkatalog reicht evtl. aus). +- **Bedarfsklärung 2026-09-10:** Nutzer hat sich trotz Empfehlung (nur eine + Organisation im Einsatz, fester Katalog deckt Bedarf ab) für eine + Umsetzung entschieden — bewusst als **abgespeckte Version** umgesetzt (siehe + DoD), keine volle Regel-Engine mit Gewichtung. +- **DoD:** ✅ umgesetzt (2026-09-10), **bewusst reduzierter Scope gegenüber + ursprünglicher Kachel-Beschreibung:** `readiness_regel` (Migration 0042, + sieben Zeilen = die sieben bisher hartcodierten Kriterien + Fehlbestand/Ablauf/Prüfung/Sperre/Mangel/in_wartung/HU-UVV), jede Zeile nur + **an/aus** (`aktiv: bool`) — KEINE Gewichtung, KEIN freier Regelkatalog + (neue Kriterien bräuchten weiterhin Code-Änderung). Die Kombinationslogik + selbst (Mindermenge-Sonderfall, „noch nie kontrolliert") bleibt fest + verdrahtet, nur die sieben Einzelkriterien sind umschaltbar. + `app/services/dashboard.py:aktive_regel_schluessel()` lädt den aktuellen + Stand bei jeder Auswertung neu (kein Cache, keine App-Neustart nötig nach + Umschalten). `GET/PATCH /readiness-regeln` (Administrator-only), Admin-Tab + „Bereitschaftsregeln". `backend/tests/test_readiness_regel.py` + (Regressionstest unverändertes Verhalten bei allen Regeln aktiv, + Deaktivierungstest, Rechte-Test). Wirkt auf READY-002/READY-004/READY-006/ + Akte-Readiness, da alle über `_kategorie_und_gruende()` laufen. ## READY-002 — Zustände READY/LIMITED/NOT_READY/UNKNOWN diff --git a/backend/alembic/versions/0042_readiness_regel.py b/backend/alembic/versions/0042_readiness_regel.py new file mode 100644 index 0000000..d2a4f58 --- /dev/null +++ b/backend/alembic/versions/0042_readiness_regel.py @@ -0,0 +1,46 @@ +"""READY-001: Regelkatalog für Einsatzbereitschaft (an/aus je Kriterium). + +Revision ID: 0042_readiness_regel +Revises: 0041_wartung_benachrichtigung +Create Date: 2026-09-10 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "0042_readiness_regel" +down_revision: Union[str, None] = "0041_wartung_benachrichtigung" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_REGELN = [ + ("fehlbestand_offen", "Offener, nicht genehmigter Fehlbestand"), + ("ablaufdatum_abgelaufen", "Abgelaufenes Ablaufdatum (Charge)"), + ("pruefung_ueberfaellig", "Überfällige Geräteprüfung"), + ("kontrolle_laeuft", "Kontrolle läuft gerade (Sperre)"), + ("mangel_kritisch_offen", "Kritischer, offener Mangel"), + ("in_wartung", "Objekt in Wartung (Status)"), + ("hu_uvv_ueberfaellig", "Überfällige HU/UVV (Fahrzeug)"), +] + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE readiness_regel ( + schluessel TEXT PRIMARY KEY, + beschreibung TEXT NOT NULL, + aktiv BOOLEAN NOT NULL DEFAULT true + ) + """ + ) + for schluessel, beschreibung in _REGELN: + op.execute( + "INSERT INTO readiness_regel (schluessel, beschreibung, aktiv) VALUES " + f"('{schluessel}', '{beschreibung}', true)" + ) + + +def downgrade() -> None: + op.execute("DROP TABLE readiness_regel") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 8966e1c..512aca9 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -22,6 +22,7 @@ from app.api.v1.endpoints import ( objekte, permission, personal, + readiness_regel, readiness_snapshot, stammdaten, suche, @@ -66,3 +67,4 @@ 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(readiness_snapshot.router, tags=["readiness_snapshot"]) +api_router.include_router(readiness_regel.router, tags=["readiness_regel"]) diff --git a/backend/app/api/v1/endpoints/readiness_regel.py b/backend/app/api/v1/endpoints/readiness_regel.py new file mode 100644 index 0000000..505939c --- /dev/null +++ b/backend/app/api/v1/endpoints/readiness_regel.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +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 RolleTyp +from app.models.readiness_regel import ReadinessRegel +from app.schemas.readiness_regel import ReadinessRegelRead, ReadinessRegelUpdate + +router = APIRouter() + +_admin_only = require_roles(RolleTyp.administration) + + +@router.get("/readiness-regeln", response_model=list[ReadinessRegelRead]) +async def liste_readiness_regeln( + db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> list[ReadinessRegel]: + result = await db.execute(select(ReadinessRegel).order_by(ReadinessRegel.schluessel)) + return list(result.scalars().all()) + + +@router.patch("/readiness-regeln/{schluessel}", response_model=ReadinessRegelRead) +async def aendere_readiness_regel( + schluessel: str, + payload: ReadinessRegelUpdate, + db: AsyncSession = Depends(get_db), + _=Depends(_admin_only), +) -> ReadinessRegel: + """READY-001: an/abschalten eines Einzelkriteriums - wirkt sofort auf + jede folgende Auswertung (kein Cache), da `aktive_regel_schluessel()` bei + jedem Aufruf frisch aus der DB liest.""" + regel = await db.get(ReadinessRegel, schluessel) + if regel is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Regel nicht gefunden") + regel.aktiv = payload.aktiv + await db.commit() + await db.refresh(regel) + return regel diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5bebbf2..b85ca56 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -17,6 +17,7 @@ from app.models.notiz import AkteNotiz from app.models.objekt import Objekt, ObjektStatus from app.models.objektposition import Objektposition, ObjektpositionStatus from app.models.permission import Berechtigung, BenutzerRolleZuordnung, Rolle, RolleBerechtigung +from app.models.readiness_regel import ReadinessRegel from app.models.readiness_snapshot import ReadinessSnapshot, ReadinessZustand from app.models.personal import ( BenutzerQualifikation, @@ -82,6 +83,7 @@ __all__ = [ "BenutzerRolleZuordnung", "Rolle", "RolleBerechtigung", + "ReadinessRegel", "ReadinessSnapshot", "ReadinessZustand", "BenutzerQualifikation", diff --git a/backend/app/models/readiness_regel.py b/backend/app/models/readiness_regel.py new file mode 100644 index 0000000..4c03741 --- /dev/null +++ b/backend/app/models/readiness_regel.py @@ -0,0 +1,24 @@ +from sqlalchemy import Boolean, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ReadinessRegel(Base): + """READY-001: Regelkatalog für `dashboard.einsatzbereitschaft()` - jede + Zeile entspricht einem der bisher hartcodierten Kriterien + (`_kategorie_und_gruende` in app/services/dashboard.py), `aktiv=False` + blendet das Kriterium aus der Bewertung aus. Bewusst NUR an/aus, keine + Gewichtung - die Kachel selbst nennt Gewichtung als "evtl."-Ausbaustufe, + nicht als Kernanforderung, und eine reine On/Off-Auswahl deckt den + genannten Bedarf ("Organisationen haben unterschiedliche Kriterien") ohne + die volle Komplexität einer gewichteten Regel-Engine. Die + Kombinationslogik selbst (z.B. "noch nie kontrolliert"/Mindermenge- + Sonderfall) bleibt fest verdrahtet - nur die sieben Einzelkriterien sind + umschaltbar, nicht die Fachlogik, wie sie sich kombinieren.""" + + __tablename__ = "readiness_regel" + + schluessel: Mapped[str] = mapped_column(String, primary_key=True) + beschreibung: Mapped[str] = mapped_column(String, nullable=False) + aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/backend/app/schemas/readiness_regel.py b/backend/app/schemas/readiness_regel.py new file mode 100644 index 0000000..69fa4ad --- /dev/null +++ b/backend/app/schemas/readiness_regel.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, ConfigDict + + +class ReadinessRegelRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + schluessel: str + beschreibung: str + aktiv: bool + + +class ReadinessRegelUpdate(BaseModel): + aktiv: bool diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index 63e88eb..f9bb5aa 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -12,6 +12,7 @@ from app.models.auth import Benutzer from app.models.objekt import Objekt, ObjektStatus from app.models.objektposition import Objektposition from app.models.personal import BenutzerQualifikation, Qualifikationstyp +from app.models.readiness_regel import ReadinessRegel from app.models.stammdaten import Material from app.services.mangel import objekt_ids_mit_kritischem_offenem_mangel from app.services.objekte import letzte_kontrollen_map @@ -101,6 +102,25 @@ async def bevorstehende_ablaufdaten(db: AsyncSession) -> list[dict]: return eintraege +async def aktive_regel_schluessel(db: AsyncSession) -> set[str]: + """READY-001: welche der sieben Einzelkriterien aktuell an sind - alle + fehlenden Zeilen (sollte praktisch nie vorkommen, Seed-Migration legt alle + sieben an) gelten als aktiv (Fail-Safe: lieber ein Kriterium zu viel + prüfen als eines fälschlich stumm zu schalten).""" + result = await db.execute(select(ReadinessRegel.schluessel, ReadinessRegel.aktiv)) + bekannt = {schluessel: aktiv for schluessel, aktiv in result.all()} + alle_schluessel = { + "fehlbestand_offen", + "ablaufdatum_abgelaufen", + "pruefung_ueberfaellig", + "kontrolle_laeuft", + "mangel_kritisch_offen", + "in_wartung", + "hu_uvv_ueberfaellig", + } + return {s for s in alle_schluessel if bekannt.get(s, True)} + + async def objekt_kategorien(db: AsyncSession) -> list[tuple[Objekt, str, list[str]]]: """Aus `einsatzbereitschaft()` extrahiert (READY-006), damit der Snapshot-Job (app/services/readiness_snapshot.py) dieselbe Fachlogik je @@ -195,6 +215,8 @@ async def objekt_kategorien(db: AsyncSession) -> list[tuple[Objekt, str, list[st .all() ) + aktive_regeln = await aktive_regel_schluessel(db) + return [ ( objekt, @@ -208,6 +230,7 @@ async def objekt_kategorien(db: AsyncSession) -> list[tuple[Objekt, str, list[st objekt_ids_gesperrt, objekt_ids_kritischer_mangel, objekt_ids_hu_uvv_ueberfaellig, + aktive_regeln, ), ) for objekt in objekte @@ -263,25 +286,43 @@ def _kategorie_und_gruende( objekt_ids_gesperrt: set, objekt_ids_kritischer_mangel: set, objekt_ids_hu_uvv_ueberfaellig: set, + aktive_regeln: set[str] | None = None, ) -> tuple[str, list[str]]: """aus der `einsatzbereitschaft()`-Schleife extrahiert, damit `objekt_readiness()` (Begründung auf der Objektakte) dieselbe Logik nutzt statt - sie zu duplizieren.""" + sie zu duplizieren. + + READY-001: `aktive_regeln` (None = alle sieben aktiv, Rückwärtskompatibilität + für Aufrufer ohne Regel-Engine-Anbindung) blendet einzelne Kriterien aus - + die KOMBINATIONSLOGIK selbst (Mindermenge-Sonderfall, "noch nie + kontrolliert") bleibt bewusst fest, nur die sieben Einzelkriterien sind + umschaltbar.""" + if aktive_regeln is None: + aktive_regeln = { + "fehlbestand_offen", + "ablaufdatum_abgelaufen", + "pruefung_ueberfaellig", + "kontrolle_laeuft", + "mangel_kritisch_offen", + "in_wartung", + "hu_uvv_ueberfaellig", + } + gruende: list[str] = [] ungenehmigt = fehlbestand_ids - fehlbestand_ids_genehmigt - if ungenehmigt: + if ungenehmigt and "fehlbestand_offen" in aktive_regeln: gruende.append("fehlbestand_offen") - if objekt.id in objekt_ids_abgelaufen: + if objekt.id in objekt_ids_abgelaufen and "ablaufdatum_abgelaufen" in aktive_regeln: gruende.append("ablaufdatum_abgelaufen") - if objekt.id in objekt_ids_pruefung_ueberfaellig: + if objekt.id in objekt_ids_pruefung_ueberfaellig and "pruefung_ueberfaellig" in aktive_regeln: gruende.append("pruefung_ueberfaellig") - if objekt.id in objekt_ids_gesperrt: + if objekt.id in objekt_ids_gesperrt and "kontrolle_laeuft" in aktive_regeln: gruende.append("kontrolle_laeuft") - if objekt.id in objekt_ids_kritischer_mangel: + if objekt.id in objekt_ids_kritischer_mangel and "mangel_kritisch_offen" in aktive_regeln: gruende.append("mangel_kritisch_offen") - if objekt.status == ObjektStatus.in_wartung: + if objekt.status == ObjektStatus.in_wartung and "in_wartung" in aktive_regeln: gruende.append("in_wartung") - if objekt.id in objekt_ids_hu_uvv_ueberfaellig: + if objekt.id in objekt_ids_hu_uvv_ueberfaellig and "hu_uvv_ueberfaellig" in aktive_regeln: gruende.append("hu_uvv_ueberfaellig") # "noch nie kontrolliert" gilt zusätzlich zu jedem anderen Grund, nicht nur @@ -375,6 +416,7 @@ async def objekt_readiness(db: AsyncSession, objekt_id: int) -> dict: ) ).first() is not None letzte_kontrolle_map = await letzte_kontrollen_map(db, [objekt_id]) + aktive_regeln = await aktive_regel_schluessel(db) kategorie, gruende = _kategorie_und_gruende( objekt, @@ -386,6 +428,7 @@ async def objekt_readiness(db: AsyncSession, objekt_id: int) -> dict: {objekt_id} if ist_gesperrt else set(), {objekt_id} if hat_kritischen_mangel else set(), {objekt_id} if hat_ueberfaellige_hu_uvv else set(), + aktive_regeln, ) return {"status": kategorie, "gruende": gruende} diff --git a/backend/tests/test_readiness_regel.py b/backend/tests/test_readiness_regel.py new file mode 100644 index 0000000..3ede1f5 --- /dev/null +++ b/backend/tests/test_readiness_regel.py @@ -0,0 +1,66 @@ +import pytest + +from app.models.objekt import ObjektStatus +from app.models.readiness_regel import ReadinessRegel +from app.services.dashboard import einsatzbereitschaft +from tests.conftest import auth_header, login + + +@pytest.mark.asyncio +async def test_in_wartung_zaehlt_normal_als_nicht_bereit(db_session, standort_factory, objekt_factory): + standort = await standort_factory("Wache-Regel-1") + objekt = await objekt_factory(name="Objekt Regel1", code="REGEL-1", standort=standort) + objekt.status = ObjektStatus.in_wartung + await db_session.commit() + + ergebnis = await einsatzbereitschaft(db_session) + eintrag = next(d for d in ergebnis["details"] if d["objekt_id"] == objekt.id) + assert "in_wartung" in eintrag["gruende"] + + +@pytest.mark.asyncio +async def test_deaktivierte_regel_wird_nicht_mehr_beruecksichtigt( + client, db_session, standort_factory, objekt_factory, admin_user +): + standort = await standort_factory("Wache-Regel-2") + objekt = await objekt_factory(name="Objekt Regel2", code="REGEL-2", standort=standort) + objekt.status = ObjektStatus.in_wartung + await db_session.commit() + + admin_token = await login(client, "admin1") + response = await client.patch( + "/api/v1/readiness-regeln/in_wartung", json={"aktiv": False}, headers=auth_header(admin_token) + ) + assert response.status_code == 200 + assert response.json()["aktiv"] is False + + ergebnis = await einsatzbereitschaft(db_session) + eintrag_details = [d for d in ergebnis["details"] if d["objekt_id"] == objekt.id] + # "in_wartung" darf nach dem Deaktivieren in keinem Grund mehr auftauchen + # (Objekt ist nie kontrolliert worden, taucht deshalb weiterhin unter + # "unbekannt" auf statt komplett zu verschwinden - das ist unabhängig von + # dieser Regel). + assert all("in_wartung" not in d["gruende"] for d in eintrag_details) + + +@pytest.mark.asyncio +async def test_liste_und_rechte(client, admin_user, mitarbeiter_user): + admin_token = await login(client, "admin1") + liste = await client.get("/api/v1/readiness-regeln", headers=auth_header(admin_token)) + assert liste.status_code == 200 + schluessel = {r["schluessel"] for r in liste.json()} + assert "hu_uvv_ueberfaellig" in schluessel + assert len(liste.json()) == 7 + + mitarbeiter_token = await login(client, "mitarbeiter1") + verboten = await client.get("/api/v1/readiness-regeln", headers=auth_header(mitarbeiter_token)) + assert verboten.status_code == 403 + + +@pytest.mark.asyncio +async def test_unbekannte_regel_404(client, admin_user): + admin_token = await login(client, "admin1") + response = await client.patch( + "/api/v1/readiness-regeln/gibt-es-nicht", json={"aktiv": False}, headers=auth_header(admin_token) + ) + assert response.status_code == 404 diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3a3baef..f57198e 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -477,6 +477,12 @@ export interface CustomRolle { berechtigung_ids: number[]; } +export interface ReadinessRegel { + schluessel: string; + beschreibung: string; + aktiv: boolean; +} + export interface Einheit { id: number; name: string; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 2d2e3a3..e097585 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -27,6 +27,7 @@ import { StandortSection } from "./admin/StandortSection"; import { VorlageSection } from "./admin/VorlageSection"; import { WartungsplanSection } from "./admin/WartungsplanSection"; import { ZustaendigkeitSection } from "./admin/ZustaendigkeitSection"; +import { ReadinessRegelSection } from "./admin/ReadinessRegelSection"; type Tab = | "objekte" @@ -44,7 +45,8 @@ type Tab = | "lager" | "ausgabe" | "wartungsplaene" - | "rollen"; + | "rollen" + | "readinessRegeln"; // Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte // (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt @@ -87,6 +89,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"], }, { key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] }, + { key: "readinessRegeln", label: "Bereitschaftsregeln", rollen: ["administration"] }, ]; /** @@ -304,6 +307,7 @@ export function AdminPage() { )} {aktiverTab === "rollen" && } + {aktiverTab === "readinessRegeln" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( <> diff --git a/frontend/src/pages/admin/ReadinessRegelSection.tsx b/frontend/src/pages/admin/ReadinessRegelSection.tsx new file mode 100644 index 0000000..07c202a --- /dev/null +++ b/frontend/src/pages/admin/ReadinessRegelSection.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { ReadinessRegel } from "../../api/types"; + +interface Props { + onFehler: (text: string) => void; +} + +// READY-001: Regelkatalog für dashboard.einsatzbereitschaft() - jedes der +// sieben hartcodierten Kriterien lässt sich hier an-/abschalten, ohne +// Codeänderung/Deploy. Kombinationslogik (Mindermenge-Sonderfall, "noch nie +// kontrolliert") bleibt bewusst fest, siehe app/models/readiness_regel.py. +export function ReadinessRegelSection({ onFehler }: Props) { + const [regeln, setRegeln] = useState([]); + const [laedt, setLaedt] = useState(true); + + async function laden() { + setLaedt(true); + try { + setRegeln(await apiRequest("/readiness-regeln")); + } catch { + onFehler("Regeln konnten nicht geladen werden."); + } finally { + setLaedt(false); + } + } + + useEffect(() => { + laden(); + }, []); + + async function umschalten(regel: ReadinessRegel) { + try { + await apiRequest(`/readiness-regeln/${regel.schluessel}`, { + method: "PATCH", + body: { aktiv: !regel.aktiv }, + }); + await laden(); + } catch { + onFehler("Regel konnte nicht geändert werden."); + } + } + + return ( + + Einsatzbereitschaft: Regeln + + + Deaktivierte Kriterien fließen nicht mehr in die Einsatzbereitschafts-Bewertung + ein (Dashboard, Akte, Bereitschafts-Verlauf). + + {laedt && Lade…} + {!laedt && ( + + {regeln.map((r) => ( + + {r.beschreibung} + + umschalten(r)} /> + {r.aktiv ? "Aktiv" : "Aus"} + + + ))} + + )} + + + ); +}
+ Deaktivierte Kriterien fließen nicht mehr in die Einsatzbereitschafts-Bewertung + ein (Dashboard, Akte, Bereitschafts-Verlauf). +
Lade…