Sprint 5: Mindermengen-Genehmigung + automatischer Ablauf, Historie für Kernkette
CI / backend-tests (push) Successful in 47s
CI / backend-tests (push) Successful in 47s
- 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
This commit is contained in:
@@ -5,8 +5,10 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
|
||||
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
|
||||
from app.models.nachfuellung import Nachfuellung
|
||||
from app.models.objektposition import Objektposition
|
||||
from app.services import historie as historie_service
|
||||
|
||||
|
||||
class FehlbestandBereitsErledigtError(Exception):
|
||||
@@ -14,7 +16,12 @@ class FehlbestandBereitsErledigtError(Exception):
|
||||
|
||||
|
||||
async def nachfuellen(
|
||||
db: AsyncSession, *, fehlbestand: Fehlbestand, menge: Decimal, benutzer_id: int
|
||||
db: AsyncSession,
|
||||
*,
|
||||
fehlbestand: Fehlbestand,
|
||||
menge: Decimal,
|
||||
benutzer_id: int,
|
||||
zustaendiger_server_id: int,
|
||||
) -> tuple[Nachfuellung, Decimal]:
|
||||
"""Vier-Kernbegriffe (Prompt 02.9): Nachfüllung ist die einzige Aktion, die
|
||||
Objektposition.istmenge tatsächlich ändert. Deckt sowohl Sofort-Nachfüllung
|
||||
@@ -50,7 +57,8 @@ async def nachfuellen(
|
||||
|
||||
fehlbestand.istmenge = neue_istmenge
|
||||
fehlbestand.fehlmenge = neue_fehlmenge
|
||||
if neue_fehlmenge <= 0:
|
||||
wird_erledigt = neue_fehlmenge <= 0
|
||||
if wird_erledigt:
|
||||
fehlbestand.status = FehlbestandStatus.erledigt
|
||||
fehlbestand.erledigt_am = datetime.now(timezone.utc)
|
||||
else:
|
||||
@@ -66,4 +74,47 @@ async def nachfuellen(
|
||||
)
|
||||
db.add(nachfuellung)
|
||||
await db.flush()
|
||||
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="nachfuellung_erfasst",
|
||||
entitaet_typ="fehlbestand",
|
||||
entitaet_id=fehlbestand.id,
|
||||
neuer_wert={"menge": str(menge), "neue_istmenge": str(neue_istmenge), "ueberbestand": str(ueberbestand)},
|
||||
)
|
||||
|
||||
if wird_erledigt:
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="fehlbestand_erledigt",
|
||||
entitaet_typ="fehlbestand",
|
||||
entitaet_id=fehlbestand.id,
|
||||
neuer_wert={"status": "erledigt"},
|
||||
)
|
||||
# U7 (Prompt 04.4): wird die Abweichung behoben, erledigt sich eine noch
|
||||
# aktive Mindermengen-Genehmigung gemeinsam mit dem Fehlbestand.
|
||||
result = await db.execute(
|
||||
select(MindermengenGenehmigung).where(
|
||||
MindermengenGenehmigung.fehlbestand_id == fehlbestand.id,
|
||||
MindermengenGenehmigung.status == MindermengeStatus.aktiv,
|
||||
)
|
||||
)
|
||||
genehmigung = result.scalar_one_or_none()
|
||||
if genehmigung is not None:
|
||||
genehmigung.status = MindermengeStatus.beendet_durch_erledigung
|
||||
genehmigung.beendet_am = datetime.now(timezone.utc)
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="mindermenge_beendet_durch_erledigung",
|
||||
entitaet_typ="mindermengen_genehmigung",
|
||||
entitaet_id=genehmigung.id,
|
||||
neuer_wert={"status": "beendet_durch_erledigung"},
|
||||
)
|
||||
await db.flush()
|
||||
return nachfuellung, ueberbestand
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.historie import Historie
|
||||
|
||||
|
||||
async def log(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
zustaendiger_server_id: int,
|
||||
benutzer_id: int | None,
|
||||
ereignistyp: str,
|
||||
entitaet_typ: str,
|
||||
entitaet_id: Any,
|
||||
alter_wert: dict | None = None,
|
||||
neuer_wert: dict | None = None,
|
||||
begruendung: str | None = None,
|
||||
) -> Historie:
|
||||
"""Prompt 13: append-only Audit-Log. Deckt Sprint 5 die Kernkette ab (Kontrolle,
|
||||
Fehlbestand, Nachfüllung, Mindermenge) - Stammdaten-/Vorlagen-/Benutzerverwaltung
|
||||
folgen bei Bedarf mit demselben Muster, hier noch nicht flächendeckend verdrahtet."""
|
||||
eintrag = Historie(
|
||||
erzeugt_von_server_id=zustaendiger_server_id,
|
||||
zeitpunkt=datetime.now(timezone.utc),
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp=ereignistyp,
|
||||
entitaet_typ=entitaet_typ,
|
||||
entitaet_id=str(entitaet_id),
|
||||
alter_wert=alter_wert,
|
||||
neuer_wert=neuer_wert,
|
||||
begruendung=begruendung,
|
||||
)
|
||||
db.add(eintrag)
|
||||
await db.flush()
|
||||
return eintrag
|
||||
@@ -6,10 +6,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
|
||||
from app.models.historie import Historie
|
||||
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
|
||||
|
||||
|
||||
@@ -59,19 +60,16 @@ async def starte_kontrolle(
|
||||
# Fehlbestände dürfen nicht stehen bleiben (Prompt 16.6: kein Fehlbestand
|
||||
# durch eine abgebrochene Kontrolle).
|
||||
await _verwirf_kontrolldaten(db, laufende.id)
|
||||
db.add(
|
||||
Historie(
|
||||
erzeugt_von_server_id=zustaendiger_server_id,
|
||||
zeitpunkt=datetime.now(timezone.utc),
|
||||
benutzer_id=benutzer_id,
|
||||
ereignistyp="kontrolle_uebernommen",
|
||||
entitaet_typ="kontrolle",
|
||||
entitaet_id=str(laufende.id),
|
||||
alter_wert={"benutzer_id": laufende.benutzer_id},
|
||||
neuer_wert={"benutzer_id": benutzer_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},
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
neue_kontrolle = Kontrolle(
|
||||
erzeugt_von_server_id=zustaendiger_server_id,
|
||||
@@ -82,9 +80,51 @@ async def starte_kontrolle(
|
||||
)
|
||||
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():
|
||||
@@ -141,6 +181,16 @@ async def erfasse_position(
|
||||
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,
|
||||
@@ -191,6 +241,22 @@ async def _synchronisiere_fehlbestand(
|
||||
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
|
||||
@@ -224,16 +290,27 @@ async def fehlende_material_ids(db: AsyncSession, kontrolle: Kontrolle) -> list[
|
||||
return sorted(erwartete - erfasste)
|
||||
|
||||
|
||||
async def abschliesse_kontrolle(db: AsyncSession, *, kontrolle: Kontrolle) -> None:
|
||||
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) -> None:
|
||||
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."""
|
||||
@@ -242,3 +319,12 @@ async def abbreche_kontrolle(db: AsyncSession, *, kontrolle: Kontrolle, grund: s
|
||||
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},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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.mindermenge import MindermengeStatus, MindermengenGenehmigung
|
||||
from app.services import historie as historie_service
|
||||
|
||||
|
||||
class FehlbestandBereitsErledigtError(Exception):
|
||||
"""Ein bereits erledigter Fehlbestand braucht keine Mindermengen-Bewertung mehr."""
|
||||
|
||||
|
||||
class BereitsGenehmigtError(Exception):
|
||||
"""Für diesen Fehlbestand existiert bereits eine aktive Genehmigung."""
|
||||
|
||||
|
||||
async def genehmigen(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
fehlbestand: Fehlbestand,
|
||||
begruendung: str,
|
||||
genehmigt_von: int,
|
||||
zustaendiger_server_id: int,
|
||||
) -> MindermengenGenehmigung:
|
||||
"""Prompt 04: reine fachliche Bewertung - ändert NIEMALS Ist-Menge, Fehlmenge
|
||||
oder den Fehlbestand-Status selbst (Leitplanke, U2). Verknüpft mit der Kontrolle,
|
||||
die den Fehlbestand ausgelöst hat ("aktuelle Kontrolle des Objekts", Prompt 04.3.4)."""
|
||||
if fehlbestand.status == FehlbestandStatus.erledigt:
|
||||
raise FehlbestandBereitsErledigtError()
|
||||
if fehlbestand.kontrolle_id is None:
|
||||
# Sollte praktisch nicht vorkommen (jeder Fehlbestand entsteht aus einer
|
||||
# Kontrolle, Sprint 3), aber das Schema erlaubt kontrolle_id=NULL.
|
||||
raise ValueError("Fehlbestand ohne auslösende Kontrolle kann nicht genehmigt werden")
|
||||
|
||||
result = await db.execute(
|
||||
select(MindermengenGenehmigung).where(
|
||||
MindermengenGenehmigung.fehlbestand_id == fehlbestand.id,
|
||||
MindermengenGenehmigung.status == MindermengeStatus.aktiv,
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is not None:
|
||||
raise BereitsGenehmigtError()
|
||||
|
||||
genehmigung = MindermengenGenehmigung(
|
||||
fehlbestand_id=fehlbestand.id,
|
||||
genehmigt_von=genehmigt_von,
|
||||
begruendung=begruendung,
|
||||
genehmigt_am=datetime.now(timezone.utc),
|
||||
ausloesende_kontrolle_id=fehlbestand.kontrolle_id,
|
||||
status=MindermengeStatus.aktiv,
|
||||
)
|
||||
db.add(genehmigung)
|
||||
await db.flush()
|
||||
|
||||
await historie_service.log(
|
||||
db,
|
||||
zustaendiger_server_id=zustaendiger_server_id,
|
||||
benutzer_id=genehmigt_von,
|
||||
ereignistyp="mindermenge_genehmigt",
|
||||
entitaet_typ="mindermengen_genehmigung",
|
||||
entitaet_id=genehmigung.id,
|
||||
neuer_wert={"fehlbestand_id": str(fehlbestand.id), "status": "aktiv"},
|
||||
begruendung=begruendung,
|
||||
)
|
||||
return genehmigung
|
||||
Reference in New Issue
Block a user