From f7367f6df32ef60d8afbffa5bc3350098bc4cc18 Mon Sep 17 00:00:00 2001 From: patrick Date: Sat, 5 Sep 2026 21:42:37 +0200 Subject: [PATCH] feat(lager): Warehouse-Epic Kern (WH-001..004+007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- backend/alembic/versions/0019_lager.py | 86 ++++++ backend/app/api/v1/api.py | 2 + backend/app/api/v1/endpoints/lager.py | 171 ++++++++++++ backend/app/models/__init__.py | 6 + backend/app/models/lager.py | 85 ++++++ backend/app/schemas/lager.py | 67 +++++ backend/app/services/lager.py | 68 +++++ backend/tests/test_lager.py | 84 ++++++ frontend/src/api/types.ts | 34 +++ frontend/src/pages/AdminPage.tsx | 4 + frontend/src/pages/admin/LagerSection.tsx | 311 ++++++++++++++++++++++ 11 files changed, 918 insertions(+) create mode 100644 backend/alembic/versions/0019_lager.py create mode 100644 backend/app/api/v1/endpoints/lager.py create mode 100644 backend/app/models/lager.py create mode 100644 backend/app/schemas/lager.py create mode 100644 backend/app/services/lager.py create mode 100644 backend/tests/test_lager.py create mode 100644 frontend/src/pages/admin/LagerSection.tsx diff --git a/backend/alembic/versions/0019_lager.py b/backend/alembic/versions/0019_lager.py new file mode 100644 index 0000000..640da5f --- /dev/null +++ b/backend/alembic/versions/0019_lager.py @@ -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") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 2e437f0..c27097c 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -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"]) diff --git a/backend/app/api/v1/endpoints/lager.py b/backend/app/api/v1/endpoints/lager.py new file mode 100644 index 0000000..df78e2a --- /dev/null +++ b/backend/app/api/v1/endpoints/lager.py @@ -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()) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c7aae51..3c4144a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/lager.py b/backend/app/models/lager.py new file mode 100644 index 0000000..0674c95 --- /dev/null +++ b/backend/app/models/lager.py @@ -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) diff --git a/backend/app/schemas/lager.py b/backend/app/schemas/lager.py new file mode 100644 index 0000000..22957dd --- /dev/null +++ b/backend/app/schemas/lager.py @@ -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 diff --git a/backend/app/services/lager.py b/backend/app/services/lager.py new file mode 100644 index 0000000..a8fd177 --- /dev/null +++ b/backend/app/services/lager.py @@ -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 diff --git a/backend/tests/test_lager.py b/backend/tests/test_lager.py new file mode 100644 index 0000000..3b62734 --- /dev/null +++ b/backend/tests/test_lager.py @@ -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 diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 684bbfc..5e95434 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -96,6 +96,40 @@ export interface Lagerbewegung { grund: string | null; } +export type LagerortTyp = "organisation" | "standort" | "gebaeude" | "raum" | "lager"; + +export interface Lagerort { + id: number; + name: string; + parent_id: number | null; + typ: LagerortTyp; +} + +export interface Lagerplatz { + id: number; + lagerort_id: number; + code: string; + bezeichnung: string | null; +} + +export interface Bestand { + id: string; + lagerplatz_id: number; + material_id: number; + menge: string; +} + +export interface Materialbewegung { + id: string; + material_id: number; + menge: string; + von_lagerplatz_id: number | null; + nach_lagerplatz_id: number | null; + benutzer_id: number; + zeitpunkt: string; + grund: string | null; +} + export interface Fehlbestand { id: string; objekt_id: number; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 32e4446..20c3560 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -11,6 +11,7 @@ import { FachSection } from "./admin/FachSection"; import { HistorieSection } from "./admin/HistorieSection"; import { KategorieSection } from "./admin/KategorieSection"; import { LagerbewegungSection } from "./admin/LagerbewegungSection"; +import { LagerSection } from "./admin/LagerSection"; import { RolleSection } from "./admin/RolleSection"; import { KontrollverantwortungSection } from "./admin/KontrollverantwortungSection"; import { MaterialSection } from "./admin/MaterialSection"; @@ -34,6 +35,7 @@ type Tab = | "zustaendigkeit" | "personal" | "lagerbewegungen" + | "lager" | "rollen"; // Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte @@ -65,6 +67,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera label: "Lagerbewegungen", rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"], }, + { key: "lager", label: "Lager", rollen: ["administration", "materialverantwortlicher"] }, { key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] }, ]; @@ -225,6 +228,7 @@ export function AdminPage() { {aktiverTab === "lagerbewegungen" && ( )} + {aktiverTab === "lager" && } {aktiverTab === "rollen" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( diff --git a/frontend/src/pages/admin/LagerSection.tsx b/frontend/src/pages/admin/LagerSection.tsx new file mode 100644 index 0000000..cf7fa00 --- /dev/null +++ b/frontend/src/pages/admin/LagerSection.tsx @@ -0,0 +1,311 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { Bestand, Lagerort, LagerortTyp, Lagerplatz, Material, Materialbewegung } from "../../api/types"; + +interface Props { + materialien: Material[]; + onFehler: (text: string) => void; +} + +const TYP_LABEL: Record = { + organisation: "Organisation", + standort: "Standort", + gebaeude: "Gebäude", + raum: "Raum", + lager: "Lager", +}; + +// WH-001..004+007 (Warehouse-Kern): Lagerort-Hierarchie, Lagerplätze, Bestand +// je Lagerplatz, Ein-/Auslagerung. Umlagerung (WH-005) und Inventur (WH-006) +// bewusst als Folgeschritt zurückgestellt (arbeitskacheln/07_warehouse.md). +export function LagerSection({ materialien, onFehler }: Props) { + const [lagerorte, setLagerorte] = useState([]); + const [lagerplaetze, setLagerplaetze] = useState([]); + + const [neuerOrtName, setNeuerOrtName] = useState(""); + const [neuerOrtTyp, setNeuerOrtTyp] = useState("lager"); + const [neuerOrtParent, setNeuerOrtParent] = useState(""); + + const [neuerPlatzOrt, setNeuerPlatzOrt] = useState(""); + const [neuerPlatzCode, setNeuerPlatzCode] = useState(""); + const [neuerPlatzBezeichnung, setNeuerPlatzBezeichnung] = useState(""); + + const [ausgewaehlterPlatz, setAusgewaehlterPlatz] = useState(""); + const [bestand, setBestand] = useState([]); + const [bewegungen, setBewegungen] = useState([]); + const [buchungMaterial, setBuchungMaterial] = useState(""); + const [buchungMenge, setBuchungMenge] = useState(""); + const [buchungGrund, setBuchungGrund] = useState(""); + + async function laden() { + try { + const [orte, plaetze] = await Promise.all([ + apiRequest("/lagerorte"), + apiRequest("/lagerplaetze"), + ]); + setLagerorte(orte); + setLagerplaetze(plaetze); + } catch { + onFehler("Lagerorte/-plätze konnten nicht geladen werden."); + } + } + + useEffect(() => { + laden(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function ladeBestandUndBewegungen(platzId: number) { + try { + const [b, bw] = await Promise.all([ + apiRequest(`/lagerplaetze/${platzId}/bestand`), + apiRequest(`/materialbewegungen?lagerplatz_id=${platzId}`), + ]); + setBestand(b); + setBewegungen(bw); + } catch { + onFehler("Bestand konnte nicht geladen werden."); + } + } + + useEffect(() => { + if (ausgewaehlterPlatz !== "") void ladeBestandUndBewegungen(ausgewaehlterPlatz); + }, [ausgewaehlterPlatz]); + + function ortName(id: number | null): string { + if (id === null) return "–"; + const o = lagerorte.find((x) => x.id === id); + return o ? o.name : `#${id}`; + } + + function materialName(id: number): string { + const m = materialien.find((x) => x.id === id); + return m ? m.name : `#${id}`; + } + + function platzCode(id: number | null): string { + if (id === null) return "extern"; + const p = lagerplaetze.find((x) => x.id === id); + return p ? p.code : `#${id}`; + } + + async function lagerortAnlegen() { + if (!neuerOrtName) { + onFehler("Name eingeben."); + return; + } + try { + await apiRequest("/lagerorte", { + method: "POST", + body: { name: neuerOrtName, typ: neuerOrtTyp, parent_id: neuerOrtParent === "" ? null : neuerOrtParent }, + }); + setNeuerOrtName(""); + setNeuerOrtParent(""); + await laden(); + } catch { + onFehler("Lagerort konnte nicht angelegt werden."); + } + } + + async function lagerplatzAnlegen() { + if (neuerPlatzOrt === "" || !neuerPlatzCode) { + onFehler("Lagerort und Code eingeben."); + return; + } + try { + await apiRequest("/lagerplaetze", { + method: "POST", + body: { lagerort_id: neuerPlatzOrt, code: neuerPlatzCode, bezeichnung: neuerPlatzBezeichnung || null }, + }); + setNeuerPlatzCode(""); + setNeuerPlatzBezeichnung(""); + await laden(); + } catch { + onFehler("Lagerplatz konnte nicht angelegt werden (Code evtl. schon vergeben)."); + } + } + + async function buchen(richtung: "einlagern" | "auslagern") { + if (ausgewaehlterPlatz === "" || buchungMaterial === "" || !buchungMenge) { + onFehler("Material und Menge angeben."); + return; + } + try { + await apiRequest(`/lagerplaetze/${ausgewaehlterPlatz}/${richtung}`, { + method: "POST", + body: { material_id: buchungMaterial, menge: buchungMenge, grund: buchungGrund || null }, + }); + setBuchungMenge(""); + setBuchungGrund(""); + await ladeBestandUndBewegungen(ausgewaehlterPlatz); + } catch { + onFehler(richtung === "auslagern" ? "Nicht genug Bestand am Lagerplatz." : "Buchung fehlgeschlagen."); + } + } + + return ( +
+

Lager

+ +
+

Lagerort anlegen

+
+ setNeuerOrtName(e.target.value)} + /> + + + +
+
    + {lagerorte.map((o) => ( +
  • + {o.name} ({TYP_LABEL[o.typ]}{o.parent_id !== null ? `, unter ${ortName(o.parent_id)}` : ""}) +
  • + ))} +
+
+ +
+

Lagerplatz anlegen

+
+ + setNeuerPlatzCode(e.target.value)} + /> + setNeuerPlatzBezeichnung(e.target.value)} + /> + +
+
    + {lagerplaetze.map((p) => ( +
  • + {p.code} ({ortName(p.lagerort_id)}{p.bezeichnung ? `, ${p.bezeichnung}` : ""}) +
  • + ))} +
+
+ +
+

Bestand & Buchungen

+ + + {ausgewaehlterPlatz !== "" && ( + <> +
+ + setBuchungMenge(e.target.value)} + /> + setBuchungGrund(e.target.value)} + /> + + +
+ +

Bestand

+ {bestand.length === 0 &&

Kein Bestand an diesem Lagerplatz.

} +
    + {bestand.map((b) => ( +
  • + {materialName(b.material_id)}: {b.menge} +
  • + ))} +
+ +

Bewegungen

+ {bewegungen.length === 0 &&

Noch keine Bewegungen.

} +
    + {bewegungen.map((bw) => ( +
  • + {new Date(bw.zeitpunkt).toLocaleString("de-DE")} — {materialName(bw.material_id)}: {bw.menge}{" "} + ({platzCode(bw.von_lagerplatz_id)} → {platzCode(bw.nach_lagerplatz_id)}) + {bw.grund && · {bw.grund}} +
  • + ))} +
+ + )} +
+
+ ); +}