CI / backend-tests (push) Successful in 54s
- benutzer.email ergänzt (additive Migration 0003) - Prompt 20 hatte kein E-Mail- Feld vorgesehen, aber Karte 05 braucht es für Benachrichtigungen - Dashboard-Kennzahlen (Prompt 12.2): offen/in_bearbeitung/nachgefuellt_teilweise zählen gemeinsam als "offen", genehmigte Mindermengen separat, kürzlich erledigt (7 Tage), problematische Objekte - Ablaufdaten-Warnungen (Prompt 14): serverseitige Statusberechnung gueltig/bald_ablaufend/abgelaufen je Objektposition, Standard-Warnzeitraum 30 Tage falls am Material nicht gesetzt, sortiert nach verbleibenden Tagen (E1: einzige Zeit-/Fälligkeits-Sicht in V1, keine Kontrollintervall-Logik) - GET /fehlbestaende um Filter (Standort/Objekt/Material/genehmigt) und Alter-Sortierung erweitert (Prompt 12.3) - Asynchrone E-Mail-Benachrichtigung bei neuem Fehlbestand (Karte 05): fire-and- forget an aktive Materialverantwortliche/Leitungsverantwortliche mit hinterlegter E-Mail; kein SMTP konfiguriert -> wird nur geloggt, kein harter Fehler - Tests: Aggregationsregel, Ablaufdaten-Filterung/Sortierung, Rollenrechte, E-Mail-Versand (SMTP gemockt, kein Docker/Test-Mailserver im Host-Runner verfügbar - Aufruf mit korrekten Empfängern/Betreff wird geprüft) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
333 lines
13 KiB
Python
333 lines
13 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 benachrichtigung as benachrichtigung_service
|
|
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),
|
|
},
|
|
)
|
|
await benachrichtigung_service.benachrichtige_neuer_fehlbestand(db, fehlbestand=bestehender)
|
|
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},
|
|
)
|