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
327 lines
13 KiB
Python
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
|