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,
|
||||
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"])
|
||||
|
||||
@@ -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.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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user