feat(assets): ASSET-009 generischer Nutzungszähler (km/Betriebsstunden)
CI / backend-tests (push) Successful in 3m25s
CI / frontend-build (push) Successful in 21s

Fahrzeugdetails war nie technisch auf Fahrzeuge beschränkt, aber es gab
keinen Weg, den Zählerstand überhaupt zu setzen (nur Formular-Ersatz-PUT
ohne Validierung, keine Frontend-Anbindung). Neuer PATCH-Endpunkt mit
Monoton-Validierung (Rückwärtslauf nur Administrator), neues
ZaehlerstandPanel in der Akte - bewusst ohne Fahrzeug-Gate, damit
Stromerzeuger/Pumpen denselben Mechanismus nutzen können.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-12 23:35:57 +02:00
1 parent e03696642f
commit 69f66c6d1c
8 files changed
+315 -2

No files matched your search

+21
View File
@@ -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 ++++++++++++++++++++++++++++++++++++++++++++++--
---
+16 -2
View File
@@ -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)
+51
View File
@@ -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,
+9
View File
@@ -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
+53
View File
@@ -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
+72
View File
@@ -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
@@ -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<void>;
}
// 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<string | null>(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 (
<div className="card">
<h3 style={{ marginTop: 0 }}>Zählerstand</h3>
<div style={{ marginBottom: "0.5rem" }}>
<span className="text-muted">Kilometerstand: </span>
{fahrzeugdetails?.kilometerstand ?? "–"} km
<span className="text-muted" style={{ marginLeft: "1rem" }}>
Betriebsstunden:{" "}
</span>
{fahrzeugdetails?.betriebsstunden ?? "–"} h
</div>
<div className="row" style={{ flexWrap: "wrap", gap: "0.5rem" }}>
<input
className="input"
type="number"
placeholder="Neuer Kilometerstand"
value={kilometerstand}
onChange={(e) => setKilometerstand(e.target.value)}
/>
<input
className="input"
type="number"
step="0.1"
placeholder="Neue Betriebsstunden"
value={betriebsstunden}
onChange={(e) => setBetriebsstunden(e.target.value)}
/>
<button
className="btn btn-primary"
onClick={melden}
disabled={wirdGespeichert || (!kilometerstand && !betriebsstunden)}
>
Melden
</button>
</div>
{fehler && <p style={{ color: "var(--color-danger)", marginBottom: 0 }}>{fehler}</p>}
</div>
);
}
+7
View File
@@ -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() {
)}
</div>
<ZaehlerstandPanel
objektId={objekt.id}
fahrzeugdetails={akte.fahrzeugdetails}
onAktualisiert={async () => laden()}
/>
<div className="akte-wartung">
<WartungSection objektId={objekt.id} onFehler={setFehler} />
</div>