diverse Aenderungen

This commit is contained in:
2026-04-15 11:18:26 +02:00
parent 59e195af71
commit 90133cd0e2
29 changed files with 1651 additions and 77 deletions
@@ -8,6 +8,9 @@
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
Transformationen
</MudNavLink>
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics">
Management Cockpit
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
Settings
</MudNavLink>
@@ -1,5 +1,6 @@
@page "/"
@using Microsoft.EntityFrameworkCore
@using System.Diagnostics
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@@ -40,6 +41,7 @@
<MudTh>Schema</MudTh>
<MudTh>Server</MudTh>
<MudTh>Status</MudTh>
<MudTh>Live-Status</MudTh>
<MudTh>Zeilen</MudTh>
<MudTh>Letzter Lauf</MudTh>
<MudTh>Dauer</MudTh>
@@ -71,16 +73,38 @@
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>
@if (!string.IsNullOrWhiteSpace(context.LiveMessage))
{
<MudTooltip Text="@context.LiveDetails">
<MudText Typo="Typo.caption" Style="max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.LiveMessage
</MudText>
</MudTooltip>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
}
</MudTd>
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
<MudStack Row Spacing="1">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileDownload"
OnClick="() => ExportSingle(context.SiteId)"
Disabled="Orchestrator.IsExporting(context.SiteId)">
Export
</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenExportFile(context)"
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
Excel öffnen
</MudButton>
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
@@ -89,6 +113,7 @@
private List<DashboardRow> _dashboardRows = new();
private bool _loading = true;
private bool _anyRunning;
private CancellationTokenSource? _pollingCts;
protected override async Task OnInitializedAsync()
{
@@ -106,10 +131,19 @@
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync();
var appLogs = await db.AppEventLogs
.Where(l => l.SiteId != null)
.OrderByDescending(l => l.Timestamp)
.Take(1000)
.ToListAsync();
var latestAppLogsBySite = appLogs
.GroupBy(l => l.SiteId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Timestamp).First());
_dashboardRows = sites.Select(s =>
{
var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
latestAppLogsBySite.TryGetValue(s.Id, out var appLog);
return new DashboardRow
{
SiteId = s.Id,
@@ -123,7 +157,10 @@
RowCount = log?.RowCount ?? 0,
LastRun = log?.Timestamp,
DurationSeconds = log?.DurationSeconds ?? 0,
ErrorMessage = log?.ErrorMessage ?? ""
ErrorMessage = log?.ErrorMessage ?? "",
FilePath = log?.FilePath ?? "",
LiveMessage = appLog is null ? string.Empty : $"{appLog.Category}: {appLog.Message}",
LiveDetails = appLog?.Details ?? ""
};
}).ToList();
@@ -134,6 +171,8 @@
private async Task ExportAll()
{
_anyRunning = true;
await LoadDataAsync();
StartPolling();
_ = Task.Run(async () =>
{
await Orchestrator.ExportAllAsync();
@@ -148,14 +187,28 @@
private void ExportSingle(int siteId)
{
_anyRunning = true;
_ = InvokeAsync(async () => await LoadDataAsync());
StartPolling();
_ = Task.Run(async () =>
{
await Orchestrator.ExportSiteByIdAsync(siteId);
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
await InvokeAsync(async () =>
{
await LoadDataAsync();
StateHasChanged();
});
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
{
await InvokeAsync(() =>
Snackbar.Add($"Export gespeichert: {result.FilePath}", Severity.Success));
}
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
{
await InvokeAsync(() =>
Snackbar.Add($"Export fehlgeschlagen: {result.Log.ErrorMessage}", Severity.Error));
}
});
Snackbar.Add("Export gestartet", Severity.Info);
}
@@ -164,21 +217,136 @@
{
await InvokeAsync(async () =>
{
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
StateHasChanged();
if (!_anyRunning)
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || _dashboardRows.Count == 0;
if (_anyRunning)
{
await LoadDataAsync();
StartPolling();
await RefreshLiveDataAsync();
StateHasChanged();
return;
}
StopPolling();
await LoadDataAsync();
StateHasChanged();
});
}
public void Dispose()
{
StopPolling();
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
}
private void OpenExportFile(DashboardRow row)
{
if (string.IsNullOrWhiteSpace(row.FilePath) || !File.Exists(row.FilePath))
{
Snackbar.Add("Exportdatei nicht gefunden.", Severity.Warning);
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = row.FilePath,
UseShellExecute = true
});
}
catch (Exception ex)
{
Snackbar.Add($"Datei konnte nicht geöffnet werden: {ex.Message}", Severity.Error);
}
}
private void StartPolling()
{
if (_pollingCts is not null && !_pollingCts.IsCancellationRequested)
return;
_pollingCts = new CancellationTokenSource();
_ = PollDashboardAsync(_pollingCts.Token);
}
private void StopPolling()
{
_pollingCts?.Cancel();
_pollingCts?.Dispose();
_pollingCts = null;
}
private async Task PollDashboardAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
try
{
while (await timer.WaitForNextTickAsync(cancellationToken))
{
var anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
if (!anyRunning)
{
await InvokeAsync(async () =>
{
_anyRunning = false;
await LoadDataAsync();
StateHasChanged();
});
StopPolling();
break;
}
await InvokeAsync(async () =>
{
_anyRunning = true;
await RefreshLiveDataAsync();
StateHasChanged();
});
}
}
catch (OperationCanceledException)
{
}
}
private async Task RefreshLiveDataAsync()
{
var runningSiteIds = _dashboardRows
.Where(r => Orchestrator.IsExporting(r.SiteId))
.Select(r => r.SiteId)
.Distinct()
.ToList();
if (runningSiteIds.Count == 0)
{
_anyRunning = false;
return;
}
using var db = await DbFactory.CreateDbContextAsync();
var appLogs = await db.AppEventLogs
.Where(l => l.SiteId != null && runningSiteIds.Contains(l.SiteId.Value))
.OrderByDescending(l => l.Timestamp)
.Take(200)
.ToListAsync();
var latestAppLogsBySite = appLogs
.GroupBy(l => l.SiteId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Timestamp).First());
foreach (var row in _dashboardRows)
{
if (!latestAppLogsBySite.TryGetValue(row.SiteId, out var appLog))
continue;
row.LiveMessage = $"{appLog.Category}: {appLog.Message}";
row.LiveDetails = appLog.Details ?? string.Empty;
}
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
}
private class DashboardRow
{
public int SiteId { get; set; }
@@ -191,5 +359,9 @@
public DateTime? LastRun { get; set; }
public double DurationSeconds { get; set; }
public string ErrorMessage { get; set; } = "";
public string FilePath { get; set; } = "";
public string LiveMessage { get; set; } = "";
public string LiveDetails { get; set; } = "";
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath);
}
}
@@ -75,8 +75,39 @@
</RowTemplate>
</MudTable>
<MudText Typo="Typo.h5" Class="mt-6 mb-2">Technische Logs</MudText>
<MudTable Items="_appLogs" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>Zeitpunkt</MudTh>
<MudTh>Level</MudTh>
<MudTh>Kategorie</MudTh>
<MudTh>Land</MudTh>
<MudTh>Meldung</MudTh>
<MudTh>Details</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
<MudTd>@context.Level</MudTd>
<MudTd>@context.Category</MudTd>
<MudTd>@(string.IsNullOrWhiteSpace(context.Land) ? "-" : context.Land)</MudTd>
<MudTd>@context.Message</MudTd>
<MudTd>
@if (!string.IsNullOrWhiteSpace(context.Details))
{
<MudTooltip Text="@context.Details">
<MudText Typo="Typo.caption" Style="max-width:420px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.Details
</MudText>
</MudTooltip>
}
</MudTd>
</RowTemplate>
</MudTable>
@code {
private List<ExportLog> _logs = new();
private List<AppEventLog> _appLogs = new();
private List<string> _availableLands = new();
private string? _filterLand;
private string? _filterStatus;
@@ -106,6 +137,16 @@
query = query.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
_logs = await query.Take(500).ToListAsync();
IQueryable<AppEventLog> appLogQuery = db.AppEventLogs.OrderByDescending(l => l.Timestamp);
if (!string.IsNullOrEmpty(_filterLand))
appLogQuery = appLogQuery.Where(l => l.Land == _filterLand);
if (_filterDate.HasValue)
appLogQuery = appLogQuery.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
_appLogs = await appLogQuery.Take(500).ToListAsync();
_loading = false;
}
@@ -0,0 +1,146 @@
@page "/management-cockpit"
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IManagementCockpitService CockpitService
@inject ISnackbar Snackbar
<PageTitle>Management Cockpit</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Management Cockpit</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="8">
<MudSelect T="string" @bind-Value="_selectedFilePath" Label="Vorhandene Excel-Datei" Dense>
@foreach (var file in _files)
{
<MudSelectItem Value="@file.Path">@file.DisplayName</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="4">
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ReloadFiles"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loadingFiles">
Dateien laden
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Analyze"
StartIcon="@Icons.Material.Filled.Analytics" Disabled="_analyzing || string.IsNullOrWhiteSpace(_selectedFilePath)">
@(_analyzing ? "Analysiere..." : "Cockpit erzeugen")
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@if (_result is not null)
{
<MudGrid Class="mb-4">
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Land</MudText><MudText Typo="Typo.h6">@_result.Summary.Land</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">TSC</MudText><MudText Typo="Typo.h6">@_result.Summary.Tsc</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Umsatz</MudText><MudText Typo="Typo.h6">@_result.Summary.SalesValueTotal.ToString("N2")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Geschätzte Marge</MudText><MudText Typo="Typo.h6">@($"{_result.Summary.EstimatedMarginPercent:F1}%")</MudText></MudPaper></MudItem>
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Management Aussagen</MudText>
@foreach (var finding in _result.Findings)
{
<MudAlert Severity="@MapSeverity(finding.Severity)" Dense Variant="Variant.Outlined" Class="mb-2">
<b>@finding.Title:</b> @finding.Detail
</MudAlert>
}
</MudPaper>
<MudGrid Class="mb-4">
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Top Kunden</MudText>
@foreach (var item in _result.TopCustomers)
{
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Top Produktgruppen</MudText>
@foreach (var item in _result.TopProductGroups)
{
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Top Sales Owner</MudText>
@foreach (var item in _result.TopSalesEmployees)
{
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
}
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Datenqualität</MudText>
@foreach (var entry in _result.DataQualityCounts.OrderByDescending(x => x.Value))
{
<MudText Typo="Typo.body2">@($"{entry.Key}: {entry.Value}")</MudText>
}
</MudPaper>
}
@code {
private List<ManagementCockpitFileOption> _files = [];
private string? _selectedFilePath;
private ManagementCockpitResult? _result;
private bool _loadingFiles;
private bool _analyzing;
protected override async Task OnInitializedAsync()
{
await ReloadFiles();
}
private async Task ReloadFiles()
{
_loadingFiles = true;
try
{
_files = await CockpitService.GetAvailableFilesAsync();
_selectedFilePath ??= _files.FirstOrDefault()?.Path;
}
finally
{
_loadingFiles = false;
}
}
private async Task Analyze()
{
if (string.IsNullOrWhiteSpace(_selectedFilePath))
return;
_analyzing = true;
try
{
_result = await CockpitService.AnalyzeAsync(_selectedFilePath);
}
catch (Exception ex)
{
Snackbar.Add($"Cockpit konnte nicht erzeugt werden: {ex.Message}", Severity.Error);
}
finally
{
_analyzing = false;
}
}
private static Severity MapSeverity(string severity) => severity switch
{
"Warning" => Severity.Warning,
"Error" => Severity.Error,
_ => Severity.Info
};
}
@@ -168,6 +168,20 @@
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="Timer aktiviert" Color="Color.Primary" />
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.DebugLoggingEnabled" Label="Debug Live-Logging" Color="Color.Warning" />
<MudText Typo="Typo.caption">
Schreibt zusätzliche technische Fortschrittsmeldungen für HANA- und SAP-Lesevorgänge ins Dashboard und in die Logs.
</MudText>
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_exportSettings.LocalSiteExportFolder" Label="Lokaler Standardpfad Standort-Dateien"
HelperText="Wenn leer, wird ./output unter dem Programmverzeichnis verwendet." />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_exportSettings.LocalConsolidatedExportFolder" Label="Lokaler Pfad Zentrale Datei"
HelperText="Optional. Wenn leer, wird der Standardpfad der Standort-Dateien verwendet." />
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
StartIcon="@Icons.Material.Filled.Save">
@@ -258,6 +272,9 @@
existing.TimerHour = _exportSettings.TimerHour;
existing.TimerMinute = _exportSettings.TimerMinute;
existing.TimerEnabled = _exportSettings.TimerEnabled;
existing.DebugLoggingEnabled = _exportSettings.DebugLoggingEnabled;
existing.LocalSiteExportFolder = _exportSettings.LocalSiteExportFolder;
existing.LocalConsolidatedExportFolder = _exportSettings.LocalConsolidatedExportFolder;
existing.SapUsername = _exportSettings.SapUsername;
existing.SapPassword = _exportSettings.SapPassword;
existing.Bi1Username = _exportSettings.Bi1Username;
@@ -8,6 +8,7 @@
@inject IDbContextFactory<AppDbContext> DbFactory
@inject IHanaQueryService HanaService
@inject ISapGatewayService SapGatewayService
@inject IAppEventLogService AppEventLogService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -149,6 +150,8 @@
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" />
@@ -216,7 +219,13 @@
<MudDivider Class="my-4" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">SAP Joins</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapJoin">Join hinzufügen</MudButton>
<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>
@@ -237,7 +246,18 @@
}
</MudSelect>
</MudTd>
<MudTd><MudTextField @bind-Value="context.LeftKeys" Dense Placeholder="VBELN,POSNR" /></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())
@@ -246,7 +266,18 @@
}
</MudSelect>
</MudTd>
<MudTd><MudTextField @bind-Value="context.RightKeys" Dense Placeholder="VBELN,POSNR" /></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>
@@ -260,8 +291,25 @@
<MudDivider Class="my-4" />
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
<MudText Typo="Typo.h6">Feldmappings ins zentrale Schema</MudText>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapMapping">Mapping hinzufügen</MudButton>
<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>
@@ -279,7 +327,14 @@
}
</MudSelect>
</MudTd>
<MudTd><MudTextField @bind-Value="context.SourceExpression" Dense Placeholder="VBAK.VBELN oder =SAP" /></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>
@@ -321,6 +376,8 @@
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 = [];
@@ -334,6 +391,7 @@
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 };
@@ -426,6 +484,8 @@
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;
@@ -457,6 +517,8 @@
HanaServerId = null
};
_sapEntitySetsCache = [];
_sapAvailableSourceExpressions = [];
_sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
_sapSources = [];
_sapJoins = [];
_sapMappings = [];
@@ -476,6 +538,7 @@
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,
@@ -487,6 +550,8 @@
_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);
@@ -522,6 +587,7 @@
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;
@@ -629,6 +695,7 @@
: _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();
@@ -698,6 +765,8 @@
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);
@@ -710,10 +779,14 @@
}
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
{
@@ -782,6 +855,83 @@
});
}
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);
@@ -792,6 +942,7 @@
_sapMappings.Add(new SapFieldMapping
{
TargetField = _salesRecordFields.First(),
SourceExpression = _sapAvailableSourceExpressions.FirstOrDefault() ?? "=SAP",
IsActive = true,
SortOrder = _sapMappings.Count
});
@@ -847,4 +998,147 @@
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)
?? [];
}