Sprint 2: Beladungsvorlagen (Versionierung), Objekte anlegen/duplizieren, Override-Logik
CI / backend-tests (push) Successful in 29s

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
2026-09-03 23:31:14 +02:00
co-authored by Claude Sonnet 5
parent 67cebbbf48
commit fdcac2f160
8 changed files with 545 additions and 2 deletions
+3 -1
View File
@@ -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"])
+222
View File
@@ -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,
)
+78
View File
@@ -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)
+3
View File
@@ -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
+7
View File
@@ -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",
]
+30 -1
View File
@@ -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
+168
View File
@@ -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
+34
View File
@@ -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