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