feat(lager): Warehouse-Epic Kern (WH-001..004+007)
CI / backend-tests (push) Successful in 1m56s
CI / frontend-build (push) Successful in 18s

Lagerort-Hierarchie (organisation/standort/gebaeude/raum/lager, self-referenzierend),
Lagerplätze mit eindeutigem Code, Bestand je Lagerplatz+Material, Ein-/Auslagerung
mit lückenlosem Materialbewegungsprotokoll (append-only, analog Lagerbewegung
auf Objekt-Ebene, hier aber Material-/Lagerplatz-fein).

- Migration 0019: 4 neue Tabellen (lagerort, lagerplatz, bestand, materialbewegung)
- Backend: CRUD Lagerort/Lagerplatz, Bestandsabfrage, Ein-/Auslagern-Endpunkte,
  Negativbestand vorab abgefangen (kein DB-Constraint-Exception-Pfad, gleiches
  Muster wie objekte.py-Fix vom 2026-09-05)
- Frontend: neuer Admin-Tab "Lager" (Lagerort/-platz-Verwaltung, Bestand+Buchung
  je Lagerplatz)
- 2 neue Tests, 139 Tests weiterhin grün

WH-005 (Umlagerung) und WH-006 (Inventur) bewusst zurückgestellt - Kern zuerst,
laut Nutzer-Entscheidung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-05 21:42:37 +02:00
co-authored by Claude Sonnet 5
parent ca0befd4a0
commit f7367f6df3
11 changed files with 918 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
"""Warehouse-Epic Kern (WH-001..004+007): Lagerort-Hierarchie, Lagerplätze,
Bestand je Lagerplatz, Materialbewegungsprotokoll. Additiv, ersetzt nicht
`standort`/`Lagerbewegung` (Objekt-Ebene bleibt unverändert).
Revision ID: 0019_lager
Revises: 0018_funkkenner
Create Date: 2026-09-05
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0019_lager"
down_revision: Union[str, None] = "0018_funkkenner"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"CREATE TYPE lagerort_typ AS ENUM ('organisation', 'standort', 'gebaeude', 'raum', 'lager')"
)
op.execute(
"""
CREATE TABLE lagerort (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES lagerort(id) ON DELETE SET NULL,
typ lagerort_typ NOT NULL
)
"""
)
op.execute("CREATE INDEX idx_lagerort_parent ON lagerort (parent_id)")
op.execute(
"""
CREATE TABLE lagerplatz (
id SERIAL PRIMARY KEY,
lagerort_id INTEGER NOT NULL REFERENCES lagerort(id),
code TEXT UNIQUE NOT NULL,
bezeichnung TEXT
)
"""
)
op.execute("CREATE INDEX idx_lagerplatz_lagerort ON lagerplatz (lagerort_id)")
op.execute(
"""
CREATE TABLE bestand (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lagerplatz_id INTEGER NOT NULL REFERENCES lagerplatz(id),
material_id INTEGER NOT NULL REFERENCES material(id),
menge NUMERIC NOT NULL DEFAULT 0,
CONSTRAINT bestand_lagerplatz_material_uq UNIQUE (lagerplatz_id, material_id)
)
"""
)
op.execute("CREATE INDEX idx_bestand_material ON bestand (material_id)")
op.execute(
"""
CREATE TABLE materialbewegung (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
material_id INTEGER NOT NULL REFERENCES material(id),
menge NUMERIC NOT NULL,
von_lagerplatz_id INTEGER REFERENCES lagerplatz(id),
nach_lagerplatz_id INTEGER REFERENCES lagerplatz(id),
benutzer_id INTEGER NOT NULL REFERENCES benutzer(id),
zeitpunkt TIMESTAMPTZ NOT NULL,
grund TEXT,
CONSTRAINT materialbewegung_von_oder_nach
CHECK (von_lagerplatz_id IS NOT NULL OR nach_lagerplatz_id IS NOT NULL)
)
"""
)
op.execute("CREATE INDEX idx_materialbewegung_material ON materialbewegung (material_id)")
op.execute("CREATE INDEX idx_materialbewegung_von ON materialbewegung (von_lagerplatz_id)")
op.execute("CREATE INDEX idx_materialbewegung_nach ON materialbewegung (nach_lagerplatz_id)")
def downgrade() -> None:
op.execute("DROP TABLE materialbewegung")
op.execute("DROP TABLE bestand")
op.execute("DROP TABLE lagerplatz")
op.execute("DROP TABLE lagerort")
op.execute("DROP TYPE lagerort_typ")
+2
View File
@@ -12,6 +12,7 @@ from app.api.v1.endpoints import (
health,
historie,
kontrollen,
lager,
lagerbewegung,
mangel,
objekte,
@@ -42,3 +43,4 @@ api_router.include_router(lagerbewegung.router, tags=["lagerbewegung"])
api_router.include_router(dokument.router, tags=["dokument"])
api_router.include_router(permission.router, tags=["permission"])
api_router.include_router(akte.router, tags=["akte"])
api_router.include_router(lager.router, tags=["lager"])
+171
View File
@@ -0,0 +1,171 @@
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.lager import Bestand, Lagerort, Lagerplatz, Materialbewegung
from app.schemas.lager import (
BestandRead,
EinAuslagernRequest,
LagerortCreate,
LagerortRead,
LagerortUpdate,
LagerplatzCreate,
LagerplatzRead,
MaterialbewegungRead,
)
from app.services.lager import NichtGenugBestandError, auslagern, einlagern
router = APIRouter()
_admin_only = require_roles(RolleTyp.administration)
_materialverantwortliche = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlicher)
# -- Lagerort (WH-001) ---------------------------------------------------------
@router.get("/lagerorte", response_model=list[LagerortRead])
async def liste_lagerorte(db: AsyncSession = Depends(get_db), _=Depends(get_current_user)) -> list[Lagerort]:
result = await db.execute(select(Lagerort))
return list(result.scalars().all())
@router.post("/lagerorte", response_model=LagerortRead, status_code=status.HTTP_201_CREATED)
async def erstelle_lagerort(
payload: LagerortCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only)
) -> Lagerort:
if payload.parent_id is not None and await db.get(Lagerort, payload.parent_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Übergeordneter Lagerort nicht gefunden")
lagerort = Lagerort(**payload.model_dump())
db.add(lagerort)
await db.flush()
return lagerort
@router.patch("/lagerorte/{lagerort_id}", response_model=LagerortRead)
async def aendere_lagerort(
lagerort_id: int, payload: LagerortUpdate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only)
) -> Lagerort:
lagerort = await db.get(Lagerort, lagerort_id)
if lagerort is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerort nicht gefunden")
daten = payload.model_dump(exclude_unset=True)
if daten.get("parent_id") == lagerort_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Lagerort kann nicht sein eigener Elternknoten sein")
for feld, wert in daten.items():
setattr(lagerort, feld, wert)
await db.flush()
return lagerort
# -- Lagerplatz (WH-002) --------------------------------------------------------
@router.get("/lagerplaetze", response_model=list[LagerplatzRead])
async def liste_lagerplaetze(
lagerort_id: int | None = None, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> list[Lagerplatz]:
stmt = select(Lagerplatz)
if lagerort_id is not None:
stmt = stmt.where(Lagerplatz.lagerort_id == lagerort_id)
result = await db.execute(stmt)
return list(result.scalars().all())
@router.post("/lagerplaetze", response_model=LagerplatzRead, status_code=status.HTTP_201_CREATED)
async def erstelle_lagerplatz(
payload: LagerplatzCreate, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche)
) -> Lagerplatz:
if await db.get(Lagerort, payload.lagerort_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerort nicht gefunden")
vorhanden = await db.execute(select(Lagerplatz).where(Lagerplatz.code == payload.code))
if vorhanden.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben")
lagerplatz = Lagerplatz(**payload.model_dump())
db.add(lagerplatz)
await db.flush()
return lagerplatz
# -- Bestand (WH-003) + Ein-/Auslagerung (WH-004) -------------------------------
@router.get("/lagerplaetze/{lagerplatz_id}/bestand", response_model=list[BestandRead])
async def hole_bestand(
lagerplatz_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> list[Bestand]:
if await db.get(Lagerplatz, lagerplatz_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerplatz nicht gefunden")
result = await db.execute(select(Bestand).where(Bestand.lagerplatz_id == lagerplatz_id))
return list(result.scalars().all())
@router.post(
"/lagerplaetze/{lagerplatz_id}/einlagern",
response_model=MaterialbewegungRead,
status_code=status.HTTP_201_CREATED,
)
async def route_einlagern(
lagerplatz_id: int,
payload: EinAuslagernRequest,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
) -> Materialbewegung:
if await db.get(Lagerplatz, lagerplatz_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerplatz nicht gefunden")
return await einlagern(
db,
lagerplatz_id=lagerplatz_id,
material_id=payload.material_id,
menge=payload.menge,
benutzer_id=current_user.id,
grund=payload.grund,
)
@router.post(
"/lagerplaetze/{lagerplatz_id}/auslagern",
response_model=MaterialbewegungRead,
status_code=status.HTTP_201_CREATED,
)
async def route_auslagern(
lagerplatz_id: int,
payload: EinAuslagernRequest,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
) -> Materialbewegung:
if await db.get(Lagerplatz, lagerplatz_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lagerplatz nicht gefunden")
try:
return await auslagern(
db,
lagerplatz_id=lagerplatz_id,
material_id=payload.material_id,
menge=payload.menge,
benutzer_id=current_user.id,
grund=payload.grund,
)
except NichtGenugBestandError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nicht genug Bestand am Lagerplatz") from exc
# -- Materialbewegung (WH-007) --------------------------------------------------
@router.get("/materialbewegungen", response_model=list[MaterialbewegungRead])
async def liste_materialbewegungen(
lagerplatz_id: int | None = None,
material_id: int | None = None,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
) -> list[Materialbewegung]:
stmt = select(Materialbewegung)
if lagerplatz_id is not None:
stmt = stmt.where(
(Materialbewegung.von_lagerplatz_id == lagerplatz_id)
| (Materialbewegung.nach_lagerplatz_id == lagerplatz_id)
)
if material_id is not None:
stmt = stmt.where(Materialbewegung.material_id == material_id)
stmt = stmt.order_by(Materialbewegung.zeitpunkt.desc()).limit(200)
result = await db.execute(stmt)
return list(result.scalars().all())
+6
View File
@@ -6,6 +6,7 @@ from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from app.models.geraet_instanz import GeraetInstanz, GeraetStatus
from app.models.historie import Historie
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.lager import Bestand, Lagerort, LagerortTyp, Lagerplatz, Materialbewegung
from app.models.lagerbewegung import Lagerbewegung
from app.models.mangel import Mangel, MangelPrioritaet, MangelStatus
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
@@ -41,6 +42,11 @@ __all__ = [
"Kontrolle",
"KontrollStatus",
"Kontrollposition",
"Bestand",
"Lagerort",
"LagerortTyp",
"Lagerplatz",
"Materialbewegung",
"Lagerbewegung",
"Mangel",
"MangelPrioritaet",
+85
View File
@@ -0,0 +1,85 @@
import enum
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import CheckConstraint, ForeignKey, Numeric, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class LagerortTyp(str, enum.Enum):
organisation = "organisation"
standort = "standort"
gebaeude = "gebaeude"
raum = "raum"
lager = "lager"
lagerort_typ_pg = PgEnum(LagerortTyp, name="lagerort_typ", create_type=False)
class Lagerort(Base):
"""WH-001: mehrstufige Hierarchie statt flachem `standort`-Feld (Objekt/
Zustaendigkeit bleiben unverändert an `standort` hängen - Lagerort ist ein
zusätzliches, feineres Konzept für das Warehouse-Epic, kein Ersatz)."""
__tablename__ = "lagerort"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("lagerort.id", ondelete="SET NULL"))
typ: Mapped[LagerortTyp] = mapped_column(lagerort_typ_pg, nullable=False)
class Lagerplatz(Base):
"""WH-002: unterste Hierarchieebene (Regal/Fach/Box) innerhalb eines
Lagerorts, mit eindeutigem Code als QR-Ziel (analog Objekt-Code, Karte 10)."""
__tablename__ = "lagerplatz"
id: Mapped[int] = mapped_column(primary_key=True)
lagerort_id: Mapped[int] = mapped_column(ForeignKey("lagerort.id"), nullable=False)
code: Mapped[str] = mapped_column(String, unique=True, nullable=False)
bezeichnung: Mapped[str | None] = mapped_column(String)
class Bestand(Base):
"""WH-003: Menge eines Materials an einem konkreten Lagerplatz - feiner als
INV-003 (Standort), ersetzt es nicht (Objekte werden weiterhin über
Lagerbewegung zwischen Standorten verschoben, dies hier ist reine
Verbrauchsmaterial-/Stückzahl-Bestandsführung je Lagerplatz)."""
__tablename__ = "bestand"
__table_args__ = (UniqueConstraint("lagerplatz_id", "material_id", name="bestand_lagerplatz_material_uq"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
lagerplatz_id: Mapped[int] = mapped_column(ForeignKey("lagerplatz.id"), nullable=False)
material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False)
menge: Mapped[Decimal] = mapped_column(Numeric, nullable=False, default=0)
class Materialbewegung(Base):
"""WH-007: lückenloses, append-only Bewegungsprotokoll auf Material-/
Lagerplatz-Ebene (feiner als `Lagerbewegung`, die ganze Objekte zwischen
Standorten verschiebt). Ein-/Auslagerung/Umlagerung erzeugen je genau
einen Eintrag hier."""
__tablename__ = "materialbewegung"
__table_args__ = (
CheckConstraint(
"von_lagerplatz_id IS NOT NULL OR nach_lagerplatz_id IS NOT NULL",
name="materialbewegung_von_oder_nach",
),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False)
menge: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
von_lagerplatz_id: Mapped[int | None] = mapped_column(ForeignKey("lagerplatz.id"))
nach_lagerplatz_id: Mapped[int | None] = mapped_column(ForeignKey("lagerplatz.id"))
benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
zeitpunkt: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
grund: Mapped[str | None] = mapped_column(String)
+67
View File
@@ -0,0 +1,67 @@
import uuid
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
from app.models.lager import LagerortTyp
class LagerortCreate(BaseModel):
name: str
parent_id: int | None = None
typ: LagerortTyp
class LagerortUpdate(BaseModel):
name: str | None = None
parent_id: int | None = None
typ: LagerortTyp | None = None
class LagerortRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
parent_id: int | None
typ: LagerortTyp
class LagerplatzCreate(BaseModel):
lagerort_id: int
code: str
bezeichnung: str | None = None
class LagerplatzRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
lagerort_id: int
code: str
bezeichnung: str | None
class BestandRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
lagerplatz_id: int
material_id: int
menge: Decimal
class EinAuslagernRequest(BaseModel):
material_id: int
menge: Decimal
grund: str | None = None
class MaterialbewegungRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
material_id: int
menge: Decimal
von_lagerplatz_id: int | None
nach_lagerplatz_id: int | None
benutzer_id: int
zeitpunkt: datetime
grund: str | None
+68
View File
@@ -0,0 +1,68 @@
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.lager import Bestand, Materialbewegung
class NichtGenugBestandError(Exception):
pass
async def _bestand_zeile(db: AsyncSession, lagerplatz_id: int, material_id: int) -> Bestand | None:
result = await db.execute(
select(Bestand).where(Bestand.lagerplatz_id == lagerplatz_id, Bestand.material_id == material_id)
)
return result.scalar_one_or_none()
async def einlagern(
db: AsyncSession, *, lagerplatz_id: int, material_id: int, menge: Decimal, benutzer_id: int, grund: str | None
) -> Materialbewegung:
"""WH-004: Bestand zubuchen (Upsert, falls noch keine Zeile existiert)."""
bestand = await _bestand_zeile(db, lagerplatz_id, material_id)
if bestand is None:
bestand = Bestand(lagerplatz_id=lagerplatz_id, material_id=material_id, menge=Decimal(0))
db.add(bestand)
bestand.menge += menge
bewegung = Materialbewegung(
material_id=material_id,
menge=menge,
von_lagerplatz_id=None,
nach_lagerplatz_id=lagerplatz_id,
benutzer_id=benutzer_id,
zeitpunkt=datetime.now(timezone.utc),
grund=grund,
)
db.add(bewegung)
await db.flush()
return bewegung
async def auslagern(
db: AsyncSession, *, lagerplatz_id: int, material_id: int, menge: Decimal, benutzer_id: int, grund: str | None
) -> Materialbewegung:
"""WH-004: Bestand abbuchen - negativer Bestand ist ausgeschlossen (Akzeptanz-
kriterium der Kachel), vorab geprüft statt DB-Constraint (gleiches Muster
wie objekte.py Selbstbezug-Check, Fund 2026-09-05: DB-Exception mitten in
einer geteilten Session hinterlässt die Session im Zustand DEACTIVE)."""
bestand = await _bestand_zeile(db, lagerplatz_id, material_id)
if bestand is None or bestand.menge < menge:
raise NichtGenugBestandError
bestand.menge -= menge
bewegung = Materialbewegung(
material_id=material_id,
menge=menge,
von_lagerplatz_id=lagerplatz_id,
nach_lagerplatz_id=None,
benutzer_id=benutzer_id,
zeitpunkt=datetime.now(timezone.utc),
grund=grund,
)
db.add(bewegung)
await db.flush()
return bewegung
+84
View File
@@ -0,0 +1,84 @@
import pytest
from tests.conftest import auth_header, login
@pytest.mark.asyncio
async def test_lagerort_hierarchie_und_lagerplatz(client, admin_user):
token = await login(client, "admin1")
wurzel = await client.post(
"/api/v1/lagerorte", json={"name": "Hauptwache", "typ": "standort"}, headers=auth_header(token)
)
assert wurzel.status_code == 201
kind = await client.post(
"/api/v1/lagerorte",
json={"name": "Lager Halle 1", "typ": "lager", "parent_id": wurzel.json()["id"]},
headers=auth_header(token),
)
assert kind.status_code == 201
assert kind.json()["parent_id"] == wurzel.json()["id"]
platz = await client.post(
"/api/v1/lagerplaetze",
json={"lagerort_id": kind.json()["id"], "code": "REGAL-A1", "bezeichnung": "Regal A, Fach 1"},
headers=auth_header(token),
)
assert platz.status_code == 201
doppelt = await client.post(
"/api/v1/lagerplaetze",
json={"lagerort_id": kind.json()["id"], "code": "REGAL-A1"},
headers=auth_header(token),
)
assert doppelt.status_code == 409
@pytest.mark.asyncio
async def test_ein_und_auslagerung_veraendert_bestand_korrekt(client, admin_user, material_factory):
token = await login(client, "admin1")
material = await material_factory("Verbandpäckchen")
lagerort = await client.post(
"/api/v1/lagerorte", json={"name": "Lager X", "typ": "lager"}, headers=auth_header(token)
)
platz = await client.post(
"/api/v1/lagerplaetze",
json={"lagerort_id": lagerort.json()["id"], "code": "PLATZ-1"},
headers=auth_header(token),
)
platz_id = platz.json()["id"]
ein = await client.post(
f"/api/v1/lagerplaetze/{platz_id}/einlagern",
json={"material_id": material.id, "menge": "10"},
headers=auth_header(token),
)
assert ein.status_code == 201
assert ein.json()["von_lagerplatz_id"] is None
assert ein.json()["nach_lagerplatz_id"] == platz_id
bestand = await client.get(f"/api/v1/lagerplaetze/{platz_id}/bestand", headers=auth_header(token))
assert bestand.json()[0]["menge"] == "10"
aus = await client.post(
f"/api/v1/lagerplaetze/{platz_id}/auslagern",
json={"material_id": material.id, "menge": "3"},
headers=auth_header(token),
)
assert aus.status_code == 201
bestand2 = await client.get(f"/api/v1/lagerplaetze/{platz_id}/bestand", headers=auth_header(token))
assert bestand2.json()[0]["menge"] == "7"
zu_viel = await client.post(
f"/api/v1/lagerplaetze/{platz_id}/auslagern",
json={"material_id": material.id, "menge": "999"},
headers=auth_header(token),
)
assert zu_viel.status_code == 409
bewegungen = await client.get(
f"/api/v1/materialbewegungen?lagerplatz_id={platz_id}", headers=auth_header(token)
)
assert len(bewegungen.json()) == 2