Compare commits
No commits in common. "d217893dcb87fd19f212c693b03d22912f73b60f" and "4675cfd2a0c49c5ff240a34ebc55f5e4bb220bc4" have entirely different histories.
d217893dcb
...
4675cfd2a0
|
|
@ -9,6 +9,5 @@ docs/
|
||||||
tests/
|
tests/
|
||||||
pytest.ini
|
pytest.ini
|
||||||
requirements-dev.txt
|
requirements-dev.txt
|
||||||
.claude/
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[server]
|
|
||||||
enableStaticServing = true
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.1 KiB |
136
intro_overlay.py
136
intro_overlay.py
|
|
@ -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)
|
|
||||||
460
invoice_logic.py
460
invoice_logic.py
|
|
@ -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
|
if data.get("delivery_date"):
|
||||||
# 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")
|
delivery = ET.SubElement(root, f"{{{CAC}}}Delivery")
|
||||||
if data.get("delivery_date"):
|
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
246
main.py
|
|
@ -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 "
|
||||||
|
|
|
||||||
BIN
static/intro.mp4
BIN
static/intro.mp4
Binary file not shown.
Loading…
Reference in New Issue