Files
MABEA/backend/app/services/kontrolle/lifecycle.py
T
patrickandClaude Sonnet 5 f6084e2d5a
CI / backend-tests (push) Successful in 53s
CI / frontend-build (push) Successful in 34s
Refactor: große Dateien in kleinere Module/Komponenten aufgeteilt
Explizite Vorgabe: keine wachsenden Monolith-Dateien. AdminPage.tsx (349
Zeilen) in pages/admin/{Standort,Material,Objekt}Section.tsx aufgeteilt,
KontrollPage.tsx (277 Zeilen) in pages/kontrolle/{useKontrolle,
PositionCard,StatusBadge,types} aufgeteilt, services/kontrolle.py (332
Zeilen) in ein Package (lifecycle/erfassung/exceptions) aufgeteilt -
öffentliche API bleibt über __init__.py unverändert re-exportiert, kein
bestehender Import musste angepasst werden. Reines Refactoring, keine
Verhaltensänderung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
2026-09-04 00:45:18 +02:00

175 lines
6.8 KiB
Python

from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fehlbestand import Fehlbestand
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
from app.models.objektposition import Objektposition, ObjektpositionStatus
from app.services import historie as historie_service
from .exceptions import ObjektGesperrtError, UnvollstaendigError
async def aktive_kontrolle(db: AsyncSession, objekt_id: int) -> Kontrolle | None:
result = await db.execute(
select(Kontrolle).where(
Kontrolle.objekt_id == objekt_id, Kontrolle.status == KontrollStatus.in_bearbeitung
)
)
return result.scalar_one_or_none()
async def starte_kontrolle(
db: AsyncSession,
*,
objekt_id: int,
benutzer_id: int,
uebernehmen: bool,
zustaendiger_server_id: int,
) -> Kontrolle:
laufende = await aktive_kontrolle(db, objekt_id)
if laufende is not None:
if not uebernehmen:
raise ObjektGesperrtError(laufende)
# Übernahme (Prompt 02.8): alte Kontrolle wird abgebrochen, nicht gelöscht -
# bleibt als Datensatz nachvollziehbar (Prompt 16.6-Analogie).
laufende.status = KontrollStatus.abgebrochen
laufende.abbruch_grund = f"Übernommen durch Benutzer {benutzer_id}"
laufende.beendet_am = datetime.now(timezone.utc)
# Vom vorherigen Inhaber während der abgebrochenen Kontrolle erzeugte
# Fehlbestände dürfen nicht stehen bleiben (Prompt 16.6: kein Fehlbestand
# durch eine abgebrochene Kontrolle).
await _verwirf_kontrolldaten(db, laufende.id)
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=benutzer_id,
ereignistyp="kontrolle_uebernommen",
entitaet_typ="kontrolle",
entitaet_id=laufende.id,
alter_wert={"benutzer_id": laufende.benutzer_id},
neuer_wert={"benutzer_id": benutzer_id},
)
neue_kontrolle = Kontrolle(
erzeugt_von_server_id=zustaendiger_server_id,
objekt_id=objekt_id,
benutzer_id=benutzer_id,
status=KontrollStatus.in_bearbeitung,
gestartet_am=datetime.now(timezone.utc),
)
db.add(neue_kontrolle)
await db.flush()
await _lasse_mindermengen_ablaufen(
db, objekt_id=objekt_id, neue_kontrolle=neue_kontrolle, zustaendiger_server_id=zustaendiger_server_id
)
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=benutzer_id,
ereignistyp="kontrolle_gestartet",
entitaet_typ="kontrolle",
entitaet_id=neue_kontrolle.id,
neuer_wert={"objekt_id": objekt_id, "status": "in_bearbeitung"},
)
return neue_kontrolle
async def _lasse_mindermengen_ablaufen(
db: AsyncSession, *, objekt_id: int, neue_kontrolle: Kontrolle, zustaendiger_server_id: int
) -> None:
"""Karte 08 / Prompt 04.4 / Sprintplan U6: Genehmigung gilt automatisch nur bis
zur nächsten Kontrolle desselben Objekts - kein Zeit-Trigger nötig, der Start
der nächsten Kontrolle IST der Ablauf-Zeitpunkt."""
result = await db.execute(
select(MindermengenGenehmigung)
.join(Fehlbestand, MindermengenGenehmigung.fehlbestand_id == Fehlbestand.id)
.where(Fehlbestand.objekt_id == objekt_id, MindermengenGenehmigung.status == MindermengeStatus.aktiv)
)
for genehmigung in result.scalars().all():
genehmigung.status = MindermengeStatus.abgelaufen
genehmigung.beendet_am = datetime.now(timezone.utc)
genehmigung.beendende_kontrolle_id = neue_kontrolle.id
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=None,
ereignistyp="mindermenge_abgelaufen",
entitaet_typ="mindermengen_genehmigung",
entitaet_id=genehmigung.id,
alter_wert={"status": "aktiv"},
neuer_wert={"status": "abgelaufen", "beendende_kontrolle_id": str(neue_kontrolle.id)},
)
await db.flush()
async def _verwirf_kontrolldaten(db: AsyncSession, kontrolle_id) -> None:
fehlbestaende = await db.execute(select(Fehlbestand).where(Fehlbestand.kontrolle_id == kontrolle_id))
for fehlbestand in fehlbestaende.scalars().all():
await db.delete(fehlbestand)
positionen = await db.execute(select(Kontrollposition).where(Kontrollposition.kontrolle_id == kontrolle_id))
for position in positionen.scalars().all():
await db.delete(position)
await db.flush()
async def fehlende_material_ids(db: AsyncSession, kontrolle: Kontrolle) -> list[int]:
result = await db.execute(
select(Objektposition.material_id).where(
Objektposition.objekt_id == kontrolle.objekt_id,
Objektposition.ist_status == ObjektpositionStatus.aktiv,
)
)
erwartete = {m for (m,) in result.all()}
result = await db.execute(
select(Kontrollposition.material_id).where(Kontrollposition.kontrolle_id == kontrolle.id)
)
erfasste = {m for (m,) in result.all()}
return sorted(erwartete - erfasste)
async def abschliesse_kontrolle(db: AsyncSession, *, kontrolle: Kontrolle, zustaendiger_server_id: int) -> None:
fehlende = await fehlende_material_ids(db, kontrolle)
if fehlende:
raise UnvollstaendigError(fehlende)
kontrolle.status = KontrollStatus.abgeschlossen
kontrolle.beendet_am = datetime.now(timezone.utc)
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=kontrolle.benutzer_id,
ereignistyp="kontrolle_abgeschlossen",
entitaet_typ="kontrolle",
entitaet_id=kontrolle.id,
neuer_wert={"status": "abgeschlossen"},
)
async def abbreche_kontrolle(
db: AsyncSession, *, kontrolle: Kontrolle, grund: str | None, zustaendiger_server_id: int
) -> None:
"""Prompt 16.6: keine Ist-Übernahme (Kontrolle rührt Objektposition ohnehin nie
an), kein Fehlbestand aus dieser Kontrolle bleibt bestehen. Kontrolle selbst
bleibt als Datensatz erhalten (Status "abgebrochen"), nicht gelöscht."""
await _verwirf_kontrolldaten(db, kontrolle.id)
kontrolle.status = KontrollStatus.abgebrochen
kontrolle.abbruch_grund = grund
kontrolle.beendet_am = datetime.now(timezone.utc)
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=kontrolle.benutzer_id,
ereignistyp="kontrolle_abgebrochen",
entitaet_typ="kontrolle",
entitaet_id=kontrolle.id,
neuer_wert={"status": "abgebrochen", "grund": grund},
)