From 8e44e64930381f1525b9bd3c9d7dd32c3c3fa623 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Thu, 30 Jul 2026 16:28:27 +0200 Subject: [PATCH] =?UTF-8?q?Stapelbetrieb:=20Ordner=20einlesen,=20per=20Amp?= =?UTF-8?q?el=20pr=C3=BCfen,=20gesammelt=20exportieren.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- README.md | 62 ++++++++---- batch_folder.py | 236 +++++++++++++++++++++++++++++++++++++++++++++ batch_mode.py | 189 ++++++++++++++++++++++++++++++++++++ docker-compose.yml | 30 +++++- invoice_logic.py | 56 +++++++++++ main.py | 59 ++++++------ 6 files changed, 581 insertions(+), 51 deletions(-) create mode 100644 batch_folder.py create mode 100644 batch_mode.py diff --git a/README.md b/README.md index bb2359e..d4acd32 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Description -Dieses Tool konvertiert Rechnungen im PDF-Format über eine Weboberfläche in die offiziellen E-Rechnungsformate XRechnung (UBL 3.0) und ZUGFeRD 2.4 (hybride PDF mit eingebettetem CII-XML). Die Datenextraktion erfolgt über ein LLM, das intern im Firmennetzwerk läuft – sensible Finanzdaten verlassen das lokale Netzwerk nicht. +Dieses Tool konvertiert Rechnungen im PDF-Format über eine Weboberfläche in die offiziellen E-Rechnungsformate XRechnung (UBL) und ZUGFeRD 2.4 (hybride PDF mit eingebettetem CII-XML). Die Datenextraktion ist vollständig deterministisch (Regex und Zeilen-Auswertung) – es wird kein LLM und kein externer Dienst verwendet, sensible Finanzdaten verlassen das lokale Netzwerk nicht. ## Zugriff @@ -12,10 +12,13 @@ http://erechnung ## Aufbau -Das System besteht aus zwei Komponenten: - -- **Web-Oberfläche** (`main.py`, Streamlit): PDF-Upload, Datenvalidierung, Export -- **vLLM-Backend**: separat betriebener Inferenz-Server (Standard: `gemma431b`), der die strukturierten Metadaten aus dem Rechnungstext extrahiert +| Datei | Zweck | +|---|---| +| `main.py` | Streamlit-Oberfläche für die Einzelrechnung, Export | +| `invoice_logic.py` | Extraktion, Berechnung, XML-Erzeugung (UBL und CII), PDF-Helfer | +| `batch_folder.py` | Ordner-Anbindung und Ampel-Bewertung des Stapelbetriebs (ohne Streamlit) | +| `batch_mode.py` | Oberfläche des Stapelbetriebs | +| `intro_overlay.py` | Intro-Video beim ersten Besuch | ## Installation (Docker) @@ -26,17 +29,20 @@ git clone https://gitea.scitech-gmbh.de:8443/dfuhrmann/E-Rechnung.git cd E-Rechnung ``` -### 2. Umgebungsvariablen prüfen +### 2. Austauschordner einbinden (nur für den Stapelbetrieb) -In `docker-compose.yml` sind das vLLM-Backend und Modell hinterlegt: +Der Stapelbetrieb liest Rechnungen aus einem Ordner und schreibt die fertigen E-Rechnungen zurück. Dafür muss die Netzwerkfreigabe dem Container zugänglich gemacht werden – ein Windows-Pfad wie `\\FS01\Daten\Technik\AI` lässt sich **nicht** in der Oberfläche eintragen, sondern wird in `docker-compose.yml` gemountet: ```yaml -environment: - - VLLM_URL=http://10.192.0.140:8002 - - LLM_MODEL=gemma431b +volumes: + - /mnt/fs01/Daten/Technik/AI:/data ``` -Bei Bedarf hier anpassen (z.B. anderes Modell oder Backend-Host). +Wichtig: Binden Sie den **übergeordneten** Ordner ein, nicht einen einzelnen Unterordner. Alle Unterordner darunter sind dann in der Oberfläche frei wählbar, ohne dass diese Datei erneut angefasst werden muss. + +Alternativ kann Docker die Freigabe selbst per CIFS einhängen – der entsprechende Block steht auskommentiert am Ende von `docker-compose.yml`. Das benötigte Konto braucht Lese- **und** Schreibrechte. + +Wird kein Ordner eingebunden, funktioniert die Einzelrechnung uneingeschränkt; der Stapelbetrieb weist dann auf den fehlenden Mount hin. ### 3. Container bauen und starten @@ -50,16 +56,36 @@ Die Anwendung ist danach unter `http://localhost:8040` (bzw. im Firmennetz unter ## Use -Der Konversionsprozess gliedert sich in folgende Schritte: +Die Betriebsart wird links in der Seitenleiste gewählt. + +### Einzelrechnung + 1. **Dokumenten-Upload:** Laden Sie die Rechnungs-PDF über die Weboberfläche hoch. Die PDF wird direkt zur Kontrolle angezeigt. -2. **Extraktion:** Starten Sie die Analyse über den Button "Daten extrahieren". -3. **Validierung:** Prüfen Sie die extrahierten Kopfdaten und die Positionstabelle in der Benutzeroberfläche. Da alle Felder editierbar sind, können Korrekturen direkt vorgenommen werden. Pflichtfelder sind mit :red[*] markiert; der Export wird blockiert, solange Pflichtangaben fehlen. -4. **Export:** Wählen Sie das gewünschte Format (XRechnung-XML oder ZUGFeRD-PDF) und starten Sie den Download. +2. **Leistungsnachweise (optional):** Bis zu drei weitere PDFs anhängen. Ihre Seiten werden beim ZUGFeRD-Export ans Ende der Rechnung gehängt, sodass eine einzelne PDF entsteht. Aus diesen Dateien werden keine Daten ausgelesen. +3. **Extraktion:** Starten Sie die Analyse über den Button "Daten extrahieren". +4. **Validierung:** Prüfen Sie die extrahierten Kopfdaten und die Positionstabelle. Alle Felder sind editierbar. Pflichtfelder sind mit :red[*] markiert; der Export wird blockiert, solange Pflichtangaben fehlen. +5. **Export:** Wählen Sie das Format (XRechnung-XML oder ZUGFeRD-PDF) und starten Sie den Download. + +### Stapel (Ordner) + +1. **Ordner wählen:** Eingangsordner (von DATEV befüllt) und Ausgabeordner aus den Unterordnern des eingebundenen Verzeichnisses auswählen. +2. **Einlesen:** Alle PDFs des Eingangsordners werden analysiert und bewertet. +3. **Prüfen:** Die Übersicht zeigt je Rechnung eine Ampel. ✅ bedeutet, dass alle Pflichtangaben vorliegen **und** die Summe der erkannten Positionen zur ausgewiesenen Bemessungsgrundlage passt. ⚠️ markierte Rechnungen nennen im Klartext, was fehlt; sie werden übersprungen und lassen sich im Einzelmodus korrigieren. +4. **Nachweise zuordnen (optional):** Liegt ein Leistungsnachweis im selben Ordner, ordnen Sie ihn der zugehörigen Rechnung zu. Er zählt dann nicht mehr als eigene Rechnung. +5. **Exportieren:** Alle grünen Rechnungen werden in einem Zug konvertiert und in den Ausgabeordner geschrieben. Die Quelldateien wandern in den Unterordner `verarbeitet` – gelöscht wird nichts. ## Notes -### Human-in-the-Loop -Das Tool dient als Automations-Assistent. Vor dem finalen Export ist eine visuelle Endkontrolle der extrahierten Daten (insbesondere Rechnungsnummer und Empfängeradresse) durch den Sachbearbeiter zwingend erforderlich, um KI-Fehlinterpretationen auszuschließen. +### Kontrolle durch den Sachbearbeiter + +Das Tool ist ein Automations-Assistent, kein Ersatz für die fachliche Prüfung. + +Im **Einzelmodus** liegen alle extrahierten Werte zur Sichtprüfung und Korrektur offen; der Export wird blockiert, solange Pflichtangaben fehlen. + +Im **Stapelmodus** tritt an die Stelle der Sichtprüfung jeder einzelnen Rechnung eine Sammelfreigabe: Es werden nur Rechnungen exportiert, bei denen alle Pflichtfelder belegt sind und die Summe der erkannten Positionen exakt der auf der Rechnung ausgewiesenen Bemessungsgrundlage entspricht. Dieser Summen-Abgleich ist das entscheidende Sicherheitsnetz – er deckt eine unvollständige Positionserkennung zuverlässig auf. Alles Auffällige wird markiert und übersprungen, nie stillschweigend exportiert. + +Die Verantwortung für die inhaltliche Richtigkeit der Rechnung bleibt in beiden Betriebsarten beim Sachbearbeiter. ### GoBD-Konformität -Das Tool generiert ausschließlich das strukturierte XML- bzw. hybride PDF-Format. Für die Einhaltung der GoBD-Richtlinien (u. a. die revisionssichere Archivierung der Original-PDF sowie der generierten E-Rechnung über den gesetzlichen Zeitraum von 10 Jahren) ist der Betreiber der Software selbst verantwortlich. + +Das Tool generiert ausschließlich das strukturierte XML- bzw. hybride PDF-Format. Für die Einhaltung der GoBD-Richtlinien (u. a. die revisionssichere Archivierung der Original-PDF sowie der generierten E-Rechnung über den gesetzlichen Zeitraum von 10 Jahren) ist der Betreiber der Software selbst verantwortlich. Der Stapelbetrieb löscht keine Quelldateien, sondern verschiebt sie nach `verarbeitet`. diff --git a/batch_folder.py b/batch_folder.py new file mode 100644 index 0000000..7ac8845 --- /dev/null +++ b/batch_folder.py @@ -0,0 +1,236 @@ +"""Ordner-Anbindung fuer den Stapelbetrieb. + +Bewusst OHNE Streamlit-Abhaengigkeit: die Pfad-, Auswahl- und Bewertungslogik ist damit +vollstaendig testbar, auch dort wo die App selbst nicht startbar ist. + +Die App laeuft im Container und kann Windows-UNC-Pfade (\\\\FS01\\...) nicht verwenden. Die +Netzwerkfreigabe wird stattdessen EINMALIG als Volume unter BATCH_ROOT_DIR gemountet; alles +darunter ist zur Laufzeit frei waehlbar. Jede Pfadoperation prueft, dass ihr Ziel innerhalb +dieser Wurzel liegt - ein im UI gewaehlter Ordnername darf nicht aus dem Mount ausbrechen. +""" +import io +import os +import shutil +from pathlib import Path + +import fitz + +from invoice_logic import ( + VERKAEUFER, + VERKAEUFER_IBAN, + collect_blockers, + compute_invoice_totals, + detect_invoice_type_code, + extract_document_references, + extract_header_metadata_pure_python, + extract_invoice_items, + extract_labeled_header_value, + extract_pre_position_freetext, + extract_referenznummer, + extract_service_period, + extract_stated_tax_basis, + kaufmaennisches_runden, + resolve_buyer_endpoint, + sums_match, +) + +# Wurzel des gemounteten Austauschverzeichnisses. Alles darunter ist im UI waehlbar. +BATCH_ROOT_DIR = os.environ.get("BATCH_ROOT_DIR", "/data") + +# Unterordner, in den verarbeitete Quell-PDFs wandern. Sie werden NICHT geloescht - die +# Originale sind GoBD-relevant und muessen aufbewahrt werden. +PROCESSED_SUBDIR = "verarbeitet" + + +def _root(root=None) -> Path: + return Path(root if root is not None else BATCH_ROOT_DIR) + + +def resolve_within_root(relative_path, root=None) -> Path: + """Loest einen (relativen) Pfad gegen die Wurzel auf und stellt sicher, dass das Ergebnis + INNERHALB der Wurzel liegt. Wirft ValueError bei einem Ausbruchsversuch ('../' oder ein + absoluter Pfad ausserhalb) - der Ordnername kommt aus dem UI und ist damit Eingabe, der + nicht blind vertraut werden darf.""" + base = _root(root).resolve() + target = (base / Path(relative_path)).resolve() + if target != base and base not in target.parents: + raise ValueError( + f"Pfad '{relative_path}' liegt ausserhalb des freigegebenen Verzeichnisses '{base}'." + ) + return target + + +def list_subfolders(root=None) -> list: + """Namen der direkten Unterordner der Wurzel, alphabetisch - die Auswahlliste im UI. + Existiert die Wurzel nicht (Mount fehlt), kommt eine leere Liste zurueck.""" + base = _root(root) + if not base.is_dir(): + return [] + return sorted(p.name for p in base.iterdir() if p.is_dir()) + + +def list_input_pdfs(relative_dir, root=None) -> list: + """Alle PDFs eines Eingangsordners, alphabetisch. Der Unterordner mit den bereits + verarbeiteten Dateien wird ausgelassen, damit ein Stapel nicht doppelt laeuft.""" + directory = resolve_within_root(relative_dir, root) + if not directory.is_dir(): + return [] + return sorted( + (p for p in directory.iterdir() if p.is_file() and p.suffix.lower() == ".pdf"), + key=lambda p: p.name, + ) + + +def _free_path(directory: Path, filename: str) -> Path: + """Ein noch freier Dateiname in directory: haengt _2, _3 ... an, statt eine vorhandene + Datei zu ueberschreiben. Ein frueherer Export darf nicht still verlorengehen.""" + candidate = directory / filename + if not candidate.exists(): + return candidate + stem, suffix = Path(filename).stem, Path(filename).suffix + counter = 2 + while (directory / f"{stem}_{counter}{suffix}").exists(): + counter += 1 + return directory / f"{stem}_{counter}{suffix}" + + +def write_output(relative_dir, filename, data: bytes, root=None) -> Path: + """Schreibt eine fertige Export-Datei in den Ausgabeordner und gibt den tatsaechlich + verwendeten Pfad zurueck (kann bei Namenskollision abweichen). Der Ordner wird bei Bedarf + angelegt.""" + directory = resolve_within_root(relative_dir, root) + directory.mkdir(parents=True, exist_ok=True) + target = _free_path(directory, filename) + target.write_bytes(data) + return target + + +def move_to_processed(pdf_path, root=None) -> Path: + """Verschiebt eine verarbeitete Quell-PDF in den Unterordner 'verarbeitet' und gibt das + Ziel zurueck. Bei Namenskollision wird angehaengt statt ueberschrieben.""" + source = Path(pdf_path).resolve() + base = _root(root).resolve() + if base not in source.parents: + raise ValueError( + f"Datei '{source}' liegt ausserhalb des freigegebenen Verzeichnisses '{base}'." + ) + processed_dir = source.parent / PROCESSED_SUBDIR + processed_dir.mkdir(parents=True, exist_ok=True) + target = _free_path(processed_dir, source.name) + shutil.move(str(source), str(target)) + return target + + +def pdf_text_of(pdf_bytes: bytes) -> str: + """Extrahiert den Text einer PDF - identisch zum Einzelmodus (main.py).""" + text = "" + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + try: + for page in doc: + text += page.get_text("text") + "\n" + finally: + doc.close() + return text + + +def analyse_pdf(pdf_bytes: bytes) -> dict: + """Fuehrt fuer eine Rechnungs-PDF dieselbe Extraktion aus wie der Einzelmodus und liefert + ein exportfertiges Rechnungs-Dict samt berechneter Summen. + + Spiegelt bewusst die Reihenfolge aus main.py: Kopfdaten, Positionen ueber den DISPATCHER + (nicht die Pure-Python-Variante - sonst faellt der Buendel-Parser aus), Bemessungs- + grundlage, Leistungszeitraum-Fallback, Freitext, Belegreferenzen.""" + text = pdf_text_of(pdf_bytes) + data = extract_header_metadata_pure_python(text) + + if data.get("recipient_email") == VERKAEUFER["email"]: + data["recipient_email"] = "" + + items = extract_invoice_items(text) + for item in items: + item["total_amount"] = kaufmaennisches_runden( + float(item.get("quantity") or 0.0) * float(item.get("unit_price") or 0.0) + ) + item["vat_rate_percent"] = float( + item.get("vat_rate_percent") or data.get("vat_rate_percent") or 19.0 + ) + data["items"] = items + data["stated_tax_basis"] = extract_stated_tax_basis(text) + + if not data.get("service_start_date") and not data.get("service_end_date"): + start, end = extract_service_period(text) + if start and end: + data["service_start_date"], data["service_end_date"] = start, end + + data["invoice_type_code"] = detect_invoice_type_code(text) + + freetext = extract_pre_position_freetext(text) + if freetext is not None: + data["invoice_note"] = freetext + + for field, value in extract_document_references(text).items(): + data[field] = value + + ihr_auftrag = extract_labeled_header_value(text, "Ihr Auftrag:") + if ihr_auftrag: + data["buyer_reference"] = ihr_auftrag + + data["bearbeiter"] = extract_labeled_header_value(text, "Bearbeiter:") + + refnr = extract_referenznummer(text) + if refnr and refnr not in str(data.get("invoice_note", "")): + existing = str(data.get("invoice_note", "")).strip() + line = f"Referenznummer: {refnr}" + data["invoice_note"] = f"{existing}\n{line}".strip() if existing else line + + # Summen und die uebrigen Exportfelder ergaenzen, damit das Dict ohne UI exportfertig ist. + totals = compute_invoice_totals(items, discount_percent=0.0) + endpoint, scheme = resolve_buyer_endpoint( + data.get("recipient_email", ""), "EM", data.get("leitweg_id", "") + ) + data.update({ + "total_net_amount": totals["tax_basis_total"], + "total_vat_amount": totals["total_vat_amount"], + "total_gross_amount": totals["total_gross_amount"], + "tax_buckets": totals["tax_buckets"], + "line_net_total": totals["line_net_total"], + "allowance_amount": totals["allowance_amount"], + "allowance_reason": "", + "iban": VERKAEUFER_IBAN, + "payment_means_code": "58", + "skonto_percent": 0.0, + "skonto_days": 0, + "vorkasse": False, + "seller_contact_name": data.get("bearbeiter", ""), + "seller_endpoint": VERKAEUFER["email"], + "seller_contact_email": VERKAEUFER["email"], + "recipient_email": endpoint, + "recipient_endpoint_scheme": scheme, + "payment_reference": "", + }) + data["computed_net_amount"] = totals["tax_basis_total"] + return data + + +def evaluate_invoice(data, computed_net_amount, is_xrechnung=False) -> list: + """Ampel-Bewertung einer Rechnung: leere Liste = gruen (freigabefaehig), sonst die + Klartext-Probleme. + + Enthaelt dieselbe Pflichtfeld-Pruefung wie der Einzelmodus (collect_blockers) plus den + Summen-Abgleich gegen die auf der Rechnung ausgewiesene Bemessungsgrundlage. Letzterer ist + der zuverlaessigste Hinweis auf eine unvollstaendige Positionserkennung und traegt im + Stapelbetrieb die Freigabe ohne visuelle Einzelkontrolle.""" + problems = collect_blockers(data, is_xrechnung) + + stated = data.get("stated_tax_basis") + if stated is None: + problems.append( + "Keine ausgewiesene Bemessungsgrundlage gefunden - die Positionssumme ist nicht " + "gegenpruefbar. Bitte einzeln kontrollieren." + ) + elif not sums_match(computed_net_amount, stated): + problems.append( + f"Summen-Abgleich fehlgeschlagen: erkannte Positionen {computed_net_amount:.2f} € " + f"weichen von der ausgewiesenen Bemessungsgrundlage {float(stated):.2f} € ab." + ) + return problems diff --git a/batch_mode.py b/batch_mode.py new file mode 100644 index 0000000..d753804 --- /dev/null +++ b/batch_mode.py @@ -0,0 +1,189 @@ +"""Stapelbetrieb: Oberflaeche fuer die Verarbeitung eines ganzen Ordners. + +Statt jede Rechnung einzeln hoch- und wieder herunterzuladen, liest das Tool einen von DATEV +befuellten Ordner ein, bewertet alle Rechnungen per Ampel und exportiert die freigegebenen in +einem Zug in einen Ausgabeordner. + +Die eigentliche Logik liegt in batch_folder (ohne Streamlit, daher testbar); hier steht nur +die Darstellung. build_zugferd_pdf wird als Parameter hereingereicht, weil sie in main.py +gecacht definiert ist - ein Import waere zirkulaer. +""" +import json + +import streamlit as st + +import batch_folder +from invoice_logic import MAX_ATTACHMENT_PDFS, append_pdf_pages, build_export_basename + +_STATE_KEY = "batch_invoices" + + +def _reset_batch(): + st.session_state.pop(_STATE_KEY, None) + + +def _load_folder(input_dir): + """Liest alle PDFs des Eingangsordners ein und bewertet sie. Ein Lesefehler bei einer + einzelnen Datei stoppt den Stapel nicht - die Datei wird als rot mit Klartext gemeldet.""" + entries = [] + for path in batch_folder.list_input_pdfs(input_dir): + try: + data = batch_folder.analyse_pdf(path.read_bytes()) + problems = batch_folder.evaluate_invoice(data, data["computed_net_amount"]) + except Exception as e: + data, problems = None, [f"PDF konnte nicht gelesen werden: {e}"] + entries.append({"path": path, "data": data, "problems": problems, "attachments": []}) + st.session_state[_STATE_KEY] = entries + + +def _assigned_attachment_names(entries): + """Alle Dateinamen, die als Leistungsnachweis zugeordnet sind - sie sind damit keine + eigenstaendigen Rechnungen mehr.""" + return {name for e in entries for name in e["attachments"]} + + +def render_batch_mode(build_zugferd_pdf): + st.subheader("Stapelverarbeitung") + + subfolders = batch_folder.list_subfolders() + if not subfolders: + st.error( + f"❌ Im freigegebenen Verzeichnis `{batch_folder.BATCH_ROOT_DIR}` sind keine Ordner " + "sichtbar.\n\n" + "Das ist zu erwarten, solange die Netzwerkfreigabe nicht als Volume in den Container " + "eingebunden ist. Ein Windows-Pfad wie `\\\\FS01\\Daten\\...` lässt sich hier nicht " + "direkt eintragen - er muss in `docker-compose.yml` gemountet werden. Danach sind alle " + "Unterordner ohne weitere Konfiguration wählbar." + ) + return + + c_in, c_out = st.columns(2) + with c_in: + input_dir = st.selectbox("Eingangsordner (von DATEV befüllt)", options=subfolders, + key="batch_input_dir", on_change=_reset_batch) + with c_out: + output_dir = st.selectbox("Ausgabeordner (fertige ZUGFeRD-PDFs)", options=subfolders, + key="batch_output_dir") + + if input_dir == output_dir: + st.warning("⚠️ Ein- und Ausgabeordner sind identisch. Die erzeugten PDFs würden neben den " + "Quelldateien liegen. Bitte wählen Sie unterschiedliche Ordner.") + + if st.button("📂 Ordner einlesen", width="stretch"): + with st.spinner("Lese Rechnungen ein..."): + _load_folder(input_dir) + + entries = st.session_state.get(_STATE_KEY) + if entries is None: + return + if not entries: + st.info(f"Im Ordner `{input_dir}` liegen keine PDFs.") + return + + # --- Leistungsnachweise zuordnen ------------------------------------------------- + all_names = [e["path"].name for e in entries] + st.markdown("---") + st.markdown("**Leistungsnachweise zuordnen** (optional)") + st.caption( + f"Ordnen Sie einer Rechnung bis zu {MAX_ATTACHMENT_PDFS} PDFs aus demselben Ordner zu. " + "Zugeordnete Dateien werden nicht mehr als eigene Rechnung behandelt, sondern hinten " + "an die gewählte Rechnung gehängt." + ) + for entry in entries: + name = entry["path"].name + others = [n for n in all_names if n != name] + chosen = st.multiselect( + f"Nachweise für {name}", options=others, key=f"batch_att_{name}", default=[], + ) + entry["attachments"] = chosen[:MAX_ATTACHMENT_PDFS] + if len(chosen) > MAX_ATTACHMENT_PDFS: + st.error(f"Maximal {MAX_ATTACHMENT_PDFS} Nachweise je Rechnung - " + f"die überzähligen werden ignoriert.") + + # --- Uebersicht mit Ampel -------------------------------------------------------- + assigned = _assigned_attachment_names(entries) + invoices = [e for e in entries if e["path"].name not in assigned] + green = [e for e in invoices if not e["problems"]] + red = [e for e in invoices if e["problems"]] + + st.markdown("---") + c1, c2, c3 = st.columns(3) + c1.metric("Rechnungen", len(invoices)) + c2.metric("Freigabefähig", len(green)) + c3.metric("Zu prüfen", len(red)) + + for entry in invoices: + data, problems = entry["data"], entry["problems"] + icon = "✅" if not problems else "⚠️" + nr = (data or {}).get("invoice_number", "") or "?" + empf = (data or {}).get("recipient_name", "") or "?" + betrag = f"{(data or {}).get('computed_net_amount', 0.0):.2f} €" if data else "-" + anh = f" +{len(entry['attachments'])} Nachweis(e)" if entry["attachments"] else "" + + if problems: + with st.expander(f"{icon} {nr} - {empf} - {betrag}{anh}", expanded=False): + for p in problems: + st.warning(p) + st.caption( + "Diese Rechnung wird beim Sammel-Export übersprungen. Korrigieren Sie sie im " + "Reiter *Einzelrechnung* - dort stehen alle Felder zur Bearbeitung bereit." + ) + else: + st.markdown(f"{icon} **{nr}** - {empf} - {betrag}{anh}") + + # --- Sammel-Export ---------------------------------------------------------------- + st.markdown("---") + if not green: + st.info("Keine freigabefähige Rechnung - bitte zuerst die markierten Fälle klären.") + return + + if st.button(f"🔒 {len(green)} Rechnung(en) exportieren", type="primary", width="stretch", + disabled=input_dir == output_dir): + by_name = {e["path"].name: e["path"] for e in entries} + progress = st.progress(0.0) + done, failed = [], [] + + for idx, entry in enumerate(green, start=1): + path, data = entry["path"], entry["data"] + try: + pdf_bytes = path.read_bytes() + if entry["attachments"]: + extras = [by_name[n].read_bytes() for n in entry["attachments"]] + pdf_bytes = append_pdf_pages(pdf_bytes, *extras) + + zugferd = build_zugferd_pdf( + json.dumps(data, default=str, sort_keys=True), pdf_bytes + ) + basename = build_export_basename( + data.get("invoice_number", ""), data.get("recipient_name", ""), + data.get("bearbeiter", ""), + ) + written = batch_folder.write_output(output_dir, f"{basename}.pdf", zugferd) + + # Erst nach erfolgreichem Schreiben verschieben - sonst waere die Quelle weg, + # ohne dass ein Ergebnis existiert. + batch_folder.move_to_processed(path) + for n in entry["attachments"]: + batch_folder.move_to_processed(by_name[n]) + done.append(written.name) + except Exception as e: + failed.append((path.name, str(e))) + progress.progress(idx / len(green)) + + if done: + st.success( + f"✅ {len(done)} Rechnung(en) nach `{output_dir}` exportiert. Die Quelldateien " + f"liegen jetzt im Unterordner `{batch_folder.PROCESSED_SUBDIR}`." + ) + st.caption(" · ".join(done)) + if failed: + # Ein Fehlschlag stoppt den Stapel nicht - die betroffene Datei bleibt im + # Eingangsordner und wird hier namentlich gemeldet. + st.error( + f"❌ {len(failed)} Rechnung(en) konnten nicht exportiert werden und liegen " + "unverändert im Eingangsordner:\n\n" + + "\n".join(f"- **{n}**: {msg}" for n, msg in failed) + ) + if red: + st.info(f"{len(red)} Rechnung(en) wurden übersprungen, weil sie zu prüfen sind.") + _reset_batch() diff --git a/docker-compose.yml b/docker-compose.yml index 563faa4..a72b974 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,29 @@ services: ports: - "8040:8040" environment: - - VLLM_URL=http://10.192.0.140:8002 - - LLM_MODEL=gemma431b - - LLM_BACKEND=vllm - restart: unless-stopped \ No newline at end of file + # Wurzel des Austauschverzeichnisses fuer den Stapelbetrieb. Alles darunter ist im UI + # frei waehlbar - fuer neue Ordner muss diese Datei NICHT angefasst werden. + - BATCH_ROOT_DIR=/data + volumes: + # Netzwerkfreigabe fuer den Stapelbetrieb einbinden. Ein UNC-Pfad (\\FS01\Daten\...) laesst + # sich im Container nicht direkt verwenden - er muss hier gemountet werden. + # + # Variante A (empfohlen): Freigabe ist auf dem Docker-Host bereits eingehaengt, + # dann nur den Host-Pfad eintragen: + # - /mnt/fs01/Daten/Technik/AI:/data + # + # Variante B: Docker haengt die Freigabe selbst per CIFS ein - dazu unten den + # auskommentierten Block 'volumes:' am Dateiende aktivieren und hier verwenden: + # - datev_austausch:/data + - ./batch_data:/data + restart: unless-stopped + +# Variante B fuer den Mount: Docker bindet die SMB-Freigabe selbst ein. Benoetigt ein Konto +# mit Lese- UND Schreibrechten auf der Freigabe. +# volumes: +# datev_austausch: +# driver: local +# driver_opts: +# type: cifs +# o: "username=DIENSTKONTO,password=GEHEIM,uid=1000,gid=1000,vers=3.0" +# device: "//FS01/Daten/Technik/AI" \ No newline at end of file diff --git a/invoice_logic.py b/invoice_logic.py index d681ddc..0714c71 100644 --- a/invoice_logic.py +++ b/invoice_logic.py @@ -207,6 +207,62 @@ def build_payment_terms_note(data): return " ".join(parts) +# Zulaessige Abweichung beim Abgleich der Positionssumme gegen die ausgewiesene +# Bemessungsgrundlage. Ein Cent deckt uebliche Rundungsdifferenzen ab. +SUM_TOLERANCE_EUR = 0.01 + + +def sums_match(computed_net_amount, stated_tax_basis) -> bool: + """True, wenn die Summe der erkannten Positionen zur ausgewiesenen Bemessungsgrundlage + passt. Die Differenz wird VOR dem Vergleich auf Cent gerundet: 100.01 - 100.00 ergibt in + Fliesskomma 0.010000000000005 und wuerde eine exakt eingehaltene Ein-Cent-Toleranz sonst + faelschlich reissen. + + Bewusst hier statt in der UI: Einzel- und Stapelmodus muessen identisch bewerten, sonst + gilt im Stapel als gruen, was einzeln eine Warnung ausloesen wuerde.""" + if stated_tax_basis is None: + return False + return round(abs(float(computed_net_amount) - float(stated_tax_basis)), 2) <= SUM_TOLERANCE_EUR + + +def collect_blockers(data, is_xrechnung): + """Sammelt alle blockierenden Pflichtfeld-Probleme einer Rechnung als Liste von Klartexten. + Leere Liste = exportierbar. + + Bewusst hier statt in der UI: Einzel- und Stapelmodus muessen exakt dieselbe Pruefung + verwenden, sonst gilt im Stapel als gruen, was einzeln blockiert wuerde. + + BT-10 (Leitweg-ID) und BT-49 (elektronische Adresse) sind NUR bei XRechnung Pflicht + (Behoerden/B2G). Bei ZUGFeRD (B2B) duerfen sie leer bleiben und blockieren nicht.""" + blocker = [] + if not str(data.get("invoice_number") or "").strip(): + blocker.append("Rechnungsnummer (BT-1) fehlt.") + if parse_to_strict_xml_date(data.get("invoice_date")) == "": + blocker.append("Rechnungsdatum (BT-2) fehlt oder ist unlesbar (erwartet: YYYY-MM-DD). " + "Bitte korrigieren - es wird KEIN Datum automatisch eingesetzt.") + if is_xrechnung and not str(data.get("leitweg_id") or "").strip(): + blocker.append("Leitweg-ID (BT-10) fehlt. Bei XRechnung (in der Regel an Behörden) ist sie " + "Pflicht (BR-DE-15) und dient als Käuferreferenz - im Reiter 'Kopfdaten' ins " + "Feld 'Leitweg-ID (BT-10)' eintragen.") + if is_xrechnung and not str(data.get("recipient_email") or "").strip(): + blocker.append("Elektronische Adresse des Kunden (BT-49) fehlt. Bei XRechnung ist sie " + "Pflicht (PEPPOL-EN16931-R010). Ist oben eine Leitweg-ID (BT-10) eingetragen, " + "wird sie automatisch als BT-49 (Schema 0204) übernommen. Alternativ im Reiter " + "'Adressen' die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088) eintragen.") + _delivery = data.get("delivery_date") + if _delivery and parse_to_strict_xml_date(_delivery) == "": + blocker.append("Lieferdatum (BT-72) ist unlesbar (erwartet: YYYY-MM-DD). Bitte korrigieren oder leeren.") + if not data.get("items"): + blocker.append("Die Rechnung enthält keine Position (mindestens eine ist erforderlich).") + _pt = str(data.get("payment_term_days") or "").strip() + if _pt == "": + blocker.append("Zahlungsziel (BT-9/BT-20) fehlt. Es wird KEIN Standardwert (z.B. 14 Tage) " + "angenommen - bitte das tatsächliche Zahlungsziel der Rechnung in Tagen eintragen.") + elif not _pt.isdigit(): + blocker.append("Zahlungsziel (BT-9/BT-20) muss eine Zahl in Tagen sein (z.B. 14 oder 30).") + return blocker + + ANGEBOT_PATTERN = re.compile( r"\bAngebot\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)\s+vom\s+(\d{1,2}[.\-/]\d{1,2}[.\-/]\d{2,4})", re.IGNORECASE, diff --git a/main.py b/main.py index 06b38dc..ab7ce2e 100644 --- a/main.py +++ b/main.py @@ -37,8 +37,11 @@ from invoice_logic import ( extract_labeled_header_value, extract_referenznummer, extract_header_metadata_pure_python, + collect_blockers, + sums_match, ) from intro_overlay import render_intro_overlay +from batch_mode import render_batch_mode # ========================================== # 0. SITZUNGS-STATUS INITIALISIEREN (ZÄHLER GEGEN WIDGET-CACHING) @@ -105,6 +108,19 @@ st.set_page_config(page_title="PDF zu E-Rechnung", page_icon=_page_icon, layout= st.title("📄 PDF zu E-Rechnung Konverter") render_intro_overlay() +# Betriebsart-Umschaltung. Bewusst ueber die Seitenleiste + st.stop() statt ueber st.tabs: +# Tabs wuerden verlangen, den gesamten Einzelmodus einzuruecken - so bleibt er unveraendert +# und der Stapelmodus ist rein additiv. +_mode = st.sidebar.radio( + "Betriebsart", + ["Einzelrechnung", "Stapel (Ordner)"], + help="Einzelrechnung: eine PDF hochladen, prüfen, exportieren. " + "Stapel: einen ganzen Ordner einlesen, per Ampel prüfen und gesammelt exportieren.", +) +if _mode.startswith("Stapel"): + render_batch_mode(build_zugferd_pdf) + st.stop() + col1, col2 = st.columns(2) with col1: @@ -543,8 +559,11 @@ with col2: # gegen die Summe der erkannten Positionen abgleichen. Weichen sie ab, ist die # Positionserkennung vermutlich unvollständig - dann deutlich warnen, damit der # Sachbearbeiter die Positionen prüft/korrigiert (v.a. bei komplexen Bündel-Rechnungen). + # Der Abgleich selbst liegt in invoice_logic.sums_match - der Stapelmodus bewertet damit + # identisch. Dort wird die Differenz vor dem Vergleich auf Cent gerundet, sonst reisst + # eine exakt eingehaltene Ein-Cent-Toleranz durch Fliesskomma-Ungenauigkeit. _stated_basis = data.get("stated_tax_basis") - if _stated_basis is not None and abs(computed_net_amount - float(_stated_basis)) > 0.01: + if _stated_basis is not None and not sums_match(computed_net_amount, _stated_basis): st.warning( f"⚠️ **Summen-Abgleich fehlgeschlagen:** Die Summe der erkannten Positionen " f"(**{computed_net_amount:.2f} €**) weicht von der auf der Rechnung ausgewiesenen " @@ -609,31 +628,9 @@ with col2: # (Behörden/B2G). Bei ZUGFeRD (B2B) dürfen sie leer bleiben und blockieren den Export nicht. is_xrechnung = "UBL XML" in export_format - blocker = [] - if not str(inv_num).strip(): - blocker.append("Rechnungsnummer (BT-1) fehlt.") - if parse_to_strict_xml_date(inv_date) == "": - blocker.append("Rechnungsdatum (BT-2) fehlt oder ist unlesbar (erwartet: YYYY-MM-DD). " - "Bitte korrigieren - es wird KEIN Datum automatisch eingesetzt.") - if is_xrechnung and not str(leitweg_id).strip(): - blocker.append("Leitweg-ID (BT-10) fehlt. Bei XRechnung (in der Regel an Behörden) ist sie " - "Pflicht (BR-DE-15) und dient als Käuferreferenz - im Reiter 'Kopfdaten' ins " - "Feld 'Leitweg-ID (BT-10)' eintragen.") - if is_xrechnung and not str(r_email_effective).strip(): - blocker.append("Elektronische Adresse des Kunden (BT-49) fehlt. Bei XRechnung ist sie " - "Pflicht (PEPPOL-EN16931-R010). Ist oben eine Leitweg-ID (BT-10) eingetragen, " - "wird sie automatisch als BT-49 (Schema 0204) übernommen. Alternativ im Reiter " - "'Adressen' die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088) eintragen.") - if inv_delivery and parse_to_strict_xml_date(inv_delivery) == "": - blocker.append("Lieferdatum (BT-72) ist unlesbar (erwartet: YYYY-MM-DD). Bitte korrigieren oder leeren.") - if not edited_items: - blocker.append("Die Rechnung enthält keine Position (mindestens eine ist erforderlich).") - _pt = str(pay_term).strip() - if _pt == "": - blocker.append("Zahlungsziel (BT-9/BT-20) fehlt. Es wird KEIN Standardwert (z.B. 14 Tage) " - "angenommen - bitte das tatsächliche Zahlungsziel der Rechnung in Tagen eintragen.") - elif not _pt.isdigit(): - blocker.append("Zahlungsziel (BT-9/BT-20) muss eine Zahl in Tagen sein (z.B. 14 oder 30).") + # Die Pruefung selbst liegt in invoice_logic.collect_blockers, damit der Stapelmodus + # exakt dieselbe Logik nutzt - sonst gaelte dort als gruen, was hier blockiert wuerde. + blocker = collect_blockers(final_invoice_dict, is_xrechnung) if blocker: st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n" @@ -751,9 +748,13 @@ with col2: st.markdown("---") st.caption( "📝 **Hinweise:**\n\n" - "**Human-in-the-Loop:** " - "Das Tool dient als Automations-Assistent. Vor dem finalen Export ist eine visuelle Endkontrolle der extrahierten Daten " - "(insbesondere Rechnungsnummer und Empfängeradresse) durch den Sachbearbeiter zwingend erforderlich, um KI-Fehlinterpretationen auszuschließen.\n\n" + "**Kontrolle durch den Sachbearbeiter:** " + "Das Tool dient als Automations-Assistent. In der Einzelrechnung ist vor dem Export eine visuelle " + "Endkontrolle der extrahierten Daten (insbesondere Rechnungsnummer und Empfängeradresse) erforderlich. " + "Im Stapelbetrieb tritt an deren Stelle die Sammelfreigabe: exportiert wird nur, was alle Pflichtfelder " + "erfüllt und dessen Positionssumme exakt der ausgewiesenen Bemessungsgrundlage entspricht - alles " + "Auffällige wird markiert und übersprungen. Die Verantwortung für die inhaltliche Richtigkeit bleibt " + "in beiden Fällen beim Sachbearbeiter.\n\n" "**GoBD-Konformität:** " "Das Tool generiert ausschließlich das strukturierte XML- bzw. hybride PDF-Format. Für die Einhaltung der GoBD-Richtlinien " "(u. a. die revisionssichere Archivierung der Original-PDF sowie der generierten E-Rechnung über den gesetzlichen Zeitraum von 10 Jahren) "