diff --git a/DEVLOG.md b/DEVLOG.md index d906c9b..92d39e5 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1241,3 +1241,66 @@ Keine Commits in dieser Session. - backend/tests/test_kontrolle.py | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ --- +## 2026-09-03 23:41 – 23:46 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- 18fba28 Sprint 4: Fehlbestand-Statusmaschine komplett, Nachfüllung sofort/später/teilweise + +### Geänderte Dateien +- DEVLOG.md | 42 ++++++++++++++++++++++ +- backend/app/api/v1/api.py | 13 ++++++- +- backend/app/api/v1/endpoints/fehlbestaende.py | 86 +++++++++++++++++++++++++++++++++++++++++++++ +- backend/app/api/v1/endpoints/kontrollen.py | 13 +++++-- +- backend/app/models/__init__.py | 2 ++ +- backend/app/models/nachfuellung.py | 26 ++++++++++++++ +- backend/app/schemas/fehlbestand.py | 46 ++++++++++++++++++++++++ +- backend/app/schemas/kontrolle.py | 1 + +- backend/app/services/fehlbestand.py | 69 ++++++++++++++++++++++++++++++++++++ +- backend/app/services/kontrolle.py | 42 ++++++++++++---------- +- backend/tests/test_fehlbestand.py | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-03 23:46 – 23:46 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 42 ++++++++++++++++++++++ +- backend/app/api/v1/api.py | 13 ++++++- +- backend/app/api/v1/endpoints/fehlbestaende.py | 86 +++++++++++++++++++++++++++++++++++++++++++++ +- backend/app/api/v1/endpoints/kontrollen.py | 13 +++++-- +- backend/app/models/__init__.py | 2 ++ +- backend/app/models/nachfuellung.py | 26 ++++++++++++++ +- backend/app/schemas/fehlbestand.py | 46 ++++++++++++++++++++++++ +- backend/app/schemas/kontrolle.py | 1 + +- backend/app/services/fehlbestand.py | 69 ++++++++++++++++++++++++++++++++++++ +- backend/app/services/kontrolle.py | 42 ++++++++++++---------- +- backend/tests/test_fehlbestand.py | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-03 23:47 – 23:47 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 42 ++++++++++++++++++++++ +- backend/app/api/v1/api.py | 13 ++++++- +- backend/app/api/v1/endpoints/fehlbestaende.py | 86 +++++++++++++++++++++++++++++++++++++++++++++ +- backend/app/api/v1/endpoints/kontrollen.py | 13 +++++-- +- backend/app/models/__init__.py | 2 ++ +- backend/app/models/nachfuellung.py | 26 ++++++++++++++ +- backend/app/schemas/fehlbestand.py | 46 ++++++++++++++++++++++++ +- backend/app/schemas/kontrolle.py | 1 + +- backend/app/services/fehlbestand.py | 69 ++++++++++++++++++++++++++++++++++++ +- backend/app/services/kontrolle.py | 42 ++++++++++++---------- +- backend/tests/test_fehlbestand.py | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- diff --git a/backend/app/api/v1/endpoints/fehlbestaende.py b/backend/app/api/v1/endpoints/fehlbestaende.py index cf57665..af51e0f 100644 --- a/backend/app/api/v1/endpoints/fehlbestaende.py +++ b/backend/app/api/v1/endpoints/fehlbestaende.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import require_roles from app.db.session import get_db -from app.models.auth import RolleTyp +from app.models.auth import KnotenTyp, RolleTyp, Systemknoten from app.models.fehlbestand import Fehlbestand from app.schemas.fehlbestand import ( FehlbestandRead, @@ -14,7 +14,11 @@ from app.schemas.fehlbestand import ( NachfuellungRead, NachfuellungResponse, ) +from app.schemas.mindermenge import MindermengeGenehmigenRequest, MindermengeRead from app.services.fehlbestand import FehlbestandBereitsErledigtError, nachfuellen +from app.services.mindermenge import BereitsGenehmigtError +from app.services.mindermenge import FehlbestandBereitsErledigtError as MindermengeAufErledigtemError +from app.services.mindermenge import genehmigen router = APIRouter() @@ -29,6 +33,11 @@ _verantwortliche = require_roles( ) +async def _hauptserver_id(db: AsyncSession) -> int: + result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt)) + return result.scalar_one() + + @router.get("/fehlbestaende", response_model=list[FehlbestandRead]) async def liste_fehlbestaende( status_filter: str | None = None, @@ -70,9 +79,14 @@ async def erfasse_nachfuellung( if fehlbestand is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fehlbestand nicht gefunden") + zustaendiger_server_id = await _hauptserver_id(db) try: nachfuellung, ueberbestand = await nachfuellen( - db, fehlbestand=fehlbestand, menge=payload.menge, benutzer_id=current_user.id + db, + fehlbestand=fehlbestand, + menge=payload.menge, + benutzer_id=current_user.id, + zustaendiger_server_id=zustaendiger_server_id, ) except FehlbestandBereitsErledigtError as exc: raise HTTPException( @@ -84,3 +98,41 @@ async def erfasse_nachfuellung( fehlbestand=FehlbestandRead.model_validate(fehlbestand), ueberbestand=ueberbestand, ) + + +@router.post( + "/fehlbestaende/{fehlbestand_id}/mindermenge", + response_model=MindermengeRead, + status_code=status.HTTP_201_CREATED, +) +async def genehmige_mindermenge( + fehlbestand_id: uuid.UUID, + payload: MindermengeGenehmigenRequest, + db: AsyncSession = Depends(get_db), + current_user=Depends(_verantwortliche), +) -> MindermengeRead: + """Prompt 04: NIEMALS durch Mitarbeiter (Rollen-Dependency erzwingt das bereits), + ändert NIEMALS Ist-Menge/Fehlmenge/Fehlbestand-Status (U2).""" + fehlbestand = await db.get(Fehlbestand, fehlbestand_id) + if fehlbestand is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fehlbestand nicht gefunden") + + zustaendiger_server_id = await _hauptserver_id(db) + try: + genehmigung = await genehmigen( + db, + fehlbestand=fehlbestand, + begruendung=payload.begruendung, + genehmigt_von=current_user.id, + zustaendiger_server_id=zustaendiger_server_id, + ) + except MindermengeAufErledigtemError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Fehlbestand ist bereits erledigt" + ) from exc + except BereitsGenehmigtError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Fehlbestand hat bereits eine aktive Genehmigung" + ) from exc + + return MindermengeRead.model_validate(genehmigung) diff --git a/backend/app/api/v1/endpoints/kontrollen.py b/backend/app/api/v1/endpoints/kontrollen.py index f0cb93a..2af5563 100644 --- a/backend/app/api/v1/endpoints/kontrollen.py +++ b/backend/app/api/v1/endpoints/kontrollen.py @@ -154,8 +154,9 @@ async def abschliesse( ) -> Kontrolle: kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id) _pruefe_besitz(kontrolle, current_user) + zustaendiger_server_id = await _hauptserver_id(db) try: - await abschliesse_kontrolle(db, kontrolle=kontrolle) + await abschliesse_kontrolle(db, kontrolle=kontrolle, zustaendiger_server_id=zustaendiger_server_id) except UnvollstaendigError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -176,5 +177,8 @@ async def abbreche( ) -> Kontrolle: kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id) _pruefe_besitz(kontrolle, current_user) - await abbreche_kontrolle(db, kontrolle=kontrolle, grund=payload.grund) + zustaendiger_server_id = await _hauptserver_id(db) + await abbreche_kontrolle( + db, kontrolle=kontrolle, grund=payload.grund, zustaendiger_server_id=zustaendiger_server_id + ) return kontrolle diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9c5e2bf..8814949 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, Kno 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.nachfuellung import Nachfuellung from app.models.objekt import Objekt, ObjektStatus from app.models.objektposition import Objektposition, ObjektpositionStatus @@ -21,6 +22,8 @@ __all__ = [ "Kontrolle", "KontrollStatus", "Kontrollposition", + "MindermengeStatus", + "MindermengenGenehmigung", "Nachfuellung", "Objekt", "ObjektStatus", diff --git a/backend/app/models/mindermenge.py b/backend/app/models/mindermenge.py new file mode 100644 index 0000000..fb3f4df --- /dev/null +++ b/backend/app/models/mindermenge.py @@ -0,0 +1,45 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class MindermengeStatus(str, enum.Enum): + aktiv = "aktiv" + abgelaufen = "abgelaufen" + beendet_durch_erledigung = "beendet_durch_erledigung" + + +mindermenge_status_pg = PgEnum(MindermengeStatus, name="mindermenge_status", create_type=False) + + +class MindermengenGenehmigung(Base): + """Prompt 04: reine fachliche Bewertung, ändert NIEMALS Ist-Menge/Fehlmenge/ + Fehlbestand-Status (Leitplanke, U2). Gilt automatisch nur bis zur nächsten + Kontrolle desselben Objekts (Karte 08, U6) oder bis der Fehlbestand durch + Nachfüllung erledigt wird (U7) - beides ohne separaten Zeit-Trigger.""" + + __tablename__ = "mindermengen_genehmigung" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + fehlbestand_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("fehlbestand.id"), nullable=False + ) + genehmigt_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False) + begruendung: Mapped[str] = mapped_column(String, nullable=False) + genehmigt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + ausloesende_kontrolle_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("kontrolle.id"), nullable=False + ) + status: Mapped[MindermengeStatus] = mapped_column( + mindermenge_status_pg, nullable=False, default=MindermengeStatus.aktiv + ) + beendet_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) + beendende_kontrolle_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("kontrolle.id") + ) diff --git a/backend/app/schemas/mindermenge.py b/backend/app/schemas/mindermenge.py new file mode 100644 index 0000000..3b92b9b --- /dev/null +++ b/backend/app/schemas/mindermenge.py @@ -0,0 +1,23 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.mindermenge import MindermengeStatus + + +class MindermengeGenehmigenRequest(BaseModel): + begruendung: str + + +class MindermengeRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + fehlbestand_id: uuid.UUID + genehmigt_von: int + begruendung: str + genehmigt_am: datetime + ausloesende_kontrolle_id: uuid.UUID + status: MindermengeStatus + beendet_am: datetime | None + beendende_kontrolle_id: uuid.UUID | None diff --git a/backend/app/services/fehlbestand.py b/backend/app/services/fehlbestand.py index 46036fa..144f555 100644 --- a/backend/app/services/fehlbestand.py +++ b/backend/app/services/fehlbestand.py @@ -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 diff --git a/backend/app/services/historie.py b/backend/app/services/historie.py new file mode 100644 index 0000000..0ce313b --- /dev/null +++ b/backend/app/services/historie.py @@ -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 diff --git a/backend/app/services/kontrolle.py b/backend/app/services/kontrolle.py index 8510219..45a86c8 100644 --- a/backend/app/services/kontrolle.py +++ b/backend/app/services/kontrolle.py @@ -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}, + ) diff --git a/backend/app/services/mindermenge.py b/backend/app/services/mindermenge.py new file mode 100644 index 0000000..6c7322e --- /dev/null +++ b/backend/app/services/mindermenge.py @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3e77a53..caf7945 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -91,6 +91,21 @@ async def mitarbeiter2_user(db_session): return benutzer +@pytest_asyncio.fixture +async def materialverantwortlicher_user(db_session): + benutzer = Benutzer( + name="Test Materialverantwortlicher", + login="materialverantwortlicher1", + passwort_hash=hash_password("test-passwort-123"), + aktiv=True, + ) + db_session.add(benutzer) + await db_session.flush() + db_session.add(BenutzerRolle(benutzer_id=benutzer.id, rolle=RolleTyp.materialverantwortlicher)) + await db_session.flush() + return benutzer + + @pytest_asyncio.fixture async def admin_user(db_session): benutzer = Benutzer( diff --git a/backend/tests/test_historie.py b/backend/tests/test_historie.py new file mode 100644 index 0000000..443658f --- /dev/null +++ b/backend/tests/test_historie.py @@ -0,0 +1,62 @@ +import pytest +from sqlalchemy import select + +from app.models.historie import Historie +from tests.conftest import auth_header, login + + +@pytest.mark.asyncio +async def test_historie_kette_soll10_ist8_genehmigt_dann_nachgefuellt( + client, db_session, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + """U14/Prompt 13.3 Beispiel-Kette: Soll 10, Ist 8 -> Fehlbestand entstanden -> + keine Nachfüllung möglich -> Mindermenge genehmigt -> später 2 nachgefüllt -> + Ist wieder 10 -> Fehlbestand erledigt, Genehmigung endet gemeinsam. Jeder + Schritt muss als chronologisch korrekter Historieneintrag auffindbar sein.""" + objekt, material = objekt_mit_position + mitarbeiter_token = await login(client, "mitarbeiter1") + + start = await client.post( + f"/api/v1/objekte/{objekt.id}/kontrollen", json={"uebernehmen": False}, headers=auth_header(mitarbeiter_token) + ) + kontrolle_id = start.json()["id"] + + put_response = await client.put( + f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}", + json={"istmenge": "8"}, + headers=auth_header(mitarbeiter_token), + ) + fehlbestand_id = put_response.json()["fehlbestand_id"] + assert fehlbestand_id is not None + + await client.post(f"/api/v1/kontrollen/{kontrolle_id}/abschliessen", headers=auth_header(mitarbeiter_token)) + + verantwortlicher_token = await login(client, "materialverantwortlicher1") + await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/mindermenge", + json={"begruendung": "Lieferengpass, Ersatz bestellt"}, + headers=auth_header(verantwortlicher_token), + ) + + await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/nachfuellungen", + json={"menge": "2"}, + headers=auth_header(mitarbeiter_token), + ) + + result = await db_session.execute(select(Historie).order_by(Historie.zeitpunkt)) + ereignisse = [h.ereignistyp for h in result.scalars().all()] + + erwartete_reihenfolge = [ + "kontrolle_gestartet", + "istmenge_erfasst", + "fehlbestand_entstanden", + "kontrolle_abgeschlossen", + "mindermenge_genehmigt", + "nachfuellung_erfasst", + "fehlbestand_erledigt", + "mindermenge_beendet_durch_erledigung", + ] + # Reine Teilfolge-Prüfung (keine anderen Events dazwischen erwartet, aber falls + # doch, ist die Reihenfolge der genannten Ereignisse das eigentlich Wichtige). + assert ereignisse == erwartete_reihenfolge diff --git a/backend/tests/test_mindermenge.py b/backend/tests/test_mindermenge.py new file mode 100644 index 0000000..01f201c --- /dev/null +++ b/backend/tests/test_mindermenge.py @@ -0,0 +1,187 @@ +import pytest +from sqlalchemy import select + +from app.models.fehlbestand import Fehlbestand +from app.models.mindermenge import MindermengenGenehmigung +from app.models.objektposition import Objektposition +from tests.conftest import auth_header, login + + +async def _fehlbestand_ueber_kontrolle(client, token, objekt, material, istmenge: str, abschliessen=False): + start = await client.post( + f"/api/v1/objekte/{objekt.id}/kontrollen", json={"uebernehmen": False}, headers=auth_header(token) + ) + kontrolle_id = start.json()["id"] + put_response = await client.put( + f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}", + json={"istmenge": istmenge}, + headers=auth_header(token), + ) + fehlbestand_id = put_response.json()["fehlbestand_id"] + if abschliessen: + await client.post(f"/api/v1/kontrollen/{kontrolle_id}/abschliessen", headers=auth_header(token)) + return kontrolle_id, fehlbestand_id + + +@pytest.mark.asyncio +async def test_genehmigung_aendert_nichts_am_fehlbestand( + client, db_session, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + """U2 - Leitplanke: Genehmigung ändert NIEMALS Ist-Menge/Fehlmenge/Status.""" + objekt, material = objekt_mit_position + mitarbeiter_token = await login(client, "mitarbeiter1") + _, fehlbestand_id = await _fehlbestand_ueber_kontrolle( + client, mitarbeiter_token, objekt, material, "8", abschliessen=True + ) + + vor = await db_session.get(Fehlbestand, fehlbestand_id) + vor_status, vor_istmenge, vor_fehlmenge = vor.status, vor.istmenge, vor.fehlmenge + + verantwortlicher_token = await login(client, "materialverantwortlicher1") + response = await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/mindermenge", + json={"begruendung": "Lieferengpass, Ersatz bestellt"}, + headers=auth_header(verantwortlicher_token), + ) + assert response.status_code == 201 + assert response.json()["status"] == "aktiv" + + await db_session.refresh(vor) + assert vor.status == vor_status + assert vor.istmenge == vor_istmenge + assert vor.fehlmenge == vor_fehlmenge + + objektposition = ( + await db_session.execute( + select(Objektposition).where( + Objektposition.objekt_id == objekt.id, Objektposition.material_id == material.id + ) + ) + ).scalar_one() + assert float(objektposition.istmenge) == 0 # unverändert (Kontrolle/Genehmigung ändern nie Ist-Menge) + + +@pytest.mark.asyncio +async def test_mitarbeiter_darf_nicht_genehmigen( + client, objekt_mit_position, mitarbeiter_user +): + objekt, material = objekt_mit_position + token = await login(client, "mitarbeiter1") + _, fehlbestand_id = await _fehlbestand_ueber_kontrolle(client, token, objekt, material, "8", abschliessen=True) + + response = await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/mindermenge", + json={"begruendung": "Versuch"}, + headers=auth_header(token), + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_genehmigung_laeuft_bei_naechster_kontrolle_ab( + client, db_session, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + """U6 / Karte 08: Genehmigung gilt automatisch nur bis zur nächsten Kontrolle.""" + objekt, material = objekt_mit_position + mitarbeiter_token = await login(client, "mitarbeiter1") + _, fehlbestand_id = await _fehlbestand_ueber_kontrolle( + client, mitarbeiter_token, objekt, material, "8", abschliessen=True + ) + + verantwortlicher_token = await login(client, "materialverantwortlicher1") + genehmigung_response = await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/mindermenge", + json={"begruendung": "Lieferengpass"}, + headers=auth_header(verantwortlicher_token), + ) + genehmigung_id = genehmigung_response.json()["id"] + + naechste_kontrolle = await client.post( + f"/api/v1/objekte/{objekt.id}/kontrollen", json={"uebernehmen": False}, headers=auth_header(mitarbeiter_token) + ) + assert naechste_kontrolle.status_code == 201 + neue_kontrolle_id = naechste_kontrolle.json()["id"] + + genehmigung = await db_session.get(MindermengenGenehmigung, genehmigung_id) + assert genehmigung.status.value == "abgelaufen" + assert str(genehmigung.beendende_kontrolle_id) == neue_kontrolle_id + + +@pytest.mark.asyncio +async def test_genehmigung_endet_gemeinsam_mit_erledigung( + client, db_session, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + """U7: wird die Abweichung behoben, erledigt sich Fehlbestand UND Genehmigung gemeinsam.""" + objekt, material = objekt_mit_position + mitarbeiter_token = await login(client, "mitarbeiter1") + _, fehlbestand_id = await _fehlbestand_ueber_kontrolle( + client, mitarbeiter_token, objekt, material, "8", abschliessen=True + ) + + verantwortlicher_token = await login(client, "materialverantwortlicher1") + genehmigung_response = await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/mindermenge", + json={"begruendung": "Lieferengpass"}, + headers=auth_header(verantwortlicher_token), + ) + genehmigung_id = genehmigung_response.json()["id"] + + nachfuellung = await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/nachfuellungen", + json={"menge": "2"}, + headers=auth_header(mitarbeiter_token), + ) + assert nachfuellung.json()["fehlbestand"]["status"] == "erledigt" + + genehmigung = await db_session.get(MindermengenGenehmigung, genehmigung_id) + assert genehmigung.status.value == "beendet_durch_erledigung" + + +@pytest.mark.asyncio +async def test_doppelte_genehmigung_ist_409( + client, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + objekt, material = objekt_mit_position + mitarbeiter_token = await login(client, "mitarbeiter1") + _, fehlbestand_id = await _fehlbestand_ueber_kontrolle( + client, mitarbeiter_token, objekt, material, "8", abschliessen=True + ) + + verantwortlicher_token = await login(client, "materialverantwortlicher1") + erste = await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/mindermenge", + json={"begruendung": "Lieferengpass"}, + headers=auth_header(verantwortlicher_token), + ) + assert erste.status_code == 201 + + zweite = await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/mindermenge", + json={"begruendung": "Nochmal"}, + headers=auth_header(verantwortlicher_token), + ) + assert zweite.status_code == 409 + + +@pytest.mark.asyncio +async def test_genehmigung_auf_erledigten_fehlbestand_ist_409( + client, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + objekt, material = objekt_mit_position + mitarbeiter_token = await login(client, "mitarbeiter1") + _, fehlbestand_id = await _fehlbestand_ueber_kontrolle( + client, mitarbeiter_token, objekt, material, "8", abschliessen=True + ) + await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/nachfuellungen", + json={"menge": "2"}, + headers=auth_header(mitarbeiter_token), + ) + + verantwortlicher_token = await login(client, "materialverantwortlicher1") + response = await client.post( + f"/api/v1/fehlbestaende/{fehlbestand_id}/mindermenge", + json={"begruendung": "Zu spät"}, + headers=auth_header(verantwortlicher_token), + ) + assert response.status_code == 409