E-Rechnung/main.py

762 lines
43 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import io
import os
import streamlit as st
import json
import fitz
from PIL import Image
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,
append_pdf_pages,
count_pdf_pages,
MAX_ATTACHMENT_PDFS,
add_pdfa_output_intent,
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,
)
from intro_overlay import render_intro_overlay
# ==========================================
# 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
# nur noch OutputIntent (sRGB-ICC). Die CII deklariert jetzt das EN16931-Profil (B2B), das
# exakt zum factur-x-Level 'en16931' passt - daher KEIN XMP-Konformitaets-Override mehr
# noetig (frueher wurde XMP auf 'XRECHNUNG'/'3.0' gezwungen, um zum alten Profil zu passen).
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)
# 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
# ==========================================
# Eigenes Tab-Icon (Beleg-Motiv, schwarz/orange). Faellt auf das Emoji zurueck, falls die
# Datei fehlt. Absoluter Pfad, damit es unabhaengig vom Arbeitsverzeichnis gefunden wird.
_FAVICON_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "favicon.png")
_page_icon = Image.open(_FAVICON_PATH) if os.path.exists(_FAVICON_PATH) else "🧾"
st.set_page_config(page_title="PDF zu E-Rechnung", page_icon=_page_icon, layout="wide")
st.title("📄 PDF zu E-Rechnung Konverter")
render_intro_overlay()
col1, col2 = st.columns(2)
with col1:
st.subheader("1. Dokument hochladen")
uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"])
# Optionale Zusatzbelege (i.d.R. Leistungsnachweise): werden beim ZUGFeRD-Export unveraendert
# in Upload-Reihenfolge hinten an die Rechnung gehaengt, sodass EINE PDF entsteht. Aus diesen
# PDFs wird nichts ausgelesen.
# Steht bewusst DIREKT unter dem Rechnungs-Uploader und VOR dem Vorschau-Block: sonst
# schiebt die eingeblendete PDF-Vorschau das Feld nach dem Upload weit nach unten.
# Ausserdem bewusst NICHT Teil der _last_file_id-Logik unten - sonst wuerde ihr Upload
# die bereits geprueften Rechnungsdaten verwerfen.
attachment_files = st.file_uploader(
f"Leistungsnachweise anhängen (optional, max. {MAX_ATTACHMENT_PDFS})",
type=["pdf"],
accept_multiple_files=True,
key="attachment_pdf",
)
st.caption(
f"Optional, bis zu {MAX_ATTACHMENT_PDFS} PDFs. Ihre Seiten werden beim ZUGFeRD-Export "
"unverändert und in dieser Reihenfolge ans Ende der Rechnung gehängt - es entsteht eine "
"einzelne PDF. Es werden daraus keine Daten ausgelesen."
)
# Zu viele Dateien: nicht stillschweigend abschneiden, sondern blockieren - sonst fehlt im
# Export ein Nachweis, den der Nutzer bewusst hochgeladen hat.
# Keine eigene Dateiliste hier: der Uploader zeigt die hochgeladenen PDFs bereits selbst
# nebeneinander an - eine zusaetzliche Anzeige waere doppelt.
attachment_limit_exceeded = len(attachment_files) > MAX_ATTACHMENT_PDFS
if attachment_limit_exceeded:
st.error(
f"❌ Es sind maximal {MAX_ATTACHMENT_PDFS} Leistungsnachweise möglich, Sie haben "
f"{len(attachment_files)} hochgeladen. Bitte entfernen Sie "
f"{len(attachment_files) - MAX_ATTACHMENT_PDFS} Datei(en) - der ZUGFeRD-Export ist "
"so lange gesperrt."
)
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 <Nr> vom <Datum>') 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(
"""
<style>
.stTabs [data-baseweb="tab-list"] { gap: 14px; }
.stTabs [data-baseweb="tab"] { padding: 12px 30px; background: transparent; }
.stTabs [data-baseweb="tab"] p {
font-size: 1.25rem !important;
font-weight: 600;
color: rgba(255, 75, 75, 0.5);
}
.stTabs [aria-selected="true"] p { color: #ff4b4b !important; }
.stTabs [aria-selected="true"] { box-shadow: inset 0 -4px 0 0 #ff4b4b; }
</style>
""",
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 <Nr> vom <Datum>').",
)
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]
# BT-83 Verwendungszweck: mit der Rechnungsnummer vorbelegt (so machen es auch die
# Lieferanten, die das Feld fuellen), aber frei ueberschreibbar - manche Empfaenger
# verlangen zur Zuordnung eine eigene Referenz. Bewusst NICHT fest an BT-1 gekoppelt.
payment_ref = st.text_input(
"Verwendungszweck / Zahlungsreferenz (BT-83)",
value=str(data.get("payment_reference", "") or inv_num or ""),
help="Erscheint als Verwendungszweck der Zahlung. Standard ist die "
"Rechnungsnummer - Sie können hier aber eine abweichende Referenz "
"eintragen (z.B. eine Referenznummer des Empfängers). Lassen Sie das Feld "
"leer, wird wieder die Rechnungsnummer verwendet.",
)
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", ""))
# BG-15 Lieferadresse: standardmäßig eingeklappt, damit die Felder im Normalfall
# (Lieferort = Rechnungsadresse) nicht stören. Wurde eine ABWEICHENDE Lieferadresse
# erkannt, sind die Felder vorbefüllt und der Bereich klappt automatisch auf.
_has_deliv = bool(str(data.get("delivery_name", "")).strip())
with st.expander("Abweichende Lieferadresse (BG-15)", expanded=_has_deliv):
if _has_deliv:
st.caption("ℹ️ Abweichende Lieferadresse erkannt und übernommen. "
"Bei Bedarf korrigieren – oder die Felder leeren, wenn keine "
"abweichende Lieferadresse gewünscht ist.")
else:
st.caption("Nur ausfüllen, wenn an einen **anderen Ort** geliefert wurde als "
"die Rechnungsadresse. Leer lassen = keine gesonderte Lieferadresse.")
d_name = st.text_input("Firma / Empfänger (BT-70)", value=data.get("delivery_name", ""))
d_street = st.text_input("Straße (BT-75)", value=data.get("delivery_street", ""))
d_zip = st.text_input("PLZ (BT-78)", value=data.get("delivery_zip", ""))
d_city = st.text_input("Stadt (BT-77)", value=data.get("delivery_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,
"delivery_name": d_name, "delivery_street": d_street, "delivery_zip": d_zip, "delivery_city": d_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,
"payment_reference": payment_ref,
"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))
# Zu viele Leistungsnachweise sperren den Export ebenfalls - die Fehlermeldung dazu steht
# bereits am Upload-Feld, hier wird nur der Button gesperrt.
export_disabled = len(blocker) > 0 or attachment_limit_exceeded
# 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: <Rechnungsnummer>_<Empfaenger, max. 15 Zeichen>_<Bearbeiternummer>
# (dateisicher aufbereitet).
export_basename = build_export_basename(inv_num, r_name, bearbeiter)
# 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:
# Eine XRechnung ist eine reine XML-Datei - dort laesst sich kein PDF-Anhang
# unterbringen. Nicht blockieren (der Nutzer darf das bewusst wollen), aber
# deutlich darauf hinweisen, damit der Nachweis nicht unbemerkt verloren geht.
if attachment_files:
_n = len(attachment_files)
st.warning(
f"⚠️ {'Ihr Leistungsnachweis kann' if _n == 1 else f'Ihre {_n} Leistungsnachweise können'} "
"in eine XRechnung-XML nicht eingebettet werden und "
f"{'entfällt' if _n == 1 else 'entfallen'} bei diesem Export. Wählen Sie das "
"ZUGFeRD-PDF-Format, wenn sie mitgeliefert werden sollen."
)
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
attachment_error = None
if not export_disabled and uploaded_file is not None:
try:
uploaded_file.seek(0)
original_pdf_bytes = uploaded_file.read()
# Optionale Leistungsnachweise ZUERST anhaengen, DANN die factur-x.xml
# einbetten: so laeuft die Erfolgskontrolle (pdf_has_embedded_facturx) und
# die Zwei-Versuche-Logik in build_zugferd_pdf ueber das fertige Dokument.
# Umgekehrt (erst einbetten, dann anhaengen) bliebe ein beschaedigender
# Anhang unbemerkt.
if attachment_files:
try:
_attachment_bytes = []
for att in attachment_files:
att.seek(0)
_attachment_bytes.append(att.read())
original_pdf_bytes = append_pdf_pages(
original_pdf_bytes, *_attachment_bytes
)
except Exception as e:
# Eigene Meldung: sonst sieht es so aus, als sei die RECHNUNG kaputt.
attachment_error = str(e)
raise
zugferd_pdf_bytes = build_zugferd_pdf(
json.dumps(final_invoice_dict, default=str, sort_keys=True),
original_pdf_bytes,
)
except Exception as e:
if attachment_error is None:
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 attachment_error:
st.error(
f"❌ Die Leistungsnachweise konnten nicht angehängt werden: {attachment_error}\n\n"
"Eine der PDFs ist möglicherweise beschädigt oder passwortgeschützt. Bitte "
"entfernen Sie die betroffene Datei oder laden Sie eine andere hoch. Der Export "
"wurde abgebrochen, damit keine Rechnung ohne die Nachweise entsteht."
)
elif zugferd_error:
st.error(f"Fehler bei der ZUGFeRD-Generierung: {zugferd_error}")
elif zugferd_pdf_bytes and attachment_files:
_n = len(attachment_files)
st.success(
f"✅ {_n} {'Leistungsnachweis' if _n == 1 else 'Leistungsnachweise'} angehängt - "
f"die exportierte PDF enthält {count_pdf_pages(zugferd_pdf_bytes)} Seiten."
)
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"
)