Compare commits

..

7 Commits

Author SHA1 Message Date
dfuhrmann3 619159cd33 Doppelte Anzeige der Leistungsnachweise entfernt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:52:32 +02:00
dfuhrmann3 c3fb9dc7cb 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>
2026-07-30 15:49:33 +02:00
dfuhrmann3 df015c9f97 Zahlungsziel als Klartext (BT-20) und Zahlungsreferenz (BT-83) ergänzt.
BT-20: Das Zahlungsziel erscheint zusätzlich zum strukturierten Fälligkeits-
datum im Klartext ("Zahlbar innerhalb von 14 Tagen ohne Abzug."). Bei Vorkasse
bewusst unterdrückt, da sich beide Angaben widersprechen würden.

BT-83: Die Rechnungsnummer als Verwendungszweck für den automatischen
Zahlungsabgleich - cbc:PaymentID (UBL) bzw. ram:PaymentReference (CII), jeweils
an der schema-korrekten Position der Sequenz.

Beides ergab der Vergleich gegen fünf Eingangs-E-Rechnungen: BT-20 lieferten
vier von fünf Lieferanten, BT-83 zwei von fünf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:01:10 +02:00
dfuhrmann3 4b4118fe70 Leistungsnachweis-Upload direkt unter den Rechnungs-Upload verschoben.
Zuvor stand das Feld hinter dem Vorschau-Block und rutschte dadurch nach dem
Hochladen einer Rechnung weit nach unten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:44:42 +02:00
dfuhrmann3 88c327c977 Erweitert um optionalen Leistungsnachweis pdf merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:39:12 +02:00
dfuhrmann3 b41554f1f1 Dateiname geändert: Kundenname auf 15 Zeichen limitiert und Datum durch Bearbeiternummer ersetzt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:58:58 +02:00
dfuhrmann3 393d00d631 Dateiname geändert: Kundenname auf 15 Zeichen limitiert und Datum durch Bearbeiternummer ersetzt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:51:25 +02:00
2 changed files with 222 additions and 18 deletions

View File

@ -14,13 +14,13 @@ from pydantic import BaseModel, Field
# 2. METADATEN SCHREIBSCHEMA # 2. METADATEN SCHREIBSCHEMA
# ========================================== # ==========================================
class InvoiceMetadata(BaseModel): class InvoiceMetadata(BaseModel):
invoice_number: str = Field(description="Die Rechnungsnummer nach 'Nummer:' (z.B. R26-219)") invoice_number: str = Field(description="Die Rechnungsnummer nach 'Nummer:' (z.B. R00-000)")
invoice_date: str = Field(description="Das Rechnungsdatum nach 'Datum:' im Format YYYY-MM-DD") invoice_date: str = Field(description="Das Rechnungsdatum nach 'Datum:' im Format YYYY-MM-DD")
delivery_date: str = Field(description="Das Lieferdatum nach 'Lieferdatum:' im Format YYYY-MM-DD") delivery_date: str = Field(description="Das Lieferdatum nach 'Lieferdatum:' im Format YYYY-MM-DD")
customer_number: str = Field(default="", description="Kundennummer (BT-46), unter der der Kunde beim Verkäufer geführt wird (z.B. der exakte Wert nach 'Kunden-Nr:'). Falls im Text keine Kundennummer erkennbar ist: leer lassen.") customer_number: str = Field(default="", description="Kundennummer (BT-46), unter der der Kunde beim Verkäufer geführt wird (z.B. der exakte Wert nach 'Kunden-Nr:'). Falls im Text keine Kundennummer erkennbar ist: leer lassen.")
leitweg_id: str = Field(default="", description="Leitweg-ID (BT-10) - NUR bei Rechnungen an Behörden (B2G), z.B. '04011000-1234-56'. Ist im Text keine Leitweg-ID erkennbar: leer lassen (NICHT die Kundennummer hier eintragen).") leitweg_id: str = Field(default="", description="Leitweg-ID (BT-10) - NUR bei Rechnungen an Behörden (B2G), z.B. '04011000-1234-56'. Ist im Text keine Leitweg-ID erkennbar: leer lassen (NICHT die Kundennummer hier eintragen).")
buyer_reference: str = Field(default="", description="Bestellnummer des KÄUFERS für diesen Auftrag (BT-13): der exakte Wert nach 'Ihr Auftrag:' o.ä. (z.B. 05.06.2026-Herr.Knospe). NICHT die Kundennummer (die gehört in customer_number) und NICHT unsere eigene Auftragsbestätigungsnummer (die gehört in order_id). Wenn im Text keine solche Käufer-Bestellnummer erkennbar ist: leer lassen.") buyer_reference: str = Field(default="", description="Bestellnummer des KÄUFERS für diesen Auftrag (BT-13): der exakte Wert nach 'Ihr Auftrag:' o.ä. (z.B. 05.06.2026-Herr.Mustermann). NICHT die Kundennummer (die gehört in customer_number) und NICHT unsere eigene Auftragsbestätigungsnummer (die gehört in order_id). Wenn im Text keine solche Käufer-Bestellnummer erkennbar ist: leer lassen.")
recipient_name: str = Field(description="Vollständige Firma des Kunden/Empfängers (z.B. Stadtwerke Hennigsdorf GmbH)") recipient_name: str = Field(description="Vollständige Firma des Kunden/Empfängers (z.B. Musterfirma GmbH)")
recipient_street: str = Field(description="Straße und Hausnummer des Kunden") recipient_street: str = Field(description="Straße und Hausnummer des Kunden")
recipient_zip: str = Field(description="Postleitzahl (PLZ) des Kunden") recipient_zip: str = Field(description="Postleitzahl (PLZ) des Kunden")
recipient_city: str = Field(description="Stadt des Kunden") recipient_city: str = Field(description="Stadt des Kunden")
@ -157,13 +157,48 @@ 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." return f"Bei Zahlung innerhalb von {days} Tagen gewähren wir {percent_str}% Skonto."
def format_payment_term_text(payment_term_days):
"""Klartext des Zahlungsziels fuer BT-20 ('14' -> 'Zahlbar innerhalb von 14 Tagen ohne
Abzug.'). Das Faelligkeitsdatum steht strukturiert in BT-9; der Klartext daneben ist bei
Lieferanten die Regel (4 von 5 Eingangsrechnungen liefern ihn) und fuer den Empfaenger
lesbar. Gibt "" zurueck, wenn kein gueltiges Zahlungsziel angegeben ist - es wird NICHTS
geraten."""
try:
days = int(str(payment_term_days).strip())
except (TypeError, ValueError):
return ""
if days <= 0:
return ""
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): def build_payment_terms_note(data):
"""Kombinierter Freitext fuer die Zahlungsbedingungen (BT-20): optionaler Vorkasse-Hinweis """Kombinierter Freitext fuer die Zahlungsbedingungen (BT-20): das Zahlungsziel im Klartext
und/oder eine Skonto-Vereinbarung. Beides sind Zahlungsbedingungen, fuer die EN16931 kein ODER ein Vorkasse-Hinweis, jeweils optional ergaenzt um eine Skonto-Vereinbarung. Fuer all
eigenes strukturiertes Feld kennt. Gibt "" zurueck, wenn keiner von beiden gesetzt ist.""" das kennt EN16931 kein eigenes strukturiertes Feld. Gibt "" zurueck, wenn nichts davon
gesetzt ist.
Vorkasse und Zahlungsziel schliessen sich bewusst aus: 'Zahlung per Vorkasse vor Lieferung'
und 'zahlbar innerhalb von 14 Tagen' wuerden sich widersprechen."""
parts = [] parts = []
if data.get("vorkasse"): if data.get("vorkasse"):
parts.append(VORKASSE_HINWEIS) parts.append(VORKASSE_HINWEIS)
else:
term_text = format_payment_term_text(data.get("payment_term_days"))
if term_text:
parts.append(term_text)
skonto_text = format_skonto_text( skonto_text = format_skonto_text(
data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"] data.get("skonto_percent"), data.get("skonto_days"), data["invoice_date"]
) )
@ -339,7 +374,7 @@ def _address_from_block(block):
def extract_delivery_address(text): def extract_delivery_address(text):
"""Zerlegt den 'Lieferadresse:'-Block (falls vorhanden) in ein Dict mit name/street/zip/city """Zerlegt den 'Lieferadresse:'-Block (falls vorhanden) in ein Dict mit name/street/zip/city
(BG-15 Lieferadresse). Der Block steht zwischen der 'Lieferadresse:'-Zeile und der ersten (BG-15 Lieferadresse). Der Block steht zwischen der 'Lieferadresse:'-Zeile und der ersten
'RECHNUNG'-Zeile; E-Mail-Zeilen (z.B. bei degewo) werden ignoriert. Gibt None zurueck, wenn 'RECHNUNG'-Zeile; E-Mail-Zeilen (bei manchen Kunden vorhanden) werden ignoriert. Gibt None zurueck, wenn
keine Lieferadresse vorhanden ist oder der Block keine 'PLZ Ort'-Zeile enthaelt.""" keine Lieferadresse vorhanden ist oder der Block keine 'PLZ Ort'-Zeile enthaelt."""
lines = text.split("\n") lines = text.split("\n")
start = next((i for i, ln in enumerate(lines) if ln.strip().lower().startswith("lieferadresse")), None) start = next((i for i, ln in enumerate(lines) if ln.strip().lower().startswith("lieferadresse")), None)
@ -458,14 +493,36 @@ def sanitize_filename_part(value):
return s.strip("-_") return s.strip("-_")
def build_export_basename(invoice_number, recipient_name, invoice_date): # Maximale Laenge des Empfaengernamens im Dateinamen - lange Kundennamen wuerden ihn sonst
"""Dateiname-Basis fuer die Exporte: '<Rechnungsnummer>_<Empfaenger>_<Rechnungsdatum>'. # unhandlich machen.
Leere Teile werden weggelassen (kein '__'); ist alles leer, bleibt 'Rechnung'.""" EXPORT_RECIPIENT_MAX_LEN = 15
date_norm = parse_to_strict_xml_date(invoice_date) or str(invoice_date or "").strip()
# Bearbeiter-Kopfzeile hat die Form '<Name> / <Nummer>', z.B. 'Max Mustermann / 1234'.
_BEARBEITER_NUMBER_PATTERN = re.compile(r"/\s*([A-Za-z0-9][A-Za-z0-9\-.]*)\s*$")
def extract_bearbeiter_number(bearbeiter):
"""Holt die Nummer hinter dem Bearbeiternamen ('Max Mustermann / 1234' -> '1234').
Steht im Feld nur eine Zahl, wird diese direkt genommen; ein Name ohne Nummer ergibt "",
damit kein Personenname im Dateinamen landet."""
s = str(bearbeiter or "").strip()
if not s:
return ""
m = _BEARBEITER_NUMBER_PATTERN.search(s)
if m:
return m.group(1)
return s if s.isdigit() else ""
def build_export_basename(invoice_number, recipient_name, bearbeiter):
"""Dateiname-Basis fuer die Exporte: '<Rechnungsnummer>_<Empfaenger>_<Bearbeiternummer>'.
Der Empfaenger wird auf EXPORT_RECIPIENT_MAX_LEN Zeichen gekuerzt. Leere Teile werden
weggelassen (kein '__'); ist alles leer, bleibt 'Rechnung'."""
recipient = sanitize_filename_part(recipient_name)[:EXPORT_RECIPIENT_MAX_LEN].strip("-_")
parts = [ parts = [
sanitize_filename_part(invoice_number), sanitize_filename_part(invoice_number),
sanitize_filename_part(recipient_name), recipient,
sanitize_filename_part(date_norm), sanitize_filename_part(extract_bearbeiter_number(bearbeiter)),
] ]
base = "_".join(p for p in parts if p) base = "_".join(p for p in parts if p)
return base or "Rechnung" return base or "Rechnung"
@ -1028,6 +1085,12 @@ def generate_compliant_xrechnung(data):
# BT-81 Zahlungsart: Standard bleibt 58 (SEPA-Ueberweisung), aber ueber # BT-81 Zahlungsart: Standard bleibt 58 (SEPA-Ueberweisung), aber ueber
# PAYMENT_MEANS_CODES pro Rechnung waehlbar (siehe UN/EDIFACT 4461). # 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")) ET.SubElement(payment_means, f"{{{CBC}}}PaymentMeansCode").text = str(data.get("payment_means_code", "58"))
# 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") payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount")
ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "") ET.SubElement(payee_acc, f"{{{CBC}}}ID").text = str(data["iban"]).replace(" ", "")
# BT-86 BIC der Verkaeuferbank (optional, aber von vielen Empfaengern erwartet). # BT-86 BIC der Verkaeuferbank (optional, aber von vielen Empfaengern erwartet).
@ -1329,6 +1392,13 @@ def generate_compliant_zugferd_cii(data):
ET.SubElement(dn_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["delivery_note_id"]).strip() ET.SubElement(dn_ref, f"{{{RAM}}}IssuerAssignedID").text = str(data["delivery_note_id"]).strip()
header_settle = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeSettlement") header_settle = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeSettlement")
# 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" ET.SubElement(header_settle, f"{{{RAM}}}InvoiceCurrencyCode").text = "EUR"
pay_means = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementPaymentMeans") pay_means = ET.SubElement(header_settle, f"{{{RAM}}}SpecifiedTradeSettlementPaymentMeans")
@ -1437,6 +1507,42 @@ def normalize_pdf_bytes(pdf_bytes: bytes) -> bytes:
return pdf_bytes return pdf_bytes
# 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."""
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()
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

108
main.py
View File

@ -23,6 +23,9 @@ 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,
MAX_ATTACHMENT_PDFS,
add_pdfa_output_intent, add_pdfa_output_intent,
compute_invoice_totals, compute_invoice_totals,
resolve_buyer_endpoint, resolve_buyer_endpoint,
@ -107,6 +110,39 @@ col1, col2 = st.columns(2)
with col1: with col1:
st.subheader("1. Dokument hochladen") st.subheader("1. Dokument hochladen")
uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"]) uploaded_file = st.file_uploader("Wähle eine Rechnungs-PDF", type=["pdf"])
# 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 ihr Upload
# die bereits geprueften Rechnungsdaten verwerfen.
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(
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.
# Keine eigene Dateiliste hier: der Uploader zeigt die hochgeladenen PDFs bereits selbst
# nebeneinander an - eine zusaetzliche Anzeige waere doppelt.
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."
)
pdf_text = "" pdf_text = ""
# Neue/andere Datei (oder Datei entfernt)? -> zuvor extrahierte Daten verwerfen, damit rechts # Neue/andere Datei (oder Datei entfernt)? -> zuvor extrahierte Daten verwerfen, damit rechts
@ -314,6 +350,18 @@ with col2:
) )
selected_payment_means_code = selected_payment_means_label.split(" - ")[0] 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) i_note = st.text_area("Globale Rechnungserläuterung / Freitext (BT-22)", value=data.get("invoice_note", ""), height=150)
with tab_parteien: with tab_parteien:
@ -530,6 +578,7 @@ with col2:
"items": edited_items, "total_net_amount": computed_net_amount, "total_vat_amount": computed_vat_amount, "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, "vat_rate_percent": vat_rate, "total_gross_amount": computed_gross_amount, "iban": VERKAEUFER_IBAN,
"payment_means_code": selected_payment_means_code, "payment_means_code": selected_payment_means_code,
"payment_reference": payment_ref,
"seller_contact_name": bearbeiter, "seller_contact_name": bearbeiter,
"seller_endpoint": seller_endpoint, "seller_endpoint": seller_endpoint,
"seller_contact_email": seller_contact_email, "seller_contact_email": seller_contact_email,
@ -590,7 +639,9 @@ with col2:
st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n" st.warning("⚠️ Export blockiert - folgende Pflichtangaben fehlen oder sind fehlerhaft:\n\n"
+ "\n".join(f"- {b}" for b in blocker)) + "\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 # 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 # ein Platzhalter "X" in BT-49 gesetzt, damit das Feld nicht komplett fehlt - dazu eine
@ -602,12 +653,24 @@ with col2:
"fehlt. Eine echte E-Mail-Adresse des Empfängers ist deutlich besser " "fehlt. Eine echte E-Mail-Adresse des Empfängers ist deutlich besser "
"(sonst bemängeln strenge Validatoren PEPPOL-EN16931-R010).") "(sonst bemängeln strenge Validatoren PEPPOL-EN16931-R010).")
# Dateiname: <Rechnungsnummer>_<Empfaenger>_<Rechnungsdatum> (dateisicher aufbereitet). # Dateiname: <Rechnungsnummer>_<Empfaenger, max. 15 Zeichen>_<Bearbeiternummer>
export_basename = build_export_basename(inv_num, r_name, inv_date) # (dateisicher aufbereitet).
export_basename = build_export_basename(inv_num, r_name, bearbeiter)
# 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_files:
_n = len(attachment_files)
st.warning(
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 "" 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",
@ -621,21 +684,56 @@ 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()
# 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_files:
try:
_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_bytes
)
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:
zugferd_error = str(e) if attachment_error is None:
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"❌ 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}") st.error(f"Fehler bei der ZUGFeRD-Generierung: {zugferd_error}")
elif zugferd_pdf_bytes and attachment_files:
_n = len(attachment_files)
st.success(
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( st.download_button(
"🔒 ZUGFeRD PDF exportieren & herunterladen", "🔒 ZUGFeRD PDF exportieren & herunterladen",