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-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-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-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-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-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}`) | | 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: if position is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden")
try: 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: except SeriennummerBereitsVergebenError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Seriennummer an dieser Position bereits vergeben" 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 fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, require_roles, require_roles_or_permission from app.api.deps import get_current_user, require_roles, require_roles_or_permission
from app.db.session import get_db from app.db.session import get_db
from app.models.auth import RolleTyp from app.models.auth import RolleTyp
from app.models.objekt import Objekt 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.models.vorlage import Beladungsvorlage, Vorlagenposition
from app.services.material_merge import finde_konflikte, fuehre_zusammen, hole_verwendung from app.services.material_merge import finde_konflikte, fuehre_zusammen, hole_verwendung
from app.schemas.stammdaten import ( from app.schemas.stammdaten import (
@@ -18,9 +19,13 @@ from app.schemas.stammdaten import (
FachRead, FachRead,
FachUpdate, FachUpdate,
FachVerwendungObjekt, FachVerwendungObjekt,
HerstellerCreate,
HerstellerRead,
KategorieCreate, KategorieCreate,
KategorieRead, KategorieRead,
KategorieUpdate, KategorieUpdate,
ModellCreate,
ModellRead,
MaterialCreate, MaterialCreate,
MaterialRead, MaterialRead,
MaterialUpdate, MaterialUpdate,
@@ -75,6 +80,61 @@ async def aendere_bereich(
return 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 ------------------------------------------------------------------ # -- Kategorie ------------------------------------------------------------------
@router.get("/kategorien", response_model=list[KategorieRead]) @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 UUID(as_uuid=True), ForeignKey("objektposition.id"), nullable=False
) )
seriennummer: Mapped[str] = mapped_column(String, 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) pruefdatum: Mapped[date | None] = mapped_column(Date)
naechste_pruefung: Mapped[date | None] = mapped_column(Date) naechste_pruefung: Mapped[date | None] = mapped_column(Date)
status: Mapped[GeraetStatus] = mapped_column( status: Mapped[GeraetStatus] = mapped_column(
+23 -1
View File
@@ -1,6 +1,6 @@
import enum 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.dialects.postgresql import ENUM as PgEnum
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -67,6 +67,28 @@ class Fach(Base):
sortierung: Mapped[int] = mapped_column(default=0) 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): class Material(Base):
__tablename__ = "material" __tablename__ = "material"
+2
View File
@@ -8,6 +8,7 @@ from app.models.geraet_instanz import GeraetStatus
class GeraetInstanzCreate(BaseModel): class GeraetInstanzCreate(BaseModel):
seriennummer: str seriennummer: str
modell_id: int | None = None
class GeraetInstanzRead(BaseModel): class GeraetInstanzRead(BaseModel):
@@ -15,6 +16,7 @@ class GeraetInstanzRead(BaseModel):
id: uuid.UUID id: uuid.UUID
objektposition_id: uuid.UUID objektposition_id: uuid.UUID
seriennummer: str seriennummer: str
modell_id: int | None
pruefdatum: date | None pruefdatum: date | None
naechste_pruefung: date | None naechste_pruefung: date | None
status: GeraetStatus status: GeraetStatus
+19
View File
@@ -35,6 +35,25 @@ class KategorieUpdate(BaseModel):
ueberkategorie_id: int | None = None 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): class StandortCreate(BaseModel):
name: str name: str
adresse: str | None = None adresse: str | None = None
+4 -2
View File
@@ -25,7 +25,9 @@ class SeriennummerBereitsVergebenError(Exception):
pass 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: # Explizit vorab prüfen statt sich auf den UNIQUE-Constraint zu verlassen:
# ein Flush-Fehlschlag hier hinterlässt die Session im Zustand DEACTIVE # ein Flush-Fehlschlag hier hinterlässt die Session im Zustand DEACTIVE
# (SQLAlchemy 2.0), was in einer über mehrere Requests geteilten Session # (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: if result.scalar_one_or_none() is not None:
raise SeriennummerBereitsVergebenError 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) db.add(instanz)
await db.flush() await db.flush()
return instanz 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) "/api/v1/fehlbestaende", params={"objekt_id": objekt.id}, headers=auth_header(token)
) )
assert len(nachher.json()) == anzahl_vorher + 1 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), headers=auth_header(token),
) )
assert response.status_code == 403 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; id: string;
objektposition_id: string; objektposition_id: string;
seriennummer: string; seriennummer: string;
modell_id: number | null;
pruefdatum: string | null; pruefdatum: string | null;
naechste_pruefung: string | null; naechste_pruefung: string | null;
status: "einsatzbereit" | "defekt" | "in_reparatur"; status: "einsatzbereit" | "defekt" | "in_reparatur";
@@ -282,6 +283,17 @@ export interface Kategorie {
ueberkategorie_id: number | null; ueberkategorie_id: number | null;
} }
export interface Hersteller {
id: number;
name: string;
}
export interface Modell {
id: number;
hersteller_id: number;
name: string;
}
export interface Objekttyp { export interface Objekttyp {
id: number; id: number;
bereich_id: number; bereich_id: number;