Files
MABEA/backend/app/services/import_export.py
T
patrickandClaude Sonnet 5 6f3589f4e0
CI / backend-tests (push) Successful in 3m16s
CI / frontend-build (push) Successful in 21s
docs(backlog): FOUND-ID-Kollision behoben, FOUND-008 won't-do
Zwei Kacheln waren fälschlich unter denselben IDs geführt wie bereits
vergebene aus der ursprünglichen Master-Prompt-Liste: CSV/XLS-Import
FOUND-007 -> FOUND-014, Fristen-Dienst FOUND-008 -> FOUND-015. Alle
Code-Kommentare/Kachel-Querverweise mitgezogen.

FOUND-008 (Docker/Compose-Setup) als won't-do geschlossen -
widerspricht der bare-metal-Policy für MABEA selbst (CLAUDE.md).
GlitchTip (FOUND-013) nutzt Docker als externe Fremdsoftware auf
einem separaten, optionalen Host - kein Widerspruch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
2026-09-10 21:09:30 +02:00

327 lines
13 KiB
Python

import csv
import io
from datetime import date, datetime, timezone
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.auth import KnotenTyp, Systemknoten
from app.models.geraet_instanz import GeraetInstanz, GeraetStatus
from app.models.objekt import Objekt, ObjektStatus
from app.models.objektposition import Objektposition
from app.models.stammdaten import Material, Objekttyp, Standort
from app.services import historie as historie_service
OBJEKTE_SPALTEN = ["code", "name", "objekttyp", "standort", "status", "fahrzeug_code"]
GERAETE_SPALTEN = [
"objekt_code",
"material_name",
"seriennummer",
"pruefdatum",
"naechste_pruefung",
"status",
"bemerkung",
]
class ImportZeile:
def __init__(self, zeile: int, fehler: str | None = None):
self.zeile = zeile
self.fehler = fehler
async def _hauptserver_id(db: AsyncSession) -> int:
result = await db.execute(select(Systemknoten.id).where(Systemknoten.typ == KnotenTyp.haupt))
return result.scalar_one()
def _parse_datum(wert: str) -> date | None:
wert = (wert or "").strip()
if not wert:
return None
return date.fromisoformat(wert)
async def exportiere_objekte(db: AsyncSession) -> str:
result = await db.execute(select(Objekt))
objekte = result.scalars().all()
objekttypen = {o.id: o for o in (await db.execute(select(Objekttyp))).scalars().all()}
standorte = {s.id: s for s in (await db.execute(select(Standort))).scalars().all()}
codes = {o.id: o.code for o in objekte}
puffer = io.StringIO()
writer = csv.DictWriter(puffer, fieldnames=OBJEKTE_SPALTEN)
writer.writeheader()
for objekt in objekte:
writer.writerow(
{
"code": objekt.code,
"name": objekt.name,
"objekttyp": objekttypen[objekt.objekttyp_id].name,
"standort": standorte[objekt.standort_id].name,
"status": objekt.status.value,
"fahrzeug_code": codes.get(objekt.fahrzeug_id, "") if objekt.fahrzeug_id else "",
}
)
return puffer.getvalue()
async def importiere_objekte(
db: AsyncSession, inhalt: str, *, commit: bool
) -> tuple[int, int, list[ImportZeile]]:
"""FOUND-014: Upsert über `code` - existiert der Code, wird aktualisiert,
sonst neu angelegt (bewusst ohne Beladungsvorlage - Excel-Rucksacklisten
haben selten eine 1:1-Vorlagen-Zuordnung, Positionen kommen ggf. separat
über den Geräte-Import oder die UI)."""
reader = csv.DictReader(io.StringIO(inhalt))
fehler: list[ImportZeile] = []
uebernommen = 0
gesamt = 0
zustaendiger_server_id = await _hauptserver_id(db)
for zeile_nr, zeile in enumerate(reader, start=2):
gesamt += 1
code = (zeile.get("code") or "").strip()
name = (zeile.get("name") or "").strip()
objekttyp_name = (zeile.get("objekttyp") or "").strip()
standort_name = (zeile.get("standort") or "").strip()
status_wert = (zeile.get("status") or ObjektStatus.aktiv.value).strip()
fahrzeug_code = (zeile.get("fahrzeug_code") or "").strip()
if not code or not name or not objekttyp_name or not standort_name:
fehler.append(ImportZeile(zeile_nr, "code, name, objekttyp und standort sind Pflichtfelder"))
continue
try:
status = ObjektStatus(status_wert)
except ValueError:
fehler.append(ImportZeile(zeile_nr, f"Ungültiger Status: {status_wert}"))
continue
objekttyp_treffer = (
(await db.execute(select(Objekttyp).where(Objekttyp.name == objekttyp_name))).scalars().all()
)
if len(objekttyp_treffer) != 1:
fehler.append(
ImportZeile(
zeile_nr,
f"Objekttyp „{objekttyp_name}“ nicht eindeutig gefunden ({len(objekttyp_treffer)} Treffer)",
)
)
continue
standort = (
await db.execute(select(Standort).where(Standort.name == standort_name))
).scalar_one_or_none()
if standort is None:
fehler.append(ImportZeile(zeile_nr, f"Standort „{standort_name}“ nicht gefunden"))
continue
fahrzeug_id = None
if fahrzeug_code:
fahrzeug = (
await db.execute(select(Objekt).where(Objekt.code == fahrzeug_code))
).scalar_one_or_none()
if fahrzeug is None:
fehler.append(ImportZeile(zeile_nr, f"Fahrzeug mit Code „{fahrzeug_code}“ nicht gefunden"))
continue
fahrzeug_id = fahrzeug.id
bestehend = (await db.execute(select(Objekt).where(Objekt.code == code))).scalar_one_or_none()
if not commit:
uebernommen += 1
continue
if bestehend is not None:
bestehend.name = name
bestehend.objekttyp_id = objekttyp_treffer[0].id
bestehend.standort_id = standort.id
bestehend.status = status
bestehend.fahrzeug_id = fahrzeug_id
else:
db.add(
Objekt(
code=code,
name=name,
objekttyp_id=objekttyp_treffer[0].id,
standort_id=standort.id,
status=status,
fahrzeug_id=fahrzeug_id,
zustaendiger_server_id=zustaendiger_server_id,
)
)
uebernommen += 1
if commit and uebernommen:
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=None,
ereignistyp="objekte_importiert",
entitaet_typ="objekt",
entitaet_id="import",
neuer_wert={"uebernommen": uebernommen, "gesamt": gesamt},
)
await db.commit()
return gesamt, uebernommen, fehler
async def exportiere_geraete(db: AsyncSession) -> str:
result = await db.execute(select(GeraetInstanz))
instanzen = result.scalars().all()
positionen = {p.id: p for p in (await db.execute(select(Objektposition))).scalars().all()}
objekte = {o.id: o for o in (await db.execute(select(Objekt))).scalars().all()}
materialien = {m.id: m for m in (await db.execute(select(Material))).scalars().all()}
puffer = io.StringIO()
writer = csv.DictWriter(puffer, fieldnames=GERAETE_SPALTEN)
writer.writeheader()
for instanz in instanzen:
position = positionen[instanz.objektposition_id]
writer.writerow(
{
"objekt_code": objekte[position.objekt_id].code,
"material_name": materialien[position.material_id].name,
"seriennummer": instanz.seriennummer,
"pruefdatum": instanz.pruefdatum.isoformat() if instanz.pruefdatum else "",
"naechste_pruefung": instanz.naechste_pruefung.isoformat() if instanz.naechste_pruefung else "",
"status": instanz.status.value,
"bemerkung": instanz.bemerkung or "",
}
)
return puffer.getvalue()
async def importiere_geraete(
db: AsyncSession, inhalt: str, *, commit: bool
) -> tuple[int, int, list[ImportZeile]]:
"""FOUND-014: Objektposition wird bei Bedarf automatisch angelegt (Sollmenge
= Anzahl importierter Geräte-Zeilen für dieselbe Objekt/Material-Kombination
- Zusatzposition ohne Vorlagenbezug, sollmenge_override ist dort Pflicht).
Duplikat-Erkennung über Seriennummer je Objektposition (Karte 14: gleiche SN
an derselben Position ist bereits per DB-Unique-Sicht ausgeschlossen)."""
reader = csv.DictReader(io.StringIO(inhalt))
rohzeilen = list(enumerate(reader, start=2))
fehler: list[ImportZeile] = []
uebernommen = 0
gesamt = len(rohzeilen)
anzahl_je_position: dict[tuple[int, int], int] = {}
aufgeloest: list[tuple[int, dict, int, int]] = []
for zeile_nr, zeile in rohzeilen:
objekt_code = (zeile.get("objekt_code") or "").strip()
material_name = (zeile.get("material_name") or "").strip()
seriennummer = (zeile.get("seriennummer") or "").strip()
if not objekt_code or not material_name or not seriennummer:
fehler.append(
ImportZeile(zeile_nr, "objekt_code, material_name und seriennummer sind Pflichtfelder")
)
continue
objekt = (await db.execute(select(Objekt).where(Objekt.code == objekt_code))).scalar_one_or_none()
if objekt is None:
fehler.append(ImportZeile(zeile_nr, f"Objekt mit Code „{objekt_code}“ nicht gefunden"))
continue
material_treffer = (
(await db.execute(select(Material).where(Material.name == material_name))).scalars().all()
)
if len(material_treffer) != 1:
fehler.append(
ImportZeile(
zeile_nr,
f"Material „{material_name}“ nicht eindeutig gefunden ({len(material_treffer)} Treffer)",
)
)
continue
try:
pruefdatum = _parse_datum(zeile.get("pruefdatum", ""))
naechste_pruefung = _parse_datum(zeile.get("naechste_pruefung", ""))
except ValueError:
fehler.append(ImportZeile(zeile_nr, "Ungültiges Datumsformat (erwartet YYYY-MM-DD)"))
continue
status_wert = (zeile.get("status") or GeraetStatus.einsatzbereit.value).strip()
try:
geraet_status = GeraetStatus(status_wert)
except ValueError:
fehler.append(ImportZeile(zeile_nr, f"Ungültiger Status: {status_wert}"))
continue
schluessel = (objekt.id, material_treffer[0].id)
anzahl_je_position[schluessel] = anzahl_je_position.get(schluessel, 0) + 1
aufgeloest.append(
(
zeile_nr,
{
"seriennummer": seriennummer,
"pruefdatum": pruefdatum,
"naechste_pruefung": naechste_pruefung,
"status": geraet_status,
"bemerkung": (zeile.get("bemerkung") or "").strip() or None,
},
objekt.id,
material_treffer[0].id,
)
)
if not commit:
return gesamt, len(aufgeloest), fehler
position_cache: dict[tuple[int, int], Objektposition] = {}
for zeile_nr, daten, objekt_id, material_id in aufgeloest:
schluessel = (objekt_id, material_id)
position = position_cache.get(schluessel)
if position is None:
position = (
await db.execute(
select(Objektposition).where(
Objektposition.objekt_id == objekt_id, Objektposition.material_id == material_id
)
)
).scalar_one_or_none()
if position is None:
position = Objektposition(
objekt_id=objekt_id,
material_id=material_id,
sollmenge_override=Decimal(anzahl_je_position[schluessel]),
istmenge=Decimal(0),
)
db.add(position)
await db.flush()
position_cache[schluessel] = position
doppelt = (
await db.execute(
select(GeraetInstanz).where(
GeraetInstanz.objektposition_id == position.id,
GeraetInstanz.seriennummer == daten["seriennummer"],
)
)
).scalar_one_or_none()
if doppelt is not None:
fehler.append(ImportZeile(zeile_nr, f"Seriennummer „{daten['seriennummer']}“ existiert bereits an dieser Position"))
continue
db.add(
GeraetInstanz(
objektposition_id=position.id,
seriennummer=daten["seriennummer"],
pruefdatum=daten["pruefdatum"],
naechste_pruefung=daten["naechste_pruefung"],
status=daten["status"],
bemerkung=daten["bemerkung"],
)
)
uebernommen += 1
if uebernommen:
zustaendiger_server_id = await _hauptserver_id(db)
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=None,
ereignistyp="geraete_importiert",
entitaet_typ="geraet_instanz",
entitaet_id="import",
neuer_wert={"uebernommen": uebernommen, "gesamt": gesamt},
)
await db.commit()
return gesamt, uebernommen, fehler