feat: descriptive export filenames, Vorkasse note (BT-20), dynamic BT-49 scheme

- Export-Dateiname: <Rechnungsnummer>_<Empfaenger>_<Rechnungsdatum> (dateisicher)
- Vorkasse-Schalter schreibt Hinweis in Zahlungsbedingungen (BT-20); Zahlungsmittelcode unveraendert
- BT-49 elektronische Kaeufer-Adresse: Schema (BT-49-1) manuell waehlbar (EM/0204/0088), Default EM

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-08 11:03:32 +02:00
parent 27f6d8bab7
commit 9a7dca1498
2 changed files with 99 additions and 16 deletions

View File

@ -68,6 +68,21 @@ PAYMENT_MEANS_CODES = {
"48": "Bankkarte (Kredit-/Debitkarte)", "48": "Bankkarte (Kredit-/Debitkarte)",
} }
# Schema-Codes fuer die elektronische Adresse des Kaeufers (BT-49-1, Code-Liste EAS). "EM" (E-Mail)
# ist der Standard fuer den B2B-Versand per Mail; die weiteren gelten v.a. beim Peppol-Versand an
# Behoerden (B2G). Die Leitweg-ID hat ihr eigentliches "Zuhause" in BT-10, kann als elektronische
# Adresse aber ueber Schema 0204 zugestellt werden.
BUYER_ENDPOINT_SCHEMES = {
"EM": "E-Mail-Adresse (Standard, B2B)",
"0204": "Leitweg-ID (deutsche Behörden / B2G)",
"0088": "GLN (Global Location Number)",
}
# Fester Hinweistext fuer Vorkasse. Vorkasse ist eine Zahlungs*bedingung* (BT-20), kein
# Zahlungs*mittel* - das Zahlungsmittel bleibt die Ueberweisung. Der Text wird daher zu den
# Zahlungsbedingungen (BT-20) gelegt, nicht in den Zahlungsmittelcode (BT-81).
VORKASSE_HINWEIS = "Zahlung erfolgt per Vorkasse vor Lieferung."
def berechne_faelligkeit(invoice_date_str, term_days): def berechne_faelligkeit(invoice_date_str, term_days):
"""Fälligkeitsdatum = Rechnungsdatum + term_days. Gibt 'YYYY-MM-DD' zurück, oder leer, """Fälligkeitsdatum = Rechnungsdatum + term_days. Gibt 'YYYY-MM-DD' zurück, oder leer,
@ -114,6 +129,43 @@ def format_skonto_text(skonto_percent, skonto_days, invoice_date_str):
return f"Bei Zahlung innerhalb von {days} Tagen gewähren wir {percent_str}% Skonto." return f"Bei Zahlung innerhalb von {days} Tagen gewähren wir {percent_str}% Skonto."
def build_payment_terms_note(data):
"""Kombinierter Freitext fuer die Zahlungsbedingungen (BT-20): optionaler Vorkasse-Hinweis
und/oder eine Skonto-Vereinbarung. Beides sind Zahlungsbedingungen, fuer die EN16931 kein
eigenes strukturiertes Feld kennt. Gibt "" zurueck, wenn keiner von beiden gesetzt ist."""
parts = []
if data.get("vorkasse"):
parts.append(VORKASSE_HINWEIS)
skonto_text = format_skonto_text(
data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"]
)
if skonto_text:
parts.append(skonto_text)
return " ".join(parts)
def sanitize_filename_part(value):
"""Macht einen String dateisystem-sicher: entfernt in Dateinamen kritische Zeichen
(Pfadtrenner/Sonderzeichen) und fasst Whitespace zu einem Bindestrich zusammen."""
s = str(value or "").strip()
s = re.sub(r'[\\/:*?"<>|]', "", s)
s = re.sub(r"\s+", "-", s)
return s.strip("-_")
def build_export_basename(invoice_number, recipient_name, invoice_date):
"""Dateiname-Basis fuer die Exporte: '<Rechnungsnummer>_<Empfaenger>_<Rechnungsdatum>'.
Leere Teile werden weggelassen (kein '__'); ist alles leer, bleibt 'Rechnung'."""
date_norm = parse_to_strict_xml_date(invoice_date) or str(invoice_date or "").strip()
parts = [
sanitize_filename_part(invoice_number),
sanitize_filename_part(recipient_name),
sanitize_filename_part(date_norm),
]
base = "_".join(p for p in parts if p)
return base or "Rechnung"
def kaufmaennisches_runden(betrag, nachkommastellen=2): def kaufmaennisches_runden(betrag, nachkommastellen=2):
if not betrag: if not betrag:
return 0.0 return 0.0
@ -447,7 +499,8 @@ def generate_compliant_xrechnung(data):
# --- Empfänger --- # --- Empfänger ---
customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty") customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty")
party_cust = ET.SubElement(customer, f"{{{CAC}}}Party") party_cust = ET.SubElement(customer, f"{{{CAC}}}Party")
ET.SubElement(party_cust, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = str(data.get("recipient_email", "")).strip() # BT-49-1 Schema der elektronischen Kaeufer-Adresse: dynamisch (EM/0204/0088), Default EM.
ET.SubElement(party_cust, f"{{{CBC}}}EndpointID", {"schemeID": str(data.get("recipient_endpoint_scheme", "EM"))}).text = str(data.get("recipient_email", "")).strip()
addr_cust = ET.SubElement(party_cust, f"{{{CAC}}}PostalAddress") addr_cust = ET.SubElement(party_cust, f"{{{CAC}}}PostalAddress")
ET.SubElement(addr_cust, f"{{{CBC}}}StreetName").text = str(data["recipient_street"]) ET.SubElement(addr_cust, f"{{{CBC}}}StreetName").text = str(data["recipient_street"])
ET.SubElement(addr_cust, f"{{{CBC}}}CityName").text = str(data["recipient_city"]) ET.SubElement(addr_cust, f"{{{CBC}}}CityName").text = str(data["recipient_city"])
@ -468,13 +521,13 @@ def generate_compliant_xrechnung(data):
payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount") payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount")
ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "") ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "")
# BT-20 Skonto-Vereinbarung als Freitext in cac:PaymentTerms/cbc:Note - EN16931 kennt kein # BT-20 Zahlungsbedingungen als Freitext in cac:PaymentTerms/cbc:Note (Vorkasse und/oder
# eigenes strukturiertes Skonto-Feld. Muss laut UBL-Schema NACH PaymentMeans und VOR # Skonto) - EN16931 kennt dafuer kein eigenes strukturiertes Feld. Muss laut UBL-Schema NACH
# AllowanceCharge/TaxTotal stehen. # PaymentMeans und VOR AllowanceCharge/TaxTotal stehen.
_skonto_text = format_skonto_text(data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"]) _payment_terms_note = build_payment_terms_note(data)
if _skonto_text: if _payment_terms_note:
payment_terms = ET.SubElement(root, f"{{{CAC}}}PaymentTerms") payment_terms = ET.SubElement(root, f"{{{CAC}}}PaymentTerms")
ET.SubElement(payment_terms, f"{{{CBC}}}Note").text = _skonto_text ET.SubElement(payment_terms, f"{{{CBC}}}Note").text = _payment_terms_note
# BT-21 Dokumentenweiter Rabatt (BG-20). Muss laut UBL-Schema NACH PaymentTerms und VOR # BT-21 Dokumentenweiter Rabatt (BG-20). Muss laut UBL-Schema NACH PaymentTerms und VOR
# TaxTotal stehen. Voll EN16931-konform: bei gemischten Steuersaetzen wird JE Steuersatz ein # TaxTotal stehen. Voll EN16931-konform: bei gemischten Steuersaetzen wird JE Steuersatz ein
@ -697,7 +750,8 @@ def generate_compliant_zugferd_cii(data):
# XSD nach PostalTradeAddress stehen. KEIN erfundener Fallback - fehlt die Adresse, wird das # XSD nach PostalTradeAddress stehen. KEIN erfundener Fallback - fehlt die Adresse, wird das
# (leere) Pflichtfeld sichtbar und die UI blockiert den Export mit Warnung. # (leere) Pflichtfeld sichtbar und die UI blockiert den Export mit Warnung.
cust_uri = ET.SubElement(customer_party, f"{{{RAM}}}URIUniversalCommunication") cust_uri = ET.SubElement(customer_party, f"{{{RAM}}}URIUniversalCommunication")
ET.SubElement(cust_uri, f"{{{RAM}}}URIID", {"schemeID": "EM"}).text = str(data.get("recipient_email", "")).strip() # BT-49-1 Schema der elektronischen Kaeufer-Adresse: dynamisch (EM/0204/0088), Default EM.
ET.SubElement(cust_uri, f"{{{RAM}}}URIID", {"schemeID": str(data.get("recipient_endpoint_scheme", "EM"))}).text = str(data.get("recipient_email", "")).strip()
if data.get("order_id") and str(data["order_id"]).strip() != "": if data.get("order_id") and str(data["order_id"]).strip() != "":
# BT-14 "Sales order reference" - unsere EIGENE Auftrags-/Verkaufsauftragsnummer. # BT-14 "Sales order reference" - unsere EIGENE Auftrags-/Verkaufsauftragsnummer.
@ -785,11 +839,11 @@ def generate_compliant_zugferd_cii(data):
# ram:DueDateDateTime stehen. Kein fester Fallback: ohne gültiges Zahlungsziel UND ohne # ram:DueDateDateTime stehen. Kein fester Fallback: ohne gültiges Zahlungsziel UND ohne
# Skonto wird der Block weggelassen (die UI blockiert dann den Export bzgl. Zahlungsziel). # Skonto wird der Block weggelassen (die UI blockiert dann den Export bzgl. Zahlungsziel).
_cii_due = berechne_faelligkeit(data["invoice_date"], data.get("payment_term_days")) _cii_due = berechne_faelligkeit(data["invoice_date"], data.get("payment_term_days"))
_skonto_text = format_skonto_text(data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"]) _payment_terms_note = build_payment_terms_note(data)
if _cii_due or _skonto_text: if _cii_due or _payment_terms_note:
payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms") payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms")
if _skonto_text: if _payment_terms_note:
ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = _skonto_text ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = _payment_terms_note
if _cii_due: if _cii_due:
due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime") due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime")
ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = _cii_due.replace("-", "") ET.SubElement(due_date_elem, f"{{{UDT}}}DateTimeString", {"format": "102"}).text = _cii_due.replace("-", "")

37
main.py
View File

@ -11,6 +11,8 @@ from invoice_logic import (
VERKAEUFER, VERKAEUFER,
VERKAEUFER_IBAN, VERKAEUFER_IBAN,
PAYMENT_MEANS_CODES, PAYMENT_MEANS_CODES,
BUYER_ENDPOINT_SCHEMES,
build_export_basename,
berechne_faelligkeit, berechne_faelligkeit,
kaufmaennisches_runden, kaufmaennisches_runden,
clean_german_float, clean_german_float,
@ -305,7 +307,22 @@ with col2:
with c_r: with c_r:
st.markdown("**Empfänger (Kunde):**") st.markdown("**Empfänger (Kunde):**")
r_name = st.text_input("Firma (BT-44) :red[*]", value=data.get("recipient_name", "")) r_name = st.text_input("Firma (BT-44) :red[*]", value=data.get("recipient_name", ""))
r_email = st.text_input("E-Mail (BT-49) :red[*]", value=data.get("recipient_email", "")) 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_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_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", "")) r_city = st.text_input("Stadt (BT-52) :red[*]", value=data.get("recipient_city", ""))
@ -384,6 +401,13 @@ with col2:
with c_disc4: with c_disc4:
skonto_days = st.text_input("Skonto-Frist (Tage)", value="") 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 # --- SUMMEN & EXPORT: bewusst AUSSERHALB der Tabs, damit sie unabhängig vom aktiven
# Reiter immer sichtbar und ohne Scrollen erreichbar sind. --- # Reiter immer sichtbar und ohne Scrollen erreichbar sind. ---
st.markdown("---") st.markdown("---")
@ -421,6 +445,8 @@ with col2:
"allowance_reason": discount_reason, "allowance_reason": discount_reason,
"skonto_percent": skonto_percent, "skonto_percent": skonto_percent,
"skonto_days": skonto_days, "skonto_days": skonto_days,
"vorkasse": vorkasse,
"recipient_endpoint_scheme": r_endpoint_scheme,
"delivery_note_id": dn_id, "delivery_note_id": dn_id,
"order_id": o_id, "order_id": o_id,
"contract_id": c_id, "contract_id": c_id,
@ -441,7 +467,7 @@ with col2:
blocker.append("Rechnungsdatum (BT-2) fehlt oder ist unlesbar (erwartet: YYYY-MM-DD). " blocker.append("Rechnungsdatum (BT-2) fehlt oder ist unlesbar (erwartet: YYYY-MM-DD). "
"Bitte korrigieren - es wird KEIN Datum automatisch eingesetzt.") "Bitte korrigieren - es wird KEIN Datum automatisch eingesetzt.")
if not str(r_email).strip(): if not str(r_email).strip():
blocker.append("E-Mail des Kunden (BT-49) fehlt. Diese elektronische Adresse ist für " blocker.append("Elektronische Adresse des Kunden (BT-49) fehlt. Diese ist für "
"XRechnung/ZUGFeRD ein Pflichtfeld (PEPPOL-EN16931-R010) und wird NICHT " "XRechnung/ZUGFeRD ein Pflichtfeld (PEPPOL-EN16931-R010) und wird NICHT "
"mit einem Platzhalter befüllt. Bitte eintragen.") "mit einem Platzhalter befüllt. Bitte eintragen.")
if inv_delivery and parse_to_strict_xml_date(inv_delivery) == "": if inv_delivery and parse_to_strict_xml_date(inv_delivery) == "":
@ -461,6 +487,9 @@ with col2:
export_disabled = len(blocker) > 0 export_disabled = len(blocker) > 0
# 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 # 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. # gelegt - ein Klick startet also sofort den Download, ohne separaten Zwischen-Button.
if "UBL XML" in export_format: if "UBL XML" in export_format:
@ -468,7 +497,7 @@ with col2:
st.download_button( st.download_button(
"🔒 XRechnung exportieren & herunterladen", "🔒 XRechnung exportieren & herunterladen",
data=xml_data, data=xml_data,
file_name=f"XRechnung_{inv_num}.xml", file_name=f"{export_basename}.xml",
mime="text/xml", mime="text/xml",
type="primary", type="primary",
width="stretch", width="stretch",
@ -496,7 +525,7 @@ with col2:
st.download_button( st.download_button(
"🔒 ZUGFeRD PDF exportieren & herunterladen", "🔒 ZUGFeRD PDF exportieren & herunterladen",
data=zugferd_pdf_bytes if zugferd_pdf_bytes else b"", data=zugferd_pdf_bytes if zugferd_pdf_bytes else b"",
file_name=f"ZUGFeRD_{inv_num}.pdf", file_name=f"{export_basename}.pdf",
mime="application/pdf", mime="application/pdf",
type="primary", type="primary",
width="stretch", width="stretch",