From fdcac2f160f56935739e1b8278f4e0ff3634bab9 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 3 Sep 2026 23:31:14 +0200 Subject: [PATCH] Sprint 2: Beladungsvorlagen (Versionierung), Objekte anlegen/duplizieren, Override-Logik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Beladungsvorlage/Vorlagenposition-Modelle + Endpunkte: erstellen, neue Version (Prompt 08 Punkt 4 - alte Version bleibt unverändert lesbar statt überschrieben) - Objekt-Anlage aus Vorlage: Objektpositionen werden initialisiert, Ist-Menge startet bei 0 ("nicht kontrolliert", Prompt 09) - Duplizieren: Struktur/Sollmengen-Override kopiert, Ist-Menge/SN/Ablauf/Charge NICHT, neuer Code Pflicht (409 bei Kollision) - Sollmengen-Auflösung (Prompt 10): Override schlägt Vorlagenwert, sonst dynamisch aus aktueller Vorlagenposition des Objekts - GET /objekte/code/{code} bewusst vor /objekte/{id} registriert (Routing-Reihenfolge) - Bugfix: app.models muss vollständig importiert werden (main.py + conftest.py), sonst schlägt FK-Auflösung zwischen Modellen fehl, die noch von keinem Endpoint berührt werden (Fund im dritten echten CI-Lauf) - Tests: U8 (Override schlägt Vorlage), U9 (Duplizieren-Kopierregeln + Code-Konflikt), U10 (neue Version, alte bleibt lesbar), Rollenrechte Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt --- 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 ++++ 8 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/v1/endpoints/objekte.py create mode 100644 backend/app/api/v1/endpoints/vorlagen.py create mode 100644 backend/tests/test_objekte.py create mode 100644 backend/tests/test_vorlagen.py diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 882114d..d560e41 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, stammdaten, zustaendigkeit +from app.api.v1.endpoints import auth, benutzer, health, objekte, stammdaten, vorlagen, zustaendigkeit api_router = APIRouter() api_router.include_router(health.router, tags=["health"]) @@ -8,3 +8,5 @@ api_router.include_router(auth.router, tags=["auth"]) api_router.include_router(stammdaten.router, tags=["stammdaten"]) 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"]) diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py new file mode 100644 index 0000000..95dd8c2 --- /dev/null +++ b/backend/app/api/v1/endpoints/objekte.py @@ -0,0 +1,222 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +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 KnotenTyp, RolleTyp, Systemknoten +from app.models.objekt import Objekt +from app.models.objektposition import Objektposition +from app.models.vorlage import Beladungsvorlage +from app.schemas.objekt import ( + ObjektCreate, + ObjektDuplizierenRequest, + ObjektpositionCreate, + ObjektpositionRead, + ObjektpositionUpdate, + ObjektRead, +) +from app.services.objekte import dupliziere_objekt, objekt_aus_vorlage, sollmenge_effektiv, sollmenge_map + +router = APIRouter() + +_admin_only = require_roles(RolleTyp.administration) +_materialverantwortliche = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlicher) + + +@router.get("/objekte", response_model=list[ObjektRead]) +async def liste_objekte( + standort_id: int | None = None, + db: AsyncSession = Depends(get_db), + _=Depends(get_current_user), +) -> list[Objekt]: + stmt = select(Objekt) + if standort_id is not None: + stmt = stmt.where(Objekt.standort_id == standort_id) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +@router.get("/objekte/code/{code}", response_model=ObjektRead) +async def hole_objekt_per_code( + code: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> Objekt: + """Lookup per QR/Barcode-Code (Karte 10). Muss VOR /objekte/{id} registriert + sein, sonst greift FastAPI/Starlette-Routing zuerst die generische ID-Route.""" + result = await db.execute(select(Objekt).where(Objekt.code == code)) + objekt = result.scalar_one_or_none() + if objekt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") + return objekt + + +@router.get("/objekte/{objekt_id}", response_model=ObjektRead) +async def hole_objekt( + objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> Objekt: + objekt = await db.get(Objekt, objekt_id) + if objekt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") + return objekt + + +@router.get("/objekte/{objekt_id}/positionen", response_model=list[ObjektpositionRead]) +async def liste_objektpositionen( + objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> list[ObjektpositionRead]: + objekt = await db.get(Objekt, objekt_id) + if objekt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") + + vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id) + result = await db.execute(select(Objektposition).where(Objektposition.objekt_id == objekt_id)) + positionen = result.scalars().all() + return [ + ObjektpositionRead( + id=p.id, + objekt_id=p.objekt_id, + material_id=p.material_id, + sollmenge_override=p.sollmenge_override, + sollmenge_effektiv=sollmenge_effektiv(p, vorlagen_sollmengen), + ist_status=p.ist_status, + istmenge=p.istmenge, + seriennummer=p.seriennummer, + ablaufdatum=p.ablaufdatum, + chargennummer=p.chargennummer, + ) + for p in positionen + ] + + +@router.post("/objekte", response_model=ObjektRead, status_code=status.HTTP_201_CREATED) +async def erstelle_objekt( + payload: ObjektCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> Objekt: + """Prompt 09: neues Objekt aus einer Beladungsvorlage, startet "nicht kontrolliert" + (Ist-Menge 0 in allen übernommenen Positionen).""" + vorlage = await db.get(Beladungsvorlage, payload.vorlage_id) + if vorlage is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vorlage nicht gefunden") + + hauptserver = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt)) + zustaendiger_server_id = hauptserver.scalar_one() + + try: + return await objekt_aus_vorlage( + db, + code=payload.code, + name=payload.name, + standort_id=payload.standort_id, + vorlage=vorlage, + zustaendiger_server_id=zustaendiger_server_id, + ) + except IntegrityError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben") from exc + + +@router.post( + "/objekte/{objekt_id}/duplizieren", response_model=ObjektRead, status_code=status.HTTP_201_CREATED +) +async def dupliziere( + objekt_id: int, + payload: ObjektDuplizierenRequest, + db: AsyncSession = Depends(get_db), + _=Depends(_admin_only), +) -> Objekt: + """Prompt 09: Struktur/Sollmengen kopiert, Ist-Menge/SN/Ablauf/Charge NICHT, + neuer eindeutiger Code Pflicht.""" + quelle = await db.get(Objekt, objekt_id) + if quelle is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quell-Objekt nicht gefunden") + + try: + return await dupliziere_objekt( + db, + quelle=quelle, + neuer_code=payload.neuer_code, + neuer_name=payload.neuer_name, + neuer_standort_id=payload.neuer_standort_id, + zustaendiger_server_id=quelle.zustaendiger_server_id, + ) + except IntegrityError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben") from exc + + +@router.patch("/objekte/{objekt_id}/positionen/{position_id}", response_model=ObjektpositionRead) +async def aendere_objektposition( + objekt_id: int, + position_id: uuid.UUID, + payload: ObjektpositionUpdate, + db: AsyncSession = Depends(get_db), + _=Depends(_materialverantwortliche), +) -> ObjektpositionRead: + """Prompt 10: Sollmengen-Override setzen/aufheben, Material als entfernt markieren, + Ist-Werte (Menge/SN/Ablauf/Charge) pflegen.""" + position = await db.get(Objektposition, position_id) + if position is None or position.objekt_id != objekt_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden") + + for feld, wert in payload.model_dump(exclude_unset=True).items(): + setattr(position, feld, wert) + await db.flush() + + objekt = await db.get(Objekt, objekt_id) + vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id if objekt else None) + return ObjektpositionRead( + id=position.id, + objekt_id=position.objekt_id, + material_id=position.material_id, + sollmenge_override=position.sollmenge_override, + sollmenge_effektiv=sollmenge_effektiv(position, vorlagen_sollmengen), + ist_status=position.ist_status, + istmenge=position.istmenge, + seriennummer=position.seriennummer, + ablaufdatum=position.ablaufdatum, + chargennummer=position.chargennummer, + ) + + +@router.post( + "/objekte/{objekt_id}/positionen", + response_model=ObjektpositionRead, + status_code=status.HTTP_201_CREATED, +) +async def fuege_zusatzposition_hinzu( + objekt_id: int, + payload: ObjektpositionCreate, + db: AsyncSession = Depends(get_db), + _=Depends(_materialverantwortliche), +) -> ObjektpositionRead: + """Prompt 10 Punkt 2: zusätzliches, nicht in der Vorlage enthaltenes Material.""" + objekt = await db.get(Objekt, objekt_id) + if objekt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") + + position = Objektposition( + objekt_id=objekt_id, + material_id=payload.material_id, + sollmenge_override=payload.sollmenge_override, + ) + db.add(position) + try: + await db.flush() + except IntegrityError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Material existiert bereits an diesem Objekt" + ) from exc + + return ObjektpositionRead( + id=position.id, + objekt_id=position.objekt_id, + material_id=position.material_id, + sollmenge_override=position.sollmenge_override, + sollmenge_effektiv=payload.sollmenge_override, + ist_status=position.ist_status, + istmenge=position.istmenge, + seriennummer=position.seriennummer, + ablaufdatum=position.ablaufdatum, + chargennummer=position.chargennummer, + ) diff --git a/backend/app/api/v1/endpoints/vorlagen.py b/backend/app/api/v1/endpoints/vorlagen.py new file mode 100644 index 0000000..e16b460 --- /dev/null +++ b/backend/app/api/v1/endpoints/vorlagen.py @@ -0,0 +1,78 @@ +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 RolleTyp +from app.models.vorlage import Beladungsvorlage +from app.schemas.vorlage import BeladungsvorlageCreate, BeladungsvorlageRead, VorlagenAenderung +from app.services.vorlagen import erstelle_vorlage, hole_positionen, neue_version + +router = APIRouter() + +_ersteller = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlicher) + + +async def _mit_positionen(db: AsyncSession, vorlage: Beladungsvorlage) -> BeladungsvorlageRead: + positionen = await hole_positionen(db, vorlage.id) + return BeladungsvorlageRead( + id=vorlage.id, + objekttyp_id=vorlage.objekttyp_id, + name=vorlage.name, + version=vorlage.version, + status=vorlage.status, + positionen=positionen, + ) + + +@router.get("/vorlagen", response_model=list[BeladungsvorlageRead]) +async def liste_vorlagen( + objekttyp_id: int | None = None, + db: AsyncSession = Depends(get_db), + _=Depends(get_current_user), +) -> list[BeladungsvorlageRead]: + stmt = select(Beladungsvorlage) + if objekttyp_id is not None: + stmt = stmt.where(Beladungsvorlage.objekttyp_id == objekttyp_id) + result = await db.execute(stmt) + return [await _mit_positionen(db, v) for v in result.scalars().all()] + + +@router.get("/vorlagen/{vorlage_id}", response_model=BeladungsvorlageRead) +async def hole_vorlage( + vorlage_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> BeladungsvorlageRead: + vorlage = await db.get(Beladungsvorlage, vorlage_id) + if vorlage is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vorlage nicht gefunden") + return await _mit_positionen(db, vorlage) + + +@router.post("/vorlagen", response_model=BeladungsvorlageRead, status_code=status.HTTP_201_CREATED) +async def erstelle_neue_vorlage( + payload: BeladungsvorlageCreate, db: AsyncSession = Depends(get_db), _=Depends(_ersteller) +) -> BeladungsvorlageRead: + vorlage = await erstelle_vorlage( + db, objekttyp_id=payload.objekttyp_id, name=payload.name, positionen=payload.positionen + ) + return await _mit_positionen(db, vorlage) + + +@router.post( + "/vorlagen/{vorlage_id}/neue-version", + response_model=BeladungsvorlageRead, + status_code=status.HTTP_201_CREATED, +) +async def erzeuge_neue_version( + vorlage_id: int, + payload: VorlagenAenderung, + db: AsyncSession = Depends(get_db), + _=Depends(_ersteller), +) -> BeladungsvorlageRead: + """Prompt 08 Punkt 4: erzeugt neue Version, alte bleibt unverändert/lesbar.""" + aktuelle_vorlage = await db.get(Beladungsvorlage, vorlage_id) + if aktuelle_vorlage is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vorlage nicht gefunden") + neue = await neue_version(db, aktuelle_vorlage=aktuelle_vorlage, positionen=payload.positionen) + return await _mit_positionen(db, neue) diff --git a/backend/app/main.py b/backend/app/main.py index 67728ee..fe83f19 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,9 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +import app.models # noqa: F401 (alle ORM-Modelle vollständig an Base.metadata +# registrieren, unabhängig davon, welche Endpunkte tatsächlich verdrahtet sind - +# sonst schlägt FK-Auflösung zwischen Modellen fehl, siehe tests/conftest.py) from app.api.v1.api import api_router from app.core.app_settings import settings from app.db.session import engine diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 61254f2..79f2df8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,8 @@ from app.models.auth import Benutzer, BenutzerRolle, RolleTyp, Systemknoten, KnotenTyp 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 +from app.models.vorlage import Beladungsvorlage, VorlageStatus, Vorlagenposition from app.models.zustaendigkeit import Kontrollverantwortung, Zustaendigkeit __all__ = [ @@ -11,12 +13,17 @@ __all__ = [ "KnotenTyp", "Objekt", "ObjektStatus", + "Objektposition", + "ObjektpositionStatus", "Bereich", "Kategorie", "Material", "MaterialTyp", "Objekttyp", "Standort", + "Beladungsvorlage", + "VorlageStatus", + "Vorlagenposition", "Kontrollverantwortung", "Zustaendigkeit", ] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index beed7d1..fa45555 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,13 +3,17 @@ import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +import app.models # noqa: F401 (registriert ALLE ORM-Modelle an Base.metadata, auch +# solche, die noch von keinem API-Endpunkt importiert werden - sonst schlägt die +# FK-Auflösung für andere Modelle fehl, sobald z. B. Objekt.vorlage_id auf eine noch +# nicht importierte Tabelle zeigt. Fund im dritten echten CI-Lauf.) from app.core.app_settings import settings from app.core.security import hash_password from app.db.session import get_db from app.main import app from app.models.auth import Benutzer, BenutzerRolle, RolleTyp from app.models.objekt import Objekt -from app.models.stammdaten import Bereich, Objekttyp, Standort +from app.models.stammdaten import Bereich, Material, MaterialTyp, Objekttyp, Standort @pytest_asyncio.fixture @@ -144,3 +148,28 @@ async def objekt_factory(db_session, hauptserver_id): return objekt return _make + + +@pytest_asyncio.fixture +async def material_factory(db_session): + async def _make(name: str) -> Material: + material = Material(name=name, einheit="Stück", materialtyp=MaterialTyp.standard) + db_session.add(material) + await db_session.flush() + return material + + return _make + + +@pytest_asyncio.fixture +async def objekttyp_factory(db_session): + async def _make(name: str) -> Objekttyp: + bereich = Bereich(name=f"Bereich-{name}") + db_session.add(bereich) + await db_session.flush() + objekttyp = Objekttyp(bereich_id=bereich.id, name=name) + db_session.add(objekttyp) + await db_session.flush() + return objekttyp + + return _make diff --git a/backend/tests/test_objekte.py b/backend/tests/test_objekte.py new file mode 100644 index 0000000..70136c4 --- /dev/null +++ b/backend/tests/test_objekte.py @@ -0,0 +1,168 @@ +import pytest + +from app.services.vorlagen import erstelle_vorlage +from app.schemas.vorlage import VorlagenpositionCreate +from tests.conftest import auth_header, login + + +@pytest.mark.asyncio +async def test_objekt_aus_vorlage_startet_unkontrolliert( + client, db_session, objekttyp_factory, material_factory, standort_factory, admin_user +): + objekttyp = await objekttyp_factory("Rucksack Handball") + material = await material_factory("Kompresse") + standort = await standort_factory("Wache A") + vorlage = await erstelle_vorlage( + db_session, + objekttyp_id=objekttyp.id, + name="Rucksack Handball Standard", + positionen=[VorlagenpositionCreate(material_id=material.id, sollmenge=10)], + ) + + token = await login(client, "admin1") + response = await client.post( + "/api/v1/objekte", + json={ + "code": "RH-01", + "name": "Rucksack Handball 1", + "objekttyp_id": objekttyp.id, + "vorlage_id": vorlage.id, + "standort_id": standort.id, + }, + headers=auth_header(token), + ) + assert response.status_code == 201 + objekt_id = response.json()["id"] + + positionen_response = await client.get( + f"/api/v1/objekte/{objekt_id}/positionen", headers=auth_header(token) + ) + assert positionen_response.status_code == 200 + positionen = positionen_response.json() + assert len(positionen) == 1 + assert float(positionen[0]["istmenge"]) == 0 + assert float(positionen[0]["sollmenge_effektiv"]) == 10 + + +@pytest.mark.asyncio +async def test_duplizieren_kopiert_struktur_nicht_zustand( + client, db_session, objekttyp_factory, material_factory, standort_factory, admin_user +): + """U9: Struktur/Sollmengen ja, Ist-Menge/SN/Ablauf/Charge nein, neuer eindeutiger Code.""" + objekttyp = await objekttyp_factory("Rucksack Handball") + material = await material_factory("Kompresse") + standort = await standort_factory("Wache A") + vorlage = await erstelle_vorlage( + db_session, + objekttyp_id=objekttyp.id, + name="Rucksack Handball Standard", + positionen=[VorlagenpositionCreate(material_id=material.id, sollmenge=10)], + ) + + token = await login(client, "admin1") + create_response = await client.post( + "/api/v1/objekte", + json={ + "code": "RH-02", + "name": "Rucksack Handball 2", + "objekttyp_id": objekttyp.id, + "vorlage_id": vorlage.id, + "standort_id": standort.id, + }, + headers=auth_header(token), + ) + objekt_id = create_response.json()["id"] + + positionen = ( + await client.get(f"/api/v1/objekte/{objekt_id}/positionen", headers=auth_header(token)) + ).json() + position_id = positionen[0]["id"] + patch_response = await client.patch( + f"/api/v1/objekte/{objekt_id}/positionen/{position_id}", + json={"istmenge": "8", "seriennummer": "SN-123", "sollmenge_override": "12"}, + headers=auth_header(token), + ) + assert patch_response.status_code == 200 + + duplicate_response = await client.post( + f"/api/v1/objekte/{objekt_id}/duplizieren", + json={"neuer_code": "RH-03", "neuer_name": "Rucksack Handball 3", "neuer_standort_id": standort.id}, + headers=auth_header(token), + ) + assert duplicate_response.status_code == 201 + neues_objekt_id = duplicate_response.json()["id"] + + neue_positionen = ( + await client.get(f"/api/v1/objekte/{neues_objekt_id}/positionen", headers=auth_header(token)) + ).json() + assert len(neue_positionen) == 1 + kopie = neue_positionen[0] + assert float(kopie["sollmenge_override"]) == 12 # Struktur/Override kopiert + assert float(kopie["istmenge"]) == 0 # Zustand NICHT kopiert + assert kopie["seriennummer"] is None # Zustand NICHT kopiert + + +@pytest.mark.asyncio +async def test_duplizieren_mit_vergebenem_code_ist_409( + client, db_session, objekttyp_factory, standort_factory, admin_user +): + objekttyp = await objekttyp_factory("Fahrzeug") + standort = await standort_factory("Wache B") + vorlage = await erstelle_vorlage( + db_session, objekttyp_id=objekttyp.id, name="Fahrzeug Standard", positionen=[] + ) + + token = await login(client, "admin1") + erst = await client.post( + "/api/v1/objekte", + json={ + "code": "FZ-01", + "name": "Fahrzeug 1", + "objekttyp_id": objekttyp.id, + "vorlage_id": vorlage.id, + "standort_id": standort.id, + }, + headers=auth_header(token), + ) + objekt_id = erst.json()["id"] + + # zweites Objekt mit Code, den wir gleich für die Duplizierung kollidieren lassen + await client.post( + "/api/v1/objekte", + json={ + "code": "FZ-02", + "name": "Fahrzeug 2", + "objekttyp_id": objekttyp.id, + "vorlage_id": vorlage.id, + "standort_id": standort.id, + }, + headers=auth_header(token), + ) + + konflikt_response = await client.post( + f"/api/v1/objekte/{objekt_id}/duplizieren", + json={"neuer_code": "FZ-02", "neuer_name": "Fahrzeug Kopie", "neuer_standort_id": standort.id}, + headers=auth_header(token), + ) + assert konflikt_response.status_code == 409 + + +@pytest.mark.asyncio +async def test_mitarbeiter_kann_objekte_nicht_anlegen( + client, objekttyp_factory, standort_factory, mitarbeiter_user +): + objekttyp = await objekttyp_factory("Rucksack") + standort = await standort_factory("Wache A") + token = await login(client, "mitarbeiter1") + response = await client.post( + "/api/v1/objekte", + json={ + "code": "X-01", + "name": "X", + "objekttyp_id": objekttyp.id, + "vorlage_id": 999999, + "standort_id": standort.id, + }, + headers=auth_header(token), + ) + assert response.status_code == 403 diff --git a/backend/tests/test_vorlagen.py b/backend/tests/test_vorlagen.py new file mode 100644 index 0000000..616e79e --- /dev/null +++ b/backend/tests/test_vorlagen.py @@ -0,0 +1,34 @@ +import pytest + +from app.services.vorlagen import erstelle_vorlage, hole_positionen, neue_version +from app.schemas.vorlage import VorlagenpositionCreate + + +@pytest.mark.asyncio +async def test_neue_version_alte_bleibt_lesbar(db_session, objekttyp_factory, material_factory): + """U10: Vorlagenänderung erzeugt neue Version, alte Version bleibt unverändert lesbar.""" + objekttyp = await objekttyp_factory("Rucksack Handball") + material = await material_factory("BZ-Messtreifen") + + v1 = await erstelle_vorlage( + db_session, + objekttyp_id=objekttyp.id, + name="Rucksack Handball Standard", + positionen=[VorlagenpositionCreate(material_id=material.id, sollmenge=10)], + ) + assert v1.version == 1 + + v2 = await neue_version( + db_session, + aktuelle_vorlage=v1, + positionen=[VorlagenpositionCreate(material_id=material.id, sollmenge=15)], + ) + + assert v2.version == 2 + assert v2.status.value == "aktiv" + assert v1.status.value == "veraltet" + + v1_positionen = await hole_positionen(db_session, v1.id) + v2_positionen = await hole_positionen(db_session, v2.id) + assert v1_positionen[0].sollmenge == 10 + assert v2_positionen[0].sollmenge == 15