Compare commits
7 Commits
test-versi
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
619159cd33 | |
|
|
c3fb9dc7cb | |
|
|
df015c9f97 | |
|
|
4b4118fe70 | |
|
|
88c327c977 | |
|
|
b41554f1f1 | |
|
|
393d00d631 |
132
invoice_logic.py
132
invoice_logic.py
|
|
@ -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
108
main.py
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue