From c3fb9dc7cba283c5b46bec3db554ac42a631ebb9 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Thu, 30 Jul 2026 15:49:33 +0200 Subject: [PATCH] =?UTF-8?q?Zahlungsreferenz=20(BT-83)=20im=20UI=20=C3=A4nd?= =?UTF-8?q?erbar=20und=20bis=20zu=20drei=20Leistungsnachweise.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- invoice_logic.py | 58 +++++++++++++++++++++--------- main.py | 91 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 108 insertions(+), 41 deletions(-) diff --git a/invoice_logic.py b/invoice_logic.py index 39c1a49..d681ddc 100644 --- a/invoice_logic.py +++ b/invoice_logic.py @@ -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() diff --git a/main.py b/main.py index 780163b..7c39fcf 100644 --- a/main.py +++ b/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(