import os import io import streamlit as st import json import re import fitz import requests from facturx import generate_from_binary as facturx_generate_from_binary from invoice_logic import ( InvoiceMetadata, VERKAEUFER, VERKAEUFER_IBAN, PAYMENT_MEANS_CODES, BUYER_ENDPOINT_SCHEMES, build_export_basename, berechne_faelligkeit, kaufmaennisches_runden, clean_german_float, extract_invoice_items_pure_python, map_unece_unit_code, parse_to_strict_xml_date, format_cii_date, generate_compliant_xrechnung, generate_compliant_zugferd_cii, normalize_pdf_bytes, pdf_has_embedded_facturx, add_pdfa_output_intent, fix_xrechnung_xmp_conformance, compute_invoice_totals, extract_service_period, detect_contract_type, extract_hardware_serial_numbers, extract_angebot_reference, extract_pre_position_freetext, extract_document_references, ) # ========================================== # 0. SITZUNGS-STATUS INITIALISIEREN (ZÄHLER GEGEN WIDGET-CACHING) # ========================================== if "invoice_counter" not in st.session_state: st.session_state.invoice_counter = 0 # ========================================== # 1. API-CLIENT FÜR DAS vLLM-BACKEND # ========================================== class LLMClient: def __init__(self, model="gemma431b", base_url="http://10.192.0.140:8002"): self.model = model base_url = base_url.strip() if base_url.startswith("[") and ")" in base_url: match = re.search(r"\((https?://.*?)\)", base_url) if match: base_url = match.group(1) self.base_url = base_url.rstrip("/") def chat(self, prompt, system_prompt="You are a helpful assistant.", temperature=0.0, json_schema=None): messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt} ] return self._openai_compatible_request(messages, temperature, json_schema) def _openai_compatible_request(self, messages, temperature, json_schema): url = f"{self.base_url}/v1/chat/completions" payload = { "model": self.model, "messages": messages, "temperature": temperature } if json_schema: payload["guided_json"] = json.dumps(json_schema) if isinstance(json_schema, dict) else json_schema try: response = requests.post(url, json=payload, timeout=120) response.raise_for_status() data = response.json() return data.get("choices", [{}])[0].get("message", {}).get("content", "") except Exception as e: return f"vLLM/OpenAI Error: {e}" # ========================================== # 4. API CLIENT INITIALISIERUNG # ========================================== def get_cached_llm(): if "llm_instance" not in st.session_state: llm_url = os.environ.get('VLLM_URL', 'http://10.192.0.140:8002') llm_model = os.environ.get('LLM_MODEL', 'gemma431b') st.session_state.llm_instance = LLMClient(model=llm_model, base_url=llm_url) return st.session_state.llm_instance @st.cache_data(show_spinner=False) def build_zugferd_pdf(invoice_json: str, original_pdf_bytes: bytes) -> bytes: """Erzeugt die ZUGFeRD-Hybrid-PDF (factur-x + PDF/A-3). Gecacht ueber (Rechnungsdaten-JSON, Original-PDF-Bytes), damit diese vergleichsweise teure Erzeugung nur laeuft, wenn sich die Rechnung tatsaechlich aendert - so kann der Export-Button direkt herunterladen, ohne bei jedem Rerun neu zu generieren.""" invoice_dict = json.loads(invoice_json) cii_bytes = generate_compliant_zugferd_cii(invoice_dict).encode("utf-8") def _attempt(src_pdf_bytes): # factur-x: XSD-Validierung der CII-XML, Einbetten, PDF/A-3-konforme XMP-Struktur; danach # OutputIntent (sRGB-ICC) und XRechnung-Konformitaets-Fix in den XMP-Metadaten. pdf_bytes = facturx_generate_from_binary( src_pdf_bytes, cii_bytes, flavor="factur-x", level="en16931", check_xsd=True, check_schematron=False, # benötigt optional 'saxonche' für Schematron-Regeln afrelationship="data", ) pdf_bytes = add_pdfa_output_intent(pdf_bytes) pdf_bytes = fix_xrechnung_xmp_conformance(pdf_bytes) # Erfolgskontrolle: factur-x laesst bei manchen PDF-Strukturen die XML still weg bzw. # produziert eine kaputte PDF - dann als Fehlschlag behandeln. if not pdf_has_embedded_facturx(pdf_bytes): raise RuntimeError("factur-x.xml wurde nicht eingebettet") return pdf_bytes try: # 1. Versuch mit der Original-PDF (funktioniert bei den meisten PDFs direkt). return _attempt(original_pdf_bytes) except Exception: # 2. Fallback: Ursprungs-PDF normalisieren (mit pikepdf neu speichern) und erneut versuchen. # Manche PDFs (z.B. von PDF-XChange) brauchen das, andere funktionieren NUR ohne - daher # beide Wege, und wir nehmen den, der eine gueltige eingebettete factur-x.xml liefert. return _attempt(normalize_pdf_bytes(original_pdf_bytes)) # ========================================== # 5. STREAMLIT FRONTEND # ========================================== st.set_page_config(page_title="PDF zu E-Rechnung", layout="wide") st.title("📄 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 = "" # Neue/andere Datei (oder Datei entfernt)? -> zuvor extrahierte Daten verwerfen, damit rechts # nicht die vorige Rechnung stehen bleibt, bis erneut extrahiert wird. _current_file_id = f"{uploaded_file.name}:{uploaded_file.size}" if uploaded_file is not None else None if st.session_state.get("_last_file_id") != _current_file_id: st.session_state._last_file_id = _current_file_id st.session_state.extracted_data = None 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!") uploaded_file.seek(0) st.pdf(uploaded_file) 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", width="stretch"): with st.spinner("Extrahiere Daten über vLLM-API..."): schema_dict = InvoiceMetadata.model_json_schema() sys_prompt = ( "Du bist ein präziser OCR-Parser für deutsche B2B-Rechnungen. " "Extrahiere die Metadaten fehlerfrei als valides JSON-Objekt. " "Das JSON MUSS zwingend exakt folgende Schlüssel (Keys) enthalten:\n" "- invoice_number\n" "- invoice_date (im Format YYYY-MM-DD)\n" "- delivery_date (im Format YYYY-MM-DD)\n" "- buyer_reference\n" "- customer_number\n" "- recipient_name\n" "- recipient_street\n" "- recipient_zip\n" "- recipient_city\n" "- recipient_email\n" "- vat_rate_percent (als Zahl, z.B. 19.0)\n" "- delivery_note_id\n" "- order_id\n" "- contract_id\n" "- service_start_date\n" "- service_end_date\n" "- payment_term_days\n" "- invoice_note\n\n" "WICHTIGE REGEL: Analysiere den Text direkt VOR der ersten Tabellenposition '1'. " "Sortiere alle dort vorlaufenden Zeilen vollständig in die dafür vorgesehenen ID-, Datums- und Freitext-Felder ein. " "Schreibe diese vorlaufenden Metadaten NIEMALS in die Beschreibung der ersten Position hinein!\n\n" "Antworte ausschließlich mit dem reinen JSON-Objekt. Schreibe keinen anderen Text." ) prompt_meta = f"Rechnungstext:\n{pdf_text}" res_meta_text = llm.chat( prompt=prompt_meta, system_prompt=sys_prompt, temperature=0.0, json_schema=schema_dict ) clean_json_text = res_meta_text.strip() backtick_trigger = chr(96) * 3 if clean_json_text.startswith(backtick_trigger): lines = clean_json_text.splitlines() if lines[0].startswith(backtick_trigger): lines = lines[1:] if lines and lines[-1].startswith(backtick_trigger): lines = lines[:-1] clean_json_text = "\n".join(lines).strip() try: final_data = json.loads(clean_json_text) except Exception as e: st.error(f"Fehler beim Parsen der API-Antwort: {e}") st.code(res_meta_text) st.stop() if final_data.get("recipient_email") == VERKAEUFER["email"]: final_data["recipient_email"] = "" final_data["items"] = extract_invoice_items_pure_python(pdf_text) # IT-Systemhaus-Regex-Fallbacks: nur ergaenzen, wenn das LLM die entsprechenden # Felder nicht bereits geliefert hat. if not final_data.get("service_start_date") and not final_data.get("service_end_date"): fallback_start, fallback_end = extract_service_period(pdf_text) if fallback_start and fallback_end: final_data["service_start_date"] = fallback_start final_data["service_end_date"] = fallback_end final_data["contract_type"] = detect_contract_type(pdf_text) final_data["hardware_serials"] = extract_hardware_serial_numbers(pdf_text) # Vorlauftext (BT-22) deterministisch aus dem PDF-Text lesen statt vom (oft # unvollstaendigen) LLM: alles zwischen Tabellenkopf und erster Position, ohne die # Referenzzeilen. Nur ersetzen, wenn die Tabellenstruktur erkannt wurde (sonst # bleibt der LLM-Freitext als Fallback erhalten). _freetext = extract_pre_position_freetext(pdf_text) if _freetext is not None: final_data["invoice_note"] = _freetext # Beleg-Referenzen deterministisch aus den Referenzzeilen lesen und die (teils falsch # zugeordneten) LLM-Werte korrigieren: Auftragsbestaetigung -> BT-14, Lieferschein -> # BT-16, Vergabeangebot -> BT-17. Nur gefundene Felder ueberschreiben. _doc_refs = extract_document_references(pdf_text) for _field, _val in _doc_refs.items(): final_data[_field] = _val # Falls das LLM die Vergabeangebot-Nummer faelschlich in BT-12 (Vertrag) hatte: if _doc_refs.get("tender_id") and str(final_data.get("contract_id", "")).strip() == _doc_refs["tender_id"]: final_data["contract_id"] = "" # Angebotsbezug ("Angebot vom ") hat kein eigenes EN16931-Feld - als # Info fuer den Kunden in den Freitext (BT-22). Zusaetzlich: falls das LLM die # Angebotsnummer faelschlich in Vertrag (BT-12) oder Vergabeangebot (BT-17) gepackt # hat, dort leeren - ein Angebot ist weder ein Vertrag noch ein Vergabeangebot. _angebot = extract_angebot_reference(pdf_text) if _angebot: _angebot_nr = _angebot.split()[1] for _k in ("contract_id", "tender_id"): if str(final_data.get(_k, "")).strip() == _angebot_nr: final_data[_k] = "" if _angebot.lower() not in str(final_data.get("invoice_note", "")).lower(): _existing_note = str(final_data.get("invoice_note", "")).strip() final_data["invoice_note"] = f"{_existing_note}\n{_angebot}".strip() if _existing_note else _angebot st.session_state.invoice_counter += 1 st.session_state.extracted_data = final_data if st.session_state.extracted_data: data = st.session_state.extracted_data st.caption("Mit :red[*] markierte Felder sind EN16931-Pflichtfelder und müssen für einen gültigen Export befüllt sein.") if data.get("contract_type"): st.caption(f"🏷️ Erkannter Vertragstyp: **{data['contract_type']}**") if data.get("hardware_serials"): st.caption(f"🔧 Erkannte Seriennummern: {', '.join(data['hardware_serials'])}") # Reiter-Navigation: gruppiert die Eingabefelder, damit man nicht die ganze Seite # runterscrollen muss. Summen & Export stehen bewusst UNTERHALB der Tabs (immer sichtbar). tab_basis, tab_parteien, tab_positionen = st.tabs( ["Kopfdaten", "Adressen", "Positionen"] ) with tab_basis: # --- BT-3 RECHNUNGSTYP DROPDOWN-MENÜ --- inv_type_ui = st.selectbox( "Rechnungstyp (BT-3)", options=["380 (Handelsrechnung)", "381 (Gutschrift)", "384 (Korrekturrechnung)", "326 (Teilrechnung)"], index=0, help="Bestimmt den steuerrechtlichen Belegtyp im XML-Header." ) selected_type_code = inv_type_ui.split(" ")[0] inv_num = st.text_input("Rechnungsnummer (BT-1) :red[*]", value=data.get("invoice_number", "")) inv_date = st.text_input("Rechnungsdatum (BT-2, YYYY-MM-DD) :red[*]", value=data.get("invoice_date", "")) inv_delivery = st.text_input("Lieferdatum (BT-72, YYYY-MM-DD)", value=data.get("delivery_date", "")) cust_num = st.text_input( "Kundennummer (BT-46)", value=data.get("customer_number", ""), help="Nummer, unter der der Kunde bei euch geführt wird.", ) leitweg_id = st.text_input( "Leitweg-ID (BT-10)", value=data.get("leitweg_id", ""), help="Nur für Behörden-Rechnungen (B2G). Bei XRechnung Pflicht (BR-DE-15) - dann die " "Leitweg-ID der Behörde eintragen. Bei ZUGFeRD / B2B leer lassen.", ) buyer_ref = st.text_input( "Ihr Auftrag / Bestellnummer (BT-13)", value=data.get("buyer_reference", "") ) # UI-Eingabefelder für die Daten vor Position 1 (BT-22 etc.) st.markdown("---") st.markdown("**Globale Referenzen & Vorlauftexte:**") c_ref1, c_ref2 = st.columns(2) with c_ref1: dn_id = st.text_input("Lieferscheinnummer (BT-16)", value=data.get("delivery_note_id", "")) with c_ref2: o_id = st.text_input("Auftragsbestätigung / Verkaufsauftrag (BT-14)", value=data.get("order_id", "")) c_ref3, c_ref4 = st.columns(2) with c_ref3: c_id = st.text_input("Vertrag (BT-12)", value=data.get("contract_id", "")) with c_ref4: tender_id = st.text_input("Vergabeangebot / Ausschreibung (BT-17)", value=data.get("tender_id", "")) c_p1, c_p2 = st.columns(2) with c_p1: s_start = st.text_input("Leistungszeitraum Start (BT-73, YYYY-MM-DD)", value=data.get("service_start_date", "")) with c_p2: s_end = st.text_input("Leistungszeitraum Ende (BT-74, YYYY-MM-DD)", value=data.get("service_end_date", "")) pay_term = st.text_input( "Zahlungsziel in Tagen (BT-9) :red[*]", value=str(data.get("payment_term_days", "") or "") ) payment_means_codes_list = list(PAYMENT_MEANS_CODES.keys()) payment_means_labels = [f"{code} - {PAYMENT_MEANS_CODES[code]}" for code in payment_means_codes_list] selected_payment_means_label = st.selectbox( "Zahlungsmittel (BT-81)", options=payment_means_labels, index=payment_means_codes_list.index("58"), ) selected_payment_means_code = selected_payment_means_label.split(" - ")[0] i_note = st.text_area("Globale Rechnungserläuterung / Freitext (BT-22)", value=data.get("invoice_note", ""), height=150) with tab_parteien: c_s, c_r = st.columns(2) with c_s: st.markdown("**Absender (Verkäufer):**") st.text_input("Firma (BT-27)", value=VERKAEUFER["name"], disabled=True) st.text_input("E-Mail (BT-34)", value=VERKAEUFER["email"], disabled=True) st.text_input("Telefon (BT-42)", value=VERKAEUFER["phone"], disabled=True) st.text_input("Straße (BT-35)", value=VERKAEUFER["street"], disabled=True) st.text_input("PLZ (BT-38)", value=VERKAEUFER["zip"], disabled=True) st.text_input("Stadt (BT-37)", value=VERKAEUFER["city"], disabled=True) st.text_input("Umsatzsteuer-ID (BT-31)", value=VERKAEUFER["vat_id"], disabled=True) st.text_input("Registernummer (BT-30)", value=VERKAEUFER["register_id"], disabled=True) st.text_input("IBAN (BT-84)", value=VERKAEUFER_IBAN, disabled=True) with c_r: st.markdown("**Empfänger (Kunde):**") r_name = st.text_input("Firma (BT-44) :red[*]", value=data.get("recipient_name", "")) r_email = st.text_input( "Elektronische Adresse (BT-49) :red[*]", value=data.get("recipient_email", ""), help="Adresse, an die der Kunde die Rechnung empfängt. Standard: E-Mail (Schema EM). " "Für Behörden ggf. Leitweg-ID (0204) oder GLN (0088) - dann rechts das passende Schema wählen.", ) _scheme_codes = list(BUYER_ENDPOINT_SCHEMES.keys()) _scheme_labels = [f"{c} - {BUYER_ENDPOINT_SCHEMES[c]}" for c in _scheme_codes] r_endpoint_scheme_label = st.selectbox( "Adress-Schema (BT-49-1)", options=_scheme_labels, index=0, help="EM = E-Mail (Standard, B2B). 0204 = Leitweg-ID für deutsche Behörden. " "0088 = GLN. Muss zum Typ der oben eingetragenen Adresse passen.", ) r_endpoint_scheme = r_endpoint_scheme_label.split(" - ")[0] r_street = st.text_input("Straße (BT-50)", value=data.get("recipient_street", "")) r_zip = st.text_input("PLZ (BT-53)", value=data.get("recipient_zip", "")) r_city = st.text_input("Stadt (BT-52) :red[*]", value=data.get("recipient_city", "")) with tab_positionen: if st.button("➕ Neue Position hinzufügen"): data["items"].append({ "name": "Neue Dienstleistung", "description": "", "quantity": 1.0, "unit": "Stk.", "unit_price": 0.0, "vat_rate_percent": float(data.get("vat_rate_percent") or 19.0), }) st.rerun() edited_items = [] inv_id = st.session_state.invoice_counter for idx, item in enumerate(data.get("items", [])): with st.expander(f"📋 Position {idx+1}: {item.get('name', 'Dienstleistung')}", expanded=True): c_del, _ = st.columns([1, 4]) with c_del: if st.button("🗑️ Position löschen", key=f"del_btn_{inv_id}_{idx}"): data["items"].pop(idx) st.rerun() c_name, c_desc = st.columns([1, 1]) with c_name: item_name = st.text_input("Artikelname (BT-153) :red[*]", value=item.get("name", ""), key=f"item_name_{inv_id}_{idx}") with c_desc: desc_text = item.get("description", "") estimated_lines = desc_text.count("\n") + (len(desc_text) // 45) + 1 calc_height = max(68, min(400, estimated_lines * 24)) item_description = st.text_area("Zusatzbeschreibung (BT-154)", value=desc_text, key=f"item_desc_{inv_id}_{idx}", height=calc_height) c_qty, c_unit, c_price, c_vat, c_total = st.columns(5) with c_qty: item_quantity = st.number_input("Menge (BT-129) :red[*]", value=float(item.get("quantity") or 0.0), format="%.3f", step=0.1, key=f"item_qty_{inv_id}_{idx}") with c_unit: item_unit = st.text_input("Einheit (BT-130) :red[*]", value=item.get("unit", "Stk."), key=f"item_unit_{inv_id}_{idx}") with c_price: item_price = st.number_input("Einzelpreis (BT-146, €) :red[*]", value=float(item.get("unit_price") or 0.0), format="%.2f", key=f"item_price_{inv_id}_{idx}") with c_vat: item_vat_rate = st.number_input( "Steuersatz (BT-152, %) :red[*]", value=float(item.get("vat_rate_percent") or data.get("vat_rate_percent") or 19.0), key=f"item_vat_{inv_id}_{idx}", ) with c_total: raw_item_total = item_quantity * item_price item_total_amount = kaufmaennisches_runden(raw_item_total) st.markdown("**Gesamtbetrag (€):**") st.markdown(f"**{item_total_amount:.2f} €**") edited_items.append({ "name": item_name, "description": item_description, "quantity": item_quantity, "unit": item_unit, "unit_price": item_price, "total_amount": item_total_amount, "vat_rate_percent": item_vat_rate, }) st.markdown("---") # Der frühere globale Steuersatz dient jetzt nur noch als Vorbelegung für neu # hinzugefügte Positionen (siehe "Neue Position hinzufügen" oben) - abgerechnet wird # pro Position mit dem jeweils eingetragenen Steuersatz (Multi-Tax-Support). vat_rate = st.number_input("Standard-Steuersatz für neue Positionen (%)", value=float(data.get("vat_rate_percent") or 19.0)) c_disc1, c_disc2, c_disc3, c_disc4 = st.columns(4) with c_disc1: discount_percent = st.number_input("Rabatt (BT-21, %)", value=0.0, min_value=0.0, max_value=100.0, step=0.5) with c_disc2: discount_reason = st.text_input("Rabattgrund", value="") with c_disc3: skonto_percent = st.number_input("Skonto (BT-20, %)", value=0.0, min_value=0.0, max_value=100.0, step=0.5) with c_disc4: skonto_days = st.text_input("Skonto-Frist (Tage)", value="") vorkasse = st.checkbox( "Vorkasse (Zahlung vor Lieferung)", value=False, help="Fügt den Hinweis 'Zahlung erfolgt per Vorkasse vor Lieferung.' zu den " "Zahlungsbedingungen (BT-20) hinzu. Der Zahlungsmittel-Code bleibt unverändert.", ) # --- SUMMEN & EXPORT: bewusst AUSSERHALB der Tabs, damit sie unabhängig vom aktiven # Reiter immer sichtbar und ohne Scrollen erreichbar sind. --- st.markdown("---") totals = compute_invoice_totals(edited_items, discount_percent=discount_percent) computed_net_amount = totals["tax_basis_total"] computed_vat_amount = totals["total_vat_amount"] computed_gross_amount = totals["total_gross_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}") # --- EXPORT-SEKTION DROPDOWN-AUSWAHL --- st.markdown("---") export_format = st.selectbox( "💾 Export-Format wählen", options=["XRechnung v3.0.2 (Reines UBL XML)", "ZUGFeRD v2.4 (Hybrid-PDF mit CII XML)"], index=1, help="XRechnung generiert ein reines, valides UBL-XML. ZUGFeRD bettet ein schema-konformes CII-XML (factur-x.xml) direkt in deine PDF ein." ) final_invoice_dict = { "invoice_type_code": selected_type_code, "invoice_number": inv_num, "invoice_date": inv_date, "delivery_date": inv_delivery, "buyer_reference": buyer_ref, "customer_number": cust_num, "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": VERKAEUFER_IBAN, "payment_means_code": selected_payment_means_code, "tax_buckets": totals["tax_buckets"], "line_net_total": totals["line_net_total"], "allowance_amount": totals["allowance_amount"], "allowance_reason": discount_reason, "skonto_percent": skonto_percent, "skonto_days": skonto_days, "vorkasse": vorkasse, "recipient_endpoint_scheme": r_endpoint_scheme, "leitweg_id": leitweg_id, "delivery_note_id": dn_id, "order_id": o_id, "contract_id": c_id, "tender_id": tender_id, "service_start_date": s_start, "service_end_date": s_end, "payment_term_days": pay_term, "invoice_note": i_note } # --- PFLICHTFELD-VALIDIERUNG vor dem Export --- # Sammelt blockierende Probleme (Export nicht möglich), damit fehlerhafte/fehlende # Pflichtangaben sichtbar werden, statt still mit Platzhaltern oder falschen Werten # in die Rechnung zu wandern. # BT-10 (Leitweg-ID) und BT-49 (elektronische Adresse) sind NUR bei XRechnung Pflicht # (Behörden/B2G). Bei ZUGFeRD (B2B) dürfen sie leer bleiben und blockieren den Export nicht. is_xrechnung = "UBL XML" in export_format blocker = [] if not str(inv_num).strip(): blocker.append("Rechnungsnummer (BT-1) fehlt.") if parse_to_strict_xml_date(inv_date) == "": blocker.append("Rechnungsdatum (BT-2) fehlt oder ist unlesbar (erwartet: YYYY-MM-DD). " "Bitte korrigieren - es wird KEIN Datum automatisch eingesetzt.") if is_xrechnung and not str(leitweg_id).strip(): blocker.append("Leitweg-ID / Käuferreferenz (BT-10) fehlt. Bei XRechnung ist sie Pflicht " "(BR-DE-15) - für Behörden-Rechnungen bitte die Leitweg-ID eintragen. " "(Bei ZUGFeRD/B2B ist das Feld optional.)") if is_xrechnung and not str(r_email).strip(): blocker.append("Elektronische Adresse des Kunden (BT-49) fehlt. Bei XRechnung ist sie " "Pflicht (PEPPOL-EN16931-R010) - entweder E-Mail-Adresse oder Netzwerkcode " "des Empfängers eintragen. (Bei ZUGFeRD/B2B ist das Feld optional.)") 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: st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n" + "\n".join(f"- {b}" for b in blocker)) export_disabled = len(blocker) > 0 # Dateiname: __ (dateisicher aufbereitet). export_basename = build_export_basename(inv_num, r_name, inv_date) # Ein-Klick-Export: die Exportdatei wird vorab erzeugt und direkt in den Download-Button # gelegt - ein Klick startet also sofort den Download, ohne separaten Zwischen-Button. if "UBL XML" in export_format: xml_data = generate_compliant_xrechnung(final_invoice_dict) if not export_disabled else "" st.download_button( "🔒 XRechnung exportieren & herunterladen", data=xml_data, file_name=f"{export_basename}.xml", mime="text/xml", type="primary", width="stretch", disabled=export_disabled, ) else: zugferd_pdf_bytes = None zugferd_error = None if not export_disabled and uploaded_file is not None: try: uploaded_file.seek(0) original_pdf_bytes = uploaded_file.read() zugferd_pdf_bytes = build_zugferd_pdf( json.dumps(final_invoice_dict, default=str, sort_keys=True), original_pdf_bytes, ) except Exception as e: zugferd_error = str(e) elif not export_disabled and uploaded_file is None: zugferd_error = "Original-PDF nicht mehr geladen. Bitte die PDF erneut hochladen." if zugferd_error: st.error(f"Fehler bei der ZUGFeRD-Generierung: {zugferd_error}") st.download_button( "🔒 ZUGFeRD PDF exportieren & herunterladen", data=zugferd_pdf_bytes if zugferd_pdf_bytes else b"", file_name=f"{export_basename}.pdf", mime="application/pdf", type="primary", width="stretch", disabled=export_disabled or zugferd_pdf_bytes is None, ) # ========================================== # FOOTER # ========================================== st.markdown("---") st.caption( "📝 **Hinweise:**\n\n" "**Human-in-the-Loop:** " "Das Tool dient als Automations-Assistent. Vor dem finalen Export ist eine visuelle Endkontrolle der extrahierten Daten " "(insbesondere Rechnungsnummer und Empfängeradresse) durch den Sachbearbeiter zwingend erforderlich, um KI-Fehlinterpretationen auszuschließen.\n\n" "**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. \n\n" "**Validierung:** " "Beim ZUGFeRD-Export wird die eingebettete CII-XML gegen das offizielle EN16931-XSD-Schema validiert. " "Für eine vollständige PDF/A-3-Zertifizierung (z. B. Schriftarten-Einbettung der Ursprungs-PDF) " "empfiehlt sich zusätzlich eine Prüfung mit veraPDF. \n\n" )