This commit is contained in:
dfuhrmann3 2026-07-02 10:13:21 +02:00
parent 76ee65accf
commit 11830c060a
3 changed files with 267 additions and 105 deletions

208
README.md Normal file
View File

@ -0,0 +1,208 @@
\# 📄 Enterprise PDF-zu-XRechnung-Konverter (Hybrid-Architektur)
Dieses Tool ist ein hochpräziser, datenschutzkonformer Konverter, der herkömmliche B2B-Rechnungen im PDF-Format vollautomatisch in gesetzeskonforme \*\*XRechnung-XMLs (UBL-Standard, Version 3.0)\*\* transformiert.
Das System setzt auf einen \*\*innovativen Hybrid-Ansatz\*\*: Es kombiniert die semantische Flexibilität eines modernen, lokalen Large Language Models (LLM) für unstrukturierte Kopfdaten mit der unfehlbaren mathematischen Präzision von nativem Python-Code für die Positionstabelle.
\---
\## 🚀 Key Features
\* \*\*100 % Lokale Ausführung \& DSGVO-konform:\*\* Keine Cloud-APIs (kein OpenAI, Anthropic etc.). Alle Daten verbleiben vollständig auf der eigenen Hardware. Absolute Datensouveränität für sensible Finanzdaten.
\* \*\*Kugelsichere Tabellen-Extraktion:\*\* Positionen, Mengen, Einheiten und Einzelpreise werden mittels optimierter regulärer Ausdrücke (Regex) fehlerfrei ausgelesen – komplett immun gegen KI-Halluzinationen oder Token-Abbrüche.
\* \*\*Finanzmathematische Präzision:\*\* Berechnung aller Netto-, MwSt- und Bruttosummen mittels Pythons `decimal`-Modul inklusive striktem \*\*kaufmännischen Runden (`ROUND\_HALF\_UP`)\*\*. Keine Floating-Point-Ungenauigkeiten.
\* \*\*Human-in-the-Loop Frontend:\*\* Eine interaktive Streamlit-Oberfläche erlaubt die sofortige visuelle Kontrolle und manuelle Korrektur aller extrahierten Daten vor dem finalen Export.
\* \*\*Validierte XML-Ausgabe:\*\* Generiert valides UBL-2.1-Invoice-XML, das die strengen Konformitätsprüfungen der offiziellen Koordinierungsstelle für IT-Standards (KoSIT) fehlerfrei (\*\*0 Fehler\*\*) besteht.
\---
\## 🛠 Wie es funktioniert (Hybrid-Architektur)
Das Tool teilt die Extraktion in zwei spezialisierte Verarbeitungsstufen auf:
```
\[PDF Dokument]
  │
  ├──> Stufe 1: Lokale KI (Qwen3.5-Instruct) ──> Extraktion von Metadaten (Kunde, Adresse, Datum, Referenz)
  │
  └──> Stufe 2: Pure Python Engine (Regex) ──> Extraktion der Artikel (Beschreibung, Menge, Einheit, Preis)
```
1\. \*\*Stufe 1: Stammdaten via Lokaler KI (LLM)\*\*
  Kopfdaten wie Rechnungsnummer, Datumsangaben, Bestell-Referenzen und Kundenadressen wandern auf Rechnungen visuell stark hin und her. Hier glänzt das lokale KI-Modell: Es versteht den Kontext des gesamten Texts und extrahiert diese unstrukturierten Metadaten zuverlässig. Um strukturelle Fehler zu vermeiden, wird die KI über eine strikte JSON-Schema-Grammatik (`LlamaGrammar`) gezwungen, die Daten exakt in ein vordefiniertes Pydantic-Schema zu gießen.
2\. \*\*Stufe 2: Positionen via Python-Regex\*\*
  Finanztabellen erfordern 0 % Fehlertoleranz. Statt das LLM mit langen Tabellen zu überfordern (Gefahr von Abbrüchen oder Zahlendrehern), scannt ein robuster Python-Algorithmus den extrahierten Textstrom nach Mustern von Mengenangaben und Einheiten (z. B. `1,375 Tag(e)` oder `2 Std.`). Er greift sich nachgelagerte Preise, isoliert den zugehörigen Beschreibungstext (auch bei komplexen mehrzeiligen Umbrüchen) und filtert irrelevante Systemtexte automatisch heraus.
\---
\## 🤖 Das KI-Modell
Das Tool ist exakt auf das folgende Open-Weights-Modell optimiert:
\* \*\*Modell:\*\* `Qwen3.5-4B-Instruct`
\* \*\*Format:\*\* GGUF (optimiert für CPU/GPU-Inferenz via `llama-cpp`)
\* \*\*Quantisierung:\*\* `Q8\_0` (Hohe Genauigkeit bei exzellenter Performance)
\*\*Wichtig:\*\* Es muss zwingend die \*\*`Instruct`\*\*-Version des Modells verwendet werden (`Qwen3.5-4B-Instruct-Q8\_0.gguf`), da das reine Base-Modell nicht in der Lage ist, strukturierte Systemanweisungen und JSON-Grammatiken korrekt zu befolgen.
\---
\## 💻 Installation \& Setup
\### 1. Repository klonen \& Ordnerstruktur vorbereiten
```bash
git clone \[https://github.com/dein-username/pdf-to-xrechnung.git](https://github.com/dein-username/pdf-to-xrechnung.git)
cd pdf-to-xrechnung
mkdir -p models
```
\### 2. Lokales Modell herrichten
Lade das GGUF-Modell von Hugging Face (z. B. aus dem Repository von `bartowski`) herunter und platziere es im `models`-Ordner:
\* \*\*Pfad:\*\* `models/Qwen3.5-4B-Instruct-Q8\_0.gguf`
\### 3. Requirements erstellen (`requirements.txt`)
Für ein sauberes und schlankes Repository erstelle eine `requirements.txt` mit folgendem minimalen Inhalt:
```text
streamlit==1.58.0
llama\_cpp\_python==0.3.32
pydantic==2.13.4
pymupdf==1.28.0
```
\### 4. Abhängigkeiten installieren (inkl. GPU-Beschleunigung)
Aktiviere deine virtuelle Umgebung (`venv`) und führe den passenden Befehl aus, damit das KI-Modell deine Nvidia-Grafikkarte (CUDA) für rasend schnelle Antworten nutzt:
\*\*Unter Windows (PowerShell):\*\*
```powershell
$env:GGML\_CUDA="1"; pip install -r requirements.txt --force-reinstall --no-cache-dir
```
\*\*Unter Linux / macOS:\*\*
```bash
GGML\_CUDA=1 pip install -r requirements.txt --force-reinstall --no-cache-dir
```
\---
\## 🎈 Benutzung
Starte die Applikation über dein Terminal:
```bash
streamlit run app.py
```
1\. \*\*PDF hochladen:\*\* Ziehe eine herkömmliche Rechnungs-PDF per Drag-and-Drop in die left Spalte der Streamlit-Oberfläche.
2\. \*\*Daten extrahieren:\*\* Klicke auf `🚀 Daten extrahieren`. Im Hintergrund analysiert die hybride Engine das Dokument.
3\. \*\*Prüfen \& Editieren:\*\* In der rechten Spalte erscheinen alle erkannten Kopfdaten sowie die editierbare Positionstabelle. Du kannst jeden Wert manuell anpassen, Zeilen löschen oder hinzufügen.
4\. \*\*Exportieren:\*\* Klicke auf `🔒 XRechnung exportieren`. Das Tool berechnet kaufmännisch alle Summen und stellt dir die fertige, konforme XML-Datei direkt als Download (`📥 Download XML`) bereit.
\---
\## ⚖️ Rechtliche \& Finanzmathematische Hinweise
\* \*\*GoBD-Konformität:\*\* Dieses Tool generiert das strukturierte XML-Format. Der Nutzer ist selbst dafür verantwortlich, sowohl die Original-PDF als auch die generierte XML-Datei gemäß den GoBD-Richtlinien revisionssicher für 10 Jahre zu archivieren.
\* \*\*Prüfpflicht:\*\* Das Tool arbeitet nach dem Prinzip \*„Human-in-the-Loop“\*. Vor dem XML-Export ist eine kurze visuelle Endkontrolle der extrahierten Daten im Streamlit-Frontend durch den Sachbearbeiter zwingend empfohlen.

99
app.py
View File

@ -5,6 +5,7 @@ import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from xml.dom import minidom from xml.dom import minidom
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP # Für finanzmathematisch exaktes Runden
import fitz # PyMuPDF import fitz # PyMuPDF
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from llama_cpp import Llama, LlamaGrammar from llama_cpp import Llama, LlamaGrammar
@ -26,17 +27,25 @@ class InvoiceMetadata(BaseModel):
# ========================================== # ==========================================
# 2. FEHLERFREIES PYTHON-PARSING DER TABELLE # 2. PRÄZISE MATHEMATISCHE FUNKTIONEN
# ========================================== # ==========================================
def kaufmaennisches_runden(betrag, nachkommastellen=2):
"""
Rundet Beträge finanzmathematisch exakt nach kaufmännischer Art (ab 5 aufrunden).
Verhindert Pythons Banker's Rounding Fehler.
"""
if not betrag:
return 0.0
quantizer = Decimal('1.' + '0' * nachkommastellen)
return float(Decimal(f"{betrag:.6f}").quantize(quantizer, rounding=ROUND_HALF_UP))
def clean_german_float(val_str): def clean_german_float(val_str):
if not val_str: if not val_str:
return 0.0 return 0.0
# Entferne Währung, Leerzeichen und unsichtbare Steuerzeichen
s = str(val_str).strip().replace("€", "").replace(" ", "").replace("'", "").replace("\n", "").replace("\r", "") s = str(val_str).strip().replace("€", "").replace(" ", "").replace("'", "").replace("\n", "").replace("\r", "")
if not s: if not s:
return 0.0 return 0.0
# Konvertierung von Deutsch (1.300,00) zu Python Float (1300.00)
if "." in s and "," in s: if "." in s and "," in s:
s = s.replace(".", "").replace(",", ".") s = s.replace(".", "").replace(",", ".")
elif "," in s: elif "," in s:
@ -47,18 +56,11 @@ def clean_german_float(val_str):
return 0.0 return 0.0
def extract_invoice_items_pure_python(text): def extract_invoice_items_pure_python(text):
"""
Extrem robuste Tabellen-Extraktion.
Findet Positionen auch dann, wenn PDF-Text Zeilenumbrüche zwischen den Spalten erzwingt.
"""
items = [] items = []
# 1. Text zeilenweise normalisieren und leere Zeilen entfernen
lines = [line.strip() for line in text.split("\n") if line.strip()] lines = [line.strip() for line in text.split("\n") if line.strip()]
# 2. Finde alle Indizes, die wie eine Mengenangabe mit Einheit aussehen unit_pattern = re.compile(r"^(\d+(?:[\.,]\d+)?)\s*(Tag\(e\)|Std\.|Stk\.|Stunden|Stück|stk|stck|stück)$", re.IGNORECASE)
# Erkennt: "1,375 Tag(e)", "2 Std.", "10 Stk.", "1,00 Stück" price_stop_pattern = re.compile(r"^[\d\.]+,[\d]{2}$")
unit_pattern = re.compile(r"^(\d+(?:,\d+)?)\s*(Tag\(e\)|Std\.|Stk\.|Stunden|Stück)$", re.IGNORECASE)
for i, line in enumerate(lines): for i, line in enumerate(lines):
match = unit_pattern.match(line) match = unit_pattern.match(line)
@ -66,42 +68,32 @@ def extract_invoice_items_pure_python(text):
raw_qty = match.group(1) raw_qty = match.group(1)
raw_unit = match.group(2) raw_unit = match.group(2)
# Jetzt schauen wir uns die Zeilen direkt *danach* an, um die Preise zu finden
# Meistens folgt: Einzelpreis (Zeile i+1) und Gesamtpreis (Zeile i+2)
raw_price = "0,00" raw_price = "0,00"
if i + 1 < len(lines): for k in range(1, 3):
# Prüfen, ob die nächste Zeile ein Geldbetrag ist (z.B. "1.300,00") if i + k < len(lines):
if re.match(r"^[\d.,]+$", lines[i+1]): candidate = lines[i+k].replace("€", "").replace(" ", "").strip()
raw_price = lines[i+1] if re.match(r"^[\d.,]+$", candidate):
raw_price = candidate
break
# Jetzt rekonstruieren wir die Beschreibung, indem wir *rückwärts* gehen
# Wir sammeln alle Textzeilen oberhalb der Menge ein, bis wir zur nächsten Position
# oder Tabellenüberschriften gelangen.
desc_lines = [] desc_lines = []
for j in range(i - 1, -1, -1): for j in range(i - 1, -1, -1):
prev_line = lines[j] prev_line = lines[j]
if unit_pattern.match(prev_line) or price_stop_pattern.match(prev_line):
break
# Abbruchbedingungen (Wann gehört der Text nicht mehr zur Beschreibung?) prev_lower = prev_line.lower()
if unit_pattern.match(prev_line): # Vorherige Menge/Einheit erreicht if prev_lower in ["pos.", "pos", "bezeichnung", "beschreibung", "menge", "einheit", "einzelpreis", "gesamtpreis", "betrag €", "betrag", "artikel"]:
break break
if any(w in prev_line.lower() for w in ["gesamt", "zwischensumme", "endbetrag", "ust.", "absender", "seite", "rechnung"]): if any(prev_lower.startswith(w) for w in ["zwischensumme", "endbetrag", "gesamtbetrag", "umsatzsteuer"]):
break
# Wenn es ein reiner Preis der vorigen Position ist, überspringen
if re.match(r"^[\d.,]+$", prev_line) and j < i - 1:
break break
if prev_line.isdigit() and len(prev_line) <= 2:
continue
desc_lines.insert(0, prev_line) desc_lines.insert(0, prev_line)
# Falls am Anfang eine reine Positionsnummer steht (z.B. "1"), putzen wir sie weg
if desc_lines and desc_lines[0].isdigit():
desc_lines.pop(0)
description = " ".join(desc_lines).strip() description = " ".join(desc_lines).strip()
# Sicherheitscheck: Schließe Summenzeilen aus, falls Regex falsch angeschlagen hat
if any(w in description.lower() for w in ["zwischensumme", "endbetrag", "gesamt"]):
continue
if description: if description:
items.append({ items.append({
"description": description, "description": description,
@ -136,6 +128,7 @@ def generate_compliant_xrechnung(data):
root = ET.Element(f"{{{INVOICE_NS}}}Invoice") root = ET.Element(f"{{{INVOICE_NS}}}Invoice")
# BT-24 ID angepasst auf den exakten Core-String
ET.SubElement(root, f"{{{CBC}}}CustomizationID").text = "urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0" ET.SubElement(root, f"{{{CBC}}}CustomizationID").text = "urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0"
ET.SubElement(root, f"{{{CBC}}}ProfileID").text = "urn:fdc:peppol.eu:poacc:bis:invoice:3" ET.SubElement(root, f"{{{CBC}}}ProfileID").text = "urn:fdc:peppol.eu:poacc:bis:invoice:3"
ET.SubElement(root, f"{{{CBC}}}ID").text = str(data["invoice_number"]) ET.SubElement(root, f"{{{CBC}}}ID").text = str(data["invoice_number"])
@ -155,25 +148,34 @@ def generate_compliant_xrechnung(data):
vat_rate = float(data['vat_rate_percent']) vat_rate = float(data['vat_rate_percent'])
vat_category = "S" if vat_rate > 0.0 else "E" vat_category = "S" if vat_rate > 0.0 else "E"
# --- SENDER --- # --- SENDER (SCITECH) ---
supplier = ET.SubElement(root, f"{{{CAC}}}AccountingSupplierParty") supplier = ET.SubElement(root, f"{{{CAC}}}AccountingSupplierParty")
party_supp = ET.SubElement(supplier, f"{{{CAC}}}Party") party_supp = ET.SubElement(supplier, f"{{{CAC}}}Party")
ET.SubElement(party_supp, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = "office@scitech-gmbh.de" ET.SubElement(party_supp, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = "office@scitech-gmbh.de"
party_name_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyName") party_name_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyName")
ET.SubElement(party_name_supp, f"{{{CBC}}}Name").text = "scitech it solutions GmbH" ET.SubElement(party_name_supp, f"{{{CBC}}}Name").text = "scitech it solutions GmbH"
addr_supp = ET.SubElement(party_supp, f"{{{CAC}}}PostalAddress") addr_supp = ET.SubElement(party_supp, f"{{{CAC}}}PostalAddress")
ET.SubElement(addr_supp, f"{{{CBC}}}StreetName").text = "Hermann-Schumann-Str. 4" ET.SubElement(addr_supp, f"{{{CBC}}}StreetName").text = "Hermann-Schumann-Str. 4"
ET.SubElement(addr_supp, f"{{{CBC}}}CityName").text = "Hennigsdorf" ET.SubElement(addr_supp, f"{{{CBC}}}CityName").text = "Hennigsdorf"
ET.SubElement(addr_supp, f"{{{CBC}}}PostalZone").text = "16761" ET.SubElement(addr_supp, f"{{{CBC}}}PostalZone").text = "16761"
country_supp = ET.SubElement(addr_supp, f"{{{CAC}}}Country") country_supp = ET.SubElement(addr_supp, f"{{{CAC}}}Country")
ET.SubElement(country_supp, f"{{{CBC}}}IdentificationCode").text = "DE" ET.SubElement(country_supp, f"{{{CBC}}}IdentificationCode").text = "DE"
tax_scheme_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyTaxScheme") tax_scheme_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyTaxScheme")
ET.SubElement(tax_scheme_supp, f"{{{CBC}}}CompanyID").text = "DE218436692" ET.SubElement(tax_scheme_supp, f"{{{CBC}}}CompanyID").text = "DE218436692"
scheme_supp = ET.SubElement(tax_scheme_supp, f"{{{CAC}}}TaxScheme") scheme_supp = ET.SubElement(tax_scheme_supp, f"{{{CAC}}}TaxScheme")
ET.SubElement(scheme_supp, f"{{{CBC}}}ID").text = "VAT" ET.SubElement(scheme_supp, f"{{{CBC}}}ID").text = "VAT"
legal_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyLegalEntity") legal_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyLegalEntity")
ET.SubElement(legal_supp, f"{{{CBC}}}RegistrationName").text = "scitech it solutions GmbH" ET.SubElement(legal_supp, f"{{{CBC}}}RegistrationName").text = "scitech it solutions GmbH"
# FIX: Hinzufügen der Gruppe "SELLER CONTACT" (BG-6) zur Behebung von BR-DE-2
contact_supp = ET.SubElement(party_supp, f"{{{CAC}}}Contact")
ET.SubElement(contact_supp, f"{{{CBC}}}Name").text = "Geschäftsführung"
ET.SubElement(contact_supp, f"{{{CBC}}}Telephone").text = "+49 3302 4940537"
ET.SubElement(contact_supp, f"{{{CBC}}}ElectronicMail").text = "office@scitech-gmbh.de"
# --- RECIPIENT --- # --- RECIPIENT ---
customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty") customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty")
party_cust = ET.SubElement(customer, f"{{{CAC}}}Party") party_cust = ET.SubElement(customer, f"{{{CAC}}}Party")
@ -212,7 +214,7 @@ def generate_compliant_xrechnung(data):
monetary_total = ET.SubElement(root, f"{{{CAC}}}LegalMonetaryTotal") monetary_total = ET.SubElement(root, f"{{{CAC}}}LegalMonetaryTotal")
ET.SubElement(monetary_total, f"{{{CBC}}}LineExtensionAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}LineExtensionAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}"
ET.SubElement(monetary_total, f"{{{CBC}}}TaxExclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}TaxExclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}"
ET.SubElement(monetary_total, f"{{{CAC}}}TaxInclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}TaxInclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}"
ET.SubElement(monetary_total, f"{{{CBC}}}PayableAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}PayableAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}"
for index, item in enumerate(data["items"], start=1): for index, item in enumerate(data["items"], start=1):
@ -243,13 +245,13 @@ def generate_compliant_xrechnung(data):
# ========================================== # ==========================================
# 3. ABSOLUTE MODELL-LADUNG (NUR METADATEN) # 3. ABSOLUTE MODELL-LADUNG (QWEN 3.5 INSTRUCT)
# ========================================== # ==========================================
def get_cached_llm(): def get_cached_llm():
if "llm_instance" not in st.session_state: if "llm_instance" not in st.session_state:
with st.spinner("🚀 INITIAL: Stammdaten-Modell wird geladen..."): with st.spinner("🚀 INITIAL: Stammdaten-Modell wird geladen..."):
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
model_path = os.path.join(base_dir, "models", "Qwen2.5-VL-3B-Instruct-Q8_0.gguf") model_path = os.path.join(base_dir, "models", "Qwen3.5-4B-Instruct-Q8_0.gguf")
st.session_state.llm_instance = Llama(model_path=model_path, n_ctx=2048, n_gpu_layers=-1, n_threads=8) st.session_state.llm_instance = Llama(model_path=model_path, n_ctx=2048, n_gpu_layers=-1, n_threads=8)
return st.session_state.llm_instance return st.session_state.llm_instance
@ -258,7 +260,7 @@ def get_cached_llm():
# 4. STREAMLIT FRONTEND # 4. STREAMLIT FRONTEND
# ========================================== # ==========================================
st.set_page_config(page_title="PDF zu E-Rechnung", layout="wide") st.set_page_config(page_title="PDF zu E-Rechnung", layout="wide")
st.title("📄 Hybrid PDF zu E-Rechnung Konverter") st.title("📄 Enterprise PDF zu E-Rechnung Konverter")
llm = get_cached_llm() llm = get_cached_llm()
col1, col2 = st.columns(2) col1, col2 = st.columns(2)
@ -279,7 +281,7 @@ with col2:
if "extracted_data" not in st.session_state: st.session_state.extracted_data = None if "extracted_data" not in st.session_state: st.session_state.extracted_data = None
if uploaded_file is not None and pdf_text.strip() != "": if uploaded_file is not None and pdf_text.strip() != "":
if st.button("🚀 Daten extrahieren (Optimierte native Tabelle)", width="stretch"): if st.button("🚀 Daten extrahieren (Kugelsichere Hybrid-Methode)", width="stretch"):
with st.spinner("Extrahiere Daten..."): with st.spinner("Extrahiere Daten..."):
# STUFE 1: Stammdaten via KI # STUFE 1: Stammdaten via KI
@ -294,7 +296,7 @@ with col2:
if final_data.get("recipient_email") == "office@scitech-gmbh.de": if final_data.get("recipient_email") == "office@scitech-gmbh.de":
final_data["recipient_email"] = "" final_data["recipient_email"] = ""
# STUFE 2: Zeilenbasiertes Python-Parsing der Tabelle (Robust gegen vertikale Verschiebungen) # STUFE 2: Zeilenbasiertes Python-Parsing für die Tabelle
final_data["items"] = extract_invoice_items_pure_python(pdf_text) final_data["items"] = extract_invoice_items_pure_python(pdf_text)
st.session_state.extracted_data = final_data st.session_state.extracted_data = final_data
@ -326,12 +328,19 @@ with col2:
edited_items = st.data_editor(data.get("items", []), num_rows="dynamic", width="stretch") edited_items = st.data_editor(data.get("items", []), num_rows="dynamic", width="stretch")
vat_rate = st.number_input("Steuersatz (%)", value=float(data.get("vat_rate_percent") or 19.0)) vat_rate = st.number_input("Steuersatz (%)", value=float(data.get("vat_rate_percent") or 19.0))
# Live-Berechnung inklusive kaufmännischem Runden auf Positionsebene
for item in edited_items: for item in edited_items:
item["total_amount"] = float(item.get("quantity") or 0.0) * float(item.get("unit_price") or 0.0) raw_total = float(item.get("quantity") or 0.0) * float(item.get("unit_price") or 0.0)
item["total_amount"] = kaufmaennisches_runden(raw_total)
computed_net_amount = sum(item["total_amount"] for item in edited_items) computed_net_amount = sum(item["total_amount"] for item in edited_items)
computed_vat_amount = computed_net_amount * (vat_rate / 100.0) # Kaufmännisches Runden für die Umsatzsteuer
computed_gross_amount = computed_net_amount + computed_vat_amount raw_vat_amount = computed_net_amount * (vat_rate / 100.0)
computed_vat_amount = kaufmaennisches_runden(raw_vat_amount)
# Brutto setzt sich sauber aus den gerundeten Netto- und MwSt-Werten zusammen
computed_gross_amount = kaufmaennisches_runden(computed_net_amount + computed_vat_amount)
st.markdown("**Berechnete Summen:**") st.markdown("**Berechnete Summen:**")
c_net_ui, c_vat_ui, c_gross_ui = st.columns(3) c_net_ui, c_vat_ui, c_gross_ui = st.columns(3)

View File

@ -1,59 +1,4 @@
altair==6.2.2
annotated-types==0.7.0
anyio==4.14.1
attrs==26.1.0
blinker==1.9.0
cachetools==7.1.4
certifi==2026.6.17
cffi==2.0.0
charset-normalizer==3.4.7
click==8.4.2
colorama==0.4.6
cryptography==49.0.0
diskcache==5.6.3
gitdb==4.0.12
GitPython==3.1.50
h11==0.16.0
httptools==0.8.0
idna==3.18
itsdangerous==2.2.0
Jinja2==3.1.6
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
llama_cpp_python==0.3.32
MarkupSafe==3.0.3
narwhals==2.22.1
ninja==1.13.0
numpy==2.5.0
packaging==26.2
pandas==3.0.3
pdfminer.six==20260107
pdfplumber==0.11.10
pillow==12.2.0
protobuf==7.35.1
pyarrow==24.0.0
pycparser==3.0
pydantic==2.13.4
pydantic_core==2.46.4
pydeck==0.9.2
pymupdf==1.28.0
pypdf==6.14.2
pypdfium2==5.11.0
python-dateutil==2.9.0.post0
python-multipart==0.0.32
referencing==0.37.0
requests==2.34.2
rpds-py==2026.6.3
six==1.17.0
smmap==5.0.3
starlette==1.3.1
streamlit==1.58.0 streamlit==1.58.0
tenacity==9.1.4 llama_cpp_python==0.3.32
toml==0.10.2 pydantic==2.13.4
typing-inspection==0.4.2 pymupdf==1.28.0
typing_extensions==4.15.0
tzdata==2026.2
urllib3==2.7.0
uvicorn==0.49.0
watchdog==6.0.0
websockets==16.0