feat(lager): Warehouse-Epic Kern (WH-001..004+007)
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:
@@ -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")
|
||||||
@@ -12,6 +12,7 @@ from app.api.v1.endpoints import (
|
|||||||
health,
|
health,
|
||||||
historie,
|
historie,
|
||||||
kontrollen,
|
kontrollen,
|
||||||
|
lager,
|
||||||
lagerbewegung,
|
lagerbewegung,
|
||||||
mangel,
|
mangel,
|
||||||
objekte,
|
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(dokument.router, tags=["dokument"])
|
||||||
api_router.include_router(permission.router, tags=["permission"])
|
api_router.include_router(permission.router, tags=["permission"])
|
||||||
api_router.include_router(akte.router, tags=["akte"])
|
api_router.include_router(akte.router, tags=["akte"])
|
||||||
|
api_router.include_router(lager.router, tags=["lager"])
|
||||||
|
|||||||
@@ -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,6 +6,7 @@ from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
|
|||||||
from app.models.geraet_instanz import GeraetInstanz, GeraetStatus
|
from app.models.geraet_instanz import GeraetInstanz, GeraetStatus
|
||||||
from app.models.historie import Historie
|
from app.models.historie import Historie
|
||||||
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
|
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.lagerbewegung import Lagerbewegung
|
||||||
from app.models.mangel import Mangel, MangelPrioritaet, MangelStatus
|
from app.models.mangel import Mangel, MangelPrioritaet, MangelStatus
|
||||||
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
|
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
|
||||||
@@ -41,6 +42,11 @@ __all__ = [
|
|||||||
"Kontrolle",
|
"Kontrolle",
|
||||||
"KontrollStatus",
|
"KontrollStatus",
|
||||||
"Kontrollposition",
|
"Kontrollposition",
|
||||||
|
"Bestand",
|
||||||
|
"Lagerort",
|
||||||
|
"LagerortTyp",
|
||||||
|
"Lagerplatz",
|
||||||
|
"Materialbewegung",
|
||||||
"Lagerbewegung",
|
"Lagerbewegung",
|
||||||
"Mangel",
|
"Mangel",
|
||||||
"MangelPrioritaet",
|
"MangelPrioritaet",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -96,6 +96,40 @@ export interface Lagerbewegung {
|
|||||||
grund: string | null;
|
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 {
|
export interface Fehlbestand {
|
||||||
id: string;
|
id: string;
|
||||||
objekt_id: number;
|
objekt_id: number;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { FachSection } from "./admin/FachSection";
|
|||||||
import { HistorieSection } from "./admin/HistorieSection";
|
import { HistorieSection } from "./admin/HistorieSection";
|
||||||
import { KategorieSection } from "./admin/KategorieSection";
|
import { KategorieSection } from "./admin/KategorieSection";
|
||||||
import { LagerbewegungSection } from "./admin/LagerbewegungSection";
|
import { LagerbewegungSection } from "./admin/LagerbewegungSection";
|
||||||
|
import { LagerSection } from "./admin/LagerSection";
|
||||||
import { RolleSection } from "./admin/RolleSection";
|
import { RolleSection } from "./admin/RolleSection";
|
||||||
import { KontrollverantwortungSection } from "./admin/KontrollverantwortungSection";
|
import { KontrollverantwortungSection } from "./admin/KontrollverantwortungSection";
|
||||||
import { MaterialSection } from "./admin/MaterialSection";
|
import { MaterialSection } from "./admin/MaterialSection";
|
||||||
@@ -34,6 +35,7 @@ type Tab =
|
|||||||
| "zustaendigkeit"
|
| "zustaendigkeit"
|
||||||
| "personal"
|
| "personal"
|
||||||
| "lagerbewegungen"
|
| "lagerbewegungen"
|
||||||
|
| "lager"
|
||||||
| "rollen";
|
| "rollen";
|
||||||
|
|
||||||
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
|
// 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",
|
label: "Lagerbewegungen",
|
||||||
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
|
||||||
},
|
},
|
||||||
|
{ key: "lager", label: "Lager", rollen: ["administration", "materialverantwortlicher"] },
|
||||||
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
|
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -225,6 +228,7 @@ export function AdminPage() {
|
|||||||
{aktiverTab === "lagerbewegungen" && (
|
{aktiverTab === "lagerbewegungen" && (
|
||||||
<LagerbewegungSection objekte={objekte} standorte={standorte} onFehler={setFehler} />
|
<LagerbewegungSection objekte={objekte} standorte={standorte} onFehler={setFehler} />
|
||||||
)}
|
)}
|
||||||
|
{aktiverTab === "lager" && <LagerSection materialien={materialien} onFehler={setFehler} />}
|
||||||
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} onFehler={setFehler} />}
|
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} onFehler={setFehler} />}
|
||||||
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
|
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
|
||||||
{aktiverTab === "personal" && (
|
{aktiverTab === "personal" && (
|
||||||
|
|||||||
@@ -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<LagerortTyp, string> = {
|
||||||
|
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<Lagerort[]>([]);
|
||||||
|
const [lagerplaetze, setLagerplaetze] = useState<Lagerplatz[]>([]);
|
||||||
|
|
||||||
|
const [neuerOrtName, setNeuerOrtName] = useState("");
|
||||||
|
const [neuerOrtTyp, setNeuerOrtTyp] = useState<LagerortTyp>("lager");
|
||||||
|
const [neuerOrtParent, setNeuerOrtParent] = useState<number | "">("");
|
||||||
|
|
||||||
|
const [neuerPlatzOrt, setNeuerPlatzOrt] = useState<number | "">("");
|
||||||
|
const [neuerPlatzCode, setNeuerPlatzCode] = useState("");
|
||||||
|
const [neuerPlatzBezeichnung, setNeuerPlatzBezeichnung] = useState("");
|
||||||
|
|
||||||
|
const [ausgewaehlterPlatz, setAusgewaehlterPlatz] = useState<number | "">("");
|
||||||
|
const [bestand, setBestand] = useState<Bestand[]>([]);
|
||||||
|
const [bewegungen, setBewegungen] = useState<Materialbewegung[]>([]);
|
||||||
|
const [buchungMaterial, setBuchungMaterial] = useState<number | "">("");
|
||||||
|
const [buchungMenge, setBuchungMenge] = useState("");
|
||||||
|
const [buchungGrund, setBuchungGrund] = useState("");
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
try {
|
||||||
|
const [orte, plaetze] = await Promise.all([
|
||||||
|
apiRequest<Lagerort[]>("/lagerorte"),
|
||||||
|
apiRequest<Lagerplatz[]>("/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<Bestand[]>(`/lagerplaetze/${platzId}/bestand`),
|
||||||
|
apiRequest<Materialbewegung[]>(`/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 (
|
||||||
|
<section className="section">
|
||||||
|
<h2>Lager</h2>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginTop: 0 }}>Lagerort anlegen</h3>
|
||||||
|
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Name"
|
||||||
|
value={neuerOrtName}
|
||||||
|
onChange={(e) => setNeuerOrtName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select className="input" value={neuerOrtTyp} onChange={(e) => setNeuerOrtTyp(e.target.value as LagerortTyp)}>
|
||||||
|
{Object.entries(TYP_LABEL).map(([wert, label]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={neuerOrtParent}
|
||||||
|
onChange={(e) => setNeuerOrtParent(Number(e.target.value) || "")}
|
||||||
|
>
|
||||||
|
<option value="">Kein übergeordneter Lagerort</option>
|
||||||
|
{lagerorte.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.name} ({TYP_LABEL[o.typ]})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className="btn btn-primary" onClick={lagerortAnlegen}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="card-list" style={{ marginTop: "0.75rem" }}>
|
||||||
|
{lagerorte.map((o) => (
|
||||||
|
<li key={o.id}>
|
||||||
|
{o.name} <span className="text-muted">({TYP_LABEL[o.typ]}{o.parent_id !== null ? `, unter ${ortName(o.parent_id)}` : ""})</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: "1rem" }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Lagerplatz anlegen</h3>
|
||||||
|
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={neuerPlatzOrt}
|
||||||
|
onChange={(e) => setNeuerPlatzOrt(Number(e.target.value) || "")}
|
||||||
|
>
|
||||||
|
<option value="">Lagerort wählen…</option>
|
||||||
|
{lagerorte.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Code (z. B. REGAL-A1)"
|
||||||
|
value={neuerPlatzCode}
|
||||||
|
onChange={(e) => setNeuerPlatzCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Bezeichnung (optional)"
|
||||||
|
value={neuerPlatzBezeichnung}
|
||||||
|
onChange={(e) => setNeuerPlatzBezeichnung(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={lagerplatzAnlegen}>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="card-list" style={{ marginTop: "0.75rem" }}>
|
||||||
|
{lagerplaetze.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
{p.code} <span className="text-muted">({ortName(p.lagerort_id)}{p.bezeichnung ? `, ${p.bezeichnung}` : ""})</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: "1rem" }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Bestand & Buchungen</h3>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ marginBottom: "0.75rem" }}
|
||||||
|
value={ausgewaehlterPlatz}
|
||||||
|
onChange={(e) => setAusgewaehlterPlatz(Number(e.target.value) || "")}
|
||||||
|
>
|
||||||
|
<option value="">Lagerplatz wählen…</option>
|
||||||
|
{lagerplaetze.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{ausgewaehlterPlatz !== "" && (
|
||||||
|
<>
|
||||||
|
<div className="row" style={{ flexWrap: "wrap", marginBottom: "0.75rem" }}>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={buchungMaterial}
|
||||||
|
onChange={(e) => setBuchungMaterial(Number(e.target.value) || "")}
|
||||||
|
>
|
||||||
|
<option value="">Material wählen…</option>
|
||||||
|
{materialien.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
placeholder="Menge"
|
||||||
|
style={{ width: "6rem" }}
|
||||||
|
value={buchungMenge}
|
||||||
|
onChange={(e) => setBuchungMenge(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Grund (optional)"
|
||||||
|
value={buchungGrund}
|
||||||
|
onChange={(e) => setBuchungGrund(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={() => buchen("einlagern")}>
|
||||||
|
Einlagern
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => buchen("auslagern")}>
|
||||||
|
Auslagern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Bestand</h4>
|
||||||
|
{bestand.length === 0 && <p className="text-muted">Kein Bestand an diesem Lagerplatz.</p>}
|
||||||
|
<ul>
|
||||||
|
{bestand.map((b) => (
|
||||||
|
<li key={b.id}>
|
||||||
|
{materialName(b.material_id)}: {b.menge}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Bewegungen</h4>
|
||||||
|
{bewegungen.length === 0 && <p className="text-muted">Noch keine Bewegungen.</p>}
|
||||||
|
<ul>
|
||||||
|
{bewegungen.map((bw) => (
|
||||||
|
<li key={bw.id}>
|
||||||
|
{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 && <span className="text-muted"> · {bw.grund}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user