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:
parent
a9a5482c94
commit
e2fe187c5d
2 changed files with 299 additions and 38 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
try {
|
try {
|
||||||
const result = await api(`/ordner/${o.id}/verarbeiten`, { method: 'POST' });
|
const data = JSON.parse(line.slice(6));
|
||||||
gesamtSortiert += result.sortiert || 0;
|
|
||||||
|
|
||||||
// Detaillierte Ausgabe pro Ordner
|
switch (data.type) {
|
||||||
let ordnerHtml = `<div class="log-entry success">
|
case 'start':
|
||||||
<strong>✓ ${escapeHtml(o.name)}</strong>: ${result.sortiert || 0} sortiert, ${result.zugferd || 0} ZUGFeRD
|
logContainer.innerHTML = `<div class="log-entry info">
|
||||||
|
<strong>Starte Grobsortierung...</strong> ${data.ordner_count} Ordner, ${data.gesamt} Dateien
|
||||||
</div>`;
|
</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;
|
||||||
|
|
||||||
// Dateien im Ordner anzeigen
|
case 'ordner':
|
||||||
if (result.verarbeitet && result.verarbeitet.length > 0) {
|
logContainer.innerHTML += `<div class="log-entry info" style="margin-top: 0.5rem; border-top: 1px solid var(--border); padding-top: 0.5rem;">
|
||||||
result.verarbeitet.forEach(d => {
|
<strong>📁 ${escapeHtml(data.ordner)}</strong> (${data.dateien} Dateien)
|
||||||
const klasse = d.status === 'sortiert' || d.status === 'direkt_verschoben' ? 'success' :
|
|
||||||
(d.status === 'fehler' ? 'error' : 'info');
|
|
||||||
ordnerHtml += `<div class="log-entry ${klasse}" style="padding-left: 2rem;">
|
|
||||||
<span>→ ${escapeHtml(d.original)} → ${escapeHtml(d.neuer_name || d.status)}</span>
|
|
||||||
</div>`;
|
</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 += ordnerHtml;
|
logContainer.innerHTML += `<div class="log-entry success">
|
||||||
} catch (error) {
|
<span>✓ ${text}</span>
|
||||||
gesamtFehler++;
|
</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">
|
logContainer.innerHTML += `<div class="log-entry error">
|
||||||
<span>✗ ${escapeHtml(o.name)}: ${error.message}</span>
|
<span>✗ ${escapeHtml(data.original || data.ordner)} <small>(${escapeHtml(data.fehler || 'Fehler')})</small></span>
|
||||||
</div>`;
|
</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue