From bec0410ef4a07b3011e579468d4529be6eebc185 Mon Sep 17 00:00:00 2001 From: metacube Date: Fri, 17 Apr 2026 10:29:41 +0200 Subject: [PATCH] refactoring --- .../Components/Pages/Dashboard.razor | 15 +- .../Components/Pages/Settings.razor | 390 ++++++++++----- .../Components/Pages/Standorte.razor | 426 +++++++++------- .../Components/Pages/Transformations.razor | 9 +- TrafagSalesExporter/Data/AppDbContext.cs | 1 + TrafagSalesExporter/HANDOFF_2026-04-15.md | 462 ++++++++++++++++++ .../Models/ConfigTransferPackage.cs | 23 +- TrafagSalesExporter/Models/ExportSettings.cs | 6 - .../Models/FieldTransformationRule.cs | 2 +- TrafagSalesExporter/Models/HanaServer.cs | 7 + TrafagSalesExporter/Models/Site.cs | 2 +- .../Models/SourceSystemDefinition.cs | 34 ++ TrafagSalesExporter/NEXT_STEPS_2026-04-15.md | 175 +++++++ .../Services/ConfigTransferService.cs | 115 +++-- .../Services/DatabaseInitializationService.cs | 238 ++++++++- .../Services/SiteExportService.cs | 98 ++-- .../ConfigTransferServiceTests.cs | 181 +++++-- 17 files changed, 1752 insertions(+), 432 deletions(-) create mode 100644 TrafagSalesExporter/Models/SourceSystemDefinition.cs diff --git a/TrafagSalesExporter/Components/Pages/Dashboard.razor b/TrafagSalesExporter/Components/Pages/Dashboard.razor index 53e9426..97d5af5 100644 --- a/TrafagSalesExporter/Components/Pages/Dashboard.razor +++ b/TrafagSalesExporter/Components/Pages/Dashboard.razor @@ -173,6 +173,7 @@ using var db = await DbFactory.CreateDbContextAsync(); var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync(); + var sourceSystems = await db.SourceSystemDefinitions.AsNoTracking().ToListAsync(); var logs = await db.ExportLogs .GroupBy(l => l.SiteId) .Select(g => g.OrderByDescending(l => l.Timestamp).First()) @@ -190,14 +191,15 @@ { var log = logs.FirstOrDefault(l => l.SiteId == s.Id); latestAppLogsBySite.TryGetValue(s.Id, out var appLog); + var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, s.SourceSystem, StringComparison.OrdinalIgnoreCase)); return new DashboardRow { SiteId = s.Id, Land = s.Land, TSC = s.TSC, Schema = s.Schema, - ServerName = string.Equals(s.SourceSystem, "SAP", StringComparison.OrdinalIgnoreCase) - ? (string.IsNullOrWhiteSpace(s.SapServiceUrl) ? "SAP Gateway" : s.SapServiceUrl) + ServerName = string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase) + ? ResolveDashboardSapServiceUrl(s, sourceSystems) : s.HanaServer?.Name ?? "", LastStatus = log?.Status ?? "", RowCount = log?.RowCount ?? 0, @@ -319,6 +321,15 @@ OpenFile(row.FilePath); } + private static string ResolveDashboardSapServiceUrl(Site site, List sourceSystems) + { + if (!string.IsNullOrWhiteSpace(site.SapServiceUrl)) + return site.SapServiceUrl; + + var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, site.SourceSystem, StringComparison.OrdinalIgnoreCase)); + return string.IsNullOrWhiteSpace(sourceSystem?.CentralServiceUrl) ? "SAP Gateway" : sourceSystem.CentralServiceUrl; + } + private void OpenFile(string filePath) { if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) diff --git a/TrafagSalesExporter/Components/Pages/Settings.razor b/TrafagSalesExporter/Components/Pages/Settings.razor index 7207bc8..abc1455 100644 --- a/TrafagSalesExporter/Components/Pages/Settings.razor +++ b/TrafagSalesExporter/Components/Pages/Settings.razor @@ -1,4 +1,4 @@ -@page "/settings" +@page "/settings" @using Microsoft.EntityFrameworkCore @using TrafagSalesExporter.Data @using TrafagSalesExporter.Models @@ -23,7 +23,7 @@ - Wenn deaktiviert, bleiben Passwörter und Secrets beim Export leer. Beim Import ohne Secrets werden bestehende Secrets auf dem Zielsystem beibehalten. + Wenn deaktiviert, bleiben Passwörter und Secrets beim Export leer. Beim Import ohne Secrets werden bestehende Secrets auf dem Zielsystem beibehalten. @@ -98,74 +98,102 @@ -Zentrale Quellsystem-Zugangsdaten +Quellsysteme - Diese Zugangsdaten werden pro Quellsystem als Standard verwendet. Ein Standort kann sie bei Bedarf mit eigenen Overrides überschreiben. + Diese Zugangsdaten werden pro Quellsystem als Standard verwendet. Ein Standort kann sie bei Bedarf mit eigenen Overrides überschreiben. - - SAP - - - - @if (_testingSystems.Contains("SAP")) - { - - @("Teste...") - } - else - { - @("SAP testen") - } - - - - BI1 - - - - @if (_testingSystems.Contains("BI1")) - { - - @("Teste...") - } - else - { - @("BI1 testen") - } - - - - SAGE - - - - @if (_testingSystems.Contains("SAGE")) - { - - @("Teste...") - } - else - { - @("SAGE testen") - } + + + Quellsystem hinzufuegen + + + Code + Name + Anschlussart + Zentrale URL + User + Aktiv + Test + + + + @context.Code + @context.DisplayName + @GetConnectionKindLabel(context.ConnectionKind) + @GetServiceUrlSummary(context) + @GetUsernameSummary(context) + + @if (context.IsActive) + { + + } + else + { + + } + + + @if (!UsesManualImport(context)) + { + + @(_testingSystems.Contains(context.Code) ? "Teste..." : "Testen") + + } + + + + + + + - - Speichern + Quellsysteme speichern + + + @(_editingSourceSystem.Id == 0 ? "Quellsystem hinzufuegen" : "Quellsystem bearbeiten") + + + + + + @foreach (var kind in SourceSystemConnectionKinds.All) + { + @GetConnectionKindLabel(kind) + } + + @if (UsesSapGateway(_editingSourceSystem)) + { + + } + + + + + + Abbrechen + Uebernehmen + + + Wechselkurse @@ -250,7 +278,7 @@ - Schreibt zusätzliche technische Fortschrittsmeldungen für HANA- und SAP-Lesevorgänge ins Dashboard und in die Logs. + Schreibt zusätzliche technische Fortschrittsmeldungen für HANA- und SAP-Lesevorgänge ins Dashboard und in die Logs. @@ -285,6 +313,8 @@ @code { private SharePointConfig _spConfig = new(); private ExportSettings _exportSettings = new(); + private List _sourceSystems = []; + private SourceSystemDefinition _editingSourceSystem = new(); private bool _testingSp; private bool _includeSecretsInExport; private bool _exportingConfig; @@ -293,12 +323,15 @@ private string _sharePointTestPreview = string.Empty; private List _exchangeRates = []; private readonly HashSet _testingSystems = []; + private bool _sourceSystemDialogVisible; + private readonly DialogOptions _sourceSystemDialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; protected override async Task OnInitializedAsync() { using var db = await DbFactory.CreateDbContextAsync(); _spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig(); _exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(); + _sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync(); _exchangeRates = await db.CurrencyExchangeRates .OrderBy(x => x.FromCurrency) .ThenBy(x => x.ToCurrency) @@ -370,18 +403,138 @@ existing.DebugLoggingEnabled = _exportSettings.DebugLoggingEnabled; existing.LocalSiteExportFolder = _exportSettings.LocalSiteExportFolder; existing.LocalConsolidatedExportFolder = _exportSettings.LocalConsolidatedExportFolder; - existing.SapUsername = _exportSettings.SapUsername; - existing.SapPassword = _exportSettings.SapPassword; - existing.Bi1Username = _exportSettings.Bi1Username; - existing.Bi1Password = _exportSettings.Bi1Password; - existing.SageUsername = _exportSettings.SageUsername; - existing.SagePassword = _exportSettings.SagePassword; } await db.SaveChangesAsync(); TimerService.Recalculate(); Snackbar.Add("Export Einstellungen gespeichert", Severity.Success); } + private void AddSourceSystem() + { + _editingSourceSystem = new SourceSystemDefinition + { + Code = string.Empty, + DisplayName = string.Empty, + ConnectionKind = SourceSystemConnectionKinds.Hana, + IsActive = true + }; + _sourceSystemDialogVisible = true; + } + + private void EditSourceSystem(SourceSystemDefinition definition) + { + _editingSourceSystem = new SourceSystemDefinition + { + Id = definition.Id, + Code = definition.Code, + DisplayName = definition.DisplayName, + ConnectionKind = definition.ConnectionKind, + IsActive = definition.IsActive, + CentralServiceUrl = definition.CentralServiceUrl, + CentralUsername = definition.CentralUsername, + CentralPassword = definition.CentralPassword + }; + _sourceSystemDialogVisible = true; + } + + private void SaveSourceSystemEdit() + { + _editingSourceSystem.Code = NormalizeSourceSystemCode(_editingSourceSystem.Code); + _editingSourceSystem.DisplayName = NormalizeConfigValue(_editingSourceSystem.DisplayName); + _editingSourceSystem.ConnectionKind = NormalizeConnectionKind(_editingSourceSystem.ConnectionKind); + _editingSourceSystem.CentralServiceUrl = NormalizeConfigValue(_editingSourceSystem.CentralServiceUrl); + _editingSourceSystem.CentralUsername = NormalizeConfigValue(_editingSourceSystem.CentralUsername); + _editingSourceSystem.CentralPassword = _editingSourceSystem.CentralPassword ?? string.Empty; + + if (string.IsNullOrWhiteSpace(_editingSourceSystem.Code) || string.IsNullOrWhiteSpace(_editingSourceSystem.DisplayName)) + { + Snackbar.Add("Code und Name fuer das Quellsystem sind Pflicht.", Severity.Warning); + return; + } + + if (_sourceSystems.Any(x => x.Id != _editingSourceSystem.Id && x.Code == _editingSourceSystem.Code)) + { + Snackbar.Add($"Quellsystem-Code doppelt vorhanden: {_editingSourceSystem.Code}", Severity.Warning); + return; + } + + if (_editingSourceSystem.Id == 0) + { + _sourceSystems.Add(_editingSourceSystem); + } + else + { + var existing = _sourceSystems.FirstOrDefault(x => x.Id == _editingSourceSystem.Id); + if (existing is not null) + { + existing.Code = _editingSourceSystem.Code; + existing.DisplayName = _editingSourceSystem.DisplayName; + existing.ConnectionKind = _editingSourceSystem.ConnectionKind; + existing.IsActive = _editingSourceSystem.IsActive; + existing.CentralServiceUrl = _editingSourceSystem.CentralServiceUrl; + existing.CentralUsername = _editingSourceSystem.CentralUsername; + existing.CentralPassword = _editingSourceSystem.CentralPassword; + } + } + + _sourceSystems = _sourceSystems.OrderBy(x => x.Code).ToList(); + _sourceSystemDialogVisible = false; + } + + private void CloseSourceSystemDialog() + { + _sourceSystemDialogVisible = false; + } + + private void RemoveSourceSystem(SourceSystemDefinition definition) + { + _sourceSystems.Remove(definition); + } + + private async Task SaveSourceSystems() + { + var normalized = _sourceSystems + .Select(x => new SourceSystemDefinition + { + Id = x.Id, + Code = NormalizeSourceSystemCode(x.Code), + DisplayName = NormalizeConfigValue(x.DisplayName), + ConnectionKind = NormalizeConnectionKind(x.ConnectionKind), + IsActive = x.IsActive, + CentralServiceUrl = NormalizeConfigValue(x.CentralServiceUrl), + CentralUsername = NormalizeConfigValue(x.CentralUsername), + CentralPassword = x.CentralPassword ?? string.Empty + }) + .Where(x => !string.IsNullOrWhiteSpace(x.Code)) + .ToList(); + + if (normalized.Any(x => string.IsNullOrWhiteSpace(x.DisplayName))) + { + Snackbar.Add("Jedes Quellsystem braucht einen Anzeigenamen.", Severity.Warning); + return; + } + + var duplicates = normalized + .GroupBy(x => x.Code) + .FirstOrDefault(g => g.Count() > 1); + if (duplicates is not null) + { + Snackbar.Add($"Quellsystem-Code doppelt vorhanden: {duplicates.Key}", Severity.Warning); + return; + } + + using var db = await DbFactory.CreateDbContextAsync(); + var existing = await db.SourceSystemDefinitions.ToListAsync(); + if (existing.Count > 0) + db.SourceSystemDefinitions.RemoveRange(existing); + + db.SourceSystemDefinitions.AddRange(normalized); + await db.SaveChangesAsync(); + + _sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync(); + Snackbar.Add("Quellsysteme gespeichert", Severity.Success); + } + private void AddExchangeRate() { _exchangeRates.Add(new CurrencyExchangeRate @@ -493,6 +646,7 @@ using var db = await DbFactory.CreateDbContextAsync(); _spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig(); _exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(); + _sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync(); _exchangeRates = await db.CurrencyExchangeRates .OrderBy(x => x.FromCurrency) .ThenBy(x => x.ToCurrency) @@ -513,61 +667,72 @@ private async Task TestCentralCredentials(string sourceSystem) { - if (sourceSystem == "SAP") + var definition = _sourceSystems.FirstOrDefault(x => string.Equals(x.Code, sourceSystem, StringComparison.OrdinalIgnoreCase)); + if (definition is null) { - await TestCentralSapCredentials(); + Snackbar.Add($"Quellsystem '{sourceSystem}' nicht gefunden.", Severity.Warning); return; } - await TestCentralHanaCredentials(sourceSystem); + if (string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)) + { + await TestCentralSapCredentials(definition); + return; + } + + if (string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase)) + { + await TestCentralHanaCredentials(definition); + } } - private async Task TestCentralHanaCredentials(string sourceSystem) + private async Task TestCentralHanaCredentials(SourceSystemDefinition definition) { + var sourceSystem = definition.Code; if (!_testingSystems.Add(sourceSystem)) return; try { - var username = GetCentralUsername(sourceSystem); - var password = GetCentralPassword(sourceSystem); + var username = definition.CentralUsername; + var password = definition.CentralPassword; if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { - Snackbar.Add($"Für {sourceSystem} sind keine zentralen Zugangsdaten gepflegt.", Severity.Warning); + Snackbar.Add($"Für {sourceSystem} sind keine zentralen Zugangsdaten gepflegt.", Severity.Warning); return; } using var db = await DbFactory.CreateDbContextAsync(); - var site = await db.Sites - .Include(s => s.HanaServer) - .Where(s => (string.IsNullOrWhiteSpace(s.SourceSystem) ? "SAP" : s.SourceSystem) == sourceSystem) - .OrderBy(s => s.Land) + var centralServer = await db.HanaServers + .Where(s => s.SourceSystem == sourceSystem) + .OrderBy(s => s.Id) .FirstOrDefaultAsync(); - if (site?.HanaServer is null) + if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host)) { - Snackbar.Add($"Kein Standort mit Quellsystem {sourceSystem} und HANA-Verbindung gefunden.", Severity.Warning); + Snackbar.Add($"Keine zentrale HANA-Konfiguration fuer {sourceSystem} gefunden.", Severity.Warning); return; } var testServer = new HanaServer { + SourceSystem = sourceSystem, Name = $"{sourceSystem} Central Test", - Host = site.HanaServer.Host, - Port = site.HanaServer.Port, + Host = centralServer.Host, + Port = centralServer.Port, Username = username.Trim(), Password = password.Trim(), - DatabaseName = site.HanaServer.DatabaseName, - UseSsl = site.HanaServer.UseSsl, - ValidateCertificate = site.HanaServer.ValidateCertificate, - AdditionalParams = site.HanaServer.AdditionalParams + DatabaseName = centralServer.DatabaseName, + UseSsl = centralServer.UseSsl, + ValidateCertificate = centralServer.ValidateCertificate, + AdditionalParams = centralServer.AdditionalParams }; var result = await Task.Run(() => HanaService.TestConnectionDetailed(testServer)); if (result.Success) { - Snackbar.Add($"{sourceSystem}: Verbindung erfolgreich über Standort '{site.Land}'.", Severity.Success); + Snackbar.Add($"{sourceSystem}: Zentrale HANA-Verbindung erfolgreich.", Severity.Success); } else { @@ -580,42 +745,35 @@ } } - private async Task TestCentralSapCredentials() + private async Task TestCentralSapCredentials(SourceSystemDefinition definition) { - const string sourceSystem = "SAP"; + var sourceSystem = definition.Code; if (!_testingSystems.Add(sourceSystem)) return; try { - var username = GetCentralUsername(sourceSystem); - var password = GetCentralPassword(sourceSystem); + var username = definition.CentralUsername; + var password = definition.CentralPassword; if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) { - Snackbar.Add("Für SAP sind keine zentralen Gateway-Zugangsdaten gepflegt.", Severity.Warning); + 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) + if (string.IsNullOrWhiteSpace(definition.CentralServiceUrl)) { - Snackbar.Add("Kein SAP-Standort mit Service URL gefunden.", Severity.Warning); + Snackbar.Add($"Fuer {sourceSystem} ist keine zentrale SAP Service URL gepflegt.", Severity.Warning); return; } - await SapGatewayService.TestConnectionAsync(site.SapServiceUrl, username.Trim(), password.Trim()); - Snackbar.Add($"SAP: Gateway-Verbindung erfolgreich über Standort '{site.Land}'.", Severity.Success); + await SapGatewayService.TestConnectionAsync(definition.CentralServiceUrl, username.Trim(), password.Trim()); + Snackbar.Add($"{sourceSystem}: Zentrale SAP Gateway-Verbindung erfolgreich.", Severity.Success); } catch (Exception ex) { - Snackbar.Add($"SAP: {ex.Message}", Severity.Error); + Snackbar.Add($"{sourceSystem}: {ex.Message}", Severity.Error); } finally { @@ -623,19 +781,32 @@ } } - private string GetCentralUsername(string sourceSystem) => sourceSystem switch + private static string NormalizeSourceSystemCode(string? code) => NormalizeConfigValue(code).ToUpperInvariant(); + + private static string NormalizeConnectionKind(string? connectionKind) + => SourceSystemConnectionKinds.All.Contains(connectionKind ?? string.Empty, StringComparer.OrdinalIgnoreCase) + ? (connectionKind ?? string.Empty).Trim().ToUpperInvariant() + : SourceSystemConnectionKinds.Hana; + + private static string GetConnectionKindLabel(string connectionKind) => connectionKind switch { - "BI1" => _exportSettings.Bi1Username, - "SAGE" => _exportSettings.SageUsername, - _ => _exportSettings.SapUsername + SourceSystemConnectionKinds.Hana => "HANA", + SourceSystemConnectionKinds.SapGateway => "SAP Gateway", + SourceSystemConnectionKinds.ManualExcel => "Manual Excel", + _ => connectionKind }; - private string GetCentralPassword(string sourceSystem) => sourceSystem switch - { - "BI1" => _exportSettings.Bi1Password, - "SAGE" => _exportSettings.SagePassword, - _ => _exportSettings.SapPassword - }; + private static bool UsesManualImport(SourceSystemDefinition definition) + => string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase); + + private static bool UsesSapGateway(SourceSystemDefinition definition) + => string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase); + + private static string GetServiceUrlSummary(SourceSystemDefinition definition) + => string.IsNullOrWhiteSpace(definition.CentralServiceUrl) ? "-" : definition.CentralServiceUrl; + + private static string GetUsernameSummary(SourceSystemDefinition definition) + => string.IsNullOrWhiteSpace(definition.CentralUsername) ? "-" : definition.CentralUsername; private static string NormalizeConfigValue(string? value) => value?.Trim() ?? string.Empty; @@ -664,3 +835,4 @@ .ToListAsync(); } } + diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor index f0c75df..4a34529 100644 --- a/TrafagSalesExporter/Components/Pages/Standorte.razor +++ b/TrafagSalesExporter/Components/Pages/Standorte.razor @@ -1,4 +1,4 @@ -@page "/standorte" +@page "/standorte" @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore @using System.Text.Json @@ -17,27 +17,30 @@ Standorte -HANA Server +Zentrale HANA-Technik - - Server hinzufügen - + + Hier erscheinen nur Quellsysteme mit Anschlussart HANA. SAP wird zentral unter Settings -> Quellsysteme gepflegt. + Standorte mit `BI1` oder `SAGE` verwenden diese technischen HANA-Werte automatisch. Im Standort selbst bleiben nur Schema, TSC, Land und optionale Username-/Password-Overrides. + + + Neue HANA-Zeilen entstehen aus den zentral gepflegten Quellsystemen. Falls hier etwas fehlt, lege das Quellsystem in Settings -> Quellsysteme mit Anschlussart `HANA` an. + + Quellsystem Name Host Port - Username Verbindungsstatus Aktionen + @context.SourceSystem @context.Name @context.Host @context.Port - @context.Username @if (_connectionStatus.TryGetValue(context.Id, out var status)) { @@ -68,7 +71,7 @@ - Neuen Standort hinzufügen + Neuen Standort hinzufügen @@ -109,22 +112,21 @@ - @(_editingServer.Id == 0 ? "Server hinzufügen" : "Server bearbeiten") + Zentrale HANA-Technik bearbeiten + - - + HelperText="Typisch 30015 (Tenant), 30013 (SystemDB), 3xx15 für Instanz xx" /> - @@ -135,16 +137,16 @@ - @(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten") + @(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten") - @foreach (var system in _sourceSystems) + @foreach (var system in GetAvailableSourceSystems()) { - @system + @GetSourceSystemLabel(system) } + HelperText="Optional. Wenn leer, wird der zentrale Standardpfad für Standort-Dateien verwendet." /> @@ -161,10 +163,11 @@ { SAP Gateway - Die Service-URL zeigt auf den OData-Service. Die verfügbaren Entity Sets werden nur per Knopfdruck aktualisiert und lokal zwischengespeichert. + Die Service-URL zeigt auf den OData-Service. Die verfügbaren Entity Sets werden nur per Knopfdruck aktualisiert und lokal zwischengespeichert. - + Zentrale SAP Service URL: @GetCentralSapServiceUrlSummary(_editingSite.SourceSystem) + @@ -188,16 +191,16 @@ SAP Quellen - Quelle hinzufügen + Quelle hinzufügen - Pro Quelle Alias und Entity Set definieren. Joins verwenden links/rechts kommagetrennte Schlüsselfelder wie `VBELN,POSNR`. Feldmappings erwarten `Alias.Feldname` oder Konstanten wie `=SAP`. + Pro Quelle Alias und Entity Set definieren. Joins verwenden links/rechts kommagetrennte Schlüsselfelder wie `VBELN,POSNR`. Feldmappings erwarten `Alias.Feldname` oder Konstanten wie `=SAP`. Alias Entity Set - Primär + Primär Aktiv Aktionen @@ -225,7 +228,7 @@ OnClick="AutoMatchSapJoins"> Auto-Match - Join hinzufügen + Join hinzufügen @@ -305,11 +308,11 @@ @("Felder aus Quellen laden") } - Mapping hinzufügen + Mapping hinzufügen - Source Expressions werden aus den hinzugefügten SAP-Quellen als `Alias.Feldname` gelesen. Vorhandene manuelle Werte bleiben auswählbar. + Source Expressions werden aus den hinzugefügten SAP-Quellen als `Alias.Feldname` gelesen. Vorhandene manuelle Werte bleiben auswählbar. @@ -346,7 +349,7 @@ { Manueller Excel-Import - Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-Datei gelesen und in `CentralSalesRecords` übernommen. + Für diesen Standort wird keine SAP- oder HANA-Verbindung verwendet. Es wird die hier hinterlegte Excel-Datei gelesen und in `CentralSalesRecords` übernommen. @if (_uploadingManualImport) @@ -371,23 +374,12 @@ { 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. + Die technische HANA-Verbindung kommt aus der zentralen HANA-Konfiguration des Quellsystems. Im Standort selbst pflegst du nur fachliche Standortdaten und optionale Username-/Password-Overrides. - - - - - - - - - + Aktive Zentralverbindung: @GetCentralHanaSummary(_editingSite.SourceSystem) + + Host, Port, SSL und technische Parameter bearbeitest du oben in der zentralen HANA-Konfiguration. + } @@ -397,10 +389,10 @@ @code { - private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE", "MANUAL_EXCEL"]; private readonly Dictionary _connectionStatus = new(); private List _servers = new(); private List _sites = new(); + private List _sourceSystemDefinitions = new(); private List _sapEntitySetsCache = []; private List _sapAvailableSourceExpressions = []; private Dictionary> _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase); @@ -413,7 +405,6 @@ .ToArray(); private HanaServer _editingServer = new(); private Site _editingSite = new(); - private HanaServer _editingSiteServer = new(); private bool _serverDialogVisible; private bool _siteDialogVisible; private bool _refreshingSapEntitySets; @@ -431,16 +422,17 @@ private async Task LoadDataAsync() { using var db = await DbFactory.CreateDbContextAsync(); - _servers = await db.HanaServers.OrderBy(s => s.Name).ToListAsync(); + _sourceSystemDefinitions = await db.SourceSystemDefinitions + .OrderBy(x => x.Code) + .ToListAsync(); + _servers = await db.HanaServers + .Where(s => GetHanaSourceSystemCodes().Contains(s.SourceSystem)) + .OrderBy(s => s.SourceSystem) + .ThenBy(s => s.Name) + .ToListAsync(); _sites = await db.Sites.Include(s => s.HanaServer).OrderBy(s => s.Land).ToListAsync(); } - private void AddServer() - { - _editingServer = new HanaServer { Port = 30015 }; - _serverDialogVisible = true; - } - private void EditServer(HanaServer server) { _editingServer = CloneServer(server); @@ -455,21 +447,50 @@ _savingServer = true; try { + _editingServer.SourceSystem = string.IsNullOrWhiteSpace(_editingServer.SourceSystem) + ? GetHanaSourceSystemCodes().FirstOrDefault() ?? string.Empty + : _editingServer.SourceSystem.Trim().ToUpperInvariant(); + _editingServer.Name = string.IsNullOrWhiteSpace(_editingServer.Name) ? _editingServer.SourceSystem : _editingServer.Name.Trim(); + _editingServer.Host = _editingServer.Host.Trim(); + _editingServer.DatabaseName = _editingServer.DatabaseName.Trim(); + _editingServer.AdditionalParams = _editingServer.AdditionalParams.Trim(); + _editingServer.Username = string.Empty; + _editingServer.Password = string.Empty; using var db = await DbFactory.CreateDbContextAsync(); if (_editingServer.Id == 0) { - db.HanaServers.Add(_editingServer); + var existingForSourceSystem = await db.HanaServers + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.SourceSystem == _editingServer.SourceSystem); + + if (existingForSourceSystem is null) + { + db.HanaServers.Add(_editingServer); + } + else + { + existingForSourceSystem.Name = _editingServer.Name; + existingForSourceSystem.Host = _editingServer.Host; + existingForSourceSystem.Port = _editingServer.Port; + existingForSourceSystem.Username = string.Empty; + existingForSourceSystem.Password = string.Empty; + existingForSourceSystem.DatabaseName = _editingServer.DatabaseName; + existingForSourceSystem.UseSsl = _editingServer.UseSsl; + existingForSourceSystem.ValidateCertificate = _editingServer.ValidateCertificate; + existingForSourceSystem.AdditionalParams = _editingServer.AdditionalParams; + } } else { var existing = await db.HanaServers.FindAsync(_editingServer.Id); if (existing is not null) { + existing.SourceSystem = _editingServer.SourceSystem; existing.Name = _editingServer.Name; existing.Host = _editingServer.Host; existing.Port = _editingServer.Port; - existing.Username = _editingServer.Username; - existing.Password = _editingServer.Password; + existing.Username = string.Empty; + existing.Password = string.Empty; existing.DatabaseName = _editingServer.DatabaseName; existing.UseSsl = _editingServer.UseSsl; existing.ValidateCertificate = _editingServer.ValidateCertificate; @@ -490,10 +511,16 @@ private async Task DeleteServer(HanaServer server) { + if (IsHanaSourceSystem(server.SourceSystem)) + { + Snackbar.Add($"Die zentrale HANA-Konfiguration fuer {server.SourceSystem} kann nicht geloescht werden.", Severity.Warning); + return; + } + var result = await DialogService.ShowMessageBox( - "Server löschen", - $"Server '{server.Name}' wirklich löschen?", - yesText: "Löschen", cancelText: "Abbrechen"); + "Server löschen", + $"Server '{server.Name}' wirklich löschen?", + yesText: "Löschen", cancelText: "Abbrechen"); if (result != true) return; @@ -509,7 +536,7 @@ if (linkedSites.Count > 0) { Snackbar.Add( - $"Server kann nicht gelöscht werden. Noch verknüpfte Standorte: {string.Join(", ", linkedSites)}", + $"Server kann nicht gelöscht werden. Noch verknüpfte Standorte: {string.Join(", ", linkedSites)}", Severity.Warning); return; } @@ -523,19 +550,51 @@ } catch (Exception ex) { - Snackbar.Add($"Server konnte nicht gelöscht werden: {ex.Message}", Severity.Error); + Snackbar.Add($"Server konnte nicht gelöscht werden: {ex.Message}", Severity.Error); return; } await LoadDataAsync(); - Snackbar.Add("Server gelöscht", Severity.Info); + Snackbar.Add("Server gelöscht", Severity.Info); } private async Task TestServerConnection(HanaServer server) { + using var db = await DbFactory.CreateDbContextAsync(); + var sourceDefinition = await db.SourceSystemDefinitions + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.Code == server.SourceSystem); + + if (sourceDefinition is null) + { + Snackbar.Add($"Quellsystem '{server.SourceSystem}' nicht gefunden.", Severity.Warning); + return; + } + + if (string.IsNullOrWhiteSpace(sourceDefinition.CentralUsername) || string.IsNullOrWhiteSpace(sourceDefinition.CentralPassword)) + { + Snackbar.Add($"Fuer {server.SourceSystem} sind keine zentralen Zugangsdaten im Quellsystem gepflegt.", Severity.Warning); + return; + } + + var testServer = new HanaServer + { + Id = server.Id, + SourceSystem = server.SourceSystem, + Name = server.Name, + Host = server.Host, + Port = server.Port, + Username = sourceDefinition.CentralUsername.Trim(), + Password = sourceDefinition.CentralPassword, + DatabaseName = server.DatabaseName, + UseSsl = server.UseSsl, + ValidateCertificate = server.ValidateCertificate, + AdditionalParams = server.AdditionalParams + }; + await AppEventLogService.WriteAsync("HANA", "Server-Test aus UI gestartet", - details: server.GetConnectionStringPreview()); - var result = await Task.Run(() => HanaService.TestConnectionDetailed(server)); + details: testServer.GetConnectionStringPreview()); + var result = await Task.Run(() => HanaService.TestConnectionDetailed(testServer)); _connectionStatus[server.Id] = result; if (result.Success) @@ -562,7 +621,7 @@ _editingSite = new Site { IsActive = true, - SourceSystem = "SAP", + SourceSystem = GetAvailableSourceSystems().FirstOrDefault()?.Code ?? "SAP", HanaServerId = null, ManualImportFilePath = string.Empty }; @@ -572,7 +631,6 @@ _sapSources = []; _sapJoins = []; _sapMappings = []; - _editingSiteServer = CreateDefaultSiteServer(); _siteDialogVisible = true; } @@ -585,7 +643,9 @@ Schema = site.Schema, TSC = site.TSC, Land = site.Land, - SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem, + SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) + ? GetAvailableSourceSystems().FirstOrDefault()?.Code ?? "SAP" + : site.SourceSystem, UsernameOverride = site.UsernameOverride, PasswordOverride = site.PasswordOverride, LocalExportFolderOverride = site.LocalExportFolderOverride, @@ -604,9 +664,6 @@ _sapMappings = db.SapFieldMappings.Where(m => m.SiteId == site.Id).OrderBy(m => m.SortOrder).ThenBy(m => m.Id).ToList(); _sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings(); _sapSourceFieldMap = BuildSourceFieldMapFromJoins(); - _editingSiteServer = site.HanaServer is null - ? CreateDefaultSiteServer(site) - : CloneServer(site.HanaServer); _siteDialogVisible = true; } @@ -619,7 +676,7 @@ try { using var db = await DbFactory.CreateDbContextAsync(); - var serverId = UsesHanaConnection() ? await SaveOrCreateSiteServerAsync(db) : (int?)null; + var serverId = UsesHanaConnection() ? await ResolveCentralHanaServerIdAsync(db, _editingSite.SourceSystem) : (int?)null; _editingSite.HanaServerId = serverId; _editingSite.SapEntitySetsCache = SerializeSapEntitySets(_sapEntitySetsCache); @@ -669,9 +726,9 @@ private async Task DeleteSite(Site site) { var result = await DialogService.ShowMessageBox( - "Standort löschen", - $"Standort '{site.Land}' wirklich löschen?", - yesText: "Löschen", cancelText: "Abbrechen"); + "Standort löschen", + $"Standort '{site.Land}' wirklich löschen?", + yesText: "Löschen", cancelText: "Abbrechen"); if (result != true) return; @@ -692,7 +749,7 @@ } await LoadDataAsync(); - Snackbar.Add("Standort gelöscht", Severity.Info); + Snackbar.Add("Standort gelöscht", Severity.Info); } private static string GetServerNode(HanaServer? server) @@ -703,40 +760,17 @@ 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; - if (string.Equals(sourceSystem, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase)) - return string.IsNullOrWhiteSpace(site.ManualImportFilePath) ? "-" : Path.GetFileName(site.ManualImportFilePath); - - return GetServerNode(site.HanaServer); - } - - private HanaServer CreateDefaultSiteServer(Site? site = null) - { - var label = !string.IsNullOrWhiteSpace(site?.Land) ? site!.Land : site?.TSC; - if (string.IsNullOrWhiteSpace(label)) - label = "Neuer Standort"; - - return new HanaServer - { - Name = $"{label} HANA", - Port = 30015 - }; - } - private static HanaServer CloneServer(HanaServer server) { return new HanaServer { Id = server.Id, + SourceSystem = server.SourceSystem, Name = server.Name, Host = server.Host, Port = server.Port, - Username = server.Username, - Password = server.Password, + Username = string.Empty, + Password = string.Empty, DatabaseName = server.DatabaseName, UseSsl = server.UseSsl, ValidateCertificate = server.ValidateCertificate, @@ -744,66 +778,98 @@ }; } - private async Task SaveOrCreateSiteServerAsync(AppDbContext db) + private async Task ResolveCentralHanaServerIdAsync(AppDbContext db, string sourceSystem) { - _editingSiteServer.Name = string.IsNullOrWhiteSpace(_editingSiteServer.Name) - ? $"{_editingSite.Land} HANA".Trim() - : _editingSiteServer.Name.Trim(); _editingSite.UsernameOverride = _editingSite.UsernameOverride.Trim(); _editingSite.PasswordOverride = _editingSite.PasswordOverride.Trim(); _editingSite.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride.Trim(); _editingSite.ManualImportFilePath = _editingSite.ManualImportFilePath.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(); - _editingSiteServer.AdditionalParams = _editingSiteServer.AdditionalParams.Trim(); - if (string.IsNullOrWhiteSpace(_editingSiteServer.Host)) - throw new InvalidOperationException("Host oder ServerNode muss gesetzt sein."); + var normalizedSourceSystem = string.IsNullOrWhiteSpace(sourceSystem) ? string.Empty : sourceSystem.Trim().ToUpperInvariant(); + var centralServer = await db.HanaServers + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.SourceSystem == normalizedSourceSystem); - if (_editingSite.HanaServerId == 0) - { - db.HanaServers.Add(_editingSiteServer); - await db.SaveChangesAsync(); - return _editingSiteServer.Id; - } + if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host)) + throw new InvalidOperationException($"Fuer Quellsystem '{normalizedSourceSystem}' ist keine gueltige zentrale HANA-Konfiguration vorhanden."); - var sharedUseCount = await db.Sites.CountAsync(s => s.HanaServerId == _editingSite.HanaServerId && s.Id != _editingSite.Id); - if (sharedUseCount > 0) - { - var dedicatedServer = CloneServer(_editingSiteServer); - dedicatedServer.Id = 0; - db.HanaServers.Add(dedicatedServer); - await db.SaveChangesAsync(); - return dedicatedServer.Id; - } - - var existingServer = await db.HanaServers.FindAsync(_editingSite.HanaServerId); - if (existingServer is null) - { - db.HanaServers.Add(_editingSiteServer); - await db.SaveChangesAsync(); - return _editingSiteServer.Id; - } - - existingServer.Name = _editingSiteServer.Name; - existingServer.Host = _editingSiteServer.Host; - existingServer.Port = _editingSiteServer.Port; - existingServer.Username = _editingSiteServer.Username; - existingServer.Password = _editingSiteServer.Password; - existingServer.DatabaseName = _editingSiteServer.DatabaseName; - existingServer.UseSsl = _editingSiteServer.UseSsl; - existingServer.ValidateCertificate = _editingSiteServer.ValidateCertificate; - existingServer.AdditionalParams = _editingSiteServer.AdditionalParams; - await db.SaveChangesAsync(); - return existingServer.Id; + return centralServer.Id; } - private bool IsSapSite() => string.Equals(_editingSite.SourceSystem, "SAP", StringComparison.OrdinalIgnoreCase); - private bool IsManualExcelSite() => string.Equals(_editingSite.SourceSystem, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase); - private bool UsesHanaConnection() => !IsSapSite() && !IsManualExcelSite(); + private IEnumerable GetAvailableSourceSystems() + => _sourceSystemDefinitions + .Where(x => x.IsActive || string.Equals(x.Code, _editingSite.SourceSystem, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.DisplayName) + .ThenBy(x => x.Code); + + private List GetHanaSourceSystemCodes() + => _sourceSystemDefinitions + .Where(x => string.Equals(x.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Code) + .OrderBy(x => x) + .ToList(); + + private string GetSourceSystemConnectionKind(string? sourceSystem) + => _sourceSystemDefinitions + .FirstOrDefault(x => string.Equals(x.Code, sourceSystem, StringComparison.OrdinalIgnoreCase)) + ?.ConnectionKind + ?? SourceSystemConnectionKinds.SapGateway; + + private bool IsHanaSourceSystem(string? sourceSystem) + => string.Equals(GetSourceSystemConnectionKind(sourceSystem), SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase); + + private bool IsSapSite() + => string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase); + + private bool IsManualExcelSite() + => string.Equals(GetSourceSystemConnectionKind(_editingSite.SourceSystem), SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase); + + private bool UsesHanaConnection() => IsHanaSourceSystem(_editingSite.SourceSystem); + + private string GetSourceSystemLabel(SourceSystemDefinition definition) + => string.IsNullOrWhiteSpace(definition.DisplayName) ? definition.Code : $"{definition.DisplayName} ({definition.Code})"; + + private string GetConnectionTarget(Site site) + { + var connectionKind = GetSourceSystemConnectionKind(site.SourceSystem); + if (string.Equals(connectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)) + return GetEffectiveSapServiceUrl(site); + if (string.Equals(connectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase)) + return string.IsNullOrWhiteSpace(site.ManualImportFilePath) ? "-" : Path.GetFileName(site.ManualImportFilePath); + + return GetServerNode(site.HanaServer); + } + + private string GetEffectiveSapServiceUrl(Site site) + { + if (!string.IsNullOrWhiteSpace(site.SapServiceUrl)) + return site.SapServiceUrl; + + var sourceDefinition = _sourceSystemDefinitions + .FirstOrDefault(x => string.Equals(x.Code, site.SourceSystem, StringComparison.OrdinalIgnoreCase)); + + return string.IsNullOrWhiteSpace(sourceDefinition?.CentralServiceUrl) ? "-" : sourceDefinition.CentralServiceUrl; + } + + private string GetCentralSapServiceUrlSummary(string sourceSystem) + { + var sourceDefinition = _sourceSystemDefinitions + .FirstOrDefault(x => string.Equals(x.Code, sourceSystem, StringComparison.OrdinalIgnoreCase)); + + return string.IsNullOrWhiteSpace(sourceDefinition?.CentralServiceUrl) ? "-" : sourceDefinition.CentralServiceUrl; + } + + private string GetCentralHanaSummary(string sourceSystem) + { + var normalizedSourceSystem = string.IsNullOrWhiteSpace(sourceSystem) ? string.Empty : sourceSystem.Trim().ToUpperInvariant(); + var centralServer = _servers.FirstOrDefault(x => x.SourceSystem == normalizedSourceSystem); + if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host)) + return $"keine zentrale HANA-Konfiguration fuer {normalizedSourceSystem}"; + + return $"{centralServer.Name} | {GetServerNode(centralServer)}"; + } private async Task RefreshSapEntitySets() { @@ -813,20 +879,28 @@ _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; + var sourceDefinition = await db.SourceSystemDefinitions + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.Code == _editingSite.SourceSystem); + var serviceUrl = string.IsNullOrWhiteSpace(_editingSite.SapServiceUrl) + ? sourceDefinition?.CentralServiceUrl ?? string.Empty + : _editingSite.SapServiceUrl; + if (string.IsNullOrWhiteSpace(serviceUrl)) + throw new InvalidOperationException("Es ist weder eine zentrale SAP Service URL noch ein Standort-Override gesetzt."); + var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride) + ? sourceDefinition?.CentralUsername ?? string.Empty + : _editingSite.UsernameOverride; + var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride) + ? sourceDefinition?.CentralPassword ?? string.Empty + : _editingSite.PasswordOverride; if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) - throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt."); + throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt."); await AppEventLogService.WriteAsync("SAP", "Refresh aus UI gestartet", siteId: _editingSite.Id, land: _editingSite.Land, - details: _editingSite.SapServiceUrl); - var entitySets = await SapGatewayService.GetEntitySetsAsync(_editingSite.SapServiceUrl, username.Trim(), password.Trim()); + details: serviceUrl); + var entitySets = await SapGatewayService.GetEntitySetsAsync(serviceUrl, username.Trim(), password.Trim()); _sapEntitySetsCache = entitySets; _editingSite.SapEntitySetsCache = SerializeSapEntitySets(entitySets); _editingSite.SapEntitySetsRefreshedAtUtc = DateTime.UtcNow; @@ -884,7 +958,7 @@ var extension = Path.GetExtension(file.Name); if (!string.Equals(extension, ".xlsx", StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx auswählen."); + throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx auswählen."); } var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports"); @@ -974,13 +1048,13 @@ if (activeSources.Count < 2) { - Snackbar.Add("Für Auto-Match werden mindestens zwei aktive SAP-Quellen benötigt.", Severity.Warning); + Snackbar.Add("Für Auto-Match werden mindestens zwei aktive SAP-Quellen benötigt.", Severity.Warning); return; } if (_sapSourceFieldMap.Count == 0) { - Snackbar.Add("Bitte zuerst 'Felder aus Quellen laden' ausführen.", Severity.Warning); + Snackbar.Add("Bitte zuerst 'Felder aus Quellen laden' ausführen.", Severity.Warning); return; } @@ -1038,7 +1112,7 @@ } NormalizeSapConfigCollections(); - Snackbar.Add($"{createdOrUpdated} Join-Vorschläge gesetzt.", Severity.Success); + Snackbar.Add($"{createdOrUpdated} Join-Vorschläge gesetzt.", Severity.Success); } private void RemoveSapJoin(SapJoinDefinition join) @@ -1116,9 +1190,6 @@ _refreshingSapSourceFields = true; try { - if (string.IsNullOrWhiteSpace(_editingSite.SapServiceUrl)) - throw new InvalidOperationException("SAP Service URL muss gesetzt sein."); - var activeSources = _sapSources .Where(s => s.IsActive && !string.IsNullOrWhiteSpace(s.Alias) && !string.IsNullOrWhiteSpace(s.EntitySet)) .OrderBy(s => s.SortOrder) @@ -1129,18 +1200,29 @@ throw new InvalidOperationException("Es gibt keine aktiven SAP-Quellen mit Alias und Entity Set."); 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; + var sourceDefinition = await db.SourceSystemDefinitions + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.Code == _editingSite.SourceSystem); + var serviceUrl = string.IsNullOrWhiteSpace(_editingSite.SapServiceUrl) + ? sourceDefinition?.CentralServiceUrl ?? string.Empty + : _editingSite.SapServiceUrl; + if (string.IsNullOrWhiteSpace(serviceUrl)) + throw new InvalidOperationException("Es ist weder eine zentrale SAP Service URL noch ein Standort-Override gesetzt."); + var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride) + ? sourceDefinition?.CentralUsername ?? string.Empty + : _editingSite.UsernameOverride; + var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride) + ? sourceDefinition?.CentralPassword ?? string.Empty + : _editingSite.PasswordOverride; if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) - throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt."); + throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt."); var expressions = new List { "=SAP" }; var sourceFieldMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var source in activeSources) { - var fieldNames = await SapGatewayService.GetEntityFieldNamesAsync(_editingSite.SapServiceUrl, source.EntitySet, username.Trim(), password.Trim()); + var fieldNames = await SapGatewayService.GetEntityFieldNamesAsync(serviceUrl, source.EntitySet, username.Trim(), password.Trim()); sourceFieldMap[source.Alias] = fieldNames; expressions.AddRange(fieldNames.Select(field => $"{source.Alias}.{field}")); } @@ -1250,4 +1332,6 @@ .Where(x => !string.IsNullOrWhiteSpace(x)) .ToHashSet(StringComparer.OrdinalIgnoreCase) ?? []; -} \ No newline at end of file +} + + diff --git a/TrafagSalesExporter/Components/Pages/Transformations.razor b/TrafagSalesExporter/Components/Pages/Transformations.razor index 905e880..a4436a7 100644 --- a/TrafagSalesExporter/Components/Pages/Transformations.razor +++ b/TrafagSalesExporter/Components/Pages/Transformations.razor @@ -45,9 +45,9 @@ - @foreach (var system in _systems) + @foreach (var system in _sourceSystems.Where(x => x.IsActive)) { - @system + @system.DisplayName (@system.Code) } @@ -176,7 +176,6 @@ @code { - private readonly string[] _systems = ["SAP", "BI1", "SAGE", "MANUAL_EXCEL"]; private readonly string[] _ruleScopes = ["Value", "Record"]; private readonly string[] _recordFields = typeof(SalesRecord) .GetProperties(BindingFlags.Public | BindingFlags.Instance) @@ -185,6 +184,7 @@ .ToArray(); private List _rules = new(); + private List _sourceSystems = []; private IReadOnlyList _catalogItems = []; private bool _codeDialogVisible; private FieldTransformationRule? _selectedRule; @@ -200,6 +200,7 @@ private async Task LoadAsync() { using var db = await DbFactory.CreateDbContextAsync(); + _sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync(); _rules = await db.FieldTransformationRules.OrderBy(r => r.SortOrder).ThenBy(r => r.Id).ToListAsync(); foreach (var rule in _rules) @@ -217,7 +218,7 @@ var nextSort = _rules.Count == 0 ? 10 : _rules.Max(r => r.SortOrder) + 10; _rules.Add(new FieldTransformationRule { - SourceSystem = "SAP", + SourceSystem = _sourceSystems.FirstOrDefault(x => x.IsActive)?.Code ?? "SAP", RuleScope = "Value", SourceField = nameof(SalesRecord.Material), TargetField = nameof(SalesRecord.Material), diff --git a/TrafagSalesExporter/Data/AppDbContext.cs b/TrafagSalesExporter/Data/AppDbContext.cs index f468bf2..19f5794 100644 --- a/TrafagSalesExporter/Data/AppDbContext.cs +++ b/TrafagSalesExporter/Data/AppDbContext.cs @@ -8,6 +8,7 @@ public class AppDbContext : DbContext public AppDbContext(DbContextOptions options) : base(options) { } public DbSet HanaServers => Set(); + public DbSet SourceSystemDefinitions => Set(); public DbSet Sites => Set(); public DbSet SharePointConfigs => Set(); public DbSet ExportSettings => Set(); diff --git a/TrafagSalesExporter/HANDOFF_2026-04-15.md b/TrafagSalesExporter/HANDOFF_2026-04-15.md index 64d9474..a2241bd 100644 --- a/TrafagSalesExporter/HANDOFF_2026-04-15.md +++ b/TrafagSalesExporter/HANDOFF_2026-04-15.md @@ -43,6 +43,426 @@ Ergebnis: - bekannte Warnung bleibt: - SAP HANA Architekturwarnung `MSB3270` +## Architekturpruefung 2026-04-17 + +Es wurde eine erneute Gesamtpruefung der Architektur gemacht, ausdruecklich ohne neue Implementierung. + +### Gesamturteil + +Die Grundrichtung ist weiterhin sinnvoll: + +- klare Trennung der Quellsysteme `SAP`, `BI1`, `SAGE`, `MANUAL_EXCEL` +- zentrales fachliches Zielschema ueber `SalesRecord` +- zentrale technische Ablage ueber `CentralSalesRecords` +- separater Orchestrator fuer Standort- und Konsolidierungsexport +- Transformationssystem als eigener Layer + +Aber: + +- die Architektur ist **noch nicht stabil genug**, um sie als "fertig sauber" zu betrachten +- die groessten Risiken liegen aktuell nicht in SAP oder Waehrungen, sondern in + - Start-/Schema-Initialisierung + - Config-Import + - Verteilung von Logik zwischen Razor-Seiten und Services + +### Wichtigste Architektur-Risiken + +#### 1. Start-/Schema-Initialisierung ist fragil + +`DatabaseInitializationService` mischt derzeit: + +- `EnsureCreated` +- manuelle `ALTER TABLE`-Pflege +- FK-Reparaturlogik +- Seeding +- empfohlenes Regel-Seeding + +Das ist funktional hilfreich, aber architektonisch gefaehrlich, weil: + +- die App-Initialisierung dadurch viel implizite Datenmigration enthaelt +- Verhalten schwer vorhersehbar wird +- Fehler im Migrationspfad sofort produktive Daten treffen + +Wichtiger konkreter Befund aus der Pruefung: + +- beim Kopieren von `Sites_old` nach `Sites` ist die Spaltenreihenfolge im SQL inkonsistent +- dadurch koennen Werte wie `ManualImportFilePath`, `SapServiceUrl`, `SapEntitySet` verschoben gespeichert werden +- das ist eine reale Datenkorruptionsgefahr und kein reines Architekturthema + +### 2. Config-Import ist destruktiv und nicht atomar + +`ConfigTransferService.ImportJsonAsync` loescht aktuell zuerst grosse Teile der Konfiguration und Daten: + +- Sites +- HanaServers +- Transformation Rules +- SAP-Konfiguration +- Wechselkurse +- sogar `CentralSalesRecords` + +und baut danach mit mehreren `SaveChangesAsync()`-Zwischenschritten neu auf. + +Risiko: + +- wenn der Import in der Mitte scheitert, bleibt das System teilweise geloescht zurueck +- `CentralSalesRecords` gehoeren fachlich ohnehin nicht sauber in einen normalen Config-Import + +### 3. Zu viel Fach- und Persistenzlogik in Razor-Seiten + +`Settings.razor` und `Standorte.razor` machen aktuell sehr viel direkt: + +- `DbContext` oeffnen +- Daten laden und speichern +- Konfigurationsimport/-export anstossen +- SAP-Refresh +- Upload-Handling +- Teile der Validierung / Persistenzlogik + +Das funktioniert momentan, fuehrt aber langfristig zu: + +- schwer testbarer UI-Logik +- verstreuten Regeln +- hoeherem Seiteneffekt-Risiko bei Erweiterungen + +### 4. Vertrag zwischen Orchestrator und konsolidiertem Export ist unscharf + +`ExportOrchestrationService` sammelt bei `ExportAllAsync` bereits `consolidatedRecords`, uebergibt sie weiter, aber `ConsolidatedExportService` ignoriert diesen Parameter und liest erneut aus `CentralSalesRecords`. + +Das zeigt ein offenes Architekturthema: + +- Soll die zentrale Datei aus dem Live-Exportlauf gebaut werden? +- oder immer nur aus dem persistenten Read Model `CentralSalesRecords`? + +Aktuell ist beides halb vorhanden. + +### 5. Reporting-/Cockpit-Logik ist noch nicht voll verallgemeinert + +Bei der Pruefung wurde gesehen: + +- `ManagementCockpitService` enthaelt noch hartcodierte Jahreslogik fuer `2025` und `2026` +- die Rohsicht bleibt bewusst ohne CHF-Umrechnung + +Das ist fuer den aktuellen Stand akzeptabel, zeigt aber: + +- Reporting ist noch kein voll abstrahierter fachlicher Layer + +## Empfohlenes Sollbild + +Die naechste Architektur-Stufe sollte in diese Richtung gehen: + +### 1. Klare Schichten + +- UI: + - Razor nur fuer Interaktion, Anzeige, Formularzustand +- Application: + - Use Cases / Commands / Queries fuer Export, Config, SAP-Refresh, Wechselkurse, Standortpflege +- Domain / Fachlogik: + - Transformationen, Mappingregeln, Waehrungsumrechnung, Cockpit-Berechnungen +- Infrastructure: + - HANA, SAP Gateway, SQLite, SharePoint, Dateisystem + +### 2. Versionierte Migrationen statt manueller Start-Reparaturen + +Statt immer mehr Reparaturlogik beim App-Start: + +- Schema-Aenderungen versionieren +- Migrationspfade testbar machen +- Startlogik nur noch fuer minimale Bootstrap-Aufgaben behalten + +### 3. Config-Import als atomarer Vorgang + +Ziel: + +- alles in einer Transaktion oder bewusst in klar getrennten Phasen +- kein halb geloeschter Zustand bei Fehlern +- `CentralSalesRecords` aus normalem Config-Import eher herausnehmen + +### 4. Zentrale Export-Semantik entscheiden + +Explizit festlegen: + +- zentrale Datei immer aus `CentralSalesRecords` + oder +- zentrale Datei aus dem aktuellen Export-Snapshot + +Danach die doppelte Semantik entfernen. + +## Priorisierung aus Architektursicht + +Wenn nach Stabilitaet priorisiert wird, dann in dieser Reihenfolge: + +1. `DatabaseInitializationService` / Migrationspfad absichern +2. `ConfigTransferService.ImportJsonAsync` atomar und weniger destruktiv machen +3. Logik aus `Settings.razor` und `Standorte.razor` in Anwendungsservices verschieben +4. Export-Semantik fuer Konsolidierung vereinheitlichen +5. erst danach weitere Fachfeatures wie Cockpit-CHF, Budget, Gruppenlogik + +## Kurzfazit + +Die Architektur ist nicht schlecht. Das Grundmodell traegt. + +Aber: + +- sie ist noch nicht robust genug fuer ruhigen weiteren Ausbau ohne technische Konsolidierung +- die aktuelle Hauptgefahr liegt in Infrastruktur- und Persistenzlogik, nicht in den Fachfeatures + +Fuer den naechsten Einstieg nach Absturz gilt daher: + +1. zuerst diesen Architektur-Nachtrag lesen +2. dann `DatabaseInitializationService` und `ConfigTransferService` als Risikobloecke ansehen +3. neue Fachfeatures erst nach dieser technischen Konsolidierung beginnen + +## Nachtrag HANA-/Standort-Workflow 2026-04-17 + +Nach der Architekturpruefung wurde der doppelte HANA-Workflow bereinigt. + +### Altes Problem + +Vorher gab es zwei konkurrierende Stellen fuer HANA-Konfiguration: + +- oben eine eigene `HANA Server`-Verwaltung +- unten im Standortdialog noch einmal eine fast vollstaendige HANA-Verbindung + +Dadurch war unklar: + +- was die zentrale Wahrheit ist +- wann ein zentraler Server geaendert wird +- wann still ein separater Server pro Standort entsteht + +### Neue Logik + +Oben gilt jetzt: + +- `HANA Server` ist zentrale HANA-Konfiguration pro Quellsystem +- aktuell relevant fuer: + - `BI1` + - `SAGE` + +Unten im Standort gilt jetzt: + +- Standort pflegt nur noch standortspezifische Daten + - `Schema` + - `TSC` + - `Land` + - `SourceSystem` + - optionale Username-/Password-Overrides +- die technische HANA-Verbindung kommt aus der zentralen Konfiguration des Quellsystems + +### Technische Umsetzung + +- `HanaServer` hat jetzt zusaetzlich `SourceSystem` +- `DatabaseInitializationService` stellt zentrale Eintraege fuer `BI1` und `SAGE` sicher +- bestehende verknuepfte HANA-Server werden dabei moeglichst auf `BI1` / `SAGE` gemappt +- `SiteExportService` baut HANA-Verbindungen jetzt aus der zentralen HANA-Konfiguration des Quellsystems +- `Settings.razor` testet BI1/SAGE nicht mehr ueber einen Beispiel-Standort, sondern ueber die zentrale HANA-Konfiguration +- `Standorte.razor` speichert im Standort fuer HANA-basierte Systeme keine eigene Vollverbindung mehr + +### Wichtige Konsequenz + +Fachlich gilt jetzt: + +- oben = Standardkonfiguration pro Quellsystem +- unten = Standort + optionale Credential-Overrides + +Das entspricht der gewuenschten Logik: + +- gleiche BI1-/SAGE-Standorte koennen zentrale Verbindungswerte teilen +- Ausnahmen koennen weiter ueber Username-/Password-Overrides reagieren + +### UI-Nachtrag + +Die frueher doppelte und dadurch verwirrende UI wurde danach auch sichtbar bereinigt. + +Aktueller UI-Stand: + +- oben heisst der Bereich jetzt klar `Zentrale HANA-Konfiguration` +- im Standortdialog gibt es fuer HANA keine zweite technische Eingabestrecke mehr +- dort wird nur noch die aktive Zentralverbindung angezeigt +- Host, Port, SSL und technische Parameter werden explizit nach oben verwiesen +- der zentrale Verbindungstest in `Settings.razor` meldet jetzt sauber die zentrale HANA-Verbindung + +## Nachtrag Quellsystem-Verwaltung 2026-04-17 + +Die bisher noch hart codierten Quellsystem-Listen wurden entfernt und durch echte Stammdaten ersetzt. + +### Neuer Stand + +- neues Modell `SourceSystemDefinition` +- Quellsysteme werden jetzt zentral in der DB gehalten statt in Razor-Arrays +- pro Quellsystem werden gepflegt: + - `Code` + - `DisplayName` + - `ConnectionKind` + - `IsActive` + - `CentralUsername` + - `CentralPassword` + +### Neue GUI-Logik + +- `Settings.razor` enthaelt jetzt eine pflegbare Quellsystem-Tabelle +- dort koennen Quellsysteme per GUI angelegt, bearbeitet und gespeichert werden +- Anschlussart ist nicht mehr implizit im Code, sondern pro Quellsystem konfigurierbar +- zentrale Zugangsdaten haengen jetzt am Quellsystem selbst + +### Anschlussarten + +Aktuell technisch vorgesehen: + +- `HANA` +- `SAP_GATEWAY` +- `MANUAL_EXCEL` + +Damit gilt: + +- HANA-Konfiguration oben in `Standorte.razor` nur noch fuer Quellsysteme mit Anschlussart `HANA` +- Standort-Dropdown zieht seine Quellsysteme jetzt aus `SourceSystemDefinitions` +- Transformationsregeln ziehen ihre Quellsystem-Auswahl ebenfalls aus `SourceSystemDefinitions` + +### Technische Umsetzung + +- `AppDbContext` hat jetzt `DbSet` +- `DatabaseInitializationService` erzeugt und seedet `SourceSystemDefinitions` +- `SiteExportService` loest zentrale Credentials jetzt ueber `SourceSystemDefinition` +- `ConfigTransferService` exportiert/importiert jetzt auch `SourceSystemDefinitions` + +### Verifikation + +Nach dieser Umstellung geprueft: + +```text +dotnet build .\TrafagSalesExporter.csproj -v minimal +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal +``` + +Ergebnis: + +- Build erfolgreich +- Tests erfolgreich +- `31/31` Tests gruen + +### Bereinigung der Legacy-Credentials + +Danach wurden auch die alten zentralen Credential-Felder technisch bereinigt. + +Aktueller Stand: + +- `ExportSettings` enthaelt keine alten Felder mehr fuer `SapUsername`, `Bi1Username`, `SageUsername` usw. +- der Config-Export schreibt zentrale Zugangsdaten nur noch ueber `SourceSystemDefinitions` +- `ConfigTransferService` hat keinen aktiven Legacy-Credential-Pfad mehr +- die fruehere Temp-Datei `standorte_numbered.tmp` wurde entfernt + +Wichtig: + +- bestehende DB-Spalten koennen physisch noch vorhanden sein, sind aber kein aktiver Codepfad mehr +- fuehrende Wahrheit fuer zentrale Zugangsdaten ist jetzt ausschliesslich `SourceSystemDefinition` + +### Schema-Bereinigung + +Danach wurde auch die SQLite-Schemabereinigung nachgezogen. + +Aktueller Stand: + +- `DatabaseInitializationService` erkennt alte Credential-Spalten in `ExportSettings` +- wenn diese Legacy-Spalten noch existieren, wird `ExportSettings` beim Start auf das neue Schema rekonstruiert +- erhalten bleiben nur die noch gueltigen Felder: + - `DateFilter` + - `TimerHour` + - `TimerMinute` + - `TimerEnabled` + - `DebugLoggingEnabled` + - `LocalSiteExportFolder` + - `LocalConsolidatedExportFolder` + +Damit gilt jetzt: + +- alte zentrale SAP/BI1/SAGE-Credentials sind nicht nur logisch entfernt +- sie werden bei bestehender DB auch aktiv aus dem `ExportSettings`-Schema entfernt + +### Letzte Bereinigung HANA-Credentials + +Danach wurde auch die letzte doppelte Credential-Stelle in der HANA-Verwaltung entfernt. + +Aktueller Stand: + +- zentrale HANA-Konfiguration speichert nur noch technische Verbindungsdaten + - `Host` + - `Port` + - `DatabaseName` + - `UseSsl` + - `ValidateCertificate` + - `AdditionalParams` +- Username/Password werden nicht mehr in der zentralen HANA-UI gepflegt +- HANA-Verbindungstests in `Standorte.razor` verwenden jetzt die zentralen Credentials aus `SourceSystemDefinition` +- `SiteExportService` faellt bei HANA nicht mehr auf in `HanaServer` gespeicherte Credentials zurueck +- `ConfigTransferService` exportiert/importiert fuer `HanaServer` keine Username-/Password-Werte mehr +- `DatabaseInitializationService` bereinigt bei bestehender DB auch das `HanaServers`-Schema und entfernt die Altspalten `Username` / `Password` + +Die fachliche Reihenfolge ist jetzt eindeutig: + +1. zentrale Credentials aus `SourceSystemDefinition` +2. optionale Override-Credentials am `Site` +3. technische HANA-Verbindung aus der zentralen HANA-Konfiguration + +### EF-/SQLite-Fix + +Beim ersten Lauf nach der Schema-Bereinigung trat noch ein Mapping-Fehler auf: + +- `SQLite Error 1: 'no such column: h.Password'` + +Ursache: + +- `HanaServers`-Schema war bereits ohne `Username` / `Password` +- das EF-Modell `HanaServer` hat diese Properties aber noch als normale Spalten behandelt + +Fix: + +- `HanaServer.Username` und `HanaServer.Password` sind jetzt `[NotMapped]` +- damit bleiben sie fuer Laufzeit-Verbindungsaufbau und Tests nutzbar +- EF erwartet sie aber nicht mehr als Datenbankspalten + +## Nachtrag Zentrale SAP-Steuerung 2026-04-17 + +Der verbleibende Architekturbruch bei SAP wurde ebenfalls bereinigt. + +### Neuer Stand + +- `SourceSystemDefinition` enthaelt jetzt auch `CentralServiceUrl` +- zentrale SAP-Service-URL wird damit am Quellsystem gepflegt, nicht mehr primaer am Standort +- `Standorte.razor` behandelt `SapServiceUrl` jetzt als Override +- wenn kein Override gesetzt ist, zieht SAP die URL zentral aus dem Quellsystem + +### UI + +- `Settings.razor` hat fuer Quellsysteme jetzt eine Dialogbearbeitung statt nur Inline-Tabellenfelder +- dadurch ist das Quellsystem sauber editierbar +- fuer `SAP_GATEWAY` wird dort die zentrale SAP-Service-URL gepflegt +- `Standorte.razor` zeigt bei SAP jetzt: + - zentrale SAP Service URL + - optionales `SAP Service URL Override` + +### Laufzeitlogik + +- `SiteExportService` verwendet bei SAP die effektive URL aus + - Standort-Override + - sonst `SourceSystemDefinition.CentralServiceUrl` +- SAP-Verbindungstest in `Settings.razor` testet die zentrale URL direkt aus dem Quellsystem +- Dashboard zeigt fuer SAP jetzt ebenfalls die effektive zentrale bzw. ueberschriebene URL + +### Verifikation + +Nach der Umstellung geprueft: + +```text +dotnet build .\TrafagSalesExporter.csproj -v minimal +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal +``` + +Ergebnis: + +- Build erfolgreich +- Tests erfolgreich +- `31/31` Tests gruen + ## Nachtrag 2026-04-16 Seit dem letzten Handoff wurden weitere Funktionen umgesetzt, die unten im alten Stand noch nicht voll enthalten sind. @@ -518,3 +938,45 @@ Ergebnis: - bekannte Warnungen bleiben: - SAP HANA Architekturwarnung `MSB3270` - MudBlazor Analyzer `Dense` + +## Nachtrag 2026-04-17 UI-Klarstellung HANA vs. SAP + +- `Components/Pages/Standorte.razor` + - Bereich oben heisst jetzt bewusst `Zentrale HANA-Technik` + - Hinweistext stellt klar: dort erscheinen nur Quellsysteme mit Anschlussart `HANA` + - `SAP` wird zentral unter `Settings -> Quellsysteme` gepflegt und gehoert nicht in diese Box + - der irrefuehrende Button `Server hinzufuegen` wurde entfernt + - neue HANA-Zeilen entstehen aus den Quellsystem-Stammdaten, nicht mehr aus einer zweiten UI-Erfassung + - Dialogtitel fuer HANA wurde auf reine Bearbeitung der zentralen Technik reduziert + +Fachliche Regel jetzt: + +- `Quellsysteme` verwalten die zentralen Systeme und deren Anschlussart +- `Standorte` zeigen fuer HANA nur noch die technische Zentralverbindung +- `SAP` wird nicht mehr implizit in der HANA-Box erwartet + +## Nachtrag 2026-04-17 Pruefung Config-Import/Export + +Der aktuelle Config-Transfer wurde nach den Umbauten nochmals geprueft. + +Status: + +- Das aktuelle Import-/Exportformat passt zum neuen Modell. +- `SourceSystemDefinitions` werden mit `ConnectionKind`, `CentralServiceUrl`, `CentralUsername`, `CentralPassword` importiert/exportiert. +- `HanaServers` enthalten nur noch technische HANA-Verbindungsdaten und keine Credentials mehr. +- Standort-Overrides fuer Username/Password sowie SAP Service URL gehen weiterhin mit. +- Die vorhandenen `ConfigTransferServiceTests` laufen grün. + +Weiterhin offene Architekturpunkte: + +- `ConfigTransferService.ImportJsonAsync` ist weiterhin destruktiv und nicht atomar. + - Erst werden bestehende Daten geloescht, danach wird in mehreren Schritten neu aufgebaut. + - Wenn der Import in der Mitte scheitert, bleibt ein teilweiser Zustand zurueck. +- Altformat-Risiko bei `ConnectionKind`: + - Wenn ein aelteres JSON bereits `SourceSystemDefinitions` enthaelt, aber noch ohne `ConnectionKind`, faellt der DTO-Default auf `HANA`. + - Dadurch koennte ein altes `SAP` beim Import falsch als `HANA` landen. + +Fazit: + +- Fuer Exporte aus dem aktuellen Stand ist der Config-Transfer konsistent. +- Fuer aeltere JSON-Staende braucht der Import noch eine explizite Migrations-/Fallback-Logik. diff --git a/TrafagSalesExporter/Models/ConfigTransferPackage.cs b/TrafagSalesExporter/Models/ConfigTransferPackage.cs index b5f2906..eef54a7 100644 --- a/TrafagSalesExporter/Models/ConfigTransferPackage.cs +++ b/TrafagSalesExporter/Models/ConfigTransferPackage.cs @@ -7,6 +7,7 @@ public class ConfigTransferPackage public bool IncludesSecrets { get; set; } public ConfigTransferSharePoint? SharePointConfig { get; set; } public ConfigTransferExportSettings? ExportSettings { get; set; } + public List SourceSystemDefinitions { get; set; } = []; public List CurrencyExchangeRates { get; set; } = []; public List HanaServers { get; set; } = []; public List Sites { get; set; } = []; @@ -16,6 +17,17 @@ public class ConfigTransferPackage public List SapFieldMappings { get; set; } = []; } +public class ConfigTransferSourceSystemDefinition +{ + public string Code { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string ConnectionKind { get; set; } = SourceSystemConnectionKinds.Hana; + public bool IsActive { get; set; } = true; + public string CentralServiceUrl { get; set; } = string.Empty; + public string? CentralUsername { get; set; } + public string? CentralPassword { get; set; } +} + public class ConfigTransferSharePoint { public string SiteUrl { get; set; } = string.Empty; @@ -35,12 +47,6 @@ public class ConfigTransferExportSettings public bool DebugLoggingEnabled { get; set; } public string LocalSiteExportFolder { get; set; } = string.Empty; public string LocalConsolidatedExportFolder { get; set; } = string.Empty; - public string? SapUsername { get; set; } - public string? SapPassword { get; set; } - public string? Bi1Username { get; set; } - public string? Bi1Password { get; set; } - public string? SageUsername { get; set; } - public string? SagePassword { get; set; } } public class ConfigTransferCurrencyExchangeRate @@ -57,11 +63,10 @@ public class ConfigTransferCurrencyExchangeRate public class ConfigTransferHanaServer { public string Key { get; set; } = Guid.NewGuid().ToString("N"); + public string SourceSystem { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string Host { get; set; } = string.Empty; public int Port { get; set; } = 30015; - public string? Username { get; set; } - public string? Password { get; set; } public string DatabaseName { get; set; } = string.Empty; public bool UseSsl { get; set; } public bool ValidateCertificate { get; set; } @@ -75,7 +80,7 @@ public class ConfigTransferSite public string Schema { get; set; } = string.Empty; public string TSC { get; set; } = string.Empty; public string Land { get; set; } = string.Empty; - public string SourceSystem { get; set; } = "SAP"; + public string SourceSystem { get; set; } = string.Empty; public string? UsernameOverride { get; set; } public string? PasswordOverride { get; set; } public string LocalExportFolderOverride { get; set; } = string.Empty; diff --git a/TrafagSalesExporter/Models/ExportSettings.cs b/TrafagSalesExporter/Models/ExportSettings.cs index c5ed501..19594ce 100644 --- a/TrafagSalesExporter/Models/ExportSettings.cs +++ b/TrafagSalesExporter/Models/ExportSettings.cs @@ -10,10 +10,4 @@ public class ExportSettings public bool DebugLoggingEnabled { get; set; } public string LocalSiteExportFolder { get; set; } = string.Empty; public string LocalConsolidatedExportFolder { get; set; } = string.Empty; - public string SapUsername { get; set; } = string.Empty; - public string SapPassword { get; set; } = string.Empty; - public string Bi1Username { get; set; } = string.Empty; - public string Bi1Password { get; set; } = string.Empty; - public string SageUsername { get; set; } = string.Empty; - public string SagePassword { get; set; } = string.Empty; } diff --git a/TrafagSalesExporter/Models/FieldTransformationRule.cs b/TrafagSalesExporter/Models/FieldTransformationRule.cs index c50b653..c19d605 100644 --- a/TrafagSalesExporter/Models/FieldTransformationRule.cs +++ b/TrafagSalesExporter/Models/FieldTransformationRule.cs @@ -7,7 +7,7 @@ public class FieldTransformationRule public int Id { get; set; } [Required] - public string SourceSystem { get; set; } = "SAP"; + public string SourceSystem { get; set; } = string.Empty; [Required] public string SourceField { get; set; } = nameof(SalesRecord.Material); diff --git a/TrafagSalesExporter/Models/HanaServer.cs b/TrafagSalesExporter/Models/HanaServer.cs index d095dd5..fc244b8 100644 --- a/TrafagSalesExporter/Models/HanaServer.cs +++ b/TrafagSalesExporter/Models/HanaServer.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using System.Data.Common; namespace TrafagSalesExporter.Models; @@ -7,6 +8,9 @@ public class HanaServer { public int Id { get; set; } + [Required] + public string SourceSystem { get; set; } = string.Empty; + [Required] public string Name { get; set; } = string.Empty; @@ -15,8 +19,10 @@ public class HanaServer public int Port { get; set; } = 30015; + [NotMapped] public string Username { get; set; } = string.Empty; + [NotMapped] public string Password { get; set; } = string.Empty; /// @@ -66,6 +72,7 @@ public class HanaServer var pwdMasked = string.IsNullOrEmpty(Password) ? "" : "***"; var copy = new HanaServer { + SourceSystem = SourceSystem, Host = Host, Port = Port, Username = Username, diff --git a/TrafagSalesExporter/Models/Site.cs b/TrafagSalesExporter/Models/Site.cs index 68a49f5..26c8b73 100644 --- a/TrafagSalesExporter/Models/Site.cs +++ b/TrafagSalesExporter/Models/Site.cs @@ -22,7 +22,7 @@ public class Site public string Land { get; set; } = string.Empty; [Required] - public string SourceSystem { get; set; } = "SAP"; + public string SourceSystem { get; set; } = string.Empty; public string UsernameOverride { get; set; } = string.Empty; diff --git a/TrafagSalesExporter/Models/SourceSystemDefinition.cs b/TrafagSalesExporter/Models/SourceSystemDefinition.cs new file mode 100644 index 0000000..10fec56 --- /dev/null +++ b/TrafagSalesExporter/Models/SourceSystemDefinition.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrafagSalesExporter.Models; + +public class SourceSystemDefinition +{ + public int Id { get; set; } + + [Required] + public string Code { get; set; } = string.Empty; + + [Required] + public string DisplayName { get; set; } = string.Empty; + + [Required] + public string ConnectionKind { get; set; } = SourceSystemConnectionKinds.Hana; + + public bool IsActive { get; set; } = true; + + public string CentralServiceUrl { get; set; } = string.Empty; + + public string CentralUsername { get; set; } = string.Empty; + + public string CentralPassword { get; set; } = string.Empty; +} + +public static class SourceSystemConnectionKinds +{ + public const string Hana = "HANA"; + public const string SapGateway = "SAP_GATEWAY"; + public const string ManualExcel = "MANUAL_EXCEL"; + + public static readonly string[] All = [Hana, SapGateway, ManualExcel]; +} diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index 9be48f3..4cf2a1c 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -27,6 +27,147 @@ Was fuer Waehrungen trotzdem noch offen bleibt: - bestaetigen, fuer welche Sichten CHF die Zielwaehrung sein soll - Management-Cockpit-Rohsicht nur dann auf CHF umstellen, wenn fachlich gewuenscht +## Architektur-Nachtrag 2026-04-17 + +Nach einer separaten Architekturpruefung wurden die naechsten Schritte neu priorisiert. + +Wichtig: + +- neue Fachfeatures sind aktuell **nicht** der erste Engpass +- zuerst muessen die Architektur-Risiken in Initialisierung, Config-Import und UI-Service-Schnitt bereinigt werden + +### Neue Top-Prioritaeten + +#### 1. `DatabaseInitializationService` absichern + +Prio sehr hoch. + +Gruende: + +- Startlogik enthaelt manuelle Schema-Migrationen +- FK-Reparaturen laufen produktiv beim App-Start +- dort wurde ein konkretes Risiko fuer verschobene Spaltenwerte beim `Sites_old`-Kopierpfad erkannt + +Vor weiterer Fachentwicklung: + +- Initialisierungspfad genau pruefen +- SQL-Kopierlogik validieren +- moeglichst Richtung versionierte Migrationen bewegen + +#### 2. `ConfigTransferService.ImportJsonAsync` neu denken + +Prio sehr hoch. + +Aktuelles Problem: + +- Import loescht sehr viel und baut danach stueckweise neu auf +- nicht atomar +- potenziell teilzerstoerter Zustand bei Fehlern +- `CentralSalesRecords` werden mitimportiert/mitgeloescht, obwohl sie eher Laufzeitdaten als Konfiguration sind + +Ziel: + +- atomarer Import +- saubere Trennung zwischen Konfiguration und Betriebsdaten + +#### 3. Razor-Seiten entlasten + +Prio hoch. + +Betroffen vor allem: + +- `Components/Pages/Settings.razor` +- `Components/Pages/Standorte.razor` + +Ziel: + +- DB- und Fachlogik aus UI-Code in Services / Application-Layer verschieben +- Seiten nur noch fuer Interaktion und Formularzustand + +#### 4. Konsolidierten Export semantisch klaeren + +Prio mittel. + +Offene Frage: + +- zentrale Datei aus laufendem Snapshot + oder +- zentrale Datei immer aus `CentralSalesRecords` + +Aktuell ist die Verantwortung unscharf. + +#### 5. Reporting verallgemeinern + +Prio mittel. + +Erst nach den Infrastrukturthemen: + +- hartcodierte Jahreslogik im Cockpit entfernen +- fachlich entscheiden, ob und wo CHF-Rohsicht gebraucht wird + +### Praktische Reihenfolge fuer den naechsten Wiedereinstieg + +Wenn nach erneutem Absturz oder Kontextverlust weitergemacht wird: + +1. `HANDOFF_2026-04-15.md` lesen, speziell die Architekturpruefung vom 2026-04-17 +2. `DatabaseInitializationService` als ersten Risikoblock ansehen +3. `ConfigTransferService.ImportJsonAsync` als zweiten Risikoblock ansehen +4. erst danach wieder an Cockpit / CHF / weitere Fachfeatures gehen + +## Nachtrag HANA-/Standort-Workflow 2026-04-17 + +Der doppelte HANA-Workflow wurde inzwischen bereits bereinigt. + +Neuer Stand: + +- oben zentrale HANA-Konfiguration pro Quellsystem `BI1` / `SAGE` +- unten im Standort keine eigene wirksame Voll-HANA-Konfiguration mehr +- HANA-basierte Standorte ziehen ihre technische Verbindung aus der zentralen Quellsystem-Konfiguration +- Standort bleibt fuer fachliche Daten und optionale Credential-Overrides zustaendig +- die frueher doppelte HANA-UI im Standortdialog ist inzwischen auch sichtbar entfernt +- der Verbindungstest in `Settings.razor` prueft und meldet jetzt die zentrale HANA-Verbindung klar + +### Was dazu noch praktisch geprueft werden sollte + +- `Standorte`-Seite im UI manuell durchklicken +- pruefen, ob `BI1`- und `SAGE`-Standort beim Speichern sauber auf die zentrale HANA-Konfiguration zeigen +- pruefen, ob Aenderung oben bei zentraler HANA-Konfiguration in nachfolgenden Exporten wirklich greift + +### Anschlussarbeiten + +- `ConfigTransferService` spaeter auf das neue zentrale HANA-Modell fachlich nachziehen und kritisch pruefen +- `DatabaseInitializationService` weiter konsolidieren, damit die Zuordnung alter HANA-Daten langfristig robuster wird + +## Nachtrag Quellsystem-Verwaltung 2026-04-17 + +Die bisher hart codierten Quellsystem-Listen wurden ersetzt. + +Neuer Stand: + +- `SourceSystemDefinition` ist jetzt die zentrale Stammdatenquelle fuer Quellsysteme +- `Settings.razor` hat jetzt eine GUI zur Pflege von Quellsystemen +- `Standorte.razor` zieht seine Quellsystem-Auswahl aus diesen Stammdaten +- `Transformations.razor` zieht die Systemauswahl ebenfalls aus diesen Stammdaten +- zentrale Credentials haengen jetzt am Quellsystem selbst +- HANA-Zentralverbindungen werden nur noch fuer Quellsysteme mit Anschlussart `HANA` gezeigt +- alte zentrale Credential-Felder in `ExportSettings` sind aus dem aktiven Codepfad entfernt +- `ExportSettings` wird beim Start auch schematisch auf das neue Feldset bereinigt +- HANA speichert zentral keine eigenen Credentials mehr; dort bleiben nur technische Verbindungsdaten +- `HanaServer.Username` / `Password` sind nur noch Laufzeitfelder und nicht mehr im EF-Schema gemappt +- SAP Service URL wird jetzt zentral im Quellsystem gepflegt; der Standort haelt nur noch ein optionales Override +- Quellsysteme werden jetzt per Dialog bearbeitet statt nur ueber Inline-Tabellenfelder + +### Was dazu noch praktisch geprueft werden sollte + +- in `Settings` ein neues Quellsystem per GUI anlegen +- pruefen, ob es danach in `Standorte` und `Transformations` sofort auswählbar ist +- pruefen, ob deaktivierte Quellsysteme in neuen Standort-/Regelanlagen nicht mehr normal angeboten werden +- pruefen, ob Aenderung der Anschlussart von `HANA` auf `SAP_GATEWAY` oder `MANUAL_EXCEL` fachlich sauber wirkt +- pruefen, ob bestehende BI1/SAGE/SAP-Daten nach Startmigration korrekt in `SourceSystemDefinitions` stehen +- pruefen, ob Konfiguration-Export/Import ohne die alten Credential-Felder sauber mit `SourceSystemDefinitions` arbeitet +- pruefen, ob zentrale SAP Service URL ohne Override sauber fuer Refresh, Export und Dashboard greift +- pruefen, ob SAP Service URL Override am Standort die zentrale URL erwartungsgemaess uebersteuert + ## Nachtrag 2026-04-16 Seit dem letzten Stand kamen mehrere groessere Erweiterungen dazu. Die offenen Punkte unten muessen deshalb im neuen Kontext gelesen werden. @@ -183,3 +324,37 @@ Aktueller Teststatus: Fuer den vollstaendigen Kontext zuerst lesen: - `HANDOFF_2026-04-15.md` + +## 8. Letzte bereinigte UI-Irritation + +Stand 2026-04-17: + +- In `Standorte` wurde die obere Box auf `Zentrale HANA-Technik` geklaert. +- Dort gibt es keinen `Server hinzufuegen`-Pfad mehr. +- Grund: zentrale HANA-Eintraege werden aus `Quellsystemen` mit Anschlussart `HANA` abgeleitet. +- `SAP` gehoert fachlich nicht in diese Box, sondern in `Settings -> Quellsysteme`. + +Wichtig fuer den naechsten Wiedereinstieg: + +- Wenn ein Benutzer fragt `wo ist SAP?`, ist die richtige Antwort: nicht in der HANA-Box, sondern in der zentralen Quellsystem-Verwaltung. +- Wenn ein HANA-System oben fehlt, zuerst `Settings -> Quellsysteme` pruefen und dort Anschlussart `HANA` setzen. + +## 9. Config-Transfer erneut geprueft + +Stand 2026-04-17: + +- Der aktuelle Config-Import/-Export passt zum neuen Datenmodell. +- Zentral verwaltete Quellsysteme, SAP-Zentral-URL, HANA-Technik ohne HANA-Credentials und Standort-Overrides werden korrekt im Transferformat abgebildet. +- Die vorhandenen `ConfigTransferServiceTests` bestaetigen den aktuellen Rundlauf. + +Fuer den naechsten Wiedereinstieg wichtig: + +- Das aktuelle Format ist fuer heutige Exporte konsistent. +- `ImportJsonAsync` ist aber weiterhin nicht atomar und loescht zuerst produktive Konfiguration. +- Zusaetzlich gibt es ein Altformat-Risiko: + - aeltere JSONs mit `SourceSystemDefinitions`, aber ohne `ConnectionKind`, koennen wegen DTO-Default falsch als `HANA` interpretiert werden. + +Naechste saubere Haertung fuer dieses Thema: + +- Config-Import transaktional machen +- Legacy-Fallback fuer fehlendes `ConnectionKind` einbauen diff --git a/TrafagSalesExporter/Services/ConfigTransferService.cs b/TrafagSalesExporter/Services/ConfigTransferService.cs index 0bd0722..bf0b279 100644 --- a/TrafagSalesExporter/Services/ConfigTransferService.cs +++ b/TrafagSalesExporter/Services/ConfigTransferService.cs @@ -20,6 +20,7 @@ public class ConfigTransferService : IConfigTransferService using var db = await _dbFactory.CreateDbContextAsync(); var sharePoint = await db.SharePointConfigs.FirstOrDefaultAsync(); var exportSettings = await db.ExportSettings.FirstOrDefaultAsync(); + var sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync(); var exchangeRates = await db.CurrencyExchangeRates .OrderBy(x => x.FromCurrency) .ThenBy(x => x.ToCurrency) @@ -55,14 +56,18 @@ public class ConfigTransferService : IConfigTransferService TimerEnabled = exportSettings.TimerEnabled, DebugLoggingEnabled = exportSettings.DebugLoggingEnabled, LocalSiteExportFolder = exportSettings.LocalSiteExportFolder, - LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder, - SapUsername = includeSecrets ? exportSettings.SapUsername : null, - SapPassword = includeSecrets ? exportSettings.SapPassword : null, - Bi1Username = includeSecrets ? exportSettings.Bi1Username : null, - Bi1Password = includeSecrets ? exportSettings.Bi1Password : null, - SageUsername = includeSecrets ? exportSettings.SageUsername : null, - SagePassword = includeSecrets ? exportSettings.SagePassword : null + LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder }, + SourceSystemDefinitions = sourceSystems.Select(system => new ConfigTransferSourceSystemDefinition + { + Code = system.Code, + DisplayName = system.DisplayName, + ConnectionKind = system.ConnectionKind, + IsActive = system.IsActive, + CentralServiceUrl = system.CentralServiceUrl, + CentralUsername = includeSecrets ? system.CentralUsername : null, + CentralPassword = includeSecrets ? system.CentralPassword : null + }).ToList(), CurrencyExchangeRates = exchangeRates.Select(rate => new ConfigTransferCurrencyExchangeRate { FromCurrency = rate.FromCurrency, @@ -76,11 +81,10 @@ public class ConfigTransferService : IConfigTransferService HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer { Key = serverKeyMap[server.Id], + SourceSystem = server.SourceSystem, Name = server.Name, Host = server.Host, Port = server.Port, - Username = includeSecrets ? server.Username : null, - Password = includeSecrets ? server.Password : null, DatabaseName = server.DatabaseName, UseSsl = server.UseSsl, ValidateCertificate = server.ValidateCertificate, @@ -158,6 +162,7 @@ public class ConfigTransferService : IConfigTransferService using var db = await _dbFactory.CreateDbContextAsync(); var existingSharePoint = await db.SharePointConfigs.FirstOrDefaultAsync(); var existingSettings = await db.ExportSettings.FirstOrDefaultAsync(); + var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync(); var existingServers = await db.HanaServers.ToListAsync(); var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync(); var existingSites = await db.Sites.ToListAsync(); @@ -168,20 +173,10 @@ public class ConfigTransferService : IConfigTransferService var existingCentralRecords = await db.CentralSalesRecords.ToListAsync(); var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty; - var preservedSecrets = existingSettings is null - ? new ConfigTransferExportSettings() - : new ConfigTransferExportSettings - { - SapUsername = existingSettings.SapUsername, - SapPassword = existingSettings.SapPassword, - Bi1Username = existingSettings.Bi1Username, - Bi1Password = existingSettings.Bi1Password, - SageUsername = existingSettings.SageUsername, - SagePassword = existingSettings.SagePassword - }; - var preservedServerSecrets = existingServers.ToDictionary( - x => BuildServerSignature(x.Name, x.Host, x.Port, x.DatabaseName), - x => (x.Username, x.Password)); + var preservedSourceSystemSecrets = existingSourceSystems.ToDictionary( + x => x.Code, + x => (CentralUsername: x.CentralUsername, CentralPassword: x.CentralPassword), + StringComparer.OrdinalIgnoreCase); var preservedSiteSecrets = existingSites.ToDictionary( x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem), x => (x.UsernameOverride, x.PasswordOverride)); @@ -194,6 +189,7 @@ public class ConfigTransferService : IConfigTransferService if (existingCentralRecords.Count > 0) db.CentralSalesRecords.RemoveRange(existingCentralRecords); if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites); if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers); + if (existingSourceSystems.Count > 0) db.SourceSystemDefinitions.RemoveRange(existingSourceSystems); if (existingSharePoint is not null) db.SharePointConfigs.Remove(existingSharePoint); if (existingSettings is not null) db.ExportSettings.Remove(existingSettings); await db.SaveChangesAsync(); @@ -218,15 +214,28 @@ public class ConfigTransferService : IConfigTransferService TimerEnabled = importedSettings.TimerEnabled, DebugLoggingEnabled = importedSettings.DebugLoggingEnabled, LocalSiteExportFolder = importedSettings.LocalSiteExportFolder, - LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder, - SapUsername = package.IncludesSecrets ? importedSettings.SapUsername ?? string.Empty : preservedSecrets.SapUsername ?? string.Empty, - SapPassword = package.IncludesSecrets ? importedSettings.SapPassword ?? string.Empty : preservedSecrets.SapPassword ?? string.Empty, - Bi1Username = package.IncludesSecrets ? importedSettings.Bi1Username ?? string.Empty : preservedSecrets.Bi1Username ?? string.Empty, - Bi1Password = package.IncludesSecrets ? importedSettings.Bi1Password ?? string.Empty : preservedSecrets.Bi1Password ?? string.Empty, - SageUsername = package.IncludesSecrets ? importedSettings.SageUsername ?? string.Empty : preservedSecrets.SageUsername ?? string.Empty, - SagePassword = package.IncludesSecrets ? importedSettings.SagePassword ?? string.Empty : preservedSecrets.SagePassword ?? string.Empty + LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder }); + var importedSourceSystems = package.SourceSystemDefinitions.Count > 0 + ? package.SourceSystemDefinitions + : BuildDefaultSourceSystems(); + + foreach (var sourceSystem in importedSourceSystems) + { + preservedSourceSystemSecrets.TryGetValue(sourceSystem.Code, out var preserved); + db.SourceSystemDefinitions.Add(new SourceSystemDefinition + { + Code = sourceSystem.Code, + DisplayName = sourceSystem.DisplayName, + ConnectionKind = sourceSystem.ConnectionKind, + IsActive = sourceSystem.IsActive, + CentralServiceUrl = sourceSystem.CentralServiceUrl, + CentralUsername = package.IncludesSecrets ? sourceSystem.CentralUsername ?? string.Empty : preserved.CentralUsername ?? string.Empty, + CentralPassword = package.IncludesSecrets ? sourceSystem.CentralPassword ?? string.Empty : preserved.CentralPassword ?? string.Empty + }); + } + if (package.CurrencyExchangeRates.Count > 0) { db.CurrencyExchangeRates.AddRange(package.CurrencyExchangeRates.Select(rate => new CurrencyExchangeRate @@ -244,14 +253,14 @@ public class ConfigTransferService : IConfigTransferService var serverIdMap = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var server in package.HanaServers) { - preservedServerSecrets.TryGetValue(BuildServerSignature(server.Name, server.Host, server.Port, server.DatabaseName), out var preserved); var entity = new HanaServer { + SourceSystem = server.SourceSystem, Name = server.Name, Host = server.Host, Port = server.Port, - Username = package.IncludesSecrets ? server.Username ?? string.Empty : preserved.Username ?? string.Empty, - Password = package.IncludesSecrets ? server.Password ?? string.Empty : preserved.Password ?? string.Empty, + Username = string.Empty, + Password = string.Empty, DatabaseName = server.DatabaseName, UseSsl = server.UseSsl, ValidateCertificate = server.ValidateCertificate, @@ -355,10 +364,42 @@ public class ConfigTransferService : IConfigTransferService await db.SaveChangesAsync(); } - - private static string BuildServerSignature(string name, string host, int port, string databaseName) - => $"{name}|{host}|{port}|{databaseName}".ToUpperInvariant(); - private static string BuildSiteSignature(string land, string tsc, string schema, string sourceSystem) => $"{land}|{tsc}|{schema}|{sourceSystem}".ToUpperInvariant(); + + private static List BuildDefaultSourceSystems() + { + return + [ + new ConfigTransferSourceSystemDefinition + { + Code = "SAP", + DisplayName = "SAP", + ConnectionKind = SourceSystemConnectionKinds.SapGateway, + IsActive = true, + CentralServiceUrl = string.Empty + }, + new ConfigTransferSourceSystemDefinition + { + Code = "BI1", + DisplayName = "BI1", + ConnectionKind = SourceSystemConnectionKinds.Hana, + IsActive = true + }, + new ConfigTransferSourceSystemDefinition + { + Code = "SAGE", + DisplayName = "SAGE", + ConnectionKind = SourceSystemConnectionKinds.Hana, + IsActive = true + }, + new ConfigTransferSourceSystemDefinition + { + Code = "MANUAL_EXCEL", + DisplayName = "Manual Excel", + ConnectionKind = SourceSystemConnectionKinds.ManualExcel, + IsActive = true + } + ]; + } } diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.cs index 1df8842..2c73a71 100644 --- a/TrafagSalesExporter/Services/DatabaseInitializationService.cs +++ b/TrafagSalesExporter/Services/DatabaseInitializationService.cs @@ -46,7 +46,10 @@ public class DatabaseInitializationService : IDatabaseInitializationService private static void EnsureSchema(AppDbContext db) { EnsureSitesTableSupportsOptionalHanaServer(db); + EnsureExportSettingsTableSupportsCurrentSchema(db); + EnsureHanaServersTableSupportsCurrentSchema(db); RepairBrokenSiteForeignKeys(db); + AddColumnIfMissing(db, "HanaServers", "SourceSystem", "TEXT NOT NULL DEFAULT ''"); 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"); @@ -61,12 +64,6 @@ public class DatabaseInitializationService : IDatabaseInitializationService 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 ''"); - AddColumnIfMissing(db, "ExportSettings", "Bi1Password", "TEXT NOT NULL DEFAULT ''"); - AddColumnIfMissing(db, "ExportSettings", "SageUsername", "TEXT NOT NULL DEFAULT ''"); - AddColumnIfMissing(db, "ExportSettings", "SagePassword", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "ExportSettings", "DebugLoggingEnabled", "INTEGER NOT NULL DEFAULT 0"); AddColumnIfMissing(db, "ExportSettings", "LocalSiteExportFolder", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "ExportSettings", "LocalConsolidatedExportFolder", "TEXT NOT NULL DEFAULT ''"); @@ -75,11 +72,57 @@ public class DatabaseInitializationService : IDatabaseInitializationService EnsureTransformationTable(db); AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'"); EnsureCurrencyExchangeRateTable(db); + EnsureSourceSystemDefinitionTable(db); + AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''"); EnsureSapSourceTable(db); EnsureSapJoinTable(db); EnsureSapFieldMappingTable(db); EnsureCentralSalesRecordTable(db); EnsureAppEventLogTable(db); + EnsureSourceSystemDefinitions(db); + EnsureCentralHanaServerRecords(db); + } + + private static void EnsureExportSettingsTableSupportsCurrentSchema(AppDbContext db) + { + var conn = db.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) + conn.Open(); + + var columns = GetTableColumns(conn, transaction: null, "ExportSettings"); + if (columns.Count == 0) + return; + + var legacyColumns = new[] + { + "SapUsername", + "SapPassword", + "Bi1Username", + "Bi1Password", + "SageUsername", + "SagePassword" + }; + + if (!legacyColumns.Any(columns.Contains)) + return; + + RebuildTable(conn, "ExportSettings", GetExportSettingsCreateSql()); + } + + private static void EnsureHanaServersTableSupportsCurrentSchema(AppDbContext db) + { + var conn = db.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) + conn.Open(); + + var columns = GetTableColumns(conn, transaction: null, "HanaServers"); + if (columns.Count == 0) + return; + + if (!columns.Contains("Username") && !columns.Contains("Password")) + return; + + RebuildTable(conn, "HanaServers", GetHanaServersCreateSql()); } private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db) @@ -272,7 +315,7 @@ FROM Sites_old;"; enableFk.ExecuteNonQuery(); } - private static List GetSharedColumns(System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction transaction, string newTableName, string oldTableName) + private static List GetSharedColumns(System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction? transaction, string newTableName, string oldTableName) { var newColumns = GetTableColumns(connection, transaction, newTableName); var oldColumns = GetTableColumns(connection, transaction, oldTableName); @@ -280,7 +323,7 @@ FROM Sites_old;"; return newColumns.Where(oldColumns.Contains).ToList(); } - private static HashSet GetTableColumns(System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction transaction, string tableName) + private static HashSet GetTableColumns(System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction? transaction, string tableName) { var columns = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -315,6 +358,31 @@ CREATE TABLE ExportLogs ( FOREIGN KEY (SiteId) REFERENCES Sites (Id) );"; + private static string GetExportSettingsCreateSql() => @" +CREATE TABLE ExportSettings ( + Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + DateFilter TEXT NOT NULL, + TimerHour INTEGER NOT NULL, + TimerMinute INTEGER NOT NULL, + TimerEnabled INTEGER NOT NULL, + DebugLoggingEnabled INTEGER NOT NULL DEFAULT 0, + LocalSiteExportFolder TEXT NOT NULL DEFAULT '', + LocalConsolidatedExportFolder TEXT NOT NULL DEFAULT '' +);"; + + private static string GetHanaServersCreateSql() => @" +CREATE TABLE HanaServers ( + Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + SourceSystem TEXT NOT NULL, + Name TEXT NOT NULL, + Host TEXT NOT NULL, + Port INTEGER NOT NULL, + DatabaseName TEXT NOT NULL DEFAULT '', + UseSsl INTEGER NOT NULL DEFAULT 0, + ValidateCertificate INTEGER NOT NULL DEFAULT 0, + AdditionalParams TEXT NOT NULL DEFAULT '' +);"; + private static string GetAppEventLogsCreateSql() => @" CREATE TABLE AppEventLogs ( Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, @@ -604,21 +672,42 @@ CREATE TABLE IF NOT EXISTS AppEventLogs ( cmd.ExecuteNonQuery(); } + private static void EnsureSourceSystemDefinitionTable(AppDbContext db) + { + var conn = db.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" +CREATE TABLE IF NOT EXISTS SourceSystemDefinitions ( + Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + Code TEXT NOT NULL, + DisplayName TEXT NOT NULL, + ConnectionKind TEXT NOT NULL, + IsActive INTEGER NOT NULL DEFAULT 1, + CentralServiceUrl TEXT NOT NULL DEFAULT '', + CentralUsername TEXT NOT NULL DEFAULT '', + CentralPassword TEXT NOT NULL DEFAULT '' +);"; + cmd.ExecuteNonQuery(); + } + private static void SeedIfEmpty(AppDbContext db) { - if (db.HanaServers.Any()) + if (db.Sites.Any() || db.HanaServers.Any() || db.SharePointConfigs.Any() || db.ExportSettings.Any()) return; - var serverInternal = new HanaServer { Name = "Internal", Host = "travtrp0", Port = 30015, Username = "", Password = "" }; - var serverIndia = new HanaServer { Name = "India", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" }; - db.HanaServers.AddRange(serverInternal, serverIndia); + var serverBi1 = new HanaServer { SourceSystem = "BI1", Name = "BI1", Host = "travtrp0", Port = 30015, Username = "", Password = "" }; + var serverSage = new HanaServer { SourceSystem = "SAGE", Name = "SAGE", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" }; + db.HanaServers.AddRange(serverBi1, serverSage); db.SaveChanges(); db.Sites.AddRange( - new Site { HanaServerId = serverInternal.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", IsActive = true }, - new Site { HanaServerId = serverInternal.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", IsActive = true }, - new Site { HanaServerId = serverInternal.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", IsActive = true }, - new Site { HanaServerId = serverIndia.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", IsActive = true } + new Site { HanaServerId = serverBi1.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", SourceSystem = "BI1", IsActive = true }, + new Site { HanaServerId = serverBi1.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", SourceSystem = "BI1", IsActive = true }, + new Site { HanaServerId = serverBi1.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", SourceSystem = "BI1", IsActive = true }, + new Site { HanaServerId = serverSage.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", SourceSystem = "SAGE", IsActive = true } ); db.SharePointConfigs.Add(new SharePointConfig @@ -695,4 +784,121 @@ CREATE TABLE IF NOT EXISTS AppEventLogs ( if (hasChanges) db.SaveChanges(); } + + private static void EnsureCentralHanaServerRecords(AppDbContext db) + { + var centralSystems = db.SourceSystemDefinitions + .AsNoTracking() + .Where(x => x.ConnectionKind == SourceSystemConnectionKinds.Hana) + .OrderBy(x => x.Code) + .Select(x => x.Code) + .ToList(); + var changed = false; + + foreach (var sourceSystem in centralSystems) + { + var existingCentral = db.HanaServers + .OrderBy(x => x.Id) + .FirstOrDefault(x => x.SourceSystem == sourceSystem); + + if (existingCentral is not null) + { + if (string.IsNullOrWhiteSpace(existingCentral.Name)) + { + existingCentral.Name = sourceSystem; + changed = true; + } + + continue; + } + + var linkedServer = db.Sites + .Include(x => x.HanaServer) + .Where(x => x.SourceSystem == sourceSystem && x.HanaServerId != null && x.HanaServer != null) + .Select(x => x.HanaServer!) + .OrderBy(x => x.Id) + .FirstOrDefault(); + + if (linkedServer is not null) + { + linkedServer.SourceSystem = sourceSystem; + if (string.IsNullOrWhiteSpace(linkedServer.Name)) + linkedServer.Name = sourceSystem; + changed = true; + continue; + } + + db.HanaServers.Add(new HanaServer + { + SourceSystem = sourceSystem, + Name = sourceSystem, + Host = string.Empty, + Port = 30015, + Username = string.Empty, + Password = string.Empty, + DatabaseName = string.Empty, + AdditionalParams = string.Empty + }); + changed = true; + } + + if (changed) + db.SaveChanges(); + } + + private static void EnsureSourceSystemDefinitions(AppDbContext db) + { + var defaults = new[] + { + new SourceSystemDefinition { Code = "SAP", DisplayName = "SAP", ConnectionKind = SourceSystemConnectionKinds.SapGateway, IsActive = true }, + new SourceSystemDefinition { Code = "BI1", DisplayName = "BI1", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true }, + new SourceSystemDefinition { Code = "SAGE", DisplayName = "SAGE", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true }, + new SourceSystemDefinition { Code = "MANUAL_EXCEL", DisplayName = "Manual Excel", ConnectionKind = SourceSystemConnectionKinds.ManualExcel, IsActive = true } + }; + + var existing = db.SourceSystemDefinitions.ToList(); + var changed = false; + + foreach (var item in defaults) + { + var current = existing.FirstOrDefault(x => x.Code == item.Code); + if (current is null) + { + db.SourceSystemDefinitions.Add(item); + existing.Add(item); + changed = true; + continue; + } + + if (string.IsNullOrWhiteSpace(current.DisplayName)) + { + current.DisplayName = item.DisplayName; + changed = true; + } + + if (string.IsNullOrWhiteSpace(current.ConnectionKind)) + { + current.ConnectionKind = item.ConnectionKind; + changed = true; + } + + if (string.IsNullOrWhiteSpace(current.CentralServiceUrl) && + string.Equals(current.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)) + { + var sapSite = db.Sites + .Where(x => x.SourceSystem == current.Code && !string.IsNullOrWhiteSpace(x.SapServiceUrl)) + .OrderBy(x => x.Id) + .FirstOrDefault(); + + if (sapSite is not null) + { + current.CentralServiceUrl = sapSite.SapServiceUrl; + changed = true; + } + } + } + + if (changed) + db.SaveChanges(); + } } diff --git a/TrafagSalesExporter/Services/SiteExportService.cs b/TrafagSalesExporter/Services/SiteExportService.cs index ed51cdf..586a18a 100644 --- a/TrafagSalesExporter/Services/SiteExportService.cs +++ b/TrafagSalesExporter/Services/SiteExportService.cs @@ -65,13 +65,19 @@ public class SiteExportService : ISiteExportService var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync(); var outputDir = ResolveSiteOutputDirectory(settings, site); var sourceSystem = NormalizeSourceSystem(site.SourceSystem); + var sourceDefinition = await db.SourceSystemDefinitions + .AsNoTracking() + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.Code == sourceSystem) + ?? throw new InvalidOperationException($"Quellsystem '{sourceSystem}' ist nicht konfiguriert."); var records = new List(); string filePath; - if (sourceSystem == "SAP") + if (string.Equals(sourceDefinition.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)) { - var credentials = ResolveCredentials(site, settings, sourceSystem); - if (string.IsNullOrWhiteSpace(site.SapServiceUrl)) + var credentials = ResolveCredentials(site, sourceDefinition); + var sapServiceUrl = ResolveSapServiceUrl(site, sourceDefinition); + if (string.IsNullOrWhiteSpace(sapServiceUrl)) throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP Service URL."); var sapSources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).ToListAsync(); var sapJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).ToListAsync(); @@ -84,7 +90,8 @@ public class SiteExportService : ISiteExportService updateStatus?.Invoke("SAP Quellen laden..."); await _appEventLogService.WriteAsync("Export", "SAP Quellen laden", siteId: site.Id, land: site.Land, details: $"Sources={sapSources.Count} | Mappings={sapMappings.Count}"); - records = await _sapCompositionService.BuildSalesRecordsAsync(site, sapSources, sapJoins, sapMappings, credentials.Username, credentials.Password); + var effectiveSite = CloneSiteWithSapServiceUrl(site, sapServiceUrl); + records = await _sapCompositionService.BuildSalesRecordsAsync(effectiveSite, sapSources, sapJoins, sapMappings, credentials.Username, credentials.Password); updateStatus?.Invoke("Transformationen anwenden..."); await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", siteId: site.Id, land: site.Land, details: $"Records vor Transformation={records.Count}"); @@ -99,7 +106,7 @@ public class SiteExportService : ISiteExportService filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records); log.RowCount = records.Count; } - else if (sourceSystem == "MANUAL_EXCEL") + else if (string.Equals(sourceDefinition.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrWhiteSpace(site.ManualImportFilePath)) throw new InvalidOperationException($"Standort '{site.Land}' hat keine manuelle Excel-Datei."); @@ -125,7 +132,7 @@ public class SiteExportService : ISiteExportService } else { - var exportServer = BuildEffectiveServer(site, settings, sourceSystem); + var exportServer = await BuildEffectiveServerAsync(db, site, sourceDefinition); updateStatus?.Invoke("HANA Abfrage..."); await _appEventLogService.WriteAsync("Export", "HANA Abfrage gestartet", siteId: site.Id, land: site.Land, details: exportServer.GetConnectionStringPreview()); @@ -208,45 +215,40 @@ public class SiteExportService : ISiteExportService } } - private static HanaServer BuildEffectiveServer(Site site, ExportSettings settings, string sourceSystem) + private static async Task BuildEffectiveServerAsync(AppDbContext db, Site site, SourceSystemDefinition sourceDefinition) { - if (site.HanaServer is null) - throw new InvalidOperationException($"Standort '{site.Land}' hat keinen HANA-Server."); + var centralServer = await db.HanaServers + .AsNoTracking() + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(x => x.SourceSystem == sourceDefinition.Code); - var credentials = ResolveCredentials(site, settings, sourceSystem); + if (centralServer is null) + throw new InvalidOperationException($"Fuer Quellsystem '{sourceDefinition.Code}' ist keine zentrale HANA-Konfiguration vorhanden."); + + var credentials = ResolveCredentials(site, sourceDefinition); return new HanaServer { - Id = site.HanaServer.Id, - Name = site.HanaServer.Name, - Host = site.HanaServer.Host, - Port = site.HanaServer.Port, - 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, - AdditionalParams = site.HanaServer.AdditionalParams + Id = centralServer.Id, + SourceSystem = centralServer.SourceSystem, + Name = centralServer.Name, + Host = centralServer.Host, + Port = centralServer.Port, + Username = credentials.Username, + Password = credentials.Password, + DatabaseName = centralServer.DatabaseName, + UseSsl = centralServer.UseSsl, + ValidateCertificate = centralServer.ValidateCertificate, + AdditionalParams = centralServer.AdditionalParams }; } - 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 Username, string Password) ResolveCredentials(Site site, SourceSystemDefinition sourceDefinition) + => (FirstNonEmpty(site.UsernameOverride, sourceDefinition.CentralUsername), + FirstNonEmpty(site.PasswordOverride, sourceDefinition.CentralPassword)); - private static string GetCentralUsername(string sourceSystem, ExportSettings settings) => sourceSystem switch - { - "BI1" => settings.Bi1Username, - "SAGE" => settings.SageUsername, - _ => settings.SapUsername - }; - - private static string GetCentralPassword(string sourceSystem, ExportSettings settings) => sourceSystem switch - { - "BI1" => settings.Bi1Password, - "SAGE" => settings.SagePassword, - _ => settings.SapPassword - }; + private static string ResolveSapServiceUrl(Site site, SourceSystemDefinition sourceDefinition) + => FirstNonEmpty(site.SapServiceUrl, sourceDefinition.CentralServiceUrl); private static string NormalizeSourceSystem(string? sourceSystem) => string.IsNullOrWhiteSpace(sourceSystem) ? "SAP" : sourceSystem.Trim().ToUpperInvariant(); @@ -269,4 +271,28 @@ public class SiteExportService : ISiteExportService ? Path.Combine(AppContext.BaseDirectory, "output") : configured; } + + private static Site CloneSiteWithSapServiceUrl(Site site, string sapServiceUrl) + { + return new Site + { + Id = site.Id, + HanaServerId = site.HanaServerId, + HanaServer = site.HanaServer, + Schema = site.Schema, + TSC = site.TSC, + Land = site.Land, + SourceSystem = site.SourceSystem, + UsernameOverride = site.UsernameOverride, + PasswordOverride = site.PasswordOverride, + LocalExportFolderOverride = site.LocalExportFolderOverride, + ManualImportFilePath = site.ManualImportFilePath, + ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc, + SapServiceUrl = sapServiceUrl, + SapEntitySet = site.SapEntitySet, + SapEntitySetsCache = site.SapEntitySetsCache, + SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc, + IsActive = site.IsActive + }; + } } diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs index 123e02e..6484ad6 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs @@ -47,14 +47,16 @@ public class ConfigTransferServiceTests : IDisposable Assert.False(package.IncludesSecrets); Assert.NotNull(package.ExportSettings); - Assert.Null(package.ExportSettings.SapUsername); - Assert.Null(package.ExportSettings.SapPassword); Assert.NotNull(package.SharePointConfig); Assert.Null(package.SharePointConfig.ClientSecret); + Assert.NotEmpty(package.SourceSystemDefinitions); + Assert.All(package.SourceSystemDefinitions, system => + { + Assert.Null(system.CentralUsername); + Assert.Null(system.CentralPassword); + }); - var server = Assert.Single(package.HanaServers); - Assert.Null(server.Username); - Assert.Null(server.Password); + Assert.Single(package.HanaServers); var site = Assert.Single(package.Sites); Assert.Null(site.UsernameOverride); @@ -90,14 +92,47 @@ public class ConfigTransferServiceTests : IDisposable TimerEnabled = false, DebugLoggingEnabled = true, LocalSiteExportFolder = "D:\\site", - LocalConsolidatedExportFolder = "D:\\consolidated", - SapUsername = null, - SapPassword = null, - Bi1Username = null, - Bi1Password = null, - SageUsername = null, - SagePassword = null + LocalConsolidatedExportFolder = "D:\\consolidated" }, + SourceSystemDefinitions = + [ + new ConfigTransferSourceSystemDefinition + { + Code = "SAP", + DisplayName = "SAP", + ConnectionKind = SourceSystemConnectionKinds.SapGateway, + IsActive = true, + CentralUsername = null, + CentralPassword = null + }, + new ConfigTransferSourceSystemDefinition + { + Code = "BI1", + DisplayName = "BI1", + ConnectionKind = SourceSystemConnectionKinds.Hana, + IsActive = true, + CentralUsername = null, + CentralPassword = null + }, + new ConfigTransferSourceSystemDefinition + { + Code = "SAGE", + DisplayName = "SAGE", + ConnectionKind = SourceSystemConnectionKinds.Hana, + IsActive = true, + CentralUsername = null, + CentralPassword = null + }, + new ConfigTransferSourceSystemDefinition + { + Code = "MANUAL_EXCEL", + DisplayName = "Manual Excel", + ConnectionKind = SourceSystemConnectionKinds.ManualExcel, + IsActive = true, + CentralUsername = null, + CentralPassword = null + } + ], HanaServers = [ new ConfigTransferHanaServer @@ -106,8 +141,6 @@ public class ConfigTransferServiceTests : IDisposable Name = "Server A", Host = "hana-a", Port = 30015, - Username = null, - Password = null, DatabaseName = "DB1", UseSsl = true, ValidateCertificate = false, @@ -152,20 +185,34 @@ public class ConfigTransferServiceTests : IDisposable await using var db = await _dbFactory.CreateDbContextAsync(); var settings = await db.ExportSettings.SingleAsync(); var sharePoint = await db.SharePointConfigs.SingleAsync(); + var systems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync(); var server = await db.HanaServers.SingleAsync(); var site = await db.Sites.SingleAsync(); var rule = await db.FieldTransformationRules.SingleAsync(); - Assert.Equal("preserved-sap-user", settings.SapUsername); - Assert.Equal("preserved-sap-password", settings.SapPassword); - Assert.Equal("preserved-bi1-user", settings.Bi1Username); - Assert.Equal("preserved-sage-password", settings.SagePassword); + Assert.Equal("2026-01-01", settings.DateFilter); + Assert.Equal(5, settings.TimerHour); + Assert.Equal(30, settings.TimerMinute); + Assert.False(settings.TimerEnabled); + Assert.True(settings.DebugLoggingEnabled); + Assert.Equal("D:\\site", settings.LocalSiteExportFolder); + Assert.Equal("D:\\consolidated", settings.LocalConsolidatedExportFolder); Assert.Equal("preserved-sharepoint-secret", sharePoint.ClientSecret); Assert.Equal("new-tenant", sharePoint.TenantId); - Assert.Equal("preserved-server-user", server.Username); - Assert.Equal("preserved-server-password", server.Password); + var sapSystem = Assert.Single(systems, x => x.Code == "SAP"); + Assert.Equal("preserved-sap-user", sapSystem.CentralUsername); + Assert.Equal("preserved-sap-password", sapSystem.CentralPassword); + var bi1System = Assert.Single(systems, x => x.Code == "BI1"); + Assert.Equal("preserved-bi1-user", bi1System.CentralUsername); + Assert.Equal("preserved-bi1-password", bi1System.CentralPassword); + var sageSystem = Assert.Single(systems, x => x.Code == "SAGE"); + Assert.Equal("preserved-sage-user", sageSystem.CentralUsername); + Assert.Equal("preserved-sage-password", sageSystem.CentralPassword); + + Assert.Equal(string.Empty, server.Username); + Assert.Equal(string.Empty, server.Password); Assert.True(server.UseSsl); Assert.Equal("preserved-site-user", site.UsernameOverride); @@ -188,23 +235,50 @@ public class ConfigTransferServiceTests : IDisposable ClientId = "client", ClientSecret = "secret" }); - db.ExportSettings.Add(new ExportSettings - { - SapUsername = "sap-user", - SapPassword = "sap-password", - Bi1Username = "bi1-user", - Bi1Password = "bi1-password", - SageUsername = "sage-user", - SagePassword = "sage-password" - }); + db.ExportSettings.Add(new ExportSettings()); + db.SourceSystemDefinitions.AddRange( + new SourceSystemDefinition + { + Code = "SAP", + DisplayName = "SAP", + ConnectionKind = SourceSystemConnectionKinds.SapGateway, + IsActive = true, + CentralUsername = "sap-user", + CentralPassword = "sap-password" + }, + new SourceSystemDefinition + { + Code = "BI1", + DisplayName = "BI1", + ConnectionKind = SourceSystemConnectionKinds.Hana, + IsActive = true, + CentralUsername = "bi1-user", + CentralPassword = "bi1-password" + }, + new SourceSystemDefinition + { + Code = "SAGE", + DisplayName = "SAGE", + ConnectionKind = SourceSystemConnectionKinds.Hana, + IsActive = true, + CentralUsername = "sage-user", + CentralPassword = "sage-password" + }, + new SourceSystemDefinition + { + Code = "MANUAL_EXCEL", + DisplayName = "Manual Excel", + ConnectionKind = SourceSystemConnectionKinds.ManualExcel, + IsActive = true + }); db.HanaServers.Add(new HanaServer { Id = 1, Name = "Server A", Host = "hana-a", Port = 30015, - Username = "server-user", - Password = "server-password", + Username = string.Empty, + Password = string.Empty, DatabaseName = "DB1" }); db.Sites.Add(new Site @@ -246,15 +320,42 @@ public class ConfigTransferServiceTests : IDisposable ClientId = "old-client", ClientSecret = "preserved-sharepoint-secret" }); - db.ExportSettings.Add(new ExportSettings - { - SapUsername = "preserved-sap-user", - SapPassword = "preserved-sap-password", - Bi1Username = "preserved-bi1-user", - Bi1Password = "preserved-bi1-password", - SageUsername = "preserved-sage-user", - SagePassword = "preserved-sage-password" - }); + db.ExportSettings.Add(new ExportSettings()); + db.SourceSystemDefinitions.AddRange( + new SourceSystemDefinition + { + Code = "SAP", + DisplayName = "SAP", + ConnectionKind = SourceSystemConnectionKinds.SapGateway, + IsActive = true, + CentralUsername = "preserved-sap-user", + CentralPassword = "preserved-sap-password" + }, + new SourceSystemDefinition + { + Code = "BI1", + DisplayName = "BI1", + ConnectionKind = SourceSystemConnectionKinds.Hana, + IsActive = true, + CentralUsername = "preserved-bi1-user", + CentralPassword = "preserved-bi1-password" + }, + new SourceSystemDefinition + { + Code = "SAGE", + DisplayName = "SAGE", + ConnectionKind = SourceSystemConnectionKinds.Hana, + IsActive = true, + CentralUsername = "preserved-sage-user", + CentralPassword = "preserved-sage-password" + }, + new SourceSystemDefinition + { + Code = "MANUAL_EXCEL", + DisplayName = "Manual Excel", + ConnectionKind = SourceSystemConnectionKinds.ManualExcel, + IsActive = true + }); db.HanaServers.Add(new HanaServer { Id = 1,