V 2.0.2 - Grobsortierung Live-Streaming

- Grobsortierung zeigt jetzt live den Fortschritt (wie Feinsortierung)
- Neuer Streaming-Endpoint /api/grobsortierung/stream
- Sticky Header mit laufender Statistik
- Auto-Scroll zum aktuellen Eintrag

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-10 15:03:39 +01:00
parent a9a5482c94
commit e2fe187c5d
2 changed files with 299 additions and 38 deletions

View file

@ -958,6 +958,206 @@ def verarbeite_ordner(id: int, db: Session = Depends(get_db)):
return ergebnis return ergebnis
@router.get("/grobsortierung/stream")
async def grobsortierung_stream(db: Session = Depends(get_db)):
"""Streaming-Endpoint für Grobsortierung aller aktiven Ordner mit Live-Updates"""
from ..models.database import SessionLocal
# Aktive Ordner vorab laden
ordner_liste = db.query(QuellOrdner).filter(QuellOrdner.aktiv == True).all()
ordner_daten = [{
"id": o.id, "name": o.name, "pfad": o.pfad, "ziel_ordner": o.ziel_ordner,
"dateitypen": o.dateitypen, "rekursiv": o.rekursiv,
"direkt_verschieben": getattr(o, 'direkt_verschieben', False),
"zugferd_behandlung": getattr(o, 'zugferd_behandlung', 'separieren'),
"ocr_aktivieren": getattr(o, 'ocr_aktivieren', True),
"original_sichern": getattr(o, 'original_sichern', None)
} for o in ordner_liste]
# Regeln vorab laden
regeln = db.query(SortierRegel).filter(SortierRegel.aktiv == True).order_by(SortierRegel.prioritaet).all()
regeln_dicts = [{
"id": r.id, "name": r.name, "prioritaet": r.prioritaet,
"muster": r.muster, "extraktion": r.extraktion,
"schema": r.schema, "unterordner": r.unterordner
} for r in regeln]
async def event_generator():
def send_event(data):
return f"data: {json.dumps(data)}\n\n"
if not ordner_daten:
yield send_event({"type": "fehler", "nachricht": "Keine aktiven Ordner konfiguriert"})
return
pdf_processor = PDFProcessor()
sorter = Sorter(regeln_dicts) if regeln_dicts else None
gesamt_stats = {"gesamt": 0, "sortiert": 0, "zugferd": 0, "fehler": 0}
# Dateien zählen
total_dateien = 0
for o in ordner_daten:
pfad = Path(o["pfad"])
if pfad.exists():
dateien = sammle_dateien_aus_pfad(str(pfad), o["dateitypen"], o["rekursiv"])
total_dateien += len(dateien)
yield send_event({"type": "start", "ordner_count": len(ordner_daten), "gesamt": total_dateien})
await asyncio.sleep(0)
session = SessionLocal()
try:
for quell_ordner in ordner_daten:
pfad = Path(quell_ordner["pfad"])
if not pfad.exists():
yield send_event({"type": "ordner_fehler", "ordner": quell_ordner["name"], "fehler": "Ordner existiert nicht"})
await asyncio.sleep(0)
continue
ziel_basis = Path(quell_ordner["ziel_ordner"])
dateien = sammle_dateien_aus_pfad(str(pfad), quell_ordner["dateitypen"], quell_ordner["rekursiv"])
yield send_event({
"type": "ordner",
"ordner": quell_ordner["name"],
"dateien": len(dateien)
})
await asyncio.sleep(0)
for datei in dateien:
gesamt_stats["gesamt"] += 1
datei_info = {"original": datei.name, "ordner": quell_ordner["name"]}
try:
ist_pdf = datei.suffix.lower() == ".pdf"
text = ""
ist_zugferd = False
ocr_gemacht = False
# PDF verarbeiten
if ist_pdf:
pdf_result = pdf_processor.verarbeite(
str(datei),
ocr_erlaubt=quell_ordner.get("ocr_aktivieren", True),
original_backup_pfad=quell_ordner.get("original_sichern")
)
if pdf_result.get("fehler"):
raise Exception(pdf_result["fehler"])
text = pdf_result.get("text", "")
ist_zugferd = pdf_result.get("ist_zugferd", False)
ocr_gemacht = pdf_result.get("ocr_durchgefuehrt", False)
# Direkt verschieben?
if quell_ordner["direkt_verschieben"]:
ziel_basis.mkdir(parents=True, exist_ok=True)
# Original sichern
if quell_ordner["original_sichern"]:
import shutil
backup_dir = Path(quell_ordner["original_sichern"])
backup_dir.mkdir(parents=True, exist_ok=True)
backup_pfad = backup_dir / datei.name
counter = 1
while backup_pfad.exists():
backup_pfad = backup_dir / f"{datei.stem}_{counter}{datei.suffix}"
counter += 1
shutil.copy2(str(datei), str(backup_pfad))
neuer_pfad = ziel_basis / datei.name
counter = 1
while neuer_pfad.exists():
neuer_pfad = ziel_basis / f"{datei.stem}_{counter}{datei.suffix}"
counter += 1
datei.rename(neuer_pfad)
gesamt_stats["sortiert"] += 1
datei_info["neuer_name"] = neuer_pfad.name
datei_info["status"] = "verschoben"
session.add(VerarbeiteteDatei(
original_pfad=str(datei),
original_name=datei.name,
neuer_pfad=str(neuer_pfad),
neuer_name=neuer_pfad.name,
ist_zugferd=ist_zugferd,
ocr_durchgefuehrt=ocr_gemacht,
status="direkt_verschoben"
))
yield send_event({"type": "datei_fertig", **datei_info})
await asyncio.sleep(0)
continue
# Mit Regeln sortieren
if sorter:
doc_info = {
"text": text,
"original_name": datei.name,
"absender": "",
"dateityp": datei.suffix.lower()
}
regel = sorter.finde_passende_regel(doc_info)
if regel:
extrahiert = sorter.extrahiere_felder(regel, doc_info)
schema = regel.get("schema", "{datum} - Dokument.pdf")
if schema.endswith(".pdf"):
schema = schema[:-4] + datei.suffix
neuer_name = sorter.generiere_dateinamen({"schema": schema, **regel}, extrahiert)
ziel = ziel_basis
if regel.get("unterordner"):
ziel = ziel / regel["unterordner"]
ziel.mkdir(parents=True, exist_ok=True)
neuer_pfad = sorter.verschiebe_datei(str(datei), str(ziel), neuer_name)
gesamt_stats["sortiert"] += 1
datei_info["neuer_name"] = neuer_name
datei_info["regel"] = regel.get("name")
session.add(VerarbeiteteDatei(
original_pfad=str(datei),
original_name=datei.name,
neuer_pfad=neuer_pfad,
neuer_name=neuer_name,
ist_zugferd=ist_zugferd,
ocr_durchgefuehrt=ocr_gemacht,
status="sortiert",
extrahierte_daten=extrahiert
))
yield send_event({"type": "datei_fertig", **datei_info})
await asyncio.sleep(0)
continue
# Keine Regel gefunden
datei_info["status"] = "keine_regel"
yield send_event({"type": "datei_keine_regel", **datei_info})
await asyncio.sleep(0)
except Exception as e:
gesamt_stats["fehler"] += 1
datei_info["fehler"] = str(e)
yield send_event({"type": "datei_fehler", **datei_info})
await asyncio.sleep(0)
session.commit()
finally:
session.close()
yield send_event({"type": "fertig", **gesamt_stats})
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
# ============ Regeln ============ # ============ Regeln ============
@router.get("/regeln") @router.get("/regeln")

View file

@ -2869,55 +2869,116 @@ async function alleOrdnerVerarbeiten() {
logContainer.innerHTML = '<div class="log-entry info">Starte Grobsortierung...</div>'; logContainer.innerHTML = '<div class="log-entry info">Starte Grobsortierung...</div>';
try { try {
debugLog('Starte Grobsortierung aller Ordner...', 'info'); debugLog('Starte Grobsortierung mit Live-Updates...', 'info');
const ordner = await api('/ordner'); // Streaming-Endpoint verwenden für Live-Updates
const aktiveOrdner = ordner.filter(o => o.aktiv); const response = await fetch('/api/grobsortierung/stream');
if (aktiveOrdner.length === 0) { if (!response.ok) {
logContainer.innerHTML = '<div class="log-entry warning">Keine aktiven Ordner konfiguriert</div>'; throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return;
} }
logContainer.innerHTML = ''; const reader = response.body.getReader();
let gesamtSortiert = 0; const decoder = new TextDecoder();
let gesamtFehler = 0; let buffer = '';
let zusammenfassungDiv = null;
let gesamt = 0, sortiert = 0, fehler = 0;
for (const o of aktiveOrdner) { while (true) {
debugLog(`Verarbeite: ${o.name}`, 'info'); const { done, value } = await reader.read();
try { if (done) break;
const result = await api(`/ordner/${o.id}/verarbeiten`, { method: 'POST' });
gesamtSortiert += result.sortiert || 0;
// Detaillierte Ausgabe pro Ordner buffer += decoder.decode(value, { stream: true });
let ordnerHtml = `<div class="log-entry success"> const lines = buffer.split('\n');
<strong> ${escapeHtml(o.name)}</strong>: ${result.sortiert || 0} sortiert, ${result.zugferd || 0} ZUGFeRD buffer = lines.pop();
</div>`;
// Dateien im Ordner anzeigen for (const line of lines) {
if (result.verarbeitet && result.verarbeitet.length > 0) { if (line.startsWith('data: ')) {
result.verarbeitet.forEach(d => { try {
const klasse = d.status === 'sortiert' || d.status === 'direkt_verschoben' ? 'success' : const data = JSON.parse(line.slice(6));
(d.status === 'fehler' ? 'error' : 'info');
ordnerHtml += `<div class="log-entry ${klasse}" style="padding-left: 2rem;"> switch (data.type) {
<span> ${escapeHtml(d.original)} ${escapeHtml(d.neuer_name || d.status)}</span> case 'start':
</div>`; logContainer.innerHTML = `<div class="log-entry info">
}); <strong>Starte Grobsortierung...</strong> ${data.ordner_count} Ordner, ${data.gesamt} Dateien
</div>`;
zusammenfassungDiv = document.createElement('div');
zusammenfassungDiv.className = 'log-entry success';
zusammenfassungDiv.style.cssText = 'position: sticky; top: 0; background: var(--bg-secondary); border-bottom: 1px solid var(--border); z-index: 1;';
zusammenfassungDiv.innerHTML = `<strong>Gesamt: 0 | Sortiert: 0 | Fehler: 0</strong>`;
logContainer.appendChild(zusammenfassungDiv);
break;
case 'ordner':
logContainer.innerHTML += `<div class="log-entry info" style="margin-top: 0.5rem; border-top: 1px solid var(--border); padding-top: 0.5rem;">
<strong>📁 ${escapeHtml(data.ordner)}</strong> (${data.dateien} Dateien)
</div>`;
break;
case 'datei_fertig':
sortiert++;
gesamt++;
let text = escapeHtml(data.original || '');
if (data.neuer_name) {
text += `${escapeHtml(data.neuer_name)}`;
}
if (data.regel) {
text += ` <small style="opacity:0.7">[${escapeHtml(data.regel)}]</small>`;
}
logContainer.innerHTML += `<div class="log-entry success">
<span> ${text}</span>
</div>`;
if (zusammenfassungDiv) {
zusammenfassungDiv.innerHTML = `<strong>Gesamt: ${gesamt} | Sortiert: ${sortiert} | Fehler: ${fehler}</strong>`;
}
break;
case 'datei_keine_regel':
gesamt++;
logContainer.innerHTML += `<div class="log-entry warning">
<span> ${escapeHtml(data.original)} - keine passende Regel</span>
</div>`;
if (zusammenfassungDiv) {
zusammenfassungDiv.innerHTML = `<strong>Gesamt: ${gesamt} | Sortiert: ${sortiert} | Fehler: ${fehler}</strong>`;
}
break;
case 'datei_fehler':
case 'ordner_fehler':
fehler++;
gesamt++;
logContainer.innerHTML += `<div class="log-entry error">
<span> ${escapeHtml(data.original || data.ordner)} <small>(${escapeHtml(data.fehler || 'Fehler')})</small></span>
</div>`;
if (zusammenfassungDiv) {
zusammenfassungDiv.innerHTML = `<strong>Gesamt: ${gesamt} | Sortiert: ${sortiert} | Fehler: ${fehler}</strong>`;
}
break;
case 'fertig':
logContainer.innerHTML += `<div class="log-entry info" style="margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 0.5rem;">
<strong> Grobsortierung abgeschlossen</strong>
</div>`;
break;
}
logContainer.scrollTop = logContainer.scrollHeight;
} catch (parseError) {
console.error('SSE Parse-Fehler:', parseError, line);
}
} }
logContainer.innerHTML += ordnerHtml;
} catch (error) {
gesamtFehler++;
logContainer.innerHTML += `<div class="log-entry error">
<span> ${escapeHtml(o.name)}: ${error.message}</span>
</div>`;
} }
} }
// Zusammenfassung am Ende if (gesamt === 0) {
logContainer.innerHTML += `<div class="log-entry info" style="margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 0.5rem;"> logContainer.innerHTML = `<div class="log-entry info">Keine Dateien zur Verarbeitung gefunden</div>`;
<strong>Zusammenfassung:</strong> ${gesamtSortiert} Dateien sortiert, ${gesamtFehler} Fehler }
</div>`;
debugLog('Grobsortierung abgeschlossen', 'success'); debugLog('Grobsortierung abgeschlossen', 'success');
} catch (error) { } catch (error) {