feat(identity): IDENT-005 Hersteller/Modell-Stammdaten
CI / backend-tests (push) Failing after 2m16s
CI / frontend-build (push) Successful in 26s

Neue Tabellen hersteller/modell (Migration 0030), CRUD-Endpunkte
(/hersteller, /modelle) nach bestehendem Kategorie-Muster, inkl. 409 bei
Duplikaten (IntegrityError abgefangen wie in personal.py). geraet_instanz
bekommt optionales modell_id-Feld - verknüpft eine konkrete Geräteinstanz
(mit Seriennummer, IDENT-004) mit ihrem Hersteller/Modell.

Ergänzt Material.hersteller (bleibt unverändert als Freitext) um eine
normalisierte Variante gezielt für Geräte mit Seriennummer - keine
Datenmigration bestehender Freitext-Werte (Scope dieser Kachel).

Bewusst kein neues Admin-UI-Screen für die Pflege (Scope-Grenze der
Kachel) - Hersteller/Modell aktuell nur über die API verwaltbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-08 14:51:15 +02:00
co-authored by Claude Sonnet 5
parent ae09433c11
commit 2dd7e574e6
12 changed files with 249 additions and 6 deletions
@@ -62,7 +62,9 @@ async def erstelle_geraet(
if position is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden")
try:
return await erstelle(db, objektposition_id=position_id, seriennummer=payload.seriennummer)
return await erstelle(
db, objektposition_id=position_id, seriennummer=payload.seriennummer, modell_id=payload.modell_id
)
except SeriennummerBereitsVergebenError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Seriennummer an dieser Position bereits vergeben"
+61 -1
View File
@@ -1,12 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, require_roles, require_roles_or_permission
from app.db.session import get_db
from app.models.auth import RolleTyp
from app.models.objekt import Objekt
from app.models.stammdaten import Bereich, Fach, Kategorie, Material, Objekttyp, Standort
from app.models.stammdaten import Bereich, Fach, Hersteller, Kategorie, Material, Modell, Objekttyp, Standort
from app.models.vorlage import Beladungsvorlage, Vorlagenposition
from app.services.material_merge import finde_konflikte, fuehre_zusammen, hole_verwendung
from app.schemas.stammdaten import (
@@ -18,9 +19,13 @@ from app.schemas.stammdaten import (
FachRead,
FachUpdate,
FachVerwendungObjekt,
HerstellerCreate,
HerstellerRead,
KategorieCreate,
KategorieRead,
KategorieUpdate,
ModellCreate,
ModellRead,
MaterialCreate,
MaterialRead,
MaterialUpdate,
@@ -75,6 +80,61 @@ async def aendere_bereich(
return bereich
# -- Hersteller/Modell (IDENT-005) ---------------------------------------------
@router.get("/hersteller", response_model=list[HerstellerRead])
async def liste_hersteller(
db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> list[Hersteller]:
result = await db.execute(select(Hersteller))
return list(result.scalars().all())
@router.post("/hersteller", response_model=HerstellerRead, status_code=status.HTTP_201_CREATED)
async def erstelle_hersteller(
payload: HerstellerCreate,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> Hersteller:
hersteller = Hersteller(**payload.model_dump())
db.add(hersteller)
try:
await db.flush()
except IntegrityError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Hersteller existiert bereits") from exc
return hersteller
@router.get("/modelle", response_model=list[ModellRead])
async def liste_modelle(
hersteller_id: int | None = None,
db: AsyncSession = Depends(get_db),
_=Depends(get_current_user),
) -> list[Modell]:
stmt = select(Modell)
if hersteller_id is not None:
stmt = stmt.where(Modell.hersteller_id == hersteller_id)
result = await db.execute(stmt)
return list(result.scalars().all())
@router.post("/modelle", response_model=ModellRead, status_code=status.HTTP_201_CREATED)
async def erstelle_modell(
payload: ModellCreate,
db: AsyncSession = Depends(get_db),
_=Depends(require_roles(RolleTyp.administration)),
) -> Modell:
if await db.get(Hersteller, payload.hersteller_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
modell = Modell(**payload.model_dump())
db.add(modell)
try:
await db.flush()
except IntegrityError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Modell existiert bereits") from exc
return modell
# -- Kategorie ------------------------------------------------------------------
@router.get("/kategorien", response_model=list[KategorieRead])
+3
View File
@@ -32,6 +32,9 @@ class GeraetInstanz(Base):
UUID(as_uuid=True), ForeignKey("objektposition.id"), nullable=False
)
seriennummer: Mapped[str] = mapped_column(String, nullable=False)
# IDENT-005: welches Hersteller-Modell dieses konkrete Gerät ist - optional,
# nicht jede Geräteinstanz muss das pflegen.
modell_id: Mapped[int | None] = mapped_column(ForeignKey("modell.id"))
pruefdatum: Mapped[date | None] = mapped_column(Date)
naechste_pruefung: Mapped[date | None] = mapped_column(Date)
status: Mapped[GeraetStatus] = mapped_column(
+23 -1
View File
@@ -1,6 +1,6 @@
import enum
from sqlalchemy import Boolean, ForeignKey, String, Text
from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM as PgEnum
from sqlalchemy.orm import Mapped, mapped_column
@@ -67,6 +67,28 @@ class Fach(Base):
sortierung: Mapped[int] = mapped_column(default=0)
class Hersteller(Base):
"""IDENT-005: normalisierte Hersteller-Stammdaten statt Freitext - macht
Geräte/Fahrzeuge herstellerübergreifend auswertbar/filterbar (z.B. "alle
Weinmann-Geräte prüfpflichtig"). Ergänzt Material.hersteller (Freitext,
bleibt unverändert), betrifft hier gezielt Geräteinstanzen mit
Seriennummer (IDENT-004)."""
__tablename__ = "hersteller"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String, unique=True, nullable=False)
class Modell(Base):
__tablename__ = "modell"
__table_args__ = (UniqueConstraint("hersteller_id", "name"),)
id: Mapped[int] = mapped_column(primary_key=True)
hersteller_id: Mapped[int] = mapped_column(ForeignKey("hersteller.id"), nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
class Material(Base):
__tablename__ = "material"
+2
View File
@@ -8,6 +8,7 @@ from app.models.geraet_instanz import GeraetStatus
class GeraetInstanzCreate(BaseModel):
seriennummer: str
modell_id: int | None = None
class GeraetInstanzRead(BaseModel):
@@ -15,6 +16,7 @@ class GeraetInstanzRead(BaseModel):
id: uuid.UUID
objektposition_id: uuid.UUID
seriennummer: str
modell_id: int | None
pruefdatum: date | None
naechste_pruefung: date | None
status: GeraetStatus
+19
View File
@@ -35,6 +35,25 @@ class KategorieUpdate(BaseModel):
ueberkategorie_id: int | None = None
class HerstellerCreate(BaseModel):
name: str
class HerstellerRead(HerstellerCreate):
model_config = ConfigDict(from_attributes=True)
id: int
class ModellCreate(BaseModel):
hersteller_id: int
name: str
class ModellRead(ModellCreate):
model_config = ConfigDict(from_attributes=True)
id: int
class StandortCreate(BaseModel):
name: str
adresse: str | None = None
+4 -2
View File
@@ -25,7 +25,9 @@ class SeriennummerBereitsVergebenError(Exception):
pass
async def erstelle(db: AsyncSession, *, objektposition_id, seriennummer: str) -> GeraetInstanz:
async def erstelle(
db: AsyncSession, *, objektposition_id, seriennummer: str, modell_id: int | None = None
) -> GeraetInstanz:
# Explizit vorab prüfen statt sich auf den UNIQUE-Constraint zu verlassen:
# ein Flush-Fehlschlag hier hinterlässt die Session im Zustand DEACTIVE
# (SQLAlchemy 2.0), was in einer über mehrere Requests geteilten Session
@@ -39,7 +41,7 @@ async def erstelle(db: AsyncSession, *, objektposition_id, seriennummer: str) ->
)
if result.scalar_one_or_none() is not None:
raise SeriennummerBereitsVergebenError
instanz = GeraetInstanz(objektposition_id=objektposition_id, seriennummer=seriennummer)
instanz = GeraetInstanz(objektposition_id=objektposition_id, seriennummer=seriennummer, modell_id=modell_id)
db.add(instanz)
await db.flush()
return instanz