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
+1 -1
View File
@@ -137,7 +137,7 @@ vergeben statt geraten.
| IDENT-002 | Identity | Inventarnummer manuell | P0 | S | LOW | IDENT-001 | ✅ (Objektposition-Code, `objektposition.py`) |
| IDENT-003 | Identity | Inventarnummer-Nummernschemata | P1 | M | MEDIUM | IDENT-002 | ⬜ (kein konfigurierbares Schema gefunden, nur fester Code-Zähler) |
| IDENT-004 | Identity | Seriennummer + Eindeutigkeitsprüfung | P0 | S | LOW | IDENT-001 | ✅ (`geraet_instanz.py` Seriennummer-Feld/Unique) |
| IDENT-005 | Identity | Hersteller/Modell-Stammdaten | P1 | S | LOW | IDENT-004 | (kein eigenes Hersteller/Modell-Stammdatenmodell gefunden) |
| IDENT-005 | Identity | Hersteller/Modell-Stammdaten | P1 | S | LOW | IDENT-004 | (`Hersteller`/`Modell`-Tabellen, Migration 0030, `geraet_instanz.modell_id` optional; kein eigenes Admin-UI, nur API) |
| IDENT-006 | Identity | QR-Code-Erzeugung | P0 | S | LOW | IDENT-001 | ✅ (`label.pdf`-Endpunkte für Objekt/Objektposition) |
| IDENT-007 | Identity | QR-Code-Druck (Label) | P0 | S | LOW | IDENT-006 | ✅ (`/objekte/{id}/label.pdf`, `/objektpositionen/{id}/label.pdf`) |
| IDENT-008 | Identity | QR-Scan → Akte öffnen | P0 | M | MEDIUM | IDENT-006, FILE-006 | ✅ (`BarcodeScanner.tsx`, Akte-Route `/akte/objekt/{id}`) |
@@ -0,0 +1,46 @@
"""IDENT-005: Hersteller/Modell-Stammdaten, geraet_instanz.modell_id.
Revision ID: 0030_hersteller_modell
Revises: 0029_dokument_original
Create Date: 2026-09-08
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0030_hersteller_modell"
down_revision: Union[str, None] = "0029_dokument_original"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE hersteller (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
)
"""
)
op.execute(
"""
CREATE TABLE modell (
id SERIAL PRIMARY KEY,
hersteller_id INTEGER NOT NULL REFERENCES hersteller(id),
name TEXT NOT NULL,
UNIQUE (hersteller_id, name)
)
"""
)
op.execute("CREATE INDEX ix_modell_hersteller_id ON modell (hersteller_id)")
op.execute("ALTER TABLE geraet_instanz ADD COLUMN modell_id INTEGER REFERENCES modell(id)")
op.execute("CREATE INDEX ix_geraet_instanz_modell_id ON geraet_instanz (modell_id)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_geraet_instanz_modell_id")
op.execute("ALTER TABLE geraet_instanz DROP COLUMN modell_id")
op.execute("DROP INDEX IF EXISTS ix_modell_hersteller_id")
op.execute("DROP TABLE modell")
op.execute("DROP TABLE hersteller")
@@ -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
+31
View File
@@ -187,3 +187,34 @@ async def test_defekt_kann_fehlbestand_erzeugen(client, objekt_mit_position, mat
"/api/v1/fehlbestaende", params={"objekt_id": objekt.id}, headers=auth_header(token)
)
assert len(nachher.json()) == anzahl_vorher + 1
@pytest.mark.asyncio
async def test_geraet_kann_mit_hersteller_modell_verknuepft_werden(
client, objekt_mit_position, materialverantwortlicher_user, admin_user
):
"""IDENT-005: optionales modell_id-Feld verweist auf Hersteller/Modell-Stammdaten."""
objekt, _material = objekt_mit_position
admin_token = await login(client, "admin1")
token = await login(client, "materialverantwortlicher1")
hersteller = await client.post(
"/api/v1/hersteller", json={"name": "Weinmann"}, headers=auth_header(admin_token)
)
modell = await client.post(
"/api/v1/modelle",
json={"hersteller_id": hersteller.json()["id"], "name": "MEDUMAT Standard2"},
headers=auth_header(admin_token),
)
modell_id = modell.json()["id"]
positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(token))
position_id = positionen.json()[0]["id"]
erstellt = await client.post(
f"/api/v1/objektpositionen/{position_id}/geraete",
json={"seriennummer": "SN-MODELL-1", "modell_id": modell_id},
headers=auth_header(token),
)
assert erstellt.status_code == 201
assert erstellt.json()["modell_id"] == modell_id
+44
View File
@@ -369,3 +369,47 @@ async def test_mitarbeiter_cannot_create_fach(client, mitarbeiter_user, admin_us
headers=auth_header(token),
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_hersteller_modell_lifecycle(client, admin_user, mitarbeiter_user):
"""IDENT-005: Hersteller/Modell-Stammdaten anlegen, per hersteller_id filtern."""
admin_token = await login(client, "admin1")
hersteller = await client.post(
"/api/v1/hersteller", json={"name": "Weinmann"}, headers=auth_header(admin_token)
)
assert hersteller.status_code == 201
hersteller_id = hersteller.json()["id"]
modell = await client.post(
"/api/v1/modelle",
json={"hersteller_id": hersteller_id, "name": "MEDUMAT Standard2"},
headers=auth_header(admin_token),
)
assert modell.status_code == 201
assert modell.json()["hersteller_id"] == hersteller_id
anderer_hersteller = await client.post(
"/api/v1/hersteller", json={"name": "Weinmann"}, headers=auth_header(admin_token)
)
assert anderer_hersteller.status_code == 409
unbekannter_hersteller = await client.post(
"/api/v1/modelle",
json={"hersteller_id": 999999, "name": "Irgendwas"},
headers=auth_header(admin_token),
)
assert unbekannter_hersteller.status_code == 404
mitarbeiter_token = await login(client, "mitarbeiter1")
verweigert = await client.post(
"/api/v1/hersteller", json={"name": "Dräger"}, headers=auth_header(mitarbeiter_token)
)
assert verweigert.status_code == 403
liste = await client.get(
f"/api/v1/modelle?hersteller_id={hersteller_id}", headers=auth_header(mitarbeiter_token)
)
assert len(liste.json()) == 1
assert liste.json()[0]["name"] == "MEDUMAT Standard2"
+12
View File
@@ -41,6 +41,7 @@ export interface GeraetInstanz {
id: string;
objektposition_id: string;
seriennummer: string;
modell_id: number | null;
pruefdatum: string | null;
naechste_pruefung: string | null;
status: "einsatzbereit" | "defekt" | "in_reparatur";
@@ -282,6 +283,17 @@ export interface Kategorie {
ueberkategorie_id: number | null;
}
export interface Hersteller {
id: number;
name: string;
}
export interface Modell {
id: number;
hersteller_id: number;
name: string;
}
export interface Objekttyp {
id: number;
bereich_id: number;