Compare commits

..

No commits in common. "d217893dcb87fd19f212c693b03d22912f73b60f" and "4675cfd2a0c49c5ff240a34ebc55f5e4bb220bc4" have entirely different histories.

7 changed files with 183 additions and 662 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

@ -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).
@ -203,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)
@ -221,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
@ -288,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 (z.B. bei degewo) 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."""
@ -559,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"
@ -1003,26 +647,9 @@ 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
@ -1030,11 +657,6 @@ def generate_compliant_xrechnung(data):
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"))
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
@ -1095,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):
@ -1164,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"])
@ -1303,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")
@ -1337,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):
@ -1405,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")
@ -1479,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()

246
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,
@ -24,18 +25,17 @@ from invoice_logic import (
normalize_pdf_bytes, normalize_pdf_bytes,
pdf_has_embedded_facturx, pdf_has_embedded_facturx,
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)
@ -44,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)
@ -62,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,
@ -75,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):
@ -94,14 +140,10 @@ 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:
@ -131,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.
@ -196,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
@ -287,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:
@ -346,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]
@ -360,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({
@ -490,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(
@ -516,17 +557,11 @@ 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,
@ -540,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,
@ -570,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:
@ -595,7 +631,7 @@ with col2:
# 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 "

Binary file not shown.