Stapelbetrieb: Ordner einlesen, per Ampel prüfen, gesammelt exportieren.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
619159cd33
commit
8e44e64930
62
README.md
62
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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
# 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"
|
||||
|
|
@ -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
59
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) "
|
||||
|
|
|
|||
Loading…
Reference in New Issue