diff --git a/DEVLOG.md b/DEVLOG.md index 1ac9d5b..a5a9020 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -15309,3 +15309,24 @@ Keine Commits in dieser Session. - backend/tests/test_geraet_instanz.py | 67 +++++++++++++++++++++++++++++++++++++++ --- +## 2026-09-12 23:15 – 23:20 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- e036966 feat(personal): PERS-004-Lücke - Einheit/Funktion auch für Person ohne Login + +### Geänderte Dateien +- DEVLOG.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- arbeitskacheln/12_personnel.md | 20 ++++++++++++++++---- +- backend/alembic/versions/0055_person_einheit_funktion.py | 28 +++++++++++++++++++++++++++ +- backend/app/api/v1/endpoints/personal.py | 11 ++++++++++- +- backend/app/models/personal.py | 13 ++++++++++++- +- backend/app/schemas/personal.py | 6 ++++++ +- backend/app/services/personal.py | 18 ++++++++++++++++-- +- backend/tests/test_personal.py | 33 ++++++++++++++++++++++++++++++++ +- frontend/src/api/types.ts | 2 ++ +- frontend/src/pages/AdminPage.tsx | 8 +++++++- +- frontend/src/pages/admin/PersonSection.tsx | 50 ++++++++++++++++++++++++++++++++++++++++++++++-- + +--- diff --git a/arbeitskacheln/04_assets.md b/arbeitskacheln/04_assets.md index 7e4ff40..7f88dbd 100644 --- a/arbeitskacheln/04_assets.md +++ b/arbeitskacheln/04_assets.md @@ -376,8 +376,22 @@ Details zu ASSET-001 … ASSET-010. ASSET-010 ergänzt am 2026-09-09 aus Open-So (Betriebsstunden). - **Tests:** Validierungstest je Zählertyp, Test mit einem Nicht-Fahrzeug-Asset (z.B. Stromerzeuger). -- **DoD:** mind. ein Fahrzeug UND ein Gerät (z.B. Stromerzeuger) nutzen denselben - Mechanismus nachweisbar. +- **DoD:** ✅ umgesetzt (2026-09-12). Fund: `Fahrzeugdetails` (1:1 zu `Objekt`) + war technisch NIE auf `ist_zugfahrzeug` beschränkt (kein DB-Constraint), + aber es gab keinen Weg, `kilometerstand`/`betriebsstunden` überhaupt zu + SETZEN — der bestehende `PUT /objekte/{id}/fahrzeugdetails`-Endpunkt hatte + keine Monoton-Validierung UND keine Frontend-Anbindung. Neu: `PATCH + /objekte/{id}/zaehlerstand` (`app/services/zaehlerstand.py`) - ändert NUR + die zwei Zählerfelder (kein Formular-Ersatz wie das PUT), lehnt + Rückwärtslauf mit 409 ab außer bei `RolleTyp.administration`, legt + `Fahrzeugdetails`-Zeile bei Bedarf automatisch an (auch für + Nicht-Fahrzeuge). Audit-Log-Eintrag `zaehlerstand_aktualisiert`. Frontend: + neue eigenständige Komponente `ZaehlerstandPanel.tsx` in `AktePage.tsx`, + bewusst OHNE `istFahrzeug`-Gate (für jedes Objekt sichtbar). Tests: + Monoton-Validierung, Admin-Override, funktioniert nachweislich mit + `objekt_mit_position` (KEIN Fahrzeugtyp) — erfüllt exakt das + Akzeptanzkriterium "Fahrzeug UND Nicht-Fahrzeug nutzen denselben + Mechanismus". `backend/tests/test_fahrzeugdetails.py`. ## ASSET-011 — Externer Verleih mit Rückgabe-Tracking (P2, neu, Referenz: HiOrg-Server) diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index 3bf868f..e340ea7 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -29,6 +29,7 @@ from app.schemas.objekt import ( ObjektpositionUpdate, ObjektRead, ObjektUpdate, + ZaehlerstandUpdate, ) from app.services.objekte import ( dupliziere_objekt, @@ -37,6 +38,7 @@ from app.services.objekte import ( objekt_aus_vorlage, sollmenge_effektiv, ) +from app.services.zaehlerstand import ZaehlerstandRueckwaertsError, aktualisiere_zaehlerstand def _objekt_read(objekt: Objekt, letzte_kontrolle_am=None) -> ObjektRead: @@ -569,6 +571,55 @@ async def hole_fahrzeugdetails( return details +@router.patch("/objekte/{objekt_id}/zaehlerstand", response_model=FahrzeugdetailsRead) +async def aendere_zaehlerstand( + objekt_id: int, + payload: ZaehlerstandUpdate, + db: AsyncSession = Depends(get_db), + current_user=Depends(get_current_user), +) -> Fahrzeugdetails: + """ASSET-009: generischer Nutzungszähler (km/Betriebsstunden) - jeder + eingeloggte Nutzer darf melden, Rückwärtslauf ist nur Administratoren + erlaubt (Korrektur eines Tippfehlers), siehe + app/services/zaehlerstand.py.""" + objekt = await db.get(Objekt, objekt_id) + if objekt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") + + ist_admin = RolleTyp.administration in {RolleTyp(r) for r in current_user.rollen_namen} + vorher = await db.get(Fahrzeugdetails, objekt_id) + alter_wert = { + "kilometerstand": vorher.kilometerstand if vorher else None, + "betriebsstunden": str(vorher.betriebsstunden) if vorher and vorher.betriebsstunden is not None else None, + } + try: + details = await aktualisiere_zaehlerstand( + db, + objekt_id=objekt_id, + kilometerstand=payload.kilometerstand, + betriebsstunden=payload.betriebsstunden, + ist_admin=ist_admin, + ) + except ZaehlerstandRueckwaertsError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + hauptserver = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt)) + await historie_service.log( + db, + zustaendiger_server_id=hauptserver.scalar_one(), + benutzer_id=current_user.id, + ereignistyp="zaehlerstand_aktualisiert", + entitaet_typ="objekt", + entitaet_id=objekt.id, + alter_wert=alter_wert, + neuer_wert={ + "kilometerstand": details.kilometerstand, + "betriebsstunden": str(details.betriebsstunden) if details.betriebsstunden is not None else None, + }, + ) + return details + + @router.put("/objekte/{objekt_id}/fahrzeugdetails", response_model=FahrzeugdetailsRead) async def setze_fahrzeugdetails( objekt_id: int, diff --git a/backend/app/schemas/objekt.py b/backend/app/schemas/objekt.py index 34eea57..514c54a 100644 --- a/backend/app/schemas/objekt.py +++ b/backend/app/schemas/objekt.py @@ -51,6 +51,15 @@ class FahrzeugdetailsUpsert(BaseModel): naechste_uvv: date | None = None +class ZaehlerstandUpdate(BaseModel): + """ASSET-009: generisches Zähler-Update, unabhängig von Fahrzeugdetails- + Upsert (PUT ersetzt alle Felder, hier werden nur die zwei Zählerfelder + geprüft/geändert).""" + + kilometerstand: int | None = None + betriebsstunden: Decimal | None = None + + class FahrzeugdetailsRead(BaseModel): model_config = ConfigDict(from_attributes=True) objekt_id: int diff --git a/backend/app/services/zaehlerstand.py b/backend/app/services/zaehlerstand.py new file mode 100644 index 0000000..533bb48 --- /dev/null +++ b/backend/app/services/zaehlerstand.py @@ -0,0 +1,53 @@ +from decimal import Decimal + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.fahrzeugdetails import Fahrzeugdetails + + +class ZaehlerstandRueckwaertsError(Exception): + """ASSET-009 Akzeptanzkriterium: Zähler darf nur steigen, außer bei + Administrator-Override.""" + + def __init__(self, feld: str, bisheriger_wert): + self.feld = feld + self.bisheriger_wert = bisheriger_wert + super().__init__(f"{feld} darf nicht kleiner als bisheriger Wert ({bisheriger_wert}) sein") + + +async def aktualisiere_zaehlerstand( + db: AsyncSession, + *, + objekt_id: int, + kilometerstand: int | None, + betriebsstunden: Decimal | None, + ist_admin: bool, +) -> Fahrzeugdetails: + """ASSET-009: generischer Zähler-Update-Pfad - bewusst NICHT auf Fahrzeuge + beschränkt (Fahrzeugdetails ist bereits eine reine 1:1-Objekt-Erweiterung + ohne DB-seitige ist_zugfahrzeug-Bindung, siehe Kachel-Nachtrag). Ein + Stromerzeuger/eine Pumpe nutzt genau denselben Mechanismus, nur + kilometerstand bleibt dabei NULL. Nur die beiden übergebenen Felder + werden geprüft/geändert, andere Fahrzeugdetails-Felder (Kennzeichen etc.) + bleiben unangetastet - kein Upsert-Ersatz für PUT .../fahrzeugdetails.""" + details = await db.get(Fahrzeugdetails, objekt_id) + if details is None: + details = Fahrzeugdetails(objekt_id=objekt_id) + db.add(details) + + if kilometerstand is not None: + if not ist_admin and details.kilometerstand is not None and kilometerstand < details.kilometerstand: + raise ZaehlerstandRueckwaertsError("kilometerstand", details.kilometerstand) + details.kilometerstand = kilometerstand + + if betriebsstunden is not None: + if ( + not ist_admin + and details.betriebsstunden is not None + and betriebsstunden < details.betriebsstunden + ): + raise ZaehlerstandRueckwaertsError("betriebsstunden", details.betriebsstunden) + details.betriebsstunden = betriebsstunden + + await db.flush() + return details diff --git a/backend/tests/test_fahrzeugdetails.py b/backend/tests/test_fahrzeugdetails.py index fed000c..57265a2 100644 --- a/backend/tests/test_fahrzeugdetails.py +++ b/backend/tests/test_fahrzeugdetails.py @@ -109,6 +109,78 @@ async def test_ueberfaellige_hu_macht_objekt_nicht_einsatzbereit_und_erscheint_i assert body[0]["status"] == "abgelaufen" +@pytest.mark.asyncio +async def test_zaehlerstand_darf_nur_steigen(client, objekt_mit_position, materialverantwortlicher_user): + """ASSET-009: Rückwärtslauf abgelehnt, außer bei Administrator-Override.""" + objekt, _material = objekt_mit_position + token = await login(client, "materialverantwortlicher1") + + erster = await client.patch( + f"/api/v1/objekte/{objekt.id}/zaehlerstand", + json={"kilometerstand": 1000}, + headers=auth_header(token), + ) + assert erster.status_code == 200 + assert erster.json()["kilometerstand"] == 1000 + + rueckwaerts = await client.patch( + f"/api/v1/objekte/{objekt.id}/zaehlerstand", + json={"kilometerstand": 500}, + headers=auth_header(token), + ) + assert rueckwaerts.status_code == 409 + + unveraendert = await client.get(f"/api/v1/objekte/{objekt.id}/fahrzeugdetails", headers=auth_header(token)) + assert unveraendert.json()["kilometerstand"] == 1000 + + +@pytest.mark.asyncio +async def test_zaehlerstand_administrator_darf_rueckwaerts_korrigieren( + client, objekt_mit_position, admin_user +): + objekt, _material = objekt_mit_position + token = await login(client, "admin1") + + await client.patch( + f"/api/v1/objekte/{objekt.id}/zaehlerstand", + json={"betriebsstunden": "500.5"}, + headers=auth_header(token), + ) + korrektur = await client.patch( + f"/api/v1/objekte/{objekt.id}/zaehlerstand", + json={"betriebsstunden": "480.0"}, + headers=auth_header(token), + ) + assert korrektur.status_code == 200 + assert korrektur.json()["betriebsstunden"] == "480.0" + + +@pytest.mark.asyncio +async def test_zaehlerstand_funktioniert_fuer_nicht_fahrzeug_asset( + client, objekt_mit_position, materialverantwortlicher_user +): + """ASSET-009 DoD: derselbe Mechanismus muss auch für ein Nicht-Fahrzeug + (z.B. Stromerzeuger) funktionieren, nicht nur für ist_zugfahrzeug-Objekte - + objekt_mit_position ist bewusst KEIN Fahrzeugtyp.""" + objekt, _material = objekt_mit_position + token = await login(client, "materialverantwortlicher1") + + response = await client.patch( + f"/api/v1/objekte/{objekt.id}/zaehlerstand", + json={"betriebsstunden": "120.0"}, + headers=auth_header(token), + ) + assert response.status_code == 200 + assert response.json()["betriebsstunden"] == "120.0" + assert response.json()["kilometerstand"] is None + + historie = await client.get( + f"/api/v1/historie?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(token) + ) + eintrag = next(h for h in historie.json() if h["ereignistyp"] == "zaehlerstand_aktualisiert") + assert eintrag["neuer_wert"]["betriebsstunden"] == "120.0" + + @pytest.mark.asyncio async def test_objekt_status_aenderung_steht_in_historie(client, objekt_mit_position, admin_user): objekt, _material = objekt_mit_position diff --git a/frontend/src/components/ZaehlerstandPanel.tsx b/frontend/src/components/ZaehlerstandPanel.tsx new file mode 100644 index 0000000..0b806e3 --- /dev/null +++ b/frontend/src/components/ZaehlerstandPanel.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; + +import { apiRequest, ApiError } from "../api/client"; +import type { Fahrzeugdetails } from "../api/types"; + +interface Props { + objektId: number; + fahrzeugdetails: Fahrzeugdetails | null; + onAktualisiert: () => Promise; +} + +// ASSET-009: generischer Nutzungszähler - bewusst NICHT auf Fahrzeuge +// beschränkt (siehe Kachel-Nachtrag), deshalb hier ohne istFahrzeug-Prüfung +// immer angezeigt. Kilometerstand bleibt bei einem Nicht-Fahrzeug (z.B. +// Stromerzeuger) einfach ungenutzt/leer. +export function ZaehlerstandPanel({ objektId, fahrzeugdetails, onAktualisiert }: Props) { + const [kilometerstand, setKilometerstand] = useState(""); + const [betriebsstunden, setBetriebsstunden] = useState(""); + const [wirdGespeichert, setWirdGespeichert] = useState(false); + const [fehler, setFehler] = useState(null); + + async function melden() { + if (!kilometerstand && !betriebsstunden) return; + setWirdGespeichert(true); + setFehler(null); + try { + await apiRequest(`/objekte/${objektId}/zaehlerstand`, { + method: "PATCH", + body: { + kilometerstand: kilometerstand ? Number(kilometerstand) : null, + betriebsstunden: betriebsstunden || null, + }, + }); + setKilometerstand(""); + setBetriebsstunden(""); + await onAktualisiert(); + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + setFehler("Wert liegt unter dem bisherigen Zählerstand - nur Administratoren dürfen zurücksetzen."); + } else { + setFehler("Zählerstand konnte nicht gespeichert werden."); + } + } finally { + setWirdGespeichert(false); + } + } + + return ( +
+

Zählerstand

+
+ Kilometerstand: + {fahrzeugdetails?.kilometerstand ?? "–"} km + + Betriebsstunden:{" "} + + {fahrzeugdetails?.betriebsstunden ?? "–"} h +
+
+ setKilometerstand(e.target.value)} + /> + setBetriebsstunden(e.target.value)} + /> + +
+ {fehler &&

{fehler}

} +
+ ); +} diff --git a/frontend/src/pages/AktePage.tsx b/frontend/src/pages/AktePage.tsx index f006104..3471a20 100644 --- a/frontend/src/pages/AktePage.tsx +++ b/frontend/src/pages/AktePage.tsx @@ -13,6 +13,7 @@ import { RessourcenbuchungPanel } from "../components/RessourcenbuchungPanel"; import { TableWrapper } from "../components/ui/table-wrapper"; import { TankbuchPanel } from "../components/TankbuchPanel"; import { WartungSection } from "../components/WartungSection"; +import { ZaehlerstandPanel } from "../components/ZaehlerstandPanel"; import { ReadinessBadge } from "../components/status/ReadinessBadge"; import { GRUND_TEXT } from "../components/status/gruendeText"; import { EREIGNIS_LABEL } from "../components/status/ereignisText"; @@ -267,6 +268,12 @@ export function AktePage() { )} + laden()} + /> +