Karte 14 Schritt 1: geraet_instanz-Tabelle (additiv)
CI / backend-tests (push) Successful in 1m13s
CI / frontend-build (push) Successful in 23s

objektposition.seriennummer war ein Einzelfeld, konnte nur EIN Gerät pro
Materialtyp/Objekt abbilden (z.B. 2 Pulsoxymeter im selben Rucksack nicht
darstellbar). Neue Tabelle geraet_instanz erlaubt beliebig viele Exemplare
pro Position (Seriennummer, Prüfdatum, nächste Prüfung, Status). Zusätzlich
objektposition.pruefintervall_monate (individuell überschreibbar).

Bewusst additiv: seriennummer-Spalte bleibt vorerst stehen (Service/
Endpunkte/Frontend hängen noch daran), bestehende Werte werden per
INSERT...SELECT nach geraet_instanz kopiert. Drop + Umbau der abhängigen
Schichten folgt in Umsetzungsschritt 2 (siehe arbeitskarten/14_...).

Migration von postgres-expert gegengeprüft (ON CONFLICT DO NOTHING als
Sicherheitsnetz, DROP TYPE IF EXISTS ergänzt), Tests decken Mehrfach-
Instanzen, UNIQUE-Constraint je Position, gleiche SN an verschiedenen
Positionen und pruefintervall_monate ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-04 20:45:04 +02:00
co-authored by Claude Sonnet 5
parent 4c87bf2d84
commit d61aabadf3
8 changed files with 353 additions and 1 deletions
+1
View File
@@ -17,6 +17,7 @@ Status: entstanden aus Klärungsrunde vor Prompt 01. Jede Karte = ein entschiede
| 11 | [Systemumfang: generisches Ressourcenmanagement](11_systemumfang_ressourcenmanagement.md) | entschieden, grundlegend | Prompt 06, 07 |
| 12 | [Eskalation offener Fehlbestände](12_eskalation.md) | Roadmap, Datenmodell-Vorbereitung nötig | Prompt 03, 24 |
| 13 | [Hauptserver mit Satelliten-Servern](13_satelliten_server.md) | entschieden, Betrieb Roadmap, Datenmodell-Vorbereitung nötig | Prompt 17, 19, 20 |
| 14 | [Prüf- und wartungspflichtige Geräte](14_pruef_wartungspflicht.md) | Datenmodell entschieden, Umsetzung offen (geraet_instanz-Tabelle nötig) | Prompt 06, 10, 24 |
Nächster Schritt: Prompt `01_anforderungsanalyse` unter Berücksichtigung aller entschiedenen Karten bearbeiten.
+98
View File
@@ -0,0 +1,98 @@
# Karte 14 Prüf- und wartungspflichtige Geräte
**Status:** Konzeptphase, Datenmodell entschieden (2026-09-04), Umsetzung offen
## Entscheidung
Das Portal deckt nicht nur Bestandskontrolle (Soll/Ist-Mengen, Verbrauchsmaterial-
Nachschub) ab, sondern auch gesetzlich/betrieblich prüf- und wartungspflichtige
Geräte (z. B. Feuerlöscher, Defibrillatoren, Leitern, Atemschutzgeräte, Pulsoxymeter)
mit wiederkehrenden Prüfintervallen und Fristen. Das ist fachlich etwas anderes als
Ablaufdatum (einmaliges festes Datum) oder Charge (Identifikation) hier geht es
um einen wiederkehrenden Prüfzyklus mit Ergebnis pro Durchgang, UND um mehrere
Geräte-Exemplare desselben Materials im selben Objekt.
## Kernproblem im aktuellen Datenmodell
`objektposition.seriennummer` ist ein Einzelfeld kann nur EIN Gerät pro
Materialtyp pro Objekt erfassen. Bei z. B. 2 Pulsoxymetern im selben Rucksack
gibt es keine Möglichkeit, beide Seriennummern zu speichern. Das ist ein echtes
Datenmodell-Defizit, kein UI-Problem erfordert eine neue Entität.
## Datenmodell (entschieden)
Neue Tabelle `geraet_instanz` ersetzt `objektposition.seriennummer` für
`materialtyp='geraet_sn'`:
```sql
CREATE TABLE geraet_instanz (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
objektposition_id UUID NOT NULL REFERENCES objektposition(id),
seriennummer TEXT NOT NULL,
pruefdatum DATE, -- letzte Prüfung
naechste_pruefung DATE, -- fällige Prüfung
status TEXT NOT NULL DEFAULT 'einsatzbereit', -- einsatzbereit / defekt / in_reparatur
bemerkung TEXT,
UNIQUE (objektposition_id, seriennummer)
);
```
- `objektposition.seriennummer` entfällt (Migration: bestehende Werte nach
`geraet_instanz` überführen, dann Spalte droppen).
- Ist-Menge bei `geraet_sn` wird nicht mehr als Zahl geführt/eingegeben, sondern
als Anzahl der `geraet_instanz`-Zeilen mit `status='einsatzbereit'` berechnet.
- `objektposition`: neues Feld `pruefintervall_monate` (int, nullable)
**individuell überschreibbar** je Objektposition (wie `sollmenge_override`),
Vorlage/Material kann einen Standard vorgeben, einzelne Objektposition kann
abweichen (z. B. älteres Gerät mit kürzerer Frist).
- `naechste_pruefung` je `geraet_instanz` wird nach jeder erfassten Prüfung neu
berechnet (`pruefdatum + pruefintervall_monate`).
## "Nicht bestanden" / defekt → Fehlbestand (entschieden)
**Pro Gerät entscheidbar, nicht global fix.** Beim Setzen von
`status='defekt'` oder `status='in_reparatur'` an einer `geraet_instanz` wird
im selben Dialog gefragt/entschieden, ob daraus ein Fehlbestand entsteht (nutzt
die bestehende Fehlbestand-/Eskalations-Infrastruktur weiter, kein Parallel-
Konzept). Die ganze bestehende Fehlbestand-Logik (Kette Fehlbestand →
Nachfüllung/Mindermenge → Historie) wird dabei mitgenutzt, nicht neu gebaut
ein "defektes Gerät" ist fachlich einfach ein Fehlbestand mit Fehlmenge=1 an
dieser Position.
## Erfassungs-UI für `geraet_sn` (komplett neu, kein Mengenfeld mehr)
Statt Mengenfeld + "Passt"-Button: Instanz-Liste pro Position.
```
┌─────────────────────────────────────────┐
│ Pulsoxymeter Modell X (Soll: 2) │
├─────────────────────────────────────────┤
│ ✓ SN-001 [Prüfdatum: 2025-03-15] [OK] │
│ ✓ SN-002 [Prüfdatum: 2025-06-22] [OK] │
│ + Gerät hinzufügen (SN scannen/eingeben) │
└─────────────────────────────────────────┘
```
Buttons pro Instanz:
- **OK** Gerät ist da, einsatzbereit.
- **Fehlt/Defekt** Dialog: Grund (fehlt/defekt/in Reparatur), Bemerkung →
Instanz-Status ändern, bei "fehlt"/"defekt" optional Fehlbestand erzeugen
(siehe oben, pro Gerät entscheidbar).
`materialtyp='ablauf_charge'` und `'standard'` bleiben unverändert (Mengenfeld
+ Passt-Button wie bisher) nur `geraet_sn` bekommt diese neue Erfassungsart.
## Eskalation bei überfälliger Prüfung
Bestehende Zwei-Stufen-Mechanik (`app/services/eskalation.py`) um eine zweite
Datenquelle erweitern: überfällige `geraet_instanz.naechste_pruefung` statt
`fehlbestand.entstanden_am`. Eigene Konfigurationszeile/-spalten, damit
Prüf-Fristen unabhängig von Fehlbestand-Fristen einstellbar sind (TÜV-Fristen
unterscheiden sich je nach Gerätetyp).
## Umsetzungsschritte (grobe Reihenfolge)
1. Migration: `geraet_instanz`-Tabelle, `objektposition.pruefintervall_monate`,
Daten-Migration bestehender `seriennummer`-Werte, danach Spalte droppen.
2. Backend: `geraet_instanz`-Model + Service (CRUD, Status ändern inkl.
optionaler Fehlbestand-Erzeugung, Ist-Menge-Berechnung für `geraet_sn`).
3. Backend: Eskalation um Prüf-Fälligkeit erweitern.
4. Frontend: neue Instanz-Listen-UI für `geraet_sn` in der Kontroll-Erfassung
(`PositionCard.tsx`/`KontrollPage.tsx`), Scan-Integration (Karte 10) für
SN-Eingabe wiederverwenden.
5. Frontend: Admin-Pflege der Geräte-Instanzen (analog `ObjektPositionenPanel`).
+42
View File
@@ -147,3 +147,45 @@ Keine Commits in dieser Session.
- frontend/src/pages/kontrolle/useKontrolle.ts | 13 +++--
---
## 2026-09-04 20:25 20:42 (16m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 4c87bf2 Zuständigkeit im Frontend anbinden (Karte 04)
### Geänderte Dateien
- DEVLOG.md | 85 +++++++++
- frontend/src/api/types.ts | 7 +
- frontend/src/pages/AdminPage.tsx | 8 +-
- frontend/src/pages/admin/ZustaendigkeitSection.tsx | 194 +++++++++++++++++++++
---
## 2026-09-04 20:42 20:42 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 85 +++++++++
- frontend/src/api/types.ts | 7 +
- frontend/src/pages/AdminPage.tsx | 8 +-
- frontend/src/pages/admin/ZustaendigkeitSection.tsx | 194 +++++++++++++++++++++
---
## 2026-09-04 20:42 20:43 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** backend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 85 +++++++++
- frontend/src/api/types.ts | 7 +
- frontend/src/pages/AdminPage.tsx | 8 +-
- frontend/src/pages/admin/ZustaendigkeitSection.tsx | 194 +++++++++++++++++++++
---
@@ -0,0 +1,65 @@
"""Karte 14: geraet_instanz-Tabelle + pruefintervall_monate (additiv)
Revision ID: 0008_geraet_instanz
Revises: 0007_indizes_und_locks
Create Date: 2026-09-04
Umsetzungsschritt 1 von Karte 14 (Prüf-/wartungspflichtige Geräte). Löst
objektposition.seriennummer als Einzelfeld ab - das konnte nur EIN Gerät pro
Materialtyp/Objekt abbilden. Bewusst ADDITIV: bestehende seriennummer-Werte
werden nach geraet_instanz kopiert, die Spalte selbst bleibt vorerst stehen
(Service/Endpunkte/Frontend hängen noch daran) - Drop folgt erst mit dem
koordinierten Umbau dieser Schichten (Umsetzungsschritt 2, siehe Karte 14).
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0008_geraet_instanz"
down_revision: Union[str, None] = "0007_indizes_und_locks"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE TYPE geraet_status AS ENUM ('einsatzbereit', 'defekt', 'in_reparatur')")
op.execute(
"""
CREATE TABLE geraet_instanz (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
objektposition_id UUID NOT NULL REFERENCES objektposition(id),
seriennummer TEXT NOT NULL,
pruefdatum DATE,
naechste_pruefung DATE,
status geraet_status NOT NULL DEFAULT 'einsatzbereit',
bemerkung TEXT,
UNIQUE (objektposition_id, seriennummer)
)
"""
)
op.execute("CREATE INDEX idx_geraet_instanz_objektposition ON geraet_instanz (objektposition_id)")
op.execute("ALTER TABLE objektposition ADD COLUMN pruefintervall_monate INTEGER")
# Bestehende Seriennummern übernehmen, damit kein Datenverlust entsteht,
# während seriennummer noch parallel existiert. objektposition_id ist pro
# Quellzeile eindeutig (1:1-Kopie), UNIQUE(objektposition_id, seriennummer)
# kann hier nie kollidieren - ON CONFLICT trotzdem als Sicherheitsnetz, falls
# die Migration je manuell erneut auf einer bereits befüllten DB läuft
# (postgres-expert-Review-Fund).
op.execute(
"""
INSERT INTO geraet_instanz (objektposition_id, seriennummer)
SELECT id, seriennummer FROM objektposition
WHERE seriennummer IS NOT NULL AND seriennummer <> ''
ON CONFLICT (objektposition_id, seriennummer) DO NOTHING
"""
)
def downgrade() -> None:
# Achtung: droppt auch alle Instanzen/Prüfungen, die NACH diesem Upgrade
# über Umsetzungsschritt 2 (Service/Endpunkte) angelegt wurden - sobald
# Schritt 2 live ist, ist dieser Downgrade nicht mehr gefahrlos.
op.execute("ALTER TABLE objektposition DROP COLUMN pruefintervall_monate")
op.execute("DROP TABLE geraet_instanz")
op.execute("DROP TYPE IF EXISTS geraet_status")
+3
View File
@@ -1,6 +1,7 @@
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp
from app.models.eskalation import EskalationKonfiguration
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from app.models.geraet_instanz import GeraetInstanz, GeraetStatus
from app.models.historie import Historie
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
@@ -20,6 +21,8 @@ __all__ = [
"EskalationKonfiguration",
"Fehlbestand",
"FehlbestandStatus",
"GeraetInstanz",
"GeraetStatus",
"Historie",
"Kontrolle",
"KontrollStatus",
+40
View File
@@ -0,0 +1,40 @@
import enum
import uuid
from datetime import date
from sqlalchemy import Date, ForeignKey, String
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class GeraetStatus(str, enum.Enum):
einsatzbereit = "einsatzbereit"
defekt = "defekt"
in_reparatur = "in_reparatur"
geraet_status_pg = PgEnum(GeraetStatus, name="geraet_status", create_type=False)
class GeraetInstanz(Base):
"""Karte 14: löst objektposition.seriennummer als Einzelfeld ab - das konnte
nur EIN Gerät pro Materialtyp/Objekt abbilden (z.B. 2 Pulsoxymeter im selben
Rucksack nicht darstellbar). Ist-Menge bei materialtyp='geraet_sn' wird aus
der Anzahl der Zeilen mit status='einsatzbereit' berechnet, nicht mehr als
Zahl eingegeben (Umsetzungsschritt 2, siehe Karte 14)."""
__tablename__ = "geraet_instanz"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
objektposition_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("objektposition.id"), nullable=False
)
seriennummer: Mapped[str] = mapped_column(String, nullable=False)
pruefdatum: Mapped[date | None] = mapped_column(Date)
naechste_pruefung: Mapped[date | None] = mapped_column(Date)
status: Mapped[GeraetStatus] = mapped_column(
geraet_status_pg, nullable=False, default=GeraetStatus.einsatzbereit
)
bemerkung: Mapped[str | None] = mapped_column(String)
+5 -1
View File
@@ -3,7 +3,7 @@ import uuid
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Numeric, String, UniqueConstraint
from sqlalchemy import Date, ForeignKey, Integer, Numeric, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -43,3 +43,7 @@ class Objektposition(Base):
# separat von seriennummer: Code identifiziert das gedruckte Label, SN das
# Herstellerattribut - beide können divergieren (Etikett verloren/neu vergeben).
code: Mapped[str | None] = mapped_column(String, unique=True)
# Karte 14: Prüf-/Wartungspflicht, individuell überschreibbar je Objektposition
# (wie sollmenge_override) - NULL = kein Prüfintervall (nicht prüfpflichtig,
# oder Standard aus dem Material/der Vorlage folgt später bei Bedarf).
pruefintervall_monate: Mapped[int | None] = mapped_column(Integer)
+99
View File
@@ -0,0 +1,99 @@
import pytest
from sqlalchemy import select
from app.models.geraet_instanz import GeraetInstanz, GeraetStatus
from app.models.objektposition import Objektposition
@pytest.mark.asyncio
async def test_mehrere_geraete_instanzen_pro_position(db_session, objekt_mit_position):
"""Karte 14 Kernproblem: objektposition.seriennummer als Einzelfeld konnte nur
EIN Gerät pro Materialtyp/Objekt abbilden - geraet_instanz erlaubt beliebig
viele Exemplare (z.B. 2 Pulsoxymeter im selben Rucksack)."""
objekt, material = objekt_mit_position
result = await db_session.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material.id
)
)
position = result.scalar_one()
db_session.add(GeraetInstanz(objektposition_id=position.id, seriennummer="SN-001"))
db_session.add(GeraetInstanz(objektposition_id=position.id, seriennummer="SN-002"))
await db_session.flush()
instanzen = (
await db_session.execute(
select(GeraetInstanz).where(GeraetInstanz.objektposition_id == position.id)
)
).scalars().all()
assert {i.seriennummer for i in instanzen} == {"SN-001", "SN-002"}
assert all(i.status == GeraetStatus.einsatzbereit for i in instanzen)
@pytest.mark.asyncio
async def test_seriennummer_je_position_eindeutig(db_session, objekt_mit_position):
from sqlalchemy.exc import IntegrityError
objekt, material = objekt_mit_position
result = await db_session.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material.id
)
)
position = result.scalar_one()
db_session.add(GeraetInstanz(objektposition_id=position.id, seriennummer="SN-001"))
await db_session.flush()
db_session.add(GeraetInstanz(objektposition_id=position.id, seriennummer="SN-001"))
with pytest.raises(IntegrityError):
await db_session.flush()
@pytest.mark.asyncio
async def test_gleiche_seriennummer_an_verschiedenen_positionen_erlaubt(
db_session, objekt_mit_position, material_factory
):
"""UNIQUE(objektposition_id, seriennummer) greift nur je Position, nicht
global - zwei verschiedene Objektpositionen dürfen dieselbe SN führen
(z.B. baugleiche Geräte an unterschiedlichen Objekten)."""
objekt, material = objekt_mit_position
result = await db_session.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material.id
)
)
position_a = result.scalar_one()
material_b = await material_factory("Pulsoxymeter")
position_b = Objektposition(objekt_id=objekt.id, material_id=material_b.id, istmenge=0)
db_session.add(position_b)
await db_session.flush()
db_session.add(GeraetInstanz(objektposition_id=position_a.id, seriennummer="SN-GLEICH"))
db_session.add(GeraetInstanz(objektposition_id=position_b.id, seriennummer="SN-GLEICH"))
await db_session.flush() # darf nicht scheitern
anzahl = (
await db_session.execute(select(GeraetInstanz).where(GeraetInstanz.seriennummer == "SN-GLEICH"))
).scalars().all()
assert len(anzahl) == 2
@pytest.mark.asyncio
async def test_pruefintervall_monate_default_und_setzen(db_session, objekt_mit_position):
"""Karte 14: pruefintervall_monate individuell überschreibbar je
Objektposition (wie sollmenge_override), NULL = kein Prüfintervall."""
objekt, material = objekt_mit_position
result = await db_session.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material.id
)
)
position = result.scalar_one()
assert position.pruefintervall_monate is None
position.pruefintervall_monate = 24
await db_session.flush()
await db_session.refresh(position)
assert position.pruefintervall_monate == 24