fix(assets): ASSET-009 Zählerstand-Panel als Opt-in je Objekttyp
CI / backend-tests (push) Successful in 3m36s
CI / frontend-build (push) Successful in 22s

Nutzer-Fund: Panel war ungeachtet des Objekttyps in jeder Akte sichtbar,
auch bei einem Rettungsrucksack ohne Kilometerstand/Betriebsstunden. Neues
Feld Objekttyp.zaehler_typ (keiner/kilometer/betriebsstunden/beide, Migration
0058, bestehende Fahrzeug-Objekttypen automatisch auf "beide" migriert)
steuert jetzt Sichtbarkeit UND welche Felder das Panel zeigt - gleiches
Opt-in-Muster wie ressourcenbuchung_aktiv (ASSET-012). Admin-UI in
ObjekttypSection.tsx ergänzt.

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-13 23:19:26 +02:00
1 parent e5bb213cca
commit 29b334578f
12 files changed
+251 -51

No files matched your search

+45
View File
@@ -15677,3 +15677,48 @@ Keine Commits in dieser Session.
- arbeitskacheln/10_maintenance.md | 6 +-----
---
## 2026-09-13 23:05 – 23:08 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- e5bb213 fix(akte): Grid-Overlap bei Nicht-Fahrzeug-Objekten auf Desktop-Breite
### Geänderte Dateien
- DEVLOG.md | 36 ++++++++++++++++++++++++++++++++++++
- frontend/src/components/ZaehlerstandPanel.tsx | 63 +++++++++++++++++++++++++++++++++++++++------------------------
- frontend/src/pages/AktePage.tsx | 12 +++++++-----
- frontend/src/styles/global/akte-grid.css | 4 ++++
- frontend/src/styles/global/responsive.css | 12 ++++++++++++
---
## 2026-09-13 23:10 – 23:11 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 36 ++++++++++++++++++++++++++++++++++++
- frontend/src/components/ZaehlerstandPanel.tsx | 63 +++++++++++++++++++++++++++++++++++++++------------------------
- frontend/src/pages/AktePage.tsx | 12 +++++++-----
- frontend/src/styles/global/akte-grid.css | 4 ++++
- frontend/src/styles/global/responsive.css | 12 ++++++++++++
---
## 2026-09-13 23:11 – 23:11 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 36 ++++++++++++++++++++++++++++++++++++
- frontend/src/components/ZaehlerstandPanel.tsx | 63 +++++++++++++++++++++++++++++++++++++++------------------------
- frontend/src/pages/AktePage.tsx | 12 +++++++-----
- frontend/src/styles/global/akte-grid.css | 4 ++++
- frontend/src/styles/global/responsive.css | 12 ++++++++++++
---
+17 -3
View File
@@ -386,12 +386,26 @@ Details zu ASSET-001 … ASSET-010. ASSET-010 ergänzt am 2026-09-09 aus Open-So
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
neue eigenständige Komponente `ZaehlerstandPanel.tsx` in `AktePage.tsx`.
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`.
**Bugfix 2026-09-13 (Nutzer-Fund per Screenshot):** "bewusst OHNE
istFahrzeug-Gate" oben war ein Fehler in der Praxis — das Panel war
dadurch in JEDER Akte sichtbar, auch bei einem Rettungsrucksack ohne
Kilometerstand/Betriebsstunden. Auf Opt-in je Objekttyp umgestellt
(gleiches Muster wie `ressourcenbuchung_aktiv`, ASSET-012): neues Feld
`Objekttyp.zaehler_typ` (Enum `keiner`/`kilometer`/`betriebsstunden`/
`beide`, Migration `0058_objekttyp_zaehler_typ.py`, bestehende
Fahrzeug-Objekttypen automatisch auf `beide` migriert). `AkteRead.
zaehler_typ` steuert die bedingte Anzeige in `AktePage.tsx` UND welche(s)
Feld(er) `ZaehlerstandPanel.tsx` überhaupt zeigt (nicht mehr immer beide).
Admin-UI: Auswahlfeld „Zählerstand in der Akte" in `ObjekttypSection.tsx`.
Der `PATCH /objekte/{id}/zaehlerstand`-Endpunkt selbst bleibt ungegatet
(reine Anzeige-Einschränkung, kein Zugriffsschutz nötig). Test:
`test_akte_zaehler_typ_folgt_objekttyp_und_ist_default_keiner`
(`backend/tests/test_akte.py`).
## ASSET-011 — Externer Verleih mit Rückgabe-Tracking (P2, neu, Referenz: HiOrg-Server)
@@ -0,0 +1,34 @@
"""ASSET-009-Bugfix: Zählerstand-Panel war ungeachtet des Objekttyps in
jeder Akte sichtbar (auch bei Rettungsrucksäcken ohne km/Betriebsstunden).
Opt-in je Objekttyp, gleiches Muster wie ressourcenbuchung_aktiv.
Revision ID: 0058_objekttyp_zaehler_typ
Revises: 0057_benutzer_standort_zugriff
Create Date: 2026-09-13
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0058_objekttyp_zaehler_typ"
down_revision: Union[str, None] = "0057_benutzer_standort_zugriff"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE TYPE zaehler_typ AS ENUM ('keiner', 'kilometer', 'betriebsstunden', 'beide')")
op.execute(
"ALTER TABLE objekttyp ADD COLUMN zaehler_typ zaehler_typ NOT NULL DEFAULT 'keiner'"
)
# Bestehende Fahrzeug-Objekttypen hatten das Zähler-Panel faktisch schon
# genutzt (Fahrzeugdetails.kilometerstand/betriebsstunden existieren seit
# Modul Fahrzeuge) - Migration setzt sie automatisch auf "beide", damit
# sich für diese produktiv genutzten Fälle nichts sichtbar ändert.
op.execute("UPDATE objekttyp SET zaehler_typ = 'beide' WHERE ist_zugfahrzeug = true")
def downgrade() -> None:
op.execute("ALTER TABLE objekttyp DROP COLUMN zaehler_typ")
op.execute("DROP TYPE zaehler_typ")
+16
View File
@@ -7,6 +7,16 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ZaehlerTyp(str, enum.Enum):
keiner = "keiner"
kilometer = "kilometer"
betriebsstunden = "betriebsstunden"
beide = "beide"
zaehler_typ_pg = PgEnum(ZaehlerTyp, name="zaehler_typ", create_type=False)
class MaterialTyp(str, enum.Enum):
standard = "standard"
ablauf_charge = "ablauf_charge"
@@ -57,6 +67,12 @@ class Objekttyp(Base):
# (Verbrauchsmaterial-Rucksäcke etc.) ergibt eine Buchungskalender-Ansicht
# keinen Sinn. Jetzt Opt-in je Objekttyp statt global erzwungen.
ressourcenbuchung_aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# ASSET-009 Bugfix (Nutzer-Fund 2026-09-13): Zählerstand-Panel war in
# JEDER Akte sichtbar, unabhängig vom Objekttyp - ein Rettungsrucksack
# hat weder Kilometerstand noch Betriebsstunden. Gleiches Opt-in-Muster
# wie ressourcenbuchung_aktiv, hier als Enum statt bool, da die Kachel
# selbst zwischen km/Betriebsstunden/beidem/keinem unterscheidet.
zaehler_typ: Mapped[ZaehlerTyp] = mapped_column(zaehler_typ_pg, nullable=False, default=ZaehlerTyp.keiner)
class Nummernschema(Base):
+1
View File
@@ -41,6 +41,7 @@ class AkteRead(BaseModel):
einsatzbereitschaft_gruende: list[str]
objekttyp_name: str
ressourcenbuchung_aktiv: bool
zaehler_typ: str
standort_name: str
fahrzeugdetails: FahrzeugdetailsRead | None
zustaendige_benutzer: list[ZustaendigerBenutzer]
+3 -1
View File
@@ -1,6 +1,6 @@
from pydantic import BaseModel, ConfigDict
from app.models.stammdaten import MaterialTyp
from app.models.stammdaten import MaterialTyp, ZaehlerTyp
class BereichCreate(BaseModel):
@@ -91,6 +91,7 @@ class ObjekttypCreate(BaseModel):
name: str
ist_zugfahrzeug: bool = False
ressourcenbuchung_aktiv: bool = False
zaehler_typ: ZaehlerTyp = ZaehlerTyp.keiner
class ObjekttypRead(ObjekttypCreate):
@@ -104,6 +105,7 @@ class ObjekttypUpdate(BaseModel):
name: str | None = None
ist_zugfahrzeug: bool | None = None
ressourcenbuchung_aktiv: bool | None = None
zaehler_typ: ZaehlerTyp | None = None
class FachCreate(BaseModel):
+1
View File
@@ -140,6 +140,7 @@ async def akte_fuer_objekt(db: AsyncSession, objekt_id: int) -> AkteRead | None:
einsatzbereitschaft_gruende=readiness["gruende"],
objekttyp_name=objekttyp.name if objekttyp else "",
ressourcenbuchung_aktiv=objekttyp.ressourcenbuchung_aktiv if objekttyp else False,
zaehler_typ=objekttyp.zaehler_typ.value if objekttyp else "keiner",
standort_name=standort.name if standort else "",
fahrzeugdetails=FahrzeugdetailsRead.model_validate(fahrzeugdetails) if fahrzeugdetails else None,
zustaendige_benutzer=zustaendige,
+24
View File
@@ -79,6 +79,30 @@ async def test_akte_ressourcenbuchung_aktiv_folgt_objekttyp_flag(client, objekt_
assert antwort.json()["ressourcenbuchung_aktiv"] is True
@pytest.mark.asyncio
async def test_akte_zaehler_typ_folgt_objekttyp_und_ist_default_keiner(client, objekt_mit_position, admin_user):
"""ASSET-009-Bugfix (Nutzer-Fund 2026-09-13): Zählerstand-Panel war
ungeachtet des Objekttyps immer sichtbar (auch bei einem
Rettungsrucksack) - jetzt Opt-in je Objekttyp wie ressourcenbuchung_aktiv."""
objekt, _material = objekt_mit_position
token = await login(client, "admin1")
ohne_aenderung = await client.get(f"/api/v1/akte/objekt/{objekt.id}", headers=auth_header(token))
assert ohne_aenderung.json()["zaehler_typ"] == "keiner"
objekttyp_id = objekt.objekttyp_id
aendern = await client.patch(
f"/api/v1/objekttypen/{objekttyp_id}",
json={"zaehler_typ": "betriebsstunden"},
headers=auth_header(token),
)
assert aendern.status_code == 200
assert aendern.json()["zaehler_typ"] == "betriebsstunden"
antwort = await client.get(f"/api/v1/akte/objekt/{objekt.id}", headers=auth_header(token))
assert antwort.json()["zaehler_typ"] == "betriebsstunden"
@pytest.mark.asyncio
async def test_akte_readiness_unbekannt_bei_nie_kontrolliert(client, objekt_mit_position, admin_user):
"""UI-005: ein Objekt ohne jede Auffälligkeit, aber noch nie kontrolliert,
+4
View File
@@ -413,6 +413,8 @@ export interface Modell {
name: string;
}
export type ZaehlerTyp = "keiner" | "kilometer" | "betriebsstunden" | "beide";
export interface Objekttyp {
id: number;
bereich_id: number;
@@ -420,6 +422,7 @@ export interface Objekttyp {
name: string;
ist_zugfahrzeug: boolean;
ressourcenbuchung_aktiv: boolean;
zaehler_typ: ZaehlerTyp;
}
export interface Nummernschema {
@@ -478,6 +481,7 @@ export interface Akte {
einsatzbereitschaft_gruende: string[];
objekttyp_name: string;
ressourcenbuchung_aktiv: boolean;
zaehler_typ: ZaehlerTyp;
standort_name: string;
fahrzeugdetails: Fahrzeugdetails | null;
zustaendige_benutzer: ZustaendigerBenutzer[];
+52 -39
View File
@@ -1,19 +1,24 @@
import { useState } from "react";
import { apiRequest, ApiError } from "../api/client";
import type { Fahrzeugdetails } from "../api/types";
import type { Fahrzeugdetails, ZaehlerTyp } from "../api/types";
interface Props {
objektId: number;
zaehlerTyp: ZaehlerTyp;
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) {
// ASSET-009-Bugfix (Nutzer-Fund 2026-09-13): war ungeachtet des Objekttyps
// IMMER sichtbar, z.B. auch bei einem Rettungsrucksack ohne Kilometerstand/
// Betriebsstunden - jetzt Opt-in über Objekttyp.zaehler_typ (gleiches
// Muster wie ressourcenbuchung_aktiv), AktePage rendert diese Komponente
// nur noch bei zaehler_typ !== "keiner". Zeigt zusätzlich nur das/die
// tatsächlich konfigurierte(n) Feld(er) statt immer beide.
export function ZaehlerstandPanel({ objektId, zaehlerTyp, fahrzeugdetails, onAktualisiert }: Props) {
const zeigtKilometer = zaehlerTyp === "kilometer" || zaehlerTyp === "beide";
const zeigtBetriebsstunden = zaehlerTyp === "betriebsstunden" || zaehlerTyp === "beide";
const [kilometerstand, setKilometerstand] = useState("");
const [betriebsstunden, setBetriebsstunden] = useState("");
const [wirdGespeichert, setWirdGespeichert] = useState(false);
@@ -49,44 +54,52 @@ export function ZaehlerstandPanel({ objektId, fahrzeugdetails, onAktualisiert }:
<div className="card">
<h3 style={{ marginTop: 0 }}>Zählerstand</h3>
<div style={{ marginBottom: "0.75rem" }}>
<div>
<span className="text-muted">Kilometerstand: </span>
{fahrzeugdetails?.kilometerstand ?? "–"} km
</div>
<div>
<span className="text-muted">Betriebsstunden: </span>
{fahrzeugdetails?.betriebsstunden ?? "–"} h
</div>
{zeigtKilometer && (
<div>
<span className="text-muted">Kilometerstand: </span>
{fahrzeugdetails?.kilometerstand ?? "–"} km
</div>
)}
{zeigtBetriebsstunden && (
<div>
<span className="text-muted">Betriebsstunden: </span>
{fahrzeugdetails?.betriebsstunden ?? "–"} h
</div>
)}
</div>
{/* Card kann schmal sein (Sidebar-Layout) - Felder untereinander statt
in einer Reihe, sonst schneidet der Platzhaltertext ab bzw.
überlappt der Zahlen-Spinner den Text (Bug-Fund per Screenshot). */}
<div className="stack" style={{ gap: "0.5rem" }}>
<div>
<label className="text-muted" style={{ fontSize: "0.85rem", display: "block", marginBottom: "0.2rem" }}>
Neuer Kilometerstand
</label>
<input
className="input"
style={{ width: "100%", boxSizing: "border-box" }}
type="number"
value={kilometerstand}
onChange={(e) => setKilometerstand(e.target.value)}
/>
</div>
<div>
<label className="text-muted" style={{ fontSize: "0.85rem", display: "block", marginBottom: "0.2rem" }}>
Neue Betriebsstunden
</label>
<input
className="input"
style={{ width: "100%", boxSizing: "border-box" }}
type="number"
step="0.1"
value={betriebsstunden}
onChange={(e) => setBetriebsstunden(e.target.value)}
/>
</div>
{zeigtKilometer && (
<div>
<label className="text-muted" style={{ fontSize: "0.85rem", display: "block", marginBottom: "0.2rem" }}>
Neuer Kilometerstand
</label>
<input
className="input"
style={{ width: "100%", boxSizing: "border-box" }}
type="number"
value={kilometerstand}
onChange={(e) => setKilometerstand(e.target.value)}
/>
</div>
)}
{zeigtBetriebsstunden && (
<div>
<label className="text-muted" style={{ fontSize: "0.85rem", display: "block", marginBottom: "0.2rem" }}>
Neue Betriebsstunden
</label>
<input
className="input"
style={{ width: "100%", boxSizing: "border-box" }}
type="number"
step="0.1"
value={betriebsstunden}
onChange={(e) => setBetriebsstunden(e.target.value)}
/>
</div>
)}
<button
className="btn btn-primary"
onClick={melden}
+10 -7
View File
@@ -268,13 +268,16 @@ export function AktePage() {
)}
</div>
<div className="akte-zaehlerstand">
<ZaehlerstandPanel
objektId={objekt.id}
fahrzeugdetails={akte.fahrzeugdetails}
onAktualisiert={async () => laden()}
/>
</div>
{akte.zaehler_typ !== "keiner" && (
<div className="akte-zaehlerstand">
<ZaehlerstandPanel
objektId={objekt.id}
zaehlerTyp={akte.zaehler_typ}
fahrzeugdetails={akte.fahrzeugdetails}
onAktualisiert={async () => laden()}
/>
</div>
)}
<div className="akte-wartung">
<WartungSection objektId={objekt.id} onFehler={setFehler} />
+44 -1
View File
@@ -1,9 +1,16 @@
import { useState } from "react";
import { apiRequest } from "../../api/client";
import type { Bereich, Kategorie, Nummernschema, Objekttyp } from "../../api/types";
import type { Bereich, Kategorie, Nummernschema, Objekttyp, ZaehlerTyp } from "../../api/types";
import { FormField } from "../../components/ui/form-field";
const ZAEHLER_TYP_LABEL: Record<ZaehlerTyp, string> = {
keiner: "Kein Zähler",
kilometer: "Kilometerstand",
betriebsstunden: "Betriebsstunden",
beide: "Kilometerstand + Betriebsstunden",
};
interface Props {
bereiche: Bereich[];
kategorien: Kategorie[];
@@ -31,6 +38,8 @@ export function ObjekttypSection({
const [bearbeitungsZugfahrzeug, setBearbeitungsZugfahrzeug] = useState(false);
const [ressourcenbuchungAktiv, setRessourcenbuchungAktiv] = useState(false);
const [bearbeitungsRessourcenbuchung, setBearbeitungsRessourcenbuchung] = useState(false);
const [zaehlerTyp, setZaehlerTyp] = useState<ZaehlerTyp>("keiner");
const [bearbeitungsZaehlerTyp, setBearbeitungsZaehlerTyp] = useState<ZaehlerTyp>("keiner");
const [schemaBearbeitenId, setSchemaBearbeitenId] = useState<number | null>(null);
const [schemaPraefix, setSchemaPraefix] = useState("");
const [schemaStellenzahl, setSchemaStellenzahl] = useState(5);
@@ -45,6 +54,7 @@ export function ObjekttypSection({
setBearbeitungsName(o.name);
setBearbeitungsZugfahrzeug(o.ist_zugfahrzeug);
setBearbeitungsRessourcenbuchung(o.ressourcenbuchung_aktiv);
setBearbeitungsZaehlerTyp(o.zaehler_typ);
}
async function speichern(id: number) {
@@ -55,6 +65,7 @@ export function ObjekttypSection({
name: bearbeitungsName,
ist_zugfahrzeug: bearbeitungsZugfahrzeug,
ressourcenbuchung_aktiv: bearbeitungsRessourcenbuchung,
zaehler_typ: bearbeitungsZaehlerTyp,
},
});
setBearbeiteteId(null);
@@ -80,11 +91,13 @@ export function ObjekttypSection({
kategorie_id: kategorieId || null,
ist_zugfahrzeug: istZugfahrzeug,
ressourcenbuchung_aktiv: ressourcenbuchungAktiv,
zaehler_typ: zaehlerTyp,
},
});
setName("");
setIstZugfahrzeug(false);
setRessourcenbuchungAktiv(false);
setZaehlerTyp("keiner");
await onGeaendert();
} catch {
onFehler("Objekttyp konnte nicht angelegt werden.");
@@ -189,6 +202,22 @@ export function ObjekttypSection({
/>
Ressourcenbuchung in der Akte anzeigen
</label>
<FormField label="Zählerstand in der Akte">
{(feldProps) => (
<select
{...feldProps}
className="input"
value={zaehlerTyp}
onChange={(e) => setZaehlerTyp(e.target.value as ZaehlerTyp)}
>
{Object.entries(ZAEHLER_TYP_LABEL).map(([wert, label]) => (
<option key={wert} value={wert}>
{label}
</option>
))}
</select>
)}
</FormField>
<button
className="btn btn-primary"
style={{ marginTop: "1.5rem" }}
@@ -230,6 +259,17 @@ export function ObjekttypSection({
/>
Ressourcenbuchung
</label>
<select
className="input"
value={bearbeitungsZaehlerTyp}
onChange={(e) => setBearbeitungsZaehlerTyp(e.target.value as ZaehlerTyp)}
>
{Object.entries(ZAEHLER_TYP_LABEL).map(([wert, label]) => (
<option key={wert} value={wert}>
{label}
</option>
))}
</select>
<button className="btn btn-primary" onClick={() => speichern(o.id)}>
Speichern
</button>
@@ -243,6 +283,9 @@ export function ObjekttypSection({
{o.name}
{o.ist_zugfahrzeug && <span className="text-muted"> (Zugfahrzeug)</span>}
{o.ressourcenbuchung_aktiv && <span className="text-muted"> (Buchungen)</span>}
{o.zaehler_typ !== "keiner" && (
<span className="text-muted"> ({ZAEHLER_TYP_LABEL[o.zaehler_typ]})</span>
)}
</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(o)}>
Bearbeiten