diff --git a/TrafagSalesExporter/.tmp_sap_probe/Program.cs b/TrafagSalesExporter/.tmp_sap_probe/Program.cs
new file mode 100644
index 0000000..67f398e
--- /dev/null
+++ b/TrafagSalesExporter/.tmp_sap_probe/Program.cs
@@ -0,0 +1,36 @@
+using System.Net.Http.Headers;
+using System.Text;
+using Microsoft.Data.Sqlite;
+
+var conn = new SqliteConnection(@"Data Source=C:\Users\koi\source\repos\Ai\TrafagSalesExporter\trafag_exporter.db");
+await conn.OpenAsync();
+string sapUsername = "", sapPassword = "";
+var cmd = conn.CreateCommand();
+cmd.CommandText = "select SapUsername, SapPassword from ExportSettings limit 1";
+using (var r = await cmd.ExecuteReaderAsync())
+{
+ if (await r.ReadAsync())
+ {
+ sapUsername = r.IsDBNull(0) ? "" : r.GetString(0);
+ sapPassword = r.IsDBNull(1) ? "" : r.GetString(1);
+ }
+}
+if (string.IsNullOrWhiteSpace(sapUsername) || string.IsNullOrWhiteSpace(sapPassword)) throw new Exception("Central SAP credentials missing");
+var serviceUrl = @"http://travt762.sap.trafag.com:8000/sap/opu/odata/sap/ZPOWERBI_EINKAUF_SRV/";
+using var client = new HttpClient();
+client.Timeout = TimeSpan.FromSeconds(20);
+client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{sapUsername}:{sapPassword}")));
+foreach (var url in new[]{ serviceUrl, serviceUrl + "" })
+{
+ Console.WriteLine($"URL|{url}");
+ using var response = await client.GetAsync(url);
+ Console.WriteLine($"STATUS|{(int)response.StatusCode}|{response.ReasonPhrase}");
+ foreach (var header in response.Headers)
+ Console.WriteLine($"HEADER|{header.Key}|{string.Join(",", header.Value)}");
+ foreach (var header in response.Content.Headers)
+ Console.WriteLine($"HEADER|{header.Key}|{string.Join(",", header.Value)}");
+ var body = await response.Content.ReadAsStringAsync();
+ Console.WriteLine("BODY_START");
+ Console.WriteLine(body.Length > 5000 ? body[..5000] : body);
+ Console.WriteLine("BODY_END");
+}
diff --git a/TrafagSalesExporter/.tmp_sap_probe/SapProbe.csproj b/TrafagSalesExporter/.tmp_sap_probe/SapProbe.csproj
new file mode 100644
index 0000000..2bdceb6
--- /dev/null
+++ b/TrafagSalesExporter/.tmp_sap_probe/SapProbe.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
diff --git a/TrafagSalesExporter/Components/Pages/Settings.razor b/TrafagSalesExporter/Components/Pages/Settings.razor
index bedc436..92f5f41 100644
--- a/TrafagSalesExporter/Components/Pages/Settings.razor
+++ b/TrafagSalesExporter/Components/Pages/Settings.razor
@@ -7,6 +7,7 @@
@inject ISharePointUploadService SpService
@inject TimerBackgroundService TimerService
@inject IHanaQueryService HanaService
+@inject ISapGatewayService SapGatewayService
@inject ISnackbar Snackbar
Settings
@@ -240,6 +241,17 @@
}
private async Task TestCentralCredentials(string sourceSystem)
+ {
+ if (sourceSystem == "SAP")
+ {
+ await TestCentralSapCredentials();
+ return;
+ }
+
+ await TestCentralHanaCredentials(sourceSystem);
+ }
+
+ private async Task TestCentralHanaCredentials(string sourceSystem)
{
if (!_testingSystems.Add(sourceSystem))
return;
@@ -297,6 +309,49 @@
}
}
+ private async Task TestCentralSapCredentials()
+ {
+ const string sourceSystem = "SAP";
+ if (!_testingSystems.Add(sourceSystem))
+ return;
+
+ try
+ {
+ var username = GetCentralUsername(sourceSystem);
+ var password = GetCentralPassword(sourceSystem);
+
+ if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
+ {
+ Snackbar.Add("Für SAP sind keine zentralen Gateway-Zugangsdaten gepflegt.", Severity.Warning);
+ return;
+ }
+
+ using var db = await DbFactory.CreateDbContextAsync();
+ var site = await db.Sites
+ .Where(s => (string.IsNullOrWhiteSpace(s.SourceSystem) ? "SAP" : s.SourceSystem) == sourceSystem
+ && !string.IsNullOrWhiteSpace(s.SapServiceUrl))
+ .OrderBy(s => s.Land)
+ .FirstOrDefaultAsync();
+
+ if (site is null)
+ {
+ Snackbar.Add("Kein SAP-Standort mit Service URL gefunden.", Severity.Warning);
+ return;
+ }
+
+ await SapGatewayService.TestConnectionAsync(site.SapServiceUrl, username.Trim(), password.Trim());
+ Snackbar.Add($"SAP: Gateway-Verbindung erfolgreich über Standort '{site.Land}'.", Severity.Success);
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add($"SAP: {ex.Message}", Severity.Error);
+ }
+ finally
+ {
+ _testingSystems.Remove(sourceSystem);
+ }
+ }
+
private string GetCentralUsername(string sourceSystem) => sourceSystem switch
{
"BI1" => _exportSettings.Bi1Username,
diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor
index 87012a4..ef57040 100644
--- a/TrafagSalesExporter/Components/Pages/Standorte.razor
+++ b/TrafagSalesExporter/Components/Pages/Standorte.razor
@@ -1,9 +1,12 @@
@page "/standorte"
@using Microsoft.EntityFrameworkCore
+@using System.Text.Json
@using TrafagSalesExporter.Data
+@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IDbContextFactory DbFactory
@inject IHanaQueryService HanaService
+@inject ISapGatewayService SapGatewayService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -71,7 +74,7 @@
TSC
Schema
Quellsystem
- Host
+ Quelle
Aktiv
Aktionen
@@ -80,7 +83,7 @@
@context.TSC
@context.Schema
@context.SourceSystem
- @GetServerNode(context.HanaServer)
+ @GetConnectionTarget(context)
@if (context.IsActive)
{
@@ -122,8 +125,8 @@
HelperText="Optional, z.B. sslCryptoProvider=openssl;communicationTimeout=0" />
- Abbrechen
- Speichern
+ Abbrechen
+ Speichern
@@ -138,7 +141,7 @@
@foreach (var system in _sourceSystems)
{
- @system
+ @system
}
- HANA-Verbindung
-
- Host, Port und technische HANA-Parameter kommen von dieser Verbindung. Username und Password hier dienen nur noch als Fallback für bestehende Einträge.
-
-
-
-
-
-
-
-
-
-
+ @if (IsSapSite())
+ {
+ SAP Gateway
+
+ Die Service-URL zeigt auf den OData-Service. Die verfügbaren Entity Sets werden nur per Knopfdruck aktualisiert und lokal zwischengespeichert.
+
+
+
+
+ @if (_refreshingSapEntitySets)
+ {
+
+ @("Lade...")
+ }
+ else
+ {
+ @("Quellen refreshen")
+ }
+
+ @if (_editingSite.SapEntitySetsRefreshedAtUtc.HasValue)
+ {
+
+ Letzter Refresh: @_editingSite.SapEntitySetsRefreshedAtUtc.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
+
+ }
+
+
+ @foreach (var entitySet in _sapEntitySetsCache)
+ {
+ @entitySet
+ }
+
+ }
+ else
+ {
+ HANA-Verbindung
+
+ Host, Port und technische HANA-Parameter kommen von dieser Verbindung. Username und Password hier dienen nur noch als Fallback für bestehende Einträge.
+
+
+
+
+
+
+
+
+
+
+ }
- Abbrechen
- Speichern
+ Abbrechen
+ Speichern
@@ -180,11 +221,15 @@
private readonly Dictionary _connectionStatus = new();
private List _servers = new();
private List _sites = new();
+ private List _sapEntitySetsCache = [];
private HanaServer _editingServer = new();
private Site _editingSite = new();
private HanaServer _editingSiteServer = new();
private bool _serverDialogVisible;
private bool _siteDialogVisible;
+ private bool _refreshingSapEntitySets;
+ private bool _savingServer;
+ private bool _savingSite;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
protected override async Task OnInitializedAsync()
@@ -213,6 +258,12 @@
private async Task SaveServer()
{
+ if (_savingServer)
+ return;
+
+ _savingServer = true;
+ try
+ {
using var db = await DbFactory.CreateDbContextAsync();
if (_editingServer.Id == 0)
{
@@ -239,6 +290,11 @@
_serverDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Server gespeichert", Severity.Success);
+ }
+ finally
+ {
+ _savingServer = false;
+ }
}
private async Task DeleteServer(HanaServer server)
@@ -294,6 +350,7 @@
SourceSystem = "SAP",
HanaServerId = 0
};
+ _sapEntitySetsCache = [];
_editingSiteServer = CreateDefaultSiteServer();
_siteDialogVisible = true;
}
@@ -310,8 +367,13 @@
SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem,
UsernameOverride = site.UsernameOverride,
PasswordOverride = site.PasswordOverride,
+ SapServiceUrl = site.SapServiceUrl,
+ SapEntitySet = site.SapEntitySet,
+ SapEntitySetsCache = site.SapEntitySetsCache,
+ SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
IsActive = site.IsActive
};
+ _sapEntitySetsCache = ParseSapEntitySets(site.SapEntitySetsCache);
_editingSiteServer = site.HanaServer is null
? CreateDefaultSiteServer(site)
: CloneServer(site.HanaServer);
@@ -320,34 +382,54 @@
private async Task SaveSite()
{
- using var db = await DbFactory.CreateDbContextAsync();
- var serverId = await SaveOrCreateSiteServerAsync(db);
+ if (_savingSite)
+ return;
- if (_editingSite.Id == 0)
+ _savingSite = true;
+ try
{
+ using var db = await DbFactory.CreateDbContextAsync();
+ var serverId = IsSapSite() ? (int?)null : await SaveOrCreateSiteServerAsync(db);
_editingSite.HanaServerId = serverId;
- db.Sites.Add(_editingSite);
- }
- else
- {
- var existing = await db.Sites.FindAsync(_editingSite.Id);
- if (existing is not null)
- {
- existing.HanaServerId = serverId;
- existing.Schema = _editingSite.Schema;
- existing.TSC = _editingSite.TSC;
- existing.Land = _editingSite.Land;
- existing.SourceSystem = _editingSite.SourceSystem;
- existing.UsernameOverride = _editingSite.UsernameOverride;
- existing.PasswordOverride = _editingSite.PasswordOverride;
- existing.IsActive = _editingSite.IsActive;
- }
- }
+ _editingSite.SapEntitySetsCache = SerializeSapEntitySets(_sapEntitySetsCache);
- await db.SaveChangesAsync();
- _siteDialogVisible = false;
- await LoadDataAsync();
- Snackbar.Add("Standort gespeichert", Severity.Success);
+ if (_editingSite.Id == 0)
+ {
+ db.Sites.Add(_editingSite);
+ }
+ else
+ {
+ var existing = await db.Sites.FindAsync(_editingSite.Id);
+ if (existing is not null)
+ {
+ existing.HanaServerId = serverId;
+ existing.Schema = _editingSite.Schema;
+ existing.TSC = _editingSite.TSC;
+ existing.Land = _editingSite.Land;
+ existing.SourceSystem = _editingSite.SourceSystem;
+ existing.UsernameOverride = _editingSite.UsernameOverride;
+ existing.PasswordOverride = _editingSite.PasswordOverride;
+ existing.SapServiceUrl = _editingSite.SapServiceUrl;
+ existing.SapEntitySet = _editingSite.SapEntitySet;
+ existing.SapEntitySetsCache = _editingSite.SapEntitySetsCache;
+ existing.SapEntitySetsRefreshedAtUtc = _editingSite.SapEntitySetsRefreshedAtUtc;
+ existing.IsActive = _editingSite.IsActive;
+ }
+ }
+
+ await db.SaveChangesAsync();
+ _siteDialogVisible = false;
+ await LoadDataAsync();
+ Snackbar.Add("Standort gespeichert", Severity.Success);
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add($"Speichern fehlgeschlagen: {ex.Message}", Severity.Error);
+ }
+ finally
+ {
+ _savingSite = false;
+ }
}
private async Task DeleteSite(Site site)
@@ -379,6 +461,15 @@
return server.Host.Contains(':', StringComparison.Ordinal) ? server.Host : $"{server.Host}:{server.Port}";
}
+ private static string GetConnectionTarget(Site site)
+ {
+ var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
+ if (string.Equals(sourceSystem, "SAP", StringComparison.OrdinalIgnoreCase))
+ return string.IsNullOrWhiteSpace(site.SapServiceUrl) ? "-" : site.SapServiceUrl;
+
+ return GetServerNode(site.HanaServer);
+ }
+
private HanaServer CreateDefaultSiteServer(Site? site = null)
{
var label = !string.IsNullOrWhiteSpace(site?.Land) ? site!.Land : site?.TSC;
@@ -416,6 +507,8 @@
: _editingSiteServer.Name.Trim();
_editingSite.UsernameOverride = _editingSite.UsernameOverride.Trim();
_editingSite.PasswordOverride = _editingSite.PasswordOverride.Trim();
+ _editingSite.SapServiceUrl = _editingSite.SapServiceUrl.Trim();
+ _editingSite.SapEntitySet = _editingSite.SapEntitySet.Trim();
_editingSiteServer.Host = _editingSiteServer.Host.Trim();
_editingSiteServer.Username = _editingSiteServer.Username.Trim();
_editingSiteServer.DatabaseName = _editingSiteServer.DatabaseName.Trim();
@@ -461,4 +554,82 @@
await db.SaveChangesAsync();
return existingServer.Id;
}
+
+ private bool IsSapSite() => string.Equals(_editingSite.SourceSystem, "SAP", StringComparison.OrdinalIgnoreCase);
+
+ private async Task RefreshSapEntitySets()
+ {
+ if (_refreshingSapEntitySets)
+ return;
+
+ _refreshingSapEntitySets = true;
+ try
+ {
+ if (string.IsNullOrWhiteSpace(_editingSite.SapServiceUrl))
+ throw new InvalidOperationException("SAP Service URL muss gesetzt sein.");
+
+ using var db = await DbFactory.CreateDbContextAsync();
+ var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new();
+ var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride) ? settings.SapUsername : _editingSite.UsernameOverride;
+ var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride) ? settings.SapPassword : _editingSite.PasswordOverride;
+
+ if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
+ throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
+
+ var entitySets = await SapGatewayService.GetEntitySetsAsync(_editingSite.SapServiceUrl, username.Trim(), password.Trim());
+ _sapEntitySetsCache = entitySets;
+ _editingSite.SapEntitySetsCache = SerializeSapEntitySets(entitySets);
+ _editingSite.SapEntitySetsRefreshedAtUtc = DateTime.UtcNow;
+
+ if (!string.IsNullOrWhiteSpace(_editingSite.SapEntitySet) &&
+ !_sapEntitySetsCache.Contains(_editingSite.SapEntitySet, StringComparer.OrdinalIgnoreCase))
+ {
+ _editingSite.SapEntitySet = string.Empty;
+ }
+
+ Snackbar.Add($"{entitySets.Count} SAP Entity Sets geladen.", Severity.Success);
+ }
+ catch (Exception ex)
+ {
+ Snackbar.Add(ex.Message, Severity.Error);
+ }
+ finally
+ {
+ _refreshingSapEntitySets = false;
+ }
+ }
+
+ private void CloseServerDialog()
+ {
+ if (_savingServer)
+ return;
+
+ _serverDialogVisible = false;
+ }
+
+ private void CloseSiteDialog()
+ {
+ if (_savingSite || _refreshingSapEntitySets)
+ return;
+
+ _siteDialogVisible = false;
+ }
+
+ private static List ParseSapEntitySets(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return [];
+
+ try
+ {
+ return JsonSerializer.Deserialize>(json) ?? [];
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ private static string SerializeSapEntitySets(List entitySets)
+ => JsonSerializer.Serialize(entitySets);
}
diff --git a/TrafagSalesExporter/Models/Site.cs b/TrafagSalesExporter/Models/Site.cs
index d1f5feb..f953d80 100644
--- a/TrafagSalesExporter/Models/Site.cs
+++ b/TrafagSalesExporter/Models/Site.cs
@@ -7,7 +7,7 @@ public class Site
{
public int Id { get; set; }
- public int HanaServerId { get; set; }
+ public int? HanaServerId { get; set; }
[ForeignKey(nameof(HanaServerId))]
public HanaServer? HanaServer { get; set; }
@@ -28,5 +28,13 @@ public class Site
public string PasswordOverride { get; set; } = string.Empty;
+ public string SapServiceUrl { get; set; } = string.Empty;
+
+ public string SapEntitySet { get; set; } = string.Empty;
+
+ public string SapEntitySetsCache { get; set; } = string.Empty;
+
+ public DateTime? SapEntitySetsRefreshedAtUtc { get; set; }
+
public bool IsActive { get; set; } = true;
}
diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs
index 288d64f..1a7dd0a 100644
--- a/TrafagSalesExporter/Program.cs
+++ b/TrafagSalesExporter/Program.cs
@@ -16,6 +16,7 @@ builder.Services.AddDbContextFactory(options =>
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.cs
index 0839763..9fe48e6 100644
--- a/TrafagSalesExporter/Services/DatabaseInitializationService.cs
+++ b/TrafagSalesExporter/Services/DatabaseInitializationService.cs
@@ -24,6 +24,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService
private static void EnsureSchema(AppDbContext db)
{
+ EnsureSitesTableSupportsOptionalHanaServer(db);
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0");
@@ -31,6 +32,10 @@ public class DatabaseInitializationService : IDatabaseInitializationService
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "PasswordOverride", "TEXT NOT NULL DEFAULT ''");
+ AddColumnIfMissing(db, "Sites", "SapServiceUrl", "TEXT NOT NULL DEFAULT ''");
+ AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''");
+ AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "TEXT NOT NULL DEFAULT ''");
+ AddColumnIfMissing(db, "Sites", "SapEntitySetsRefreshedAtUtc", "TEXT NULL");
AddColumnIfMissing(db, "ExportSettings", "SapUsername", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "SapPassword", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "Bi1Username", "TEXT NOT NULL DEFAULT ''");
@@ -40,6 +45,104 @@ public class DatabaseInitializationService : IDatabaseInitializationService
EnsureTransformationTable(db);
}
+ private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db)
+ {
+ var conn = db.Database.GetDbConnection();
+ if (conn.State != ConnectionState.Open)
+ conn.Open();
+
+ var hanaServerIdIsRequired = false;
+ {
+ using var pragma = conn.CreateCommand();
+ pragma.CommandText = "PRAGMA table_info(Sites)";
+ using var reader = pragma.ExecuteReader();
+
+ while (reader.Read())
+ {
+ if (string.Equals(reader["name"]?.ToString(), "HanaServerId", StringComparison.OrdinalIgnoreCase))
+ {
+ hanaServerIdIsRequired = Convert.ToInt32(reader["notnull"]) == 1;
+ break;
+ }
+ }
+ }
+
+ if (!hanaServerIdIsRequired)
+ return;
+
+ using var disableFk = conn.CreateCommand();
+ disableFk.CommandText = "PRAGMA foreign_keys = OFF;";
+ disableFk.ExecuteNonQuery();
+
+ using var transaction = conn.BeginTransaction();
+
+ using (var rename = conn.CreateCommand())
+ {
+ rename.Transaction = transaction;
+ rename.CommandText = "ALTER TABLE Sites RENAME TO Sites_old;";
+ rename.ExecuteNonQuery();
+ }
+
+ using (var create = conn.CreateCommand())
+ {
+ create.Transaction = transaction;
+ create.CommandText = @"
+CREATE TABLE Sites (
+ Id INTEGER NOT NULL CONSTRAINT PK_Sites PRIMARY KEY AUTOINCREMENT,
+ HanaServerId INTEGER NULL,
+ Schema TEXT NOT NULL,
+ TSC TEXT NOT NULL,
+ Land TEXT NOT NULL,
+ SourceSystem TEXT NOT NULL DEFAULT 'SAP',
+ UsernameOverride TEXT NOT NULL DEFAULT '',
+ PasswordOverride TEXT NOT NULL DEFAULT '',
+ SapServiceUrl TEXT NOT NULL DEFAULT '',
+ SapEntitySet TEXT NOT NULL DEFAULT '',
+ SapEntitySetsCache TEXT NOT NULL DEFAULT '',
+ SapEntitySetsRefreshedAtUtc TEXT NULL,
+ IsActive INTEGER NOT NULL,
+ CONSTRAINT FK_Sites_HanaServers_HanaServerId FOREIGN KEY (HanaServerId) REFERENCES HanaServers (Id)
+);";
+ create.ExecuteNonQuery();
+ }
+
+ using (var copy = conn.CreateCommand())
+ {
+ copy.Transaction = transaction;
+ copy.CommandText = @"
+INSERT INTO Sites (
+ Id, HanaServerId, Schema, TSC, Land, SourceSystem,
+ UsernameOverride, PasswordOverride, SapServiceUrl, SapEntitySet,
+ SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive
+)
+SELECT
+ Id, HanaServerId, Schema, TSC, Land,
+ COALESCE(SourceSystem, 'SAP'),
+ COALESCE(UsernameOverride, ''),
+ COALESCE(PasswordOverride, ''),
+ COALESCE(SapServiceUrl, ''),
+ COALESCE(SapEntitySet, ''),
+ COALESCE(SapEntitySetsCache, ''),
+ SapEntitySetsRefreshedAtUtc,
+ IsActive
+FROM Sites_old;";
+ copy.ExecuteNonQuery();
+ }
+
+ using (var drop = conn.CreateCommand())
+ {
+ drop.Transaction = transaction;
+ drop.CommandText = "DROP TABLE Sites_old;";
+ drop.ExecuteNonQuery();
+ }
+
+ transaction.Commit();
+
+ using var enableFk = conn.CreateCommand();
+ enableFk.CommandText = "PRAGMA foreign_keys = ON;";
+ enableFk.ExecuteNonQuery();
+ }
+
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
{
var conn = db.Database.GetDbConnection();
diff --git a/TrafagSalesExporter/Services/ExcelExportService.cs b/TrafagSalesExporter/Services/ExcelExportService.cs
index 2379675..fe12bb5 100644
--- a/TrafagSalesExporter/Services/ExcelExportService.cs
+++ b/TrafagSalesExporter/Services/ExcelExportService.cs
@@ -23,6 +23,16 @@ public class ExcelExportService : IExcelExportService
return fullPath;
}
+ public string CreateGenericExcelFile(string outputDirectory, string filePrefix, DateTime fileDate, string worksheetName, IReadOnlyList> rows)
+ {
+ Directory.CreateDirectory(outputDirectory);
+ var safePrefix = string.IsNullOrWhiteSpace(filePrefix) ? "Export" : filePrefix.Trim();
+ var fileName = $"{safePrefix}_{fileDate:yyyy-MM-dd}.xlsx";
+ var fullPath = Path.Combine(outputDirectory, fileName);
+ WriteGenericWorkbook(fullPath, worksheetName, rows);
+ return fullPath;
+ }
+
private static void WriteWorkbook(string fullPath, List records)
{
using var workbook = new XLWorkbook();
@@ -99,4 +109,35 @@ public class ExcelExportService : IExcelExportService
ws.Columns().AdjustToContents();
workbook.SaveAs(fullPath);
}
+
+ private static void WriteGenericWorkbook(string fullPath, string worksheetName, IReadOnlyList> rows)
+ {
+ using var workbook = new XLWorkbook();
+ var sheetName = string.IsNullOrWhiteSpace(worksheetName) ? "Export" : worksheetName.Trim();
+ var ws = workbook.Worksheets.Add(sheetName.Length > 31 ? sheetName[..31] : sheetName);
+
+ var headers = rows
+ .SelectMany(r => r.Keys)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ for (var i = 0; i < headers.Count; i++)
+ {
+ ws.Cell(1, i + 1).Value = headers[i];
+ ws.Cell(1, i + 1).Style.Font.Bold = true;
+ }
+
+ for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++)
+ {
+ var row = rows[rowIndex];
+ for (var colIndex = 0; colIndex < headers.Count; colIndex++)
+ {
+ row.TryGetValue(headers[colIndex], out var value);
+ ws.Cell(rowIndex + 2, colIndex + 1).Value = value?.ToString() ?? string.Empty;
+ }
+ }
+
+ ws.Columns().AdjustToContents();
+ workbook.SaveAs(fullPath);
+ }
}
diff --git a/TrafagSalesExporter/Services/ExportOrchestrationService.cs b/TrafagSalesExporter/Services/ExportOrchestrationService.cs
index 14a3893..b31c84a 100644
--- a/TrafagSalesExporter/Services/ExportOrchestrationService.cs
+++ b/TrafagSalesExporter/Services/ExportOrchestrationService.cs
@@ -70,7 +70,6 @@ public class ExportOrchestrationService
private async Task ExportSiteAsync(Site site)
{
- if (site.HanaServer is null) return null;
SiteExportResult? result = null;
lock (_lock)
diff --git a/TrafagSalesExporter/Services/IExcelExportService.cs b/TrafagSalesExporter/Services/IExcelExportService.cs
index b7dbcb1..5444422 100644
--- a/TrafagSalesExporter/Services/IExcelExportService.cs
+++ b/TrafagSalesExporter/Services/IExcelExportService.cs
@@ -6,4 +6,5 @@ public interface IExcelExportService
{
string CreateExcelFile(string outputDirectory, string tsc, DateTime fileDate, List records);
string CreateConsolidatedExcelFile(string outputDirectory, DateTime fileDate, List records);
+ string CreateGenericExcelFile(string outputDirectory, string filePrefix, DateTime fileDate, string worksheetName, IReadOnlyList> rows);
}
diff --git a/TrafagSalesExporter/Services/ISapGatewayService.cs b/TrafagSalesExporter/Services/ISapGatewayService.cs
new file mode 100644
index 0000000..3041ab3
--- /dev/null
+++ b/TrafagSalesExporter/Services/ISapGatewayService.cs
@@ -0,0 +1,8 @@
+namespace TrafagSalesExporter.Services;
+
+public interface ISapGatewayService
+{
+ Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
+ Task> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
+ Task>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
+}
diff --git a/TrafagSalesExporter/Services/SapGatewayService.cs b/TrafagSalesExporter/Services/SapGatewayService.cs
new file mode 100644
index 0000000..c53291c
--- /dev/null
+++ b/TrafagSalesExporter/Services/SapGatewayService.cs
@@ -0,0 +1,140 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Xml.Linq;
+
+namespace TrafagSalesExporter.Services;
+
+public class SapGatewayService : ISapGatewayService
+{
+ private static readonly XNamespace AppNs = "http://www.w3.org/2007/app";
+ private static readonly XNamespace EdmNs = "http://docs.oasis-open.org/odata/ns/edm";
+
+ public async Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default)
+ {
+ using var client = CreateClient(username, password);
+ using var response = await client.GetAsync(BuildServiceUri(serviceUrl), cancellationToken);
+ response.EnsureSuccessStatusCode();
+ }
+
+ public async Task> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default)
+ {
+ using var client = CreateClient(username, password);
+ var baseUrl = BuildServiceUri(serviceUrl);
+
+ var entitySets = await TryReadEntitySetsFromServiceRootAsync(client, baseUrl, cancellationToken);
+ if (entitySets.Count > 0)
+ return entitySets;
+
+ return await ReadEntitySetsFromMetadataAsync(client, baseUrl, cancellationToken);
+ }
+
+ public async Task>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default)
+ {
+ using var client = CreateClient(username, password);
+ var requestUrl = $"{BuildServiceUri(serviceUrl)}{entitySet}?$format=json";
+ using var response = await client.GetAsync(requestUrl, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync(cancellationToken);
+ using var document = JsonDocument.Parse(json);
+ if (!document.RootElement.TryGetProperty("d", out var dNode))
+ return [];
+
+ if (!dNode.TryGetProperty("results", out var resultsNode) || resultsNode.ValueKind != JsonValueKind.Array)
+ return [];
+
+ var rows = new List>();
+ foreach (var item in resultsNode.EnumerateArray())
+ {
+ var row = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var property in item.EnumerateObject())
+ {
+ row[property.Name] = ConvertJsonValue(property.Value);
+ }
+
+ rows.Add(row);
+ }
+
+ return rows;
+ }
+
+ private static HttpClient CreateClient(string username, string password)
+ {
+ var client = new HttpClient();
+ client.Timeout = TimeSpan.FromSeconds(15);
+ client.DefaultRequestHeaders.Accept.Clear();
+ client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/atomsvc+xml"));
+ client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml"));
+ client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
+ "Basic",
+ Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));
+ return client;
+ }
+
+ private static string BuildServiceUri(string serviceUrl)
+ {
+ var trimmed = serviceUrl.Trim();
+ if (string.IsNullOrWhiteSpace(trimmed))
+ throw new InvalidOperationException("SAP Service URL darf nicht leer sein.");
+
+ var entityPathMarker = "/sap/opu/odata/sap/";
+ var markerIndex = trimmed.IndexOf(entityPathMarker, StringComparison.OrdinalIgnoreCase);
+ if (markerIndex >= 0)
+ {
+ var servicePath = trimmed[(markerIndex + entityPathMarker.Length)..].Trim('/');
+ var parts = servicePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length > 1)
+ {
+ trimmed = $"{trimmed[..(markerIndex + entityPathMarker.Length)]}{parts[0]}/";
+ }
+ }
+
+ return trimmed.EndsWith('/') ? trimmed : $"{trimmed}/";
+ }
+
+ private static async Task> TryReadEntitySetsFromServiceRootAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken)
+ {
+ using var response = await client.GetAsync(baseUrl, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var xml = await response.Content.ReadAsStringAsync(cancellationToken);
+ var document = XDocument.Parse(xml);
+
+ return document
+ .Descendants(AppNs + "collection")
+ .Select(x => x.Attribute("href")?.Value ?? string.Empty)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private static async Task> ReadEntitySetsFromMetadataAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken)
+ {
+ using var response = await client.GetAsync($"{baseUrl}$metadata", cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var xml = await response.Content.ReadAsStringAsync(cancellationToken);
+ var document = XDocument.Parse(xml);
+
+ return document
+ .Descendants(EdmNs + "EntitySet")
+ .Select(x => x.Attribute("Name")?.Value ?? string.Empty)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private static object? ConvertJsonValue(JsonElement element) => element.ValueKind switch
+ {
+ JsonValueKind.String => element.GetString(),
+ JsonValueKind.Number => element.ToString(),
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ JsonValueKind.Null => null,
+ _ => element.ToString()
+ };
+}
diff --git a/TrafagSalesExporter/Services/SiteExportService.cs b/TrafagSalesExporter/Services/SiteExportService.cs
index 8f99873..01f2e65 100644
--- a/TrafagSalesExporter/Services/SiteExportService.cs
+++ b/TrafagSalesExporter/Services/SiteExportService.cs
@@ -9,6 +9,7 @@ public class SiteExportService : ISiteExportService
{
private readonly IDbContextFactory _dbFactory;
private readonly IHanaQueryService _hanaService;
+ private readonly ISapGatewayService _sapGatewayService;
private readonly IExcelExportService _excelService;
private readonly ISharePointUploadService _sharePointService;
private readonly IRecordTransformationService _transformationService;
@@ -17,6 +18,7 @@ public class SiteExportService : ISiteExportService
public SiteExportService(
IDbContextFactory dbFactory,
IHanaQueryService hanaService,
+ ISapGatewayService sapGatewayService,
IExcelExportService excelService,
ISharePointUploadService sharePointService,
IRecordTransformationService transformationService,
@@ -24,6 +26,7 @@ public class SiteExportService : ISiteExportService
{
_dbFactory = dbFactory;
_hanaService = hanaService;
+ _sapGatewayService = sapGatewayService;
_excelService = excelService;
_sharePointService = sharePointService;
_transformationService = transformationService;
@@ -32,9 +35,6 @@ public class SiteExportService : ISiteExportService
public async Task ExportAsync(Site site, Action? updateStatus = null)
{
- if (site.HanaServer is null)
- throw new InvalidOperationException($"Standort '{site.Land}' hat keinen HANA-Server.");
-
var sw = Stopwatch.StartNew();
var log = new ExportLog
{
@@ -49,22 +49,44 @@ public class SiteExportService : ISiteExportService
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
- var exportServer = BuildEffectiveServer(site, settings);
-
- updateStatus?.Invoke("HANA Abfrage...");
- var records = await Task.Run(() => _hanaService.GetSalesRecords(
- exportServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
-
- updateStatus?.Invoke("Transformationen anwenden...");
- var rules = await db.FieldTransformationRules
- .Where(r => r.IsActive && r.SourceSystem == (string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem))
- .OrderBy(r => r.SortOrder)
- .ToListAsync();
- _transformationService.Apply(records, rules);
-
- updateStatus?.Invoke("Excel erstellen...");
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
- var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
+ var sourceSystem = NormalizeSourceSystem(site.SourceSystem);
+ var records = new List();
+ string filePath;
+
+ if (sourceSystem == "SAP")
+ {
+ var credentials = ResolveCredentials(site, settings, sourceSystem);
+ if (string.IsNullOrWhiteSpace(site.SapServiceUrl))
+ throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP Service URL.");
+ if (string.IsNullOrWhiteSpace(site.SapEntitySet))
+ throw new InvalidOperationException($"Standort '{site.Land}' hat kein SAP Entity Set ausgewählt.");
+
+ updateStatus?.Invoke("SAP Gateway Abfrage...");
+ var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, site.SapEntitySet, credentials.Username, credentials.Password);
+ updateStatus?.Invoke("Excel erstellen...");
+ filePath = _excelService.CreateGenericExcelFile(outputDir, $"SAP_{site.TSC}_{site.SapEntitySet}", DateTime.UtcNow.Date, site.SapEntitySet, rows);
+ log.RowCount = rows.Count;
+ }
+ else
+ {
+ var exportServer = BuildEffectiveServer(site, settings, sourceSystem);
+ updateStatus?.Invoke("HANA Abfrage...");
+ records = await Task.Run(() => _hanaService.GetSalesRecords(
+ exportServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
+
+ updateStatus?.Invoke("Transformationen anwenden...");
+ var rules = await db.FieldTransformationRules
+ .Where(r => r.IsActive && r.SourceSystem == sourceSystem)
+ .OrderBy(r => r.SortOrder)
+ .ToListAsync();
+ _transformationService.Apply(records, rules);
+
+ updateStatus?.Invoke("Excel erstellen...");
+ filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
+ log.RowCount = records.Count;
+ }
+
var fileName = Path.GetFileName(filePath);
if (spConfig is not null &&
@@ -80,12 +102,11 @@ public class SiteExportService : ISiteExportService
sw.Stop();
log.Status = "OK";
- log.RowCount = records.Count;
log.FileName = fileName;
log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s",
- site.Land, site.TSC, records.Count, sw.Elapsed.TotalSeconds);
+ site.Land, site.TSC, log.RowCount, sw.Elapsed.TotalSeconds);
return new SiteExportResult
{
@@ -113,14 +134,12 @@ public class SiteExportService : ISiteExportService
}
}
- private static HanaServer BuildEffectiveServer(Site site, ExportSettings settings)
+ private static HanaServer BuildEffectiveServer(Site site, ExportSettings settings, string sourceSystem)
{
if (site.HanaServer is null)
throw new InvalidOperationException($"Standort '{site.Land}' hat keinen HANA-Server.");
- var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem.Trim().ToUpperInvariant();
- var inheritedUsername = GetCentralUsername(sourceSystem, settings);
- var inheritedPassword = GetCentralPassword(sourceSystem, settings);
+ var credentials = ResolveCredentials(site, settings, sourceSystem);
return new HanaServer
{
@@ -128,8 +147,8 @@ public class SiteExportService : ISiteExportService
Name = site.HanaServer.Name,
Host = site.HanaServer.Host,
Port = site.HanaServer.Port,
- Username = FirstNonEmpty(site.UsernameOverride, inheritedUsername, site.HanaServer.Username),
- Password = FirstNonEmpty(site.PasswordOverride, inheritedPassword, site.HanaServer.Password),
+ Username = FirstNonEmpty(credentials.Username, site.HanaServer.Username),
+ Password = FirstNonEmpty(credentials.Password, site.HanaServer.Password),
DatabaseName = site.HanaServer.DatabaseName,
UseSsl = site.HanaServer.UseSsl,
ValidateCertificate = site.HanaServer.ValidateCertificate,
@@ -137,6 +156,10 @@ public class SiteExportService : ISiteExportService
};
}
+ private static (string Username, string Password) ResolveCredentials(Site site, ExportSettings settings, string sourceSystem)
+ => (FirstNonEmpty(site.UsernameOverride, GetCentralUsername(sourceSystem, settings)),
+ FirstNonEmpty(site.PasswordOverride, GetCentralPassword(sourceSystem, settings)));
+
private static string GetCentralUsername(string sourceSystem, ExportSettings settings) => sourceSystem switch
{
"BI1" => settings.Bi1Username,
@@ -151,6 +174,9 @@ public class SiteExportService : ISiteExportService
_ => settings.SapPassword
};
+ private static string NormalizeSourceSystem(string? sourceSystem)
+ => string.IsNullOrWhiteSpace(sourceSystem) ? "SAP" : sourceSystem.Trim().ToUpperInvariant();
+
private static string FirstNonEmpty(params string[] values)
{
foreach (var value in values)
diff --git a/trafag_exporter.db b/trafag_exporter.db
new file mode 100644
index 0000000..e69de29