feat(readiness): READY-001 an/abschaltbare Bereitschafts-Kriterien (abgespeckte Regel-Engine)
CI / backend-tests (push) Successful in 2m48s
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 12:51:02 +02:00
1 parent d41d04c26c
commit 46a65ba6ff
12 files changed
+343 -15

No files matched your search

+19 -6
View File
@@ -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
@@ -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")
+2
View File
@@ -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"])
@@ -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
+2
View File
@@ -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",
+24
View File
@@ -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)
+12
View File
@@ -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
+51 -8
View File
@@ -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}
+66
View File
@@ -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
+6
View File
@@ -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;
+5 -1
View File
@@ -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() {
<WartungsplanSection objekttypen={objekttypen} onFehler={setFehler} />
)}
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} standorte={standorte} onFehler={setFehler} />}
{aktiverTab === "readinessRegeln" && <ReadinessRegelSection onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
<>
@@ -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<ReadinessRegel[]>([]);
const [laedt, setLaedt] = useState(true);
async function laden() {
setLaedt(true);
try {
setRegeln(await apiRequest<ReadinessRegel[]>("/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 (
<section className="section">
<h2>Einsatzbereitschaft: Regeln</h2>
<div className="card">
<p className="text-muted" style={{ fontSize: "0.85rem", marginTop: 0 }}>
Deaktivierte Kriterien fließen nicht mehr in die Einsatzbereitschafts-Bewertung
ein (Dashboard, Akte, Bereitschafts-Verlauf).
</p>
{laedt && <p className="text-muted">Lade…</p>}
{!laedt && (
<ul className="card-list">
{regeln.map((r) => (
<li key={r.schluessel} className="row-between" style={{ padding: "0.35rem 0" }}>
<span>{r.beschreibung}</span>
<label className="row" style={{ alignItems: "center", gap: "0.4rem" }}>
<input type="checkbox" checked={r.aktiv} onChange={() => umschalten(r)} />
{r.aktiv ? "Aktiv" : "Aus"}
</label>
</li>
))}
</ul>
)}
</div>
</section>
);
}