feat(assets): ASSET-009 generischer Nutzungszähler (km/Betriebsstunden)
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:
1 parent
e03696642f
commit
69f66c6d1c
8 files changed
+315
-2
No files matched your search
@@ -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 ++++++++++++++++++++++++++++++++++++++++++++++--
|
||||
|
||||
---
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user