import uuid from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, require_roles from app.db.session import get_db from app.models.auth import KnotenTyp, RolleTyp, Systemknoten from app.models.objekt import Objekt from app.models.objektposition import Objektposition from app.models.stammdaten import Material from app.models.vorlage import Beladungsvorlage from app.services.label import generiere_code128_label_pdf from app.schemas.objekt import ( ObjektCreate, ObjektDuplizierenRequest, ObjektpositionCreate, ObjektpositionRead, ObjektpositionUpdate, ObjektRead, ) from app.services.objekte import ( dupliziere_objekt, naechster_freier_code, objekt_aus_vorlage, sollmenge_effektiv, sollmenge_map, ) router = APIRouter() _admin_only = require_roles(RolleTyp.administration) _materialverantwortliche = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlicher) @router.get("/objekte", response_model=list[ObjektRead]) async def liste_objekte( standort_id: int | None = None, db: AsyncSession = Depends(get_db), _=Depends(get_current_user), ) -> list[Objekt]: stmt = select(Objekt) if standort_id is not None: stmt = stmt.where(Objekt.standort_id == standort_id) result = await db.execute(stmt) return list(result.scalars().all()) @router.get("/objekte/code/{code}", response_model=ObjektRead) async def hole_objekt_per_code( code: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) ) -> Objekt: """Lookup per QR/Barcode-Code (Karte 10). Muss VOR /objekte/{id} registriert sein, sonst greift FastAPI/Starlette-Routing zuerst die generische ID-Route.""" result = await db.execute(select(Objekt).where(Objekt.code == code)) objekt = result.scalar_one_or_none() if objekt is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") return objekt @router.get("/objekte/naechster-code") async def naechster_objekt_code( praefix: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) ) -> dict[str, str]: """Code-Vorschlag beim Anlegen/Duplizieren (Konzept-Ergänzung Karte 10/Prompt 09). Vorschlag ist überschreibbar, Eindeutigkeit erzwingt weiterhin objekt.code UNIQUE.""" return {"code": await naechster_freier_code(db, Objekt.code, praefix)} @router.get("/objektpositionen/naechster-code") async def naechster_objektposition_code( praefix: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) ) -> dict[str, str]: """Code-Vorschlag für Geräte-Exemplare (objektposition.code, eigener Nummernkreis je Präfix, unabhängig von objekt.code).""" return {"code": await naechster_freier_code(db, Objektposition.code, praefix)} @router.get("/objektpositionen/code/{code}", response_model=ObjektpositionRead) async def hole_objektposition_per_code( code: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) ) -> ObjektpositionRead: """Geräte-Scan (Konzept-Ergänzung Karte 10/Prompt 09): Code128-Etikett am Exemplar identifiziert die Objektposition direkt, unabhängig vom Objekt-Code - löst SN-vorbelegte Kontroll-Erfassung ohne Umweg über Objekt-Scan aus.""" result = await db.execute(select(Objektposition).where(Objektposition.code == code)) position = result.scalar_one_or_none() if position is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden") objekt = await db.get(Objekt, position.objekt_id) vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id if objekt else None) return ObjektpositionRead( id=position.id, objekt_id=position.objekt_id, material_id=position.material_id, sollmenge_override=position.sollmenge_override, sollmenge_effektiv=sollmenge_effektiv(position, vorlagen_sollmengen), ist_status=position.ist_status, istmenge=position.istmenge, seriennummer=position.seriennummer, ablaufdatum=position.ablaufdatum, chargennummer=position.chargennummer, code=position.code, ) @router.get("/objekte/{objekt_id}", response_model=ObjektRead) async def hole_objekt( objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) ) -> Objekt: objekt = await db.get(Objekt, objekt_id) if objekt is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") return objekt @router.get("/objekte/{objekt_id}/label.pdf") async def objekt_label( objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) ) -> Response: """Druckfertiges Code128-Label (Karte 10): Code + Objektname als Klartext- Fallback. Vektor-PDF, skaliert verlustfrei auf jede Labelgröße beim Druck.""" objekt = await db.get(Objekt, objekt_id) if objekt is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") pdf = generiere_code128_label_pdf(objekt.code, objekt.name) return Response( content=pdf, media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="{objekt.code}.pdf"'}, ) @router.get("/objektpositionen/{position_id}/label.pdf") async def objektposition_label( position_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) ) -> Response: """Geräte-Etikett (Karte 10): eigener Code je Exemplar, Klartext-Zusatz ist der Materialname (Objektname wäre bei mehreren Geräten am selben Objekt nicht unterscheidend genug).""" position = await db.get(Objektposition, position_id) if position is None or position.code is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kein Geräte-Code vergeben") material = await db.get(Material, position.material_id) pdf = generiere_code128_label_pdf(position.code, material.name if material else None) return Response( content=pdf, media_type="application/pdf", headers={"Content-Disposition": f'inline; filename="{position.code}.pdf"'}, ) @router.get("/objekte/{objekt_id}/positionen", response_model=list[ObjektpositionRead]) async def liste_objektpositionen( objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) ) -> list[ObjektpositionRead]: objekt = await db.get(Objekt, objekt_id) if objekt is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id) result = await db.execute(select(Objektposition).where(Objektposition.objekt_id == objekt_id)) positionen = result.scalars().all() return [ ObjektpositionRead( id=p.id, objekt_id=p.objekt_id, material_id=p.material_id, sollmenge_override=p.sollmenge_override, sollmenge_effektiv=sollmenge_effektiv(p, vorlagen_sollmengen), ist_status=p.ist_status, istmenge=p.istmenge, seriennummer=p.seriennummer, ablaufdatum=p.ablaufdatum, chargennummer=p.chargennummer, code=p.code, ) for p in positionen ] @router.post("/objekte", response_model=ObjektRead, status_code=status.HTTP_201_CREATED) async def erstelle_objekt( payload: ObjektCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) ) -> Objekt: """Prompt 09: neues Objekt aus einer Beladungsvorlage, startet "nicht kontrolliert" (Ist-Menge 0 in allen übernommenen Positionen).""" vorlage = await db.get(Beladungsvorlage, payload.vorlage_id) if vorlage is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vorlage nicht gefunden") hauptserver = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt)) zustaendiger_server_id = hauptserver.scalar_one() try: return await objekt_aus_vorlage( db, code=payload.code, name=payload.name, standort_id=payload.standort_id, vorlage=vorlage, zustaendiger_server_id=zustaendiger_server_id, ) except IntegrityError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben") from exc @router.post( "/objekte/{objekt_id}/duplizieren", response_model=ObjektRead, status_code=status.HTTP_201_CREATED ) async def dupliziere( objekt_id: int, payload: ObjektDuplizierenRequest, db: AsyncSession = Depends(get_db), _=Depends(_admin_only), ) -> Objekt: """Prompt 09: Struktur/Sollmengen kopiert, Ist-Menge/SN/Ablauf/Charge NICHT, neuer eindeutiger Code Pflicht.""" quelle = await db.get(Objekt, objekt_id) if quelle is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quell-Objekt nicht gefunden") try: return await dupliziere_objekt( db, quelle=quelle, neuer_code=payload.neuer_code, neuer_name=payload.neuer_name, neuer_standort_id=payload.neuer_standort_id, zustaendiger_server_id=quelle.zustaendiger_server_id, ) except IntegrityError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben") from exc @router.patch("/objekte/{objekt_id}/positionen/{position_id}", response_model=ObjektpositionRead) async def aendere_objektposition( objekt_id: int, position_id: uuid.UUID, payload: ObjektpositionUpdate, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche), ) -> ObjektpositionRead: """Prompt 10: Sollmengen-Override setzen/aufheben, Material als entfernt markieren, Ist-Werte (Menge/SN/Ablauf/Charge) pflegen.""" position = await db.get(Objektposition, position_id) if position is None or position.objekt_id != objekt_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden") for feld, wert in payload.model_dump(exclude_unset=True).items(): setattr(position, feld, wert) await db.flush() objekt = await db.get(Objekt, objekt_id) vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id if objekt else None) return ObjektpositionRead( id=position.id, objekt_id=position.objekt_id, material_id=position.material_id, sollmenge_override=position.sollmenge_override, sollmenge_effektiv=sollmenge_effektiv(position, vorlagen_sollmengen), ist_status=position.ist_status, istmenge=position.istmenge, seriennummer=position.seriennummer, ablaufdatum=position.ablaufdatum, chargennummer=position.chargennummer, code=position.code, ) @router.post( "/objekte/{objekt_id}/positionen", response_model=ObjektpositionRead, status_code=status.HTTP_201_CREATED, ) async def fuege_zusatzposition_hinzu( objekt_id: int, payload: ObjektpositionCreate, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche), ) -> ObjektpositionRead: """Prompt 10 Punkt 2: zusätzliches, nicht in der Vorlage enthaltenes Material.""" objekt = await db.get(Objekt, objekt_id) if objekt is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") position = Objektposition( objekt_id=objekt_id, material_id=payload.material_id, sollmenge_override=payload.sollmenge_override, ) db.add(position) try: await db.flush() except IntegrityError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Material existiert bereits an diesem Objekt" ) from exc return ObjektpositionRead( id=position.id, objekt_id=position.objekt_id, material_id=position.material_id, sollmenge_override=position.sollmenge_override, sollmenge_effektiv=payload.sollmenge_override, ist_status=position.ist_status, istmenge=position.istmenge, seriennummer=position.seriennummer, ablaufdatum=position.ablaufdatum, chargennummer=position.chargennummer, code=position.code, )