diff --git a/backend/app/models/objekt.py b/backend/app/models/objekt.py index ccebfd5..9d8bd49 100644 --- a/backend/app/models/objekt.py +++ b/backend/app/models/objekt.py @@ -16,9 +16,8 @@ objekt_status_pg = PgEnum(ObjektStatus, name="objekt_status", create_type=False) class Objekt(Base): - """Minimales Modell für Sprint 1 (nur für Zuständigkeits-Vererbung, Karte 04/E2 - benötigt). Volle Objekt-Verwaltung (Vorlagen-Referenz, Duplizieren, Objektpositionen) - folgt Sprint 2 (Prompt 08/09/10) – dann kommt u.a. `vorlage_id` dazu.""" + """Sprint 2 (Prompt 08/09/10): Objekt referenziert eine konkrete Beladungsvorlagen- + Version. Sollmenge-Auflösung/Duplizieren siehe app/services/objekte.py.""" __tablename__ = "objekt" @@ -26,6 +25,7 @@ class Objekt(Base): code: Mapped[str] = mapped_column(String, unique=True, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False) objekttyp_id: Mapped[int] = mapped_column(ForeignKey("objekttyp.id"), nullable=False) + vorlage_id: Mapped[int | None] = mapped_column(ForeignKey("beladungsvorlage.id")) standort_id: Mapped[int] = mapped_column(ForeignKey("standort.id"), nullable=False) status: Mapped[ObjektStatus] = mapped_column( objekt_status_pg, nullable=False, default=ObjektStatus.aktiv diff --git a/backend/app/models/objektposition.py b/backend/app/models/objektposition.py new file mode 100644 index 0000000..b5e9515 --- /dev/null +++ b/backend/app/models/objektposition.py @@ -0,0 +1,41 @@ +import enum +import uuid +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, ForeignKey, Numeric, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import ENUM as PgEnum, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ObjektpositionStatus(str, enum.Enum): + aktiv = "aktiv" + entfernt = "entfernt" + + +objektposition_status_pg = PgEnum( + ObjektpositionStatus, name="objektposition_status", create_type=False +) + + +class Objektposition(Base): + """Prompt 10: Ist-Bestand + individuelle Soll-Abweichung je Objekt/Material. + UUID-PK (Karte 13: sync-relevant, siehe ergebnisse/20_datenbank_schema.md).""" + + __tablename__ = "objektposition" + __table_args__ = (UniqueConstraint("objekt_id", "material_id"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False) + material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False) + # NULL = Sollmenge folgt dynamisch der aktuellen Vorlagenposition (Prompt 10). + sollmenge_override: Mapped[Decimal | None] = mapped_column(Numeric) + ist_status: Mapped[ObjektpositionStatus] = mapped_column( + objektposition_status_pg, nullable=False, default=ObjektpositionStatus.aktiv + ) + istmenge: Mapped[Decimal] = mapped_column(Numeric, nullable=False, default=0) + seriennummer: Mapped[str | None] = mapped_column(String) + ablaufdatum: Mapped[date | None] = mapped_column(Date) + chargennummer: Mapped[str | None] = mapped_column(String) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4308df7..e5595ad 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,10 @@ dependencies = [ "alembic>=1.13,<1.14", "pydantic-settings>=2.6,<2.7", "passlib[bcrypt]>=1.7,<1.8", + # passlib 1.7.4 liest bcrypt.__about__.__version__, das ab bcrypt 4.1 entfernt wurde + # (Fund im ersten echten CI-Lauf: "password cannot be longer than 72 bytes" ist eine + # irreführende Folgefehlermeldung, echte Ursache ist die AttributeError beim Backend-Check). + "bcrypt>=4.0,<4.1", "pyjwt>=2.9,<2.10", "python-multipart>=0.0.12,<0.1", ] diff --git a/backend/pytest.ini b/backend/pytest.ini index 6ad80c7..86403f5 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,4 +1,10 @@ [pytest] asyncio_mode = auto +# Fund im ersten echten CI-Lauf: der globale engine in conftest.py wird einmal beim Import +# erzeugt, asyncpg-Verbindungen sind an die Event-Loop gebunden, in der sie entstanden sind. +# Ohne session-weiten Loop-Scope erzeugt pytest-asyncio pro Test eine neue Loop -> +# "RuntimeError: Task ... got Future ... attached to a different loop". +asyncio_default_fixture_loop_scope = session +asyncio_default_test_loop_scope = session addopts = --cov=app --cov-report=term-missing --cov-fail-under=50 testpaths = tests