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
|
||||
|
||||
|
||||
@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 ============
|
||||
|
||||
@router.get("/regeln")
|
||||
|
|
|
|||
|
|
@ -2869,55 +2869,116 @@ async function alleOrdnerVerarbeiten() {
|
|||
logContainer.innerHTML = '<div class="log-entry info">Starte Grobsortierung...</div>';
|
||||
|
||||
try {
|
||||
debugLog('Starte Grobsortierung aller Ordner...', 'info');
|
||||
debugLog('Starte Grobsortierung mit Live-Updates...', 'info');
|
||||
|
||||
const ordner = await api('/ordner');
|
||||
const aktiveOrdner = ordner.filter(o => o.aktiv);
|
||||
// Streaming-Endpoint verwenden für Live-Updates
|
||||
const response = await fetch('/api/grobsortierung/stream');
|
||||
|
||||
if (aktiveOrdner.length === 0) {
|
||||
logContainer.innerHTML = '<div class="log-entry warning">Keine aktiven Ordner konfiguriert</div>';
|
||||
return;
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
logContainer.innerHTML = '';
|
||||
let gesamtSortiert = 0;
|
||||
let gesamtFehler = 0;
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let zusammenfassungDiv = null;
|
||||
let gesamt = 0, sortiert = 0, fehler = 0;
|
||||
|
||||
for (const o of aktiveOrdner) {
|
||||
debugLog(`Verarbeite: ${o.name}`, 'info');
|
||||
while (true) {
|
||||
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 {
|
||||
const result = await api(`/ordner/${o.id}/verarbeiten`, { method: 'POST' });
|
||||
gesamtSortiert += result.sortiert || 0;
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
// Detaillierte Ausgabe pro Ordner
|
||||
let ordnerHtml = `<div class="log-entry success">
|
||||
<strong>✓ ${escapeHtml(o.name)}</strong>: ${result.sortiert || 0} sortiert, ${result.zugferd || 0} ZUGFeRD
|
||||
switch (data.type) {
|
||||
case 'start':
|
||||
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;
|
||||
|
||||
// Dateien im Ordner anzeigen
|
||||
if (result.verarbeitet && result.verarbeitet.length > 0) {
|
||||
result.verarbeitet.forEach(d => {
|
||||
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>
|
||||
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 += ordnerHtml;
|
||||
} catch (error) {
|
||||
gesamtFehler++;
|
||||
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(o.name)}: ${error.message}</span>
|
||||
<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zusammenfassung am Ende
|
||||
logContainer.innerHTML += `<div class="log-entry info" style="margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 0.5rem;">
|
||||
<strong>Zusammenfassung:</strong> ${gesamtSortiert} Dateien sortiert, ${gesamtFehler} Fehler
|
||||
</div>`;
|
||||
if (gesamt === 0) {
|
||||
logContainer.innerHTML = `<div class="log-entry info">Keine Dateien zur Verarbeitung gefunden</div>`;
|
||||
}
|
||||
|
||||
debugLog('Grobsortierung abgeschlossen', 'success');
|
||||
} catch (error) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue