Files
Ai/TrafagSalesExporter/Components/Pages/Standorte.razor
T
2026-04-15 11:18:26 +02:00

1145 lines
49 KiB
Plaintext

@page "/standorte"
@using Microsoft.EntityFrameworkCore
@using System.Text.Json
@using System.Reflection
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject IHanaQueryService HanaService
@inject ISapGatewayService SapGatewayService
@inject IAppEventLogService AppEventLogService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Standorte</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Standorte</MudText>
<MudText Typo="Typo.h5" Class="mb-2">HANA Server</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
OnClick="AddServer" Class="mb-3">
Server hinzufügen
</MudButton>
<MudTable Items="_servers" Dense Hover Striped>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Host</MudTh>
<MudTh>Port</MudTh>
<MudTh>Username</MudTh>
<MudTh>Verbindungsstatus</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>@context.Host</MudTd>
<MudTd>@context.Port</MudTd>
<MudTd>@context.Username</MudTd>
<MudTd>
@if (_connectionStatus.TryGetValue(context.Id, out var status))
{
<MudTooltip Text="@BuildStatusTooltip(status)">
<MudChip T="string" Color="@(status.Success ? Color.Success : Color.Error)" Variant="Variant.Filled" Size="Size.Small">
@(status.Success ? "OK" : "Fehler") - @status.Stage
</MudChip>
</MudTooltip>
}
else
{
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">Nicht getestet</MudChip>
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
OnClick="() => EditServer(context)" />
<MudIconButton Icon="@Icons.Material.Filled.NetworkCheck" Size="Size.Small" Color="Color.Info"
OnClick="() => TestServerConnection(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="() => DeleteServer(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
<MudText Typo="Typo.h5" Class="mb-2">Standorte (Sites)</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
OnClick="AddSite" Class="mb-3">
Neuen Standort hinzufügen
</MudButton>
<MudTable Items="_sites" Dense Hover Striped>
<HeaderContent>
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Quellsystem</MudTh>
<MudTh>Quelle</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.SourceSystem</MudTd>
<MudTd>@GetConnectionTarget(context)</MudTd>
<MudTd>
@if (context.IsActive)
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Default" Size="Size.Small" />
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
OnClick="() => EditSite(context)" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="() => DeleteSite(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
<MudDialog @bind-Visible="_serverDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingServer.Id == 0 ? "Server hinzufügen" : "Server bearbeiten")</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_editingServer.Name" Label="Name" Required />
<MudTextField @bind-Value="_editingServer.Host" Label="Host" Required
HelperText="IP oder Hostname (ohne Protokoll)" />
<MudNumericField @bind-Value="_editingServer.Port" Label="Port"
HelperText="Typisch 30015 (Tenant), 30013 (SystemDB), 3xx15 für Instanz xx" />
<MudTextField @bind-Value="_editingServer.Username" Label="Username" />
<MudTextField @bind-Value="_editingServer.Password" Label="Password" InputType="InputType.Password" />
<MudTextField @bind-Value="_editingServer.DatabaseName" Label="Database Name (MDC)"
HelperText="Nur bei Multi-Tenant Setup angeben, sonst leer lassen" />
<MudSwitch @bind-Value="_editingServer.UseSsl" Label="SSL/TLS verwenden (encrypt=true)" Color="Color.Primary" />
<MudSwitch @bind-Value="_editingServer.ValidateCertificate" Label="SSL-Zertifikat validieren" Color="Color.Primary"
Disabled="!_editingServer.UseSsl" />
<MudTextField @bind-Value="_editingServer.AdditionalParams" Label="Zusätzliche Parameter"
HelperText="Optional, z.B. sslCryptoProvider=openssl;communicationTimeout=0" />
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseServerDialog">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveServer" Disabled="_savingServer">Speichern</MudButton>
</DialogActions>
</MudDialog>
<MudDialog @bind-Visible="_siteDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten")</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_editingSite.Schema" Label="Schema" Required />
<MudTextField @bind-Value="_editingSite.TSC" Label="TSC" Required />
<MudTextField @bind-Value="_editingSite.Land" Label="Land" Required />
<MudSelect @bind-Value="_editingSite.SourceSystem" Label="Quellsystem" Required>
@foreach (var system in _sourceSystems)
{
<MudSelectItem Value="@system">@system</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_editingSite.UsernameOverride" Label="Username Override"
HelperText="Optional. Wenn leer, wird der zentrale Username des Quellsystems verwendet." />
<MudTextField @bind-Value="_editingSite.PasswordOverride" Label="Password Override" InputType="InputType.Password"
HelperText="Optional. Wenn leer, wird das zentrale Passwort des Quellsystems verwendet." />
<MudTextField @bind-Value="_editingSite.LocalExportFolderOverride" Label="Lokaler Exportpfad Override"
HelperText="Optional. Wenn leer, wird der zentrale Standardpfad für Standort-Dateien verwendet." />
<MudCheckBox @bind-Value="_editingSite.IsActive" Label="Aktiv" />
<MudDivider Class="my-4" />
@if (IsSapSite())
{
<MudText Typo="Typo.h6" Class="mb-2">SAP Gateway</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
Die Service-URL zeigt auf den OData-Service. Die verfügbaren Entity Sets werden nur per Knopfdruck aktualisiert und lokal zwischengespeichert.
</MudAlert>
<MudTextField @bind-Value="_editingSite.SapServiceUrl" Label="SAP Service URL" Required
HelperText="z.B. http://server:8000/sap/opu/odata/sap/ZPOWERBI_EINKAUF_SRV/" />
<MudStack Row Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshSapEntitySets"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingSapEntitySets">
@if (_refreshingSapEntitySets)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Lade...")
}
else
{
@("Quellen refreshen")
}
</MudButton>
@if (_editingSite.SapEntitySetsRefreshedAtUtc.HasValue)
{
<MudText Typo="Typo.caption" Class="mt-2">
Letzter Refresh: @_editingSite.SapEntitySetsRefreshedAtUtc.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
</MudText>
}
</MudStack>
<MudDivider Class="my-4" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">SAP Quellen</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapSource">Quelle hinzufügen</MudButton>
</MudStack>
<MudText Typo="Typo.caption" Class="mb-2">
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`.
</MudText>
<MudTable Items="_sapSources" Dense Hover Striped>
<HeaderContent>
<MudTh>Alias</MudTh>
<MudTh>Entity Set</MudTh>
<MudTh>Primär</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudTextField @bind-Value="context.Alias" Dense /></MudTd>
<MudTd>
<MudSelect @bind-Value="context.EntitySet" Dense>
@foreach (var entitySet in _sapEntitySetsCache)
{
<MudSelectItem Value="@entitySet">@entitySet</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsPrimary" Dense /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSapSource(context)" /></MudTd>
</RowTemplate>
</MudTable>
<MudDivider Class="my-4" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">SAP Joins</MudText>
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.AutoFixHigh"
OnClick="AutoMatchSapJoins">
Auto-Match
</MudButton>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapJoin">Join hinzufügen</MudButton>
</MudStack>
</MudStack>
<MudTable Items="_sapJoins" Dense Hover Striped>
<HeaderContent>
<MudTh>Links</MudTh>
<MudTh>Left Keys</MudTh>
<MudTh>Rechts</MudTh>
<MudTh>Right Keys</MudTh>
<MudTh>Typ</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudSelect @bind-Value="context.LeftAlias" Dense>
@foreach (var alias in GetSapAliases())
{
<MudSelectItem Value="@alias">@alias</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string"
SelectedValues="GetSelectedJoinKeys(context.LeftKeys)"
SelectedValuesChanged="@(values => context.LeftKeys = string.Join(',', values))"
MultiSelection="true"
Dense>
@foreach (var field in GetAvailableJoinFields(context.LeftAlias, context.LeftKeys))
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect @bind-Value="context.RightAlias" Dense>
@foreach (var alias in GetSapAliases())
{
<MudSelectItem Value="@alias">@alias</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string"
SelectedValues="GetSelectedJoinKeys(context.RightKeys)"
SelectedValuesChanged="@(values => context.RightKeys = string.Join(',', values))"
MultiSelection="true"
Dense>
@foreach (var field in GetAvailableJoinFields(context.RightAlias, context.RightKeys))
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect @bind-Value="context.JoinType" Dense>
<MudSelectItem Value="@("Left")">Left</MudSelectItem>
</MudSelect>
</MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSapJoin(context)" /></MudTd>
</RowTemplate>
</MudTable>
<MudDivider Class="my-4" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">Feldmappings ins zentrale Schema</MudText>
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Schema"
OnClick="RefreshSapSourceFields" Disabled="_refreshingSapSourceFields">
@if (_refreshingSapSourceFields)
{
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
@("Lade Felder...")
}
else
{
@("Felder aus Quellen laden")
}
</MudButton>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapMapping">Mapping hinzufügen</MudButton>
</MudStack>
</MudStack>
<MudText Typo="Typo.caption" Class="mb-2">
Source Expressions werden aus den hinzugefügten SAP-Quellen als `Alias.Feldname` gelesen. Vorhandene manuelle Werte bleiben auswählbar.
</MudText>
<MudTable Items="_sapMappings" Dense Hover Striped>
<HeaderContent>
<MudTh>Zielfeld</MudTh>
<MudTh>Source Expression</MudTh>
<MudTh>Pflicht</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudSelect @bind-Value="context.TargetField" Dense>
@foreach (var field in _salesRecordFields)
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" @bind-Value="context.SourceExpression" Dense>
@foreach (var expression in GetAvailableSourceExpressions(context.SourceExpression))
{
<MudSelectItem Value="@expression">@expression</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense /></MudTd>
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSapMapping(context)" /></MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudText Typo="Typo.h6" Class="mb-2">HANA-Verbindung</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
Host, Port und technische HANA-Parameter kommen von dieser Verbindung. Username und Password hier dienen nur noch als Fallback für bestehende Einträge.
</MudAlert>
<MudTextField @bind-Value="_editingSiteServer.Name" Label="Verbindungsname" Required
HelperText="Interner Anzeigename für diesen Standort" />
<MudTextField @bind-Value="_editingSiteServer.Host" Label="Host oder ServerNode" Required
HelperText="z.B. hana01 oder hana01:30015 oder derselbe HanaServer-Wert wie in Power BI" />
<MudNumericField @bind-Value="_editingSiteServer.Port" Label="Port"
HelperText="Wird ignoriert, wenn im Host bereits ein Port enthalten ist" />
<MudTextField @bind-Value="_editingSiteServer.Username" Label="Username" />
<MudTextField @bind-Value="_editingSiteServer.Password" Label="Password" InputType="InputType.Password" />
<MudTextField @bind-Value="_editingSiteServer.DatabaseName" Label="Database Name (MDC)"
HelperText="Nur bei Multi-Tenant Setup angeben, sonst leer lassen" />
<MudSwitch @bind-Value="_editingSiteServer.UseSsl" Label="SSL/TLS verwenden (encrypt=true)" Color="Color.Primary" />
<MudSwitch @bind-Value="_editingSiteServer.ValidateCertificate" Label="SSL-Zertifikat validieren" Color="Color.Primary"
Disabled="!_editingSiteServer.UseSsl" />
<MudTextField @bind-Value="_editingSiteServer.AdditionalParams" Label="Zusätzliche Parameter"
HelperText="Optional, z.B. sslCryptoProvider=openssl;communicationTimeout=0" />
}
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseSiteDialog" Disabled="_savingSite">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSite" Disabled="_savingSite || _refreshingSapEntitySets">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE"];
private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new();
private List<HanaServer> _servers = new();
private List<Site> _sites = new();
private List<string> _sapEntitySetsCache = [];
private List<string> _sapAvailableSourceExpressions = [];
private Dictionary<string, List<string>> _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
private List<SapSourceDefinition> _sapSources = [];
private List<SapJoinDefinition> _sapJoins = [];
private List<SapFieldMapping> _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 HanaServer _editingSiteServer = new();
private bool _serverDialogVisible;
private bool _siteDialogVisible;
private bool _refreshingSapEntitySets;
private bool _refreshingSapSourceFields;
private bool _savingServer;
private bool _savingSite;
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();
_servers = await db.HanaServers.OrderBy(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);
_serverDialogVisible = true;
}
private async Task SaveServer()
{
if (_savingServer)
return;
_savingServer = true;
try
{
using var db = await DbFactory.CreateDbContextAsync();
if (_editingServer.Id == 0)
{
db.HanaServers.Add(_editingServer);
}
else
{
var existing = await db.HanaServers.FindAsync(_editingServer.Id);
if (existing is not null)
{
existing.Name = _editingServer.Name;
existing.Host = _editingServer.Host;
existing.Port = _editingServer.Port;
existing.Username = _editingServer.Username;
existing.Password = _editingServer.Password;
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)
{
var result = await DialogService.ShowMessageBox(
"Server löschen",
$"Server '{server.Name}' wirklich löschen?",
yesText: "Löschen", cancelText: "Abbrechen");
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.HanaServers.FindAsync(server.Id);
if (entity is not null)
{
db.HanaServers.Remove(entity);
await db.SaveChangesAsync();
}
await LoadDataAsync();
Snackbar.Add("Server gelöscht", Severity.Info);
}
private async Task TestServerConnection(HanaServer server)
{
await AppEventLogService.WriteAsync("HANA", "Server-Test aus UI gestartet",
details: server.GetConnectionStringPreview());
var result = await Task.Run(() => HanaService.TestConnectionDetailed(server));
_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 = "SAP",
HanaServerId = null
};
_sapEntitySetsCache = [];
_sapAvailableSourceExpressions = [];
_sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
_sapSources = [];
_sapJoins = [];
_sapMappings = [];
_editingSiteServer = CreateDefaultSiteServer();
_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) ? "SAP" : site.SourceSystem,
UsernameOverride = site.UsernameOverride,
PasswordOverride = site.PasswordOverride,
LocalExportFolderOverride = site.LocalExportFolderOverride,
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();
_editingSiteServer = site.HanaServer is null
? CreateDefaultSiteServer(site)
: CloneServer(site.HanaServer);
_siteDialogVisible = true;
}
private async Task SaveSite()
{
if (_savingSite)
return;
_savingSite = true;
try
{
using var db = await DbFactory.CreateDbContextAsync();
var serverId = IsSapSite() ? (int?)null : await SaveOrCreateSiteServerAsync(db);
_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.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 string GetConnectionTarget(Site site)
{
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
if (string.Equals(sourceSystem, "SAP", StringComparison.OrdinalIgnoreCase))
return string.IsNullOrWhiteSpace(site.SapServiceUrl) ? "-" : site.SapServiceUrl;
return GetServerNode(site.HanaServer);
}
private HanaServer CreateDefaultSiteServer(Site? site = null)
{
var label = !string.IsNullOrWhiteSpace(site?.Land) ? site!.Land : site?.TSC;
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,
Name = server.Name,
Host = server.Host,
Port = server.Port,
Username = server.Username,
Password = server.Password,
DatabaseName = server.DatabaseName,
UseSsl = server.UseSsl,
ValidateCertificate = server.ValidateCertificate,
AdditionalParams = server.AdditionalParams
};
}
private async Task<int> SaveOrCreateSiteServerAsync(AppDbContext db)
{
_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.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.");
if (_editingSite.HanaServerId == 0)
{
db.HanaServers.Add(_editingSiteServer);
await db.SaveChangesAsync();
return _editingSiteServer.Id;
}
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;
}
private bool IsSapSite() => string.Equals(_editingSite.SourceSystem, "SAP", StringComparison.OrdinalIgnoreCase);
private async Task RefreshSapEntitySets()
{
if (_refreshingSapEntitySets)
return;
_refreshingSapEntitySets = true;
try
{
if (string.IsNullOrWhiteSpace(_editingSite.SapServiceUrl))
throw new InvalidOperationException("SAP Service URL muss gesetzt sein.");
using var db = await DbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new();
var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride) ? settings.SapUsername : _editingSite.UsernameOverride;
var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride) ? settings.SapPassword : _editingSite.PasswordOverride;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
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());
_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)
return;
_siteDialogVisible = false;
}
private static List<string> ParseSapEntitySets(string json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
private static string SerializeSapEntitySets(List<string> 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<string> 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
{
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)
.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 settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new();
var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride) ? settings.SapUsername : _editingSite.UsernameOverride;
var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride) ? settings.SapPassword : _editingSite.PasswordOverride;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
var expressions = new List<string> { "=SAP" };
var sourceFieldMap = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var source in activeSources)
{
var fieldNames = await SapGatewayService.GetEntityFieldNamesAsync(_editingSite.SapServiceUrl, 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<string> GetAvailableSourceExpressions(string? currentValue)
{
var expressions = new List<string>(_sapAvailableSourceExpressions);
if (!string.IsNullOrWhiteSpace(currentValue) && !expressions.Contains(currentValue, StringComparer.OrdinalIgnoreCase))
expressions.Insert(0, currentValue);
return expressions;
}
private List<string> BuildSourceExpressionsFromMappings()
=> _sapMappings
.Select(m => m.SourceExpression)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
private Dictionary<string, List<string>> BuildSourceFieldMapFromJoins()
{
var result = new Dictionary<string, List<string>>(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<string, List<string>> 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<string> GetAvailableJoinFields(string? alias, string? currentKeys)
{
var values = new List<string>();
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<string> GetSelectedJoinKeys(string? keys)
=> keys?
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToHashSet(StringComparer.OrdinalIgnoreCase)
?? [];
}