Compare commits

...

16 Commits

Author SHA1 Message Date
dfuhrmann3 d217893dcb fix(v3): Replay-Button links neben Streamlit-Menue statt darueber
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:21:08 +02:00
dfuhrmann3 c2b2589dd0 feat(v3): Intro auf MP4 umgestellt (video statt HTML-Bundle)
- static/intro.html entfernt, static/intro.mp4 als Quelle
- Overlay nutzt <video> autoplay/muted, Ausblenden exakt per 'ended'-Event
- Replay-Button z-index-Fix (lag hinter Streamlit-Header)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:18:43 +02:00
dfuhrmann3 7e0866ba4b chore(v3): .claude/ (nur Vorschau-Tool) auf ignore, .streamlit bleibt (Static Serving)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:04:18 +02:00
dfuhrmann3 f28f090b06 feat(v3): Intro-Overlay beim ersten Besuch (zentriert, Replay oben rechts)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:02:02 +02:00
dfuhrmann3 35fcc5a576 feat(v3): Intro-Overlay-Baustein (Overlay-Injektion, Skip/Auto-Fade/Replay)
- Player-Leiste des Bundler-Exports ausgeblendet, Panel 16:9 (1280x720)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:02:01 +02:00
dfuhrmann3 d89cc0290d feat(v3): Intro-Animation als statisches Asset + Static Serving aktiviert
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:02:01 +02:00
dfuhrmann3 1bcc3d0f57 feat(v3): eigenes Tab-Icon (Beleg-Motiv, schwarz/orange)
Custom-Favicon assets/favicon.png (256px, schwarzer abgerundeter Hintergrund,
oranger Beleg mit Sägezahn-Unterkante) statt Emoji. page_icon lädt die Datei
über absoluten Pfad, mit Fallback auf 🧾, falls sie fehlt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:49:17 +02:00
dfuhrmann3 565fe097dd chore(v3): Tab-Icon (Favicon) auf 🧾 setzen (page_icon)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:44:50 +02:00
dfuhrmann3 bacf1798f9 feat(v3): BG-15 Lieferadresse (nur bei abweichendem Lieferort)
Die Lieferadresse (BG-15) wird jetzt erfasst und exportiert - aber nur, wenn sie
von der Rechnungsadresse ABWEICHT (sonst wäre sie reine Dopplung; die
Rechnungsadresse ist meist auch der Lieferort).

- extract_delivery_address: parst den 'Lieferadresse:'-Block (Name/Straße/PLZ/Ort),
  E-Mail-Zeilen ignoriert. Block-Zerlegung mit extract_recipient_address geteilt.
- delivery_address_differs: umbruch-/schreibweisenrobuster Vergleich
  (Str.=Straße, ß=ss) - so gilt z.B. ein mehrzeilig umbrochener Straßenname nicht
  fälschlich als Abweichung.
- Header-Extraktor: delivery_* nur vorbelegt, wenn abweichend; sonst leer.
- Generatoren: UBL cac:Delivery/DeliveryLocation/Address + DeliveryParty (BT-70/
  75/77/78/80), CII ShipToTradeParty (vor dem Lieferdatum). Nur wenn Name+Ort
  gesetzt sind.
- UI: eingeklappter Bereich 'Abweichende Lieferadresse (BG-15)' im Reiter Adressen -
  klappt automatisch auf und ist vorbefüllt, wenn eine Abweichung erkannt wurde;
  sonst unsichtbar. Editierbar/leerbar.

Verifiziert: Stadtwerke (Neuendorfstr. abweichend) und Gemeinde (EDV) -> BG-15;
BIM/degewo/ZenIT (gleiche Adresse) -> keine BG-15. ZUGFeRD-PDF mit check_xsd=True
(ShipToTradeParty-Reihenfolge korrekt). Alle 7 Beispielrechnungen unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:54:56 +02:00
dfuhrmann3 9ee56440f5 feat(v3): BT-113 Vorauszahlung, BT-86 BIC + ZUGFeRD-Profil auf EN16931
Aus dem Vergleich mit eingegangenen Lieferanten-Rechnungen (ZUGFeRD/Factur-X)
übernommen:

1) BT-113 TotalPrepaidAmount/PrepaidAmount = 0,00 immer ausgeben (UBL + CII) -
   macht die Summensektion vollständig (4/4 Lieferanten hatten es).
2) BT-86 BIC der Verkäuferbank (DEUTDEDB160) ins XML: UBL
   FinancialInstitutionBranch/ID, CII PayeeSpecifiedCreditorFinancialInstitution/
   BICID. BIC in VERKAEUFER-Stammdaten ergänzt.
3) ZUGFeRD-Profil-Konsistenz: die CII deklariert jetzt das EN16931-Profil
   (urn:cen.eu:en16931:2017, B2B) statt des strengen KoSIT-XRechnung-Profils -
   das passt zum factur-x-Level 'en16931'. Damit entfällt der bisherige XMP-
   Override (fix_xrechnung_xmp_conformance): factur-x schreibt nativ
   'EN 16931'/'1.0', konsistent zum eingebetteten XML. Die reine XRechnung (UBL,
   B2G) bleibt beim strengen xrechnung_3.0-Profil.

Verifiziert: ZUGFeRD-PDF mit check_xsd=True erzeugt (Element-Reihenfolge korrekt),
XMP 'EN 16931' == eingebettetes Profil. Alle 7 Beispielrechnungen unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:34:39 +02:00
dfuhrmann3 555272bcf6 fix(v3): Seiten-Möblierung aus BT-22-Freitext strippen (mehrseitige Rechnungen)
Bei mehrseitigen Rechnungen steht die erste 'Pos.'-Zeile auf Seite 1, die erste
echte Position aber erst nach einem Seitenumbruch. Dadurch rutschte die dazwischen
liegende Seiten-Möblierung (Seitenkopf RECHNUNG..Betrag €, Bank-/Registerzeilen)
in den globalen Freitext BT-22. extract_pre_position_freetext strippt jetzt diese
Möblierung (via _strip_bundle_furniture), bevor die Referenzzeilen entfernt werden.

Verifiziert: R26-252 hat nun leeren BT-22; die 7 bekannten Rechnungen behalten
ihren Freitext unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:06:36 +02:00
dfuhrmann3 cd187585dd feat(v3): Bundle-Parser für konfigurierte Positionen (z.B. Dell) + Summen-Check
Rechnungen mit konfigurierten Bündel-Positionen (Signal: 'Menge Komponenten')
haben einen völlig anderen Aufbau: Produktname + Dutzende Komponentenzeilen über
viele Seiten, Preis als 3-Zeilen-Block mittendrin, Service-Tags erst danach. Der
bisherige Parser scheitert daran (falsche Namen, Fehltreffer).

Neu in invoice_logic.py:
- is_bundle_invoice / extract_bundle_items: eigener Parser, verankert am
  3-Zeilen-Preisblock (Menge+Einheit / Einzelpreis / Zeilensumme), segmentiert an
  den Positions-Headern; entfernt Seitenmöblierung (Kopf RECHNUNG..Betrag €,
  Bank-/Registerzeilen, Übertrag/Vortrag). Beschreibung (BT-154) = volle
  Komponentenliste + Service-Tags.
- extract_invoice_items: Dispatcher (Bundle vs. Standardparser) - die bekannten
  Service-Rechnungen bleiben unverändert.
- extract_stated_tax_basis: liest die ausgewiesene Bemessungsgrundlage aus der
  Steuerzeile.

main.py: nutzt den Dispatcher; Summen-Abgleich als Sicherheitsnetz - weicht die
Summe der erkannten Positionen von der ausgewiesenen Bemessungsgrundlage ab,
erscheint eine deutliche Warnung.

Verifiziert an R26-252 (9 Seiten): 4 Positionen korrekt, Summe 217.500,55 =
Bemessungsgrundlage, Brutto 258.825,65 = Endbetrag, valides XRechnung/ZUGFeRD mit
Service-Tags in BT-154. Alle 7 bekannten Rechnungen unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:58:59 +02:00
dfuhrmann3 23968bb911 fix(v3): '@' -> EM auch im Leitweg-Feld; BT-49-Info nennt beide Felder
- resolve_buyer_endpoint: das Schema wird jetzt am finalen Wert entschieden,
  egal aus welchem Feld er stammt. Ein '@' (auch versehentlich ins
  Leitweg-ID-Feld getippt) fuehrt immer zu Schema EM statt 0204.
- Der sichtbare Hinweis unter dem Schema-Feld benennt jetzt ausdruecklich
  beide Felder (Elektronische Adresse BT-49 UND Adress-Schema BT-49-1) mit
  ihren effektiven Werten, damit das leere BT-49-Feld nicht als "offen"
  missverstanden wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:24:56 +02:00
dfuhrmann3 2e259589c0 ui(v3): BT-49-Ableitung sichtbar machen + Schema robuster (E-Mail=EM)
- resolve_buyer_endpoint: eine Adresse mit '@' wird immer als Schema EM
  gewertet (unabhaengig vom Dropdown), damit ein falsch gewaehltes Schema
  nicht durchschlaegt. Ohne '@' entscheidet weiter das gewaehlte Schema
  (z.B. GLN 0088); leeres Feld + Leitweg-ID -> 0204 wie bisher.
- main.py: unter dem BT-49-Feld wird jetzt sichtbar angezeigt, was
  tatsaechlich exportiert wird - bei Ableitung aus der Leitweg-ID ein
  gruener Hinweis, sonst eine kurze Bestaetigung mit Wert + Schema. So
  wirkt das (bewusst leere) Feld fuer den Sachbearbeiter nicht mehr offen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:18:52 +02:00
dfuhrmann3 991384a362 feat(v3): Angebot -> BT-17 und BT-49 aus Leitweg-ID ableiten
1) BT-17 (tender_id): extract_document_references sortiert jetzt nicht nur
   ein Vergabeangebot, sondern auch ein einfaches 'Angebot <Nr> vom <Datum>'
   in BT-17 ein (Vergabeangebot hat Vorrang; pro Rechnung kommt nur eines
   von beiden vor). Der bisherige Angebot->Freitext-Block in main.py entfaellt.

2) BT-49 (elektronische Adresse) aus BT-10 ableiten: neue Funktion
   resolve_buyer_endpoint - 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 nur einmal eingegeben werden. Das Schema bleibt an
   den Wert gekoppelt (E-Mail -> EM, Leitweg-ID -> 0204), Default bleibt EM.
   Blocker + ZUGFeRD-Platzhalter nutzen die abgeleitete Adresse; UI-Hilfen und
   das BT-17-Label ('Angebot / Vergabeangebot') entsprechend angepasst.

Verifiziert gegen alle 7 Beispielrechnungen: die 3 Angebot-Rechnungen landen
in BT-17 (im XML), die 2 Vergabeangebote bleiben, Freitext ohne Dopplung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:13:29 +02:00
dfuhrmann3 51ed31b1e8 feat(v3): KI-freie Kopfdaten-Extraktion (ohne vLLM/LLM-Backend)
Ersetzt den LLM-Aufruf (gemma431b via vLLM) durch eine vollstaendig
deterministische Kopfdaten-Extraktion (extract_header_metadata_pure_python
in invoice_logic.py). Empfaenger-Adressblock, Rechnungsnummer, Daten,
Kunden-Nr., Zahlungsziel, Leistungszeitraum und Kunden-E-Mail werden aus
den regelmaessigen Kopf-Labeln und dem Adressaufbau der scitech-Rechnungen
gelesen. Die nachgelagerte Pipeline (Positionen, Beleg-Referenzen, Freitext,
Angebots-/Referenzbezug) bleibt unveraendert.

- Kein Server/Netzwerk mehr noetig, offline-faehig, quasi instant statt
  mehrere Sekunden pro Rechnung.
- LLMClient/get_cached_llm und die Imports os/requests/re/InvoiceMetadata
  entfernt.
- Verifiziert gegen alle 7 Beispielrechnungen: Kopfdaten korrekt, XRechnung-
  (UBL) + ZUGFeRD-(CII) XML wohlgeformt, Bruttobetraege == Endbetrag der PDFs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:47:38 +02:00
7 changed files with 662 additions and 183 deletions

1
.gitignore vendored
View File

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

2
.streamlit/config.toml Normal file
View File

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

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

136
intro_overlay.py Normal file
View File

@ -0,0 +1,136 @@
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,6 +58,7 @@ 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.
@ -80,6 +81,31 @@ 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).
@ -177,8 +203,11 @@ 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. Gibt den Freitext zurueck ("" wenn keiner vorhanden) oder None, wenn die Felder haben, und der zwischen Seiten wiederholten Moeblierung (Seitenkopf, Bank-/Register-
Tabellenstruktur nicht erkennbar ist (dann bleibt ein evtl. LLM-Freitext unveraendert).""" zeilen, Uebertrag/Vortrag) - relevant bei mehrseitigen Rechnungen, deren erste 'Pos.'-Zeile auf
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)
@ -192,32 +221,46 @@ def extract_pre_position_freetext(text):
if first_pos_idx is None: if first_pos_idx is None:
return None return None
freetext_lines = [ # Leere Zeilen raus, dann die Seiten-Moeblierung strippen (Seitenkopf 'RECHNUNG'..'Betrag €',
ln for ln in lines[header_idx + 1:first_pos_idx] # Bank-/Registerzeilen, Uebertrag/Vortrag), zuletzt die Referenzzeilen mit eigenem Feld.
if ln and not _REFERENCE_LINE_PATTERN.match(ln) region = [ln for ln in lines[header_idx + 1:first_pos_idx] if 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 die # Beleg-Referenzen aus den Referenzzeilen: Schluesselwort -> Feld. Zuverlaessiger als eine
# 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), Vergabeangebot (BT-17). Gibt ein Dict nur Auftragsbestaetigung (BT-14), Lieferschein (BT-16) und - fuer BT-17 - Vergabeangebot ODER,
mit den gefundenen Feldern zurueck - so lassen sich die (teils falschen) LLM-Zuordnungen gezielt falls keines vorhanden ist, ein einfaches Angebot ('Angebot <Nr> vom <Datum>'). Pro Rechnung
korrigieren, ohne fehlende Felder zu ueberschreiben.""" kommt nur eines von beiden vor; beide gehoeren fachlich in BT-17. Gibt ein Dict nur mit den
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
@ -245,6 +288,167 @@ 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."""
@ -355,6 +559,158 @@ 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"
@ -647,9 +1003,26 @@ 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"])
if data.get("delivery_date"): # 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") delivery = ET.SubElement(root, f"{{{CAC}}}Delivery")
ET.SubElement(delivery, f"{{{CBC}}}ActualDeliveryDate").text = parse_to_strict_xml_date(data["delivery_date"]) if data.get("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
@ -657,6 +1030,11 @@ 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
@ -717,6 +1095,9 @@ 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):
@ -783,7 +1164,10 @@ 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")
ET.SubElement(guideline, f"{{{RAM}}}ID").text = "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0" # BT-24 Profil: ZUGFeRD/Factur-X wird B2B verschickt - daher das EN16931-Profil (COMFORT)
# 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"])
@ -919,6 +1303,18 @@ 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")
@ -941,6 +1337,11 @@ 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):
@ -1004,6 +1405,9 @@ 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")
@ -1075,31 +1479,3 @@ 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,13 +1,11 @@
import os
import io import io
import os
import streamlit as st import streamlit as st
import json import json
import re
import fitz import fitz
import requests from PIL import Image
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,
@ -16,7 +14,8 @@ from invoice_logic import (
berechne_faelligkeit, berechne_faelligkeit,
kaufmaennisches_runden, kaufmaennisches_runden,
clean_german_float, clean_german_float,
extract_invoice_items_pure_python, extract_invoice_items,
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,
@ -25,17 +24,18 @@ 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,58 +44,11 @@ if "invoice_counter" not in st.session_state:
st.session_state.invoice_counter = 0 st.session_state.invoice_counter = 0
# ========================================== # ==========================================
# 1. API-CLIENT FÜR DAS vLLM-BACKEND # 1. KOPFDATEN-EXTRAKTION (KI-frei, test-version3)
# ========================================== # ==========================================
class LLMClient: # Diese Variante kommt ohne LLM/vLLM-Backend aus: die Kopfdaten werden vollstaendig
# deterministisch aus dem PDF-Text gelesen (siehe extract_header_metadata_pure_python in
def __init__(self, model="gemma431b", base_url="http://10.192.0.140:8002"): # invoice_logic.py). Das ist schneller, offline-faehig und ohne Server-Abhaengigkeit.
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)
@ -109,7 +62,9 @@ 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
# OutputIntent (sRGB-ICC) und XRechnung-Konformitaets-Fix in den XMP-Metadaten. # nur noch OutputIntent (sRGB-ICC). Die CII deklariert jetzt das EN16931-Profil (B2B), das
# 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,
@ -120,7 +75,6 @@ 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):
@ -140,10 +94,14 @@ def build_zugferd_pdf(invoice_json: str, original_pdf_bytes: bytes) -> bytes:
# ========================================== # ==========================================
# 5. STREAMLIT FRONTEND # 5. STREAMLIT FRONTEND
# ========================================== # ==========================================
st.set_page_config(page_title="PDF zu E-Rechnung", layout="wide") # Eigenes Tab-Icon (Beleg-Motiv, schwarz/orange). Faellt auf das Emoji zurueck, falls die
# 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:
@ -173,67 +131,23 @@ 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 über vLLM-API..."): with st.spinner("Extrahiere Daten..."):
schema_dict = InvoiceMetadata.model_json_schema() # KI-frei (test-version3): Kopfdaten vollstaendig deterministisch aus dem
# PDF-Text lesen - kein vLLM/LLM-Aufruf mehr. Liefert dasselbe Dict-Schema,
sys_prompt = ( # auf das die nachfolgende Pipeline aufsetzt.
"Du bist ein präziser OCR-Parser für deutsche B2B-Rechnungen. " final_data = extract_header_metadata_pure_python(pdf_text)
"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_pure_python(pdf_text) final_data["items"] = extract_invoice_items(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.
@ -282,19 +196,9 @@ 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
# Angebotsbezug ("Angebot <Nr> vom <Datum>") hat kein eigenes EN16931-Feld - als # Hinweis: Ein Angebotsbezug ('Angebot <Nr> vom <Datum>') wird - genau wie ein
# Info fuer den Kunden in den Freitext (BT-22). Zusaetzlich: falls das LLM die # Vergabeangebot - direkt in BT-17 (tender_id) einsortiert (siehe
# Angebotsnummer faelschlich in Vertrag (BT-12) oder Vergabeangebot (BT-17) gepackt # extract_document_references oben), nicht mehr in den Freitext.
# 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
@ -383,7 +287,12 @@ 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("Vergabeangebot / Ausschreibung (BT-17)", value=data.get("tender_id", "")) tender_id = st.text_input(
"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:
@ -437,7 +346,9 @@ 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). "
"Für Behörden ggf. Leitweg-ID (0204) oder GLN (0088) - dann rechts das passende Schema wählen.", "Leer lassen genügt bei Behörden-Rechnungen: Ist oben eine Leitweg-ID (BT-10) "
"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]
@ -449,10 +360,41 @@ 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({
@ -548,6 +490,23 @@ 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(
@ -557,11 +516,17 @@ 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, "buyer_reference": buyer_ref, "customer_number": cust_num, "recipient_name": r_name, "recipient_email": r_email_effective,
"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,
@ -575,7 +540,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_endpoint_scheme, "recipient_endpoint_scheme": r_scheme_effective,
"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,
@ -605,12 +570,11 @@ 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).strip(): if is_xrechnung and not str(r_email_effective).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). Da XRechnung meist an Behörden geht, gehört " "Pflicht (PEPPOL-EN16931-R010). Ist oben eine Leitweg-ID (BT-10) eingetragen, "
"hier in der Regel die Leitweg-ID rein - im Reiter 'Adressen' ins Feld " "wird sie automatisch als BT-49 (Schema 0204) übernommen. Alternativ im Reiter "
"'Elektronische Adresse (BT-49)' eintragen und als Schema '0204 (Leitweg-ID)' " "'Adressen' die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088) eintragen.")
"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:
@ -631,7 +595,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).strip(): if not is_xrechnung and not str(r_email_effective).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 Normal file

Binary file not shown.