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:
parent
27f6d8bab7
commit
9a7dca1498
|
|
@ -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
37
main.py
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue