diff --git a/arbeitskacheln/00_index.md b/arbeitskacheln/00_index.md index d182ae9..e5f98f6 100644 --- a/arbeitskacheln/00_index.md +++ b/arbeitskacheln/00_index.md @@ -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}`) | diff --git a/backend/alembic/versions/0030_hersteller_modell.py b/backend/alembic/versions/0030_hersteller_modell.py new file mode 100644 index 0000000..60bf1aa --- /dev/null +++ b/backend/alembic/versions/0030_hersteller_modell.py @@ -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") diff --git a/backend/app/api/v1/endpoints/geraet_instanz.py b/backend/app/api/v1/endpoints/geraet_instanz.py index a3f227d..b07221b 100644 --- a/backend/app/api/v1/endpoints/geraet_instanz.py +++ b/backend/app/api/v1/endpoints/geraet_instanz.py @@ -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" diff --git a/backend/app/api/v1/endpoints/stammdaten.py b/backend/app/api/v1/endpoints/stammdaten.py index 2f8000d..88ca18b 100644 --- a/backend/app/api/v1/endpoints/stammdaten.py +++ b/backend/app/api/v1/endpoints/stammdaten.py @@ -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]) diff --git a/backend/app/models/geraet_instanz.py b/backend/app/models/geraet_instanz.py index 205bc89..2f9c4c8 100644 --- a/backend/app/models/geraet_instanz.py +++ b/backend/app/models/geraet_instanz.py @@ -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( diff --git a/backend/app/models/stammdaten.py b/backend/app/models/stammdaten.py index d162d2a..f0cde2b 100644 --- a/backend/app/models/stammdaten.py +++ b/backend/app/models/stammdaten.py @@ -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" diff --git a/backend/app/schemas/geraet_instanz.py b/backend/app/schemas/geraet_instanz.py index a018042..e41c906 100644 --- a/backend/app/schemas/geraet_instanz.py +++ b/backend/app/schemas/geraet_instanz.py @@ -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 diff --git a/backend/app/schemas/stammdaten.py b/backend/app/schemas/stammdaten.py index 6bd974c..57b8f0a 100644 --- a/backend/app/schemas/stammdaten.py +++ b/backend/app/schemas/stammdaten.py @@ -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 diff --git a/backend/app/services/geraet_instanz.py b/backend/app/services/geraet_instanz.py index 2845b38..22d560c 100644 --- a/backend/app/services/geraet_instanz.py +++ b/backend/app/services/geraet_instanz.py @@ -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 diff --git a/backend/tests/test_geraet_instanz.py b/backend/tests/test_geraet_instanz.py index 9e84029..7272628 100644 --- a/backend/tests/test_geraet_instanz.py +++ b/backend/tests/test_geraet_instanz.py @@ -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 diff --git a/backend/tests/test_stammdaten.py b/backend/tests/test_stammdaten.py index 288c371..3d9f629 100644 --- a/backend/tests/test_stammdaten.py +++ b/backend/tests/test_stammdaten.py @@ -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" diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e1b6408..a09b6ec 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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;