Files
patrickandClaude Sonnet 5 f7367f6df3
CI / backend-tests (push) Successful in 1m56s
CI / frontend-build (push) Successful in 18s
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
2026-09-05 21:42:37 +02:00

86 lines
3.6 KiB
Python

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)