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)",
|
||||
}
|
||||
|
||||
# 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):
|
||||
"""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."
|
||||
|
||||
|
||||
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):
|
||||
if not betrag:
|
||||
return 0.0
|
||||
|
|
@ -447,7 +499,8 @@ def generate_compliant_xrechnung(data):
|
|||
# --- Empfänger ---
|
||||
customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty")
|
||||
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")
|
||||
ET.SubElement(addr_cust, f"{{{CBC}}}StreetName").text = str(data["recipient_street"])
|
||||
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")
|
||||
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
|
||||
# eigenes strukturiertes Skonto-Feld. Muss laut UBL-Schema NACH PaymentMeans und VOR
|
||||
# AllowanceCharge/TaxTotal stehen.
|
||||
_skonto_text = format_skonto_text(data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"])
|
||||
if _skonto_text:
|
||||
# BT-20 Zahlungsbedingungen als Freitext in cac:PaymentTerms/cbc:Note (Vorkasse und/oder
|
||||
# Skonto) - EN16931 kennt dafuer kein eigenes strukturiertes Feld. Muss laut UBL-Schema NACH
|
||||
# PaymentMeans und VOR AllowanceCharge/TaxTotal stehen.
|
||||
_payment_terms_note = build_payment_terms_note(data)
|
||||
if _payment_terms_note:
|
||||
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
|
||||
# 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
|
||||
# (leere) Pflichtfeld sichtbar und die UI blockiert den Export mit Warnung.
|
||||
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() != "":
|
||||
# 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
|
||||
# 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"))
|
||||
_skonto_text = format_skonto_text(data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"])
|
||||
if _cii_due or _skonto_text:
|
||||
_payment_terms_note = build_payment_terms_note(data)
|
||||
if _cii_due or _payment_terms_note:
|
||||
payment_terms = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradePaymentTerms")
|
||||
if _skonto_text:
|
||||
ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = _skonto_text
|
||||
if _payment_terms_note:
|
||||
ET.SubElement(payment_terms, f"{{{RAM}}}Description").text = _payment_terms_note
|
||||
if _cii_due:
|
||||
due_date_elem = ET.SubElement(payment_terms, f"{{{RAM}}}DueDateDateTime")
|
||||
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_IBAN,
|
||||
PAYMENT_MEANS_CODES,
|
||||
BUYER_ENDPOINT_SCHEMES,
|
||||
build_export_basename,
|
||||
berechne_faelligkeit,
|
||||
kaufmaennisches_runden,
|
||||
clean_german_float,
|
||||
|
|
@ -305,7 +307,22 @@ with col2:
|
|||
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("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_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", ""))
|
||||
|
|
@ -384,6 +401,13 @@ with col2:
|
|||
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("---")
|
||||
|
|
@ -421,6 +445,8 @@ with col2:
|
|||
"allowance_reason": discount_reason,
|
||||
"skonto_percent": skonto_percent,
|
||||
"skonto_days": skonto_days,
|
||||
"vorkasse": vorkasse,
|
||||
"recipient_endpoint_scheme": r_endpoint_scheme,
|
||||
"delivery_note_id": dn_id,
|
||||
"order_id": o_id,
|
||||
"contract_id": c_id,
|
||||
|
|
@ -441,7 +467,7 @@ with col2:
|
|||
blocker.append("Rechnungsdatum (BT-2) fehlt oder ist unlesbar (erwartet: YYYY-MM-DD). "
|
||||
"Bitte korrigieren - es wird KEIN Datum automatisch eingesetzt.")
|
||||
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 "
|
||||
"mit einem Platzhalter befüllt. Bitte eintragen.")
|
||||
if inv_delivery and parse_to_strict_xml_date(inv_delivery) == "":
|
||||
|
|
@ -461,6 +487,9 @@ with col2:
|
|||
|
||||
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
|
||||
# gelegt - ein Klick startet also sofort den Download, ohne separaten Zwischen-Button.
|
||||
if "UBL XML" in export_format:
|
||||
|
|
@ -468,7 +497,7 @@ with col2:
|
|||
st.download_button(
|
||||
"🔒 XRechnung exportieren & herunterladen",
|
||||
data=xml_data,
|
||||
file_name=f"XRechnung_{inv_num}.xml",
|
||||
file_name=f"{export_basename}.xml",
|
||||
mime="text/xml",
|
||||
type="primary",
|
||||
width="stretch",
|
||||
|
|
@ -496,7 +525,7 @@ with col2:
|
|||
st.download_button(
|
||||
"🔒 ZUGFeRD PDF exportieren & herunterladen",
|
||||
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",
|
||||
type="primary",
|
||||
width="stretch",
|
||||
|
|
|
|||
Loading…
Reference in New Issue