Files
MABEA/backend/app/services/kontrolle/lifecycle.py
T
patrickandClaude Sonnet 5 032126c605
CI / backend-tests (push) Successful in 57s
CI / frontend-build (push) Successful in 5m12s
fix(kontrolle): Abbruch löscht bereits nachgefüllten Fehlbestand nicht mehr
Beim Testen des neuen Inline-Nachfüll-Dialogs (Karte 07) gefunden: Sofort-
Nachfüllung während laufender Kontrolle + anschließender Abbruch führte zu
IntegrityError (FK-Verweis aus nachfuellung), da _verwirf_kontrolldaten
jeden Fehlbestand der Kontrolle unbedingt löschte.

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

181 lines
7.2 KiB
Python

from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
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():
if fehlbestand.status != FehlbestandStatus.offen:
# Bereits (teilweise) nachgefüllt (Karte 07 Sofort-Nachfüllung während
# der Kontrolle) - das ist eine echte, dokumentierte Bestandsbewegung
# und darf beim Verwerfen/Abbruch der Kontrolle NICHT gelöscht werden
# (FK-Verweis aus nachfuellung + Historie müssen erhalten bleiben).
continue
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},
)