[PowerShell] USt-IdNr. (VAT-ID) Massenprüfung gegen VIES – mit Auto-Retry, HTML-Report, Mailversand

scenix26

Aktives Mitglied
5. Juni 2022
15
5
[PowerShell] USt-IdNr. (VAT-ID) Massenprüfung gegen VIES – mit Auto-Retry, HTML-Report, Mailversand


Hallo zusammen,


ich hatte die Anforderung, mehrere hundert USt-IdNr. aus einer SQL-Datenbank (bei mir JTL-Wawi, lässt sich aber auf jede SQL-Quelle anpassen) gegen das offizielle EU-Prüfsystem VIES zu validieren – regelmäßig, automatisiert, mit Ergebnisbericht per Mail. Da ich dazu nichts Fertiges gefunden habe, hier mein Ergebnis zum Nachnutzen.


Was das Skript macht:


  • Zieht Firmenname / Kundennummer / USt-IdNr. per SQL-Query aus einer beliebigen SQL-Server-Quelle
  • Prüft jede Nummer gegen den offiziellen SOAP-Webservice der EU-Kommission (checkVatService) – bewusst SOAP statt der (inoffiziellen, weniger stabilen) REST-API
  • Nicht-EU-Präfixe (GB, NO, CH, …) werden automatisch übersprungen, da VIES die ohnehin nicht prüfen kann
  • Bei technischen Fehlern (VIES ist notorisch überlastet, MS_MAX_CONCURRENT_REQ kommt öfter vor) automatischer Retry mit exponentiellem Backoff, zusätzlich bis zu 5 komplette Prüfrunden über die Gesamtliste – jede Folgerunde prüft nur noch das, was in der Vorrunde tatsächlich fehlgeschlagen ist
  • Erzeugt HTML-Report (mit clientseitigem Filter/Sortierung per JavaScript – Achtung, funktioniert nur beim Öffnen der Datei im Browser, nicht im Mail-Client) + CSV
  • Versand per SMTP, HTML sowohl als Mail-Body als auch als Anhang
  • Automatische Archivierung nach Monatsordnern (JJJJMM), automatische Bereinigung alter Arbeitsdateien

Technische Stolperfallen, die ich unterwegs gefixt habe (evtl. hilfreich für andere):


  • DataTable als Rückgabewert einer Funktion: PowerShell "entpackt" das beim return in ein Array von DataRow, wenn man nicht den unären Komma-Operator nutzt (return ,$table). Führt sonst zu kryptischen "Index auf ein NULL-Array"-Fehlern.
  • $Variable: in einem interpolierten String wird als PowerShell-Drive-Syntax interpretiert (wie $env:pATH), nicht als Variable gefolgt von einem Doppelpunkt-Zeichen. Fix: ${Variable}: statt $Variable:.
  • SOAP-Fault-Parsing: VIES liefert bei Überlastung HTTP 500 mit SOAP-Fault im Body (faultstring), das muss man separat aus $_.ErrorDetails.Message auslesen, nicht aus dem regulären Response.
  • New-WebServiceProxy (der "klassische" Weg für SOAP in PowerShell) ist unter PowerShell 7 / .NET Core nicht mehr verfügbar. Ich baue den SOAP-Envelope daher als rohen XML-String und schicke ihn per Invoke-WebRequest – läuft dadurch auf PS 5.1 und PS7 gleichermaßen.

Voraussetzungen / Setup:


  • Windows PowerShell 5.1 oder PowerShell 7
  • SQL-Zugriff auf die Quelldatenbank (im Skript oben eintragen)
  • SMTP-Zugang für den Mailversand (idealerweise App-Passwort, kein Hauptkennwort)
  • SQL-Query und Spaltennamen (cFirma, cKundenNr, cUSTID) im Skript an das eigene Datenmodell anpassen

Sicherheitshinweis: In der Beispielversion stehen SQL-/SMTP-Passwörter als Platzhalter im Klartext im Skript – das ist der einfachste Weg für den Eigenbedarf, aber für produktiven/geteilten Einsatz würde ich dringend zu einem Secret-Store, verschlüsselten Windows-Credentials (Export-Clixml) oder Umgebungsvariablen raten.


Skript ist im Anhang.


Code:
<#
.SYNOPSIS
    Prueft USt-IdNr. aus einer SQL-Server-Quelle gegen den offiziellen VIES SOAP-Webservice
    der EU-Kommission und versendet das Ergebnis per Mail (SMTP).

.DESCRIPTION
    1. Verbindet sich mit SQL Server (Zugangsdaten unten im Abschnitt "SQL CONFIG" statisch hinterlegt)
    2. Fuehrt die fest hinterlegte SQL-Abfrage aus (Abschnitt "SQL QUERY")
    3. Prueft jede USt-IdNr. gegen den SOAP-Endpoint checkVatService
       (http://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl, Operation "checkVat")
    4. Erzeugt HTML-Report (mit clientseitigem Filter per Status/Spalte) + CSV
    5. Versendet den Report IMMER per SMTP (Zugangsdaten unten im Abschnitt "MAIL CONFIG" statisch
       hinterlegt) - HTML als Mail-Body UND als Anhang, CSV zusaetzlich als Anhang

.NOTES
    VIES ist ein oeffentlicher Dienst ohne SLA. Timeouts/MS_UNAVAILABLE sind normal -> Retry-Logik eingebaut.
    SOAP-Aufruf erfolgt per rohem Envelope ueber Invoke-WebRequest (kein New-WebServiceProxy, da dieser
    unter PowerShell 7 / .NET Core nicht verfuegbar ist). Laeuft damit auf Windows PowerShell 5.1 und PS7.

    SICHERHEITSHINWEIS: SQL- und SMTP-Zugangsdaten werden in diesem Beispiel-Script im Klartext
    hinterlegt. Fuer den produktiven Einsatz wird dringend empfohlen, stattdessen z.B. ein
    verschluesseltes Windows-Credential (Export-Clixml, nur auf dem ausfuehrenden Rechner/Account
    lesbar), einen Secret-Store, oder eine Umgebungsvariable zu verwenden. Falls Zugangsdaten
    dennoch direkt im Script gepflegt werden, unbedingt per NTFS-Rechte zugriffsschuetzen und
    nicht in oeffentliche Repos einchecken.

.PARAMETER OutputFolder
    Zielordner fuer HTML/CSV-Report. Default: aktuelles Verzeichnis.

.EXAMPLE
    .\Check-VatNumbers.ps1
#>

[CmdletBinding()]
param(
    [string]$OutputFolder = "C:\VatCheck\output"
)

$ErrorActionPreference = "Stop"

# =====================================================================================
# SQL CONFIG -- HIER STATISCH PFLEGEN
# =====================================================================================
# Trag hier deine SQL-Server-Zugangsdaten fest ein. Diese Werte verlassen die Maschine nicht,
# werden aber im Klartext im Script gespeichert -> Datei entsprechend zugriffsgeschuetzt ablegen
# (NTFS-Rechte einschraenken, nicht in oeffentliche Repos committen).
$SqlServer   = "DEIN-SQL-SERVER"       # z.B. "SRV01" oder "SRV01\INSTANZ,1433"
$SqlDatabase = "DEINE_DATENBANK"       # Ziel-Datenbank
$SqlUser     = "DEIN_SQL_USER"         # SQL-Login (SA oder dedizierter Read-Only-User empfohlen)
$SqlPassword = "HIER_PASSWORT_EINTRAGEN"

# =====================================================================================
# SQL QUERY -- HIER STATISCH PFLEGEN
# =====================================================================================
# Beispiel-Query. Erwartet werden mindestens die Spalten cFirma, cKundenNr, cUSTID
# (Firmenname, Kundennummer, vollstaendige USt-IdNr. inkl. Laenderpraefix, z.B. "DE123456789").
# An das eigene Datenmodell anpassen.
$SqlQuery = @"
select top 1 cFirma, cKundenNr, cUSTID
from Kunde.lvKundenDaten
where cLand in (
    'Deutschland','Belgien','Bulgarien','Dänemark','Estland','Finnland',
    'Frankreich','Griechenland','Irland','Italien','Kroatien','Lettland',
    'Litauen','Luxemburg','Malta','Niederlande','Österreich','Polen',
    'Portugal','Rumänien','Schweden','Slowakei','Slowenien','Spanien',
    'Tschechische Republik','Ungarn','Zypern'
)
and nullif(ltrim(rtrim(cUSTID)), '') is not null order by cLand
"@

# =====================================================================================
# MAIL CONFIG -- HIER STATISCH PFLEGEN
# =====================================================================================
# ACHTUNG: Passwort liegt hier im Klartext im Script. Datei entsprechend absichern
# (NTFS-Rechte einschraenken, nicht in Git/Repo, idealerweise App-Passwort statt Hauptkennwort).
$MailFrom       = "noreply@deine-domain.de"
$MailToDefault  = "empfaenger1@deine-domain.de, empfaenger2@deine-domain.de"
$MailSubject    = "USt-IdNr.-Pruefung (VIES) - Ergebnisliste"
$SmtpServer     = "smtp.office365.com"
$SmtpPort       = 587
$SmtpUser       = "noreply@deine-domain.de"
$SmtpPassword   = "HIER_PASSWORT_EINTRAGEN"

# =====================================================================================
# VIES CONFIG (SOAP)
# =====================================================================================
$ViesEndpoint      = "http://ec.europa.eu/taxation_customs/vies/services/checkVatService"
$ViesNamespace     = "urn:ec.europa.eu:taxud:vies:services:checkVat:types"
$SoapAction        = "" # checkVatService benoetigt keine explizite SOAPAction, leer lassen
$MaxRetries        = 5
$RetryDelaySec     = 15
$RequestTimeoutSec = 20
$InterRequestDelayMs = 800   # Pause zwischen JEDEM VIES-Call, angehoben um MS_MAX_CONCURRENT_REQ zu vermeiden

# Anzahl der GESAMTEN Pruef-Durchlaeufe ueber die Liste (nicht zu verwechseln mit $MaxRetries oben,
# das sind die HTTP-Retries innerhalb EINES einzelnen VIES-Aufrufs). Runde 1 prueft den kompletten
# Batch, Runde 2+ prueft nur die Eintraege, die in der Vorrunde mit Status "Fehler" endeten.
$MaxCheckRounds    = 5

# =====================================================================================
# ARCHIV CONFIG
# =====================================================================================
# Zusaetzlich zu $OutputFolder werden HTML+CSV hier unter einem Monats-Unterordner (YYYYMM)
# archiviert. Wird bei Bedarf automatisch angelegt. Bei Nichterreichbarkeit (z.B. Netzlaufwerk
# down) bricht das Skript NICHT ab, sondern gibt nur eine Warnung aus - der Mailversand hat Vorrang.
$ArchiveBasePath = "C:\VatCheck\Archiv"

# =====================================================================================
# OUTPUT-BEREINIGUNG CONFIG
# =====================================================================================
# Dateien im Output-Ordner (VatCheck_*.csv/.html), die aelter als diese Anzahl Tage sind,
# werden nach jedem Lauf geloescht. Das Archiv unter $ArchiveBasePath ist davon NICHT
# betroffen und bleibt unbegrenzt erhalten.
$OutputRetentionDays = 7

# Gueltige EU-Laendercodes (VIES deckt nur diese ab; XI = Nordirland-Sonderfall wird von VIES noch akzeptiert)
$ValidCountryCodes = @(
    "AT","BE","BG","CY","CZ","DE","DK","EE","EL","ES","FI","FR","HR","HU",
    "IE","IT","LT","LU","LV","MT","NL","PL","PT","RO","SE","SI","SK","XI"
)

# Drittstaaten-Praefixe, die bewusst NICHT gegen VIES geprueft werden sollen (kein EU-Mitglied,
# VIES kann diese ohnehin nicht validieren). Diese Datensaetze werden im Report als "Uebersprungen"
# gefuehrt, nicht als Fehler. Liste bei Bedarf erweitern.
$SkipCountryCodes = @("GB", "NO", "CH")

# =====================================================================================
# FUNKTIONEN
# =====================================================================================

function Get-VatNumbersFromSql {
    param(
        [string]$Server,
        [string]$Database,
        [string]$User,
        [string]$Password,
        [string]$Query
    )

    Write-Host "Verbinde mit SQL Server '$Server' / DB '$Database' ..." -ForegroundColor Cyan

    $connString = "Server=$Server;Database=$Database;User Id=$User;Password=$Password;TrustServerCertificate=True;Connection Timeout=15;"
    $conn = New-Object System.Data.SqlClient.SqlConnection $connString

    try {
        $conn.Open()
        $cmd = $conn.CreateCommand()
        $cmd.CommandText = $Query
        $cmd.CommandTimeout = 60

        $adapter = New-Object System.Data.SqlClient.SqlDataAdapter $cmd
        $table = New-Object System.Data.DataTable
        [void]$adapter.Fill($table)

        Write-Host "  -> $($table.Rows.Count) Datensaetze geladen." -ForegroundColor Green
        # Komma-Operator verhindert, dass PowerShell das DataTable-Objekt beim Return
        # in seine Zeilen "entpackt" (DataTable ist IEnumerable) - ohne das Komma kaeme
        # im Aufrufer ggf. ein Array von DataRow statt eines DataTable-Objekts an.
        return ,$table
    }
    catch {
        Write-Error "SQL-Fehler: $($_.Exception.Message)"
        throw
    }
    finally {
        if ($conn.State -eq 'Open') { $conn.Close() }
    }
}

function Split-VatNumber {
    param([string]$RawVatId)

    $clean = ($RawVatId -replace '[\s\-\.]', '').ToUpperInvariant()

    if ($clean.Length -lt 3) {
        return [PSCustomObject]@{ CountryCode = $null; Number = $null; Raw = $RawVatId; ParseError = "Zu kurz / leer"; Skip = $false }
    }

    $cc  = $clean.Substring(0, 2)
    $num = $clean.Substring(2)

    if ($SkipCountryCodes -contains $cc) {
        return [PSCustomObject]@{ CountryCode = $cc; Number = $num; Raw = $RawVatId; ParseError = $null; Skip = $true }
    }

    if ($ValidCountryCodes -notcontains $cc) {
        return [PSCustomObject]@{ CountryCode = $cc; Number = $num; Raw = $RawVatId; ParseError = "Unbekannter/kein EU-Laendercode ('$cc')"; Skip = $false }
    }

    return [PSCustomObject]@{ CountryCode = $cc; Number = $num; Raw = $RawVatId; ParseError = $null; Skip = $false }
}

function Get-XmlChildText {
    param($ParentNode, [string]$LocalName)
    if (-not $ParentNode) { return $null }
    foreach ($child in $ParentNode.ChildNodes) {
        if ($child.LocalName -eq $LocalName) { return $child.InnerText }
    }
    return $null
}

function Invoke-ViesCheck {
    param(
        [string]$CountryCode,
        [string]$Number
    )

    # SOAP 1.1 Envelope fuer die Operation checkVat des offiziellen VIES-Webservice
    $envelope = @"
<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="$ViesNamespace">
   <soapenv:Header/>
   <soapenv:Body>
      <urn:checkVat>
         <urn:countryCode>$CountryCode</urn:countryCode>
         <urn:vatNumber>$Number</urn:vatNumber>
      </urn:checkVat>
   </soapenv:Body>
</soapenv:Envelope>
"@

    $headers = @{
        "Content-Type" = "text/xml; charset=utf-8"
    }
    if ($SoapAction) { $headers["SOAPAction"] = $SoapAction }

    for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) {
        try {
            $response = Invoke-WebRequest -Uri $ViesEndpoint -Method Post -Body $envelope -Headers $headers -TimeoutSec $RequestTimeoutSec -UseBasicParsing

            [xml]$xmlResponse = $response.Content
            $ns = New-Object System.Xml.XmlNamespaceManager($xmlResponse.NameTable)
            $ns.AddNamespace("soap", "http://schemas.xmlsoap.org/soap/envelope/")
            $ns.AddNamespace("urn", $ViesNamespace)

            $node = $xmlResponse.SelectSingleNode("//urn:checkVatResponse", $ns)
            if (-not $node) {
                # Fallback: manche Gateways antworten mit abweichendem/fehlendem Praefix
                $candidates = $xmlResponse.GetElementsByTagName("checkVatResponse")
                if ($candidates -and $candidates.Count -gt 0) { $node = $candidates[0] }
            }

            if (-not $node) {
                throw "checkVatResponse nicht im XML gefunden. Rohantwort: $($response.Content.Substring(0, [Math]::Min(300, $response.Content.Length)))"
            }

            $validText   = Get-XmlChildText -ParentNode $node -LocalName "valid"
            $nameText    = Get-XmlChildText -ParentNode $node -LocalName "name"
            $addressText = Get-XmlChildText -ParentNode $node -LocalName "address"

            return [PSCustomObject]@{
                Success = $true
                Valid   = ($validText -eq "true")
                Name    = $nameText
                Address = $addressText
                Error   = $null
            }
        }
        catch {
            # SOAP-Faults (INVALID_INPUT, MS_UNAVAILABLE, SERVICE_UNAVAILABLE, TIMEOUT, MS_MAX_CONCURRENT_REQ, ...)
            # kommen bei Invoke-WebRequest i.d.R. als HTTP 500 mit SOAP-Fault-XML im Body.
            $faultString = $null
            try {
                if ($_.ErrorDetails -and $_.ErrorDetails.Message) {
                    [xml]$faultXml = $_.ErrorDetails.Message
                    $faultNode = $faultXml.SelectSingleNode("//faultstring")
                    if ($faultNode) { $faultString = $faultNode.InnerText }
                }
            } catch {}

            $retryableFaults = @("MS_UNAVAILABLE", "SERVICE_UNAVAILABLE", "TIMEOUT", "GLOBAL_MAX_CONCURRENT_REQ", "MS_MAX_CONCURRENT_REQ")

            if ($faultString -and ($retryableFaults -contains $faultString) -and $attempt -lt $MaxRetries) {
                # Exponentielles Backoff: 15s, 30s, 60s, 120s ... - MS_MAX_CONCURRENT_REQ braucht oft
                # laenger als ein paar Sekunden, bis das jeweilige Mitgliedsstaat-System wieder frei ist.
                $backoffSec = $RetryDelaySec * [Math]::Pow(2, $attempt - 1)
                Write-Host "    $faultString fuer $CountryCode$Number, Retry $attempt/$MaxRetries in $backoffSec s ..." -ForegroundColor Yellow
                Start-Sleep -Seconds $backoffSec
                continue
            }

            if ($faultString) {
                return [PSCustomObject]@{
                    Success = $false
                    Valid   = $null
                    Name    = $null
                    Address = $null
                    Error   = $faultString
                }
            }

            if ($attempt -lt $MaxRetries) {
                $backoffSec = $RetryDelaySec * [Math]::Pow(2, $attempt - 1)
                Write-Host "    Fehler bei $CountryCode$Number (Versuch $attempt/$MaxRetries): $($_.Exception.Message) -> Retry in $backoffSec s" -ForegroundColor Yellow
                Start-Sleep -Seconds $backoffSec
                continue
            }

            return [PSCustomObject]@{
                Success = $false
                Valid   = $null
                Name    = $null
                Address = $null
                Error   = "HTTP-/Verbindungsfehler: $($_.Exception.Message)"
            }
        }
    }

    return [PSCustomObject]@{
        Success = $false
        Valid   = $null
        Name    = $null
        Address = $null
        Error   = "Max. Retries erreicht ohne Ergebnis"
    }
}

function New-HtmlReport {
    param(
        [array]$Results,
        [datetime]$RunTime
    )

    $totalCount   = $Results.Count
    $validCount   = ($Results | Where-Object { $_.Status -eq "Gueltig" }).Count
    $invalidCount = ($Results | Where-Object { $_.Status -eq "Ungueltig" }).Count
    $errorCount   = ($Results | Where-Object { $_.Status -eq "Fehler" }).Count
    $skippedCount = ($Results | Where-Object { $_.Status -eq "Uebersprungen" }).Count

    $rows = foreach ($r in $Results) {
        $rowColor = switch ($r.Status) {
            "Gueltig"       { "#d4edda" }
            "Ungueltig"     { "#f8d7da" }
            "Uebersprungen" { "#e2e3e5" }
            default         { "#fff3cd" }
        }
        @"
        <tr style="background-color:$rowColor;" data-status="$($r.Status)">
            <td>$([System.Net.WebUtility]::HtmlEncode($r.KundenNr))</td>
            <td>$([System.Net.WebUtility]::HtmlEncode($r.Bezeichnung))</td>
            <td>$([System.Net.WebUtility]::HtmlEncode($r.UstId))</td>
            <td><b>$($r.Status)</b></td>
            <td>$([System.Net.WebUtility]::HtmlEncode($r.Firmenname))</td>
            <td>$([System.Net.WebUtility]::HtmlEncode($r.Adresse))</td>
            <td>$([System.Net.WebUtility]::HtmlEncode($r.Hinweis))</td>
        </tr>
"@
    }

    $html = @"
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
    body { font-family: Segoe UI, Arial, sans-serif; font-size: 13px; color: #222; }
    h2 { margin-bottom: 4px; }
    .summary { margin-bottom: 16px; }
    .summary span { display:inline-block; margin-right: 20px; padding: 4px 10px; border-radius: 4px; }
    .filterbar { margin-bottom: 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
    .filterbar select, .filterbar input {
        padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;
    }
    .filterbar input[type="text"] { min-width: 220px; }
    .hint { color: #777; font-size: 11px; margin-bottom: 10px; }
    table { border-collapse: collapse; width: 100%; }
    th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; vertical-align: top; }
    th { background-color: #2c3e50; color: white; cursor: pointer; user-select: none; white-space: nowrap; }
    th:hover { background-color: #3d5470; }
    th .arrow { font-size: 10px; opacity: 0.7; }
    tr:hover { filter: brightness(0.96); }
    tr.js-hidden { display: none; }
    #resultCount { font-weight: bold; }
</style>
</head>
<body>
    <h2>USt-IdNr.-Pruefung (VIES)</h2>
    <div>Lauf: $($RunTime.ToString("dd.MM.yyyy HH:mm"))</div>
    <div class="summary">
        <span style="background:#e2e3e5;">Gesamt: $totalCount</span>
        <span style="background:#d4edda;">Gueltig: $validCount</span>
        <span style="background:#f8d7da;">Ungueltig: $invalidCount</span>
        <span style="background:#fff3cd;">Fehler: $errorCount</span>
        <span style="background:#e2e3e5;">Uebersprungen (Drittstaat): $skippedCount</span>
    </div>

    <div class="filterbar">
        <label for="statusFilter"><b>Status:</b></label>
        <select id="statusFilter" onchange="filterTable()">
            <option value="">Alle</option>
            <option value="Gueltig">Gueltig</option>
            <option value="Ungueltig">Ungueltig</option>
            <option value="Fehler">Fehler</option>
            <option value="Uebersprungen">Uebersprungen</option>
        </select>
        <label for="textFilter"><b>Suche:</b></label>
        <input type="text" id="textFilter" placeholder="Kd-Nr., Firma, USt-IdNr. ..." oninput="filterTable()">
        <span>Treffer: <span id="resultCount">$totalCount</span></span>
    </div>
    <div class="hint">
        Hinweis: Filter/Sortierung funktionieren nur, wenn dieser Report im Browser geoeffnet wird
        (z.B. ueber den HTML-Anhang) - im Mail-Programm selbst wird nur die statische Tabelle angezeigt,
        da die meisten Mail-Clients JavaScript aus Sicherheitsgruenden nicht ausfuehren.
    </div>

    <table id="reportTable">
        <thead>
        <tr>
            <th onclick="sortTable(0)">Kd-Nr. <span class="arrow">&#8597;</span></th>
            <th onclick="sortTable(1)">Bezeichnung <span class="arrow">&#8597;</span></th>
            <th onclick="sortTable(2)">USt-IdNr. <span class="arrow">&#8597;</span></th>
            <th onclick="sortTable(3)">Status <span class="arrow">&#8597;</span></th>
            <th onclick="sortTable(4)">Firmenname (VIES) <span class="arrow">&#8597;</span></th>
            <th onclick="sortTable(5)">Adresse (VIES) <span class="arrow">&#8597;</span></th>
            <th onclick="sortTable(6)">Hinweis <span class="arrow">&#8597;</span></th>
        </tr>
        </thead>
        <tbody>
        $($rows -join "`n")
        </tbody>
    </table>

    <script>
        function filterTable() {
            var statusVal = document.getElementById('statusFilter').value;
            var textVal = document.getElementById('textFilter').value.toLowerCase();
            var rows = document.querySelectorAll('#reportTable tbody tr');
            var visibleCount = 0;

            rows.forEach(function(row) {
                var matchesStatus = !statusVal || row.getAttribute('data-status') === statusVal;
                var matchesText = !textVal || row.textContent.toLowerCase().indexOf(textVal) !== -1;

                if (matchesStatus && matchesText) {
                    row.classList.remove('js-hidden');
                    visibleCount++;
                } else {
                    row.classList.add('js-hidden');
                }
            });

            document.getElementById('resultCount').textContent = visibleCount;
        }

        var sortState = {};
        function sortTable(colIndex) {
            var table = document.getElementById('reportTable');
            var tbody = table.querySelector('tbody');
            var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));

            var asc = !sortState[colIndex];
            sortState = {};
            sortState[colIndex] = asc;

            rows.sort(function(a, b) {
                var aText = a.children[colIndex].textContent.trim().toLowerCase();
                var bText = b.children[colIndex].textContent.trim().toLowerCase();
                if (aText < bText) return asc ? -1 : 1;
                if (aText > bText) return asc ? 1 : -1;
                return 0;
            });

            rows.forEach(function(row) { tbody.appendChild(row); });
        }
    </script>
</body>
</html>
"@
    return $html
}

function Remove-OldOutputFiles {
    param(
        [string]$Folder,
        [int]$RetentionDays
    )

    if (-not (Test-Path $Folder)) {
        Write-Host "Bereinigung uebersprungen: Ordner '$Folder' nicht erreichbar." -ForegroundColor DarkGray
        return
    }

    $cutoffDate = (Get-Date).AddDays(-$RetentionDays)

    try {
        # Nur eigene Dateien anfassen (Namensmuster VatCheck_*), nicht den ganzen Ordner leerraeumen -
        # falls dort noch andere Dateien liegen, die nicht von diesem Skript stammen.
        $oldFiles = Get-ChildItem -Path $Folder -Filter "VatCheck_*.*" -File -ErrorAction Stop |
            Where-Object { $_.LastWriteTime -lt $cutoffDate }

        if (-not $oldFiles -or $oldFiles.Count -eq 0) {
            Write-Host "Bereinigung: keine Dateien aelter als $RetentionDays Tage im Output-Ordner gefunden." -ForegroundColor DarkGray
            return
        }

        $deletedCount = 0
        foreach ($file in $oldFiles) {
            try {
                Remove-Item -Path $file.FullName -Force -ErrorAction Stop
                $deletedCount++
            }
            catch {
                Write-Warning "Konnte Datei nicht loeschen: $($file.FullName) - $($_.Exception.Message)"
            }
        }

        Write-Host "Bereinigung: $deletedCount von $($oldFiles.Count) Datei(en) aelter als $RetentionDays Tage geloescht." -ForegroundColor Green
    }
    catch {
        Write-Warning "Bereinigung des Output-Ordners fehlgeschlagen: $($_.Exception.Message)"
    }
}

function Send-ReportMail {
    param(
        [string]$HtmlBody,
        [string]$CsvAttachmentPath,
        [string]$HtmlAttachmentPath,
        [int]$ValidCount,
        [int]$InvalidCount,
        [int]$ErrorCount
    )

    Write-Host ""
    Write-Host "=== Mailversand ueber $SmtpServer ===" -ForegroundColor Cyan

    $securePass = ConvertTo-SecureString $SmtpPassword -AsPlainText -Force
    $cred = New-Object System.Management.Automation.PSCredential ($SmtpUser, $securePass)
    $recipients = $MailToDefault -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }

    $subject = "$MailSubject - $((Get-Date).ToString('dd.MM.yyyy')) - $ValidCount gueltig / $InvalidCount ungueltig / $ErrorCount Fehler"

    $mailParams = @{
        From        = $MailFrom
        To          = $recipients
        Subject     = $subject
        Body        = $HtmlBody
        BodyAsHtml  = $true
        SmtpServer  = $SmtpServer
        Port        = $SmtpPort
        UseSsl      = $true
        Credential  = $cred
        Attachments = @($CsvAttachmentPath, $HtmlAttachmentPath)
    }

    try {
        Send-MailMessage @mailParams
        Write-Host "Mail erfolgreich versendet an: $($recipients -join ', ')" -ForegroundColor Green
    }
    catch {
        Write-Error "Mailversand fehlgeschlagen: $($_.Exception.Message)"
        throw
    }
}

function Test-VatEntry {
    param(
        [string]$KundenNr,
        [string]$Bezeichnung,
        [string]$RawVatId
    )

    $parsed = Split-VatNumber -RawVatId $RawVatId

    if ($parsed.Skip) {
        Write-Host " -> Uebersprungen (Drittstaat: $($parsed.CountryCode))" -ForegroundColor DarkGray
        return [PSCustomObject]@{
            KundenNr    = $KundenNr
            Bezeichnung = $Bezeichnung
            UstId       = $RawVatId
            Status      = "Uebersprungen"
            Firmenname  = ""
            Adresse     = ""
            Hinweis     = "Drittstaat ($($parsed.CountryCode)), nicht via VIES pruefbar"
        }
    }

    if ($parsed.ParseError) {
        Write-Host " -> Parse-Fehler: $($parsed.ParseError)" -ForegroundColor Red
        return [PSCustomObject]@{
            KundenNr    = $KundenNr
            Bezeichnung = $Bezeichnung
            UstId       = $RawVatId
            Status      = "Fehler"
            Firmenname  = ""
            Adresse     = ""
            Hinweis     = $parsed.ParseError
        }
    }

    $viesResult = Invoke-ViesCheck -CountryCode $parsed.CountryCode -Number $parsed.Number

    if (-not $viesResult.Success) {
        Write-Host " -> Fehler: $($viesResult.Error)" -ForegroundColor Red
        return [PSCustomObject]@{
            KundenNr    = $KundenNr
            Bezeichnung = $Bezeichnung
            UstId       = $RawVatId
            Status      = "Fehler"
            Firmenname  = ""
            Adresse     = ""
            Hinweis     = $viesResult.Error
        }
    }
    elseif ($viesResult.Valid -eq $true) {
        Write-Host " -> Gueltig" -ForegroundColor Green
        return [PSCustomObject]@{
            KundenNr    = $KundenNr
            Bezeichnung = $Bezeichnung
            UstId       = $RawVatId
            Status      = "Gueltig"
            Firmenname  = $viesResult.Name
            Adresse     = $viesResult.Address
            Hinweis     = ""
        }
    }
    else {
        Write-Host " -> Ungueltig" -ForegroundColor Yellow
        return [PSCustomObject]@{
            KundenNr    = $KundenNr
            Bezeichnung = $Bezeichnung
            UstId       = $RawVatId
            Status      = "Ungueltig"
            Firmenname  = $viesResult.Name
            Adresse     = $viesResult.Address
            Hinweis     = ""
        }
    }
}

# =====================================================================================
# HAUPTABLAUF
# =====================================================================================

$runTime = Get-Date
Write-Host "=== VIES USt-IdNr.-Pruefung gestartet: $($runTime.ToString('dd.MM.yyyy HH:mm:ss')) ===" -ForegroundColor Cyan

# 1) Daten aus SQL holen
$sqlData = Get-VatNumbersFromSql -Server $SqlServer -Database $SqlDatabase -User $SqlUser -Password $SqlPassword -Query $SqlQuery

if ($sqlData -isnot [System.Data.DataTable]) {
    Write-Error "Unerwarteter Rueckgabetyp aus SQL-Abfrage: $($sqlData.GetType().FullName). Erwartet: System.Data.DataTable. Abbruch."
    return
}

if ($sqlData.Rows.Count -eq 0) {
    Write-Warning "Keine Datensaetze aus SQL erhalten. Abbruch."
    return
}

foreach ($col in @("cUSTID", "cFirma", "cKundenNr")) {
    if (-not $sqlData.Columns.Contains($col)) {
        Write-Error "Erwartete Spalte '$col' fehlt im SQL-Ergebnis. Vorhandene Spalten: $($sqlData.Columns.ColumnName -join ', '). Abbruch."
        return
    }
}

# 2) Runde 1: Jede USt-IdNr aus dem kompletten Batch pruefen
$results = New-Object System.Collections.Generic.List[PSObject]
$rowIndex = 0
$totalRows = $sqlData.Rows.Count

Write-Host ""
Write-Host "--- Runde 1/${MaxCheckRounds}: kompletter Batch ($totalRows Datensaetze) ---" -ForegroundColor Cyan

foreach ($row in $sqlData.Rows) {
    $rowIndex++
    $rawVatId    = if ($row["cUSTID"] -is [DBNull])    { "" } else { [string]$row["cUSTID"] }
    $bezeichnung = if ($row["cFirma"] -is [DBNull])    { "" } else { [string]$row["cFirma"] }
    $kundenNr    = if ($row["cKundenNr"] -is [DBNull]) { "" } else { [string]$row["cKundenNr"] }

    Write-Host "[$rowIndex/$totalRows] Pruefe $rawVatId ($bezeichnung, Kd-Nr. $kundenNr) ..." -NoNewline

    $entryResult = Test-VatEntry -KundenNr $kundenNr -Bezeichnung $bezeichnung -RawVatId $rawVatId
    $results.Add($entryResult)

    # kleine Pause um die oeffentliche API nicht zu ueberlasten (nur bei tatsaechlichem API-Call noetig,
    # Skip/Parse-Fehler-Faelle erzeugen keine Last, schaden aber auch nicht durch die kurze Pause)
    if ($entryResult.Status -ne "Uebersprungen") {
        Start-Sleep -Milliseconds $InterRequestDelayMs
    }
}

# 2b) Retry-Runden: nur Eintraege mit Status "Fehler" erneut pruefen, insgesamt max. $MaxCheckRounds Durchlaeufe
for ($round = 2; $round -le $MaxCheckRounds; $round++) {
    $failedIndexes = for ($i = 0; $i -lt $results.Count; $i++) {
        if ($results[$i].Status -eq "Fehler") { $i }
    }

    if ($failedIndexes.Count -eq 0) {
        Write-Host ""
        Write-Host "Keine Fehler-Eintraege mehr vorhanden, ueberspringe Runde $round." -ForegroundColor DarkGray
        break
    }

    Write-Host ""
    Write-Host "--- Runde ${round}/${MaxCheckRounds}: erneute Pruefung von $($failedIndexes.Count) fehlgeschlagenen Eintraegen ---" -ForegroundColor Cyan
    Write-Host "Warte 30s vor Rundenstart, damit sich der VIES-Dienst erholen kann ..." -ForegroundColor DarkGray
    Start-Sleep -Seconds 30

    $retryCount = 0
    foreach ($idx in $failedIndexes) {
        $retryCount++
        $prevResult = $results[$idx]

        Write-Host "[$retryCount/$($failedIndexes.Count)] Re-Check $($prevResult.UstId) ($($prevResult.Bezeichnung)) ..." -NoNewline

        $entryResult = Test-VatEntry -KundenNr $prevResult.KundenNr -Bezeichnung $prevResult.Bezeichnung -RawVatId $prevResult.UstId
        $results[$idx] = $entryResult

        # In Retry-Runden grosszuegigere Pause: diese Eintraege sind bereits einmal an
        # Ueberlastung gescheitert, daher zusaetzliche Schonzeit fuer den betroffenen VIES-Dienst.
        Start-Sleep -Milliseconds ($InterRequestDelayMs * 2)
    }
}

# 3) Reports erzeugen
# Falls der konfigurierte (UNC-)Ausgabeordner nicht erreichbar ist, auf lokalen Temp-Ordner
# ausweichen, damit Report-Erzeugung und Mailversand trotzdem stattfinden koennen.
try {
    if (-not (Test-Path $OutputFolder)) {
        New-Item -ItemType Directory -Path $OutputFolder -Force -ErrorAction Stop | Out-Null
    }
    $effectiveOutputFolder = $OutputFolder
}
catch {
    $effectiveOutputFolder = Join-Path $env:TEMP "VatCheck_Fallback"
    if (-not (Test-Path $effectiveOutputFolder)) {
        New-Item -ItemType Directory -Path $effectiveOutputFolder -Force | Out-Null
    }
    Write-Warning "Ausgabeordner '$OutputFolder' nicht erreichbar ($($_.Exception.Message)). Weiche aus auf: $effectiveOutputFolder"
}

$timestamp = $runTime.ToString("yyyyMMdd_HHmmss")
$csvPath  = Join-Path $effectiveOutputFolder "VatCheck_$timestamp.csv"
$htmlPath = Join-Path $effectiveOutputFolder "VatCheck_$timestamp.html"

$results | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 -Delimiter ';'

$htmlReport = New-HtmlReport -Results $results -RunTime $runTime
$htmlReport | Out-File -FilePath $htmlPath -Encoding UTF8

# 3b) Zusaetzlich ins Monats-Archiv kopieren (YYYYMM-Unterordner unter $ArchiveBasePath)
$archiveMonthFolder = $null
try {
    $archiveMonthFolder = Join-Path $ArchiveBasePath $runTime.ToString("yyyyMM")
    if (-not (Test-Path $archiveMonthFolder)) {
        New-Item -ItemType Directory -Path $archiveMonthFolder -Force | Out-Null
        Write-Host "Archiv-Ordner angelegt: $archiveMonthFolder" -ForegroundColor DarkGray
    }

    Copy-Item -Path $csvPath -Destination $archiveMonthFolder -Force
    Copy-Item -Path $htmlPath -Destination $archiveMonthFolder -Force
    Write-Host "Archiviert nach: $archiveMonthFolder" -ForegroundColor Green
}
catch {
    Write-Warning "Archivierung nach '$ArchiveBasePath' fehlgeschlagen: $($_.Exception.Message). Mailversand laeuft trotzdem weiter."
}

$validCount   = ($results | Where-Object { $_.Status -eq "Gueltig" }).Count
$invalidCount = ($results | Where-Object { $_.Status -eq "Ungueltig" }).Count
$errorCount   = ($results | Where-Object { $_.Status -eq "Fehler" }).Count
$skippedCount = ($results | Where-Object { $_.Status -eq "Uebersprungen" }).Count

Write-Host ""
Write-Host "=== Zusammenfassung (nach bis zu $MaxCheckRounds Runden) ===" -ForegroundColor Cyan
Write-Host "Gesamt: $($results.Count) | Gueltig: $validCount | Ungueltig: $invalidCount | Fehler: $errorCount | Uebersprungen (Drittstaat): $skippedCount"
Write-Host "CSV:  $csvPath"
Write-Host "HTML: $htmlPath"
if ($archiveMonthFolder) { Write-Host "Archiv: $archiveMonthFolder" } else { Write-Host "Archiv: nicht verfuegbar (siehe Warnung oben)" -ForegroundColor Yellow }

# 4) Mailversand (immer, kein Opt-in mehr noetig)
Send-ReportMail -HtmlBody $htmlReport -CsvAttachmentPath $csvPath -HtmlAttachmentPath $htmlPath -ValidCount $validCount -InvalidCount $invalidCount -ErrorCount $errorCount

# 5) Output-Ordner bereinigen (nur Output, Archiv bleibt unangetastet)
Remove-OldOutputFiles -Folder $effectiveOutputFolder -RetentionDays $OutputRetentionDays

Write-Host ""
Write-Host "=== Fertig ===" -ForegroundColor Cyan
 

sebjo82

Sehr aktives Mitglied
3. Juni 2021
697
211
Im Hinterkopf behalten, dass die Lieferadresse entscheidend ist, d.h. man muss eine Whitelist pro VAT-ID pflegen gegen die jede einzelne Rechnung geprüft wird
Ich habe bei uns einfach beim Kunden ein Eigenes Feld für die geprüften Lieferadressen und beim Auftrag ein Eigenes Feld für die API-Antwort eingerichtet und das ganze dann als Auftrags-Workflow laufen

Funktioniert super
 
  • Gefällt mir
Reaktionen: scenix26

Morimus

Sehr aktives Mitglied
16. Mai 2019
607
131
Für IGL-Lieferungen würde ich bei der VIES-Abfrage auf jeden Fall auch die eigene UID als Antragsteller mitsenden.
Nur dann bekommst du eine eindeutige Abfragenummer, die du zusammen mit dem Prüfergebnis archivieren kannst.