@page "/standorte" @attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)] @using Microsoft.AspNetCore.Components.Forms @using System.Text.Json @using System.Reflection @using TrafagSalesExporter.Models @using TrafagSalesExporter.Services @inject IStandortePageService StandortePageService @inject IStandorteSapEditorService SapEditorService @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") @if (UsesHanaConnection()) { @if (_loadingSchemas) { @("Lade Schemas...") } else { @("Schemas laden") } @if (_availableSchemas.Count > 0) { @foreach (var schema in _availableSchemas) { @schema } } Die Liste wird aus der zentralen HANA-Verbindung des Quellsystems gelesen und auf typische B1-Schemas eingeschraenkt. } @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. Pfad pruefen @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 _availableSchemas = []; 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 _loadingSchemas; private bool _uploadingManualImport; private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; protected override async Task OnInitializedAsync() { await LoadDataAsync(); } private async Task LoadDataAsync() { var state = await StandortePageService.LoadAsync(); _sourceSystemDefinitions = state.SourceSystems; _servers = state.Servers; _sites = state.Sites; } private void EditServer(HanaServer server) { _editingServer = CloneServer(server); _serverDialogVisible = true; } private async Task SaveServer() { if (_savingServer) return; _savingServer = true; try { await StandortePageService.SaveServerAsync(_editingServer, GetHanaSourceSystemCodes()); _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 { await StandortePageService.DeleteServerAsync(server); } 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) { try { var result = await StandortePageService.TestServerConnectionAsync(server); _connectionStatus[server.Id] = result; Snackbar.Add( result.Success ? $"Verbindung zu '{server.Name}' erfolgreich." : $"{server.Name}: {result.ExceptionType} - {result.ErrorMessage}", result.Success ? Severity.Success : Severity.Error); } catch (Exception ex) { Snackbar.Add(ex.Message, Severity.Warning); } } 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 }; _availableSchemas = []; _sapEntitySetsCache = []; _sapAvailableSourceExpressions = []; _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase); _sapSources = []; _sapJoins = []; _sapMappings = []; _siteDialogVisible = true; } private void EditSite(Site site) { _ = EditSiteAsync(site); } private async Task EditSiteAsync(Site site) { var editorState = await StandortePageService.LoadSiteEditorAsync(site, GetAvailableSourceSystems()); _editingSite = editorState.Site; _availableSchemas = []; _sapEntitySetsCache = editorState.SapEntitySets; _sapSources = editorState.SapSources; _sapJoins = editorState.SapJoins; _sapMappings = editorState.SapMappings; _sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings(); _sapSourceFieldMap = BuildSourceFieldMapFromJoins(); _siteDialogVisible = true; } private async Task SaveSite() { if (_savingSite) return; _savingSite = true; try { await StandortePageService.SaveSiteAsync(_editingSite, UsesHanaConnection(), IsSapSite(), _sapSources, _sapJoins, _sapMappings, _sapEntitySetsCache); _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; await StandortePageService.DeleteSiteAsync(site); 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 Task OnSchemaSelected(string schema) { _editingSite.Schema = schema; return Task.CompletedTask; } private Task OnSourceSystemChanged(string value) { _editingSite.SourceSystem = value; _availableSchemas = []; return Task.CompletedTask; } 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 LoadAvailableSchemasAsync() { if (_loadingSchemas) return; _loadingSchemas = true; try { _availableSchemas = await StandortePageService.LoadAvailableSchemasAsync(_editingSite); if (_availableSchemas.Count == 0) { Snackbar.Add("Keine passenden Schemas gefunden.", Severity.Info); return; } if (string.IsNullOrWhiteSpace(_editingSite.Schema) || !_availableSchemas.Contains(_editingSite.Schema, StringComparer.OrdinalIgnoreCase)) { _editingSite.Schema = _availableSchemas[0]; } Snackbar.Add($"{_availableSchemas.Count} Schemas geladen.", Severity.Success); } catch (Exception ex) { Snackbar.Add($"Schemas laden fehlgeschlagen: {ex.Message}", Severity.Error); } finally { _loadingSchemas = false; } } private async Task RefreshSapEntitySets() { if (_refreshingSapEntitySets) return; _refreshingSapEntitySets = true; try { var result = await StandortePageService.RefreshSapEntitySetsAsync(_editingSite); _sapEntitySetsCache = result.EntitySets; _editingSite.SapEntitySetsCache = SerializeSapEntitySets(result.EntitySets); _editingSite.SapEntitySetsRefreshedAtUtc = result.RefreshedAtUtc; if (!string.IsNullOrWhiteSpace(_editingSite.SapEntitySet) && !_sapEntitySetsCache.Contains(_editingSite.SapEntitySet, StringComparer.OrdinalIgnoreCase)) { _editingSite.SapEntitySet = string.Empty; } Snackbar.Add($"{result.EntitySets.Count} SAP Entity Sets geladen.", Severity.Success); } catch (Exception ex) { Snackbar.Add(ex.Message, Severity.Error); } finally { _refreshingSapEntitySets = false; } } private void CloseServerDialog() { if (_savingServer) return; _serverDialogVisible = false; } private void CloseSiteDialog() { if (_savingSite || _refreshingSapEntitySets || _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); } catch (Exception ex) { Snackbar.Add($"Upload fehlgeschlagen: {ex.Message}", Severity.Error); } finally { _uploadingManualImport = false; } } private async Task ValidateManualImportPathAsync() { try { _editingSite.ManualImportLastUploadedAtUtc = await StandortePageService.ValidateManualImportPathAsync(_editingSite.ManualImportFilePath); Snackbar.Add("Dateipfad ist gueltig und die Excel-Datei ist erreichbar.", Severity.Success); } catch (Exception ex) { Snackbar.Add($"Pfadpruefung fehlgeschlagen: {ex.Message}", Severity.Error); } } 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() { SapEditorService.AddSapSource(_sapSources, _sapEntitySetsCache); } private void RemoveSapSource(SapSourceDefinition source) { SapEditorService.RemoveSapSource(_sapSources, source); } private void AddSapJoin() { SapEditorService.AddSapJoin(_sapJoins); } private void AutoMatchSapJoins() { var result = SapEditorService.AutoMatchSapJoins(_sapSources, _sapJoins, _sapSourceFieldMap); SapEditorService.NormalizeSapConfigCollections(_sapSources, _sapJoins, _sapMappings); Snackbar.Add(result.Message, result.Success ? Severity.Success : result.Warning ? Severity.Warning : Severity.Info); } private void RemoveSapJoin(SapJoinDefinition join) { SapEditorService.RemoveSapJoin(_sapJoins, join); } private void AddSapMapping() { SapEditorService.AddSapMapping(_sapMappings, _salesRecordFields, _sapAvailableSourceExpressions); } private void RemoveSapMapping(SapFieldMapping mapping) { SapEditorService.RemoveSapMapping(_sapMappings, mapping); } private IEnumerable GetSapAliases() => SapEditorService.GetSapAliases(_sapSources); private void NormalizeSapConfigCollections() => SapEditorService.NormalizeSapConfigCollections(_sapSources, _sapJoins, _sapMappings); 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."); var result = await StandortePageService.RefreshSapSourceFieldsAsync(_editingSite, activeSources, _sapMappings); _sapAvailableSourceExpressions = result.SourceExpressions; _sapSourceFieldMap = result.SourceFieldMap; 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) => SapEditorService.GetAvailableSourceExpressions(_sapAvailableSourceExpressions, currentValue); private List BuildSourceExpressionsFromMappings() => SapEditorService.BuildSourceExpressionsFromMappings(_sapMappings); private Dictionary> BuildSourceFieldMapFromJoins() => SapEditorService.BuildSourceFieldMapFromJoins(_sapJoins); private IEnumerable GetAvailableJoinFields(string? alias, string? currentKeys) => SapEditorService.GetAvailableJoinFields(_sapSourceFieldMap, alias, currentKeys); private static HashSet GetSelectedJoinKeys(string? keys) => keys? .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(x => !string.IsNullOrWhiteSpace(x)) .ToHashSet(StringComparer.OrdinalIgnoreCase) ?? []; }