commit 76ee65accf79ccca35667fb3dc3a6ac8a0af6d5a Author: dfuhrmann3 Date: Wed Jul 1 16:42:35 2026 +0200 Dings diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b7a10c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +venv +models \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..2ab79e7 --- /dev/null +++ b/app.py @@ -0,0 +1,351 @@ +import os +import streamlit as st +import json +import re +import xml.etree.ElementTree as ET +from xml.dom import minidom +from datetime import datetime, timedelta +import fitz # PyMuPDF +from pydantic import BaseModel, Field +from llama_cpp import Llama, LlamaGrammar + +# ========================================== +# 1. METADATEN SCHREIBSCHEMA +# ========================================== +class InvoiceMetadata(BaseModel): + invoice_number: str = Field(description="Die Rechnungsnummer nach 'Nummer:' (z.B. R26-219)") + invoice_date: str = Field(description="Das Rechnungsdatum nach 'Datum:' im Format YYYY-MM-DD") + delivery_date: str = Field(description="Das Lieferdatum nach 'Lieferdatum:' im Format YYYY-MM-DD") + buyer_reference: str = Field(description="Der exakte Wert nach 'Ihr Auftrag:' (z.B. 05.06.2026-Herr.Knospe)") + recipient_name: str = Field(description="Vollständige Firma des Kunden/Empfängers (z.B. Stadtwerke Hennigsdorf GmbH)") + recipient_street: str = Field(description="Straße und Hausnummer des Kunden") + recipient_zip: str = Field(description="Postleitzahl (PLZ) des Kunden") + recipient_city: str = Field(description="Stadt des Kunden") + recipient_email: str = Field(description="E-Mail-Adresse des Kunden (LEER lassen, wenn nicht explizit im Text!)") + vat_rate_percent: float = Field(description="Der primäre Umsatzsteuersatz in Prozent (z.B. 19.0)") + + +# ========================================== +# 2. FEHLERFREIES PYTHON-PARSING DER TABELLE +# ========================================== +def clean_german_float(val_str): + if not val_str: + return 0.0 + # Entferne Währung, Leerzeichen und unsichtbare Steuerzeichen + s = str(val_str).strip().replace("€", "").replace(" ", "").replace("'", "").replace("\n", "").replace("\r", "") + if not s: + return 0.0 + + # Konvertierung von Deutsch (1.300,00) zu Python Float (1300.00) + if "." in s and "," in s: + s = s.replace(".", "").replace(",", ".") + elif "," in s: + s = s.replace(",", ".") + try: + return float(s) + except ValueError: + return 0.0 + +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 = [] + + # 1. Text zeilenweise normalisieren und leere Zeilen entfernen + lines = [line.strip() for line in text.split("\n") if line.strip()] + + # 2. Finde alle Indizes, die wie eine Mengenangabe mit Einheit aussehen + # Erkennt: "1,375 Tag(e)", "2 Std.", "10 Stk.", "1,00 Stück" + unit_pattern = re.compile(r"^(\d+(?:,\d+)?)\s*(Tag\(e\)|Std\.|Stk\.|Stunden|Stück)$", re.IGNORECASE) + + for i, line in enumerate(lines): + match = unit_pattern.match(line) + if match: + raw_qty = match.group(1) + 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" + if i + 1 < len(lines): + # Prüfen, ob die nächste Zeile ein Geldbetrag ist (z.B. "1.300,00") + if re.match(r"^[\d.,]+$", lines[i+1]): + raw_price = lines[i+1] + + # 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 = [] + for j in range(i - 1, -1, -1): + prev_line = lines[j] + + # Abbruchbedingungen (Wann gehört der Text nicht mehr zur Beschreibung?) + if unit_pattern.match(prev_line): # Vorherige Menge/Einheit erreicht + break + if any(w in prev_line.lower() for w in ["gesamt", "zwischensumme", "endbetrag", "ust.", "absender", "seite", "rechnung"]): + break + # Wenn es ein reiner Preis der vorigen Position ist, überspringen + if re.match(r"^[\d.,]+$", prev_line) and j < i - 1: + break + + 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() + + # 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: + items.append({ + "description": description, + "quantity": clean_german_float(raw_qty), + "unit": raw_unit, + "unit_price": clean_german_float(raw_price) + }) + + return items + +def map_unece_unit_code(unit_str): + u = str(unit_str).lower() + if "tag" in u or "day" in u: return "DAY" + elif "std" in u or "stund" in u or "hour" in u or "hur" in u: return "HUR" + return "C62" + +def parse_to_strict_xml_date(date_str): + clean_str = str(date_str).strip() + for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d-%m-%Y"): + try: return datetime.strptime(clean_str, fmt).strftime("%Y-%m-%d") + except ValueError: continue + return datetime.today().strftime("%Y-%m-%d") + +def generate_compliant_xrechnung(data): + INVOICE_NS = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + CBC = "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + CAC = "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" + + ET.register_namespace('', INVOICE_NS) + ET.register_namespace('cbc', CBC) + ET.register_namespace('cac', CAC) + + root = ET.Element(f"{{{INVOICE_NS}}}Invoice") + + 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}}}ID").text = str(data["invoice_number"]) + ET.SubElement(root, f"{{{CBC}}}IssueDate").text = parse_to_strict_xml_date(data["invoice_date"]) + + try: + base_date = datetime.strptime(parse_to_strict_xml_date(data["invoice_date"]), "%Y-%m-%d") + computed_due = (base_date + timedelta(days=14)).strftime("%Y-%m-%d") + except Exception: + computed_due = (datetime.today() + timedelta(days=14)).strftime("%Y-%m-%d") + ET.SubElement(root, f"{{{CBC}}}DueDate").text = computed_due + + ET.SubElement(root, f"{{{CBC}}}InvoiceTypeCode").text = "380" + ET.SubElement(root, f"{{{CBC}}}DocumentCurrencyCode").text = "EUR" + ET.SubElement(root, f"{{{CBC}}}BuyerReference").text = str(data["buyer_reference"]) + + vat_rate = float(data['vat_rate_percent']) + vat_category = "S" if vat_rate > 0.0 else "E" + + # --- SENDER --- + supplier = ET.SubElement(root, f"{{{CAC}}}AccountingSupplierParty") + party_supp = ET.SubElement(supplier, f"{{{CAC}}}Party") + ET.SubElement(party_supp, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = "office@scitech-gmbh.de" + party_name_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyName") + ET.SubElement(party_name_supp, f"{{{CBC}}}Name").text = "scitech it solutions GmbH" + 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}}}CityName").text = "Hennigsdorf" + ET.SubElement(addr_supp, f"{{{CBC}}}PostalZone").text = "16761" + country_supp = ET.SubElement(addr_supp, f"{{{CAC}}}Country") + ET.SubElement(country_supp, f"{{{CBC}}}IdentificationCode").text = "DE" + tax_scheme_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyTaxScheme") + ET.SubElement(tax_scheme_supp, f"{{{CBC}}}CompanyID").text = "DE218436692" + scheme_supp = ET.SubElement(tax_scheme_supp, f"{{{CAC}}}TaxScheme") + ET.SubElement(scheme_supp, f"{{{CBC}}}ID").text = "VAT" + legal_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyLegalEntity") + ET.SubElement(legal_supp, f"{{{CBC}}}RegistrationName").text = "scitech it solutions GmbH" + + # --- RECIPIENT --- + customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty") + party_cust = ET.SubElement(customer, f"{{{CAC}}}Party") + ET.SubElement(party_cust, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = str(data["recipient_email"]) if data.get("recipient_email") else "rechnung@kunde.de" + party_name_cust = ET.SubElement(party_cust, f"{{{CAC}}}PartyName") + ET.SubElement(party_name_cust, f"{{{CBC}}}Name").text = str(data["recipient_name"]) + addr_cust = ET.SubElement(party_cust, f"{{{CAC}}}PostalAddress") + ET.SubElement(addr_cust, f"{{{CBC}}}StreetName").text = str(data["recipient_street"]) + ET.SubElement(addr_cust, f"{{{CBC}}}CityName").text = str(data["recipient_city"]) + ET.SubElement(addr_cust, f"{{{CBC}}}PostalZone").text = str(data["recipient_zip"]) + country_cust = ET.SubElement(addr_cust, f"{{{CAC}}}Country") + ET.SubElement(country_cust, f"{{{CBC}}}IdentificationCode").text = "DE" + legal_cust = ET.SubElement(party_cust, f"{{{CAC}}}PartyLegalEntity") + ET.SubElement(legal_cust, f"{{{CBC}}}RegistrationName").text = str(data["recipient_name"]) + + if data.get("delivery_date"): + delivery = ET.SubElement(root, f"{{{CAC}}}Delivery") + ET.SubElement(delivery, f"{{{CBC}}}ActualDeliveryDate").text = parse_to_strict_xml_date(data["delivery_date"]) + + payment_means = ET.SubElement(root, f"{{{CAC}}}PaymentMeans") + ET.SubElement(payment_means, f"{{{CBC}}}PaymentMeansCode").text = "30" + payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount") + ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "") + + tax_total = ET.SubElement(root, f"{{{CAC}}}TaxTotal") + ET.SubElement(tax_total, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" + tax_subtotal = ET.SubElement(tax_total, f"{{{CAC}}}TaxSubtotal") + ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxableAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" + ET.SubElement(tax_subtotal, f"{{{CBC}}}TaxAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" + tax_cat = ET.SubElement(tax_subtotal, f"{{{CAC}}}TaxCategory") + ET.SubElement(tax_cat, f"{{{CBC}}}ID").text = vat_category + ET.SubElement(tax_cat, f"{{{CBC}}}Percent").text = f"{vat_rate:.2f}" + tax_scheme = ET.SubElement(tax_cat, f"{{{CAC}}}TaxScheme") + ET.SubElement(tax_scheme, f"{{{CBC}}}ID").text = "VAT" + + 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}}}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}}}PayableAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" + + for index, item in enumerate(data["items"], start=1): + line = ET.SubElement(root, f"{{{CAC}}}InvoiceLine") + ET.SubElement(line, f"{{{CBC}}}ID").text = str(index) + + u_code = map_unece_unit_code(item.get("unit", "C62")) + q_val = float(item.get("quantity") or 0.0) + qty_formatted = f"{q_val:.4f}".rstrip('0').rstrip('.') if not q_val.is_integer() else f"{q_val:.2f}" + if qty_formatted == "" or qty_formatted == "0": qty_formatted = "0.00" + + ET.SubElement(line, f"{{{CBC}}}InvoicedQuantity", {"unitCode": u_code}).text = qty_formatted + ET.SubElement(line, f"{{{CBC}}}LineExtensionAmount", {"currencyID": "EUR"}).text = f"{item['total_amount']:.2f}" + + item_elem = ET.SubElement(line, f"{{{CAC}}}Item") + ET.SubElement(item_elem, f"{{{CBC}}}Name").text = str(item["description"]) + item_tax = ET.SubElement(item_elem, f"{{{CAC}}}ClassifiedTaxCategory") + ET.SubElement(item_tax, f"{{{CBC}}}ID").text = vat_category + ET.SubElement(item_tax, f"{{{CBC}}}Percent").text = f"{vat_rate:.2f}" + item_scheme = ET.SubElement(item_tax, f"{{{CAC}}}TaxScheme") + ET.SubElement(item_scheme, f"{{{CBC}}}ID").text = "VAT" + + price_elem = ET.SubElement(line, f"{{{CAC}}}Price") + ET.SubElement(price_elem, f"{{{CBC}}}PriceAmount", {"currencyID": "EUR"}).text = f"{item['unit_price']:.2f}" + + xml_str = ET.tostring(root, encoding="utf-8") + return minidom.parseString(xml_str).toprettyxml(indent=" ") + + +# ========================================== +# 3. ABSOLUTE MODELL-LADUNG (NUR METADATEN) +# ========================================== +def get_cached_llm(): + if "llm_instance" not in st.session_state: + with st.spinner("🚀 INITIAL: Stammdaten-Modell wird geladen..."): + 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") + 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 + + +# ========================================== +# 4. STREAMLIT FRONTEND +# ========================================== +st.set_page_config(page_title="PDF zu E-Rechnung", layout="wide") +st.title("📄 Hybrid PDF zu E-Rechnung Konverter") + +llm = get_cached_llm() +col1, col2 = st.columns(2) + +with col1: + st.subheader("1. Dokument hochladen") + uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"]) + pdf_text = "" + if uploaded_file is not None: + doc = fitz.open(stream=uploaded_file.read(), filetype="pdf") + for page in doc: + pdf_text += page.get_text("text") + "\n" + st.success("PDF eingelesen!") + with st.expander("Gefundener Text"): st.text(pdf_text) + +with col2: + st.subheader("2. Strukturierte Daten validieren") + 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 st.button("🚀 Daten extrahieren (Optimierte native Tabelle)", width="stretch"): + with st.spinner("Extrahiere Daten..."): + + # STUFE 1: Stammdaten via KI + grammar_meta = LlamaGrammar.from_json_schema(json.dumps(InvoiceMetadata.model_json_schema())) + prompt_meta = f"""Du bist ein OCR-Parser für deutsche B2B-Rechnungen. Extrahiere die Metadaten fehlerfrei. + GIB DAS JSON REIN ALS EINZEILER AUS. KEINE ZEILENUMBRÜCHE! + Rechnungstext:\n{pdf_text}""" + + res_meta = llm(prompt=prompt_meta, max_tokens=512, grammar=grammar_meta, temperature=0.0) + final_data = json.loads(res_meta["choices"][0]["text"]) + + if final_data.get("recipient_email") == "office@scitech-gmbh.de": + final_data["recipient_email"] = "" + + # STUFE 2: Zeilenbasiertes Python-Parsing der Tabelle (Robust gegen vertikale Verschiebungen) + final_data["items"] = extract_invoice_items_pure_python(pdf_text) + st.session_state.extracted_data = final_data + + if st.session_state.extracted_data: + data = st.session_state.extracted_data + inv_num = st.text_input("Rechnungsnummer", value=data.get("invoice_number", "")) + inv_date = st.text_input("Rechnungsdatum (YYYY-MM-DD)", value=data.get("invoice_date", "")) + inv_delivery = st.text_input("Lieferdatum (YYYY-MM-DD)", value=data.get("delivery_date", "")) + buyer_ref = st.text_input("Ihr Auftrag / Referenz", value=data.get("buyer_reference", "")) + + c_s, c_r = st.columns(2) + with c_s: + st.markdown("**Absender (Verkäufer - Fest hinterlegt):**") + st.text_input("Firma (Sender)", value="scitech it solutions GmbH", disabled=True) + st.text_input("E-Mail (Sender)", value="office@scitech-gmbh.de", disabled=True) + st.text_input("Telefon (Sender)", value="+49 3302 4940537", disabled=True) + st.text_input("Straße (Sender)", value="Hermann-Schumann-Str. 4", disabled=True) + st.text_input("PLZ (Sender)", value="16761", disabled=True) + st.text_input("Stadt (Sender)", value="Hennigsdorf", disabled=True) + with c_r: + st.markdown("**Empfänger (Kunde):**") + r_name = st.text_input("Firma (Kunde)", value=data.get("recipient_name", "")) + r_email = st.text_input("E-Mail (Kunde)", value=data.get("recipient_email", "")) + r_street = st.text_input("Straße (Kunde)", value=data.get("recipient_street", "")) + r_zip = st.text_input("PLZ (Kunde)", value=data.get("recipient_zip", "")) + r_city = st.text_input("Stadt (Kunde)", value=data.get("recipient_city", "")) + + st.markdown("**3. Positionen:**") + 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)) + + for item in edited_items: + item["total_amount"] = float(item.get("quantity") or 0.0) * float(item.get("unit_price") or 0.0) + computed_net_amount = sum(item["total_amount"] for item in edited_items) + + computed_vat_amount = computed_net_amount * (vat_rate / 100.0) + computed_gross_amount = computed_net_amount + computed_vat_amount + + st.markdown("**Berechnete Summen:**") + c_net_ui, c_vat_ui, c_gross_ui = st.columns(3) + with c_net_ui: st.metric("Netto-Gesamt (€)", f"{computed_net_amount:.2f}") + with c_vat_ui: st.metric("MwSt-Gesamt (€)", f"{computed_vat_amount:.2f}") + with c_gross_ui: st.metric("Brutto-Zahlbetrag (€)", f"{computed_gross_amount:.2f}") + + st.markdown("---") + if st.button("🔒 XRechnung exportieren", type="primary", width="stretch"): + final_invoice_dict = { + "invoice_number": inv_num, "invoice_date": inv_date, "delivery_date": inv_delivery, + "buyer_reference": buyer_ref, "recipient_name": r_name, "recipient_email": r_email, + "recipient_street": r_street, "recipient_zip": r_zip, "recipient_city": r_city, + "items": edited_items, "total_net_amount": computed_net_amount, "total_vat_amount": computed_vat_amount, + "vat_rate_percent": vat_rate, "total_gross_amount": computed_gross_amount, "iban": "DE06120700240456207000" + } + st.download_button(label="📥 Download XML", data=generate_compliant_xrechnung(final_invoice_dict), file_name=f"XRechnung_{inv_num}.xml", mime="text/xml") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aec148b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,59 @@ +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 +tenacity==9.1.4 +toml==0.10.2 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2026.2 +urllib3==2.7.0 +uvicorn==0.49.0 +watchdog==6.0.0 +websockets==16.0