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/
pytest.ini
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
"vat_id": "DE218436692", # BT-31 (Umsatzsteuer-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.
@ -80,6 +81,31 @@ BUYER_ENDPOINT_SCHEMES = {
"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
# Zahlungs*mittel* - das Zahlungsmittel bleibt die Ueberweisung. Der Text wird daher zu den
# 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
zwischen der ersten 'Pos.'-Zeile und der ersten Positionsnummer wird uebernommen, ausser den
Referenzzeilen (Auftragsbestaetigung/Angebot/Vergabeangebot/Lieferschein/Vertrag), die eigene
Felder haben. Gibt den Freitext zurueck ("" wenn keiner vorhanden) oder None, wenn die
Tabellenstruktur nicht erkennbar ist (dann bleibt ein evtl. LLM-Freitext unveraendert)."""
Felder haben, und der zwischen Seiten wiederholten Moeblierung (Seitenkopf, Bank-/Register-
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")]
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:
return None
freetext_lines = [
ln for ln in lines[header_idx + 1:first_pos_idx]
if ln and not _REFERENCE_LINE_PATTERN.match(ln)
]
# Leere Zeilen raus, dann die Seiten-Moeblierung strippen (Seitenkopf 'RECHNUNG'..'Betrag €',
# Bank-/Registerzeilen, Uebertrag/Vortrag), zuletzt die Referenzzeilen mit eigenem Feld.
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)
# 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.
_DOC_REF_PATTERNS = {
"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),
"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):
"""Liest Beleg-Referenzen deterministisch aus den Referenzzeilen des PDF-Texts:
Auftragsbestaetigung (BT-14), Lieferschein (BT-16), Vergabeangebot (BT-17). Gibt ein Dict nur
mit den gefundenen Feldern zurueck - so lassen sich die (teils falschen) LLM-Zuordnungen gezielt
korrigieren, ohne fehlende Felder zu ueberschreiben."""
Auftragsbestaetigung (BT-14), Lieferschein (BT-16) und - fuer BT-17 - Vergabeangebot ODER,
falls keines vorhanden ist, ein einfaches Angebot ('Angebot <Nr> vom <Datum>'). Pro Rechnung
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 = {}
for field, pattern in _DOC_REF_PATTERNS.items():
m = pattern.search(text)
if m:
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
@ -245,6 +288,167 @@ def extract_referenznummer(text):
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):
"""Macht einen String dateisystem-sicher: entfernt in Dateinamen kritische Zeichen
(Pfadtrenner/Sonderzeichen) und fasst Whitespace zu einem Bindestrich zusammen."""
@ -355,6 +559,158 @@ def extract_invoice_items_pure_python(text):
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):
u = str(unit_str).lower()
if "monat" in u: return "MON"
@ -647,9 +1003,26 @@ def generate_compliant_xrechnung(data):
legal_cust = ET.SubElement(party_cust, f"{{{CAC}}}PartyLegalEntity")
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")
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")
# 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"))
payee_acc = ET.SubElement(payment_means, f"{{{CAC}}}PayeeFinancialAccount")
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
# 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}"
if data.get("allowance_amount", 0.0) > 0:
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}"
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"
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")
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"
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"):
act_deliv = ET.SubElement(header_deliv, f"{{{RAM}}}ActualDeliverySupplyChainEvent")
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"))
payee_acc = ET.SubElement(pay_means, f"{{{RAM}}}PayeePartyCreditorFinancialAccount")
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).
# 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}}}TaxTotalAmount", {"currencyID": "EUR"}).text = f"{data['total_vat_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}"
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()
finally:
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 os
import streamlit as st
import json
import re
import fitz
import requests
from PIL import Image
from facturx import generate_from_binary as facturx_generate_from_binary
from invoice_logic import (
InvoiceMetadata,
VERKAEUFER,
VERKAEUFER_IBAN,
PAYMENT_MEANS_CODES,
@ -16,7 +14,8 @@ from invoice_logic import (
berechne_faelligkeit,
kaufmaennisches_runden,
clean_german_float,
extract_invoice_items_pure_python,
extract_invoice_items,
extract_stated_tax_basis,
map_unece_unit_code,
parse_to_strict_xml_date,
format_cii_date,
@ -25,17 +24,18 @@ from invoice_logic import (
normalize_pdf_bytes,
pdf_has_embedded_facturx,
add_pdfa_output_intent,
fix_xrechnung_xmp_conformance,
compute_invoice_totals,
resolve_buyer_endpoint,
extract_service_period,
detect_contract_type,
detect_invoice_type_code,
extract_angebot_reference,
extract_pre_position_freetext,
extract_document_references,
extract_labeled_header_value,
extract_referenznummer,
extract_header_metadata_pure_python,
)
from intro_overlay import render_intro_overlay
# ==========================================
# 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
# ==========================================
# 1. API-CLIENT FÜR DAS vLLM-BACKEND
# 1. KOPFDATEN-EXTRAKTION (KI-frei, test-version3)
# ==========================================
class LLMClient:
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
# 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
# invoice_logic.py). Das ist schneller, offline-faehig und ohne Server-Abhaengigkeit.
@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):
# 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(
src_pdf_bytes,
cii_bytes,
@ -120,7 +75,6 @@ def build_zugferd_pdf(invoice_json: str, original_pdf_bytes: bytes) -> bytes:
afrelationship="data",
)
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.
# produziert eine kaputte PDF - dann als Fehlschlag behandeln.
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
# ==========================================
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")
render_intro_overlay()
llm = get_cached_llm()
col1, col2 = st.columns(2)
with col1:
@ -173,67 +131,23 @@ with col2:
if uploaded_file is not None and pdf_text.strip() != "":
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()
sys_prompt = (
"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()
# KI-frei (test-version3): Kopfdaten vollstaendig deterministisch aus dem
# PDF-Text lesen - kein vLLM/LLM-Aufruf mehr. Liefert dasselbe Dict-Schema,
# auf das die nachfolgende Pipeline aufsetzt.
final_data = extract_header_metadata_pure_python(pdf_text)
# 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"]:
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
# Felder nicht bereits geliefert hat.
@ -282,19 +196,9 @@ with col2:
_en = str(final_data.get("invoice_note", "")).strip()
final_data["invoice_note"] = f"{_en}\n{_refline}".strip() if _en else _refline
# Angebotsbezug ("Angebot <Nr> vom <Datum>") hat kein eigenes EN16931-Feld - als
# Info fuer den Kunden in den Freitext (BT-22). Zusaetzlich: falls das LLM die
# 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
# Hinweis: Ein Angebotsbezug ('Angebot <Nr> vom <Datum>') wird - genau wie ein
# Vergabeangebot - direkt in BT-17 (tender_id) einsortiert (siehe
# extract_document_references oben), nicht mehr in den Freitext.
st.session_state.invoice_counter += 1
st.session_state.extracted_data = final_data
@ -383,7 +287,12 @@ with col2:
with c_ref3:
c_id = st.text_input("Vertrag (BT-12)", value=data.get("contract_id", ""))
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)
with c_p1:
@ -437,7 +346,9 @@ with col2:
"Elektronische Adresse (BT-49)",
value=data.get("recipient_email", ""),
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_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.",
)
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_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", ""))
# 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:
if st.button("➕ Neue Position hinzufügen"):
data["items"].append({
@ -548,6 +490,23 @@ with col2:
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}")
# --- 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 ---
st.markdown("---")
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."
)
# 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 = {
"invoice_type_code": selected_type_code,
"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,
"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,
"vat_rate_percent": vat_rate, "total_gross_amount": computed_gross_amount, "iban": VERKAEUFER_IBAN,
"payment_means_code": selected_payment_means_code,
@ -575,7 +540,7 @@ with col2:
"skonto_percent": skonto_percent,
"skonto_days": skonto_days,
"vorkasse": vorkasse,
"recipient_endpoint_scheme": r_endpoint_scheme,
"recipient_endpoint_scheme": r_scheme_effective,
"leitweg_id": leitweg_id,
"delivery_note_id": dn_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 "
"Pflicht (BR-DE-15) und dient als Käuferreferenz - im Reiter 'Kopfdaten' ins "
"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 "
"Pflicht (PEPPOL-EN16931-R010). Da XRechnung meist an Behörden geht, gehört "
"hier in der Regel die Leitweg-ID rein - im Reiter 'Adressen' ins Feld "
"'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).")
"Pflicht (PEPPOL-EN16931-R010). Ist oben eine Leitweg-ID (BT-10) eingetragen, "
"wird sie automatisch als BT-49 (Schema 0204) übernommen. Alternativ im Reiter "
"'Adressen' die E-Mail-Adresse des Empfängers (Schema EM) oder eine GLN (0088) eintragen.")
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.")
if not edited_items:
@ -631,7 +595,7 @@ with col2:
# 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
# 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"
st.warning("⚠️ Es ist keine elektronische Adresse des Kunden (BT-49) angegeben - es wird "
"nur ein Platzhalter \"X\" eingesetzt, damit das Pflichtfeld nicht komplett "

BIN
static/intro.mp4 Normal file

Binary file not shown.