Zahlungsreferenz (BT-83) im UI änderbar und bis zu drei Leistungsnachweise.
BT-83: Neues Feld "Verwendungszweck / Zahlungsreferenz" im Reiter Basis, mit der Rechnungsnummer vorbelegt, aber frei überschreibbar - manche Empfänger verlangen zur Zuordnung eine eigene Referenz. Ein leeres Feld bedeutet "Standard nehmen", nicht "weglassen". BT-1 bleibt davon unberührt. Leistungsnachweise: statt einer PDF jetzt bis zu drei (MAX_ATTACHMENT_PDFS), nebeneinander angezeigt und in Upload-Reihenfolge angehängt. Bei mehr als drei Dateien wird der Export gesperrt statt still abzuschneiden, damit kein bewusst hochgeladener Nachweis unbemerkt fehlt. Verifiziert: 162 Tests, XSD-Validierung mit 0/1/2/3 Anhängen sowie mit abweichender Zahlungsreferenz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
df015c9f97
commit
c3fb9dc7cb
|
|
@ -172,6 +172,18 @@ def format_payment_term_text(payment_term_days):
|
|||
return f"Zahlbar innerhalb von {days} Tagen ohne Abzug."
|
||||
|
||||
|
||||
def resolve_payment_reference(data):
|
||||
"""Zahlungsreferenz/Verwendungszweck (BT-83). Standard ist die Rechnungsnummer - so machen es
|
||||
auch die Lieferanten, die das Feld ueberhaupt fuellen. Ist aber eine eigene Referenz
|
||||
angegeben (z.B. eine SAP-Referenz, die die Buchhaltung des Empfaengers zur Zuordnung
|
||||
verlangt), gewinnt diese. Ein leeres Feld bedeutet 'Standard nehmen', nicht 'leer lassen' -
|
||||
ganz weglassen laesst sich BT-83 nur, wenn auch keine Rechnungsnummer vorliegt."""
|
||||
explicit = str(data.get("payment_reference") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
return str(data.get("invoice_number") or "").strip()
|
||||
|
||||
|
||||
def build_payment_terms_note(data):
|
||||
"""Kombinierter Freitext fuer die Zahlungsbedingungen (BT-20): das Zahlungsziel im Klartext
|
||||
ODER ein Vorkasse-Hinweis, jeweils optional ergaenzt um eine Skonto-Vereinbarung. Fuer all
|
||||
|
|
@ -1073,11 +1085,12 @@ def generate_compliant_xrechnung(data):
|
|||
# BT-81 Zahlungsart: Standard bleibt 58 (SEPA-Ueberweisung), aber ueber
|
||||
# PAYMENT_MEANS_CODES pro Rechnung waehlbar (siehe UN/EDIFACT 4461).
|
||||
ET.SubElement(payment_means, f"{{{CBC}}}PaymentMeansCode").text = str(data.get("payment_means_code", "58"))
|
||||
# BT-83 Zahlungsreferenz (Verwendungszweck): die Rechnungsnummer, damit der Empfaenger die
|
||||
# Zahlung automatisch zuordnen kann. Muss laut UBL-Sequenz NACH PaymentMeansCode und VOR
|
||||
# PayeeFinancialAccount stehen.
|
||||
if str(data.get("invoice_number") or "").strip():
|
||||
ET.SubElement(payment_means, f"{{{CBC}}}PaymentID").text = str(data["invoice_number"]).strip()
|
||||
# BT-83 Zahlungsreferenz (Verwendungszweck), damit der Empfaenger die Zahlung automatisch
|
||||
# zuordnen kann - standardmaessig die Rechnungsnummer, im UI aber ueberschreibbar. Muss laut
|
||||
# UBL-Sequenz NACH PaymentMeansCode und VOR PayeeFinancialAccount stehen.
|
||||
_payment_ref = resolve_payment_reference(data)
|
||||
if _payment_ref:
|
||||
ET.SubElement(payment_means, f"{{{CBC}}}PaymentID").text = _payment_ref
|
||||
payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount")
|
||||
ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "")
|
||||
# BT-86 BIC der Verkaeuferbank (optional, aber von vielen Empfaengern erwartet).
|
||||
|
|
@ -1379,11 +1392,13 @@ def generate_compliant_zugferd_cii(data):
|
|||
ET.SubElement(dn_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["delivery_note_id"]).strip()
|
||||
|
||||
header_settle = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeSettlement")
|
||||
# BT-83 Zahlungsreferenz (Verwendungszweck): die Rechnungsnummer, damit der Empfaenger die
|
||||
# Zahlung automatisch zuordnen kann. Muss laut CII-Sequenz VOR InvoiceCurrencyCode stehen
|
||||
# (die Sequenz ist CreditorReferenceID -> PaymentReference -> ... -> InvoiceCurrencyCode).
|
||||
if str(data.get("invoice_number") or "").strip():
|
||||
ET.SubElement(header_settle, f"{{{RAM}}}PaymentReference").text = str(data["invoice_number"]).strip()
|
||||
# BT-83 Zahlungsreferenz (Verwendungszweck), damit der Empfaenger die Zahlung automatisch
|
||||
# zuordnen kann - standardmaessig die Rechnungsnummer, im UI aber ueberschreibbar. Muss laut
|
||||
# CII-Sequenz VOR InvoiceCurrencyCode stehen (die Sequenz ist CreditorReferenceID ->
|
||||
# PaymentReference -> ... -> InvoiceCurrencyCode).
|
||||
_payment_ref = resolve_payment_reference(data)
|
||||
if _payment_ref:
|
||||
ET.SubElement(header_settle, f"{{{RAM}}}PaymentReference").text = _payment_ref
|
||||
ET.SubElement(header_settle, f"{{{RAM}}}InvoiceCurrencyCode").text = "EUR"
|
||||
|
||||
pay_means = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementPaymentMeans")
|
||||
|
|
@ -1492,18 +1507,27 @@ def normalize_pdf_bytes(pdf_bytes: bytes) -> bytes:
|
|||
return pdf_bytes
|
||||
|
||||
|
||||
def append_pdf_pages(base_pdf_bytes: bytes, extra_pdf_bytes: bytes) -> bytes:
|
||||
"""Haengt alle Seiten von extra_pdf_bytes hinten an base_pdf_bytes an (z.B. einen
|
||||
Leistungsnachweis an die Rechnung) und gibt die zusammengefuehrte PDF zurueck. Aus der
|
||||
angehaengten PDF wird nichts ausgelesen - sie ist reiner Seiteninhalt.
|
||||
# Wie viele Leistungsnachweise maximal an eine Rechnung gehaengt werden duerfen. Begrenzt,
|
||||
# damit die Export-PDF handhabbar bleibt und ein versehentlicher Massen-Upload auffaellt.
|
||||
MAX_ATTACHMENT_PDFS = 3
|
||||
|
||||
|
||||
def append_pdf_pages(base_pdf_bytes: bytes, *extra_pdf_bytes: bytes) -> bytes:
|
||||
"""Haengt die Seiten aller uebergebenen PDFs in Reihenfolge hinten an base_pdf_bytes an
|
||||
(z.B. Leistungsnachweise an die Rechnung) und gibt die zusammengefuehrte PDF zurueck. Aus
|
||||
den angehaengten PDFs wird nichts ausgelesen - sie sind reiner Seiteninhalt. Ohne Anhang
|
||||
kommen die Basis-Bytes unveraendert zurueck.
|
||||
|
||||
Bewusst OHNE try/except: schlaegt das Zusammenfuehren fehl (kaputte oder passwortgeschuetzte
|
||||
Datei), muss der Export sichtbar scheitern. Eine stillschweigend ohne Leistungsnachweis
|
||||
exportierte Rechnung waere schlimmer als eine Fehlermeldung - anders als bei
|
||||
normalize_pdf_bytes, wo das Zurueckfallen auf die Eingabe harmlos ist."""
|
||||
with pikepdf.open(io.BytesIO(base_pdf_bytes)) as base, \
|
||||
pikepdf.open(io.BytesIO(extra_pdf_bytes)) as extra:
|
||||
base.pages.extend(extra.pages)
|
||||
if not extra_pdf_bytes:
|
||||
return base_pdf_bytes
|
||||
with pikepdf.open(io.BytesIO(base_pdf_bytes)) as base:
|
||||
for extra_bytes in extra_pdf_bytes:
|
||||
with pikepdf.open(io.BytesIO(extra_bytes)) as extra:
|
||||
base.pages.extend(extra.pages)
|
||||
buf = io.BytesIO()
|
||||
base.save(buf)
|
||||
return buf.getvalue()
|
||||
|
|
|
|||
91
main.py
91
main.py
|
|
@ -25,6 +25,7 @@ from invoice_logic import (
|
|||
pdf_has_embedded_facturx,
|
||||
append_pdf_pages,
|
||||
count_pdf_pages,
|
||||
MAX_ATTACHMENT_PDFS,
|
||||
add_pdfa_output_intent,
|
||||
compute_invoice_totals,
|
||||
resolve_buyer_endpoint,
|
||||
|
|
@ -110,21 +111,42 @@ with col1:
|
|||
st.subheader("1. Dokument hochladen")
|
||||
uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"])
|
||||
|
||||
# Optionaler Zusatzbeleg (i.d.R. Leistungsnachweis): wird beim ZUGFeRD-Export unveraendert
|
||||
# hinten an die Rechnung gehaengt, sodass EINE PDF entsteht. Aus dieser PDF wird nichts
|
||||
# ausgelesen.
|
||||
# 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 sein Upload
|
||||
# Ausserdem bewusst NICHT Teil der _last_file_id-Logik unten - sonst wuerde ihr Upload
|
||||
# die bereits geprueften Rechnungsdaten verwerfen.
|
||||
attachment_file = st.file_uploader(
|
||||
"Leistungsnachweis anhängen (optional)", type=["pdf"], key="attachment_pdf"
|
||||
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(
|
||||
"Optional. Die Seiten dieser PDF werden beim ZUGFeRD-Export unverändert ans Ende der "
|
||||
"Rechnung gehängt - es entsteht eine einzelne PDF. Es werden daraus keine Daten ausgelesen."
|
||||
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.
|
||||
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."
|
||||
)
|
||||
elif attachment_files:
|
||||
# Nebeneinander anzeigen, damit die Reihenfolge auf einen Blick erkennbar ist.
|
||||
for col, att in zip(st.columns(MAX_ATTACHMENT_PDFS), attachment_files):
|
||||
with col:
|
||||
st.markdown(f"**{att.name}**")
|
||||
st.caption(f"{att.size / 1024:.0f} KB")
|
||||
|
||||
pdf_text = ""
|
||||
|
||||
# Neue/andere Datei (oder Datei entfernt)? -> zuvor extrahierte Daten verwerfen, damit rechts
|
||||
|
|
@ -332,6 +354,18 @@ with col2:
|
|||
)
|
||||
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:
|
||||
|
|
@ -548,6 +582,7 @@ with col2:
|
|||
"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,
|
||||
|
|
@ -608,7 +643,9 @@ with col2:
|
|||
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
|
||||
# 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
|
||||
|
|
@ -630,11 +667,13 @@ with col2:
|
|||
# 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_file is not None:
|
||||
if attachment_files:
|
||||
_n = len(attachment_files)
|
||||
st.warning(
|
||||
"⚠️ Ihr Leistungsnachweis kann in eine XRechnung-XML nicht eingebettet werden "
|
||||
"und entfällt bei diesem Export. Wählen Sie das ZUGFeRD-PDF-Format, wenn der "
|
||||
"Nachweis mitgeliefert werden soll."
|
||||
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(
|
||||
|
|
@ -655,16 +694,19 @@ with col2:
|
|||
uploaded_file.seek(0)
|
||||
original_pdf_bytes = uploaded_file.read()
|
||||
|
||||
# Optionalen Leistungsnachweis ZUERST anhaengen, DANN die factur-x.xml
|
||||
# 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_file is not None:
|
||||
if attachment_files:
|
||||
try:
|
||||
attachment_file.seek(0)
|
||||
_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_file.read()
|
||||
original_pdf_bytes, *_attachment_bytes
|
||||
)
|
||||
except Exception as e:
|
||||
# Eigene Meldung: sonst sieht es so aus, als sei die RECHNUNG kaputt.
|
||||
|
|
@ -683,17 +725,18 @@ with col2:
|
|||
|
||||
if attachment_error:
|
||||
st.error(
|
||||
f"❌ Der Leistungsnachweis konnte nicht angehängt werden: {attachment_error}\n\n"
|
||||
"Die PDF ist möglicherweise beschädigt oder passwortgeschützt. Bitte entfernen "
|
||||
"Sie die Datei oder laden Sie eine andere hoch. Der Export wurde abgebrochen, "
|
||||
"damit keine Rechnung ohne den Nachweis entsteht."
|
||||
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_file is not None:
|
||||
elif zugferd_pdf_bytes and attachment_files:
|
||||
_n = len(attachment_files)
|
||||
st.success(
|
||||
f"✅ Leistungsnachweis angehängt - die exportierte PDF enthält "
|
||||
f"{count_pdf_pages(zugferd_pdf_bytes)} Seiten."
|
||||
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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue