diff --git a/DEVLOG.md b/DEVLOG.md index 3eac9ac..e471fc5 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1160,3 +1160,42 @@ Keine Commits in dieser Session. - backend/tests/test_zustaendigkeit.py | 80 +++++++++++++++++++++++++++++++++++ --- +## 2026-09-03 23:13 – 23:31 (18m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- fdcac2f Sprint 2: Beladungsvorlagen (Versionierung), Objekte anlegen/duplizieren, Override-Logik +- 67cebbb Test-Engine pro Test statt Modul-Global erzeugen (echter Fix für Event-Loop-Konflikt) +- 8f9bd57 CI-Bugfixes aus erstem echten Testlauf: bcrypt-Pin, pytest-asyncio Loop-Scope +- d7d61f2 CI umgebaut: Host-Runner ohne Docker statt Service-Container + +### Geänderte Dateien +- backend/app/api/v1/api.py | 4 +- +- backend/app/api/v1/endpoints/objekte.py | 222 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- backend/app/api/v1/endpoints/vorlagen.py | 78 +++++++++++++++++++++++++++ +- backend/app/main.py | 3 ++ +- backend/app/models/__init__.py | 7 +++ +- backend/tests/conftest.py | 31 ++++++++++- +- backend/tests/test_objekte.py | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- backend/tests/test_vorlagen.py | 34 ++++++++++++ + +--- +## 2026-09-03 23:31 – 23:31 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/api/v1/api.py | 4 +- +- backend/app/api/v1/endpoints/objekte.py | 222 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- backend/app/api/v1/endpoints/vorlagen.py | 78 +++++++++++++++++++++++++++ +- backend/app/main.py | 3 ++ +- backend/app/models/__init__.py | 7 +++ +- backend/tests/conftest.py | 31 ++++++++++- +- backend/tests/test_objekte.py | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- backend/tests/test_vorlagen.py | 34 ++++++++++++ + +--- diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index d560e41..18ec28b 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1.endpoints import auth, benutzer, health, objekte, stammdaten, vorlagen, zustaendigkeit +from app.api.v1.endpoints import auth, benutzer, health, kontrollen, objekte, stammdaten, vorlagen, zustaendigkeit api_router = APIRouter() api_router.include_router(health.router, tags=["health"]) @@ -10,3 +10,4 @@ api_router.include_router(benutzer.router, tags=["benutzer"]) api_router.include_router(zustaendigkeit.router, tags=["zustaendigkeit"]) api_router.include_router(vorlagen.router, tags=["vorlagen"]) api_router.include_router(objekte.router, tags=["objekte"]) +api_router.include_router(kontrollen.router, tags=["kontrollen"]) diff --git a/backend/app/api/v1/endpoints/kontrollen.py b/backend/app/api/v1/endpoints/kontrollen.py new file mode 100644 index 0000000..00d8f20 --- /dev/null +++ b/backend/app/api/v1/endpoints/kontrollen.py @@ -0,0 +1,171 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, require_roles +from app.db.session import get_db +from app.models.auth import Benutzer, KnotenTyp, RolleTyp, Systemknoten +from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition +from app.models.objekt import Objekt +from app.schemas.kontrolle import ( + KontrolleAbbrechenRequest, + KontrolleCreate, + KontrolleFortschritt, + KontrolleRead, + KontrollpositionInput, + KontrollpositionRead, +) +from app.services.kontrolle import ( + ObjektGesperrtError, + UnvollstaendigError, + abbreche_kontrolle, + abschliesse_kontrolle, + erfasse_position, + fehlende_material_ids, + starte_kontrolle, +) + +router = APIRouter() + +_mitarbeiter_plus = require_roles( + RolleTyp.mitarbeiter, + RolleTyp.materialverantwortlicher, + RolleTyp.leitungsverantwortlicher, + RolleTyp.administration, +) + + +async def _hauptserver_id(db: AsyncSession) -> int: + result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt)) + return result.scalar_one() + + +async def _hole_kontrolle_oder_404(db: AsyncSession, kontrolle_id: uuid.UUID) -> Kontrolle: + kontrolle = await db.get(Kontrolle, kontrolle_id) + if kontrolle is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kontrolle nicht gefunden") + return kontrolle + + +def _pruefe_besitz(kontrolle: Kontrolle, current_user: Benutzer) -> None: + """Objekt-Sperre (Prompt 02.8) wirkt auch hier: nur wer die Kontrolle gestartet + (oder übernommen) hat, darf sie fortführen/abschließen/abbrechen.""" + if kontrolle.benutzer_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Diese Kontrolle gehört einem anderen Benutzer – ggf. Übernahme nötig", + ) + if kontrolle.status != KontrollStatus.in_bearbeitung: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Kontrolle ist nicht mehr aktiv") + + +@router.post( + "/objekte/{objekt_id}/kontrollen", response_model=KontrolleRead, status_code=status.HTTP_201_CREATED +) +async def starte_neue_kontrolle( + objekt_id: int, + payload: KontrolleCreate, + db: AsyncSession = Depends(get_db), + current_user: Benutzer = Depends(_mitarbeiter_plus), +) -> Kontrolle: + objekt = await db.get(Objekt, objekt_id) + if objekt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") + + zustaendiger_server_id = await _hauptserver_id(db) + try: + return await starte_kontrolle( + db, + objekt_id=objekt_id, + benutzer_id=current_user.id, + uebernehmen=payload.uebernehmen, + zustaendiger_server_id=zustaendiger_server_id, + ) + except ObjektGesperrtError as exc: + aktive = exc.aktive_kontrolle + besitzer = await db.get(Benutzer, aktive.benutzer_id) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "nachricht": "wird gerade kontrolliert", + "benutzer": besitzer.name if besitzer else None, + "seit": aktive.gestartet_am.isoformat(), + }, + ) from exc + + +@router.get("/kontrollen/{kontrolle_id}", response_model=KontrolleFortschritt) +async def hole_kontroll_fortschritt( + kontrolle_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> KontrolleFortschritt: + kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id) + result = await db.execute( + select(Kontrollposition).where(Kontrollposition.kontrolle_id == kontrolle_id) + ) + positionen = list(result.scalars().all()) + fehlende = await fehlende_material_ids(db, kontrolle) + return KontrolleFortschritt( + kontrolle=KontrolleRead.model_validate(kontrolle), + positionen=[KontrollpositionRead.model_validate(p) for p in positionen], + anzahl_positionen_gesamt=len(positionen) + len(fehlende), + anzahl_positionen_erfasst=len(positionen), + ) + + +@router.put("/kontrollen/{kontrolle_id}/positionen/{material_id}", response_model=KontrollpositionRead) +async def erfasse_kontrollposition( + kontrolle_id: uuid.UUID, + material_id: int, + payload: KontrollpositionInput, + db: AsyncSession = Depends(get_db), + current_user: Benutzer = Depends(_mitarbeiter_plus), +) -> Kontrollposition: + kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id) + _pruefe_besitz(kontrolle, current_user) + + objekt = await db.get(Objekt, kontrolle.objekt_id) + zustaendiger_server_id = await _hauptserver_id(db) + return await erfasse_position( + db, + kontrolle=kontrolle, + objekt=objekt, + material_id=material_id, + istmenge=payload.istmenge, + zustaendiger_server_id=zustaendiger_server_id, + ) + + +@router.post("/kontrollen/{kontrolle_id}/abschliessen", response_model=KontrolleRead) +async def abschliesse( + kontrolle_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: Benutzer = Depends(_mitarbeiter_plus), +) -> Kontrolle: + kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id) + _pruefe_besitz(kontrolle, current_user) + try: + await abschliesse_kontrolle(db, kontrolle=kontrolle) + except UnvollstaendigError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "nachricht": "nicht alle Positionen erfasst", + "fehlende_material_ids": exc.fehlende_material_ids, + }, + ) from exc + return kontrolle + + +@router.post("/kontrollen/{kontrolle_id}/abbrechen", response_model=KontrolleRead) +async def abbreche( + kontrolle_id: uuid.UUID, + payload: KontrolleAbbrechenRequest, + db: AsyncSession = Depends(get_db), + current_user: Benutzer = Depends(_mitarbeiter_plus), +) -> Kontrolle: + kontrolle = await _hole_kontrolle_oder_404(db, kontrolle_id) + _pruefe_besitz(kontrolle, current_user) + await abbreche_kontrolle(db, kontrolle=kontrolle, grund=payload.grund) + return kontrolle diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 79f2df8..eb5278b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,7 @@ from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp +from app.models.fehlbestand import Fehlbestand, FehlbestandStatus +from app.models.historie import Historie +from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition from app.models.objekt import Objekt, ObjektStatus from app.models.objektposition import Objektposition, ObjektpositionStatus from app.models.stammdaten import Bereich, Kategorie, Material, MaterialTyp, Objekttyp, Standort @@ -11,6 +14,12 @@ __all__ = [ "RolleTyp", "Systemknoten", "KnotenTyp", + "Fehlbestand", + "FehlbestandStatus", + "Historie", + "Kontrolle", + "KontrollStatus", + "Kontrollposition", "Objekt", "ObjektStatus", "Objektposition", diff --git a/backend/app/models/fehlbestand.py b/backend/app/models/fehlbestand.py new file mode 100644 index 0000000..fcf4317 --- /dev/null +++ b/backend/app/models/fehlbestand.py @@ -0,0 +1,47 @@ +import enum +import uuid +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import ForeignKey, Numeric, 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 FehlbestandStatus(str, enum.Enum): + offen = "offen" + in_bearbeitung = "in_bearbeitung" + nachgefuellt_teilweise = "nachgefuellt_teilweise" + erledigt = "erledigt" + + +fehlbestand_status_pg = PgEnum(FehlbestandStatus, name="fehlbestand_status", create_type=False) + + +class Fehlbestand(Base): + """Prompt 03: automatisch entstandener Vorgang bei Ist < Soll, ändert selbst + NIEMALS die Ist-Menge (nur Nachfüllung tut das, Sprint 4). Sollmenge/Istmenge/ + Fehlmenge hier sind Snapshot-Werte zum Feststellungszeitpunkt, nicht live + referenziert (Prompt 03 Punkt 2: "Sollmenge zum Zeitpunkt der Feststellung").""" + + __tablename__ = "fehlbestand" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + erzeugt_von_server_id: Mapped[int] = mapped_column(ForeignKey("systemknoten.id"), nullable=False) + objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False) + material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False) + standort_id: Mapped[int] = mapped_column(ForeignKey("standort.id"), nullable=False) + sollmenge: Mapped[Decimal] = mapped_column(Numeric, nullable=False) + istmenge: Mapped[Decimal] = mapped_column(Numeric, nullable=False) + fehlmenge: Mapped[Decimal] = mapped_column(Numeric, nullable=False) + entstanden_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + festgestellt_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False) + kontrolle_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("kontrolle.id")) + ursache: Mapped[str | None] = mapped_column(String) + verantwortlicher_id: Mapped[int | None] = mapped_column(ForeignKey("benutzer.id")) + status: Mapped[FehlbestandStatus] = mapped_column( + fehlbestand_status_pg, nullable=False, default=FehlbestandStatus.offen + ) + erledigt_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) diff --git a/backend/app/models/historie.py b/backend/app/models/historie.py new file mode 100644 index 0000000..a37ab14 --- /dev/null +++ b/backend/app/models/historie.py @@ -0,0 +1,27 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Historie(Base): + """Prompt 13: vollständige Audit-Protokollierung ist Sprint 5. Hier bereits + angelegt, weil Sprint 3 (Objekt-Übernahme, Prompt 02.8) einen ersten + Ereignistyp braucht – append-only, kein Update/Delete durch die Anwendung.""" + + __tablename__ = "historie" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + erzeugt_von_server_id: Mapped[int] = mapped_column(ForeignKey("systemknoten.id"), nullable=False) + zeitpunkt: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + benutzer_id: Mapped[int | None] = mapped_column(ForeignKey("benutzer.id")) + ereignistyp: Mapped[str] = mapped_column(String, nullable=False) + entitaet_typ: Mapped[str] = mapped_column(String, nullable=False) + entitaet_id: Mapped[str] = mapped_column(String, nullable=False) + alter_wert: Mapped[dict | None] = mapped_column(JSONB) + neuer_wert: Mapped[dict | None] = mapped_column(JSONB) + begruendung: Mapped[str | None] = mapped_column(String) diff --git a/backend/app/models/kontrolle.py b/backend/app/models/kontrolle.py new file mode 100644 index 0000000..7bb7254 --- /dev/null +++ b/backend/app/models/kontrolle.py @@ -0,0 +1,53 @@ +import enum +import uuid +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import Boolean, ForeignKey, Numeric, 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 KontrollStatus(str, enum.Enum): + nicht_gestartet = "nicht_gestartet" + in_bearbeitung = "in_bearbeitung" + abgeschlossen = "abgeschlossen" + abgebrochen = "abgebrochen" + + +kontroll_status_pg = PgEnum(KontrollStatus, name="kontroll_status", create_type=False) + + +class Kontrolle(Base): + """Prompt 02: Kontrolle ändert NIEMALS Ist-Menge, ist reine Erfassung (Punkt 9, + Vier-Kernbegriffe-Tabelle). Status='in_bearbeitung' IST die Objekt-Sperre + (Punkt 8) – kein separates Sperr-Modell nötig.""" + + __tablename__ = "kontrolle" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + erzeugt_von_server_id: Mapped[int] = mapped_column(ForeignKey("systemknoten.id"), nullable=False) + objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False) + benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False) + status: Mapped[KontrollStatus] = mapped_column( + kontroll_status_pg, nullable=False, default=KontrollStatus.in_bearbeitung + ) + gestartet_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + beendet_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) + abbruch_grund: Mapped[str | None] = mapped_column(String) + signatur: Mapped[bytes | None] = mapped_column() + + +class Kontrollposition(Base): + __tablename__ = "kontrollposition" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + kontrolle_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("kontrolle.id"), nullable=False + ) + material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False) + sollmenge_snapshot: Mapped[Decimal] = mapped_column(Numeric, nullable=False) + istmenge_erfasst: Mapped[Decimal] = mapped_column(Numeric, nullable=False) + abweichung: Mapped[bool] = mapped_column(Boolean, nullable=False) diff --git a/backend/app/schemas/kontrolle.py b/backend/app/schemas/kontrolle.py new file mode 100644 index 0000000..ee435a6 --- /dev/null +++ b/backend/app/schemas/kontrolle.py @@ -0,0 +1,47 @@ +import uuid +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + +from app.models.kontrolle import KontrollStatus + + +class KontrolleCreate(BaseModel): + uebernehmen: bool = False + + +class KontrolleRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + objekt_id: int + benutzer_id: int + status: KontrollStatus + gestartet_am: datetime + beendet_am: datetime | None + abbruch_grund: str | None + + +class KontrollpositionInput(BaseModel): + istmenge: Decimal + + +class KontrollpositionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + kontrolle_id: uuid.UUID + material_id: int + sollmenge_snapshot: Decimal + istmenge_erfasst: Decimal + abweichung: bool + + +class KontrolleAbbrechenRequest(BaseModel): + grund: str | None = None + + +class KontrolleFortschritt(BaseModel): + kontrolle: KontrolleRead + positionen: list[KontrollpositionRead] + anzahl_positionen_gesamt: int + anzahl_positionen_erfasst: int diff --git a/backend/app/services/kontrolle.py b/backend/app/services/kontrolle.py new file mode 100644 index 0000000..5ad94a0 --- /dev/null +++ b/backend/app/services/kontrolle.py @@ -0,0 +1,240 @@ +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.historie import Historie +from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition +from app.models.objekt import Objekt +from app.models.objektposition import Objektposition, ObjektpositionStatus +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) + 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 db.flush() + + 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() + return neue_kontrolle + + +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, +) -> Kontrollposition: + """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).""" + 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 _synchronisiere_fehlbestand( + db, + kontrolle=kontrolle, + objekt=objekt, + material_id=material_id, + sollmenge=sollmenge, + istmenge=istmenge, + zustaendiger_server_id=zustaendiger_server_id, + ) + return kontrollposition + + +async def _synchronisiere_fehlbestand( + db: AsyncSession, + *, + kontrolle: Kontrolle, + objekt: Objekt, + material_id: int, + sollmenge: Decimal, + istmenge: Decimal, + zustaendiger_server_id: int, +) -> 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: + db.add( + 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, + ) + ) + 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) + 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) -> 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() + + +async def abbreche_kontrolle(db: AsyncSession, *, kontrolle: Kontrolle, grund: str | None) -> 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() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fa45555..3e77a53 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -76,6 +76,21 @@ async def mitarbeiter_user(db_session): return benutzer +@pytest_asyncio.fixture +async def mitarbeiter2_user(db_session): + benutzer = Benutzer( + name="Test Mitarbeiter 2", + login="mitarbeiter2", + 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.mitarbeiter)) + await db_session.flush() + return benutzer + + @pytest_asyncio.fixture async def admin_user(db_session): benutzer = Benutzer( @@ -173,3 +188,40 @@ async def objekttyp_factory(db_session): return objekttyp return _make + + +@pytest_asyncio.fixture +async def objekt_mit_position(db_session, hauptserver_id): + """Fertiges Objekt mit einer Vorlagen-Position (Sollmenge 10) für Kontroll-Tests.""" + from app.services.objekte import objekt_aus_vorlage + from app.services.vorlagen import erstelle_vorlage + from app.schemas.vorlage import VorlagenpositionCreate + + bereich = Bereich(name="Bereich-KT") + db_session.add(bereich) + await db_session.flush() + objekttyp = Objekttyp(bereich_id=bereich.id, name="Rucksack-KT") + db_session.add(objekttyp) + await db_session.flush() + material = Material(name="Kompresse", einheit="Stück", materialtyp=MaterialTyp.standard) + db_session.add(material) + await db_session.flush() + standort = Standort(name="Wache-KT") + db_session.add(standort) + await db_session.flush() + + vorlage = await erstelle_vorlage( + db_session, + objekttyp_id=objekttyp.id, + name="Vorlage-KT", + positionen=[VorlagenpositionCreate(material_id=material.id, sollmenge=10)], + ) + objekt = await objekt_aus_vorlage( + db_session, + code="KT-01", + name="Kontroll-Test-Objekt", + standort_id=standort.id, + vorlage=vorlage, + zustaendiger_server_id=hauptserver_id, + ) + return objekt, material diff --git a/backend/tests/test_kontrolle.py b/backend/tests/test_kontrolle.py new file mode 100644 index 0000000..93fda86 --- /dev/null +++ b/backend/tests/test_kontrolle.py @@ -0,0 +1,238 @@ +import pytest +from sqlalchemy import select + +from app.models.fehlbestand import Fehlbestand +from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition +from tests.conftest import auth_header, login + + +async def _starte(client, token, objekt_id, uebernehmen=False): + return await client.post( + f"/api/v1/objekte/{objekt_id}/kontrollen", + json={"uebernehmen": uebernehmen}, + headers=auth_header(token), + ) + + +@pytest.mark.asyncio +async def test_fehlbestand_entsteht_bei_abweichung(client, db_session, objekt_mit_position, mitarbeiter_user): + """U1: Fehlbestand entsteht automatisch bei Ist < Soll.""" + objekt, material = objekt_mit_position + token = await login(client, "mitarbeiter1") + start = await _starte(client, token, objekt.id) + assert start.status_code == 201 + 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(token), + ) + assert put_response.status_code == 200 + assert put_response.json()["abweichung"] is True + + result = await db_session.execute(select(Fehlbestand).where(Fehlbestand.kontrolle_id == kontrolle_id)) + fehlbestand = result.scalar_one() + assert float(fehlbestand.fehlmenge) == 2 + assert fehlbestand.status.value == "offen" + + +@pytest.mark.asyncio +async def test_ueberbestand_erzeugt_keinen_fehlbestand(client, db_session, objekt_mit_position, mitarbeiter_user): + objekt, material = objekt_mit_position + token = await login(client, "mitarbeiter1") + start = await _starte(client, token, objekt.id) + kontrolle_id = start.json()["id"] + + put_response = await client.put( + f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}", + json={"istmenge": "12"}, + headers=auth_header(token), + ) + assert put_response.status_code == 200 + assert put_response.json()["abweichung"] is True # protokolliert... + + result = await db_session.execute(select(Fehlbestand).where(Fehlbestand.kontrolle_id == kontrolle_id)) + assert result.scalar_one_or_none() is None # ...aber KEIN Fehlbestand (Prompt 02.4) + + +@pytest.mark.asyncio +async def test_abschluss_mit_fehlender_position_ist_409(client, objekt_mit_position, mitarbeiter_user): + """U11: Abschluss mit unbestätigten Positionen -> Fehler mit Liste.""" + objekt, material = objekt_mit_position + token = await login(client, "mitarbeiter1") + start = await _starte(client, token, objekt.id) + kontrolle_id = start.json()["id"] + + response = await client.post( + f"/api/v1/kontrollen/{kontrolle_id}/abschliessen", headers=auth_header(token) + ) + assert response.status_code == 409 + assert material.id in response.json()["detail"]["fehlende_material_ids"] + + +@pytest.mark.asyncio +async def test_abschluss_erfolgreich_wenn_vollstaendig(client, objekt_mit_position, mitarbeiter_user): + objekt, material = objekt_mit_position + token = await login(client, "mitarbeiter1") + start = await _starte(client, token, objekt.id) + kontrolle_id = start.json()["id"] + + await client.put( + f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}", + json={"istmenge": "10"}, + headers=auth_header(token), + ) + response = await client.post( + f"/api/v1/kontrollen/{kontrolle_id}/abschliessen", headers=auth_header(token) + ) + assert response.status_code == 200 + assert response.json()["status"] == "abgeschlossen" + + +@pytest.mark.asyncio +async def test_abbruch_verwirft_fehlbestand_und_positionen( + client, db_session, objekt_mit_position, mitarbeiter_user +): + """U12: abgebrochene Kontrolle - keine Ist-Übernahme, kein Fehlbestand, Objekt + bleibt unkontrolliert (Kontrolle-Datensatz selbst bleibt aber erhalten).""" + objekt, material = objekt_mit_position + token = await login(client, "mitarbeiter1") + start = await _starte(client, token, objekt.id) + kontrolle_id = start.json()["id"] + + await client.put( + f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}", + json={"istmenge": "8"}, + headers=auth_header(token), + ) + response = await client.post( + f"/api/v1/kontrollen/{kontrolle_id}/abbrechen", + json={"grund": "Einsatzalarmierung"}, + headers=auth_header(token), + ) + assert response.status_code == 200 + assert response.json()["status"] == "abgebrochen" + + fb_result = await db_session.execute(select(Fehlbestand).where(Fehlbestand.kontrolle_id == kontrolle_id)) + assert fb_result.scalar_one_or_none() is None + + pos_result = await db_session.execute( + select(Kontrollposition).where(Kontrollposition.kontrolle_id == kontrolle_id) + ) + assert pos_result.scalar_one_or_none() is None + + kontrolle = await db_session.get(Kontrolle, kontrolle_id) + assert kontrolle is not None # Datensatz bleibt erhalten (Prompt 16.6) + assert kontrolle.status == KontrollStatus.abgebrochen + + +@pytest.mark.asyncio +async def test_objekt_sperre_zweiter_zugriff_ist_409( + client, objekt_mit_position, mitarbeiter_user, mitarbeiter2_user +): + objekt, _material = objekt_mit_position + token1 = await login(client, "mitarbeiter1") + token2 = await login(client, "mitarbeiter2") + + erster = await _starte(client, token1, objekt.id) + assert erster.status_code == 201 + + zweiter = await _starte(client, token2, objekt.id) + assert zweiter.status_code == 409 + assert zweiter.json()["detail"]["benutzer"] == "Test Mitarbeiter" + + +@pytest.mark.asyncio +async def test_uebernahme_beendet_alte_kontrolle_und_verwirft_ihren_fehlbestand( + client, db_session, objekt_mit_position, mitarbeiter_user, mitarbeiter2_user +): + objekt, material = objekt_mit_position + token1 = await login(client, "mitarbeiter1") + token2 = await login(client, "mitarbeiter2") + + erster = await _starte(client, token1, objekt.id) + alte_kontrolle_id = erster.json()["id"] + await client.put( + f"/api/v1/kontrollen/{alte_kontrolle_id}/positionen/{material.id}", + json={"istmenge": "5"}, + headers=auth_header(token1), + ) + + uebernahme = await _starte(client, token2, objekt.id, uebernehmen=True) + assert uebernahme.status_code == 201 + neue_kontrolle_id = uebernahme.json()["id"] + assert neue_kontrolle_id != alte_kontrolle_id + + alte_kontrolle = await db_session.get(Kontrolle, alte_kontrolle_id) + assert alte_kontrolle.status == KontrollStatus.abgebrochen + + fb_result = await db_session.execute( + select(Fehlbestand).where(Fehlbestand.kontrolle_id == alte_kontrolle_id) + ) + assert fb_result.scalar_one_or_none() is None + + +@pytest.mark.asyncio +async def test_fremder_benutzer_kann_position_nicht_erfassen( + client, objekt_mit_position, mitarbeiter_user, mitarbeiter2_user +): + objekt, material = objekt_mit_position + token1 = await login(client, "mitarbeiter1") + token2 = await login(client, "mitarbeiter2") + + start = await _starte(client, token1, objekt.id) + kontrolle_id = start.json()["id"] + + response = await client.put( + f"/api/v1/kontrollen/{kontrolle_id}/positionen/{material.id}", + json={"istmenge": "5"}, + headers=auth_header(token2), + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_verschiedene_objekte_parallel_kontrollierbar( + client, + db_session, + hauptserver_id, + objekt_mit_position, + standort_factory, + objekttyp_factory, + material_factory, + mitarbeiter_user, + mitarbeiter2_user, +): + """Sperre gilt pro Objekt, nicht global (Prompt 02.8 Klarstellung).""" + from app.schemas.vorlage import VorlagenpositionCreate + from app.services.objekte import objekt_aus_vorlage + from app.services.vorlagen import erstelle_vorlage + + objekt_a, _material_a = objekt_mit_position + token1 = await login(client, "mitarbeiter1") + token2 = await login(client, "mitarbeiter2") + + objekttyp_b = await objekttyp_factory("Objekttyp-B") + standort_b = await standort_factory("Standort-B") + material_b = await material_factory("Verbandspäckchen") + vorlage_b = await erstelle_vorlage( + db_session, + objekttyp_id=objekttyp_b.id, + name="Vorlage-B", + positionen=[VorlagenpositionCreate(material_id=material_b.id, sollmenge=5)], + ) + objekt_b = await objekt_aus_vorlage( + db_session, + code="OB-01", + name="Objekt B", + standort_id=standort_b.id, + vorlage=vorlage_b, + zustaendiger_server_id=hauptserver_id, + ) + + erster = await _starte(client, token1, objekt_a.id) + assert erster.status_code == 201 + + zweiter = await _starte(client, token2, objekt_b.id) + assert zweiter.status_code == 201