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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user