Erweitert um optionalen Leistungsnachweis pdf merge

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-29 14:39:12 +02:00
parent b41554f1f1
commit 88c327c977
2 changed files with 83 additions and 2 deletions

View File

@ -1459,6 +1459,33 @@ def normalize_pdf_bytes(pdf_bytes: bytes) -> bytes:
return pdf_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: 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 """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 Erfolgskontrolle nach der ZUGFeRD-Erzeugung - factur-x kann bei manchen PDF-Strukturen die XML

56
main.py
View File

@ -23,6 +23,8 @@ from invoice_logic import (
generate_compliant_zugferd_cii, generate_compliant_zugferd_cii,
normalize_pdf_bytes, normalize_pdf_bytes,
pdf_has_embedded_facturx, pdf_has_embedded_facturx,
append_pdf_pages,
count_pdf_pages,
add_pdfa_output_intent, add_pdfa_output_intent,
compute_invoice_totals, compute_invoice_totals,
resolve_buyer_endpoint, resolve_buyer_endpoint,
@ -125,6 +127,18 @@ with col1:
st.pdf(uploaded_file) st.pdf(uploaded_file)
with st.expander("Gefundener Text"): st.text(pdf_text) 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: with col2:
st.subheader("2. Strukturierte Daten validieren") st.subheader("2. Strukturierte Daten validieren")
if "extracted_data" not in st.session_state: st.session_state.extracted_data = None 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 # 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:
# 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 "" xml_data = generate_compliant_xrechnung(final_invoice_dict) if not export_disabled else ""
st.download_button( st.download_button(
"🔒 XRechnung exportieren & herunterladen", "🔒 XRechnung exportieren & herunterladen",
@ -622,21 +645,52 @@ with col2:
else: else:
zugferd_pdf_bytes = None zugferd_pdf_bytes = None
zugferd_error = None zugferd_error = None
attachment_error = None
if not export_disabled and uploaded_file is not None: if not export_disabled and uploaded_file is not None:
try: try:
uploaded_file.seek(0) uploaded_file.seek(0)
original_pdf_bytes = uploaded_file.read() 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( zugferd_pdf_bytes = build_zugferd_pdf(
json.dumps(final_invoice_dict, default=str, sort_keys=True), json.dumps(final_invoice_dict, default=str, sort_keys=True),
original_pdf_bytes, original_pdf_bytes,
) )
except Exception as e: except Exception as e:
if attachment_error is None:
zugferd_error = str(e) zugferd_error = str(e)
elif not export_disabled and uploaded_file is None: elif not export_disabled and uploaded_file is None:
zugferd_error = "Original-PDF nicht mehr geladen. Bitte die PDF erneut hochladen." 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}") 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( st.download_button(
"🔒 ZUGFeRD PDF exportieren & herunterladen", "🔒 ZUGFeRD PDF exportieren & herunterladen",