import io import streamlit as st import json import fitz from facturx import generate_from_binary as facturx_generate_from_binary from invoice_logic import ( VERKAEUFER, VERKAEUFER_IBAN, PAYMENT_MEANS_CODES, BUYER_ENDPOINT_SCHEMES, build_export_basename, berechne_faelligkeit, kaufmaennisches_runden, clean_german_float, extract_invoice_items, extract_stated_tax_basis, 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, resolve_buyer_endpoint, extract_service_period, detect_contract_type, detect_invoice_type_code, extract_pre_position_freetext, extract_document_references, extract_labeled_header_value, extract_referenznummer, extract_header_metadata_pure_python, ) # ========================================== # 0. SITZUNGS-STATUS INITIALISIEREN (ZÄHLER GEGEN WIDGET-CACHING) # ========================================== if "invoice_counter" not in st.session_state: st.session_state.invoice_counter = 0 # ========================================== # 1. KOPFDATEN-EXTRAKTION (KI-frei, test-version3) # ========================================== # Diese Variante kommt ohne LLM/vLLM-Backend aus: die Kopfdaten werden vollstaendig # deterministisch aus dem PDF-Text gelesen (siehe extract_header_metadata_pure_python in # invoice_logic.py). Das ist schneller, offline-faehig und ohne Server-Abhaengigkeit. @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") 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..."): # KI-frei (test-version3): Kopfdaten vollstaendig deterministisch aus dem # PDF-Text lesen - kein vLLM/LLM-Aufruf mehr. Liefert dasselbe Dict-Schema, # auf das die nachfolgende Pipeline aufsetzt. final_data = extract_header_metadata_pure_python(pdf_text) # Sicherheitsnetz: falls doch die eigene scitech-Adresse als Kunden-E-Mail # erkannt wurde, leeren (BT-49 ist die Adresse des Empfaengers, nicht unsere). if final_data.get("recipient_email") == VERKAEUFER["email"]: final_data["recipient_email"] = "" final_data["items"] = extract_invoice_items(pdf_text) # Sicherheitsnetz fuer komplexe (Buendel-)Rechnungen: die auf der Rechnung # ausgewiesene Bemessungsgrundlage merken, um sie spaeter gegen die Summe der # erkannten Positionen abzugleichen (siehe Summen-Check unten). final_data["stated_tax_basis"] = extract_stated_tax_basis(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) # Rechnungstyp (BT-3) aus dem Titel vorerkennen (z.B. 'Teilrechnung' -> 326). final_data["invoice_type_code"] = detect_invoice_type_code(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"] = "" # 'Ihr Auftrag' deterministisch -> BT-13 (Bestellnummer/Kaeuferreferenz), damit es # nicht (wie beim LLM gesehen) in BT-14 landet. _ihr_auftrag = extract_labeled_header_value(pdf_text, "Ihr Auftrag:") if _ihr_auftrag: final_data["buyer_reference"] = _ihr_auftrag # 'Bearbeiter' -> Verkaeufer-Kontaktperson (BT-41), als Vorbelegung fuers UI-Feld. final_data["bearbeiter"] = extract_labeled_header_value(pdf_text, "Bearbeiter:") # 'Referenznummer' (z.B. SAP) hat kein eigenes EN16931-Feld -> in den Freitext (BT-22). _refnr = extract_referenznummer(pdf_text) if _refnr: _refline = f"Referenznummer: {_refnr}" if _refnr not in str(final_data.get("invoice_note", "")): _en = str(final_data.get("invoice_note", "")).strip() final_data["invoice_note"] = f"{_en}\n{_refline}".strip() if _en else _refline # Hinweis: Ein Angebotsbezug ('Angebot vom ') wird - genau wie ein # Vergabeangebot - direkt in BT-17 (tender_id) einsortiert (siehe # extract_document_references oben), nicht mehr in den Freitext. 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']}**") # Reiter-Navigation: gruppiert die Eingabefelder, damit man nicht die ganze Seite # runterscrollen muss. Summen & Export stehen bewusst UNTERHALB der Tabs (immer sichtbar). # Groessere, auffaellige Tabs per CSS, damit klar ist: hier gibt es mehrere Reiter zu pruefen. st.markdown( """ """, unsafe_allow_html=True, ) st.markdown("➡️ **Bitte alle drei Reiter prüfen** (Kopfdaten · Adressen · Positionen), bevor Sie unten exportieren.") tab_basis, tab_parteien, tab_positionen = st.tabs( ["Kopfdaten", "Adressen", "Positionen"] ) with tab_basis: # --- BT-3 RECHNUNGSTYP DROPDOWN-MENÜ --- _type_options = ["380 (Handelsrechnung)", "381 (Gutschrift)", "384 (Korrekturrechnung)", "326 (Teilrechnung)"] _detected_type = str(data.get("invoice_type_code", "380")) _type_index = next((i for i, o in enumerate(_type_options) if o.startswith(_detected_type)), 0) inv_type_ui = st.selectbox( "Rechnungstyp (BT-3)", options=_type_options, index=_type_index, help="Bestimmt den steuerrechtlichen Belegtyp im XML-Header. Wird aus dem Rechnungstitel " "vorerkannt (z.B. 'Teilrechnung' -> 326) und ist hier änderbar." ) 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) die Leitweg-ID eintragen. Bleibt das Feld leer " "(normaler B2B-Fall), wird automatisch die Kundennummer als Käuferreferenz (BT-10) " "verwendet - so bleibt das für XRechnung verpflichtende Feld nie leer.", ) buyer_ref = st.text_input( "Ihr Auftrag / Bestellnummer (BT-13)", value=data.get("buyer_reference", "") ) bearbeiter = st.text_input( "Bearbeiter (BT-41)", value=data.get("bearbeiter", ""), help="Ansprechpartner beim Verkäufer (Verkäufer-Kontaktperson). Wird aus der " "Rechnung erkannt und ist editierbar. Für XRechnung Pflicht (BR-DE-5) - " "leer lassen nur, wenn wirklich kein Ansprechpartner vorhanden ist.", ) # 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( "Angebot / Vergabeangebot (BT-17)", value=data.get("tender_id", ""), help="Nummer eines Angebots, Vergabeangebots oder einer Ausschreibung. Wird aus der " "Rechnung erkannt ('ANGEBOT'/'VERGABEANGEBOT vom ').", ) 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) seller_endpoint = st.text_input( "Elektronische Adresse (BT-34)", value=VERKAEUFER["email"], help="Elektronische Zustelladresse des Verkäufers (Pflicht, PEPPOL-EN16931-R020). " "Standard: die office-Mailadresse.", ) seller_contact_email = st.text_input( "Kontakt-E-Mail (BT-43)", value=VERKAEUFER["email"], help="E-Mail des Ansprechpartners beim Verkäufer (Verkäufer-Kontakt).", ) 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)", value=data.get("recipient_email", ""), help="Adresse, an die der Kunde die Rechnung empfängt. Standard: E-Mail (Schema EM). " "Leer lassen genügt bei Behörden-Rechnungen: Ist oben eine Leitweg-ID (BT-10) " "eingetragen, wird sie automatisch als elektronische Adresse mit Schema 0204 " "übernommen (keine Doppeleingabe nötig). Für eine GLN das Schema 0088 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] # Sichtbar machen, was tatsächlich in BEIDE Felder (BT-49 + BT-49-1) exportiert wird - # vor allem, wenn die Adresse aus der Leitweg-ID (BT-10) abgeleitet wird und das # Feld BT-49 daher leer bleibt (sonst könnte man es für "noch offen" halten). _bt49_preview, _bt49_scheme_preview = resolve_buyer_endpoint(r_email, r_endpoint_scheme, leitweg_id) if _bt49_preview and not str(r_email).strip(): st.success(f"➡️ Wird automatisch aus der Leitweg-ID (BT-10) übernommen – die beiden " f"Felder oben können leer bleiben:\n\n" f"- **Elektronische Adresse (BT-49):** {_bt49_preview}\n" f"- **Adress-Schema (BT-49-1):** {_bt49_scheme_preview}") elif _bt49_preview: st.info(f"➡️ Export → **Elektronische Adresse (BT-49):** {_bt49_preview} · " f"**Adress-Schema (BT-49-1):** {_bt49_scheme_preview}") 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}") # --- SUMMEN-ABGLEICH (Sicherheitsnetz) --- # Die auf der Rechnung ausgewiesene Bemessungsgrundlage (z.B. '19 % USt. auf € 217.500,55') # gegen die Summe der erkannten Positionen abgleichen. Weichen sie ab, ist die # Positionserkennung vermutlich unvollständig - dann deutlich warnen, damit der # Sachbearbeiter die Positionen prüft/korrigiert (v.a. bei komplexen Bündel-Rechnungen). _stated_basis = data.get("stated_tax_basis") if _stated_basis is not None and abs(computed_net_amount - float(_stated_basis)) > 0.01: st.warning( f"⚠️ **Summen-Abgleich fehlgeschlagen:** Die Summe der erkannten Positionen " f"(**{computed_net_amount:.2f} €**) weicht von der auf der Rechnung ausgewiesenen " f"Bemessungsgrundlage (**{float(_stated_basis):.2f} €**) ab. Bitte die Positionen im " f"Reiter *Positionen* prüfen und korrigieren, bevor Sie exportieren." ) elif _stated_basis is not None: st.caption(f"✅ Summen-Abgleich ok: Positionen = ausgewiesene Bemessungsgrundlage " f"({float(_stated_basis):.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 Ihre PDF ein." ) # BT-49 (elektronische Adresse) + BT-49-1 (Schema) aufloesen: ist das E-Mail-Feld leer, # aber eine Leitweg-ID (BT-10) vorhanden, wird die Leitweg-ID mit Schema '0204' als BT-49 # verwendet - so muss die Leitweg-ID nicht doppelt (in BT-10 und BT-49) eingegeben werden. r_email_effective, r_scheme_effective = resolve_buyer_endpoint(r_email, r_endpoint_scheme, leitweg_id) 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_effective, "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, "seller_contact_name": bearbeiter, "seller_endpoint": seller_endpoint, "seller_contact_email": seller_contact_email, "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_scheme_effective, "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 (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: 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 # ZUGFeRD (B2B) ohne Kunden-Adresse: KEIN Blocker, aber statt eines leeren Pflichtfelds wird # ein Platzhalter "X" in BT-49 gesetzt, damit das Feld nicht komplett fehlt - dazu eine # orange Warnung (st.warning), dass eine echte E-Mail besser waere. if not is_xrechnung and not str(r_email_effective).strip(): final_invoice_dict["recipient_email"] = "X" st.warning("⚠️ Es ist keine elektronische Adresse des Kunden (BT-49) angegeben - es wird " "nur ein Platzhalter \"X\" eingesetzt, damit das Pflichtfeld nicht komplett " "fehlt. Eine echte E-Mail-Adresse des Empfängers ist deutlich besser " "(sonst bemängeln strenge Validatoren PEPPOL-EN16931-R010).") # 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" )