From d61aabadf33440c87859b01caa2715e744f6235b Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 4 Sep 2026 20:45:04 +0200 Subject: [PATCH] Karte 14 Schritt 1: geraet_instanz-Tabelle (additiv) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- arbeitskarten/00_index.md | 1 + arbeitskarten/14_pruef_wartungspflicht.md | 98 ++++++++++++++++++ backend/DEVLOG.md | 42 ++++++++ .../alembic/versions/0008_geraet_instanz.py | 65 ++++++++++++ backend/app/models/__init__.py | 3 + backend/app/models/geraet_instanz.py | 40 ++++++++ backend/app/models/objektposition.py | 6 +- backend/tests/test_geraet_instanz.py | 99 +++++++++++++++++++ 8 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 arbeitskarten/14_pruef_wartungspflicht.md create mode 100644 backend/alembic/versions/0008_geraet_instanz.py create mode 100644 backend/app/models/geraet_instanz.py create mode 100644 backend/tests/test_geraet_instanz.py diff --git a/arbeitskarten/00_index.md b/arbeitskarten/00_index.md index 9c4c3a5..122d637 100644 --- a/arbeitskarten/00_index.md +++ b/arbeitskarten/00_index.md @@ -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. diff --git a/arbeitskarten/14_pruef_wartungspflicht.md b/arbeitskarten/14_pruef_wartungspflicht.md new file mode 100644 index 0000000..c0b56a8 --- /dev/null +++ b/arbeitskarten/14_pruef_wartungspflicht.md @@ -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`). diff --git a/backend/DEVLOG.md b/backend/DEVLOG.md index b6cd7a5..58c11cb 100644 --- a/backend/DEVLOG.md +++ b/backend/DEVLOG.md @@ -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 +++++++++++++++++++++ + +--- diff --git a/backend/alembic/versions/0008_geraet_instanz.py b/backend/alembic/versions/0008_geraet_instanz.py new file mode 100644 index 0000000..505b3ca --- /dev/null +++ b/backend/alembic/versions/0008_geraet_instanz.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 04a5694..46ffe4e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/geraet_instanz.py b/backend/app/models/geraet_instanz.py new file mode 100644 index 0000000..205bc89 --- /dev/null +++ b/backend/app/models/geraet_instanz.py @@ -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) diff --git a/backend/app/models/objektposition.py b/backend/app/models/objektposition.py index 7658ebb..dbb6066 100644 --- a/backend/app/models/objektposition.py +++ b/backend/app/models/objektposition.py @@ -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) diff --git a/backend/tests/test_geraet_instanz.py b/backend/tests/test_geraet_instanz.py new file mode 100644 index 0000000..7387e10 --- /dev/null +++ b/backend/tests/test_geraet_instanz.py @@ -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