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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-05 21:42:37 +02:00
co-authored by Claude Sonnet 5
parent ca0befd4a0
commit f7367f6df3
11 changed files with 918 additions and 0 deletions
+6
View File
@@ -6,6 +6,7 @@ from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from app.models.geraet_instanz import GeraetInstanz, GeraetStatus
from app.models.historie import Historie
from app.models.kontrolle import Kontrolle, KontrollStatus, Kontrollposition
from app.models.lager import Bestand, Lagerort, LagerortTyp, Lagerplatz, Materialbewegung
from app.models.lagerbewegung import Lagerbewegung
from app.models.mangel import Mangel, MangelPrioritaet, MangelStatus
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
@@ -41,6 +42,11 @@ __all__ = [
"Kontrolle",
"KontrollStatus",
"Kontrollposition",
"Bestand",
"Lagerort",
"LagerortTyp",
"Lagerplatz",
"Materialbewegung",
"Lagerbewegung",
"Mangel",
"MangelPrioritaet",
+85
View File
@@ -0,0 +1,85 @@
import enum
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import CheckConstraint, ForeignKey, Numeric, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class LagerortTyp(str, enum.Enum):
organisation = "organisation"
standort = "standort"
gebaeude = "gebaeude"
raum = "raum"
lager = "lager"
lagerort_typ_pg = PgEnum(LagerortTyp, name="lagerort_typ", create_type=False)
class Lagerort(Base):
"""WH-001: mehrstufige Hierarchie statt flachem `standort`-Feld (Objekt/
Zustaendigkeit bleiben unverändert an `standort` hängen - Lagerort ist ein
zusätzliches, feineres Konzept für das Warehouse-Epic, kein Ersatz)."""
__tablename__ = "lagerort"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
parent_id: Mapped[int | None] = mapped_column(ForeignKey("lagerort.id", ondelete="SET NULL"))
typ: Mapped[LagerortTyp] = mapped_column(lagerort_typ_pg, nullable=False)
class Lagerplatz(Base):
"""WH-002: unterste Hierarchieebene (Regal/Fach/Box) innerhalb eines
Lagerorts, mit eindeutigem Code als QR-Ziel (analog Objekt-Code, Karte 10)."""
__tablename__ = "lagerplatz"
id: Mapped[int] = mapped_column(primary_key=True)
lagerort_id: Mapped[int] = mapped_column(ForeignKey("lagerort.id"), nullable=False)
code: Mapped[str] = mapped_column(String, unique=True, nullable=False)
bezeichnung: Mapped[str | None] = mapped_column(String)
class Bestand(Base):
"""WH-003: Menge eines Materials an einem konkreten Lagerplatz - feiner als
INV-003 (Standort), ersetzt es nicht (Objekte werden weiterhin über
Lagerbewegung zwischen Standorten verschoben, dies hier ist reine
Verbrauchsmaterial-/Stückzahl-Bestandsführung je Lagerplatz)."""
__tablename__ = "bestand"
__table_args__ = (UniqueConstraint("lagerplatz_id", "material_id", name="bestand_lagerplatz_material_uq"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
lagerplatz_id: Mapped[int] = mapped_column(ForeignKey("lagerplatz.id"), nullable=False)
material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False)
menge: Mapped[Decimal] = mapped_column(Numeric, nullable=False, default=0)
class Materialbewegung(Base):
"""WH-007: lückenloses, append-only Bewegungsprotokoll auf Material-/
Lagerplatz-Ebene (feiner als `Lagerbewegung`, die ganze Objekte zwischen
Standorten verschiebt). Ein-/Auslagerung/Umlagerung erzeugen je genau
einen Eintrag hier."""
__tablename__ = "materialbewegung"
__table_args__ = (
CheckConstraint(
"von_lagerplatz_id IS NOT NULL OR nach_lagerplatz_id IS NOT NULL",
name="materialbewegung_von_oder_nach",
),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False)
menge: Mapped[Decimal] = mapped_column(Numeric, nullable=False)
von_lagerplatz_id: Mapped[int | None] = mapped_column(ForeignKey("lagerplatz.id"))
nach_lagerplatz_id: Mapped[int | None] = mapped_column(ForeignKey("lagerplatz.id"))
benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
zeitpunkt: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
grund: Mapped[str | None] = mapped_column(String)