Objekttyp.ist_zugfahrzeug (Migration 0011) grenzt die Fahrzeug-Zuordnung serverseitig auf tatsächliche Zugfahrzeuge ein (422 bei Verstoß), Frontend filtert die Fahrzeug-Dropdowns entsprechend. Neue Dashboard-Endpunkte /dashboard/einsatzbereitschaft und /dashboard/prueftermine plus DashboardPage mit drei Kacheln (Einsatzbereitschaft, Prüftermine, Ablaufdaten). Aktive Mindermengen-Genehmigung zählt als "eingeschränkt einsatzbereit", nicht als voll einsatzbereit (U2/Karte 08). Neue FehlbestandListePage: Fehlbestände eigenständig nachfüllen/Mindermenge genehmigen, ohne komplette Kontrolle des Objekts zu starten (Backend existierte bereits, nur das Frontend fehlte). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
372 lines
15 KiB
Python
372 lines
15 KiB
Python
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,
|
|
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)
|
|
|
|
|
|
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[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,
|
|
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)
|
|
) -> 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,
|
|
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)
|
|
) -> 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")
|
|
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:
|
|
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,
|
|
fahrzeug_id=payload.fahrzeug_id,
|
|
)
|
|
except IntegrityError as exc:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Code bereits vergeben") from exc
|
|
|
|
|
|
@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)
|
|
) -> Objekt:
|
|
"""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
|
|
return objekt
|
|
|
|
|
|
@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,
|
|
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,
|
|
)
|