KubischConsulting
Technische Dokumentation · eGECKO / Integris

ZUGFeRD-Positionen automatisch ins
Rechnungseingangsbuch

📄 Businessprozess / Trigger
🗺️ Fuzzy-Matching ≥ 80%
🔁 Fallback auf Dummy-Konto
≥ 80%Match-Score
0Manuelle Eingaben
ZUGFeRD 2.xkompatibel
Anforderung

Ausgangssituation

Nach dem automatischen Rechnungsimport werden ZUGFeRD-Daten verarbeitet und Belegpositionen angelegt. Ziel: minimaler Nachbearbeitungsaufwand durch vorgebelegte Sachkonten.

📋 Kontext

📥Rechnungsimport erzeugt FibuBeleg im Rechnungseingangsbuch
🔗Beleg wird mit ZUGFeRD-Dokument (PDF/XML) verknüpft
📑Belegpositionen sollen automatisch übernommen werden
🎯Sachkonto soll bereits vorbelegt sein
Problem & Ursache

Warum fehlt das Sachkonto?

Der ZUGFeRD-Standard enthält keine Information über das zu bebuchende Sachkonto — das ist unternehmensindividuell.

❌ Das Problem

🚫ZUGFeRD-Positionen enthalten kein Sachkonto-Feld
Jede Position muss manuell einem Konto zugeordnet werden
⚠️Fehleranfällig bei hohem Belegvolumen
👤Abhängig von Mitarbeiterwissen
🔑Steuerschlüssel muss zusätzlich manuell gepflegt werden

Lösungsansatz

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.

1

🗂️ Mapping-Tabelle „Zugferd"

Pflegbare Tabelle mit Positionsmustern (Eingangswert) und zugehörigen Sachkontonummern (Ausgangswert). Kein Code-Eingriff nötig.

2

🧠 Fuzzy-Matching (Levenshtein)

Positionstext aus ZUGFeRD wird normalisiert und mit jedem Mapping-Muster verglichen. Score ≥ 80% → Treffer. Contain-Boost auf 90% bei exaktem Teilstring.

3

🔁 Fallback & Empfehlung

Kein Treffer → Dummy-Sachkonto als Auffangnetz. Empfehlung: Trigger, der verhindert, dass mit SK = „dummy" kontiert werden kann.

Prozessfluss
📄
ZUGFeRD-XML
Positionstext, Netto, MwSt
🔍
Normalisierung
Kleinbuchstaben, Sonderzeichen
🧠
Fuzzy-Matching
Levenshtein + Contain-Boost
🗺️
Score ≥ 80%?
Treffer oder Dummy
Belegposition
SK, Buchtext, Brutto, Steuer
💡 Steuerschlüssel wird automatisch aus dem gefundenen Sachkonto übernommen — keine separate Pflege nötig.
🛡️ Duplikat-Schutz: Bereits vorhandene Positionen (SK + Betrag + Text) werden erkannt und übersprungen.

Mapping-Tabelle

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

Umsetzung

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".


Quellcode

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-Import.gscript
// ============================================================
// 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("&lt;")

def lineItemPat = escaped
    ? /(?s)&lt;[^&]*IncludedSupplyChainTradeLineItem[^&]*&gt;(.*?)&lt;\/[^&]*IncludedSupplyChainTradeLineItem&gt;/
    : /(?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) }
Erstgespräch · kostenlos · unverbindlich

Erzählen Sie mir, was Sie abtippen.

Meist ist nach zwanzig Minuten klar, ob sich das automatisieren lässt — und ob es sich lohnt. Wenn nicht, sage ich Ihnen das auch.