In dieser Lektion lernen Sie, wie GuiXT-Tabellen und -Strukturen als JSON an eine .NET-Funktion übergeben werden und wie das .NET-Ergebnis als JSON-Array zurück in eine GuiXT-Tabelle geladen wird. Als Praxisbeispiel implementieren wir einen Routenoptimierer: Aus den SAP-Kundenstammdaten werden Lieferadressen mit Geokoordinaten gelesen und über die REST-API von OpenRouteService in der optimalen Reihenfolge für eine Fahrtroute sortiert.

Das Zusammenspiel der Komponenten:

  1. GuiXT liest Kundendaten per Datenbank-Select in eine interne Tabellenvariable.
  2. GuiXT übergibt die Tabelle als JSON-Array zusammen mit dem Startpunkt an die .NET-Funktion OptimizeDeliveries.
  3. .NET geocodiert fehlende Koordinaten (Nominatim/OpenStreetMap), ruft die ORS-API auf und gibt die sortierte Route als JSON-Array zurück.
  4. GuiXT lädt das JSON-Ergebnis automatisch in die Zieltabelle routetable.

1. Die Bildschirmmaske

Das GuiXT-Script definiert Eingabefelder für den Startpunkt sowie zwei Tabellen: customer für die geladenen Kundendaten und routetable für die optimierte Route. Der Pushbutton löst beim Klick das InputScript calc_route.txt aus.

GuiXT
InputField (15,32) "Country Code" (15,52) size=2  name="sCC"
InputField (14,32) "Postcode"     (14,52) size=10 name="sPC" -numerical
InputField (13,32) "City"         (13,52) size=30 name="sC"
InputField (12,32) "Street"       (12,52) size=30 name="sSt"
Box (11,31) (18,83) "Start Point"

Pushbutton (17,47) "Calculate Best Route" process="calc_route.txt"

Table (20,1) (40,150) name="customer" title="Customer Locations"
Column "Customer No." size=10 name="kunnr"
Column "Name"         size=20 name="name1"
Column "Name 2"       size=20 name="name2"
Column "Street"       size=20 name="street"
Column "City"         size=20 name="city"
Column "Post Code"    size=10 name="postcode"
Column "Country"      size=8  name="country"
Column "Latitude"     size=15 name="latitude"
Column "Longitude"    size=15 name="longitude"

Table (41,1) (61,90) name="routetable" title="Route"
Column "Stop"      size=20 name="stop"
Column "Street"    size=20 name="street"
Column "City"      size=20 name="city"
Column "Post Code" size=10 name="postcode"
Column "Country"   size=8  name="country"

Die vier InputField-Anweisungen legen Felder für Straße, Stadt, Postleitzahl und Ländercode des Startpunkts an. Straße ist optional und wird im Script gesondert behandelt. Die Tabelle customer hat neun Spalten, darunter latitude und longitude, die aus einer Tabelle geoloc stammen. Die Tabelle routetable wird durch den .NET-Rückgabewert befüllt.

2. Das GuiXT Script (calc_route.txt)

Das InputScript wird durch den Pushbutton ausgelöst. Es legt die Tabellenvariablen an, führt den Datenbankzugriff durch, validiert den Startpunkt und ruft die .NET-Funktion auf.

GuiXT
// create table variable with column names
CreateTable V[customer] _
    kunnr name1 name2 street city postcode country latitude longitude

// build SQL join expression
Set V[table] "knvp join kna1 on kna1~kunnr = knvp~kunnr"
Set V[table] "&V[table] join adrc on adrc~addrnumber = kna1~adrnr"
Set V[table] _
    "&V[table] left outer join geoloc on geoloc~addrnumber = adrc~addrnumber"

// comma-separated field list
Set V[fields] "knvp~kunnr"
Set V[fields] "&V[fields],kna1~name1"
Set V[fields] "&V[fields],kna1~name2"
Set V[fields] "&V[fields],adrc~street"
Set V[fields] "&V[fields],adrc~city1"
Set V[fields] "&V[fields],adrc~post_code1"
Set V[fields] "&V[fields],adrc~country"
Set V[fields] "&V[fields],geoloc~latitude"
Set V[fields] "&V[fields],geoloc~longitude"

// WHERE condition (without keyword WHERE)
Set V[condition] "knvp~pernr = '00000005'"
Set V[condition] "&V[condition] and knvp~vkorg = '1010'"
Set V[condition] "&V[condition] and knvp~vtweg = '10'"

Set V[orderby] "kna1~name1"

// execute database select
Call "/guixt/dbselect" _
    export.table="table" _
    export.fields="fields" _
    export.condition="condition" _
    export.orderby="orderby" _
    import.values="customer"

if not Q[ok]
    Message "&V[_exception]"
endif

// validate mandatory start point fields (V[name=] means "is empty")
if V[sC=] or V[sPC=] or V[sCC=]
    Message "No startpoint given"
    leave
else
    // Street is optional
    if not V[sSt=]
        Set V[Start] "&V[sSt], &V[sC], &V[sPC], &V[sCC]"
    else
        Set V[Start] "&V[sC], &V[sPC], &V[sCC]"
    endif
endif

// create output table from a structure template
CreateStructure V[struct] stop street city postcode country
CreateTable V[routetable] include=V[struct]

// call .NET: table is serialized as JSON, result fills routetable
CallVB routetable = DataVB.RouteOptimizer.OptimizeDeliveries _
    "&V[Start]<|>&V[customer]"

2.1 Tabelle anlegen mit CreateTable

CreateTable V[customer] kunnr name1 ... legt eine leere Tabellenvariable mit den angegebenen Spalten an. Die Spaltennamen müssen mit den Spalten der GuiXT-Bildschirmtabelle übereinstimmen, damit GuiXT nach dem CallVB die Daten automatisch in die Anzeige überträgt.

2.2 Datenbankabfrage mit Call "/guixt/dbselect"

Der Befehl Call "/guixt/dbselect" führt einen generischen SQL-Select direkt auf der SAP-Datenbank aus. Die Parameter werden als Textvariablen übergeben:

  • export.table: Tabellenausdruck inklusive JOINs
  • export.fields: kommagetrennte Feldliste
  • export.condition: WHERE-Bedingung ohne das Schlüsselwort WHERE
  • export.orderby: Sortierfeld
  • import.values: Name der Zieltabellenvariablen, ohne V[...], GuiXT löst den Namen intern auf

Nach dem Aufruf enthält V[customer] alle Ergebniszeilen. Q[ok] zeigt Erfolg an; bei Fehler liefert V[_exception] die Meldung. Der left outer join mit GEOLOC stellt sicher, dass Kunden ohne Geokoordinaten trotzdem geliefert werden, fehlende Werte werden im .NET-Code per Geocoding nachgeschlagen.

2.3 Startpunkt validieren

Die Schreibweise V[name=] ist eine GuiXT-Abkürzung für "Variable ist leer". Mit if V[sC=] or V[sPC=] or V[sCC=] werden die Pflichtfelder geprüft. Straße ist optional, der Startpunkt-String wird je nach Eingabe mit vier oder drei Teilen zusammengebaut: "Hauptstr. 1, Frankfurt, 60311, DE" bzw. "Frankfurt, 60311, DE". Diese Unterscheidung wird im .NET-Code beim Aufteilen am Komma berücksichtigt.

2.4 Ausgabestruktur mit CreateStructure und CreateTable

CreateStructure V[struct] stop street city postcode country definiert eine benannte Felddefinition. CreateTable V[routetable] include=V[struct] erzeugt daraus eine leere Tabelle, ein praktischer Weg, eine Tabelle auf Basis einer Strukturvorlage zu erstellen, ohne die Felder ein zweites Mal aufzulisten.

2.5 CallVB: Tabellen an .NET übergeben und Ergebnis zurückladen

GuiXT
CallVB routetable = DataVB.RouteOptimizer.OptimizeDeliveries _
    "&V[Start]<|>&V[customer]"

Wie GuiXT Tabellen als JSON serialisiert:
Wird in einem String-Parameter ein Ausdruck der Form &V[tabellenname] verwendet und die Variable enthält eine Tabelle, serialisiert GuiXT sie automatisch als JSON-Array. Jede Zeile wird zu einem JSON-Objekt, die Spaltennamen werden zu den JSON-Schlüsseln. Beispiel für zwei Zeilen in V[customer]:

[{"kunnr":"1000","name1":"Müller GmbH","street":"Hauptstr. 1","latitude":"50.1109","longitude":"8.6821"},
{"kunnr":"1001","name1":"Schulz AG","street":"Bahnhofstr. 5","latitude":"","longitude":""}]

In diesem Beispiel werden zwei Werte in einem einzigen String-Parameter kombiniert: der Startpunkt-String und das JSON-Array der Kundentabelle. Als Trennzeichen dient <|>, eine Zeichenfolge die weder in Adressen noch in JSON vorkommt. Im .NET-Code wird am Separator gesplittet.

Rückgabe: JSON-Array wird automatisch zur GuiXT-Tabelle:
Gibt die .NET-Funktion einen JSON-Array-String zurück und steht links vom = eine Tabellenvariable, befüllt GuiXT die Zieltabelle automatisch: Jedes Objekt im Array wird zur Zeile, die JSON-Schlüssel werden den gleichnamigen Spalten zugeordnet. Fehlende Schlüssel bleiben leer, unbekannte werden ignoriert. Kein weiterer Script-Code ist nötig.

3. Der .NET-Code

Die Klasse RouteOptimizer im Namespace DataVB enthält drei Methoden: die öffentliche Einstiegsfunktion OptimizeDeliveries, die Geocodierung GeocodeAddress und die Routenoptimierung OptimizeRoute.

3.1 Einstiegspunkt: JSON empfangen und parsen

Die Methode empfängt den kombinierten String, trennt ihn am <|>-Separator und parst das JSON-Array der Kundentabelle mit System.Text.Json. Für den Startpunkt wird zunächst die Koordinate (0, 0) als Platzhalter eingetragen.

using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace DataVB
{
    public class RouteOptimizer
    {
        private const string NominatimUrl =
                "https://nominatim.openstreetmap.org/search";
        private const string ORSUrl =
                "https://api.openrouteservice.org/optimization";
        private const string OrsApiKey = "YOUR_ORS_API_KEY_HERE";

        public string OptimizeDeliveries(string inputJson)
        {
            System.Net.ServicePointManager.SecurityProtocol =
                    System.Net.SecurityProtocolType.Tls12;

            // GuiXT hat "&V[Start]<|>&V[customer]" zusammengesetzt.
            // Index 0 = Startpunkt-String, Index 1 = JSON-Array der Tabelle.
            var sep = new string[] { "<|>" };
            string[] splitParts = inputJson.Split(
                    sep, StringSplitOptions.None);

            var addresses = new List<string>();
            var coordinates = new List<(double lat, double lon)>();

            // Startpunkt eintragen, Koordinaten kommen per Geocoding
            addresses.Add(splitParts[0]);
            coordinates.Add((0, 0));

            // JSON-Array der Kundentabelle durchlaufen
            JsonDocument doc = JsonDocument.Parse(splitParts[1]);
            foreach (JsonElement el in doc.RootElement.EnumerateArray())
            {
                // .Replace wegen SAP-Dezimalformat (Komma statt Punkt)
                double lat = double.Parse(
                        el.GetProperty("latitude").GetString()
                          .Replace(",", "."),
                        CultureInfo.InvariantCulture);
                double lon = double.Parse(
                        el.GetProperty("longitude").GetString()
                          .Replace(",", "."),
                        CultureInfo.InvariantCulture);
                string street = el.GetProperty("street").GetString();
                string city = el.GetProperty("city").GetString();
                string postcode = el.GetProperty("postcode").GetString();
                string country = el.GetProperty("country").GetString();
                addresses.Add(
                        $"{street}, {city}, {postcode}, {country}");
                coordinates.Add((lat, lon));
            }
        }
    }
}

Beim Parsen der Koordinaten wird .Replace(",", ".") angewendet. Abhängig von den SAP-Systemeinstellungen kann GuiXT Dezimalzahlen mit Komma serialisieren. CultureInfo.InvariantCulture erzwingt anschließend die Interpretation des Punktes als Dezimalzeichen.

3.2 Geocodierung fehlender Koordinaten (Nominatim API)

Kunden ohne Geokoordinaten (Latitude = 0 und Longitude = 0) werden über die Nominatim-API von OpenStreetMap geocodiert. Die Thread.Sleep(1100)-Pause hält das Rate-Limit ein (max. 1 Anfrage/Sekunde). Bei keinem Treffer wird ein Fallback ohne Straße versucht. Da GuiXT .NET-Funktionen synchron ausführt, wird die async Methode über einen synchronen Wrapper mit GetAwaiter().GetResult() aufgerufen.

        // Alle Adressen ohne Koordinaten geocodieren
        for (int i = 0; i < addresses.Count; i++)
        {
            if (coordinates[i].lat == 0 && coordinates[i].lon == 0)
                coordinates[i] = GeocodeAddressSync(addresses[i]);
            Thread.Sleep(1100);
        }

        private (double lat, double lon) GeocodeAddressSync(
                string address)
        {
            return GeocodeAddress(address).GetAwaiter().GetResult();
        }

        private async Task<(double lat, double lon)> GeocodeAddress(
                string address)
        {
            using (var client = new HttpClient())
            {
                client.DefaultRequestHeaders.Add(
                        "User-Agent", "SAPRoutingDemo/1.0");

                string q = Uri.EscapeDataString(address);
                string url =
                        $"{NominatimUrl}?q={q}&format=json&limit=1";
                string response = await client.GetStringAsync(url);

                // Fallback: Suche ohne Straße wenn kein Ergebnis
                if (response == "[]")
                {
                    var parts = address.Split(',');
                    string fallback =
                        $"{parts[1].Trim()}, " +
                        $"{parts[2].Trim()}, " +
                        parts[3].Trim();
                    string fq = Uri.EscapeDataString(fallback);
                    url = $"{NominatimUrl}?q={fq}" +
                          "&format=json&limit=1";
                    response = await client.GetStringAsync(url);
                    Thread.Sleep(1100);
                }

                JsonDocument doc = JsonDocument.Parse(response);
                double lat = double.Parse(
                        doc.RootElement[0].GetProperty("lat")
                           .GetString(),
                        CultureInfo.InvariantCulture);
                double lon = double.Parse(
                        doc.RootElement[0].GetProperty("lon")
                           .GetString(),
                        CultureInfo.InvariantCulture);
                return (lat, lon);
            }
        }

3.3 Routenoptimierung über die OpenRouteService API

OptimizeRoute baut einen JSON-Request für die ORS Optimization API auf. Alle Kundenadressen (ab Index 1) werden als "Jobs" übergeben, der Startpunkt (Index 0) als Fahrzeug-Startposition. Die API antwortet mit einer optimierten Reihenfolge der Job-IDs in routes[0].steps. Einträge vom Typ "job" sind Kundenstopps, "start" und "end" werden übersprungen.

Wichtig: ORS erwartet Koordinaten im Format [lon, lat], Längengrad zuerst. Beim Konvertieren von double-Werten in Strings muss CultureInfo.InvariantCulture verwendet werden, damit der Dezimalpunkt (nicht Komma) ausgegeben wird.

        private List<int> OptimizeRouteSync(
                List<(double lat, double lon)> stops)
        {
            return OptimizeRoute(stops).GetAwaiter().GetResult();
        }

        private async Task<List<int>> OptimizeRoute(
                List<(double lat, double lon)> stops)
        {
            using (var client = new HttpClient())
            {
                client.DefaultRequestHeaders.Add(
                        "Authorization", "Bearer " + OrsApiKey);

                // Jobs-Array: jede Kundenadresse wird ein Job mit Index-ID
                // ORS-Koordinatenformat: [lon, lat] (Längengrad zuerst!)
                var jobs = new StringBuilder();
                for (int i = 0; i < stops.Count; i++)
                {
                    string sLon = stops[i].lon.ToString(
                            CultureInfo.InvariantCulture);
                    string sLat = stops[i].lat.ToString(
                            CultureInfo.InvariantCulture);
                    if (i > 0) jobs.Append(",");
                    jobs.Append(
                        $"{{\"id\":{i}," +
                        $"\"location\":[{sLon},{sLat}]}}");
                }

                // Fahrzeug-Startposition aus stops[0] (= Vertreter-Startpunkt)
                string vLon = stops[0].lon.ToString(
                        CultureInfo.InvariantCulture);
                string vLat = stops[0].lat.ToString(
                        CultureInfo.InvariantCulture);
                string body =
                    "{\"jobs\":[" + jobs + "]," +
                    "\"vehicles\":[{" +
                    "\"id\":1," +
                    "\"profile\":\"driving-car\"," +
                    "\"start\":[" + vLon + "," + vLat + "]}]}";

                var content = new StringContent(
                        body, Encoding.UTF8, "application/json");
                var httpResp = await client.PostAsync(ORSUrl, content);
                string response =
                        await httpResp.Content.ReadAsStringAsync();

                // ORS liefert optimierte Reihenfolge in routes[0].steps
                JsonDocument doc = JsonDocument.Parse(response);
                JsonElement steps = doc.RootElement
                        .GetProperty("routes")[0]
                        .GetProperty("steps");

                var optimizedOrder = new List<int>();
                foreach (JsonElement step in steps.EnumerateArray())
                {
                    if (step.GetProperty("type").GetString() == "job")
                        optimizedOrder.Add(
                                step.GetProperty("job").GetInt32());
                }

                return optimizedOrder;
            }
        }

3.4 Ergebnis aufbauen und als JSON an GuiXT zurückgeben

Die vom ORS gelieferte optimierte Job-Reihenfolge wird als JSON-Array zusammengebaut. Die Feldnamen im JSON (stop, street, city, postcode, country) müssen exakt mit den Spaltennamen der GuiXT-Zieltabelle routetable übereinstimmen, GuiXT befüllt die Tabelle dann automatisch. Eine Adresse mit Straße hat vier Teile nach dem Split am Komma, eine ohne Straße nur drei.

            var optimizedOrder = OptimizeRouteSync(coordinates);

            var result = new StringBuilder();
            result.Append("[");
            for (int i = 0; i < optimizedOrder.Count; i++)
            {
                if (i > 0) result.Append(",");
                var parts = addresses[optimizedOrder[i]].Split(',');
                string street, city, postcode, country;

                // 4 Teile = mit Straße, 3 Teile = ohne Straße
                if (parts.Length == 4)
                {
                    street = parts[0].Trim();
                    city = parts[1].Trim();
                    postcode = parts[2].Trim();
                    country = parts[3].Trim();
                }
                else
                {
                    street = "";
                    city = parts[0].Trim();
                    postcode = parts[1].Trim();
                    country = parts[2].Trim();
                }

                // Feldnamen müssen den Spaltennamen von routetable entsprechen
                string obj =
                    $"{{\"stop\":{i + 1}," +
                    $"\"street\":\"{street}\"," +
                    $"\"city\":\"{city}\"," +
                    $"\"postcode\":\"{postcode}\"," +
                    $"\"country\":\"{country}\"}}";
                result.Append(obj);
            }
            result.Append("]");
            return result.ToString();

Das zurückgegebene JSON hat beispielsweise folgendes Format:

[{"stop":1,"street":"Hauptstr. 1","city":"Frankfurt", "postcode":"60311","country":"DE"},
{"stop":2,"street":"Bahnhofstr. 5","city":"Darmstadt", "postcode":"64283","country":"DE"}]

Schlüsselprinzip: Die JSON-Schlüsselnamen müssen exakt mit den Spaltennamen der GuiXT-Zieltabelle übereinstimmen, Groß-/Kleinschreibung eingeschlossen. GuiXT erzeugt für jedes Objekt im Array eine Tabellenzeile und ordnet die Werte den gleichnamigen Spalten zu.

Routenoptimierer-Skripte herunterladen (ZIP)