From 88c327c9771fae2c82e7f0d735416b457c604bf1 Mon Sep 17 00:00:00 2001 From: dfuhrmann3 Date: Wed, 29 Jul 2026 14:39:12 +0200 Subject: [PATCH] Erweitert um optionalen Leistungsnachweis pdf merge Co-Authored-By: Claude Opus 5 --- invoice_logic.py | 27 ++++++++++++++++++++++ main.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/invoice_logic.py b/invoice_logic.py index 5998121..b4fb2a5 100644 --- a/invoice_logic.py +++ b/invoice_logic.py @@ -1459,6 +1459,33 @@ 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. + + 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) + buf = io.BytesIO() + base.save(buf) + return buf.getvalue() + + +def count_pdf_pages(pdf_bytes: bytes) -> int: + """Seitenzahl einer PDF. Dient nur einer Anzeige-Meldung und gibt bei unlesbaren Bytes 0 + zurueck, statt den bereits erfolgreichen Export mit einer Exception zu kippen.""" + try: + with pikepdf.open(io.BytesIO(pdf_bytes)) as pdf: + return len(pdf.pages) + except Exception: + return 0 + + def pdf_has_embedded_facturx(pdf_bytes: bytes) -> bool: """True, wenn die PDF wieder lesbar ist UND die eingebettete 'factur-x.xml' enthaelt. Dient als Erfolgskontrolle nach der ZUGFeRD-Erzeugung - factur-x kann bei manchen PDF-Strukturen die XML diff --git a/main.py b/main.py index 79f5e3b..bf59c4c 100644 --- a/main.py +++ b/main.py @@ -23,6 +23,8 @@ from invoice_logic import ( generate_compliant_zugferd_cii, normalize_pdf_bytes, pdf_has_embedded_facturx, + append_pdf_pages, + count_pdf_pages, add_pdfa_output_intent, compute_invoice_totals, resolve_buyer_endpoint, @@ -125,6 +127,18 @@ with col1: st.pdf(uploaded_file) with st.expander("Gefundener Text"): st.text(pdf_text) + # Optionaler Zusatzbeleg (i.d.R. Leistungsnachweis): wird beim ZUGFeRD-Export unveraendert + # hinten an die Rechnung gehaengt, sodass EINE PDF entsteht. Bewusst NICHT Teil der + # _last_file_id-Logik oben - sonst wuerde sein Upload die bereits geprueften Rechnungsdaten + # verwerfen. Aus dieser PDF wird nichts ausgelesen. + attachment_file = st.file_uploader( + "Leistungsnachweis anhängen (optional)", type=["pdf"], 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." + ) + with col2: st.subheader("2. Strukturierte Daten validieren") if "extracted_data" not in st.session_state: st.session_state.extracted_data = None @@ -609,6 +623,15 @@ with col2: # 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: + # 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: + 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." + ) xml_data = generate_compliant_xrechnung(final_invoice_dict) if not export_disabled else "" st.download_button( "🔒 XRechnung exportieren & herunterladen", @@ -622,21 +645,52 @@ with col2: else: zugferd_pdf_bytes = None zugferd_error = None + attachment_error = None if not export_disabled and uploaded_file is not None: try: uploaded_file.seek(0) original_pdf_bytes = uploaded_file.read() + + # Optionalen Leistungsnachweis 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: + try: + attachment_file.seek(0) + original_pdf_bytes = append_pdf_pages( + original_pdf_bytes, attachment_file.read() + ) + except Exception as e: + # Eigene Meldung: sonst sieht es so aus, als sei die RECHNUNG kaputt. + attachment_error = str(e) + raise + zugferd_pdf_bytes = build_zugferd_pdf( json.dumps(final_invoice_dict, default=str, sort_keys=True), original_pdf_bytes, ) except Exception as e: - zugferd_error = str(e) + if attachment_error is None: + zugferd_error = str(e) elif not export_disabled and uploaded_file is None: zugferd_error = "Original-PDF nicht mehr geladen. Bitte die PDF erneut hochladen." - if zugferd_error: + 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." + ) + elif zugferd_error: st.error(f"Fehler bei der ZUGFeRD-Generierung: {zugferd_error}") + elif zugferd_pdf_bytes and attachment_file is not None: + st.success( + f"✅ Leistungsnachweis angehängt - die exportierte PDF enthält " + f"{count_pdf_pages(zugferd_pdf_bytes)} Seiten." + ) st.download_button( "🔒 ZUGFeRD PDF exportieren & herunterladen",