Bisher wurde die Sollmenge einer Objektposition live aus der Vorlagenposition
gelesen (objekt.vorlage_id -> vorlagenposition.sollmenge), Bearbeiten der
Vorlage erzeugte deshalb zwingend eine neue Version - sonst hätte sich jede
Änderung rückwirkend auf alle Objekte ausgewirkt, die diese Vorlage nutzen.
- Neue Spalte objektposition.sollmenge_vorlage (Migration 0020): kopiert die
Sollmenge fest bei Anlage/Duplizieren statt live nachzuschlagen
- sollmenge_effektiv(position) liest jetzt nur noch override/sollmenge_vorlage,
kein DB-Lookup mehr nötig (sollmenge_map() entfernt)
- Neuer Endpoint PUT /vorlagen/{id}/positionen: ersetzt Positionen direkt,
ohne neue Version. Bestehende Objekte unbetroffen, nur neu angelegte nutzen
die aktualisierten Werte (Test: test_aktualisiere_positionen_wirkt_nicht_
rueckwirkend_auf_bestehendes_objekt)
- POST /vorlagen/{id}/neue-version bleibt zusätzlich bestehen (nicht entfernt)
- Frontend: "Neue Version speichern" -> "Positionen speichern" (PUT statt POST)
- 140 Tests grün
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
461 lines
19 KiB
Python
461 lines
19 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.fahrzeugdetails import Fahrzeugdetails
|
|
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 import historie as historie_service
|
|
from app.services.label import generiere_code128_label_pdf
|
|
from app.schemas.objekt import (
|
|
FahrzeugdetailsRead,
|
|
FahrzeugdetailsUpsert,
|
|
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,
|
|
)
|
|
|
|
|
|
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)
|
|
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),
|
|
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")
|
|
|
|
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),
|
|
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),
|
|
current_user=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:
|
|
# Selbstbezug explizit vorab abfangen statt sich auf den DB-CHECK-
|
|
# 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 (z.B. Testsuite) jeden folgenden
|
|
# Request mit PendingRollbackError zum Absturz bringt (Fund 2026-09-05).
|
|
if ziel_id == objekt_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="Zyklus: Objekt kann nicht sich selbst als Fahrzeug zugeordnet werden",
|
|
)
|
|
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",
|
|
)
|
|
|
|
alte_werte = {feld: getattr(objekt, feld) for feld in daten}
|
|
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
|
|
|
|
if daten:
|
|
# Historie-Lücke (Roadmap-Review): Status-/Fahrzeug-Zuordnungsänderungen
|
|
# waren bisher nicht protokolliert, anders als z.B. Geräteinstanz-Status.
|
|
hauptserver = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt))
|
|
await historie_service.log(
|
|
db,
|
|
zustaendiger_server_id=hauptserver.scalar_one(),
|
|
benutzer_id=current_user.id,
|
|
ereignistyp="objekt_geaendert",
|
|
entitaet_typ="objekt",
|
|
entitaet_id=objekt.id,
|
|
alter_wert={k: (v.value if hasattr(v, "value") else v) for k, v in alte_werte.items()},
|
|
neuer_wert={k: (v.value if hasattr(v, "value") else v) for k, v in daten.items()},
|
|
)
|
|
|
|
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)
|
|
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),
|
|
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,
|
|
)
|
|
|
|
|
|
@router.get("/objekte/{objekt_id}/fahrzeugdetails", response_model=FahrzeugdetailsRead)
|
|
async def hole_fahrzeugdetails(
|
|
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
|
) -> Fahrzeugdetails:
|
|
"""Roadmap Phase 3 (Modul Fahrzeuge): 404 statt leerem Default, wenn noch
|
|
keine Fahrzeugdetails erfasst sind - Frontend zeigt dann ein leeres
|
|
Formular statt scheinbar vorhandener Nullwerte."""
|
|
details = await db.get(Fahrzeugdetails, objekt_id)
|
|
if details is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Fahrzeugdetails erfasst")
|
|
return details
|
|
|
|
|
|
@router.put("/objekte/{objekt_id}/fahrzeugdetails", response_model=FahrzeugdetailsRead)
|
|
async def setze_fahrzeugdetails(
|
|
objekt_id: int,
|
|
payload: FahrzeugdetailsUpsert,
|
|
db: AsyncSession = Depends(get_db),
|
|
_=Depends(_materialverantwortliche),
|
|
) -> Fahrzeugdetails:
|
|
"""Upsert (PUT statt PATCH): Fahrzeugdetails existieren typischerweise
|
|
genau einmal je Objekt, ein Formular ersetzt komplett statt Teil-Update -
|
|
einfacher als Anlegen/Ändern zu unterscheiden."""
|
|
objekt = await db.get(Objekt, objekt_id)
|
|
if objekt is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
|
|
|
|
details = await db.get(Fahrzeugdetails, objekt_id)
|
|
if details is None:
|
|
details = Fahrzeugdetails(objekt_id=objekt_id)
|
|
db.add(details)
|
|
for feld, wert in payload.model_dump().items():
|
|
setattr(details, feld, wert)
|
|
await db.flush()
|
|
return details
|