Compare commits
6 Commits
main
...
automation
| Author | SHA1 | Date |
|---|---|---|
|
|
c7b3ce4146 | |
|
|
ff88eca741 | |
|
|
61fc8c45a6 | |
|
|
a4e9918e23 | |
|
|
35139ab2b1 | |
|
|
8e44e64930 |
|
|
@ -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
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
78
README.md
78
README.md
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
107
invoice_logic.py
107
invoice_logic.py
|
|
@ -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
59
main.py
|
|
@ -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) "
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue