E-Rechnung/main.py

700 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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 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,
detect_invoice_type_code,
extract_angebot_reference,
extract_pre_position_freetext,
extract_document_references,
extract_labeled_header_value,
extract_referenznummer,
)
# ==========================================
# 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)
# 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
# Angebotsbezug ("Angebot <Nr> vom <Datum>") 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']}**")
# 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("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)
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). "
"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 Ihre 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,
"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_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 (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).strip():
blocker.append("Elektronische Adresse des Kunden (BT-49) fehlt. Bei XRechnung ist sie "
"Pflicht (PEPPOL-EN16931-R010). Da XRechnung meist an Behörden geht, gehört "
"hier in der Regel die Leitweg-ID rein - im Reiter 'Adressen' ins Feld "
"'Elektronische Adresse (BT-49)' eintragen und als Schema '0204 (Leitweg-ID)' "
"wählen. Alternativ die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088).")
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).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>_<Rechnungsdatum> (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"
)