@page "/standorte" @using Microsoft.AspNetCore.Components.Forms @using Microsoft.EntityFrameworkCore @using System.Text.Json @using System.Reflection @using TrafagSalesExporter.Data @using TrafagSalesExporter.Models @using TrafagSalesExporter.Services @inject IDbContextFactory DbFactory @inject IHanaQueryService HanaService @inject ISapGatewayService SapGatewayService @inject IAppEventLogService AppEventLogService @inject ISnackbar Snackbar @inject IDialogService DialogService Standorte Standorte Zentrale HANA-Technik 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 Verbindungsstatus Aktionen @context.SourceSystem @context.Name @context.Host @context.Port @if (_connectionStatus.TryGetValue(context.Id, out var status)) { @(status.Success ? "OK" : "Fehler") - @status.Stage } else { Nicht getestet } Standorte (Sites) Neuen Standort hinzufügen Land TSC Schema Quellsystem Quelle Aktiv Aktionen @context.Land @context.TSC @context.Schema @context.SourceSystem @GetConnectionTarget(context) @if (context.IsActive) { } else { } Zentrale HANA-Technik bearbeiten Abbrechen Speichern @(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten") @foreach (var system in GetAvailableSourceSystems()) { @GetSourceSystemLabel(system) } @if (IsSapSite()) { SAP Gateway Die Service-URL zeigt auf den OData-Service. Die verfügbaren Entity Sets werden nur per Knopfdruck aktualisiert und lokal zwischengespeichert. Zentrale SAP Service URL: @GetCentralSapServiceUrlSummary(_editingSite.SourceSystem) @if (_refreshingSapEntitySets) { @("Lade...") } else { @("Quellen refreshen") } @if (_editingSite.SapEntitySetsRefreshedAtUtc.HasValue) { Letzter Refresh: @_editingSite.SapEntitySetsRefreshedAtUtc.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") } SAP Quellen 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`. Alias Entity Set Primär Aktiv Aktionen @foreach (var entitySet in _sapEntitySetsCache) { @entitySet } SAP Joins Auto-Match Join hinzufügen Links Left Keys Rechts Right Keys Typ Aktiv Aktionen @foreach (var alias in GetSapAliases()) { @alias } @foreach (var field in GetAvailableJoinFields(context.LeftAlias, context.LeftKeys)) { @field } @foreach (var alias in GetSapAliases()) { @alias } @foreach (var field in GetAvailableJoinFields(context.RightAlias, context.RightKeys)) { @field } Left Feldmappings ins zentrale Schema @if (_refreshingSapSourceFields) { @("Lade Felder...") } else { @("Felder aus Quellen laden") } Mapping hinzufügen Source Expressions werden aus den hinzugefügten SAP-Quellen als `Alias.Feldname` gelesen. Vorhandene manuelle Werte bleiben auswählbar. Zielfeld Source Expression Pflicht Aktiv Aktionen @foreach (var field in _salesRecordFields) { @field } @foreach (var expression in GetAvailableSourceExpressions(context.SourceExpression)) { @expression } } else if (IsManualExcelSite()) { 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. @if (_uploadingManualImport) { Datei wird hochgeladen... } @if (!string.IsNullOrWhiteSpace(_editingSite.ManualImportFilePath)) { Datei: @_editingSite.ManualImportFilePath Letzter Upload: @(_editingSite.ManualImportLastUploadedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-") } else { Noch keine Datei hinterlegt. } } else { HANA-Verbindung 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. } Abbrechen Speichern @code { 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); private List _sapSources = []; private List _sapJoins = []; private List _sapMappings = []; private readonly string[] _salesRecordFields = typeof(SalesRecord) .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Select(p => p.Name) .ToArray(); private HanaServer _editingServer = new(); private Site _editingSite = new(); private bool _serverDialogVisible; private bool _siteDialogVisible; private bool _refreshingSapEntitySets; private bool _refreshingSapSourceFields; private bool _savingServer; private bool _savingSite; private bool _uploadingManualImport; private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; protected override async Task OnInitializedAsync() { await LoadDataAsync(); } private async Task LoadDataAsync() { using var db = await DbFactory.CreateDbContextAsync(); _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 EditServer(HanaServer server) { _editingServer = CloneServer(server); _serverDialogVisible = true; } private async Task SaveServer() { if (_savingServer) return; _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) { 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 = string.Empty; existing.Password = string.Empty; existing.DatabaseName = _editingServer.DatabaseName; existing.UseSsl = _editingServer.UseSsl; existing.ValidateCertificate = _editingServer.ValidateCertificate; existing.AdditionalParams = _editingServer.AdditionalParams; } } await db.SaveChangesAsync(); _serverDialogVisible = false; await LoadDataAsync(); Snackbar.Add("Server gespeichert", Severity.Success); } finally { _savingServer = false; } } 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"); if (result != true) return; try { using var db = await DbFactory.CreateDbContextAsync(); var linkedSites = await db.Sites .Where(s => s.HanaServerId == server.Id) .OrderBy(s => s.Land) .Select(s => $"{s.Land} ({s.TSC})") .ToListAsync(); if (linkedSites.Count > 0) { Snackbar.Add( $"Server kann nicht gelöscht werden. Noch verknüpfte Standorte: {string.Join(", ", linkedSites)}", Severity.Warning); return; } var entity = await db.HanaServers.FindAsync(server.Id); if (entity is not null) { db.HanaServers.Remove(entity); await db.SaveChangesAsync(); } } catch (Exception ex) { Snackbar.Add($"Server konnte nicht gelöscht werden: {ex.Message}", Severity.Error); return; } await LoadDataAsync(); 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: testServer.GetConnectionStringPreview()); var result = await Task.Run(() => HanaService.TestConnectionDetailed(testServer)); _connectionStatus[server.Id] = result; if (result.Success) { Snackbar.Add($"Verbindung zu '{server.Name}' erfolgreich.", Severity.Success); } else { Snackbar.Add($"{server.Name}: {result.ExceptionType} - {result.ErrorMessage}", Severity.Error); } } private static string BuildStatusTooltip(ConnectionTestResult status) { var stamp = status.TestedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); if (status.Success) return $"Letzter Test: {stamp}\nStage: {status.Stage}\n{status.ConnectionStringPreview}"; return $"Letzter Test: {stamp}\nStage: {status.Stage}\nFehler: {status.ErrorMessage}\n{status.ConnectionStringPreview}"; } private void AddSite() { _editingSite = new Site { IsActive = true, SourceSystem = GetAvailableSourceSystems().FirstOrDefault()?.Code ?? "SAP", HanaServerId = null, ManualImportFilePath = string.Empty }; _sapEntitySetsCache = []; _sapAvailableSourceExpressions = []; _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase); _sapSources = []; _sapJoins = []; _sapMappings = []; _siteDialogVisible = true; } private void EditSite(Site site) { _editingSite = new Site { Id = site.Id, HanaServerId = site.HanaServerId, Schema = site.Schema, TSC = site.TSC, Land = site.Land, SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? GetAvailableSourceSystems().FirstOrDefault()?.Code ?? "SAP" : site.SourceSystem, UsernameOverride = site.UsernameOverride, PasswordOverride = site.PasswordOverride, LocalExportFolderOverride = site.LocalExportFolderOverride, ManualImportFilePath = site.ManualImportFilePath, ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc, SapServiceUrl = site.SapServiceUrl, SapEntitySet = site.SapEntitySet, SapEntitySetsCache = site.SapEntitySetsCache, SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc, IsActive = site.IsActive }; _sapEntitySetsCache = ParseSapEntitySets(site.SapEntitySetsCache); using var db = DbFactory.CreateDbContext(); _sapSources = db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).OrderBy(s => s.SortOrder).ThenBy(s => s.Id).ToList(); _sapJoins = db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).OrderBy(j => j.SortOrder).ThenBy(j => j.Id).ToList(); _sapMappings = db.SapFieldMappings.Where(m => m.SiteId == site.Id).OrderBy(m => m.SortOrder).ThenBy(m => m.Id).ToList(); _sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings(); _sapSourceFieldMap = BuildSourceFieldMapFromJoins(); _siteDialogVisible = true; } private async Task SaveSite() { if (_savingSite) return; _savingSite = true; try { using var db = await DbFactory.CreateDbContextAsync(); var serverId = UsesHanaConnection() ? await ResolveCentralHanaServerIdAsync(db, _editingSite.SourceSystem) : (int?)null; _editingSite.HanaServerId = serverId; _editingSite.SapEntitySetsCache = SerializeSapEntitySets(_sapEntitySetsCache); if (_editingSite.Id == 0) { db.Sites.Add(_editingSite); } else { var existing = await db.Sites.FindAsync(_editingSite.Id); if (existing is not null) { existing.HanaServerId = serverId; existing.Schema = _editingSite.Schema; existing.TSC = _editingSite.TSC; existing.Land = _editingSite.Land; existing.SourceSystem = _editingSite.SourceSystem; existing.UsernameOverride = _editingSite.UsernameOverride; existing.PasswordOverride = _editingSite.PasswordOverride; existing.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride; existing.ManualImportFilePath = _editingSite.ManualImportFilePath; existing.ManualImportLastUploadedAtUtc = _editingSite.ManualImportLastUploadedAtUtc; existing.SapServiceUrl = _editingSite.SapServiceUrl; existing.SapEntitySet = _editingSite.SapEntitySet; existing.SapEntitySetsCache = _editingSite.SapEntitySetsCache; existing.SapEntitySetsRefreshedAtUtc = _editingSite.SapEntitySetsRefreshedAtUtc; existing.IsActive = _editingSite.IsActive; } } await db.SaveChangesAsync(); await SaveSapConfigurationAsync(db, _editingSite.Id); _siteDialogVisible = false; await LoadDataAsync(); Snackbar.Add("Standort gespeichert", Severity.Success); } catch (Exception ex) { Snackbar.Add($"Speichern fehlgeschlagen: {ex.Message}", Severity.Error); } finally { _savingSite = false; } } private async Task DeleteSite(Site site) { var result = await DialogService.ShowMessageBox( "Standort löschen", $"Standort '{site.Land}' wirklich löschen?", yesText: "Löschen", cancelText: "Abbrechen"); if (result != true) return; using var db = await DbFactory.CreateDbContextAsync(); var entity = await db.Sites.FindAsync(site.Id); if (entity is not null) { var sources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).ToListAsync(); var joins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).ToListAsync(); var mappings = await db.SapFieldMappings.Where(m => m.SiteId == site.Id).ToListAsync(); var centralRows = await db.CentralSalesRecords.Where(r => r.SiteId == site.Id).ToListAsync(); if (sources.Count > 0) db.SapSourceDefinitions.RemoveRange(sources); if (joins.Count > 0) db.SapJoinDefinitions.RemoveRange(joins); if (mappings.Count > 0) db.SapFieldMappings.RemoveRange(mappings); if (centralRows.Count > 0) db.CentralSalesRecords.RemoveRange(centralRows); db.Sites.Remove(entity); await db.SaveChangesAsync(); } await LoadDataAsync(); Snackbar.Add("Standort gelöscht", Severity.Info); } private static string GetServerNode(HanaServer? server) { if (server is null || string.IsNullOrWhiteSpace(server.Host)) return "-"; return server.Host.Contains(':', StringComparison.Ordinal) ? server.Host : $"{server.Host}:{server.Port}"; } 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 = string.Empty, Password = string.Empty, DatabaseName = server.DatabaseName, UseSsl = server.UseSsl, ValidateCertificate = server.ValidateCertificate, AdditionalParams = server.AdditionalParams }; } private async Task ResolveCentralHanaServerIdAsync(AppDbContext db, string sourceSystem) { _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(); 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 (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host)) throw new InvalidOperationException($"Fuer Quellsystem '{normalizedSourceSystem}' ist keine gueltige zentrale HANA-Konfiguration vorhanden."); return centralServer.Id; } 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() { if (_refreshingSapEntitySets) return; _refreshingSapEntitySets = true; try { using var db = await DbFactory.CreateDbContextAsync(); 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."); await AppEventLogService.WriteAsync("SAP", "Refresh aus UI gestartet", siteId: _editingSite.Id, land: _editingSite.Land, details: serviceUrl); var entitySets = await SapGatewayService.GetEntitySetsAsync(serviceUrl, username.Trim(), password.Trim()); _sapEntitySetsCache = entitySets; _editingSite.SapEntitySetsCache = SerializeSapEntitySets(entitySets); _editingSite.SapEntitySetsRefreshedAtUtc = DateTime.UtcNow; if (!string.IsNullOrWhiteSpace(_editingSite.SapEntitySet) && !_sapEntitySetsCache.Contains(_editingSite.SapEntitySet, StringComparer.OrdinalIgnoreCase)) { _editingSite.SapEntitySet = string.Empty; } Snackbar.Add($"{entitySets.Count} SAP Entity Sets geladen.", Severity.Success); await AppEventLogService.WriteAsync("SAP", "Refresh aus UI erfolgreich", siteId: _editingSite.Id, land: _editingSite.Land, details: $"EntitySets={entitySets.Count}"); } catch (Exception ex) { Snackbar.Add(ex.Message, Severity.Error); await AppEventLogService.WriteAsync("SAP", "Refresh aus UI fehlgeschlagen", "Error", siteId: _editingSite.Id, land: _editingSite.Land, details: ex.ToString()); } finally { _refreshingSapEntitySets = false; } } private void CloseServerDialog() { if (_savingServer) return; _serverDialogVisible = false; } private void CloseSiteDialog() { if (_savingSite || _refreshingSapEntitySets || _uploadingManualImport) return; _siteDialogVisible = false; } private async Task UploadManualImportFileAsync(InputFileChangeEventArgs args) { if (_uploadingManualImport) return; var file = args.File; if (file is null) return; _uploadingManualImport = true; try { 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."); } var uploadDirectory = Path.Combine(AppContext.BaseDirectory, "manual-imports"); Directory.CreateDirectory(uploadDirectory); var safeBaseName = string.Concat(Path.GetFileNameWithoutExtension(file.Name).Select(ch => char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' ? ch : '_')); if (string.IsNullOrWhiteSpace(safeBaseName)) safeBaseName = "manual_import"; var targetPath = Path.Combine(uploadDirectory, $"{safeBaseName}_{Guid.NewGuid():N}{extension}"); await using (var sourceStream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024)) await using (var targetStream = File.Create(targetPath)) { await sourceStream.CopyToAsync(targetStream); } _editingSite.ManualImportFilePath = targetPath; _editingSite.ManualImportLastUploadedAtUtc = DateTime.UtcNow; Snackbar.Add("Excel-Datei hochgeladen.", Severity.Success); await AppEventLogService.WriteAsync("ManualImport", "Excel-Datei hochgeladen", siteId: _editingSite.Id, land: _editingSite.Land, details: targetPath); } catch (Exception ex) { Snackbar.Add($"Upload fehlgeschlagen: {ex.Message}", Severity.Error); await AppEventLogService.WriteAsync("ManualImport", "Excel-Upload fehlgeschlagen", "Error", siteId: _editingSite.Id, land: _editingSite.Land, details: ex.ToString()); } finally { _uploadingManualImport = false; } } private static List ParseSapEntitySets(string json) { if (string.IsNullOrWhiteSpace(json)) return []; try { return JsonSerializer.Deserialize>(json) ?? []; } catch { return []; } } private static string SerializeSapEntitySets(List entitySets) => JsonSerializer.Serialize(entitySets); private void AddSapSource() { _sapSources.Add(new SapSourceDefinition { Alias = $"SRC{_sapSources.Count + 1}", EntitySet = _sapEntitySetsCache.FirstOrDefault() ?? string.Empty, IsActive = true, IsPrimary = _sapSources.Count == 0, SortOrder = _sapSources.Count }); } private void RemoveSapSource(SapSourceDefinition source) { _sapSources.Remove(source); } private void AddSapJoin() { _sapJoins.Add(new SapJoinDefinition { JoinType = "Left", IsActive = true, SortOrder = _sapJoins.Count }); } private void AutoMatchSapJoins() { var activeSources = _sapSources .Where(s => s.IsActive && !string.IsNullOrWhiteSpace(s.Alias)) .OrderBy(s => s.SortOrder) .ThenBy(s => s.Id) .ToList(); if (activeSources.Count < 2) { 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); return; } var primary = activeSources.FirstOrDefault(s => s.IsPrimary) ?? activeSources.First(); var createdOrUpdated = 0; foreach (var source in activeSources.Where(s => !string.Equals(s.Alias, primary.Alias, StringComparison.OrdinalIgnoreCase))) { if (!_sapSourceFieldMap.TryGetValue(primary.Alias, out var leftFields) || leftFields.Count == 0) continue; if (!_sapSourceFieldMap.TryGetValue(source.Alias, out var rightFields) || rightFields.Count == 0) continue; var matchingFields = leftFields .Intersect(rightFields, StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); if (matchingFields.Count == 0) continue; var existingJoin = _sapJoins.FirstOrDefault(j => string.Equals(j.LeftAlias, primary.Alias, StringComparison.OrdinalIgnoreCase) && string.Equals(j.RightAlias, source.Alias, StringComparison.OrdinalIgnoreCase)); var keyList = string.Join(',', matchingFields); if (existingJoin is null) { _sapJoins.Add(new SapJoinDefinition { LeftAlias = primary.Alias, RightAlias = source.Alias, LeftKeys = keyList, RightKeys = keyList, JoinType = "Left", IsActive = true, SortOrder = _sapJoins.Count }); } else { existingJoin.LeftKeys = keyList; existingJoin.RightKeys = keyList; existingJoin.JoinType = "Left"; existingJoin.IsActive = true; } createdOrUpdated++; } if (createdOrUpdated == 0) { Snackbar.Add("Kein passender Join-Vorschlag gefunden.", Severity.Info); return; } NormalizeSapConfigCollections(); Snackbar.Add($"{createdOrUpdated} Join-Vorschläge gesetzt.", Severity.Success); } private void RemoveSapJoin(SapJoinDefinition join) { _sapJoins.Remove(join); } private void AddSapMapping() { _sapMappings.Add(new SapFieldMapping { TargetField = _salesRecordFields.First(), SourceExpression = _sapAvailableSourceExpressions.FirstOrDefault() ?? "=SAP", IsActive = true, SortOrder = _sapMappings.Count }); } private void RemoveSapMapping(SapFieldMapping mapping) { _sapMappings.Remove(mapping); } private IEnumerable GetSapAliases() => _sapSources.Where(s => !string.IsNullOrWhiteSpace(s.Alias)).Select(s => s.Alias).Distinct(StringComparer.OrdinalIgnoreCase); private async Task SaveSapConfigurationAsync(AppDbContext db, int siteId) { var oldSources = await db.SapSourceDefinitions.Where(s => s.SiteId == siteId).ToListAsync(); var oldJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == siteId).ToListAsync(); var oldMappings = await db.SapFieldMappings.Where(m => m.SiteId == siteId).ToListAsync(); if (oldSources.Count > 0) db.SapSourceDefinitions.RemoveRange(oldSources); if (oldJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(oldJoins); if (oldMappings.Count > 0) db.SapFieldMappings.RemoveRange(oldMappings); if (IsSapSite()) { NormalizeSapConfigCollections(); foreach (var source in _sapSources) source.SiteId = siteId; foreach (var join in _sapJoins) join.SiteId = siteId; foreach (var mapping in _sapMappings) mapping.SiteId = siteId; db.SapSourceDefinitions.AddRange(_sapSources); db.SapJoinDefinitions.AddRange(_sapJoins); db.SapFieldMappings.AddRange(_sapMappings); } await db.SaveChangesAsync(); } private void NormalizeSapConfigCollections() { for (var i = 0; i < _sapSources.Count; i++) _sapSources[i].SortOrder = i; for (var i = 0; i < _sapJoins.Count; i++) _sapJoins[i].SortOrder = i; for (var i = 0; i < _sapMappings.Count; i++) _sapMappings[i].SortOrder = i; var selectedPrimaryIndex = _sapSources.FindIndex(s => s.IsPrimary); var primarySource = selectedPrimaryIndex >= 0 ? _sapSources[selectedPrimaryIndex] : _sapSources.FirstOrDefault(); foreach (var source in _sapSources) source.IsPrimary = primarySource is not null && ReferenceEquals(source, primarySource); if (_sapSources.Count > 0 && _sapSources.All(s => !s.IsPrimary)) _sapSources[0].IsPrimary = true; } private async Task RefreshSapSourceFields() { if (_refreshingSapSourceFields) return; _refreshingSapSourceFields = true; try { var activeSources = _sapSources .Where(s => s.IsActive && !string.IsNullOrWhiteSpace(s.Alias) && !string.IsNullOrWhiteSpace(s.EntitySet)) .OrderBy(s => s.SortOrder) .ThenBy(s => s.Id) .ToList(); if (activeSources.Count == 0) throw new InvalidOperationException("Es gibt keine aktiven SAP-Quellen mit Alias und Entity Set."); using var db = await DbFactory.CreateDbContextAsync(); 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."); var expressions = new List { "=SAP" }; var sourceFieldMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var source in activeSources) { var fieldNames = await SapGatewayService.GetEntityFieldNamesAsync(serviceUrl, source.EntitySet, username.Trim(), password.Trim()); sourceFieldMap[source.Alias] = fieldNames; expressions.AddRange(fieldNames.Select(field => $"{source.Alias}.{field}")); } _sapAvailableSourceExpressions = expressions .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); _sapSourceFieldMap = sourceFieldMap; foreach (var current in BuildSourceExpressionsFromMappings()) { if (!_sapAvailableSourceExpressions.Contains(current, StringComparer.OrdinalIgnoreCase)) _sapAvailableSourceExpressions.Add(current); } _sapAvailableSourceExpressions = _sapAvailableSourceExpressions .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); Snackbar.Add($"{_sapAvailableSourceExpressions.Count} Source Expressions geladen.", Severity.Success); } catch (Exception ex) { Snackbar.Add(ex.Message, Severity.Error); } finally { _refreshingSapSourceFields = false; } } private IEnumerable GetAvailableSourceExpressions(string? currentValue) { var expressions = new List(_sapAvailableSourceExpressions); if (!string.IsNullOrWhiteSpace(currentValue) && !expressions.Contains(currentValue, StringComparer.OrdinalIgnoreCase)) expressions.Insert(0, currentValue); return expressions; } private List BuildSourceExpressionsFromMappings() => _sapMappings .Select(m => m.SourceExpression) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); private Dictionary> BuildSourceFieldMapFromJoins() { var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var join in _sapJoins) { AddJoinKeysToFieldMap(result, join.LeftAlias, join.LeftKeys); AddJoinKeysToFieldMap(result, join.RightAlias, join.RightKeys); } return result; } private static void AddJoinKeysToFieldMap(Dictionary> target, string alias, string keys) { if (string.IsNullOrWhiteSpace(alias)) return; if (!target.TryGetValue(alias, out var fields)) { fields = []; target[alias] = fields; } foreach (var key in GetSelectedJoinKeys(keys)) { if (!fields.Contains(key, StringComparer.OrdinalIgnoreCase)) fields.Add(key); } fields.Sort(StringComparer.OrdinalIgnoreCase); } private IEnumerable GetAvailableJoinFields(string? alias, string? currentKeys) { var values = new List(); if (!string.IsNullOrWhiteSpace(alias) && _sapSourceFieldMap.TryGetValue(alias, out var fields)) values.AddRange(fields); foreach (var key in GetSelectedJoinKeys(currentKeys)) { if (!values.Contains(key, StringComparer.OrdinalIgnoreCase)) values.Add(key); } return values .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .ToList(); } private static HashSet GetSelectedJoinKeys(string? keys) => keys? .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(x => !string.IsNullOrWhiteSpace(x)) .ToHashSet(StringComparer.OrdinalIgnoreCase) ?? []; }