Stapelbetrieb: Ordner einlesen, per Ampel prüfen, gesammelt exportieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-30 16:28:27 +02:00
parent 619159cd33
commit 8e44e64930
6 changed files with 581 additions and 51 deletions

View File

@ -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`.

236
batch_folder.py Normal file
View File

@ -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

189
batch_mode.py Normal file
View File

@ -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()

View File

@ -6,7 +6,29 @@ services:
ports:
- "8040:8040"
environment:
- VLLM_URL=http://10.192.0.140:8002
- LLM_MODEL=gemma431b
- LLM_BACKEND=vllm
# 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"

View File

@ -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,

59
main.py
View File

@ -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) "