feat(identity): IDENT-005 Hersteller/Modell-Stammdaten
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:
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user