Ausgangssituation
Nach dem automatischen Rechnungsimport werden ZUGFeRD-Daten verarbeitet und Belegpositionen angelegt. Ziel: minimaler Nachbearbeitungsaufwand durch vorgebelegte Sachkonten.
📋 Kontext
Warum fehlt das Sachkonto?
Der ZUGFeRD-Standard enthält keine Information über das zu bebuchende Sachkonto — das ist unternehmensindividuell.
❌ Das Problem
Mapping-basiertes Fuzzy-Matching
Eine konfigurierbare Mapping-Tabelle verbindet Positionstexte mit Sachkonten. Das Skript vergleicht jeden ZUGFeRD-Positionstext gegen alle Mapping-Einträge und wählt den besten Treffer — oder fällt auf ein Dummy-Konto zurück.
🗂️ Mapping-Tabelle „Zugferd"
Pflegbare Tabelle mit Positionsmustern (Eingangswert) und zugehörigen Sachkontonummern (Ausgangswert). Kein Code-Eingriff nötig.
🧠 Fuzzy-Matching (Levenshtein)
Positionstext aus ZUGFeRD wird normalisiert und mit jedem Mapping-Muster verglichen. Score ≥ 80% → Treffer. Contain-Boost auf 90% bei exaktem Teilstring.
🔁 Fallback & Empfehlung
Kein Treffer → Dummy-Sachkonto als Auffangnetz. Empfehlung: Trigger, der verhindert, dass mit SK = „dummy" kontiert werden kann.
Aufbau & Beispieldaten
Die Tabelle „Zugferd" wird in eGECKO unter USS → Mapping gepflegt. Jeder Eintrag verbindet einen Positionstext-Muster mit einer Sachkontonummer.
Mapping: „Zugferd" — Beispieldaten
| # | Eingangswert (Positionsmuster) | Ausgangswert (Sachkonto) | Treffer-Beispiel | Status |
|---|---|---|---|---|
| 1 | DL Finanzen | 630300 | „DL Finanzen Q4" → Score 0.92 | ✓ Match |
| 2 | DL Vertragsmanagement | 630300 | „Vertragsmanagement" → Score 0.88 | ✓ Match |
| 3 | DL Sonstiges | 630300 | „Sonstige DL" → Score 0.82 | ✓ Match |
| 4 | DL Anwendungsmanagement | 630300 | „DL Anwend.-Mgmt." → Score 0.81 | ✓ Match |
| 5 | dummy | 000000 | Kein Treffer ≥ 80% | ↩ Fallback |
Drei Schritte zur Inbetriebnahme
Die Lösung ist vollständig in eGECKO konfigurierbar — kein Datenbankeingriff, keine externe Abhängigkeit.
Mapping-Tabelle anlegen
In eGECKO unter USS → Mapping eine neue Tabelle mit der Bezeichnung Zugferd anlegen.
- Bezeichnung: exakt „Zugferd"
- Eingangswertspalte: Positionsmuster
- Ausgangswertspalte: Sachkontonummer
- Pflichtzeile:
dummy → 000000
Businessprozess anlegen
Das Skript als neuen Einzelprozess im Businessprozess-Modul anlegen (Typ: Skript / Kommando).
- DD-Objekt:
de.css.fibu.RechnungsEingangsBuch - Skript einfügen (siehe Tab unten)
- Parameter:
MIN_SCORE = 0.80 - Manuell testbar im Rechnungseingang
Funktionsaufruf / Trigger
Zwei Aufrufmöglichkeiten — manuell oder vollautomatisch nach Belegimport.
- Manuell: Businessprozess per Button im Beleg starten
- Automatisch: Skript auf Import-Trigger legen
- Trigger läuft nach Belegeanlage & Dokumentverknüpfung
- Empfehlung: Zusatz-Trigger für Dummy-SK-Sperre
Empfehlung: Trigger zur Dummy-Sachkonto-Sperre
Wird einer Position das Fallback-Sachkonto 000000 (dummy) zugewiesen, sollte ein zusätzlicher
Validierungstrigger verhindern, dass dieser Beleg kontiert werden kann, bis das Sachkonto manuell
korrigiert wurde. Das erzwingt eine bewusste Nachbearbeitung und verhindert Fehlbuchungen.
Implementierung als Vor-Speichern-Trigger auf FibuBeleg mit Prüfung auf
belegpositionen.sachkonto.kontonummer == "000000".
GScript — vollständige Implementierung
Das Skript ist in Groovy/GScript geschrieben und läuft vollständig in der eGECKO/Integris-Laufzeitumgebung. Alle Abhängigkeiten (PDFBox, KIT-API) sind im Standard enthalten.
// ============================================================ // ZUGFeRD Positions-Import für eGECKO/Integris // Kubisch Consulting — kubisch.consulting // ============================================================ #!gscript import de.integris.kit.ctl.PropertyEdit; import de.css.report.archiv.DokumentenarchivTools; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.Loader; import org.apache.pdfbox.text.PDFTextStripper; import org.apache.pdfbox.cos.*; // ── Parameter ───────────────────────────────────────────── final double MIN_SCORE = 0.80d // Mindest-Trefferquote final String DUMMY_KEY = "dummy" // Fallback-Mapping-Schlüssel // ── Hilfsfunktionen ─────────────────────────────────────── def normalizeText = { s -> if (!s) return "" s.toLowerCase() .replaceAll("[\\p{Punct}]+", " ") .replaceAll("\\s+", " ") .trim() } def levenshtein = { a, b -> a = (a ?: "").toLowerCase() b = (b ?: "").toLowerCase() int m = a.length(), n = b.length() int[][] dp = new int[m+1][n+1] (0..m).each { dp[it][0] = it } (0..n).each { dp[0][it] = it } for (i in 1..m) { for (j in 1..n) { int cost = (a.charAt(i-1) == b.charAt(j-1)) ? 0 : 1 dp[i][j] = Math.min(Math.min(dp[i-1][j]+1, dp[i][j-1]+1), dp[i-1][j-1]+cost) } } dp[m][n] } def similarity = { a, b -> def na = normalizeText(a), nb = normalizeText(b) if (!na || !nb) return 0d // Contain-Boost: exakter Teilstring → mind. 90% boolean boost = (" "+na+" ").contains(" "+nb+" ") || (" "+nb+" ").contains(" "+na+" ") int dist = levenshtein(na, nb) double base = 1d - (dist / (double)Math.max(na.length(), nb.length())) if (boost) base = Math.max(base, 0.90d) Math.min(1d, Math.max(0d, base)) } // ── 1) FibuBeleg laden ──────────────────────────────────── def fibuBeleg = obj.fibuBeleg if (!fibuBeleg) { println("ABBRUCH: fibuBeleg ist null"); return } // ── 2) XML aus PDF/Archiv extrahieren ──────────────────── def xmlString = null try { def zuordnungen = DokumentenarchivTools .getDokumentenzuordnungenZuIObject(fibuBeleg) def binary = zuordnungen?.find { true }?.getDokumentenarchivBinaryData() byte[] pdfBytes = binary?.dokumentBinaryData?.data if (pdfBytes) { def header = new String(pdfBytes, 0, Math.min(pdfBytes.length, 200), "UTF-8") if (header.contains("<?xml") || header.contains("CrossIndustryInvoice")) { xmlString = new String(pdfBytes, "UTF-8") // direkte XML-Datei } else { PDDocument pdf = Loader.loadPDF(pdfBytes) try { // Names-Tree → AF-Array (ZUGFeRD 2.x Standard) // ... [PDF-Parsing-Logik] ... } finally { pdf.close() } } } } catch (Exception ex) { println("WARNUNG: " + ex.message) } if (!xmlString) { println("ABBRUCH: Keine XML gefunden"); return } // ── 3) Positionen aus XML parsen ───────────────────────── def positions = [] def xml = xmlString def escaped = xml.contains("<") def lineItemPat = escaped ? /(?s)<[^&]*IncludedSupplyChainTradeLineItem[^&]*>(.*?)<\/[^&]*IncludedSupplyChainTradeLineItem>/ : /(?s)<[^>]*IncludedSupplyChainTradeLineItem[^>]*>(.*?)<\/[^>]*IncludedSupplyChainTradeLineItem>/ (xml =~ lineItemPat).each { m -> // Name, LineTotalAmount, ApplicablePercent extrahieren // → positions << [text, net, vatPct] } // ── 4) Mapping laden ───────────────────────────────────── def mapping = fetchObject("de.css.uss.Mapping", "bezeichnung", "Zugferd") def mvList = mapping?.mappingValues?.collect { [ eingang : it.eingangsWert.toString(), ausgang : it.ausgangsWert.toString(), eingangNorm: normalizeText(it.eingangsWert.toString()) ] } def dummyMV = mvList?.find { it.eingang?.equalsIgnoreCase(DUMMY_KEY) } // ── 5 + 6) Positionen anlegen ──────────────────────────── PropertyEdit edit = obj.getEditingContext().beginEdit("ZUGFeRD-Positionen") try { positions.each { p -> // Bestes Mapping finden def best = null; double bestScore = 0d mvList.each { mv -> double sc = similarity(normalizeText(p.text), mv.eingangNorm) if (sc > bestScore) { bestScore = sc; best = mv } } def chosen = (bestScore >= MIN_SCORE) ? best : dummyMV if (!chosen) { println("Übersprungen: kein Mapping"); return } def sk = fetchObject("de.css.fibu.stammdaten.konten.Sachkonto", "kontonummer", chosen.ausgang) if (!sk) { return } // kein gültiges Konto // Brutto aus Netto + MwSt berechnen BigDecimal rate = p.vatPct.divide(new BigDecimal("100"), 10, ROUND_HALF_UP) BigDecimal gross = p.net.multiply(BigDecimal.ONE.add(rate)).setScale(2, ROUND_HALF_UP) BigDecimal vat = gross.subtract(p.net).setScale(2, ROUND_HALF_UP) def bp = fibuBeleg.createIntoBelegpositionen(edit, -1, [] as Object[]) bp.setSachkonto(sk) fibuBeleg.clientSetBelegwaehrungsbetrag(edit, bp, gross) edit.set(bp, "steuerbetragBelegwaehrung", vat) edit.set(bp, "buchtext", p.text) } edit.commit() } catch (Exception ex) { edit.rollback(ex) } // ── 7) Saldo neu berechnen ─────────────────────────────── def e2 = obj.getEditingContext().beginEdit("Saldo aktualisieren") try { fibuBeleg.setzeSollHabenSaldo(e2) e2.commit() } catch (ex) { e2.rollback(ex) } println "OK – ZUGFeRD-Import abgeschlossen."
// ── Fuzzy-Matching-Kern ────────────────────────────────── // Normalisierung: Kleinbuchstaben, Sonderzeichen entfernen def normalizeText = { s -> if (!s) return "" s.toLowerCase() .replaceAll("[\\p{Punct}]+", " ") .replaceAll("\\s+", " ") .trim() } // Levenshtein-Distanz (Editierabstand) def levenshtein = { a, b -> /* ... dynamische Programmierung ... */ dp[m][n] // Anzahl Einfüge-/Lösch-/Ersetzoperationen } // Ähnlichkeits-Score 0.0 – 1.0 def similarity = { a, b -> def na = normalizeText(a) def nb = normalizeText(b) // Contain-Boost: exakter Teilstring → mindestens 90% boolean boost = (" "+na+" ").contains(" "+nb+" ") || (" "+nb+" ").contains(" "+na+" ") int dist = levenshtein(na, nb) double base = 1d - (dist / (double)Math.max(na.length(), nb.length())) if (boost) base = Math.max(base, 0.90d) Math.min(1d, Math.max(0d, base)) } // ── Entscheidungslogik ─────────────────────────────────── def best = null; double bestScore = 0d mvList.each { mv -> double sc = similarity(normalizeText(p.text), mv.eingangNorm) if (sc > bestScore) { bestScore = sc; best = mv } } // Treffer oder Fallback auf Dummy if (bestScore >= MIN_SCORE) { chosen = best // ✓ Match — Sachkonto aus Mapping } else if (dummyMV) { chosen = dummyMV // ↩ Fallback — Dummy-Sachkonto } else { println "Übersprungen: kein Match, kein Dummy" return } // Beispiel-Ausgabe im Log: // ZUGFeRD-Position: 'DL Finanzen Q4' | Netto: 1200.00 // Beste Übereinstimmung: 'DL Finanzen' | Score=0.923 // → ANGELEGT: SK=630300 (match), Brutto=1428.00
// ── Belegposition anlegen ──────────────────────────────── // Voraussetzung: chosen (Mapping-Eintrag) und sk (Sachkonto-Objekt) // 1. Brutto & Steuer berechnen BigDecimal rate = p.vatPct.divide(new BigDecimal("100"), 10, ROUND_HALF_UP) BigDecimal gross = p.net .multiply(BigDecimal.ONE.add(rate)) .setScale(2, ROUND_HALF_UP) BigDecimal vat = gross.subtract(p.net).setScale(2, ROUND_HALF_UP) // Beispiel: Netto 1200 + 19% → Brutto 1428, Steuer 228 // 2. Duplikat-Check (SK + Brutto + Text ≥ 90% ähnlich) if (isDuplicate(chosen.ausgang, gross, p.text)) { println "INFO: Duplikat → übersprungen" return } // 3. Position anlegen def bp = fibuBeleg.createIntoBelegpositionen(edit, -1, [] as Object[]) if (!bp) throw new RuntimeException("Belegposition null") bp.setSachkonto(sk) // Sachkonto setzen // 4. Bruttobetrag (belegwaehrungsbetrag) setzen fibuBeleg.clientSetBelegwaehrungsbetrag(edit, bp, gross) // 5. Steuerbetrag (optional, Feldverfügbarkeit prüfen) try { edit.set(bp, "steuerbetragBelegwaehrung", vat) } catch (ignored) {} // Feld existiert nicht in allen Modellen // 6. Buchtext = ZUGFeRD-Positionstext edit.set(bp, "buchtext", p.text) println "→ ANGELEGT: SK=${chosen.ausgang}, Brutto=${gross}, Text='${p.text}'" // ── Nach der Schleife ──────────────────────────────────── edit.commit() // alle Positionen in einer Transaktion // ── Saldo aktualisieren ────────────────────────────────── def e2 = obj.getEditingContext().beginEdit("Saldo aktualisieren") try { fibuBeleg.setzeSollHabenSaldo(e2) e2.commit() println "Saldo: " + fibuBeleg.getSaldo() } catch (ex) { e2.rollback(ex) }