Compare commits

..

6 Commits

Author SHA1 Message Date
dfuhrmann3 c7b3ce4146 Umsatzsteuersatz (BT-152) in die Ampel aufgenommen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:59:34 +02:00
dfuhrmann3 ff88eca741 Ampel prüft jetzt auch Käuferadresse und Positionsfelder (BR-7/10/22/23/25/27).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:51:39 +02:00
dfuhrmann3 61fc8c45a6 Stapel: PDFs direkt hochladen statt Serverordner, Ergebnis als ZIP.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:45:28 +02:00
dfuhrmann3 a4e9918e23 SMB-Zugangsdaten nach .env ausgelagert statt in docker-compose.yml.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:37:53 +02:00
dfuhrmann3 35139ab2b1 Netzwerkfreigabe per CIFS-Volume als empfohlenen Weg dokumentiert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:32:55 +02:00
dfuhrmann3 8e44e64930 Stapelbetrieb: Ordner einlesen, per Ampel prüfen, gesammelt exportieren.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:28:27 +02:00
8 changed files with 751 additions and 52 deletions

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
# Vorlage fuer die Zugangsdaten der Netzwerkfreigabe (Stapelbetrieb).
#
# Kopieren nach '.env' und ausfuellen:
# copy .env.example .env
#
# '.env' ist in .gitignore eingetragen und landet damit NICHT im Repository.
# Diese Vorlage hier enthaelt bewusst keine echten Werte und wird versioniert.
#
# Das Konto braucht Lese- UND Schreibrechte auf //FS01/Daten/Technik/AI - der Stapelbetrieb
# schreibt die fertigen E-Rechnungen zurueck und verschiebt die Quelldateien nach 'verarbeitet'.
SMB_USER=dienstkonto
SMB_PASSWORD=
SMB_DOMAIN=SCITECH

7
.gitignore vendored
View File

@ -4,6 +4,13 @@ models
__pycache__/ __pycache__/
*.pyc *.pyc
# Zugangsdaten der Netzwerkfreigabe - duerfen NICHT ins Repository (.env.example ist die
# Vorlage ohne echte Werte und wird versioniert).
.env
# Lokaler Ersatzordner, wenn keine Freigabe eingebunden ist
batch_data/
# Nur lokal - nicht zum Betrieb des Tools noetig (Tests, Test-Config, Planungsdoku) # Nur lokal - nicht zum Betrieb des Tools noetig (Tests, Test-Config, Planungsdoku)
docs/ docs/
tests/ tests/

View File

@ -2,7 +2,7 @@
## Description ## 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 ## Zugriff
@ -12,10 +12,13 @@ http://erechnung
## Aufbau ## Aufbau
Das System besteht aus zwei Komponenten: | Datei | Zweck |
|---|---|
- **Web-Oberfläche** (`main.py`, Streamlit): PDF-Upload, Datenvalidierung, Export | `main.py` | Streamlit-Oberfläche für die Einzelrechnung, Export |
- **vLLM-Backend**: separat betriebener Inferenz-Server (Standard: `gemma431b`), der die strukturierten Metadaten aus dem Rechnungstext extrahiert | `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) ## Installation (Docker)
@ -26,17 +29,31 @@ git clone https://gitea.scitech-gmbh.de:8443/dfuhrmann/E-Rechnung.git
cd E-Rechnung cd E-Rechnung
``` ```
### 2. Umgebungsvariablen prüfen ### 2. Austauschordner einbinden (optional)
In `docker-compose.yml` sind das vLLM-Backend und Modell hinterlegt: **Für den Stapelbetrieb nicht erforderlich** – dort können die PDFs einfach hochgeladen werden. Dieser Schritt ist nur sinnvoll, wenn die Rechnungen ohnehin auf einer Netzwerkfreigabe liegen und Sie sich das Hoch- und Herunterladen sparen wollen.
```yaml Ist ein Verzeichnis eingebunden, liest der Stapelbetrieb Rechnungen direkt daraus und schreibt die fertigen E-Rechnungen zurück. Die Netzwerkfreigabe wird dafür in `docker-compose.yml` eingetragen – der Container arbeitet dann **direkt** auf ihr, es wird nichts kopiert oder synchronisiert.
environment:
- VLLM_URL=http://10.192.0.140:8002 Der Windows-Pfad wird dabei in Docker-Schreibweise angegeben: aus `\\FS01\Daten\Technik\AI` wird `//FS01/Daten/Technik/AI`.
- LLM_MODEL=gemma431b
**1. Zugangsdaten hinterlegen** – nicht in `docker-compose.yml`, sondern in einer `.env`-Datei. Diese ist in `.gitignore` eingetragen und landet nicht im Repository:
```cmd
copy .env.example .env
``` ```
Bei Bedarf hier anpassen (z.B. anderes Modell oder Backend-Host). Dann `.env` öffnen und Benutzer, Passwort und Domäne des Dienstkontos eintragen. Das Konto braucht Lese- **und** Schreibrechte auf der Freigabe.
**2. Volume aktivieren** – in `docker-compose.yml` den Block am Dateiende einkommentieren sowie beim Service `- datev_austausch:/data` statt `- ./batch_data:/data` verwenden. Beides steht dort vorbereitet.
Wichtig: Binden Sie den **übergeordneten** Ordner ein, nicht einen einzelnen Unterordner. `Test1`, `Test2` und jeder künftige Unterordner sind dann in der Oberfläche frei wählbar, ohne dass diese Datei erneut angefasst werden muss.
Ist die Freigabe auf dem Docker-Host bereits eingehängt, genügt stattdessen ein einfacher Bind-Mount ohne Zugangsdaten (`- /mnt/fs01/Daten/Technik/AI:/data`).
Ein Eintippen des Pfads in der Oberfläche ist nicht möglich: Die Anwendung läuft auf dem Server, der Browser auf Ihrem PC – ein dort gewählter Pfad wäre für den Server bedeutungslos, und Browser geben Web-Anwendungen aus Sicherheitsgründen keine Dateipfade.
Wird nichts eingebunden, funktioniert die Einzelrechnung uneingeschränkt; der Stapelbetrieb weist dann auf den fehlenden Mount hin.
### 3. Container bauen und starten ### 3. Container bauen und starten
@ -50,16 +67,39 @@ Die Anwendung ist danach unter `http://localhost:8040` (bzw. im Firmennetz unter
## Use ## 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. 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". 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. **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. 3. **Extraktion:** Starten Sie die Analyse über den Button "Daten extrahieren".
4. **Export:** Wählen Sie das gewünschte Format (XRechnung-XML oder ZUGFeRD-PDF) und starten Sie den Download. 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
Mehrere Rechnungen in einem Durchgang. Es ist **keine Einrichtung nötig** – der Stapel funktioniert direkt.
1. **Dateien auswählen:** Markieren Sie im Explorer alle gewünschten Rechnungs-PDFs und ziehen Sie sie in das Upload-Feld. Lokale Ordner und Netzlaufwerke funktionieren gleichermaßen.
2. **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 in der Betriebsart *Einzelrechnung* korrigieren.
3. **Nachweise zuordnen (optional):** Ist ein Leistungsnachweis mit hochgeladen, ordnen Sie ihn der zugehörigen Rechnung zu. Er zählt dann nicht mehr als eigene Rechnung, sondern wird hinten angehängt.
4. **Konvertieren:** Alle grünen Rechnungen werden in einem Zug verarbeitet und als ZIP zum Download angeboten.
**Optional: Ordner auf dem Server.** Ist ein Austauschverzeichnis eingebunden (siehe Installation), erscheint zusätzlich die Quelle „Ordner auf dem Server". Dann werden Ein- und Ausgabeordner ausgewählt, die Ergebnisse landen direkt im Ausgabeordner und die Quelldateien wandern nach `verarbeitet` – gelöscht wird nichts. Das spart das Herunterladen, ist aber nicht Voraussetzung für den Stapelbetrieb.
## Notes ## Notes
### Human-in-the-Loop ### Kontrolle durch den Sachbearbeiter
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.
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 ### 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`.

262
batch_folder.py Normal file
View File

@ -0,0 +1,262 @@
"""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 build_zip(files) -> bytes:
"""Packt fertige Exporte in ein ZIP - der Rueckweg fuer den Upload-Stapel, bei dem es
keinen Ausgabeordner auf dem Server gibt.
Nimmt ein Dict {name: bytes} oder eine Liste [(name, bytes), ...]. Doppelte Namen werden
mit _2, _3 ... entzerrt, statt sich gegenseitig zu ueberschreiben - zwei Rechnungen koennen
denselben Exportnamen ergeben."""
import zipfile
items = list(files.items()) if isinstance(files, dict) else list(files)
buf = io.BytesIO()
used = set()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
for name, data in items:
final = name
if final in used:
stem, suffix = Path(name).stem, Path(name).suffix
counter = 2
while f"{stem}_{counter}{suffix}" in used:
counter += 1
final = f"{stem}_{counter}{suffix}"
used.add(final)
archive.writestr(final, data)
return buf.getvalue()
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

239
batch_mode.py Normal file
View File

@ -0,0 +1,239 @@
"""Stapelbetrieb: mehrere Rechnungen in einem Durchgang statt einzeln.
Zwei Quellen:
* Dateien hochladen - funktioniert immer, egal ob die PDFs lokal oder auf einer
Netzwerkfreigabe liegen; der Browser kommt an beides. Ergebnis kommt als ZIP zurueck.
* Ordner auf dem Server - nur wenn ein Austauschverzeichnis gemountet ist. Dann bleiben die
Dateien serverseitig und die Ergebnisse landen direkt im Ausgabeordner.
Die Logik liegt in batch_folder (ohne Streamlit, daher testbar); hier steht nur die
Darstellung. build_zugferd_pdf wird 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 _make_entry(name, pdf_bytes):
"""Analysiert eine PDF und verpackt sie mit ihrer Bewertung. Ein Lesefehler stoppt den
Stapel nicht - die Datei wird als zu pruefen mit Klartext gemeldet."""
try:
data = batch_folder.analyse_pdf(pdf_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}"]
return {"name": name, "bytes": pdf_bytes, "data": data,
"problems": problems, "attachments": [], "source": None}
def _render_overview(entries, build_zugferd_pdf, write_to_folder=None):
"""Zuordnung, Ampel und Sammel-Export. write_to_folder ist der Ausgabeordner im
Server-Betrieb; ist er None, wird ein ZIP zum Download erzeugt."""
all_names = [e["name"] for e in entries]
if len(entries) > 1:
with st.expander("Leistungsnachweise zuordnen (optional)"):
st.caption(
f"Gehoert eine der hochgeladenen PDFs als Nachweis zu einer Rechnung, ordnen Sie "
f"sie hier zu (max. {MAX_ATTACHMENT_PDFS} je Rechnung). Zugeordnete Dateien "
"zaehlen dann nicht mehr als eigene Rechnung, sondern werden hinten angehaengt."
)
for entry in entries:
others = [n for n in all_names if n != entry["name"]]
chosen = st.multiselect(
f"Nachweise für {entry['name']}", options=others,
key=f"batch_att_{entry['name']}",
)
entry["attachments"] = chosen[:MAX_ATTACHMENT_PDFS]
if len(chosen) > MAX_ATTACHMENT_PDFS:
st.error(f"Maximal {MAX_ATTACHMENT_PDFS} Nachweise je Rechnung - "
"die überzähligen werden ignoriert.")
assigned = {n for e in entries for n in e["attachments"]}
invoices = [e for e in entries if e["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 = entry["data"] or {}
nr = data.get("invoice_number") or "?"
empf = data.get("recipient_name") or "?"
betrag = f"{data.get('computed_net_amount', 0.0):.2f} €" if entry["data"] else "-"
anh = f" · +{len(entry['attachments'])} Nachweis(e)" if entry["attachments"] else ""
titel = f"{nr} — {empf} — {betrag}{anh}"
if entry["problems"]:
with st.expander(f"⚠️ {titel}"):
for p in entry["problems"]:
st.warning(p)
st.caption(
"Diese Rechnung wird beim Sammel-Export übersprungen. Korrigieren Sie sie in "
"der Betriebsart *Einzelrechnung* - dort stehen alle Felder bereit."
)
else:
st.markdown(f"✅ **{nr}** — {empf} — {betrag}{anh}")
st.markdown("---")
if not green:
st.info("Keine freigabefähige Rechnung - bitte zuerst die markierten Fälle klären.")
return
if not st.button(f"🔒 {len(green)} Rechnung(en) konvertieren", type="primary", width="stretch"):
return
by_name = {e["name"]: e for e in entries}
progress = st.progress(0.0)
results, failed = [], []
for idx, entry in enumerate(green, start=1):
try:
pdf_bytes = entry["bytes"]
if entry["attachments"]:
extras = [by_name[n]["bytes"] for n in entry["attachments"]]
pdf_bytes = append_pdf_pages(pdf_bytes, *extras)
zugferd = build_zugferd_pdf(
json.dumps(entry["data"], default=str, sort_keys=True), pdf_bytes
)
basename = build_export_basename(
entry["data"].get("invoice_number", ""),
entry["data"].get("recipient_name", ""),
entry["data"].get("bearbeiter", ""),
)
results.append((f"{basename}.pdf", zugferd, entry))
except Exception as e:
failed.append((entry["name"], str(e)))
progress.progress(idx / len(green))
if results and write_to_folder is not None:
# Server-Betrieb: direkt in den Ausgabeordner schreiben, Quellen wegraeumen.
written = []
for name, data, entry in results:
try:
out = batch_folder.write_output(write_to_folder, name, data)
batch_folder.move_to_processed(entry["source"])
for n in entry["attachments"]:
batch_folder.move_to_processed(by_name[n]["source"])
written.append(out.name)
except Exception as e:
failed.append((entry["name"], str(e)))
if written:
st.success(
f"✅ {len(written)} Rechnung(en) nach `{write_to_folder}` geschrieben. Die "
f"Quelldateien liegen jetzt im Unterordner `{batch_folder.PROCESSED_SUBDIR}`."
)
st.caption(" · ".join(written))
_reset_batch()
elif results:
# Upload-Betrieb: alles in ein ZIP, das der Nutzer herunterlaedt.
zip_bytes = batch_folder.build_zip([(name, data) for name, data, _ in results])
st.success(f"✅ {len(results)} Rechnung(en) konvertiert.")
st.download_button(
f"⬇️ {len(results)} E-Rechnung(en) als ZIP herunterladen",
data=zip_bytes,
file_name="e-rechnungen.zip",
mime="application/zip",
type="primary",
width="stretch",
)
with st.expander("Enthaltene Dateien"):
for name, data, _ in results:
st.markdown(f"- `{name}` ({len(data) / 1024:.0f} KB)")
if failed:
# Ein Fehlschlag stoppt den Stapel nicht - die uebrigen laufen durch.
st.error(
f"❌ {len(failed)} Rechnung(en) konnten nicht konvertiert werden:\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.")
def render_batch_mode(build_zugferd_pdf):
st.subheader("Stapelverarbeitung")
st.caption(
"Mehrere Rechnungen in einem Durchgang: einlesen, per Ampel prüfen, gesammelt "
"konvertieren. Geprüft wird, ob alle Pflichtfelder belegt sind und ob die Summe der "
"erkannten Positionen zur ausgewiesenen Bemessungsgrundlage passt."
)
subfolders = batch_folder.list_subfolders()
quelle = "Dateien hochladen"
if subfolders:
quelle = st.radio(
"Quelle", ["Dateien hochladen", "Ordner auf dem Server"], horizontal=True,
on_change=_reset_batch,
)
if quelle == "Dateien hochladen":
uploads = st.file_uploader(
"Rechnungs-PDFs auswählen (mehrere möglich)", type=["pdf"],
accept_multiple_files=True, key="batch_uploads",
)
st.caption(
"Markieren Sie im Explorer alle gewünschten PDFs und ziehen Sie sie hierher - "
"lokale Ordner und Netzlaufwerke funktionieren gleichermaßen."
)
if not uploads:
return
signature = [(f.name, f.size) for f in uploads]
if st.session_state.get("_batch_signature") != signature:
st.session_state._batch_signature = signature
with st.spinner(f"Analysiere {len(uploads)} Rechnung(en)..."):
entries = []
for f in uploads:
f.seek(0)
entries.append(_make_entry(f.name, f.read()))
st.session_state[_STATE_KEY] = entries
entries = st.session_state.get(_STATE_KEY) or []
if entries:
_render_overview(entries, build_zugferd_pdf)
return
# --- Server-Ordner ---------------------------------------------------------------
c_in, c_out = st.columns(2)
with c_in:
input_dir = st.selectbox("Eingangsordner", options=subfolders,
key="batch_input_dir", on_change=_reset_batch)
with c_out:
output_dir = st.selectbox("Ausgabeordner", options=subfolders, key="batch_output_dir")
if input_dir == output_dir:
st.warning("⚠️ Ein- und Ausgabeordner sind identisch. Bitte unterschiedliche Ordner wählen.")
return
if st.button("📂 Ordner einlesen", width="stretch"):
with st.spinner("Lese Rechnungen ein..."):
entries = []
for path in batch_folder.list_input_pdfs(input_dir):
entry = _make_entry(path.name, path.read_bytes())
entry["source"] = path
entries.append(entry)
st.session_state[_STATE_KEY] = entries
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
_render_overview(entries, build_zugferd_pdf, write_to_folder=output_dir)

View File

@ -6,7 +6,36 @@ services:
ports: ports:
- "8040:8040" - "8040:8040"
environment: environment:
- VLLM_URL=http://10.192.0.140:8002 # Wurzel des Austauschverzeichnisses fuer den Stapelbetrieb. Alles darunter ist im UI
- LLM_MODEL=gemma431b # frei waehlbar - fuer neue Ordner muss diese Datei NICHT angefasst werden.
- LLM_BACKEND=vllm - BATCH_ROOT_DIR=/data
restart: unless-stopped volumes:
# Netzwerkfreigabe einbinden: die naechste Zeile einkommentieren und dafuer
# './batch_data:/data' darunter entfernen. Der Container arbeitet dann DIREKT auf der
# Freigabe - es wird nichts kopiert oder synchronisiert.
# - datev_austausch:/data
#
# Vorbelegung ohne Freigabe: lokaler Ordner, damit der Container startet. Der
# Stapelbetrieb findet darin nichts und weist darauf hin; die Einzelrechnung
# funktioniert uneingeschraenkt.
- ./batch_data:/data
restart: unless-stopped
# Netzwerkfreigabe als Volume. 'device' ist der Windows-Netzwerkpfad in Docker-Schreibweise:
# aus \\FS01\Daten\Technik\AI wird //FS01/Daten/Technik/AI.
#
# Es wird der UEBERGEORDNETE Ordner eingebunden, nicht ein einzelner Unterordner - dadurch
# sind Test1, Test2 und jeder kuenftige Unterordner im UI waehlbar, ohne diese Datei erneut
# anzufassen.
#
# WICHTIG: Zugangsdaten stehen NICHT hier, sondern in der Datei '.env' (siehe .env.example).
# Diese Datei liegt im Git - ein hier eingetragenes Passwort waere im Repository sichtbar.
# Das Konto braucht Lese- UND Schreibrechte auf der Freigabe.
#
# volumes:
# datev_austausch:
# driver: local
# driver_opts:
# type: cifs
# device: "//FS01/Daten/Technik/AI"
# o: "username=${SMB_USER},password=${SMB_PASSWORD},domain=${SMB_DOMAIN},uid=1000,gid=1000,vers=3.0"

View File

@ -207,6 +207,113 @@ def build_payment_terms_note(data):
return " ".join(parts) 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.")
# BG-7 Kaeuferadresse: ohne Name, Strasse, PLZ und Ort verletzt die Rechnung BR-7/BR-10 und
# wird von einem Validator abgewiesen. Fuer BEIDE Formate Pflicht, nicht nur XRechnung.
for _feld, _bt, _bezeichnung in (
("recipient_name", "BT-44", "Name des Kunden"),
("recipient_street", "BT-50", "Straße des Kunden"),
("recipient_zip", "BT-53", "Postleitzahl des Kunden"),
("recipient_city", "BT-52", "Ort des Kunden"),
):
if not str(data.get(_feld) or "").strip():
blocker.append(f"{_bezeichnung} ({_bt}) fehlt. Die Käuferadresse ist vollständig "
"erforderlich, sonst wird die Rechnung beim Empfänger abgewiesen.")
_items = data.get("items") or []
if not _items:
blocker.append("Die Rechnung enthält keine Position (mindestens eine ist erforderlich).")
for _i, _item in enumerate(_items, start=1):
if not str(_item.get("name") or "").strip():
blocker.append(f"Position {_i}: Artikelname (BT-153) fehlt.")
try:
_menge = float(_item.get("quantity") or 0)
except (TypeError, ValueError):
_menge = 0.0
if _menge == 0:
blocker.append(f"Position {_i}: Menge (BT-129) ist 0 oder unlesbar.")
if not str(_item.get("unit") or "").strip():
blocker.append(f"Position {_i}: Einheit (BT-130) fehlt.")
try:
_preis = float(_item.get("unit_price") or 0)
except (TypeError, ValueError):
_preis = -1.0
# 0,00 ist zulaessig (kostenlose Position), negativ nicht (BR-27).
if _preis < 0:
blocker.append(f"Position {_i}: Einzelpreis (BT-146) darf nicht negativ sein.")
# BT-152 je Position: 0 % ist zulaessig (steuerbefreit), ein unlesbarer Wert nicht.
_satz = _item.get("vat_rate_percent")
if _satz not in (None, ""):
try:
float(_satz)
except (TypeError, ValueError):
blocker.append(f"Position {_i}: Umsatzsteuersatz (BT-152) ist unlesbar.")
# BT-152 Umsatzsteuersatz auf Kopfebene: die XML-Erzeugung liest ihn dort fest aus und
# scheitert sonst mit einer kryptischen Meldung. 0 % ist zulaessig (steuerbefreit).
_vat = data.get("vat_rate_percent")
if _vat in (None, ""):
blocker.append("Umsatzsteuersatz (BT-152) fehlt. Ohne ihn lässt sich die Steuer nicht "
"ausweisen und die Rechnung wird beim Empfänger abgewiesen.")
else:
try:
float(_vat)
except (TypeError, ValueError):
blocker.append("Umsatzsteuersatz (BT-152) ist unlesbar (erwartet wird eine Zahl, z.B. 19).")
_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( 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})", 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, re.IGNORECASE,

59
main.py
View File

@ -37,8 +37,11 @@ from invoice_logic import (
extract_labeled_header_value, extract_labeled_header_value,
extract_referenznummer, extract_referenznummer,
extract_header_metadata_pure_python, extract_header_metadata_pure_python,
collect_blockers,
sums_match,
) )
from intro_overlay import render_intro_overlay from intro_overlay import render_intro_overlay
from batch_mode import render_batch_mode
# ========================================== # ==========================================
# 0. SITZUNGS-STATUS INITIALISIEREN (ZÄHLER GEGEN WIDGET-CACHING) # 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") st.title("📄 PDF zu E-Rechnung Konverter")
render_intro_overlay() 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) col1, col2 = st.columns(2)
with col1: with col1:
@ -543,8 +559,11 @@ with col2:
# gegen die Summe der erkannten Positionen abgleichen. Weichen sie ab, ist die # gegen die Summe der erkannten Positionen abgleichen. Weichen sie ab, ist die
# Positionserkennung vermutlich unvollständig - dann deutlich warnen, damit der # Positionserkennung vermutlich unvollständig - dann deutlich warnen, damit der
# Sachbearbeiter die Positionen prüft/korrigiert (v.a. bei komplexen Bündel-Rechnungen). # 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") _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( st.warning(
f"⚠️ **Summen-Abgleich fehlgeschlagen:** Die Summe der erkannten Positionen " f"⚠️ **Summen-Abgleich fehlgeschlagen:** Die Summe der erkannten Positionen "
f"(**{computed_net_amount:.2f} €**) weicht von der auf der Rechnung ausgewiesenen " 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. # (Behörden/B2G). Bei ZUGFeRD (B2B) dürfen sie leer bleiben und blockieren den Export nicht.
is_xrechnung = "UBL XML" in export_format is_xrechnung = "UBL XML" in export_format
blocker = [] # Die Pruefung selbst liegt in invoice_logic.collect_blockers, damit der Stapelmodus
if not str(inv_num).strip(): # exakt dieselbe Logik nutzt - sonst gaelte dort als gruen, was hier blockiert wuerde.
blocker.append("Rechnungsnummer (BT-1) fehlt.") blocker = collect_blockers(final_invoice_dict, is_xrechnung)
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).")
if blocker: if blocker:
st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n" st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n"
@ -751,9 +748,13 @@ with col2:
st.markdown("---") st.markdown("---")
st.caption( st.caption(
"📝 **Hinweise:**\n\n" "📝 **Hinweise:**\n\n"
"**Human-in-the-Loop:** " "**Kontrolle durch den Sachbearbeiter:** "
"Das Tool dient als Automations-Assistent. Vor dem finalen Export ist eine visuelle Endkontrolle der extrahierten Daten " "Das Tool dient als Automations-Assistent. In der Einzelrechnung ist vor dem Export eine visuelle "
"(insbesondere Rechnungsnummer und Empfängeradresse) durch den Sachbearbeiter zwingend erforderlich, um KI-Fehlinterpretationen auszuschließen.\n\n" "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:** " "**GoBD-Konformität:** "
"Das Tool generiert ausschließlich das strukturierte XML- bzw. hybride PDF-Format. Für die Einhaltung der GoBD-Richtlinien " "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) " "(u. a. die revisionssichere Archivierung der Original-PDF sowie der generierten E-Rechnung über den gesetzlichen Zeitraum von 10 Jahren) "