feat(inspections): INSP Phase 2 - Admin-UI + Dashboard-Lesepfad-Ergänzung
Prüfarten-Verwaltung + Objekttyp-Zuordnung im Admin-Frontend
(PruefartenSection.tsx, neue Endpunkte GET/PUT/DELETE
/objekttypen/{id}/pruefarten), Prüfhistorie + FAILED-Ampel-Badge je
Geräte-Exemplar (GeraeteInstanzenListe.tsx).
Dashboard-Lesepfad ergänzt (nicht ersetzt): bevorstehende_prueftermine()
zeigt ein Gerät mit fehlgeschlagener Prüfung jetzt auch dann, wenn
naechste_pruefung datumsbasiert noch als "gueltig" gelten würde.
Bewusst NICHT umgestellt: naechste_pruefung-Berechnung bleibt auf
Objektposition.pruefintervall_monate (nicht objekttyp_pruefart),
Fristen-Dienst/ASSET-010 bleiben unverändert - siehe Kachel-DoD.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
1 parent
e5dc80c3a6
commit
536b2edc4d
12 files changed
+574
-18
No files matched your search
@@ -194,10 +194,10 @@ vergeben statt geraten.
|
||||
| LOAD-005 | Loadout | Kontrollhistorie | P1 | S | LOW | LOAD-004, FOUND-006 | ✅ (`Kontrolle`-Modell append-only, Historie-Anbindung) |
|
||||
| LOAD-006 | Loadout | Readiness-Kopplung | P1 | S | MEDIUM | LOAD-003, READY-001 | ✅ (Fehlbestand fließt in `einsatzbereitschaft()`-Berechnung ein) |
|
||||
| LOAD-007 | Loadout | Gemeinsame Sollmenge über mehrere Standorte (neu, Referenz: HiOrg-Server) | P3 | M | MEDIUM | LOAD-002, WH-004 | ✅ erledigt 2026-09-10 |
|
||||
| INSP-001 | Inspections | Prüfarten-Stammdaten | P1 | S | LOW | ASSET-002 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (`pruefart`-Tabelle, Dual-Write; Phase 2 Lesepfad-Umstellung/Frontend offen) |
|
||||
| INSP-002 | Inspections | Prüfintervalle | P1 | S | LOW | INSP-001 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (`objekttyp_pruefart`; Berechnung nutzt weiterhin Phase-1-Altpfad) |
|
||||
| INSP-003 | Inspections | Prüfdurchführung/-ergebnis | P1 | M | MEDIUM | INSP-002 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (`pruefung`-Tabelle mit Ergebnis/Prüfer, Dual-Write) |
|
||||
| INSP-004 | Inspections | Status-Ampel (VALID/DUE_SOON/OVERDUE/FAILED) | P1 | S | LOW | INSP-003 | 🔶 Phase 1 additiv umgesetzt 2026-09-10 (FAILED-Zustand ergänzt in `pruefung.py`; kein Frontend) |
|
||||
| INSP-001 | Inspections | Prüfarten-Stammdaten | P1 | S | LOW | ASSET-002 | ✅ erledigt 2026-09-10 (Phase 1+2: `pruefart`-Tabelle, Dual-Write, Admin-UI) |
|
||||
| INSP-002 | Inspections | Prüfintervalle | P1 | S | LOW | INSP-001 | 🔶 Phase 1+2 additiv umgesetzt 2026-09-10 (`objekttyp_pruefart` + Admin-UI; Fälligkeits-BERECHNUNG bewusst weiter auf altem Pfad, siehe Kachel-DoD) |
|
||||
| INSP-003 | Inspections | Prüfdurchführung/-ergebnis | P1 | M | MEDIUM | INSP-002 | ✅ erledigt 2026-09-10 (Phase 1+2: `pruefung`-Tabelle + Historie-Anzeige im Frontend) |
|
||||
| INSP-004 | Inspections | Status-Ampel (VALID/DUE_SOON/OVERDUE/FAILED) | P1 | S | LOW | INSP-003 | ✅ erledigt 2026-09-10 (Phase 1+2: FAILED-Zustand + Dashboard/Frontend-Anzeige) |
|
||||
| INSP-005 | Inspections | Prüfdokument-Anbindung | P2 | XS | LOW | INSP-003, DOC-001 | ✅ (Dokument polymorph, `entitaet_typ` deckt Geräteinstanz ab) |
|
||||
| INSP-006 | Inspections | Prüftermin-Übersicht | P1 | S | LOW | INSP-004 | ✅ (`PrueftermineTile.tsx`, `/dashboard/prueftermine`) |
|
||||
| MAINT-001 | Maintenance | Wartungspläne | P2 | S | LOW | ASSET-002 | ✅ (`Wartungsplan`, Migration 0022 referenziert MAINT-001) |
|
||||
|
||||
@@ -51,6 +51,9 @@ Details zu INSP-001 … INSP-006 (vollständig).
|
||||
anlegbar, an Objekttyp koppelbar) erfüllt über Seed-Daten +
|
||||
`objekttyp_pruefart`-Tabelle (Koppel-UI folgt bei Bedarf in Phase 2, aktuell
|
||||
nur über die Tabelle direkt befüllbar, kein Admin-UI-Screen dafür).
|
||||
**Phase 2 (2026-09-10):** Admin-UI „Prüfarten" (`PruefartenSection.tsx`)
|
||||
ergänzt — Prüfarten anlegen UND je Objekttyp mit Intervall zuordnen, über
|
||||
neue Endpunkte `GET/PUT/DELETE /objekttypen/{id}/pruefarten(/{pruefart_id})`.
|
||||
|
||||
## INSP-002 — Prüfintervalle
|
||||
|
||||
@@ -76,8 +79,15 @@ Details zu INSP-001 … INSP-006 (vollständig).
|
||||
Kopplung angelegt (Tabelle existiert, noch ohne eigenes Admin-UI, siehe
|
||||
INSP-001-DoD). Die tatsächliche Fälligkeits-BERECHNUNG bleibt in Phase 1
|
||||
weiterhin `pruefintervall_monate` an `Objektposition` +
|
||||
`_plus_monate()` (unverändert, kein Regressionsrisiko) — Umstellung der
|
||||
Berechnung auf `objekttyp_pruefart` ist Teil von Phase 2.
|
||||
`_plus_monate()` (unverändert, kein Regressionsrisiko).
|
||||
**Phase 2 (2026-09-10):** bewusst NICHT umgestellt — `objekttyp_pruefart`
|
||||
ist jetzt über die Admin-UI voll pflegbar (INSP-001-DoD), dient aber
|
||||
weiterhin nur als Stammdaten-Referenz/zukünftige Grundlage, NICHT als
|
||||
Eingabe für die tatsächliche `naechste_pruefung`-Berechnung. Umstellung der
|
||||
Berechnung selbst wäre ein Verhaltens-Umbau am zentralen Dual-Write-Pfad
|
||||
(`geraet_instanz.py:aendere_status()`) mit Regressionsrisiko für
|
||||
bestehende, produktive Prüftermine — explizit als eigener, künftiger
|
||||
Schritt zurückgestellt, nicht Teil dieser Phase-2-Runde.
|
||||
|
||||
## INSP-003 — Prüfdurchführung/-ergebnis
|
||||
|
||||
@@ -112,6 +122,9 @@ Details zu INSP-001 … INSP-006 (vollständig).
|
||||
`naechste_uvv` bisher direkt) — Dual-Write dafür ist Teil von Phase 2, sobald
|
||||
dafür ein echter Erfassen-Workflow existiert. `GET /geraete/{id}/pruefungen`
|
||||
liefert die volle Historie.
|
||||
**Phase 2 (2026-09-10):** Historie jetzt auch im Admin-Frontend sichtbar —
|
||||
`GeraeteInstanzenListe.tsx` bekam einen aufklappbaren „Prüfhistorie"-Bereich
|
||||
je Geräte-Exemplar (Datum/Ergebnis/Bemerkung je Eintrag).
|
||||
|
||||
## INSP-004 — Status-Ampel (VALID/DUE_SOON/OVERDUE/FAILED)
|
||||
|
||||
@@ -141,6 +154,16 @@ Details zu INSP-001 … INSP-006 (vollständig).
|
||||
wiederverwendet statt dupliziert) bezogen auf `GeraetInstanz.naechste_pruefung`.
|
||||
`GET /geraete/{id}/pruefungs-ampel`. Nur für Geräte (Phase 1), Fahrzeug-
|
||||
HU/UVV-Ampel bleibt vorerst beim bestehenden Dashboard-Mechanismus.
|
||||
**Phase 2 (2026-09-10):** FAILED-Zustand jetzt an ZWEI Stellen im Frontend
|
||||
sichtbar statt nur über den (bis dahin ungenutzten) Endpoint — (1)
|
||||
`GeraeteInstanzenListe.tsx` zeigt die Ampel je Geräte-Exemplar als Badge,
|
||||
(2) `app/services/dashboard.py:bevorstehende_prueftermine()` überschreibt
|
||||
den datumsbasierten Status mit `fehlgeschlagen`, wenn die jüngste Prüfung
|
||||
`nicht_bestanden` war — vorher hätte ein Gerät mit weit in der Zukunft
|
||||
liegendem `naechste_pruefung`, aber fehlgeschlagener Prüfung, die
|
||||
Prüftermine-Dashboardkachel als "gueltig" gar nicht erst erreicht. Neuer
|
||||
Frist-Zustand `"fehlgeschlagen"` in `FristBadge.tsx` (rot, fett, eigene
|
||||
Kennzeichnung statt Kollaps auf "ok").
|
||||
|
||||
## INSP-005 — Prüfdokument-Anbindung
|
||||
|
||||
@@ -205,3 +228,23 @@ bisherigen Stand (siehe deren eigene DoD-Einträge oben). **Kein Frontend in Pha
|
||||
(reines Backend, additiv, kein UI-Bedarf ohne Lesepfad-Umstellung) — Prüfarten-
|
||||
Verwaltungs-UI, Prüfungshistorie-Anzeige und FAILED-Ampel-Darstellung im Portal
|
||||
folgen mit Phase 2.
|
||||
|
||||
**Update 2026-09-10 (Phase 2 — Lesepfad-Ergänzung + Frontend):** Bewusst
|
||||
BEGRENZTER Umfang statt Voll-Migration (Risiko-Abwägung, kein Zwang zur
|
||||
"perfekten" Lösung in einem Rutsch):
|
||||
- ✅ Dashboard-Lesepfad ERGÄNZT (nicht ersetzt): `bevorstehende_prueftermine()`
|
||||
zieht jetzt zusätzlich `dringlichkeit_fuer_geraet()` heran, um den
|
||||
FAILED-Zustand datumsunabhängig durchzureichen (schließt die INSP-004-Lücke
|
||||
im Dashboard selbst, nicht nur im bis dahin ungenutzten Ampel-Endpoint).
|
||||
- ✅ Admin-Frontend: Prüfarten-Verwaltung + Objekttyp-Zuordnung
|
||||
(`PruefartenSection.tsx`), Prüfhistorie-Anzeige + FAILED-Badge je
|
||||
Geräte-Exemplar (`GeraeteInstanzenListe.tsx`).
|
||||
- ❌ BEWUSST NICHT umgestellt: die `naechste_pruefung`-BERECHNUNG selbst
|
||||
bleibt auf `Objektposition.pruefintervall_monate` (nicht auf
|
||||
`objekttyp_pruefart`) — siehe INSP-002-DoD. Fristen-Dienst
|
||||
(`app/services/fristen.py`) und ASSET-010 (Custody-Ausgabe) lesen weiterhin
|
||||
ausschließlich `GeraetInstanz.naechste_pruefung`/`status`, nicht die neue
|
||||
`Pruefung`-Tabelle — beide zeigen bei Prüfungen weiterhin dieselben Werte
|
||||
wie vor Phase 2 (Dual-Write hält `naechste_pruefung` synchron), nur den
|
||||
NEUEN FAILED-Zustand kennen sie noch nicht. Vollständige Migration bliebe
|
||||
ein möglicher Phase-3-Schritt, falls der Bedarf sich zeigt.
|
||||
@@ -1,18 +1,28 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
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 RolleTyp
|
||||
from app.models.pruefung import Pruefart, Pruefung
|
||||
from app.schemas.pruefung import PruefartCreate, PruefartRead, PruefungRead, PruefungsAmpelRead
|
||||
from app.models.pruefung import ObjekttypPruefart, Pruefart, Pruefung
|
||||
from app.schemas.pruefung import (
|
||||
ObjekttypPruefartCreate,
|
||||
ObjekttypPruefartRead,
|
||||
PruefartCreate,
|
||||
PruefartRead,
|
||||
PruefungRead,
|
||||
PruefungsAmpelRead,
|
||||
)
|
||||
from app.services.pruefung import (
|
||||
dringlichkeit_fuer_geraet,
|
||||
entferne_objekttyp_pruefart,
|
||||
erstelle_pruefart,
|
||||
liste_pruefarten,
|
||||
liste_pruefarten_fuer_objekttyp,
|
||||
liste_pruefungen_fuer_geraet,
|
||||
setze_objekttyp_pruefart,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -58,3 +68,39 @@ async def pruefungs_ampel_endpunkt(
|
||||
) -> PruefungsAmpelRead:
|
||||
dringlichkeit = await dringlichkeit_fuer_geraet(db, geraet_instanz_id)
|
||||
return PruefungsAmpelRead(dringlichkeit=dringlichkeit)
|
||||
|
||||
|
||||
@router.get("/objekttypen/{objekttyp_id}/pruefarten", response_model=list[ObjekttypPruefartRead])
|
||||
async def liste_objekttyp_pruefarten_endpunkt(
|
||||
objekttyp_id: int, db: AsyncSession = Depends(get_db), _=Depends(_lesend)
|
||||
) -> list[ObjekttypPruefart]:
|
||||
return await liste_pruefarten_fuer_objekttyp(db, objekttyp_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/objekttypen/{objekttyp_id}/pruefarten/{pruefart_id}",
|
||||
response_model=ObjekttypPruefartRead,
|
||||
)
|
||||
async def setze_objekttyp_pruefart_endpunkt(
|
||||
objekttyp_id: int,
|
||||
pruefart_id: int,
|
||||
payload: ObjekttypPruefartCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(_administration),
|
||||
) -> ObjekttypPruefart:
|
||||
if payload.pruefart_id != pruefart_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="pruefart_id im Pfad und Body weichen ab")
|
||||
return await setze_objekttyp_pruefart(
|
||||
db, objekttyp_id=objekttyp_id, pruefart_id=pruefart_id, intervall_monate=payload.intervall_monate
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/objekttypen/{objekttyp_id}/pruefarten/{pruefart_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def entferne_objekttyp_pruefart_endpunkt(
|
||||
objekttyp_id: int, pruefart_id: int, db: AsyncSession = Depends(get_db), _=Depends(_administration)
|
||||
) -> None:
|
||||
gefunden = await entferne_objekttyp_pruefart(db, objekttyp_id=objekttyp_id, pruefart_id=pruefart_id)
|
||||
if not gefunden:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zuordnung nicht gefunden")
|
||||
@@ -35,3 +35,15 @@ class PruefungRead(BaseModel):
|
||||
|
||||
class PruefungsAmpelRead(BaseModel):
|
||||
dringlichkeit: str
|
||||
|
||||
|
||||
class ObjekttypPruefartCreate(BaseModel):
|
||||
pruefart_id: int
|
||||
intervall_monate: int | None = None
|
||||
|
||||
|
||||
class ObjekttypPruefartRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
objekttyp_id: int
|
||||
pruefart_id: int
|
||||
intervall_monate: int | None
|
||||
@@ -449,7 +449,21 @@ async def bevorstehende_prueftermine(db: AsyncSession, warnzeitraum_tage: int =
|
||||
)
|
||||
for geraet, objektposition, material in result.all():
|
||||
status_wert, verbleibende_tage = _ablauf_status(geraet.naechste_pruefung, warnzeitraum_tage, heute)
|
||||
if status_wert == "gueltig":
|
||||
# INSP-004 Phase 2: eine nicht bestandene Prüfung überstrahlt die
|
||||
# datumsbasierte Einstufung, unabhängig davon wie weit naechste_pruefung
|
||||
# noch in der Zukunft liegt (siehe pruefung_service.dringlichkeit_fuer_geraet) -
|
||||
# sonst würde die Prüftermine-Kachel ein fehlgeschlagenes Gerät als
|
||||
# "gueltig" ausblenden, nur weil das Datum selbst noch nicht erreicht ist.
|
||||
# Lazy-Import: app.services.fristen importiert diese Funktion hier
|
||||
# (bevorstehende_prueftermine wird selbst nicht von fristen.py genutzt,
|
||||
# aber pruefung.py importiert fristen.py - ein Top-Level-Import hier
|
||||
# würde einen Zirkelimport dashboard->pruefung->fristen->dashboard
|
||||
# erzeugen).
|
||||
from app.services.pruefung import dringlichkeit_fuer_geraet
|
||||
|
||||
if await dringlichkeit_fuer_geraet(db, geraet.id) == "fehlgeschlagen":
|
||||
status_wert = "fehlgeschlagen"
|
||||
elif status_wert == "gueltig":
|
||||
continue
|
||||
eintraege.append(
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.geraet_instanz import GeraetInstanz
|
||||
from app.models.pruefung import Pruefart, Pruefung, PruefungErgebnis
|
||||
from app.models.pruefung import ObjekttypPruefart, Pruefart, Pruefung, PruefungErgebnis
|
||||
from app.services.fristen import ampel_aus_tagen
|
||||
|
||||
_WARNZEITRAUM_TAGE = 30
|
||||
@@ -25,6 +25,39 @@ async def liste_pruefarten(db: AsyncSession) -> list[Pruefart]:
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def liste_pruefarten_fuer_objekttyp(db: AsyncSession, objekttyp_id: int) -> list[ObjekttypPruefart]:
|
||||
result = await db.execute(
|
||||
select(ObjekttypPruefart).where(ObjekttypPruefart.objekttyp_id == objekttyp_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def setze_objekttyp_pruefart(
|
||||
db: AsyncSession, *, objekttyp_id: int, pruefart_id: int, intervall_monate: int | None
|
||||
) -> ObjekttypPruefart:
|
||||
"""INSP-002: legt die Zuordnung an oder aktualisiert das Intervall, falls
|
||||
sie schon existiert (Composite-PK objekttyp_id+pruefart_id - eine
|
||||
Objekttyp/Prüfart-Kombination ist eindeutig)."""
|
||||
zuordnung = await db.get(ObjekttypPruefart, (objekttyp_id, pruefart_id))
|
||||
if zuordnung is None:
|
||||
zuordnung = ObjekttypPruefart(
|
||||
objekttyp_id=objekttyp_id, pruefart_id=pruefart_id, intervall_monate=intervall_monate
|
||||
)
|
||||
db.add(zuordnung)
|
||||
else:
|
||||
zuordnung.intervall_monate = intervall_monate
|
||||
await db.flush()
|
||||
return zuordnung
|
||||
|
||||
|
||||
async def entferne_objekttyp_pruefart(db: AsyncSession, *, objekttyp_id: int, pruefart_id: int) -> bool:
|
||||
zuordnung = await db.get(ObjekttypPruefart, (objekttyp_id, pruefart_id))
|
||||
if zuordnung is None:
|
||||
return False
|
||||
await db.delete(zuordnung)
|
||||
return True
|
||||
|
||||
|
||||
async def erstelle_pruefung_fuer_geraet(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import pytest
|
||||
|
||||
from tests.conftest import auth_header, login
|
||||
|
||||
|
||||
async def _erstelle_geraet(client, objekt_mit_position, token, seriennummer: str) -> str:
|
||||
objekt, material = objekt_mit_position
|
||||
positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(token))
|
||||
position_id = positionen.json()[0]["id"]
|
||||
erstellt = await client.post(
|
||||
f"/api/v1/objektpositionen/{position_id}/geraete",
|
||||
json={"seriennummer": seriennummer},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
return erstellt.json()["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_objekttyp_pruefart_zuordnen_lesen_entfernen(client, db_session, objekttyp_factory, admin_user):
|
||||
token = await login(client, "admin1")
|
||||
objekttyp = await objekttyp_factory("INSP2-Typ")
|
||||
|
||||
pruefart = await client.post(
|
||||
"/api/v1/pruefarten", json={"name": "INSP2-Pruefart"}, headers=auth_header(token)
|
||||
)
|
||||
pruefart_id = pruefart.json()["id"]
|
||||
|
||||
gesetzt = await client.put(
|
||||
f"/api/v1/objekttypen/{objekttyp.id}/pruefarten/{pruefart_id}",
|
||||
json={"pruefart_id": pruefart_id, "intervall_monate": 12},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert gesetzt.status_code == 200
|
||||
assert gesetzt.json()["intervall_monate"] == 12
|
||||
|
||||
liste = await client.get(f"/api/v1/objekttypen/{objekttyp.id}/pruefarten", headers=auth_header(token))
|
||||
assert liste.status_code == 200
|
||||
assert len(liste.json()) == 1
|
||||
|
||||
entfernt = await client.delete(
|
||||
f"/api/v1/objekttypen/{objekttyp.id}/pruefarten/{pruefart_id}", headers=auth_header(token)
|
||||
)
|
||||
assert entfernt.status_code == 204
|
||||
|
||||
leer = await client.get(f"/api/v1/objekttypen/{objekttyp.id}/pruefarten", headers=auth_header(token))
|
||||
assert leer.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_objekttyp_pruefart_erfordert_administration(client, objekttyp_factory, mitarbeiter_user):
|
||||
token = await login(client, "mitarbeiter1")
|
||||
objekttyp = await objekttyp_factory("INSP2-Typ-2")
|
||||
antwort = await client.put(
|
||||
f"/api/v1/objekttypen/{objekttyp.id}/pruefarten/1",
|
||||
json={"pruefart_id": 1, "intervall_monate": 6},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert antwort.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entfernen_unbekannter_zuordnung_404(client, objekttyp_factory, admin_user):
|
||||
token = await login(client, "admin1")
|
||||
objekttyp = await objekttyp_factory("INSP2-Typ-3")
|
||||
antwort = await client.delete(
|
||||
f"/api/v1/objekttypen/{objekttyp.id}/pruefarten/999999", headers=auth_header(token)
|
||||
)
|
||||
assert antwort.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_prueftermine_zeigt_fehlgeschlagenes_geraet_trotz_gueltigem_datum(
|
||||
client, db_session, objekt_mit_position, materialverantwortlicher_user
|
||||
):
|
||||
"""INSP-004 Phase 2: dashboard.bevorstehende_prueftermine() darf ein Gerät
|
||||
mit nicht bestandener Prüfung nicht als 'gueltig' herausfiltern, nur weil
|
||||
naechste_pruefung noch weit in der Zukunft liegt."""
|
||||
token = await login(client, "materialverantwortlicher1")
|
||||
objekt, _material = objekt_mit_position
|
||||
positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(token))
|
||||
position_id = positionen.json()[0]["id"]
|
||||
await client.patch(
|
||||
f"/api/v1/objekte/{objekt.id}/positionen/{position_id}",
|
||||
json={"pruefintervall_monate": 12},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
geraet_id = await _erstelle_geraet(client, objekt_mit_position, token, "SN-INSP2-004")
|
||||
|
||||
weit_in_zukunft = "2099-01-01"
|
||||
await client.patch(
|
||||
f"/api/v1/geraete/{geraet_id}",
|
||||
json={"status": "defekt", "pruefdatum": weit_in_zukunft},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
|
||||
prueftermine = await client.get("/api/v1/dashboard/prueftermine", headers=auth_header(token))
|
||||
assert prueftermine.status_code == 200
|
||||
treffer = [p for p in prueftermine.json() if p.get("geraet_instanz_id") == geraet_id]
|
||||
assert len(treffer) == 1
|
||||
assert treffer[0]["status"] == "fehlgeschlagen"
|
||||
@@ -55,6 +55,37 @@ export interface GeraetInstanz {
|
||||
bemerkung: string | null;
|
||||
}
|
||||
|
||||
export type PruefungErgebnis = "bestanden" | "nicht_bestanden" | "mit_maengeln";
|
||||
|
||||
export interface Pruefart {
|
||||
id: number;
|
||||
name: string;
|
||||
beschreibung: string | null;
|
||||
gesetzliche_grundlage: string | null;
|
||||
}
|
||||
|
||||
export interface Pruefung {
|
||||
id: string;
|
||||
geraet_instanz_id: string | null;
|
||||
objekt_id: number | null;
|
||||
pruefart_id: number;
|
||||
geprueft_am: string;
|
||||
pruefer_id: number | null;
|
||||
ergebnis: PruefungErgebnis;
|
||||
bemerkung: string | null;
|
||||
erstellt_am: string;
|
||||
}
|
||||
|
||||
export interface PruefungsAmpel {
|
||||
dringlichkeit: "unbekannt" | "ok" | "bald" | "kritisch" | "ueberfaellig" | "fehlgeschlagen";
|
||||
}
|
||||
|
||||
export interface ObjekttypPruefart {
|
||||
objekttyp_id: number;
|
||||
pruefart_id: number;
|
||||
intervall_monate: number | null;
|
||||
}
|
||||
|
||||
export interface Wartungsintervall {
|
||||
id: number;
|
||||
wartungsplan_position_id: number;
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
export type FristStatus = "abgelaufen" | "bald_ablaufend" | "ok";
|
||||
export type FristStatus = "abgelaufen" | "bald_ablaufend" | "fehlgeschlagen" | "ok";
|
||||
|
||||
const LABEL: Record<FristStatus, string> = {
|
||||
abgelaufen: "Überfällig",
|
||||
bald_ablaufend: "Bald fällig",
|
||||
fehlgeschlagen: "Fehlgeschlagen",
|
||||
ok: "In Ordnung",
|
||||
};
|
||||
|
||||
// Überfällig = auffällig gefüllt, bald fällig = zurückhaltender Outline-Look,
|
||||
// ok = dezente Füllung - Dringlichkeit soll auch ohne Text erkennbar sein.
|
||||
// fehlgeschlagen (INSP-004): eigene, datumsunabhängige Kennzeichnung - eine
|
||||
// nicht bestandene Prüfung ist kein "bald abgelaufen", sondern jetzt schon
|
||||
// ein Problem, unabhängig vom Fälligkeitsdatum.
|
||||
const KLASSE: Record<FristStatus, string> = {
|
||||
abgelaufen: "bg-[var(--status-critical)] text-white",
|
||||
bald_ablaufend: "border border-[var(--status-warn)] text-[var(--status-warn)]",
|
||||
fehlgeschlagen: "bg-[var(--status-critical)] text-white font-bold",
|
||||
ok: "bg-[var(--status-ok)]/15 text-[var(--status-ok)]",
|
||||
};
|
||||
|
||||
@@ -20,11 +25,13 @@ interface Props {
|
||||
|
||||
/** gemeinsame Basis für InspectionStatus/InventoryStatus -
|
||||
* beide Backend-Endpunkte (Prüftermine/Ablaufdaten) liefern dieselben zwei
|
||||
* Fristzustände (siehe app/services/dashboard.py `_ablauf_status`). Zeigt nur
|
||||
* den Status - die verbleibenden Tage rendert die jeweilige Kachel separat
|
||||
* als eigenes Countdown-Element. */
|
||||
* Fristzustände (siehe app/services/dashboard.py `_ablauf_status`), Prüftermine
|
||||
* zusätzlich "fehlgeschlagen" (INSP-004 Phase 2). Zeigt nur den Status - die
|
||||
* verbleibenden Tage rendert die jeweilige Kachel separat als eigenes
|
||||
* Countdown-Element. */
|
||||
export function FristBadge({ status }: Props) {
|
||||
const bekannt = status === "abgelaufen" || status === "bald_ablaufend" ? status : "ok";
|
||||
const bekannt: FristStatus =
|
||||
status === "abgelaufen" || status === "bald_ablaufend" || status === "fehlgeschlagen" ? status : "ok";
|
||||
return (
|
||||
<span className={`inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${KLASSE[bekannt]}`}>
|
||||
{LABEL[bekannt]}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { MaterialSetSection } from "./admin/MaterialSetSection";
|
||||
import { SollmengenGruppeSection } from "./admin/SollmengenGruppeSection";
|
||||
import { InventurSection } from "./admin/InventurSection";
|
||||
import { FehlerTrackingSection } from "./admin/FehlerTrackingSection";
|
||||
import { PruefartenSection } from "./admin/PruefartenSection";
|
||||
import { VerleihSection } from "./admin/VerleihSection";
|
||||
|
||||
type Tab =
|
||||
@@ -58,7 +59,8 @@ type Tab =
|
||||
| "verleih"
|
||||
| "sollmengenGruppen"
|
||||
| "inventur"
|
||||
| "fehlerTracking";
|
||||
| "fehlerTracking"
|
||||
| "pruefarten";
|
||||
|
||||
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
|
||||
// (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt
|
||||
@@ -112,6 +114,11 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
|
||||
},
|
||||
{ key: "inventur", label: "Inventur", rollen: ["administration", "materialverantwortlicher"] },
|
||||
{ key: "fehlerTracking", label: "Fehler-Tracking", rollen: ["administration"] },
|
||||
{
|
||||
key: "pruefarten",
|
||||
label: "Prüfarten",
|
||||
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -337,6 +344,7 @@ export function AdminPage() {
|
||||
)}
|
||||
{aktiverTab === "inventur" && <InventurSection materialien={materialien} onFehler={setFehler} />}
|
||||
{aktiverTab === "fehlerTracking" && <FehlerTrackingSection onFehler={setFehler} />}
|
||||
{aktiverTab === "pruefarten" && <PruefartenSection objekttypen={objekttypen} onFehler={setFehler} />}
|
||||
{aktiverTab === "verleih" && <VerleihSection objekte={objekte} onFehler={setFehler} />}
|
||||
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
|
||||
{aktiverTab === "personal" && (
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest } from "../../api/client";
|
||||
import type { GeraetInstanz } from "../../api/types";
|
||||
import type { GeraetInstanz, Pruefung, PruefungsAmpel } from "../../api/types";
|
||||
import { CustodyDialog } from "../../components/CustodyDialog";
|
||||
import { FristBadge } from "../../components/status/FristBadge";
|
||||
|
||||
const ERGEBNIS_LABEL: Record<Pruefung["ergebnis"], string> = {
|
||||
bestanden: "Bestanden",
|
||||
nicht_bestanden: "Nicht bestanden",
|
||||
mit_maengeln: "Mit Mängeln",
|
||||
};
|
||||
|
||||
// PruefungsAmpel-Dringlichkeit (fristen.py Dringlichkeit-Enum + "fehlgeschlagen")
|
||||
// auf die zwei/drei FristBadge-Zustände abbilden.
|
||||
function ampelZuFristStatus(dringlichkeit: PruefungsAmpel["dringlichkeit"]): string {
|
||||
if (dringlichkeit === "fehlgeschlagen") return "fehlgeschlagen";
|
||||
if (dringlichkeit === "ueberfaellig") return "abgelaufen";
|
||||
if (dringlichkeit === "bald" || dringlichkeit === "kritisch") return "bald_ablaufend";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
objektpositionId: string;
|
||||
@@ -37,16 +53,44 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
|
||||
const [custodyDialog, setCustodyDialog] = useState<{ geraetId: string; modus: "ausgeben" | "zurueckgeben" } | null>(
|
||||
null
|
||||
);
|
||||
const [ampeln, setAmpeln] = useState<Record<string, PruefungsAmpel["dringlichkeit"]>>({});
|
||||
const [historieOffenId, setHistorieOffenId] = useState<string | null>(null);
|
||||
const [historie, setHistorie] = useState<Pruefung[]>([]);
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const daten = await apiRequest<GeraetInstanz[]>(`/objektpositionen/${objektpositionId}/geraete`);
|
||||
setInstanzen(daten);
|
||||
const ampelEintraege = await Promise.all(
|
||||
daten.map(async (instanz) => {
|
||||
try {
|
||||
const ampel = await apiRequest<PruefungsAmpel>(`/geraete/${instanz.id}/pruefungs-ampel`);
|
||||
return [instanz.id, ampel.dringlichkeit] as const;
|
||||
} catch {
|
||||
return [instanz.id, "unbekannt"] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
setAmpeln(Object.fromEntries(ampelEintraege));
|
||||
} catch {
|
||||
onFehler("Geräte-Liste konnte nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function historieUmschalten(geraetId: string) {
|
||||
if (historieOffenId === geraetId) {
|
||||
setHistorieOffenId(null);
|
||||
return;
|
||||
}
|
||||
setHistorieOffenId(geraetId);
|
||||
try {
|
||||
const daten = await apiRequest<Pruefung[]>(`/geraete/${geraetId}/pruefungen`);
|
||||
setHistorie(daten);
|
||||
} catch {
|
||||
onFehler("Prüfhistorie konnte nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
laden();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -115,6 +159,9 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
|
||||
<span>
|
||||
{instanz.status === "einsatzbereit" ? "✓" : "✗"} {instanz.seriennummer}{" "}
|
||||
<span className={`badge ${STATUS_BADGE[instanz.status]}`}>{STATUS_LABEL[instanz.status]}</span>{" "}
|
||||
{ampeln[instanz.id] && ampeln[instanz.id] !== "unbekannt" && (
|
||||
<FristBadge status={ampelZuFristStatus(ampeln[instanz.id])} />
|
||||
)}{" "}
|
||||
{instanz.naechste_pruefung && (
|
||||
<span className="text-muted">nächste Prüfung: {instanz.naechste_pruefung}</span>
|
||||
)}
|
||||
@@ -132,6 +179,9 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
|
||||
>
|
||||
Zurücknehmen
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => historieUmschalten(instanz.id)}>
|
||||
{historieOffenId === instanz.id ? "Historie schließen" : "Prüfhistorie"}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => bearbeiten(instanz)}>
|
||||
{bearbeiteId === instanz.id ? "Schließen" : "Bearbeiten"}
|
||||
</button>
|
||||
@@ -140,6 +190,22 @@ export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate,
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{historieOffenId === instanz.id && (
|
||||
<div style={{ marginTop: "0.4rem", paddingLeft: "1rem" }}>
|
||||
{historie.length === 0 ? (
|
||||
<p className="text-muted" style={{ fontSize: "0.85rem" }}>Keine Prüfungen erfasst.</p>
|
||||
) : (
|
||||
<ul className="card-list">
|
||||
{historie.map((p) => (
|
||||
<li key={p.id} style={{ padding: "0.2rem 0", fontSize: "0.85rem" }}>
|
||||
{p.geprueft_am} — {ERGEBNIS_LABEL[p.ergebnis]}
|
||||
{p.bemerkung && <span className="text-muted"> · {p.bemerkung}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{bearbeiteId === instanz.id && (
|
||||
<div className="stack" style={{ marginTop: "0.4rem", paddingLeft: "1rem" }}>
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiRequest } from "../../api/client";
|
||||
import type { ObjekttypPruefart, Objekttyp, Pruefart } from "../../api/types";
|
||||
|
||||
interface Props {
|
||||
objekttypen: Objekttyp[];
|
||||
onFehler: (text: string) => void;
|
||||
}
|
||||
|
||||
// INSP-001/002 Phase 2: Prüfarten-Stammdaten pflegen und je Objekttyp mit
|
||||
// einem Prüfintervall verknüpfen (rein informativ/Verwaltung - die
|
||||
// tatsächliche Fälligkeitsberechnung nutzt weiterhin
|
||||
// Objektposition.pruefintervall_monate, siehe arbeitskacheln/09_inspections.md
|
||||
// Phasenplan).
|
||||
export function PruefartenSection({ objekttypen, onFehler }: Props) {
|
||||
const [pruefarten, setPruefarten] = useState<Pruefart[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [beschreibung, setBeschreibung] = useState("");
|
||||
const [grundlage, setGrundlage] = useState("");
|
||||
|
||||
const [ausgewaehlterObjekttyp, setAusgewaehlterObjekttyp] = useState<number | "">("");
|
||||
const [zuordnungen, setZuordnungen] = useState<ObjekttypPruefart[]>([]);
|
||||
const [neuePruefartId, setNeuePruefartId] = useState<number | "">("");
|
||||
const [neuesIntervall, setNeuesIntervall] = useState("");
|
||||
|
||||
async function ladePruefarten() {
|
||||
try {
|
||||
setPruefarten(await apiRequest<Pruefart[]>("/pruefarten"));
|
||||
} catch {
|
||||
onFehler("Prüfarten konnten nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
ladePruefarten();
|
||||
}, []);
|
||||
|
||||
async function ladeZuordnungen(objekttypId: number) {
|
||||
try {
|
||||
setZuordnungen(await apiRequest<ObjekttypPruefart[]>(`/objekttypen/${objekttypId}/pruefarten`));
|
||||
} catch {
|
||||
onFehler("Zuordnungen konnten nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (ausgewaehlterObjekttyp !== "") void ladeZuordnungen(ausgewaehlterObjekttyp);
|
||||
}, [ausgewaehlterObjekttyp]);
|
||||
|
||||
async function pruefartAnlegen() {
|
||||
if (!name) return;
|
||||
try {
|
||||
await apiRequest("/pruefarten", {
|
||||
method: "POST",
|
||||
body: { name, beschreibung: beschreibung || null, gesetzliche_grundlage: grundlage || null },
|
||||
});
|
||||
setName("");
|
||||
setBeschreibung("");
|
||||
setGrundlage("");
|
||||
await ladePruefarten();
|
||||
} catch {
|
||||
onFehler("Prüfart konnte nicht angelegt werden (Name evtl. schon vergeben).");
|
||||
}
|
||||
}
|
||||
|
||||
async function zuordnungSpeichern() {
|
||||
if (ausgewaehlterObjekttyp === "" || neuePruefartId === "") return;
|
||||
try {
|
||||
await apiRequest(`/objekttypen/${ausgewaehlterObjekttyp}/pruefarten/${neuePruefartId}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
pruefart_id: neuePruefartId,
|
||||
intervall_monate: neuesIntervall ? Number(neuesIntervall) : null,
|
||||
},
|
||||
});
|
||||
setNeuePruefartId("");
|
||||
setNeuesIntervall("");
|
||||
await ladeZuordnungen(ausgewaehlterObjekttyp);
|
||||
} catch {
|
||||
onFehler("Zuordnung konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
async function zuordnungEntfernen(pruefartId: number) {
|
||||
if (ausgewaehlterObjekttyp === "") return;
|
||||
try {
|
||||
await apiRequest(`/objekttypen/${ausgewaehlterObjekttyp}/pruefarten/${pruefartId}`, { method: "DELETE" });
|
||||
await ladeZuordnungen(ausgewaehlterObjekttyp);
|
||||
} catch {
|
||||
onFehler("Zuordnung konnte nicht entfernt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
function pruefartName(id: number) {
|
||||
return pruefarten.find((p) => p.id === id)?.name ?? `#${id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<h2>Prüfarten</h2>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Prüfarten-Stammdaten</h3>
|
||||
<div className="row" style={{ flexWrap: "wrap", marginBottom: "0.75rem" }}>
|
||||
<input className="input" placeholder="Name (z.B. UVV-Prüfung)" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Beschreibung (optional)"
|
||||
value={beschreibung}
|
||||
onChange={(e) => setBeschreibung(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Gesetzliche Grundlage (optional)"
|
||||
value={grundlage}
|
||||
onChange={(e) => setGrundlage(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={pruefartAnlegen} disabled={!name}>
|
||||
Anlegen
|
||||
</button>
|
||||
</div>
|
||||
<ul className="card-list">
|
||||
{pruefarten.map((p) => (
|
||||
<li key={p.id}>
|
||||
<strong>{p.name}</strong>
|
||||
{p.gesetzliche_grundlage && <span className="text-muted"> — {p.gesetzliche_grundlage}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: "1rem" }}>
|
||||
<h3 style={{ marginTop: 0 }}>Zuordnung je Objekttyp</h3>
|
||||
<select
|
||||
className="input"
|
||||
style={{ marginBottom: "0.75rem" }}
|
||||
value={ausgewaehlterObjekttyp}
|
||||
onChange={(e) => setAusgewaehlterObjekttyp(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Objekttyp wählen…</option>
|
||||
{objekttypen.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{ausgewaehlterObjekttyp !== "" && (
|
||||
<>
|
||||
<ul className="card-list" style={{ marginBottom: "0.75rem" }}>
|
||||
{zuordnungen.length === 0 && <p className="text-muted">Keine Prüfart zugeordnet.</p>}
|
||||
{zuordnungen.map((z) => (
|
||||
<li key={z.pruefart_id} className="row-between">
|
||||
<span>
|
||||
{pruefartName(z.pruefart_id)}
|
||||
{z.intervall_monate && <span className="text-muted"> — alle {z.intervall_monate} Monate</span>}
|
||||
</span>
|
||||
<button className="btn btn-secondary" onClick={() => zuordnungEntfernen(z.pruefart_id)}>
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
<select
|
||||
className="input"
|
||||
value={neuePruefartId}
|
||||
onChange={(e) => setNeuePruefartId(Number(e.target.value) || "")}
|
||||
>
|
||||
<option value="">Prüfart wählen…</option>
|
||||
{pruefarten.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="input"
|
||||
style={{ maxWidth: "10rem" }}
|
||||
placeholder="Intervall (Monate)"
|
||||
value={neuesIntervall}
|
||||
onChange={(e) => setNeuesIntervall(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={zuordnungSpeichern} disabled={neuePruefartId === ""}>
|
||||
Zuordnen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user