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, Objekttyp 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, ObjektUpdate, ) from app.services.objekte import ( dupliziere_objekt, letzte_kontrollen_map, naechster_freier_code, objekt_aus_vorlage, sollmenge_effektiv, sollmenge_map, ) def _objekt_read(objekt: Objekt, letzte_kontrolle_am=None) -> ObjektRead: return ObjektRead( id=objekt.id, code=objekt.code, name=objekt.name, objekttyp_id=objekt.objekttyp_id, vorlage_id=objekt.vorlage_id, standort_id=objekt.standort_id, status=objekt.status, fahrzeug_id=objekt.fahrzeug_id, letzte_kontrolle_am=letzte_kontrolle_am, ) router = APIRouter() _admin_only = require_roles(RolleTyp.administration) _materialverantwortliche = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlicher) async def _pruefe_zugfahrzeug(db: AsyncSession, fahrzeug_id: int) -> Objekt: """Karte 11 Nachtrag (Option A+C): Fahrzeug-Zuordnung nur auf Objekte, deren Objekttyp ist_zugfahrzeug=True ist - grenzt Rucksäcke/Geräte serverseitig aus, unabhängig vom (evtl. umgangenen) Frontend-Dropdown.""" ziel = await db.get(Objekt, fahrzeug_id) if ziel is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fahrzeug nicht gefunden") objekttyp = await db.get(Objekttyp, ziel.objekttyp_id) if objekttyp is None or not objekttyp.ist_zugfahrzeug: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Zielobjekt ist kein Zugfahrzeug (Objekttyp.ist_zugfahrzeug=False)", ) return ziel @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[ObjektRead]: stmt = select(Objekt) if standort_id is not None: stmt = stmt.where(Objekt.standort_id == standort_id) result = await db.execute(stmt) objekte = list(result.scalars().all()) letzte_map = await letzte_kontrollen_map(db, [o.id for o in objekte]) return [_objekt_read(o, letzte_map.get(o.id)) for o in objekte] @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) ) -> ObjektRead: """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") letzte_map = await letzte_kontrollen_map(db, [objekt.id]) return _objekt_read(objekt, letzte_map.get(objekt.id)) @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, pruefintervall_monate=position.pruefintervall_monate, ) @router.get("/objekte/{objekt_id}", response_model=ObjektRead) async def hole_objekt( objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) ) -> ObjektRead: objekt = await db.get(Objekt, objekt_id) if objekt is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") letzte_map = await letzte_kontrollen_map(db, [objekt.id]) return _objekt_read(objekt, letzte_map.get(objekt.id)) @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, pruefintervall_monate=p.pruefintervall_monate, ) 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) ) -> ObjektRead: """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") if payload.fahrzeug_id is not None: await _pruefe_zugfahrzeug(db, payload.fahrzeug_id) hauptserver = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt)) zustaendiger_server_id = hauptserver.scalar_one() try: objekt = await objekt_aus_vorlage( db, code=payload.code, name=payload.name, standort_id=payload.standort_id, vorlage=vorlage, zustaendiger_server_id=zustaendiger_server_id, fahrzeug_id=payload.fahrzeug_id, ) except IntegrityError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben") from exc return _objekt_read(objekt) @router.patch("/objekte/{objekt_id}", response_model=ObjektRead) async def aendere_objekt( objekt_id: int, payload: ObjektUpdate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) ) -> ObjektRead: """Fahrzeug-Zuordnung nachträglich setzen/lösen: jedes Objekt gehört zu jedem Zeitpunkt höchstens einem Fahrzeug (1:n). Zielobjekt-Existenz und direkter Zyklus (A↔B) werden geprüft, mehrstufige Ketten sind fachlich nicht vorgesehen und bewusst nicht geprüft (Aufwand/Nutzen).""" objekt = await db.get(Objekt, objekt_id) if objekt is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") daten = payload.model_dump(exclude_unset=True) ziel_id = daten.get("fahrzeug_id") if ziel_id is not None: ziel = await _pruefe_zugfahrzeug(db, ziel_id) if ziel.fahrzeug_id == objekt_id: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Zyklus: Zielobjekt ist bereits diesem Objekt zugeordnet", ) for feld, wert in daten.items(): setattr(objekt, feld, wert) try: await db.flush() except IntegrityError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Ungültige Zuordnung") from exc letzte_map = await letzte_kontrollen_map(db, [objekt.id]) return _objekt_read(objekt, letzte_map.get(objekt.id)) @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), ) -> ObjektRead: """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: neues_objekt = 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 return _objekt_read(neues_objekt) @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, pruefintervall_monate=position.pruefintervall_monate, ) @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, pruefintervall_monate=position.pruefintervall_monate, )