docker.dateiverwaltung/backend/app/modules/sorter.py
data 21e1ffe9e2 Version 1.1: Dateimanager mit 3-Panel Layout
Neue Features:
- 3-Panel Dateimanager (Ordnerbaum, Dateiliste, Vorschau)
- Separates Vorschau-Fenster für zweiten Monitor
- Resize-Handles für flexible Panel-Größen (horizontal & vertikal)
- Vorschau-Panel ausblendbar wenn externes Fenster aktiv
- Natürliche Sortierung (Sonderzeichen → Zahlen → Buchstaben)
- PDF-Vorschau mit Fit-to-Page
- Email-Attachment Abruf erweitert

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 12:51:40 +01:00

531 lines
18 KiB
Python

"""
Sorter Modul
Regel-basierte Erkennung und Benennung von Dokumenten
"""
import re
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Any
import logging
import shutil
from .extraktoren import extrahiere_alle_felder, baue_dateiname
logger = logging.getLogger(__name__)
class Sorter:
"""Sortiert und benennt Dokumente basierend auf Regeln"""
def __init__(self, regeln: List[Dict]):
"""
Args:
regeln: Liste von Regel-Dicts, sortiert nach Priorität
"""
# Nach Priorität sortieren (niedrig = wichtig)
self.regeln = sorted(regeln, key=lambda r: r.get("prioritaet", 100))
def finde_passende_regel(self, dokument_info: Dict) -> Optional[Dict]:
"""
Findet die erste passende Regel für ein Dokument
Args:
dokument_info: Dict mit text, original_name, absender, etc.
Returns:
Passende Regel oder None
"""
for regel in self.regeln:
if not regel.get("aktiv", True):
continue
muster = regel.get("muster", {})
if self._pruefe_muster(muster, dokument_info):
logger.info(f"Regel '{regel.get('name')}' matched für {dokument_info.get('original_name')}")
return regel
return None
def _pruefe_muster(self, muster: Dict, dokument_info: Dict) -> bool:
"""Prüft ob alle Muster auf das Dokument zutreffen"""
text = dokument_info.get("text", "").lower()
original_name = dokument_info.get("original_name", "").lower()
absender = dokument_info.get("absender", "").lower()
dateityp = dokument_info.get("dateityp", "").lower() # z.B. ".pdf", ".jpg"
# ========== TYP-BASIERTE REGELN (Stufe 1: Grob-Sortierung) ==========
# dateityp_ist - Nur bestimmte Dateitypen (z.B. [".pdf", ".PDF"])
if "dateityp_ist" in muster:
erlaubte = muster["dateityp_ist"]
if isinstance(erlaubte, str):
erlaubte = [erlaubte]
erlaubte_lower = [t.lower() for t in erlaubte]
if dateityp not in erlaubte_lower:
return False
# dateityp_nicht - Ausschluss bestimmter Dateitypen
if "dateityp_nicht" in muster:
verbotene = muster["dateityp_nicht"]
if isinstance(verbotene, str):
verbotene = [verbotene]
verbotene_lower = [t.lower() for t in verbotene]
if dateityp in verbotene_lower:
return False
# ist_zugferd - Nur ZUGFeRD/E-Rechnungen
if "ist_zugferd" in muster:
ist_zugferd = dokument_info.get("ist_zugferd", False)
if muster["ist_zugferd"] and not ist_zugferd:
return False
if not muster["ist_zugferd"] and ist_zugferd:
return False
# ist_signiert - Nur signierte PDFs
if "ist_signiert" in muster:
ist_signiert = dokument_info.get("ist_signiert", False)
if muster["ist_signiert"] and not ist_signiert:
return False
if not muster["ist_signiert"] and ist_signiert:
return False
# hat_text - Nur PDFs mit/ohne Text
if "hat_text" in muster:
hat_text = dokument_info.get("hat_text", False)
if muster["hat_text"] and not hat_text:
return False
if not muster["hat_text"] and hat_text:
return False
# ist_bild - Prüft ob Datei ein Bild ist
if "ist_bild" in muster:
bild_typen = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp"]
ist_bild = dateityp in bild_typen
if muster["ist_bild"] and not ist_bild:
return False
if not muster["ist_bild"] and ist_bild:
return False
# ist_pdf - Prüft ob Datei ein PDF ist
if "ist_pdf" in muster:
ist_pdf = dateityp == ".pdf"
if muster["ist_pdf"] and not ist_pdf:
return False
if not muster["ist_pdf"] and ist_pdf:
return False
# ========== INHALT-BASIERTE REGELN (Stufe 2: Fein-Sortierung) ==========
# keywords (einfache Komma-getrennte Liste - für UI)
if "keywords" in muster:
keywords = muster["keywords"]
if isinstance(keywords, str):
keywords = [k.strip() for k in keywords.split(",")]
# Alle Keywords müssen vorkommen
for keyword in keywords:
keyword = keyword.lower().strip()
if keyword and keyword not in text and keyword not in original_name:
return False
# absender_contains
if "absender_contains" in muster:
if muster["absender_contains"].lower() not in absender:
return False
# dateiname_match
if "dateiname_match" in muster:
pattern = muster["dateiname_match"]
if isinstance(pattern, str):
if pattern.lower() not in original_name:
return False
elif isinstance(pattern, list):
if not any(p.lower() in original_name for p in pattern):
return False
# text_match (alle müssen enthalten sein)
if "text_match" in muster:
patterns = muster["text_match"]
if isinstance(patterns, str):
patterns = [patterns]
for pattern in patterns:
if pattern.lower() not in text:
return False
# text_match_any (mindestens einer muss enthalten sein)
if "text_match_any" in muster:
patterns = muster["text_match_any"]
if isinstance(patterns, str):
patterns = [patterns]
if not any(p.lower() in text for p in patterns):
return False
# text_regex
if "text_regex" in muster:
pattern = muster["text_regex"]
if not re.search(pattern, text, re.IGNORECASE):
return False
return True
def extrahiere_felder(self, regel: Dict, dokument_info: Dict) -> Dict[str, Any]:
"""
Extrahiert Felder aus dem Dokument - nutzt globale Extraktoren mit Fallbacks
Returns:
Dict mit extrahierten Werten
"""
text = dokument_info.get("text", "")
regel_extraktion = regel.get("extraktion", {})
# Globale Extraktoren nutzen (mit Regel-spezifischen Überschreibungen)
felder = extrahiere_alle_felder(text, dokument_info, regel_extraktion)
# Regel-spezifische statische Werte überschreiben
for feld_name, feld_config in regel_extraktion.items():
if isinstance(feld_config, dict):
if "wert" in feld_config:
felder[feld_name] = feld_config["wert"]
elif "regex" in feld_config:
# Einzelnes Regex aus Regel
wert = self._extrahiere_mit_regex(feld_config, text)
if wert:
felder[feld_name] = wert
elif isinstance(feld_config, str):
# Direkter statischer Wert
felder[feld_name] = feld_config
return felder
def _extrahiere_mit_regex(self, config: Dict, text: str) -> Optional[str]:
"""Extrahiert ein Feld mit einem einzelnen Regex"""
try:
match = re.search(config["regex"], text, re.IGNORECASE | re.MULTILINE)
if match:
wert = match.group(1) if match.groups() else match.group(0)
# Datum formatieren
if "format" in config:
try:
datum = datetime.strptime(wert.strip(), config["format"])
return datum.strftime("%Y-%m-%d")
except:
pass
# Betrag formatieren
if config.get("typ") == "betrag":
wert = self._formatiere_betrag(wert)
return wert.strip()
except Exception as e:
logger.debug(f"Regex-Extraktion fehlgeschlagen: {e}")
return None
def _formatiere_betrag(self, betrag: str) -> str:
"""Formatiert Betrag einheitlich"""
betrag = betrag.replace(" ", "").replace(".", "").replace(",", ".")
try:
wert = float(betrag)
if wert == int(wert):
return str(int(wert))
return f"{wert:.2f}".replace(".", ",")
except:
return betrag
def generiere_dateinamen(self, regel: Dict, extrahierte_felder: Dict) -> str:
"""
Generiert den neuen Dateinamen basierend auf Schema
Nutzt den intelligenten Schema-Builder der fehlende Felder entfernt
"""
schema = regel.get("schema", "{datum} - Dokument.pdf")
return baue_dateiname(schema, extrahierte_felder, ".pdf")
def verschiebe_datei(self, quell_pfad: str, ziel_ordner: str, neuer_name: str) -> str:
"""
Verschiebt und benennt Datei um
Returns:
Neuer Pfad der Datei
"""
ziel_dir = Path(ziel_ordner)
ziel_dir.mkdir(parents=True, exist_ok=True)
ziel_pfad = ziel_dir / neuer_name
# Eindeutigen Namen sicherstellen
counter = 1
original_name = ziel_pfad.stem
suffix = ziel_pfad.suffix
while ziel_pfad.exists():
ziel_pfad = ziel_dir / f"{original_name} ({counter}){suffix}"
counter += 1
# Verschieben
shutil.move(quell_pfad, ziel_pfad)
logger.info(f"Verschoben: {quell_pfad} -> {ziel_pfad}")
return str(ziel_pfad)
# ============ STANDARD-DOKUMENTTYPEN ============
# Diese werden für das einfache UI verwendet
DOKUMENTTYPEN = {
"rechnung": {
"name": "Rechnung",
"keywords": ["rechnung", "invoice"],
"schema": "{datum} - Rechnung - {firma} - {nummer} - {betrag} EUR.pdf",
"unterordner": "rechnungen"
},
"angebot": {
"name": "Angebot",
"keywords": ["angebot", "quotation", "offerte"],
"schema": "{datum} - Angebot - {firma} - {nummer} - {betrag} EUR.pdf",
"unterordner": "angebote"
},
"gutschrift": {
"name": "Gutschrift",
"keywords": ["gutschrift", "credit"],
"schema": "{datum} - Gutschrift - {firma} - {nummer} - {betrag} EUR.pdf",
"unterordner": "gutschriften"
},
"lieferschein": {
"name": "Lieferschein",
"keywords": ["lieferschein", "delivery"],
"schema": "{datum} - Lieferschein - {firma} - {nummer}.pdf",
"unterordner": "lieferscheine"
},
"auftragsbestaetigung": {
"name": "Auftragsbestätigung",
"keywords": ["auftragsbestätigung", "bestellbestätigung"],
"schema": "{datum} - Auftragsbestätigung - {firma} - {nummer}.pdf",
"unterordner": "auftraege"
},
"vertrag": {
"name": "Vertrag",
"keywords": ["vertrag", "contract"],
"schema": "Vertrag - {firma} - {nummer} - {datum}.pdf",
"unterordner": "vertraege"
},
"versicherung": {
"name": "Versicherung",
"keywords": ["versicherung", "police", "beitrag"],
"schema": "Versicherung - {firma} - {nummer} - {datum}.pdf",
"unterordner": "versicherungen"
},
"zeugnis": {
"name": "Zeugnis",
"keywords": ["zeugnis", "zertifikat"],
"schema": "Zeugnis - {firma} - {nummer} - {datum}.pdf",
"unterordner": "zeugnisse"
},
"bescheinigung": {
"name": "Bescheinigung",
"keywords": ["bescheinigung", "nachweis", "bestätigung"],
"schema": "Bescheinigung - {firma} - {nummer} - {datum}.pdf",
"unterordner": "bescheinigungen"
},
"kontoauszug": {
"name": "Kontoauszug",
"keywords": ["kontoauszug", "account statement"],
"schema": "{datum} - Kontoauszug - {firma} - {nummer}.pdf",
"unterordner": "kontoauszuege"
},
"sonstiges": {
"name": "Sonstiges",
"keywords": [],
"schema": "{datum} - {typ} - {firma}.pdf",
"unterordner": "sonstiges"
}
}
def erstelle_einfache_regel(name: str, dokumenttyp: str, keywords: str,
firma_wert: str = None, unterordner: str = None,
prioritaet: int = 50) -> Dict:
"""
Erstellt eine einfache Regel basierend auf Dokumenttyp
Args:
name: Name der Regel (z.B. "Sonepar Rechnung")
dokumenttyp: Typ aus DOKUMENTTYPEN
keywords: Komma-getrennte Keywords zur Erkennung
firma_wert: Optionaler fester Firmenwert
unterordner: Optionaler Unterordner (überschreibt Standard)
prioritaet: Priorität (niedriger = wichtiger)
Returns:
Regel-Dict für die Datenbank
"""
typ_config = DOKUMENTTYPEN.get(dokumenttyp, DOKUMENTTYPEN["sonstiges"])
regel = {
"name": name,
"prioritaet": prioritaet,
"aktiv": True,
"muster": {
"keywords": keywords
},
"extraktion": {},
"schema": typ_config["schema"],
"unterordner": unterordner or typ_config["unterordner"]
}
# Feste Firma wenn angegeben
if firma_wert:
regel["extraktion"]["firma"] = {"wert": firma_wert}
return regel
def liste_dokumenttypen() -> List[Dict]:
"""Gibt Liste aller Dokumenttypen für UI zurück"""
return [
{"id": key, "name": config["name"], "schema": config["schema"]}
for key, config in DOKUMENTTYPEN.items()
]
# ============ TYP-BASIERTE STANDARD-REGELN ============
# Diese Regeln sortieren nach Dateityp/Eigenschaften (Stufe 1: Grob-Sortierung)
TYP_REGELN = {
"zugferd": {
"name": "E-Rechnungen (ZUGFeRD/XRechnung)",
"beschreibung": "Elektronische Rechnungen mit maschinenlesbaren XML-Daten",
"prioritaet": 5, # Sehr hohe Priorität - vor anderen PDF-Regeln
"muster": {
"ist_pdf": True,
"ist_zugferd": True
},
"schema": "{original}", # Originalname behalten
"unterordner": "e-rechnungen",
"ist_fallback": False
},
"signierte_pdfs": {
"name": "Signierte PDFs",
"beschreibung": "Digital signierte PDF-Dokumente (Verträge, Bescheide)",
"prioritaet": 10,
"muster": {
"ist_pdf": True,
"ist_signiert": True
},
"schema": "{original}",
"unterordner": "signierte_dokumente",
"ist_fallback": False
},
"bilder": {
"name": "Bilder",
"beschreibung": "Alle Bilddateien (JPG, PNG, TIFF, etc.)",
"prioritaet": 20,
"muster": {
"ist_bild": True
},
"schema": "{original}",
"unterordner": "bilder",
"ist_fallback": False
},
"pdfs_ohne_text": {
"name": "Gescannte PDFs (ohne OCR)",
"beschreibung": "PDFs ohne durchsuchbaren Text (Scans)",
"prioritaet": 30,
"muster": {
"ist_pdf": True,
"hat_text": False
},
"schema": "{original}",
"unterordner": "scans",
"ist_fallback": False
},
"alle_pdfs": {
"name": "Alle PDFs (Fallback)",
"beschreibung": "Alle PDF-Dokumente die keiner anderen Regel entsprechen",
"prioritaet": 900, # Sehr niedrige Priorität - als Fallback
"muster": {
"ist_pdf": True
},
"schema": "{original}",
"unterordner": "dokumente",
"ist_fallback": True
},
"alle_bilder_fallback": {
"name": "Alle Bilder (Fallback)",
"beschreibung": "Alle Bilddateien die keiner anderen Regel entsprechen",
"prioritaet": 910,
"muster": {
"ist_bild": True
},
"schema": "{original}",
"unterordner": "bilder",
"ist_fallback": True
},
"alle_dateien_fallback": {
"name": "Alle anderen Dateien (Fallback)",
"beschreibung": "Alle Dateien die keiner Regel entsprechen - letzte Auffang-Regel",
"prioritaet": 999, # Absolut letzte Regel
"muster": {}, # Leeres Muster = passt auf alles
"schema": "{original}",
"unterordner": "sonstiges",
"ist_fallback": True
}
}
def liste_typ_regeln(nur_fallback: bool = None) -> List[Dict]:
"""
Gibt Liste aller Typ-basierten Regeln für UI zurück
Args:
nur_fallback: None = alle, True = nur Fallbacks, False = keine Fallbacks
"""
result = []
for key, config in TYP_REGELN.items():
ist_fallback = config.get("ist_fallback", False)
# Filtern nach Fallback-Status
if nur_fallback is True and not ist_fallback:
continue
if nur_fallback is False and ist_fallback:
continue
result.append({
"id": key,
"name": config["name"],
"beschreibung": config["beschreibung"],
"prioritaet": config["prioritaet"],
"muster": config["muster"],
"unterordner": config["unterordner"],
"ist_fallback": ist_fallback
})
# Nach Priorität sortieren
return sorted(result, key=lambda x: x["prioritaet"])
def erstelle_typ_regel(typ_id: str, unterordner: str = None, prioritaet: int = None) -> Dict:
"""
Erstellt eine Typ-basierte Regel
Args:
typ_id: ID aus TYP_REGELN (z.B. "zugferd", "bilder")
unterordner: Optionaler Unterordner (überschreibt Standard)
prioritaet: Optionale Priorität (überschreibt Standard)
Returns:
Regel-Dict für die Datenbank
"""
if typ_id not in TYP_REGELN:
raise ValueError(f"Unbekannter Typ: {typ_id}")
typ_config = TYP_REGELN[typ_id]
return {
"name": typ_config["name"],
"prioritaet": prioritaet or typ_config["prioritaet"],
"aktiv": True,
"muster": typ_config["muster"].copy(),
"extraktion": {},
"schema": typ_config["schema"],
"unterordner": unterordner or typ_config["unterordner"]
}