Files
MABEA/backend/app/services/kontrolle.py
T
patrickandClaude Sonnet 5 1e9012cba3
CI / backend-tests (push) Successful in 47s
Sprint 5: Mindermengen-Genehmigung + automatischer Ablauf, Historie für Kernkette
- MindermengenGenehmigung-Modell/Service (Prompt 04): reine fachliche Bewertung,
  ändert NIEMALS Ist-Menge/Fehlmenge/Fehlbestand-Status (Leitplanke, U2)
- Automatischer Ablauf bei Start der nächsten Kontrolle desselben Objekts (Karte 08,
  U6) - kein Zeit-Trigger, der Kontrollstart selbst ist der Ablauf-Zeitpunkt
- Genehmigung endet gemeinsam mit dem Fehlbestand, wenn dieser durch Nachfüllung
  erledigt wird (U7), unabhängig davon ob vorher eine neue Kontrolle stattfand
- Historie-Service (app/services/historie.py) + Verdrahtung in die komplette
  Kernkette: kontrolle_gestartet/-abgeschlossen/-abgebrochen/-uebernommen,
  istmenge_erfasst, fehlbestand_entstanden/-erledigt, nachfuellung_erfasst,
  mindermenge_genehmigt/-abgelaufen/-beendet_durch_erledigung (U14)
  Hinweis: Stammdaten-/Vorlagen-/Benutzerverwaltung noch nicht retrofittet -
  Sprint 5 deckt bewusst die im Testkonzept referenzierte Kernkette ab, keine
  flächendeckende Audit-Abdeckung aller CRUD-Endpunkte.
- Tests: U2, U6, U7, vollständige Historie-Kette nach Prompt-13.3-Beispiel (U14)

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

331 lines
12 KiB
Python

import uuid
from datetime import 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.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
from app.models.objekt import Objekt
from app.models.objektposition import Objektposition, ObjektpositionStatus
from app.services import historie as historie_service
from app.services.objekte import sollmenge_effektiv, sollmenge_map
class ObjektGesperrtError(Exception):
"""Prompt 02.8: Objekt wird bereits von einer anderen, laufenden Kontrolle belegt."""
def __init__(self, aktive_kontrolle: Kontrolle):
self.aktive_kontrolle = aktive_kontrolle
super().__init__("Objekt wird bereits kontrolliert")
class UnvollstaendigError(Exception):
"""Prompt 16.1: Abschluss mit unbestätigten Positionen."""
def __init__(self, fehlende_material_ids: list[int]):
self.fehlende_material_ids = fehlende_material_ids
super().__init__("Nicht alle Positionen wurden erfasst")
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 erfasse_position(
db: AsyncSession,
*,
kontrolle: Kontrolle,
objekt: Objekt,
material_id: int,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> tuple[Kontrollposition, uuid.UUID | None]:
"""Prompt 02.3/09 Vier-Kernbegriffe: Kontrolle erfasst nur, ändert nie die
persistente Objektposition.istmenge. Bei Ist < Soll entsteht automatisch ein
Fehlbestand (nie bei Überbestand, Prompt 02.4). Gibt zusätzlich die
Fehlbestand-ID zurück (falls einer aktiv ist), damit die UI direkt eine
Sofort-Nachfüllung (Karte 07) anbieten kann, ohne separat nachzufragen."""
vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id)
result = await db.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material_id
)
)
objektposition = result.scalar_one_or_none()
sollmenge = sollmenge_effektiv(objektposition, vorlagen_sollmengen) if objektposition else Decimal(0)
abweichung = istmenge != sollmenge
result = await db.execute(
select(Kontrollposition).where(
Kontrollposition.kontrolle_id == kontrolle.id, Kontrollposition.material_id == material_id
)
)
kontrollposition = result.scalar_one_or_none()
if kontrollposition is None:
kontrollposition = Kontrollposition(
kontrolle_id=kontrolle.id,
material_id=material_id,
sollmenge_snapshot=sollmenge,
istmenge_erfasst=istmenge,
abweichung=abweichung,
)
db.add(kontrollposition)
else:
kontrollposition.sollmenge_snapshot = sollmenge
kontrollposition.istmenge_erfasst = istmenge
kontrollposition.abweichung = abweichung
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=kontrolle.benutzer_id,
ereignistyp="istmenge_erfasst",
entitaet_typ="kontrollposition",
entitaet_id=kontrollposition.id,
neuer_wert={"material_id": material_id, "sollmenge": str(sollmenge), "istmenge": str(istmenge)},
)
fehlbestand_id = await _synchronisiere_fehlbestand(
db,
kontrolle=kontrolle,
objekt=objekt,
material_id=material_id,
sollmenge=sollmenge,
istmenge=istmenge,
zustaendiger_server_id=zustaendiger_server_id,
)
return kontrollposition, fehlbestand_id
async def _synchronisiere_fehlbestand(
db: AsyncSession,
*,
kontrolle: Kontrolle,
objekt: Objekt,
material_id: int,
sollmenge: Decimal,
istmenge: Decimal,
zustaendiger_server_id: int,
) -> uuid.UUID | None:
result = await db.execute(
select(Fehlbestand).where(
Fehlbestand.kontrolle_id == kontrolle.id,
Fehlbestand.material_id == material_id,
Fehlbestand.status == FehlbestandStatus.offen,
)
)
bestehender = result.scalar_one_or_none()
unterdeckung = sollmenge - istmenge
if unterdeckung > 0:
# Ist < Soll (Prompt 02.3) - Überbestand (Ist > Soll) erzeugt laut Prompt 02.4
# ausdrücklich KEINEN Fehlbestand.
if bestehender is None:
bestehender = Fehlbestand(
erzeugt_von_server_id=zustaendiger_server_id,
objekt_id=objekt.id,
material_id=material_id,
standort_id=objekt.standort_id,
sollmenge=sollmenge,
istmenge=istmenge,
fehlmenge=unterdeckung,
entstanden_am=datetime.now(timezone.utc),
festgestellt_von=kontrolle.benutzer_id,
kontrolle_id=kontrolle.id,
status=FehlbestandStatus.offen,
)
db.add(bestehender)
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=kontrolle.benutzer_id,
ereignistyp="fehlbestand_entstanden",
entitaet_typ="fehlbestand",
entitaet_id=bestehender.id,
neuer_wert={
"objekt_id": objekt.id,
"material_id": material_id,
"sollmenge": str(sollmenge),
"istmenge": str(istmenge),
"fehlmenge": str(unterdeckung),
},
)
else:
# Korrektur vor Abschluss (z. B. Tippfehler nochmal erfasst) - solange die
# Kontrolle noch läuft, ist der von IHR erzeugte Fehlbestand noch nicht
# final und darf berichtigt werden.
bestehender.sollmenge = sollmenge
bestehender.istmenge = istmenge
bestehender.fehlmenge = unterdeckung
elif bestehender is not None:
# Abweichung wurde vor Abschluss korrigiert - der von dieser Kontrolle
# erzeugte Fehlbestand hat sich erledigt, bevor er je "real" wurde.
await db.delete(bestehender)
bestehender = None
await db.flush()
return bestehender.id if bestehender is not None else None
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},
)