Files
MABEA/backend/app/services/geraet_instanz.py
T
patrickandClaude Sonnet 5 2dd7e574e6
CI / backend-tests (push) Failing after 2m16s
CI / frontend-build (push) Successful in 26s
feat(identity): IDENT-005 Hersteller/Modell-Stammdaten
Neue Tabellen hersteller/modell (Migration 0030), CRUD-Endpunkte
(/hersteller, /modelle) nach bestehendem Kategorie-Muster, inkl. 409 bei
Duplikaten (IntegrityError abgefangen wie in personal.py). geraet_instanz
bekommt optionales modell_id-Feld - verknüpft eine konkrete Geräteinstanz
(mit Seriennummer, IDENT-004) mit ihrem Hersteller/Modell.

Ergänzt Material.hersteller (bleibt unverändert als Freitext) um eine
normalisierte Variante gezielt für Geräte mit Seriennummer - keine
Datenmigration bestehender Freitext-Werte (Scope dieser Kachel).

Bewusst kein neues Admin-UI-Screen für die Pflege (Scope-Grenze der
Kachel) - Hersteller/Modell aktuell nur über die API verwaltbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
2026-09-08 14:51:15 +02:00

139 lines
5.0 KiB
Python

import uuid
from datetime import date, datetime, timezone
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from app.models.geraet_instanz import GeraetInstanz, GeraetStatus
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition
from app.services import benachrichtigung as benachrichtigung_service
from app.services import historie as historie_service
from app.services.datumshilfen import plus_monate as _plus_monate
async def liste_fuer_position(db: AsyncSession, objektposition_id) -> list[GeraetInstanz]:
result = await db.execute(
select(GeraetInstanz).where(GeraetInstanz.objektposition_id == objektposition_id)
)
return list(result.scalars().all())
class SeriennummerBereitsVergebenError(Exception):
pass
async def erstelle(
db: AsyncSession, *, objektposition_id, seriennummer: str, modell_id: int | None = None
) -> GeraetInstanz:
# Explizit vorab prüfen statt sich auf den UNIQUE-Constraint zu verlassen:
# ein Flush-Fehlschlag hier hinterlässt die Session im Zustand DEACTIVE
# (SQLAlchemy 2.0), was in einer über mehrere Requests geteilten Session
# (z.B. Testsuite) jeden folgenden Request mit PendingRollbackError zum
# Absturz bringt (Fund 2026-09-05).
result = await db.execute(
select(GeraetInstanz).where(
GeraetInstanz.objektposition_id == objektposition_id,
GeraetInstanz.seriennummer == seriennummer,
)
)
if result.scalar_one_or_none() is not None:
raise SeriennummerBereitsVergebenError
instanz = GeraetInstanz(objektposition_id=objektposition_id, seriennummer=seriennummer, modell_id=modell_id)
db.add(instanz)
await db.flush()
return instanz
async def loesche(db: AsyncSession, instanz: GeraetInstanz) -> None:
await db.delete(instanz)
async def aendere_status(
db: AsyncSession,
*,
instanz: GeraetInstanz,
status: GeraetStatus,
pruefdatum: date | None,
bemerkung: str | None,
erzeuge_fehlbestand: bool,
benutzer_id: int,
zustaendiger_server_id: int,
) -> tuple[GeraetInstanz, uuid.UUID | None]:
"""Karte 14: Status ändern (einsatzbereit/defekt/in_reparatur), optional
Prüfung erfassen (naechste_pruefung wird neu berechnet, sofern die
Objektposition ein pruefintervall_monate hat), optional einen Fehlbestand
erzeugen (pro Gerät entscheidbar, nutzt bestehende Fehlbestand-
Infrastruktur statt eigenem Parallel-Konzept)."""
alter_status = instanz.status
instanz.status = status
instanz.bemerkung = bemerkung
if pruefdatum is not None:
instanz.pruefdatum = pruefdatum
objektposition = await db.get(Objektposition, instanz.objektposition_id)
if objektposition is not None and objektposition.pruefintervall_monate:
instanz.naechste_pruefung = _plus_monate(pruefdatum, objektposition.pruefintervall_monate)
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=benutzer_id,
ereignistyp="geraet_status_geaendert",
entitaet_typ="geraet_instanz",
entitaet_id=instanz.id,
alter_wert={"status": alter_status.value},
neuer_wert={"status": status.value, "seriennummer": instanz.seriennummer},
begruendung=bemerkung,
)
fehlbestand_id = None
if erzeuge_fehlbestand and status != GeraetStatus.einsatzbereit:
fehlbestand_id = await _erzeuge_fehlbestand(
db,
instanz=instanz,
benutzer_id=benutzer_id,
zustaendiger_server_id=zustaendiger_server_id,
)
return instanz, fehlbestand_id
async def _erzeuge_fehlbestand(
db: AsyncSession, *, instanz: GeraetInstanz, benutzer_id: int, zustaendiger_server_id: int
):
objektposition = await db.get(Objektposition, instanz.objektposition_id)
objekt = await db.get(Objekt, objektposition.objekt_id)
fehlbestand = Fehlbestand(
erzeugt_von_server_id=zustaendiger_server_id,
objekt_id=objektposition.objekt_id,
material_id=objektposition.material_id,
standort_id=objekt.standort_id,
sollmenge=Decimal(1),
istmenge=Decimal(0),
fehlmenge=Decimal(1),
entstanden_am=datetime.now(timezone.utc),
festgestellt_von=benutzer_id,
ursache=f"Gerät {instanz.seriennummer} ({instanz.status.value})",
status=FehlbestandStatus.offen,
)
db.add(fehlbestand)
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=benutzer_id,
ereignistyp="fehlbestand_entstanden",
entitaet_typ="fehlbestand",
entitaet_id=fehlbestand.id,
neuer_wert={"ursache": fehlbestand.ursache, "geraet_instanz_id": str(instanz.id)},
)
await benachrichtigung_service.benachrichtige_neuer_fehlbestand(db, fehlbestand=fehlbestand)
return fehlbestand.id