Compare commits

..

No commits in common. "main" and "test-version2" have entirely different histories.

7 changed files with 200 additions and 883 deletions

1
.gitignore vendored
View File

@ -9,6 +9,5 @@ docs/
tests/ tests/
pytest.ini pytest.ini
requirements-dev.txt requirements-dev.txt
.claude/

View File

@ -1,2 +0,0 @@
[server]
enableStaticServing = true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -1,136 +0,0 @@
import streamlit.components.v1 as components
# Sicherheitsnetz-Timer (ms): das Ausblenden geschieht primaer ueber das 'ended'-Event des
# Videos (exakt am Ende). Dieser Timer greift nur, falls 'ended' mal nicht feuert
# (z. B. Autoplay blockiert). Grosszuegig ueber der Videolaenge (~15,8 s) waehlen.
INTRO_FALLBACK_MS = 20000
# Statische URL (Streamlit Static Serving). Beim Live-Test ggf. auf "./static/intro.mp4"
# oder "/static/intro.mp4" korrigieren, falls "app/static/..." 404 liefert.
_INTRO_SRC = "app/static/intro.mp4"
_SEEN_KEY = "einrechnung_intro_seen"
def render_intro_overlay(fallback_ms: int = INTRO_FALLBACK_MS,
intro_src: str = _INTRO_SRC,
seen_key: str = _SEEN_KEY) -> None:
"""Injiziert ein einmaliges Intro-Overlay (MP4) in die Streamlit-Elternseite (Ansatz A).
Ein unsichtbarer components.html-Baustein (Hoehe 0) fuehrt JS aus, das ein
position:fixed-Overlay in window.parent.document einfuegt - ein echtes Overlay ueber der
Seite. Die Animation ist ein <video> (autoplay, muted, ohne Steuerleiste, ohne Loop):
Ausblenden exakt am Ende via 'ended'-Event (Timer nur als Sicherheitsnetz). Autoplay nur
einmal pro Browser (localStorage), "Ueberspringen"-Button + Replay-Button oben rechts
(Replay aendert den "gesehen"-Merker nicht).
"""
html = f"""
<script>
(function() {{
var doc = window.parent.document;
if (window.parent.__introInstalled) return; // Rerun-Schutz: nur einmal pro Seitenladen
window.parent.__introInstalled = true;
var FALLBACK = {fallback_ms};
var SRC = {intro_src!r};
var SEEN_KEY = {seen_key!r};
var style = doc.createElement('style');
style.textContent = `
#eir-backdrop {{
position: fixed; inset: 0; z-index: 2147483646;
display: flex; align-items: center; justify-content: center;
background: rgba(8,8,10,0.82); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px);
opacity: 0; transition: opacity .45s ease; pointer-events: none;
}}
#eir-backdrop.eir-show {{ opacity: 1; pointer-events: auto; }}
#eir-panel {{
position: relative; width: min(50vw, 1100px); aspect-ratio: 16 / 9;
max-width: 94vw; max-height: 88vh;
background: #0a0a0a; border-radius: 16px; overflow: hidden;
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(245,126,26,0.25);
}}
@media (max-width: 820px) {{ #eir-panel {{ width: 92vw; }} }}
#eir-video {{ width: 100%; height: 100%; display: block; object-fit: contain; background: #0a0a0a; }}
#eir-skip {{
position: absolute; top: 10px; right: 12px; z-index: 2;
font: 600 13px/1 -apple-system, BlinkMacSystemFont, sans-serif;
color: #eee; background: rgba(0,0,0,0.45); border: 1px solid rgba(255,255,255,0.18);
padding: 7px 12px; border-radius: 999px; cursor: pointer; user-select: none;
}}
#eir-skip:hover {{ background: rgba(0,0,0,0.7); }}
#eir-replay {{
position: fixed; top: 12px; right: 72px; z-index: 2147483647;
width: 40px; height: 40px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; cursor: pointer;
background: #17171a; border: 1px solid rgba(245,126,26,0.55);
box-shadow: 0 2px 10px rgba(0,0,0,0.35);
}}
#eir-replay:hover {{ background: #202024; }}
#eir-replay svg {{ width: 16px; height: 16px; }}
`;
doc.head.appendChild(style);
var replay = doc.createElement('div');
replay.id = 'eir-replay';
replay.title = 'Intro erneut abspielen';
replay.innerHTML = '<svg viewBox="0 0 24 24" fill="#F57E1A"><path d="M8 5v14l11-7z"/></svg>';
doc.body.appendChild(replay);
// Den ▶-Button links NEBEN Streamlits Menue-Leiste (oben rechts) platzieren, statt darueber.
// Die Toolbar (Deploy + ⋮-Menue) rendert asynchron -> mehrfach nachjustieren + bei Resize.
function positionReplay() {{
var win = window.parent, gap = 12, fallback = 72;
var tb = doc.querySelector('[data-testid="stToolbar"]');
var rightPx = fallback;
if (tb) {{
var cluster = win.innerWidth - tb.getBoundingClientRect().left; // Breite des Menue-Clusters
if (cluster > 8 && cluster < win.innerWidth * 0.6) rightPx = Math.round(cluster) + gap;
}}
replay.style.right = rightPx + 'px';
}}
positionReplay();
[150, 500, 1200].forEach(function(t) {{ setTimeout(positionReplay, t); }});
window.parent.addEventListener('resize', positionReplay);
var backdrop = doc.createElement('div');
backdrop.id = 'eir-backdrop';
backdrop.innerHTML =
'<div id="eir-panel">' +
'<div id="eir-skip">Überspringen ✕</div>' +
'<video id="eir-video" muted playsinline preload="none" src="' + SRC + '"></video>' +
'</div>';
doc.body.appendChild(backdrop);
var video = doc.getElementById('eir-video');
var skip = doc.getElementById('eir-skip');
var hideTimer = null;
function hide() {{
clearTimeout(hideTimer);
backdrop.classList.remove('eir-show');
setTimeout(function() {{ try {{ video.pause(); }} catch (e) {{}} }}, 500);
}}
function show() {{
backdrop.classList.add('eir-show');
try {{ video.currentTime = 0; }} catch (e) {{}} // Neustart von vorn (auch bei Replay)
var p = video.play();
if (p && p.catch) p.catch(function() {{}}); // Autoplay-Ablehnung ignorieren
clearTimeout(hideTimer);
hideTimer = setTimeout(hide, FALLBACK); // Sicherheitsnetz, falls 'ended' ausbleibt
}}
video.addEventListener('ended', hide); // exaktes Ausblenden am Videoende
skip.addEventListener('click', hide);
backdrop.addEventListener('click', function(e) {{ if (e.target === backdrop) hide(); }});
replay.addEventListener('click', show);
var seen;
try {{ seen = window.parent.localStorage.getItem(SEEN_KEY); }} catch (e) {{ seen = null; }}
if (seen !== '1') {{
try {{ window.parent.localStorage.setItem(SEEN_KEY, '1'); }} catch (e) {{}}
show();
}}
}})();
</script>
"""
components.html(html, height=0)

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. R00-000)") invoice_number: str = Field(description="Die Rechnungsnummer nach 'Nummer:' (z.B. R26-219)")
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.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.") 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.")
recipient_name: str = Field(description="Vollständige Firma des Kunden/Empfängers (z.B. Musterfirma GmbH)") recipient_name: str = Field(description="Vollständige Firma des Kunden/Empfängers (z.B. Stadtwerke Hennigsdorf 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")
@ -58,7 +58,6 @@ VERKAEUFER = {
"country": "DE", # BT-40 "country": "DE", # BT-40
"vat_id": "DE218436692", # BT-31 (Umsatzsteuer-ID) "vat_id": "DE218436692", # BT-31 (Umsatzsteuer-ID)
"register_id": "HRB 6058 NP", # BT-30 (Handelsregisternummer, NICHT die USt-ID) "register_id": "HRB 6058 NP", # BT-30 (Handelsregisternummer, NICHT die USt-ID)
"bic": "DEUTDEDB160", # BT-86 (BIC der Hausbank, Deutsche Bank)
} }
# UN/EDIFACT 4461 Zahlungsmittelcodes (BT-81), Auswahl der fuer B2B/B2G relevanten Codes. # UN/EDIFACT 4461 Zahlungsmittelcodes (BT-81), Auswahl der fuer B2B/B2G relevanten Codes.
@ -81,31 +80,6 @@ BUYER_ENDPOINT_SCHEMES = {
"0088": "GLN (Global Location Number)", "0088": "GLN (Global Location Number)",
} }
def resolve_buyer_endpoint(recipient_email, recipient_scheme, leitweg_id):
"""Ermittelt die effektive elektronische Adresse des Kaeufers (BT-49) samt Schema (BT-49-1).
Zuerst wird der Wert bestimmt: das BT-49-Feld hat Vorrang; ist es leer, wird die Leitweg-ID
(BT-10) genommen (so muss sie nur einmal eingegeben werden). Dann das Schema:
1. Enthaelt der Wert ein '@', ist es immer eine E-Mail -> Schema 'EM' (egal aus welchem Feld
er stammt und unabhaengig vom Dropdown - ein falsch gewaehltes Schema schlaegt nicht durch).
2. Stammt der Wert aus der Leitweg-ID (BT-49-Feld war leer) -> Schema '0204'.
3. Sonst (Wert direkt in BT-49 eingetragen, ohne '@', z.B. GLN) -> das gewaehlte Schema.
Ist beides leer, bleibt die Adresse leer (Schema unveraendert). Gibt (adresse, schema) zurueck."""
email = str(recipient_email or "").strip()
if email:
value, from_leitweg = email, False
else:
value, from_leitweg = str(leitweg_id or "").strip(), True
if not value:
return "", recipient_scheme
if "@" in value:
return value, "EM"
if from_leitweg:
return value, "0204"
return value, recipient_scheme
# Fester Hinweistext fuer Vorkasse. Vorkasse ist eine Zahlungs*bedingung* (BT-20), kein # Fester Hinweistext fuer Vorkasse. Vorkasse ist eine Zahlungs*bedingung* (BT-20), kein
# Zahlungs*mittel* - das Zahlungsmittel bleibt die Ueberweisung. Der Text wird daher zu den # Zahlungs*mittel* - das Zahlungsmittel bleibt die Ueberweisung. Der Text wird daher zu den
# Zahlungsbedingungen (BT-20) gelegt, nicht in den Zahlungsmittelcode (BT-81). # Zahlungsbedingungen (BT-20) gelegt, nicht in den Zahlungsmittelcode (BT-81).
@ -157,48 +131,13 @@ 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): das Zahlungsziel im Klartext """Kombinierter Freitext fuer die Zahlungsbedingungen (BT-20): optionaler Vorkasse-Hinweis
ODER ein Vorkasse-Hinweis, jeweils optional ergaenzt um eine Skonto-Vereinbarung. Fuer all und/oder eine Skonto-Vereinbarung. Beides sind Zahlungsbedingungen, fuer die EN16931 kein
das kennt EN16931 kein eigenes strukturiertes Feld. Gibt "" zurueck, wenn nichts davon eigenes strukturiertes Feld kennt. Gibt "" zurueck, wenn keiner von beiden gesetzt ist."""
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"]
) )
@ -238,11 +177,8 @@ def extract_pre_position_freetext(text):
Position deterministisch aus dem PDF-Text - unabhaengig vom (oft unvollstaendigen) LLM. Alles Position deterministisch aus dem PDF-Text - unabhaengig vom (oft unvollstaendigen) LLM. Alles
zwischen der ersten 'Pos.'-Zeile und der ersten Positionsnummer wird uebernommen, ausser den zwischen der ersten 'Pos.'-Zeile und der ersten Positionsnummer wird uebernommen, ausser den
Referenzzeilen (Auftragsbestaetigung/Angebot/Vergabeangebot/Lieferschein/Vertrag), die eigene Referenzzeilen (Auftragsbestaetigung/Angebot/Vergabeangebot/Lieferschein/Vertrag), die eigene
Felder haben, und der zwischen Seiten wiederholten Moeblierung (Seitenkopf, Bank-/Register- Felder haben. Gibt den Freitext zurueck ("" wenn keiner vorhanden) oder None, wenn die
zeilen, Uebertrag/Vortrag) - relevant bei mehrseitigen Rechnungen, deren erste 'Pos.'-Zeile auf Tabellenstruktur nicht erkennbar ist (dann bleibt ein evtl. LLM-Freitext unveraendert)."""
Seite 1 steht, die erste echte Position aber erst nach einem Seitenumbruch. Gibt den Freitext
zurueck ("" wenn keiner vorhanden) oder None, wenn die Tabellenstruktur nicht erkennbar ist
(dann bleibt ein evtl. LLM-Freitext unveraendert)."""
lines = [ln.strip() for ln in text.split("\n")] lines = [ln.strip() for ln in text.split("\n")]
header_idx = next((i for i, ln in enumerate(lines) if ln == "Pos."), None) header_idx = next((i for i, ln in enumerate(lines) if ln == "Pos."), None)
@ -256,46 +192,32 @@ def extract_pre_position_freetext(text):
if first_pos_idx is None: if first_pos_idx is None:
return None return None
# Leere Zeilen raus, dann die Seiten-Moeblierung strippen (Seitenkopf 'RECHNUNG'..'Betrag €', freetext_lines = [
# Bank-/Registerzeilen, Uebertrag/Vortrag), zuletzt die Referenzzeilen mit eigenem Feld. ln for ln in lines[header_idx + 1:first_pos_idx]
region = [ln for ln in lines[header_idx + 1:first_pos_idx] if ln] if ln and not _REFERENCE_LINE_PATTERN.match(ln)
region = _strip_bundle_furniture(region) ]
freetext_lines = [ln for ln in region if not _REFERENCE_LINE_PATTERN.match(ln)]
return "\n".join(freetext_lines) return "\n".join(freetext_lines)
# Beleg-Referenzen aus den Referenzzeilen: Schluesselwort -> Feld. Zuverlaessiger als eine # Beleg-Referenzen aus den Referenzzeilen: Schluesselwort -> Feld. Zuverlaessiger als die
# LLM-Zuordnung, die z.B. ein Vergabeangebot faelschlich in BT-12 (Vertrag) statt BT-17 einsortiert. # LLM-Zuordnung, die z.B. ein Vergabeangebot faelschlich in BT-12 (Vertrag) statt BT-17 einsortiert.
_DOC_REF_PATTERNS = { _DOC_REF_PATTERNS = {
"order_id": re.compile(r"\bAUFTRAGSBESTÄTIGUNG\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE), "order_id": re.compile(r"\bAUFTRAGSBESTÄTIGUNG\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE),
"delivery_note_id": re.compile(r"\bLIEFERSCHEIN\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE), "delivery_note_id": re.compile(r"\bLIEFERSCHEIN\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE),
"tender_id": re.compile(r"\bVERGABEANGEBOT\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE),
} }
# BT-17: Vergabeangebot. '\bVERGABEANGEBOT' greift dank Wortgrenze NICHT in ein blosses 'ANGEBOT'.
_TENDER_VERGABE_PATTERN = re.compile(r"\bVERGABEANGEBOT\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)", re.IGNORECASE)
def extract_document_references(text): def extract_document_references(text):
"""Liest Beleg-Referenzen deterministisch aus den Referenzzeilen des PDF-Texts: """Liest Beleg-Referenzen deterministisch aus den Referenzzeilen des PDF-Texts:
Auftragsbestaetigung (BT-14), Lieferschein (BT-16) und - fuer BT-17 - Vergabeangebot ODER, Auftragsbestaetigung (BT-14), Lieferschein (BT-16), Vergabeangebot (BT-17). Gibt ein Dict nur
falls keines vorhanden ist, ein einfaches Angebot ('Angebot <Nr> vom <Datum>'). Pro Rechnung mit den gefundenen Feldern zurueck - so lassen sich die (teils falschen) LLM-Zuordnungen gezielt
kommt nur eines von beiden vor; beide gehoeren fachlich in BT-17. Gibt ein Dict nur mit den korrigieren, ohne fehlende Felder zu ueberschreiben."""
gefundenen Feldern zurueck, damit fehlende Felder nicht ueberschrieben werden."""
result = {} result = {}
for field, pattern in _DOC_REF_PATTERNS.items(): for field, pattern in _DOC_REF_PATTERNS.items():
m = pattern.search(text) m = pattern.search(text)
if m: if m:
result[field] = m.group(1).strip() result[field] = m.group(1).strip()
# BT-17: Vergabeangebot bevorzugt; sonst ein einfaches 'Angebot <Nr> vom <Datum>'
# (ANGEBOT_PATTERN erzwingt die 'vom <Datum>'-Form und vermeidet so Fehltreffer auf
# Fliesstext wie 'Angebot erstellen').
m_vergabe = _TENDER_VERGABE_PATTERN.search(text)
if m_vergabe:
result["tender_id"] = m_vergabe.group(1).strip()
else:
m_angebot = ANGEBOT_PATTERN.search(text)
if m_angebot:
result["tender_id"] = m_angebot.group(1).strip()
return result return result
@ -323,167 +245,6 @@ def extract_referenznummer(text):
return m.group(1).strip() if m else "" return m.group(1).strip() if m else ""
# Erkennt eine "PLZ Ort"-Zeile (deutsche 5-stellige PLZ + Ort), z.B. "16761 Hennigsdorf".
_ZIP_CITY_PATTERN = re.compile(r"^(\d{5})\s+(.+)$")
# E-Mail-Adresse (grob), zum Herausfischen einer Kunden-E-Mail aus dem Adressbereich.
_EMAIL_PATTERN = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
def extract_recipient_address(text):
"""Zerlegt den Empfaenger-Adressblock deterministisch in (name, street, zip, city).
Aufbau der scitech-Rechnungen: Zeile 0 ist die eigene Absender-Rueckadresse
('scitech ... Str. 4 ... Hennigsdorf'). Danach folgt die Rechnungsadresse des Kunden bis
zur ersten 'Lieferadresse:'- oder 'RECHNUNG'-Zeile. Die 'PLZ Ort'-Zeile ist der Anker:
die Zeile davor ist die Strasse, alles davor der (ggf. mehrzeilige) Firmenname. Eine
nachgestellte USt-IdNr.-Zeile ('DE 123456789') steht hinter 'PLZ Ort' und wird ignoriert.
Gibt ("", "", "", "") zurueck, wenn keine 'PLZ Ort'-Zeile gefunden wird."""
lines = text.split("\n")
end = len(lines)
for i in range(1, len(lines)):
s = lines[i].strip()
if s.lower().startswith("lieferadresse") or s.upper().startswith("RECHNUNG"):
end = i
break
# Zeile 0 (eigene Absenderadresse) ueberspringen, leere Zeilen entfernen.
block = [ln.strip() for ln in lines[1:end] if ln.strip()]
return _address_from_block(block)
def _address_from_block(block):
"""Zerlegt einen Adressblock (Liste nicht-leerer Zeilen) in (name, street, zip, city) anhand
der 'PLZ Ort'-Zeile als Anker: die Zeile davor ist die Strasse, alles davor der (ggf.
mehrzeilige) Firmenname. Zeilen hinter 'PLZ Ort' (z.B. USt-IdNr., E-Mail) werden ignoriert.
Gibt ("", "", "", "") zurueck, wenn keine 'PLZ Ort'-Zeile gefunden wird."""
zip_city_idx = None
zip_val = city_val = ""
for idx, ln in enumerate(block):
m = _ZIP_CITY_PATTERN.match(ln)
if m:
zip_city_idx = idx # letzte Uebereinstimmung gewinnt (die echte Adresse)
zip_val, city_val = m.group(1), m.group(2).strip()
if zip_city_idx is None or zip_city_idx < 1:
return "", "", "", ""
street = block[zip_city_idx - 1]
name = " ".join(block[: zip_city_idx - 1]).strip()
return name, street, zip_val, city_val
def extract_delivery_address(text):
"""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
'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."""
lines = text.split("\n")
start = next((i for i, ln in enumerate(lines) if ln.strip().lower().startswith("lieferadresse")), None)
if start is None:
return None
block = []
for ln in lines[start + 1:]:
s = ln.strip()
if s.upper().startswith("RECHNUNG"):
break
if s and not _EMAIL_PATTERN.search(s):
block.append(s)
name, street, zip_val, city_val = _address_from_block(block)
if not (name and city_val):
return None
return {"name": name, "street": street, "zip": zip_val, "city": city_val}
def _normalize_address_text(*parts):
"""Normalisiert Adressteile fuer den Vergleich: kleinschreiben, 'Str.'/'Straße' vereinheitlichen,
dann alle Nicht-Alphanumerik entfernen. So sind Zeilenumbrueche und Schreibweisen egal."""
s = " ".join(str(p or "") for p in parts).lower()
s = s.replace("straße", "strasse").replace("str.", "strasse").replace("ß", "ss")
return re.sub(r"[^a-z0-9]", "", s)
def delivery_address_differs(delivery, recipient_name, recipient_street, recipient_zip, recipient_city):
"""True, wenn die Lieferadresse (dict von extract_delivery_address) inhaltlich von der
Rechnungsadresse abweicht - unempfindlich gegen Zeilenumbrueche und 'Str.'/'Straße'. Nur dann
traegt BG-15 echte Information (sonst waere die Lieferadresse eine reine Dopplung)."""
if not delivery:
return False
d = _normalize_address_text(delivery.get("name"), delivery.get("street"), delivery.get("zip"), delivery.get("city"))
b = _normalize_address_text(recipient_name, recipient_street, recipient_zip, recipient_city)
return d != b
def extract_recipient_email(text):
"""Sucht im Adressbereich (bis zur 'RECHNUNG'-Zeile) nach einer Kunden-E-Mail. Die eigene
scitech-Adresse wird ausgeschlossen. Gibt "" zurueck, wenn keine gefunden wird."""
lines = text.split("\n")
end = len(lines)
for i, ln in enumerate(lines):
if ln.strip().upper().startswith("RECHNUNG"):
end = i
break
for ln in lines[:end]:
m = _EMAIL_PATTERN.search(ln)
if m and "scitech" not in m.group(0).lower():
return m.group(0)
return ""
# USt-Satz aus der Steuerzeile lesen, z.B. '19 % USt. auf € 2.680,00' -> 19.0.
_VAT_RATE_PATTERN = re.compile(r"(\d+(?:[.,]\d+)?)\s*%\s*USt", re.IGNORECASE)
def extract_header_metadata_pure_python(text):
"""Deterministischer Ersatz fuer die LLM-Kopfdatenextraktion (test-version3, KI-frei).
Liefert dasselbe Dict-Schema, das zuvor das vLLM/gemma-Modell produziert hat, damit die
nachgelagerte Pipeline in main.py (Positionen, Beleg-Referenzen, Freitext, Angebots-/
Referenzbezug usw.) unveraendert darauf aufsetzen kann. Alle Werte stammen aus den
regelmaessigen Kopf-Labeln ('Nummer:', 'Datum:', 'Kunden-Nr.:', ...) und dem Adressblock
der scitech-Rechnungen. Felder ohne dedizierte Label (leitweg_id, doc-refs, invoice_note)
bleiben leer - sie werden in main.py separat deterministisch befuellt bzw. im UI ergaenzt."""
name, street, zip_val, city_val = extract_recipient_address(text)
zahlungsziel = extract_labeled_header_value(text, "Zahlungsziel:")
m_days = re.search(r"\d+", zahlungsziel)
m_vat = _VAT_RATE_PATTERN.search(text)
vat_rate = float(m_vat.group(1).replace(",", ".")) if m_vat else 19.0
# BG-15 Lieferadresse nur vorbelegen, wenn sie von der Rechnungsadresse abweicht - sonst waere
# sie reine Dopplung (die Rechnungsadresse ist meist auch der Lieferort). Die UI-Felder bleiben
# dann leer und es wird keine BG-15 exportiert.
_deliv = extract_delivery_address(text)
_deliv = _deliv if delivery_address_differs(_deliv, name, street, zip_val, city_val) else None
return {
"invoice_number": extract_labeled_header_value(text, "Nummer:"),
"invoice_date": parse_to_strict_xml_date(extract_labeled_header_value(text, "Datum:")),
"delivery_date": parse_to_strict_xml_date(extract_labeled_header_value(text, "Lieferdatum:")),
"customer_number": extract_labeled_header_value(text, "Kunden-Nr.:"),
"leitweg_id": "", # BT-10: nur B2G, kein eigenes Label - im UI zu erfassen
"buyer_reference": extract_labeled_header_value(text, "Ihr Auftrag:"),
"recipient_name": name,
"recipient_street": street,
"recipient_zip": zip_val,
"recipient_city": city_val,
"recipient_email": extract_recipient_email(text),
"vat_rate_percent": vat_rate,
"delivery_note_id": "", # wird in main.py via extract_document_references gesetzt
"order_id": "",
"contract_id": "",
"tender_id": "",
"service_start_date": parse_to_strict_xml_date(extract_labeled_header_value(text, "Zeitraum von:")),
"service_end_date": parse_to_strict_xml_date(extract_labeled_header_value(text, "Zeitraum bis:")),
"payment_term_days": m_days.group(0) if m_days else "",
"invoice_note": "", # wird in main.py via extract_pre_position_freetext gesetzt
# BG-15 Lieferadresse (nur bei abweichendem Lieferort vorbelegt, sonst leer):
"delivery_name": _deliv["name"] if _deliv else "",
"delivery_street": _deliv["street"] if _deliv else "",
"delivery_zip": _deliv["zip"] if _deliv else "",
"delivery_city": _deliv["city"] if _deliv else "",
}
def sanitize_filename_part(value): def sanitize_filename_part(value):
"""Macht einen String dateisystem-sicher: entfernt in Dateinamen kritische Zeichen """Macht einen String dateisystem-sicher: entfernt in Dateinamen kritische Zeichen
(Pfadtrenner/Sonderzeichen) und fasst Whitespace zu einem Bindestrich zusammen.""" (Pfadtrenner/Sonderzeichen) und fasst Whitespace zu einem Bindestrich zusammen."""
@ -493,36 +254,14 @@ def sanitize_filename_part(value):
return s.strip("-_") return s.strip("-_")
# Maximale Laenge des Empfaengernamens im Dateinamen - lange Kundennamen wuerden ihn sonst def build_export_basename(invoice_number, recipient_name, invoice_date):
# unhandlich machen. """Dateiname-Basis fuer die Exporte: '<Rechnungsnummer>_<Empfaenger>_<Rechnungsdatum>'.
EXPORT_RECIPIENT_MAX_LEN = 15 Leere Teile werden weggelassen (kein '__'); ist alles leer, bleibt 'Rechnung'."""
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),
recipient, sanitize_filename_part(recipient_name),
sanitize_filename_part(extract_bearbeiter_number(bearbeiter)), sanitize_filename_part(date_norm),
] ]
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"
@ -616,158 +355,6 @@ def extract_invoice_items_pure_python(text):
return items return items
# ==========================================
# BUNDLE-RECHNUNGEN (konfigurierte Positionen mit Komponentenliste, z.B. Dell-Notebooks)
# ==========================================
# Solche Rechnungen (Signal: "Menge Komponenten") haben einen voellig anderen Aufbau als die
# ueblichen Service-Rechnungen: jede Position ist ein konfiguriertes Buendel aus Produktname +
# Dutzenden Komponentenzeilen, verteilt ueber viele Seiten mit wiederholten Seitenkoepfen und
# "Uebertrag/Vortrag"-Zeilen. Der Preis steht als 3-Zeilen-Block (Menge+Einheit / Einzelpreis /
# Zeilensumme) mittendrin; die Service-Tags (Seriennummern) folgen erst danach. Dafuer gibt es
# diesen eigenen Parser - der uebrige (haeufigere) Rechnungstyp bleibt beim Standardparser.
_BUNDLE_MARKER = "Menge Komponenten"
_BUNDLE_MONEY_LINE = re.compile(r"^[\d.]+,\d{2}$")
_BUNDLE_UNIT_LINE = re.compile(
r"^(\d+(?:[.,]\d+)?)\s*(Stk\.|pausch\.|pauschal|Monat\(e\)|Tag\(e\)|Std\.|Stück)$",
re.IGNORECASE,
)
_STATED_TAX_BASIS_PATTERN = re.compile(r"%\s*USt\.?\s*auf\s*€\s*([\d.]+,\d{2})", re.IGNORECASE)
def is_bundle_invoice(text):
"""True, wenn die Rechnung dem Buendel-Layout entspricht (konfigurierte Positionen mit
Komponentenliste) - erkennbar am Marker 'Menge Komponenten'. Nur dann greift der Bundle-Parser."""
return _BUNDLE_MARKER in text
def _is_bundle_position_number(line):
"""Eine alleinstehende kleine Ganzzahl (Positions- oder Komponentennummer)."""
return line.isdigit() and len(line) <= 3
def _strip_bundle_furniture(lines):
"""Entfernt die zwischen den Seiten wiederholte Moeblierung aus dem Zeilenstrom: den ganzen
Seitenkopf ('RECHNUNG' ... 'Betrag €'), die Bank-/Registerzeilen sowie 'Uebertrag/Vortrag'
samt der jeweils angrenzenden laufenden Uebertragssumme (Geldzeile)."""
out = []
i, n = 0, len(lines)
while i < n:
ln = lines[i]
if ln == "RECHNUNG": # kompletten Seitenkopf bis 'Betrag €' ueberspringen
j = i + 1
while j < n and lines[j] != "Betrag €":
j += 1
i = j + 1
continue
if ln.startswith("Deutsche Bank ") or ln.startswith("Registergericht"):
i += 1
continue
if ln == "Übertrag:":
if out and _BUNDLE_MONEY_LINE.match(out[-1]):
out.pop() # laufende Uebertragssumme davor entfernen
i += 1
continue
if ln == "Vortrag:":
i += 1
if i < n and _BUNDLE_MONEY_LINE.match(lines[i]):
i += 1 # laufende Vortragssumme danach entfernen
continue
out.append(ln)
i += 1
return out
def extract_bundle_items(text):
"""Parst eine Buendel-Rechnung: liefert je Position (Produkt) name, description (volle
Komponentenliste + Service-Tags = BT-154), quantity, unit, unit_price. Ankerpunkt ist der
3-Zeilen-Preisblock; die Positionsgrenzen sind die Positions-Header (Name vor 'Menge
Komponenten' bzw. die Zeile hinter der Positionsnummer bei Positionen ohne Komponentenliste)."""
raw = [l.strip() for l in text.split("\n") if l.strip()]
lines = _strip_bundle_furniture(raw)
end = next((i for i, l in enumerate(lines) if l.startswith("Zwischensumme")), len(lines))
lines = lines[:end]
# 1) Preisbloecke: <Menge Einheit> gefolgt von 2 Geldzeilen (Einzelpreis, Zeilensumme).
price_blocks = []
for i in range(len(lines) - 2):
m = _BUNDLE_UNIT_LINE.match(lines[i])
if m and _BUNDLE_MONEY_LINE.match(lines[i + 1]) and _BUNDLE_MONEY_LINE.match(lines[i + 2]):
price_blocks.append((i, clean_german_float(m.group(1)), m.group(2),
clean_german_float(lines[i + 1])))
if not price_blocks:
return []
marker_idxs = [i for i, l in enumerate(lines) if l == _BUNDLE_MARKER]
# 2) Fuer jeden Preisblock den Positions-Header (Startindex + Name) bestimmen.
headers = [] # (start_idx, name, pb_idx, qty, unit, unit_price)
prev_boundary = 0
for (pb_idx, qty, unit, up) in price_blocks:
mk = next((m for m in reversed(marker_idxs) if prev_boundary <= m < pb_idx), None)
if mk is not None:
# Name = Zeilen direkt vor 'Menge Komponenten' bis zur Positionsnummer.
k = mk - 1
name_lines = []
while k >= prev_boundary and not _is_bundle_position_number(lines[k]):
name_lines.insert(0, lines[k])
k -= 1
start = k if (k >= prev_boundary and _is_bundle_position_number(lines[k])) else k + 1
name = " ".join(name_lines).strip()
else:
# Position ohne Komponentenliste (z.B. Serviceleistung): rueckwaerts vom Preisblock die
# Positionsnummer suchen; die Zeile dahinter ist der Name.
k = pb_idx
while k > prev_boundary and not _is_bundle_position_number(lines[k - 1]):
k -= 1
start = k - 1 if (k - 1 >= prev_boundary and _is_bundle_position_number(lines[k - 1])) else k
name = lines[k] if k < pb_idx else ""
headers.append((start, name, pb_idx, qty, unit, up))
prev_boundary = pb_idx + 3
# 3) Positionen bauen: Beschreibung = Segment vom Header bis zum naechsten Header,
# ohne Preisblock-Zeilen, fuehrende Positionsnummer und verirrte Geldzeilen.
items = []
for h_i, (start, name, pb_idx, qty, unit, up) in enumerate(headers):
seg_end = headers[h_i + 1][0] if h_i + 1 < len(headers) else len(lines)
seg = lines[start:seg_end]
skip = {pb_idx - start, pb_idx - start + 1, pb_idx - start + 2}
desc_lines = []
for idx, l in enumerate(seg):
if idx in skip:
continue
if idx == 0 and _is_bundle_position_number(l):
continue
if _BUNDLE_MONEY_LINE.match(l):
continue
desc_lines.append(l)
items.append({
"name": name,
"description": "\n".join(desc_lines).strip(),
"quantity": qty,
"unit": unit,
"unit_price": up,
})
return items
def extract_invoice_items(text):
"""Dispatcher: Buendel-Rechnungen (konfigurierte Positionen mit Komponentenliste) ueber den
Bundle-Parser, alle uebrigen ueber den Standardparser (unveraendertes Verhalten)."""
if is_bundle_invoice(text):
return extract_bundle_items(text)
return extract_invoice_items_pure_python(text)
def extract_stated_tax_basis(text):
"""Liest die auf der Rechnung ausgewiesene Bemessungsgrundlage aus der Steuerzeile
('19 % USt. auf € 217.500,55' -> 217500.55). Fuer den Summen-Abgleich als Sicherheitsnetz.
Gibt None zurueck, wenn keine solche Zeile gefunden wird."""
m = _STATED_TAX_BASIS_PATTERN.search(text)
return clean_german_float(m.group(1)) if m else None
def map_unece_unit_code(unit_str): def map_unece_unit_code(unit_str):
u = str(unit_str).lower() u = str(unit_str).lower()
if "monat" in u: return "MON" if "monat" in u: return "MON"
@ -1060,44 +647,16 @@ def generate_compliant_xrechnung(data):
legal_cust = ET.SubElement(party_cust, f"{{{CAC}}}PartyLegalEntity") legal_cust = ET.SubElement(party_cust, f"{{{CAC}}}PartyLegalEntity")
ET.SubElement(legal_cust, f"{{{CBC}}}RegistrationName").text = str(data["recipient_name"]) ET.SubElement(legal_cust, f"{{{CBC}}}RegistrationName").text = str(data["recipient_name"])
# BG-15 Lieferadresse nur, wenn Name + Ort gesetzt sind (im UI nur bei abweichendem Lieferort
# vorbelegt). UBL-Reihenfolge in cac:Delivery: ActualDeliveryDate, DeliveryLocation, DeliveryParty.
_has_deliv_addr = bool(str(data.get("delivery_name") or "").strip() and str(data.get("delivery_city") or "").strip())
if data.get("delivery_date") or _has_deliv_addr:
delivery = ET.SubElement(root, f"{{{CAC}}}Delivery")
if data.get("delivery_date"): if data.get("delivery_date"):
delivery = ET.SubElement(root, f"{{{CAC}}}Delivery")
ET.SubElement(delivery, f"{{{CBC}}}ActualDeliveryDate").text = parse_to_strict_xml_date(data["delivery_date"]) ET.SubElement(delivery, f"{{{CBC}}}ActualDeliveryDate").text = parse_to_strict_xml_date(data["delivery_date"])
if _has_deliv_addr:
deliv_loc = ET.SubElement(delivery, f"{{{CAC}}}DeliveryLocation")
deliv_addr = ET.SubElement(deliv_loc, f"{{{CAC}}}Address")
if str(data.get("delivery_street") or "").strip():
ET.SubElement(deliv_addr, f"{{{CBC}}}StreetName").text = str(data["delivery_street"]).strip() # BT-75
ET.SubElement(deliv_addr, f"{{{CBC}}}CityName").text = str(data["delivery_city"]).strip() # BT-77
if str(data.get("delivery_zip") or "").strip():
ET.SubElement(deliv_addr, f"{{{CBC}}}PostalZone").text = str(data["delivery_zip"]).strip() # BT-78
country = ET.SubElement(deliv_addr, f"{{{CAC}}}Country")
ET.SubElement(country, f"{{{CBC}}}IdentificationCode").text = "DE" # BT-80
deliv_party = ET.SubElement(delivery, f"{{{CAC}}}DeliveryParty")
party_name_deliv = ET.SubElement(deliv_party, f"{{{CAC}}}PartyName")
ET.SubElement(party_name_deliv, f"{{{CBC}}}Name").text = str(data["delivery_name"]).strip() # BT-70
payment_means = ET.SubElement(root, f"{{{CAC}}}PaymentMeans") payment_means = ET.SubElement(root, f"{{{CAC}}}PaymentMeans")
# 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).
_bic = str(data.get("bic") or VERKAEUFER["bic"]).replace(" ", "")
if _bic:
fin_branch = ET.SubElement(payee_acc, f"{{{CAC}}}FinancialInstitutionBranch")
ET.SubElement(fin_branch, f"{{{CBC}}}ID").text = _bic
# BT-20 Zahlungsbedingungen als Freitext in cac:PaymentTerms/cbc:Note (Vorkasse und/oder # BT-20 Zahlungsbedingungen als Freitext in cac:PaymentTerms/cbc:Note (Vorkasse und/oder
# Skonto) - EN16931 kennt dafuer kein eigenes strukturiertes Feld. Muss laut UBL-Schema NACH # Skonto) - EN16931 kennt dafuer kein eigenes strukturiertes Feld. Muss laut UBL-Schema NACH
@ -1158,9 +717,6 @@ def generate_compliant_xrechnung(data):
ET.SubElement(monetary_total, f"{{{CBC}}}TaxInclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}TaxInclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}"
if data.get("allowance_amount", 0.0) > 0: if data.get("allowance_amount", 0.0) > 0:
ET.SubElement(monetary_total, f"{{{CBC}}}AllowanceTotalAmount", {"currencyID": "EUR"}).text = f"{data['allowance_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}AllowanceTotalAmount", {"currencyID": "EUR"}).text = f"{data['allowance_amount']:.2f}"
# BT-113 Vorauszahlung: mangels Anzahlungsdaten immer 0,00 - macht die Summensektion
# vollstaendig (viele Empfaenger/Validatoren erwarten das Feld). Muss VOR PayableAmount stehen.
ET.SubElement(monetary_total, f"{{{CBC}}}PrepaidAmount", {"currencyID": "EUR"}).text = "0.00"
ET.SubElement(monetary_total, f"{{{CBC}}}PayableAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}PayableAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}"
for index, item in enumerate(data["items"], start=1): for index, item in enumerate(data["items"], start=1):
@ -1227,10 +783,7 @@ def generate_compliant_zugferd_cii(data):
ET.SubElement(biz_process, f"{{{RAM}}}ID").text = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0" ET.SubElement(biz_process, f"{{{RAM}}}ID").text = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"
guideline = ET.SubElement(ex_doc_ctx, f"{{{RAM}}}GuidelineSpecifiedDocumentContextParameter") guideline = ET.SubElement(ex_doc_ctx, f"{{{RAM}}}GuidelineSpecifiedDocumentContextParameter")
# BT-24 Profil: ZUGFeRD/Factur-X wird B2B verschickt - daher das EN16931-Profil (COMFORT) ET.SubElement(guideline, f"{{{RAM}}}ID").text = "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0"
# deklarieren, NICHT das strengere KoSIT-XRechnung-Behoerdenprofil. So passt die
# Profildeklaration zum factur-x-Level 'en16931' und zur B2B-Ausrichtung (vgl. Lieferanten-XML).
ET.SubElement(guideline, f"{{{RAM}}}ID").text = "urn:cen.eu:en16931:2017"
ex_doc = ET.SubElement(root, f"{{{RSM}}}ExchangedDocument") ex_doc = ET.SubElement(root, f"{{{RSM}}}ExchangedDocument")
ET.SubElement(ex_doc, f"{{{RAM}}}ID").text = str(data["invoice_number"]) ET.SubElement(ex_doc, f"{{{RAM}}}ID").text = str(data["invoice_number"])
@ -1366,18 +919,6 @@ def generate_compliant_zugferd_cii(data):
ET.SubElement(tender_ref, f"{{{RAM}}}TypeCode").text = "50" ET.SubElement(tender_ref, f"{{{RAM}}}TypeCode").text = "50"
header_deliv = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeDelivery") header_deliv = ET.SubElement(trade_trans, f"{{{RAM}}}ApplicableHeaderTradeDelivery")
# BG-15 Lieferadresse (ShipToTradeParty) nur bei gesetztem Name + Ort. Muss laut CII-Sequenz
# VOR dem Lieferdatum (ActualDeliverySupplyChainEvent) stehen.
if str(data.get("delivery_name") or "").strip() and str(data.get("delivery_city") or "").strip():
ship_to = ET.SubElement(header_deliv, f"{{{RAM}}}ShipToTradeParty")
ET.SubElement(ship_to, f"{{{RAM}}}Name").text = str(data["delivery_name"]).strip() # BT-70
deliv_addr = ET.SubElement(ship_to, f"{{{RAM}}}PostalTradeAddress")
if str(data.get("delivery_zip") or "").strip():
ET.SubElement(deliv_addr, f"{{{RAM}}}PostcodeCode").text = str(data["delivery_zip"]).strip() # BT-78
if str(data.get("delivery_street") or "").strip():
ET.SubElement(deliv_addr, f"{{{RAM}}}LineOne").text = str(data["delivery_street"]).strip() # BT-75
ET.SubElement(deliv_addr, f"{{{RAM}}}CityName").text = str(data["delivery_city"]).strip() # BT-77
ET.SubElement(deliv_addr, f"{{{RAM}}}CountryID").text = "DE" # BT-80
if data.get("delivery_date"): if data.get("delivery_date"):
act_deliv = ET.SubElement(header_deliv, f"{{{RAM}}}ActualDeliverySupplyChainEvent") act_deliv = ET.SubElement(header_deliv, f"{{{RAM}}}ActualDeliverySupplyChainEvent")
occ_date = ET.SubElement(act_deliv, f"{{{RAM}}}OccurrenceDateTime") occ_date = ET.SubElement(act_deliv, f"{{{RAM}}}OccurrenceDateTime")
@ -1392,13 +933,6 @@ 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")
@ -1407,11 +941,6 @@ def generate_compliant_zugferd_cii(data):
ET.SubElement(pay_means, f"{{{RAM}}}TypeCode").text = str(data.get("payment_means_code", "58")) ET.SubElement(pay_means, f"{{{RAM}}}TypeCode").text = str(data.get("payment_means_code", "58"))
payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount") payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount")
ET.SubElement(payee_acc, f"{{{RAM}}}IBANID").text = str(data["iban"]).replace(" ", "") ET.SubElement(payee_acc, f"{{{RAM}}}IBANID").text = str(data["iban"]).replace(" ", "")
# BT-86 BIC der Verkaeuferbank (nach dem Konto, laut CII-Schema).
_bic = str(data.get("bic") or VERKAEUFER["bic"]).replace(" ", "")
if _bic:
fin_inst = ET.SubElement(pay_means, f"{{{RAM}}}PayeeSpecifiedCreditorFinancialInstitution")
ET.SubElement(fin_inst, f"{{{RAM}}}BICID").text = _bic
# BG-23: pro Steuersatz-Bucket ein eigenes ram:ApplicableTradeTax (Mehrfach-Steuersaetze). # BG-23: pro Steuersatz-Bucket ein eigenes ram:ApplicableTradeTax (Mehrfach-Steuersaetze).
# Fallback fuer Aufrufer ohne tax_buckets (Einzelsteuersatz, byte-identisch zu vor Phase 1): # Fallback fuer Aufrufer ohne tax_buckets (Einzelsteuersatz, byte-identisch zu vor Phase 1):
@ -1475,9 +1004,6 @@ def generate_compliant_zugferd_cii(data):
ET.SubElement(header_sum, f"{{{RAM}}}TaxBasisTotalAmount").text = f"{data['total_net_amount']:.2f}" ET.SubElement(header_sum, f"{{{RAM}}}TaxBasisTotalAmount").text = f"{data['total_net_amount']:.2f}"
ET.SubElement(header_sum, f"{{{RAM}}}TaxTotalAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}" ET.SubElement(header_sum, f"{{{RAM}}}TaxTotalAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_amount']:.2f}"
ET.SubElement(header_sum, f"{{{RAM}}}GrandTotalAmount").text = f"{data['total_gross_amount']:.2f}" ET.SubElement(header_sum, f"{{{RAM}}}GrandTotalAmount").text = f"{data['total_gross_amount']:.2f}"
# BT-113 Vorauszahlung: mangels Anzahlungsdaten immer 0,00. Muss laut CII-Schema NACH
# GrandTotalAmount und VOR DuePayableAmount stehen (DuePayable = GrandTotal - Vorauszahlung).
ET.SubElement(header_sum, f"{{{RAM}}}TotalPrepaidAmount").text = "0.00"
ET.SubElement(header_sum, f"{{{RAM}}}DuePayableAmount").text = f"{data['total_gross_amount']:.2f}" ET.SubElement(header_sum, f"{{{RAM}}}DuePayableAmount").text = f"{data['total_gross_amount']:.2f}"
xml_str = ET.tostring(root, encoding="utf-8") xml_str = ET.tostring(root, encoding="utf-8")
@ -1507,42 +1033,6 @@ 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
@ -1585,3 +1075,31 @@ def add_pdfa_output_intent(pdf_bytes: bytes, icc_path: str = SRGB_ICC_PATH) -> b
return buf.getvalue() return buf.getvalue()
finally: finally:
pdf.close() pdf.close()
def fix_xrechnung_xmp_conformance(pdf_bytes: bytes) -> bytes:
"""
Die factur-x-Bibliothek schreibt standardmäßig fx:ConformanceLevel = 'EN 16931' und
fx:Version = '1.0' in die XMP-Metadaten (generischer Factur-X-Standard). Da unsere CII-XML
aber explizit das KoSIT-XRechnung-Profil deklariert (GuidelineID .../xrechnung_3.0), erwarten
Validatoren hier stattdessen 'XRECHNUNG' und '3.0' - sonst schlagen z. B.
VD-Valitool-H-022/H-024 fehl, weil PDF-Metadaten und eingebettetes XML nicht zusammenpassen.
"""
pdf = pikepdf.open(io.BytesIO(pdf_bytes))
try:
if "/Metadata" in pdf.Root:
xmp_bytes = pdf.Root.Metadata.read_bytes()
xmp_str = xmp_bytes.decode("utf-8")
xmp_str = xmp_str.replace(
"<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>",
"<fx:ConformanceLevel>XRECHNUNG</fx:ConformanceLevel>",
).replace(
"<fx:Version>1.0</fx:Version>",
"<fx:Version>3.0</fx:Version>",
)
pdf.Root.Metadata.write(xmp_str.encode("utf-8"))
buf = io.BytesIO()
pdf.save(buf)
return buf.getvalue()
finally:
pdf.close()

352
main.py
View File

@ -1,11 +1,13 @@
import io
import os import os
import io
import streamlit as st import streamlit as st
import json import json
import re
import fitz import fitz
from PIL import Image import requests
from facturx import generate_from_binary as facturx_generate_from_binary from facturx import generate_from_binary as facturx_generate_from_binary
from invoice_logic import ( from invoice_logic import (
InvoiceMetadata,
VERKAEUFER, VERKAEUFER,
VERKAEUFER_IBAN, VERKAEUFER_IBAN,
PAYMENT_MEANS_CODES, PAYMENT_MEANS_CODES,
@ -14,8 +16,7 @@ from invoice_logic import (
berechne_faelligkeit, berechne_faelligkeit,
kaufmaennisches_runden, kaufmaennisches_runden,
clean_german_float, clean_german_float,
extract_invoice_items, extract_invoice_items_pure_python,
extract_stated_tax_basis,
map_unece_unit_code, map_unece_unit_code,
parse_to_strict_xml_date, parse_to_strict_xml_date,
format_cii_date, format_cii_date,
@ -23,22 +24,18 @@ 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,
fix_xrechnung_xmp_conformance,
compute_invoice_totals, compute_invoice_totals,
resolve_buyer_endpoint,
extract_service_period, extract_service_period,
detect_contract_type, detect_contract_type,
detect_invoice_type_code, detect_invoice_type_code,
extract_angebot_reference,
extract_pre_position_freetext, extract_pre_position_freetext,
extract_document_references, extract_document_references,
extract_labeled_header_value, extract_labeled_header_value,
extract_referenznummer, extract_referenznummer,
extract_header_metadata_pure_python,
) )
from intro_overlay import render_intro_overlay
# ========================================== # ==========================================
# 0. SITZUNGS-STATUS INITIALISIEREN (ZÄHLER GEGEN WIDGET-CACHING) # 0. SITZUNGS-STATUS INITIALISIEREN (ZÄHLER GEGEN WIDGET-CACHING)
@ -47,11 +44,58 @@ if "invoice_counter" not in st.session_state:
st.session_state.invoice_counter = 0 st.session_state.invoice_counter = 0
# ========================================== # ==========================================
# 1. KOPFDATEN-EXTRAKTION (KI-frei, test-version3) # 1. API-CLIENT FÜR DAS vLLM-BACKEND
# ========================================== # ==========================================
# Diese Variante kommt ohne LLM/vLLM-Backend aus: die Kopfdaten werden vollstaendig class LLMClient:
# deterministisch aus dem PDF-Text gelesen (siehe extract_header_metadata_pure_python in
# invoice_logic.py). Das ist schneller, offline-faehig und ohne Server-Abhaengigkeit. def __init__(self, model="gemma431b", base_url="http://10.192.0.140:8002"):
self.model = model
base_url = base_url.strip()
if base_url.startswith("[") and ")" in base_url:
match = re.search(r"\((https?://.*?)\)", base_url)
if match:
base_url = match.group(1)
self.base_url = base_url.rstrip("/")
def chat(self, prompt, system_prompt="You are a helpful assistant.", temperature=0.0, json_schema=None):
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
]
return self._openai_compatible_request(messages, temperature, json_schema)
def _openai_compatible_request(self, messages, temperature, json_schema):
url = f"{self.base_url}/v1/chat/completions"
payload = {
"model": self.model,
"messages": messages,
"temperature": temperature
}
if json_schema:
payload["guided_json"] = json.dumps(json_schema) if isinstance(json_schema, dict) else json_schema
try:
response = requests.post(url, json=payload, timeout=120)
response.raise_for_status()
data = response.json()
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
except Exception as e:
return f"vLLM/OpenAI Error: {e}"
# ==========================================
# 4. API CLIENT INITIALISIERUNG
# ==========================================
def get_cached_llm():
if "llm_instance" not in st.session_state:
llm_url = os.environ.get('VLLM_URL', 'http://10.192.0.140:8002')
llm_model = os.environ.get('LLM_MODEL', 'gemma431b')
st.session_state.llm_instance = LLMClient(model=llm_model, base_url=llm_url)
return st.session_state.llm_instance
@st.cache_data(show_spinner=False) @st.cache_data(show_spinner=False)
@ -65,9 +109,7 @@ def build_zugferd_pdf(invoice_json: str, original_pdf_bytes: bytes) -> bytes:
def _attempt(src_pdf_bytes): def _attempt(src_pdf_bytes):
# factur-x: XSD-Validierung der CII-XML, Einbetten, PDF/A-3-konforme XMP-Struktur; danach # factur-x: XSD-Validierung der CII-XML, Einbetten, PDF/A-3-konforme XMP-Struktur; danach
# nur noch OutputIntent (sRGB-ICC). Die CII deklariert jetzt das EN16931-Profil (B2B), das # OutputIntent (sRGB-ICC) und XRechnung-Konformitaets-Fix in den XMP-Metadaten.
# exakt zum factur-x-Level 'en16931' passt - daher KEIN XMP-Konformitaets-Override mehr
# noetig (frueher wurde XMP auf 'XRECHNUNG'/'3.0' gezwungen, um zum alten Profil zu passen).
pdf_bytes = facturx_generate_from_binary( pdf_bytes = facturx_generate_from_binary(
src_pdf_bytes, src_pdf_bytes,
cii_bytes, cii_bytes,
@ -78,6 +120,7 @@ def build_zugferd_pdf(invoice_json: str, original_pdf_bytes: bytes) -> bytes:
afrelationship="data", afrelationship="data",
) )
pdf_bytes = add_pdfa_output_intent(pdf_bytes) pdf_bytes = add_pdfa_output_intent(pdf_bytes)
pdf_bytes = fix_xrechnung_xmp_conformance(pdf_bytes)
# Erfolgskontrolle: factur-x laesst bei manchen PDF-Strukturen die XML still weg bzw. # Erfolgskontrolle: factur-x laesst bei manchen PDF-Strukturen die XML still weg bzw.
# produziert eine kaputte PDF - dann als Fehlschlag behandeln. # produziert eine kaputte PDF - dann als Fehlschlag behandeln.
if not pdf_has_embedded_facturx(pdf_bytes): if not pdf_has_embedded_facturx(pdf_bytes):
@ -97,52 +140,15 @@ def build_zugferd_pdf(invoice_json: str, original_pdf_bytes: bytes) -> bytes:
# ========================================== # ==========================================
# 5. STREAMLIT FRONTEND # 5. STREAMLIT FRONTEND
# ========================================== # ==========================================
# Eigenes Tab-Icon (Beleg-Motiv, schwarz/orange). Faellt auf das Emoji zurueck, falls die st.set_page_config(page_title="PDF zu E-Rechnung", layout="wide")
# Datei fehlt. Absoluter Pfad, damit es unabhaengig vom Arbeitsverzeichnis gefunden wird.
_FAVICON_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "favicon.png")
_page_icon = Image.open(_FAVICON_PATH) if os.path.exists(_FAVICON_PATH) else "🧾"
st.set_page_config(page_title="PDF zu E-Rechnung", page_icon=_page_icon, layout="wide")
st.title("📄 PDF zu E-Rechnung Konverter") st.title("📄 PDF zu E-Rechnung Konverter")
render_intro_overlay()
llm = get_cached_llm()
col1, col2 = st.columns(2) 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
@ -167,23 +173,67 @@ with col2:
if uploaded_file is not None and pdf_text.strip() != "": if uploaded_file is not None and pdf_text.strip() != "":
if st.button("Daten extrahieren", width="stretch"): if st.button("Daten extrahieren", width="stretch"):
with st.spinner("Extrahiere Daten..."): with st.spinner("Extrahiere Daten über vLLM-API..."):
# KI-frei (test-version3): Kopfdaten vollstaendig deterministisch aus dem schema_dict = InvoiceMetadata.model_json_schema()
# PDF-Text lesen - kein vLLM/LLM-Aufruf mehr. Liefert dasselbe Dict-Schema,
# auf das die nachfolgende Pipeline aufsetzt. sys_prompt = (
final_data = extract_header_metadata_pure_python(pdf_text) "Du bist ein präziser OCR-Parser für deutsche B2B-Rechnungen. "
"Extrahiere die Metadaten fehlerfrei als valides JSON-Objekt. "
"Das JSON MUSS zwingend exakt folgende Schlüssel (Keys) enthalten:\n"
"- invoice_number\n"
"- invoice_date (im Format YYYY-MM-DD)\n"
"- delivery_date (im Format YYYY-MM-DD)\n"
"- buyer_reference\n"
"- customer_number\n"
"- recipient_name\n"
"- recipient_street\n"
"- recipient_zip\n"
"- recipient_city\n"
"- recipient_email\n"
"- vat_rate_percent (als Zahl, z.B. 19.0)\n"
"- delivery_note_id\n"
"- order_id\n"
"- contract_id\n"
"- service_start_date\n"
"- service_end_date\n"
"- payment_term_days\n"
"- invoice_note\n\n"
"WICHTIGE REGEL: Analysiere den Text direkt VOR der ersten Tabellenposition '1'. "
"Sortiere alle dort vorlaufenden Zeilen vollständig in die dafür vorgesehenen ID-, Datums- und Freitext-Felder ein. "
"Schreibe diese vorlaufenden Metadaten NIEMALS in die Beschreibung der ersten Position hinein!\n\n"
"Antworte ausschließlich mit dem reinen JSON-Objekt. Schreibe keinen anderen Text."
)
prompt_meta = f"Rechnungstext:\n{pdf_text}"
res_meta_text = llm.chat(
prompt=prompt_meta,
system_prompt=sys_prompt,
temperature=0.0,
json_schema=schema_dict
)
clean_json_text = res_meta_text.strip()
backtick_trigger = chr(96) * 3
if clean_json_text.startswith(backtick_trigger):
lines = clean_json_text.splitlines()
if lines[0].startswith(backtick_trigger):
lines = lines[1:]
if lines and lines[-1].startswith(backtick_trigger):
lines = lines[:-1]
clean_json_text = "\n".join(lines).strip()
try:
final_data = json.loads(clean_json_text)
except Exception as e:
st.error(f"Fehler beim Parsen der API-Antwort: {e}")
st.code(res_meta_text)
st.stop()
# Sicherheitsnetz: falls doch die eigene scitech-Adresse als Kunden-E-Mail
# erkannt wurde, leeren (BT-49 ist die Adresse des Empfaengers, nicht unsere).
if final_data.get("recipient_email") == VERKAEUFER["email"]: if final_data.get("recipient_email") == VERKAEUFER["email"]:
final_data["recipient_email"] = "" final_data["recipient_email"] = ""
final_data["items"] = extract_invoice_items(pdf_text) final_data["items"] = extract_invoice_items_pure_python(pdf_text)
# Sicherheitsnetz fuer komplexe (Buendel-)Rechnungen: die auf der Rechnung
# ausgewiesene Bemessungsgrundlage merken, um sie spaeter gegen die Summe der
# erkannten Positionen abzugleichen (siehe Summen-Check unten).
final_data["stated_tax_basis"] = extract_stated_tax_basis(pdf_text)
# IT-Systemhaus-Regex-Fallbacks: nur ergaenzen, wenn das LLM die entsprechenden # IT-Systemhaus-Regex-Fallbacks: nur ergaenzen, wenn das LLM die entsprechenden
# Felder nicht bereits geliefert hat. # Felder nicht bereits geliefert hat.
@ -232,9 +282,19 @@ with col2:
_en = str(final_data.get("invoice_note", "")).strip() _en = str(final_data.get("invoice_note", "")).strip()
final_data["invoice_note"] = f"{_en}\n{_refline}".strip() if _en else _refline final_data["invoice_note"] = f"{_en}\n{_refline}".strip() if _en else _refline
# Hinweis: Ein Angebotsbezug ('Angebot <Nr> vom <Datum>') wird - genau wie ein # Angebotsbezug ("Angebot <Nr> vom <Datum>") hat kein eigenes EN16931-Feld - als
# Vergabeangebot - direkt in BT-17 (tender_id) einsortiert (siehe # Info fuer den Kunden in den Freitext (BT-22). Zusaetzlich: falls das LLM die
# extract_document_references oben), nicht mehr in den Freitext. # Angebotsnummer faelschlich in Vertrag (BT-12) oder Vergabeangebot (BT-17) gepackt
# hat, dort leeren - ein Angebot ist weder ein Vertrag noch ein Vergabeangebot.
_angebot = extract_angebot_reference(pdf_text)
if _angebot:
_angebot_nr = _angebot.split()[1]
for _k in ("contract_id", "tender_id"):
if str(final_data.get(_k, "")).strip() == _angebot_nr:
final_data[_k] = ""
if _angebot.lower() not in str(final_data.get("invoice_note", "")).lower():
_existing_note = str(final_data.get("invoice_note", "")).strip()
final_data["invoice_note"] = f"{_existing_note}\n{_angebot}".strip() if _existing_note else _angebot
st.session_state.invoice_counter += 1 st.session_state.invoice_counter += 1
st.session_state.extracted_data = final_data st.session_state.extracted_data = final_data
@ -323,12 +383,7 @@ with col2:
with c_ref3: with c_ref3:
c_id = st.text_input("Vertrag (BT-12)", value=data.get("contract_id", "")) c_id = st.text_input("Vertrag (BT-12)", value=data.get("contract_id", ""))
with c_ref4: with c_ref4:
tender_id = st.text_input( tender_id = st.text_input("Vergabeangebot / Ausschreibung (BT-17)", value=data.get("tender_id", ""))
"Angebot / Vergabeangebot (BT-17)",
value=data.get("tender_id", ""),
help="Nummer eines Angebots, Vergabeangebots oder einer Ausschreibung. Wird aus der "
"Rechnung erkannt ('ANGEBOT'/'VERGABEANGEBOT <Nr> vom <Datum>').",
)
c_p1, c_p2 = st.columns(2) c_p1, c_p2 = st.columns(2)
with c_p1: with c_p1:
@ -350,18 +405,6 @@ 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:
@ -394,9 +437,7 @@ with col2:
"Elektronische Adresse (BT-49)", "Elektronische Adresse (BT-49)",
value=data.get("recipient_email", ""), value=data.get("recipient_email", ""),
help="Adresse, an die der Kunde die Rechnung empfängt. Standard: E-Mail (Schema EM). " help="Adresse, an die der Kunde die Rechnung empfängt. Standard: E-Mail (Schema EM). "
"Leer lassen genügt bei Behörden-Rechnungen: Ist oben eine Leitweg-ID (BT-10) " "Für Behörden ggf. Leitweg-ID (0204) oder GLN (0088) - dann rechts das passende Schema wählen.",
"eingetragen, wird sie automatisch als elektronische Adresse mit Schema 0204 "
"übernommen (keine Doppeleingabe nötig). Für eine GLN das Schema 0088 wählen.",
) )
_scheme_codes = list(BUYER_ENDPOINT_SCHEMES.keys()) _scheme_codes = list(BUYER_ENDPOINT_SCHEMES.keys())
_scheme_labels = [f"{c} - {BUYER_ENDPOINT_SCHEMES[c]}" for c in _scheme_codes] _scheme_labels = [f"{c} - {BUYER_ENDPOINT_SCHEMES[c]}" for c in _scheme_codes]
@ -408,41 +449,10 @@ with col2:
"0088 = GLN. Muss zum Typ der oben eingetragenen Adresse passen.", "0088 = GLN. Muss zum Typ der oben eingetragenen Adresse passen.",
) )
r_endpoint_scheme = r_endpoint_scheme_label.split(" - ")[0] r_endpoint_scheme = r_endpoint_scheme_label.split(" - ")[0]
# Sichtbar machen, was tatsächlich in BEIDE Felder (BT-49 + BT-49-1) exportiert wird -
# vor allem, wenn die Adresse aus der Leitweg-ID (BT-10) abgeleitet wird und das
# Feld BT-49 daher leer bleibt (sonst könnte man es für "noch offen" halten).
_bt49_preview, _bt49_scheme_preview = resolve_buyer_endpoint(r_email, r_endpoint_scheme, leitweg_id)
if _bt49_preview and not str(r_email).strip():
st.success(f"➡️ Wird automatisch aus der Leitweg-ID (BT-10) übernommen – die beiden "
f"Felder oben können leer bleiben:\n\n"
f"- **Elektronische Adresse (BT-49):** {_bt49_preview}\n"
f"- **Adress-Schema (BT-49-1):** {_bt49_scheme_preview}")
elif _bt49_preview:
st.info(f"➡️ Export → **Elektronische Adresse (BT-49):** {_bt49_preview} · "
f"**Adress-Schema (BT-49-1):** {_bt49_scheme_preview}")
r_street = st.text_input("Straße (BT-50)", value=data.get("recipient_street", "")) r_street = st.text_input("Straße (BT-50)", value=data.get("recipient_street", ""))
r_zip = st.text_input("PLZ (BT-53)", value=data.get("recipient_zip", "")) r_zip = st.text_input("PLZ (BT-53)", value=data.get("recipient_zip", ""))
r_city = st.text_input("Stadt (BT-52) :red[*]", value=data.get("recipient_city", "")) r_city = st.text_input("Stadt (BT-52) :red[*]", value=data.get("recipient_city", ""))
# BG-15 Lieferadresse: standardmäßig eingeklappt, damit die Felder im Normalfall
# (Lieferort = Rechnungsadresse) nicht stören. Wurde eine ABWEICHENDE Lieferadresse
# erkannt, sind die Felder vorbefüllt und der Bereich klappt automatisch auf.
_has_deliv = bool(str(data.get("delivery_name", "")).strip())
with st.expander("Abweichende Lieferadresse (BG-15)", expanded=_has_deliv):
if _has_deliv:
st.caption("ℹ️ Abweichende Lieferadresse erkannt und übernommen. "
"Bei Bedarf korrigieren – oder die Felder leeren, wenn keine "
"abweichende Lieferadresse gewünscht ist.")
else:
st.caption("Nur ausfüllen, wenn an einen **anderen Ort** geliefert wurde als "
"die Rechnungsadresse. Leer lassen = keine gesonderte Lieferadresse.")
d_name = st.text_input("Firma / Empfänger (BT-70)", value=data.get("delivery_name", ""))
d_street = st.text_input("Straße (BT-75)", value=data.get("delivery_street", ""))
d_zip = st.text_input("PLZ (BT-78)", value=data.get("delivery_zip", ""))
d_city = st.text_input("Stadt (BT-77)", value=data.get("delivery_city", ""))
with tab_positionen: with tab_positionen:
if st.button("➕ Neue Position hinzufügen"): if st.button("➕ Neue Position hinzufügen"):
data["items"].append({ data["items"].append({
@ -538,23 +548,6 @@ with col2:
with c_vat_ui: st.metric("MwSt-Gesamt (€)", f"{computed_vat_amount:.2f}") with c_vat_ui: st.metric("MwSt-Gesamt (€)", f"{computed_vat_amount:.2f}")
with c_gross_ui: st.metric("Brutto-Zahlbetrag (€)", f"{computed_gross_amount:.2f}") with c_gross_ui: st.metric("Brutto-Zahlbetrag (€)", f"{computed_gross_amount:.2f}")
# --- SUMMEN-ABGLEICH (Sicherheitsnetz) ---
# Die auf der Rechnung ausgewiesene Bemessungsgrundlage (z.B. '19 % USt. auf € 217.500,55')
# gegen die Summe der erkannten Positionen abgleichen. Weichen sie ab, ist die
# Positionserkennung vermutlich unvollständig - dann deutlich warnen, damit der
# Sachbearbeiter die Positionen prüft/korrigiert (v.a. bei komplexen Bündel-Rechnungen).
_stated_basis = data.get("stated_tax_basis")
if _stated_basis is not None and abs(computed_net_amount - float(_stated_basis)) > 0.01:
st.warning(
f"⚠️ **Summen-Abgleich fehlgeschlagen:** Die Summe der erkannten Positionen "
f"(**{computed_net_amount:.2f} €**) weicht von der auf der Rechnung ausgewiesenen "
f"Bemessungsgrundlage (**{float(_stated_basis):.2f} €**) ab. Bitte die Positionen im "
f"Reiter *Positionen* prüfen und korrigieren, bevor Sie exportieren."
)
elif _stated_basis is not None:
st.caption(f"✅ Summen-Abgleich ok: Positionen = ausgewiesene Bemessungsgrundlage "
f"({float(_stated_basis):.2f} €).")
# --- EXPORT-SEKTION DROPDOWN-AUSWAHL --- # --- EXPORT-SEKTION DROPDOWN-AUSWAHL ---
st.markdown("---") st.markdown("---")
export_format = st.selectbox( export_format = st.selectbox(
@ -564,21 +557,14 @@ with col2:
help="XRechnung generiert ein reines, valides UBL-XML. ZUGFeRD bettet ein schema-konformes CII-XML (factur-x.xml) direkt in Ihre PDF ein." help="XRechnung generiert ein reines, valides UBL-XML. ZUGFeRD bettet ein schema-konformes CII-XML (factur-x.xml) direkt in Ihre PDF ein."
) )
# BT-49 (elektronische Adresse) + BT-49-1 (Schema) aufloesen: ist das E-Mail-Feld leer,
# aber eine Leitweg-ID (BT-10) vorhanden, wird die Leitweg-ID mit Schema '0204' als BT-49
# verwendet - so muss die Leitweg-ID nicht doppelt (in BT-10 und BT-49) eingegeben werden.
r_email_effective, r_scheme_effective = resolve_buyer_endpoint(r_email, r_endpoint_scheme, leitweg_id)
final_invoice_dict = { final_invoice_dict = {
"invoice_type_code": selected_type_code, "invoice_type_code": selected_type_code,
"invoice_number": inv_num, "invoice_date": inv_date, "delivery_date": inv_delivery, "invoice_number": inv_num, "invoice_date": inv_date, "delivery_date": inv_delivery,
"buyer_reference": buyer_ref, "customer_number": cust_num, "recipient_name": r_name, "recipient_email": r_email_effective, "buyer_reference": buyer_ref, "customer_number": cust_num, "recipient_name": r_name, "recipient_email": r_email,
"recipient_street": r_street, "recipient_zip": r_zip, "recipient_city": r_city, "recipient_street": r_street, "recipient_zip": r_zip, "recipient_city": r_city,
"delivery_name": d_name, "delivery_street": d_street, "delivery_zip": d_zip, "delivery_city": d_city,
"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,
@ -589,7 +575,7 @@ with col2:
"skonto_percent": skonto_percent, "skonto_percent": skonto_percent,
"skonto_days": skonto_days, "skonto_days": skonto_days,
"vorkasse": vorkasse, "vorkasse": vorkasse,
"recipient_endpoint_scheme": r_scheme_effective, "recipient_endpoint_scheme": r_endpoint_scheme,
"leitweg_id": leitweg_id, "leitweg_id": leitweg_id,
"delivery_note_id": dn_id, "delivery_note_id": dn_id,
"order_id": o_id, "order_id": o_id,
@ -619,11 +605,12 @@ with col2:
blocker.append("Leitweg-ID (BT-10) fehlt. Bei XRechnung (in der Regel an Behörden) ist sie " blocker.append("Leitweg-ID (BT-10) fehlt. Bei XRechnung (in der Regel an Behörden) ist sie "
"Pflicht (BR-DE-15) und dient als Käuferreferenz - im Reiter 'Kopfdaten' ins " "Pflicht (BR-DE-15) und dient als Käuferreferenz - im Reiter 'Kopfdaten' ins "
"Feld 'Leitweg-ID (BT-10)' eintragen.") "Feld 'Leitweg-ID (BT-10)' eintragen.")
if is_xrechnung and not str(r_email_effective).strip(): if is_xrechnung and not str(r_email).strip():
blocker.append("Elektronische Adresse des Kunden (BT-49) fehlt. Bei XRechnung ist sie " blocker.append("Elektronische Adresse des Kunden (BT-49) fehlt. Bei XRechnung ist sie "
"Pflicht (PEPPOL-EN16931-R010). Ist oben eine Leitweg-ID (BT-10) eingetragen, " "Pflicht (PEPPOL-EN16931-R010). Da XRechnung meist an Behörden geht, gehört "
"wird sie automatisch als BT-49 (Schema 0204) übernommen. Alternativ im Reiter " "hier in der Regel die Leitweg-ID rein - im Reiter 'Adressen' ins Feld "
"'Adressen' die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088) eintragen.") "'Elektronische Adresse (BT-49)' eintragen und als Schema '0204 (Leitweg-ID)' "
"wählen. Alternativ die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088).")
if inv_delivery and parse_to_strict_xml_date(inv_delivery) == "": if inv_delivery and parse_to_strict_xml_date(inv_delivery) == "":
blocker.append("Lieferdatum (BT-72) ist unlesbar (erwartet: YYYY-MM-DD). Bitte korrigieren oder leeren.") blocker.append("Lieferdatum (BT-72) ist unlesbar (erwartet: YYYY-MM-DD). Bitte korrigieren oder leeren.")
if not edited_items: if not edited_items:
@ -639,38 +626,24 @@ 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))
# Zu viele Leistungsnachweise sperren den Export ebenfalls - die Fehlermeldung dazu steht export_disabled = len(blocker) > 0
# 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
# orange Warnung (st.warning), dass eine echte E-Mail besser waere. # orange Warnung (st.warning), dass eine echte E-Mail besser waere.
if not is_xrechnung and not str(r_email_effective).strip(): if not is_xrechnung and not str(r_email).strip():
final_invoice_dict["recipient_email"] = "X" final_invoice_dict["recipient_email"] = "X"
st.warning("⚠️ Es ist keine elektronische Adresse des Kunden (BT-49) angegeben - es wird " st.warning("⚠️ Es ist keine elektronische Adresse des Kunden (BT-49) angegeben - es wird "
"nur ein Platzhalter \"X\" eingesetzt, damit das Pflichtfeld nicht komplett " "nur ein Platzhalter \"X\" eingesetzt, damit das Pflichtfeld nicht komplett "
"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, max. 15 Zeichen>_<Bearbeiternummer> # Dateiname: <Rechnungsnummer>_<Empfaenger>_<Rechnungsdatum> (dateisicher aufbereitet).
# (dateisicher aufbereitet). export_basename = build_export_basename(inv_num, r_name, inv_date)
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",
@ -684,56 +657,21 @@ 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:
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 attachment_error: if zugferd_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",

Binary file not shown.