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)
?? [];
}
+1
View File
@@ -12,6 +12,7 @@ public class AppDbContext : DbContext
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
+13
View File
@@ -0,0 +1,13 @@
namespace TrafagSalesExporter.Models;
public class AppEventLog
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public string Level { get; set; } = "Info";
public string Category { get; set; } = string.Empty;
public int? SiteId { get; set; }
public string Land { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Details { get; set; } = string.Empty;
}
@@ -30,6 +30,9 @@ public class ConfigTransferExportSettings
public int TimerHour { get; set; } = 3;
public int TimerMinute { get; set; }
public bool TimerEnabled { get; set; } = true;
public bool DebugLoggingEnabled { get; set; }
public string LocalSiteExportFolder { get; set; } = string.Empty;
public string LocalConsolidatedExportFolder { get; set; } = string.Empty;
public string? SapUsername { get; set; }
public string? SapPassword { get; set; }
public string? Bi1Username { get; set; }
@@ -62,6 +65,7 @@ public class ConfigTransferSite
public string SourceSystem { get; set; } = "SAP";
public string? UsernameOverride { get; set; }
public string? PasswordOverride { get; set; }
public string LocalExportFolderOverride { get; set; } = string.Empty;
public string SapServiceUrl { get; set; } = string.Empty;
public string SapEntitySet { get; set; } = string.Empty;
public string SapEntitySetsCache { get; set; } = string.Empty;
+1
View File
@@ -17,5 +17,6 @@ public class ExportLog
public int RowCount { get; set; }
public string? ErrorMessage { get; set; }
public string FileName { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public double DurationSeconds { get; set; }
}
@@ -7,6 +7,9 @@ public class ExportSettings
public int TimerHour { get; set; } = 3;
public int TimerMinute { get; set; }
public bool TimerEnabled { get; set; } = true;
public bool DebugLoggingEnabled { get; set; }
public string LocalSiteExportFolder { get; set; } = string.Empty;
public string LocalConsolidatedExportFolder { get; set; } = string.Empty;
public string SapUsername { get; set; } = string.Empty;
public string SapPassword { get; set; } = string.Empty;
public string Bi1Username { get; set; } = string.Empty;
@@ -0,0 +1,50 @@
namespace TrafagSalesExporter.Models;
public class ManagementCockpitFileOption
{
public string Path { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public DateTime LastModified { get; set; }
}
public class ManagementCockpitSummary
{
public string Land { get; set; } = string.Empty;
public string Tsc { get; set; } = string.Empty;
public DateTime? ExtractionDate { get; set; }
public int RowCount { get; set; }
public int InvoiceCount { get; set; }
public int CustomerCount { get; set; }
public decimal SalesValueTotal { get; set; }
public decimal EstimatedCostTotal { get; set; }
public decimal EstimatedMarginTotal { get; set; }
public decimal EstimatedMarginPercent { get; set; }
public decimal ServiceSharePercent { get; set; }
public decimal MissingOrderDatePercent { get; set; }
public decimal MissingSupplierPercent { get; set; }
}
public class ManagementCockpitFinding
{
public string Severity { get; set; } = "Info";
public string Title { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
}
public class ManagementCockpitTopItem
{
public string Label { get; set; } = string.Empty;
public decimal Value { get; set; }
public decimal SharePercent { get; set; }
}
public class ManagementCockpitResult
{
public string FilePath { get; set; } = string.Empty;
public ManagementCockpitSummary Summary { get; set; } = new();
public List<ManagementCockpitFinding> Findings { get; set; } = [];
public List<ManagementCockpitTopItem> TopCustomers { get; set; } = [];
public List<ManagementCockpitTopItem> TopProductGroups { get; set; } = [];
public List<ManagementCockpitTopItem> TopSalesEmployees { get; set; } = [];
public Dictionary<string, int> DataQualityCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
+1
View File
@@ -27,6 +27,7 @@ public class Site
public string UsernameOverride { get; set; } = string.Empty;
public string PasswordOverride { get; set; } = string.Empty;
public string LocalExportFolderOverride { get; set; } = string.Empty;
public string SapServiceUrl { get; set; } = string.Empty;
+3 -1
View File
@@ -11,7 +11,7 @@ builder.Services.AddRazorComponents()
builder.Services.AddMudServices();
builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db"));
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=10"));
builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
@@ -26,6 +26,8 @@ builder.Services.AddSingleton<ITransformationStrategy, SuffixTransformationStrat
builder.Services.AddSingleton<ITransformationStrategy, ReplaceTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, ConstantTransformationStrategy>();
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class AppEventLogService : IAppEventLogService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public AppEventLogService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null)
{
using var db = await _dbFactory.CreateDbContextAsync();
db.AppEventLogs.Add(new AppEventLog
{
Timestamp = DateTime.Now,
Level = string.IsNullOrWhiteSpace(level) ? "Info" : level.Trim(),
Category = category?.Trim() ?? string.Empty,
SiteId = siteId,
Land = land?.Trim() ?? string.Empty,
Message = message?.Trim() ?? string.Empty,
Details = details?.Trim() ?? string.Empty
});
await db.SaveChangesAsync();
}
public async Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null)
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync();
if (settings is null || !settings.DebugLoggingEnabled)
return;
db.AppEventLogs.Add(new AppEventLog
{
Timestamp = DateTime.Now,
Level = "Debug",
Category = category?.Trim() ?? string.Empty,
SiteId = siteId,
Land = land?.Trim() ?? string.Empty,
Message = message?.Trim() ?? string.Empty,
Details = details?.Trim() ?? string.Empty
});
await db.SaveChangesAsync();
}
}
@@ -1,3 +1,4 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
@@ -6,55 +7,50 @@ namespace TrafagSalesExporter.Services;
public class CentralSalesRecordService : ICentralSalesRecordService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private const int BatchSize = 25;
public CentralSalesRecordService(IDbContextFactory<AppDbContext> dbFactory)
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IAppEventLogService _appEventLogService;
public CentralSalesRecordService(IDbContextFactory<AppDbContext> dbFactory, IAppEventLogService appEventLogService)
{
_dbFactory = dbFactory;
_appEventLogService = appEventLogService;
}
public async Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records)
public async Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records, Action<string>? updateStatus = null)
{
using var db = await _dbFactory.CreateDbContextAsync();
var existing = await db.CentralSalesRecords.Where(r => r.SiteId == site.Id).ToListAsync();
if (existing.Count > 0)
db.CentralSalesRecords.RemoveRange(existing);
var recordList = records.ToList();
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
db.CentralSalesRecords.AddRange(records.Select(record => new CentralSalesRecord
await db.Database.OpenConnectionAsync();
var connection = (SqliteConnection)db.Database.GetDbConnection();
try
{
StoredAtUtc = DateTime.UtcNow,
SiteId = site.Id,
SourceSystem = sourceSystem,
ExtractionDate = record.ExtractionDate,
Tsc = record.Tsc,
InvoiceNumber = record.InvoiceNumber,
PositionOnInvoice = record.PositionOnInvoice,
Material = record.Material,
Name = record.Name,
ProductGroup = record.ProductGroup,
Quantity = record.Quantity,
SupplierNumber = record.SupplierNumber,
SupplierName = record.SupplierName,
SupplierCountry = record.SupplierCountry,
CustomerNumber = record.CustomerNumber,
CustomerName = record.CustomerName,
CustomerCountry = record.CustomerCountry,
CustomerIndustry = record.CustomerIndustry,
StandardCost = record.StandardCost,
StandardCostCurrency = record.StandardCostCurrency,
PurchaseOrderNumber = record.PurchaseOrderNumber,
SalesPriceValue = record.SalesPriceValue,
SalesCurrency = record.SalesCurrency,
Incoterms2020 = record.Incoterms2020,
SalesResponsibleEmployee = record.SalesResponsibleEmployee,
InvoiceDate = record.InvoiceDate,
OrderDate = record.OrderDate,
Land = record.Land,
DocumentType = record.DocumentType
}));
updateStatus?.Invoke("Zentrale Tabelle: bestehende Saetze zaehlen...");
var existingCount = await CountExistingAsync(connection, site.Id);
await db.SaveChangesAsync();
if (existingCount > 0)
{
updateStatus?.Invoke("Zentrale Tabelle: alte Saetze loeschen...");
await DeleteExistingAsync(connection, site.Id);
}
updateStatus?.Invoke("Zentrale Tabelle: neue Saetze vorbereiten...");
await InsertRecordsInCommittedBatchesAsync(connection, site, recordList, updateStatus);
await _appEventLogService.WriteAsync(
"Export",
"Zentrale Tabelle aktualisiert",
siteId: site.Id,
land: site.Land,
details: $"Geloescht={existingCount} | Neu={recordList.Count}");
}
finally
{
await db.Database.CloseConnectionAsync();
}
}
public async Task<List<SalesRecord>> GetAllAsync()
@@ -94,4 +90,147 @@ public class CentralSalesRecordService : ICentralSalesRecordService
})
.ToListAsync();
}
private static async Task<int> CountExistingAsync(SqliteConnection connection, int siteId)
{
await using var command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(1) FROM CentralSalesRecords WHERE SiteId = $siteId;";
command.Parameters.AddWithValue("$siteId", siteId);
var scalar = await command.ExecuteScalarAsync();
return scalar is null or DBNull ? 0 : Convert.ToInt32(scalar);
}
private static async Task DeleteExistingAsync(SqliteConnection connection, int siteId)
{
await using var transaction = connection.BeginTransaction();
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "DELETE FROM CentralSalesRecords WHERE SiteId = $siteId;";
command.Parameters.AddWithValue("$siteId", siteId);
await command.ExecuteNonQueryAsync();
await transaction.CommitAsync();
}
private static async Task InsertRecordsInCommittedBatchesAsync(
SqliteConnection connection,
Site site,
IReadOnlyList<SalesRecord> records,
Action<string>? updateStatus)
{
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
var total = records.Count;
var totalBatches = Math.Max(1, (int)Math.Ceiling(total / (double)BatchSize));
var processed = 0;
for (var batchIndex = 0; batchIndex < totalBatches; batchIndex++)
{
updateStatus?.Invoke($"Zentrale Tabelle: Batch {batchIndex + 1}/{totalBatches} speichern...");
await using var transaction = connection.BeginTransaction();
await using var command = CreateInsertCommand(connection, transaction);
var batchRecords = records
.Skip(batchIndex * BatchSize)
.Take(BatchSize);
foreach (var record in batchRecords)
{
SetInsertParameters(command, site, sourceSystem, record);
await command.ExecuteNonQueryAsync();
processed++;
}
updateStatus?.Invoke($"Zentrale Tabelle: Batch {batchIndex + 1}/{totalBatches} abschliessen...");
await transaction.CommitAsync();
}
updateStatus?.Invoke($"Zentrale Tabelle: {processed} Datensaetze gespeichert.");
}
private static SqliteCommand CreateInsertCommand(SqliteConnection connection, SqliteTransaction transaction)
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO CentralSalesRecords (
StoredAtUtc, SiteId, SourceSystem, ExtractionDate, Tsc, InvoiceNumber, PositionOnInvoice,
Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry,
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType
)
VALUES (
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $invoiceNumber, $positionOnInvoice,
$material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry,
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
$salesResponsibleEmployee, $invoiceDate, $orderDate, $land, $documentType
);
""";
command.Parameters.Add("$storedAtUtc", SqliteType.Text);
command.Parameters.Add("$siteId", SqliteType.Integer);
command.Parameters.Add("$sourceSystem", SqliteType.Text);
command.Parameters.Add("$extractionDate", SqliteType.Text);
command.Parameters.Add("$tsc", SqliteType.Text);
command.Parameters.Add("$invoiceNumber", SqliteType.Text);
command.Parameters.Add("$positionOnInvoice", SqliteType.Integer);
command.Parameters.Add("$material", SqliteType.Text);
command.Parameters.Add("$name", SqliteType.Text);
command.Parameters.Add("$productGroup", SqliteType.Text);
command.Parameters.Add("$quantity", SqliteType.Real);
command.Parameters.Add("$supplierNumber", SqliteType.Text);
command.Parameters.Add("$supplierName", SqliteType.Text);
command.Parameters.Add("$supplierCountry", SqliteType.Text);
command.Parameters.Add("$customerNumber", SqliteType.Text);
command.Parameters.Add("$customerName", SqliteType.Text);
command.Parameters.Add("$customerCountry", SqliteType.Text);
command.Parameters.Add("$customerIndustry", SqliteType.Text);
command.Parameters.Add("$standardCost", SqliteType.Real);
command.Parameters.Add("$standardCostCurrency", SqliteType.Text);
command.Parameters.Add("$purchaseOrderNumber", SqliteType.Text);
command.Parameters.Add("$salesPriceValue", SqliteType.Real);
command.Parameters.Add("$salesCurrency", SqliteType.Text);
command.Parameters.Add("$incoterms2020", SqliteType.Text);
command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text);
command.Parameters.Add("$invoiceDate", SqliteType.Text);
command.Parameters.Add("$orderDate", SqliteType.Text);
command.Parameters.Add("$land", SqliteType.Text);
command.Parameters.Add("$documentType", SqliteType.Text);
return command;
}
private static void SetInsertParameters(SqliteCommand command, Site site, string sourceSystem, SalesRecord record)
{
command.Parameters["$storedAtUtc"].Value = DateTime.UtcNow.ToString("O");
command.Parameters["$siteId"].Value = site.Id;
command.Parameters["$sourceSystem"].Value = sourceSystem;
command.Parameters["$extractionDate"].Value = record.ExtractionDate.ToString("O");
command.Parameters["$tsc"].Value = record.Tsc ?? string.Empty;
command.Parameters["$invoiceNumber"].Value = record.InvoiceNumber ?? string.Empty;
command.Parameters["$positionOnInvoice"].Value = record.PositionOnInvoice;
command.Parameters["$material"].Value = record.Material ?? string.Empty;
command.Parameters["$name"].Value = record.Name ?? string.Empty;
command.Parameters["$productGroup"].Value = record.ProductGroup ?? string.Empty;
command.Parameters["$quantity"].Value = record.Quantity;
command.Parameters["$supplierNumber"].Value = record.SupplierNumber ?? string.Empty;
command.Parameters["$supplierName"].Value = record.SupplierName ?? string.Empty;
command.Parameters["$supplierCountry"].Value = record.SupplierCountry ?? string.Empty;
command.Parameters["$customerNumber"].Value = record.CustomerNumber ?? string.Empty;
command.Parameters["$customerName"].Value = record.CustomerName ?? string.Empty;
command.Parameters["$customerCountry"].Value = record.CustomerCountry ?? string.Empty;
command.Parameters["$customerIndustry"].Value = record.CustomerIndustry ?? string.Empty;
command.Parameters["$standardCost"].Value = record.StandardCost;
command.Parameters["$standardCostCurrency"].Value = record.StandardCostCurrency ?? string.Empty;
command.Parameters["$purchaseOrderNumber"].Value = record.PurchaseOrderNumber ?? string.Empty;
command.Parameters["$salesPriceValue"].Value = record.SalesPriceValue;
command.Parameters["$salesCurrency"].Value = record.SalesCurrency ?? string.Empty;
command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty;
command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty;
command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$orderDate"].Value = record.OrderDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$land"].Value = record.Land ?? string.Empty;
command.Parameters["$documentType"].Value = record.DocumentType ?? string.Empty;
}
}
@@ -47,6 +47,9 @@ public class ConfigTransferService : IConfigTransferService
TimerHour = exportSettings.TimerHour,
TimerMinute = exportSettings.TimerMinute,
TimerEnabled = exportSettings.TimerEnabled,
DebugLoggingEnabled = exportSettings.DebugLoggingEnabled,
LocalSiteExportFolder = exportSettings.LocalSiteExportFolder,
LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder,
SapUsername = includeSecrets ? exportSettings.SapUsername : null,
SapPassword = includeSecrets ? exportSettings.SapPassword : null,
Bi1Username = includeSecrets ? exportSettings.Bi1Username : null,
@@ -77,6 +80,7 @@ public class ConfigTransferService : IConfigTransferService
SourceSystem = site.SourceSystem,
UsernameOverride = includeSecrets ? site.UsernameOverride : null,
PasswordOverride = includeSecrets ? site.PasswordOverride : null,
LocalExportFolderOverride = site.LocalExportFolderOverride,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
@@ -190,6 +194,9 @@ public class ConfigTransferService : IConfigTransferService
TimerHour = importedSettings.TimerHour,
TimerMinute = importedSettings.TimerMinute,
TimerEnabled = importedSettings.TimerEnabled,
DebugLoggingEnabled = importedSettings.DebugLoggingEnabled,
LocalSiteExportFolder = importedSettings.LocalSiteExportFolder,
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder,
SapUsername = package.IncludesSecrets ? importedSettings.SapUsername ?? string.Empty : preservedSecrets.SapUsername ?? string.Empty,
SapPassword = package.IncludesSecrets ? importedSettings.SapPassword ?? string.Empty : preservedSecrets.SapPassword ?? string.Empty,
Bi1Username = package.IncludesSecrets ? importedSettings.Bi1Username ?? string.Empty : preservedSecrets.Bi1Username ?? string.Empty,
@@ -234,6 +241,7 @@ public class ConfigTransferService : IConfigTransferService
SourceSystem = site.SourceSystem,
UsernameOverride = package.IncludesSecrets ? site.UsernameOverride ?? string.Empty : preserved.UsernameOverride ?? string.Empty,
PasswordOverride = package.IncludesSecrets ? site.PasswordOverride ?? string.Empty : preserved.PasswordOverride ?? string.Empty,
LocalExportFolderOverride = site.LocalExportFolderOverride,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
@@ -31,7 +31,8 @@ public class ConsolidatedExportService : IConsolidatedExportService
using var db = await _dbFactory.CreateDbContextAsync();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var outputDir = ResolveConsolidatedOutputDirectory(settings);
var consolidatedPath = _excelService.CreateConsolidatedExcelFile(
outputDir,
DateTime.UtcNow.Date,
@@ -55,4 +56,15 @@ public class ConsolidatedExportService : IConsolidatedExportService
return consolidatedPath;
}
private static string ResolveConsolidatedOutputDirectory(ExportSettings settings)
{
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
return settings.LocalConsolidatedExportFolder.Trim();
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
return settings.LocalSiteExportFolder.Trim();
return Path.Combine(AppContext.BaseDirectory, "output");
}
}
@@ -18,10 +18,30 @@ public class DatabaseInitializationService : IDatabaseInitializationService
{
using var db = await _dbFactory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync();
ConfigureSqlite(db);
EnsureSchema(db);
SeedIfEmpty(db);
}
private static void ConfigureSqlite(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using (var wal = conn.CreateCommand())
{
wal.CommandText = "PRAGMA journal_mode=WAL;";
wal.ExecuteNonQuery();
}
using (var timeout = conn.CreateCommand())
{
timeout.CommandText = "PRAGMA busy_timeout=10000;";
timeout.ExecuteNonQuery();
}
}
private static void EnsureSchema(AppDbContext db)
{
EnsureSitesTableSupportsOptionalHanaServer(db);
@@ -32,6 +52,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "PasswordOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "LocalExportFolderOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapServiceUrl", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "TEXT NOT NULL DEFAULT ''");
@@ -42,11 +63,16 @@ public class DatabaseInitializationService : IDatabaseInitializationService
AddColumnIfMissing(db, "ExportSettings", "Bi1Password", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "SageUsername", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "SagePassword", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "DebugLoggingEnabled", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "ExportSettings", "LocalSiteExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "LocalConsolidatedExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''");
EnsureTransformationTable(db);
EnsureSapSourceTable(db);
EnsureSapJoinTable(db);
EnsureSapFieldMappingTable(db);
EnsureCentralSalesRecordTable(db);
EnsureAppEventLogTable(db);
}
private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db)
@@ -100,6 +126,7 @@ CREATE TABLE Sites (
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
UsernameOverride TEXT NOT NULL DEFAULT '',
PasswordOverride TEXT NOT NULL DEFAULT '',
LocalExportFolderOverride TEXT NOT NULL DEFAULT '',
SapServiceUrl TEXT NOT NULL DEFAULT '',
SapEntitySet TEXT NOT NULL DEFAULT '',
SapEntitySetsCache TEXT NOT NULL DEFAULT '',
@@ -116,7 +143,7 @@ CREATE TABLE Sites (
copy.CommandText = @"
INSERT INTO Sites (
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
UsernameOverride, PasswordOverride, SapServiceUrl, SapEntitySet,
UsernameOverride, PasswordOverride, LocalExportFolderOverride, SapServiceUrl, SapEntitySet,
SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive
)
SELECT
@@ -124,6 +151,7 @@ SELECT
COALESCE(SourceSystem, 'SAP'),
COALESCE(UsernameOverride, ''),
COALESCE(PasswordOverride, ''),
COALESCE(LocalExportFolderOverride, ''),
COALESCE(SapServiceUrl, ''),
COALESCE(SapEntitySet, ''),
COALESCE(SapEntitySetsCache, ''),
@@ -306,6 +334,28 @@ CREATE TABLE IF NOT EXISTS CentralSalesRecords (
cmd.ExecuteNonQuery();
}
private static void EnsureAppEventLogTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS AppEventLogs (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Timestamp TEXT NOT NULL,
Level TEXT NOT NULL,
Category TEXT NOT NULL,
SiteId INTEGER NULL,
Land TEXT NOT NULL,
Message TEXT NOT NULL,
Details TEXT NOT NULL,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
cmd.ExecuteNonQuery();
}
private static void SeedIfEmpty(AppDbContext db)
{
if (db.HanaServers.Any())
@@ -337,7 +387,10 @@ CREATE TABLE IF NOT EXISTS CentralSalesRecords (
DateFilter = "2025-01-01",
TimerHour = 3,
TimerMinute = 0,
TimerEnabled = true
TimerEnabled = true,
DebugLoggingEnabled = false,
LocalSiteExportFolder = "",
LocalConsolidatedExportFolder = ""
});
db.SaveChanges();
@@ -60,12 +60,12 @@ public class ExportOrchestrationService
await _consolidatedExportService.ExportAsync(consolidatedRecords);
}
public async Task ExportSiteByIdAsync(int siteId)
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
{
using var db = await _dbFactory.CreateDbContextAsync();
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
if (site is null) return;
await ExportSiteAsync(site);
if (site is null) return null;
return await ExportSiteAsync(site);
}
private async Task<SiteExportResult?> ExportSiteAsync(Site site)
@@ -5,20 +5,48 @@ namespace TrafagSalesExporter.Services;
public class HanaQueryService : IHanaQueryService
{
private readonly IAppEventLogService _appEventLogService;
public HanaQueryService(IAppEventLogService appEventLogService)
{
_appEventLogService = appEventLogService;
}
public List<SalesRecord> GetSalesRecords(HanaServer server,
string schema, string tsc, string land, string dateFilter)
{
var connectionString = server.BuildConnectionString();
var result = new List<SalesRecord>();
using var connection = new HanaConnection(connectionString);
connection.Open();
try
{
_appEventLogService.WriteAsync("HANA", "Verbindungsaufbau gestartet", land: land,
details: $"Server={server.GetConnectionStringPreview()} | Schema={schema} | TSC={tsc}").GetAwaiter().GetResult();
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
using var connection = new HanaConnection(connectionString);
connection.Open();
result.AddRange(ReadRecords(connection, invoiceQuery, land));
result.AddRange(ReadRecords(connection, creditNoteQuery, land));
_appEventLogService.WriteAsync("HANA", "Verbindung erfolgreich", land: land,
details: $"Schema={schema} | TSC={tsc}").GetAwaiter().GetResult();
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
_appEventLogService.WriteAsync("HANA", "Invoice-Query gestartet", land: land, details: invoiceQuery).GetAwaiter().GetResult();
var invoiceRecords = ReadRecords(connection, invoiceQuery, land, "Invoice");
result.AddRange(invoiceRecords);
_appEventLogService.WriteAsync("HANA", "Invoice-Query beendet", land: land, details: $"Zeilen={invoiceRecords.Count}").GetAwaiter().GetResult();
_appEventLogService.WriteAsync("HANA", "Credit-Query gestartet", land: land, details: creditNoteQuery).GetAwaiter().GetResult();
var creditRecords = ReadRecords(connection, creditNoteQuery, land, "Credit");
result.AddRange(creditRecords);
_appEventLogService.WriteAsync("HANA", "Credit-Query beendet", land: land, details: $"Zeilen={creditRecords.Count}").GetAwaiter().GetResult();
}
catch (Exception ex)
{
_appEventLogService.WriteAsync("HANA", "HANA-Abfrage fehlgeschlagen", "Error", land: land, details: ex.ToString()).GetAwaiter().GetResult();
throw;
}
foreach (var record in result)
{
@@ -43,6 +71,8 @@ public class HanaQueryService : IHanaQueryService
try
{
_appEventLogService.WriteAsync("HANA", "Verbindungstest gestartet",
details: testResult.ConnectionStringPreview).GetAwaiter().GetResult();
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
@@ -53,6 +83,8 @@ public class HanaQueryService : IHanaQueryService
testResult.Success = true;
testResult.Stage = "OK";
_appEventLogService.WriteAsync("HANA", "Verbindungstest erfolgreich",
details: testResult.ConnectionStringPreview).GetAwaiter().GetResult();
return testResult;
}
catch (Exception ex)
@@ -60,6 +92,8 @@ public class HanaQueryService : IHanaQueryService
testResult.Success = false;
testResult.ErrorMessage = ex.Message;
testResult.ExceptionType = ex.GetType().Name;
_appEventLogService.WriteAsync("HANA", "Verbindungstest fehlgeschlagen", "Error",
details: $"{testResult.ConnectionStringPreview}{Environment.NewLine}{ex}").GetAwaiter().GetResult();
return testResult;
}
}
@@ -71,12 +105,13 @@ public class HanaQueryService : IHanaQueryService
connection.Open();
}
private static List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land)
private List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land, string queryName)
{
var records = new List<SalesRecord>();
using var command = new HanaCommand(query, connection);
using var reader = command.ExecuteReader();
var counter = 0;
while (reader.Read())
{
@@ -109,6 +144,13 @@ public class HanaQueryService : IHanaQueryService
Land = land,
DocumentType = reader["doc_type"]?.ToString() ?? string.Empty
});
counter++;
if (counter % 250 == 0)
{
_appEventLogService.WriteDebugAsync("HANA", $"{queryName}-Query liest Daten", land: land,
details: $"Bisher gelesene Zeilen={counter}").GetAwaiter().GetResult();
}
}
return records;
@@ -0,0 +1,7 @@
namespace TrafagSalesExporter.Services;
public interface IAppEventLogService
{
Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null);
Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null);
}
@@ -4,6 +4,6 @@ namespace TrafagSalesExporter.Services;
public interface ICentralSalesRecordService
{
Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records);
Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records, Action<string>? updateStatus = null);
Task<List<SalesRecord>> GetAllAsync();
}
@@ -0,0 +1,9 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IManagementCockpitService
{
Task<List<ManagementCockpitFileOption>> GetAvailableFilesAsync();
Task<ManagementCockpitResult> AnalyzeAsync(string filePath);
}
@@ -4,5 +4,6 @@ public interface ISapGatewayService
{
Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task<List<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,387 @@
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ManagementCockpitService : IManagementCockpitService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<List<ManagementCockpitFileOption>> GetAvailableFilesAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var exportLogs = await db.ExportLogs
.Where(x => x.Status == "OK" && !string.IsNullOrWhiteSpace(x.FilePath))
.OrderByDescending(x => x.Timestamp)
.Take(200)
.ToListAsync();
var files = new Dictionary<string, ManagementCockpitFileOption>(StringComparer.OrdinalIgnoreCase);
foreach (var log in exportLogs)
{
if (!File.Exists(log.FilePath))
continue;
files[log.FilePath] = new ManagementCockpitFileOption
{
Path = log.FilePath,
DisplayName = $"{log.Land} | {log.TSC} | {Path.GetFileName(log.FilePath)}",
LastModified = File.GetLastWriteTime(log.FilePath)
};
}
foreach (var directory in GetCandidateDirectories(settings))
{
if (!Directory.Exists(directory))
continue;
foreach (var file in Directory.EnumerateFiles(directory, "*.xlsx", SearchOption.TopDirectoryOnly))
{
if (files.ContainsKey(file))
continue;
var fileName = Path.GetFileName(file);
files[file] = new ManagementCockpitFileOption
{
Path = file,
DisplayName = fileName,
LastModified = File.GetLastWriteTime(file)
};
}
}
return files.Values
.OrderByDescending(x => x.LastModified)
.ThenBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public Task<ManagementCockpitResult> AnalyzeAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
throw new InvalidOperationException("Die ausgewählte Excel-Datei wurde nicht gefunden.");
using var workbook = new XLWorkbook(filePath);
var worksheet = workbook.Worksheets.First();
var usedRange = worksheet.RangeUsed() ?? throw new InvalidOperationException("Die Excel-Datei enthält keine Daten.");
var headerRow = usedRange.FirstRow();
var headers = headerRow.Cells()
.Select((cell, index) => new { Index = index + 1, Header = NormalizeHeader(cell.GetString()) })
.Where(x => !string.IsNullOrWhiteSpace(x.Header))
.ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);
var rows = new List<CockpitRow>();
foreach (var row in usedRange.RowsUsed().Skip(1))
{
if (row.CellsUsed().All(c => string.IsNullOrWhiteSpace(c.GetString())))
continue;
rows.Add(ReadRow(row, headers));
}
if (rows.Count == 0)
throw new InvalidOperationException("Die Excel-Datei enthält keine auswertbaren Datenzeilen.");
var result = new ManagementCockpitResult
{
FilePath = filePath,
Summary = BuildSummary(rows),
Findings = BuildFindings(rows),
TopCustomers = BuildTopItems(rows, x => x.CustomerName, x => x.SalesValueTotal),
TopProductGroups = BuildTopItems(rows, x => x.ProductGroup, x => x.SalesValueTotal),
TopSalesEmployees = BuildTopItems(rows, x => x.SalesResponsibleEmployee, x => x.SalesValueTotal),
DataQualityCounts = BuildDataQualityCounts(rows)
};
return Task.FromResult(result);
}
private static IEnumerable<string> GetCandidateDirectories(ExportSettings settings)
{
yield return Path.Combine(AppContext.BaseDirectory, "output");
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
yield return settings.LocalSiteExportFolder.Trim();
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
yield return settings.LocalConsolidatedExportFolder.Trim();
}
private static CockpitRow ReadRow(IXLRangeRow row, IReadOnlyDictionary<string, int> headers)
{
var quantity = GetDecimal(row, headers, "quantity");
var standardCost = GetDecimal(row, headers, "standardcost");
var salesValue = GetDecimal(row, headers, "salespricevalue");
var estimatedCostTotal = quantity > 0 ? quantity * standardCost : standardCost;
return new CockpitRow
{
ExtractionDate = GetDate(row, headers, "extractiondate"),
Tsc = GetText(row, headers, "tsc"),
InvoiceNumber = GetText(row, headers, "invoicenumber"),
PositionOnInvoice = GetText(row, headers, "positiononinvoice"),
Material = GetText(row, headers, "material"),
Name = GetText(row, headers, "name"),
ProductGroup = GetText(row, headers, "productgroup"),
Quantity = quantity,
SupplierNumber = GetText(row, headers, "suppliernumber"),
SupplierName = GetText(row, headers, "suppliername"),
SupplierCountry = GetText(row, headers, "suppliercountry"),
CustomerNumber = GetText(row, headers, "customernumber"),
CustomerName = GetText(row, headers, "customername"),
CustomerCountry = GetText(row, headers, "customercountry"),
CustomerIndustry = GetText(row, headers, "customerindustry"),
StandardCost = standardCost,
SalesValueTotal = salesValue,
Incoterms2020 = GetText(row, headers, "incoterms2020"),
SalesResponsibleEmployee = GetText(row, headers, "salesresponsibleemployee"),
InvoiceDate = GetDate(row, headers, "invoicedate"),
OrderDate = GetDate(row, headers, "orderdate"),
Land = GetText(row, headers, "land"),
EstimatedCostTotal = estimatedCostTotal,
EstimatedMarginTotal = salesValue - estimatedCostTotal
};
}
private static ManagementCockpitSummary BuildSummary(List<CockpitRow> rows)
{
var salesTotal = rows.Sum(x => x.SalesValueTotal);
var costTotal = rows.Sum(x => x.EstimatedCostTotal);
var marginTotal = rows.Sum(x => x.EstimatedMarginTotal);
var serviceRows = rows.Where(x =>
x.ProductGroup.Contains("service", StringComparison.OrdinalIgnoreCase) ||
x.Name.Contains("port", StringComparison.OrdinalIgnoreCase) ||
x.Name.Contains("zeugnis", StringComparison.OrdinalIgnoreCase)).ToList();
return new ManagementCockpitSummary
{
Land = rows.Select(x => x.Land).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-",
Tsc = rows.Select(x => x.Tsc).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-",
ExtractionDate = rows.Select(x => x.ExtractionDate).FirstOrDefault(x => x.HasValue),
RowCount = rows.Count,
InvoiceCount = rows.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
CustomerCount = rows.Select(x => x.CustomerName).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
SalesValueTotal = salesTotal,
EstimatedCostTotal = costTotal,
EstimatedMarginTotal = marginTotal,
EstimatedMarginPercent = salesTotal == 0 ? 0 : marginTotal / salesTotal * 100m,
ServiceSharePercent = salesTotal == 0 ? 0 : serviceRows.Sum(x => x.SalesValueTotal) / salesTotal * 100m,
MissingOrderDatePercent = rows.Count == 0 ? 0 : rows.Count(x => !x.OrderDate.HasValue) * 100m / rows.Count,
MissingSupplierPercent = rows.Count == 0 ? 0 : rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)) * 100m / rows.Count
};
}
private static List<ManagementCockpitFinding> BuildFindings(List<CockpitRow> rows)
{
var findings = new List<ManagementCockpitFinding>();
var salesTotal = rows.Sum(x => x.SalesValueTotal);
var topCustomer = rows
.Where(x => !string.IsNullOrWhiteSpace(x.CustomerName))
.GroupBy(x => x.CustomerName, StringComparer.OrdinalIgnoreCase)
.Select(g => new { Customer = g.Key, Sales = g.Sum(x => x.SalesValueTotal) })
.OrderByDescending(x => x.Sales)
.FirstOrDefault();
if (topCustomer is not null && salesTotal > 0)
{
var share = topCustomer.Sales / salesTotal * 100m;
findings.Add(new ManagementCockpitFinding
{
Severity = share >= 50 ? "Warning" : "Info",
Title = "Kundenkonzentration",
Detail = $"{topCustomer.Customer} trägt {share:F1}% des Umsatzes."
});
}
var zeroValueRows = rows.Where(x => x.SalesValueTotal == 0 || x.StandardCost == 0).ToList();
if (zeroValueRows.Count > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = zeroValueRows.Count >= Math.Max(3, rows.Count / 10) ? "Warning" : "Info",
Title = "Nullwerte in Kosten oder Umsatz",
Detail = $"{zeroValueRows.Count} Zeilen haben 0 in Umsatz oder Standard Cost und sollten fachlich geprüft werden."
});
}
var missingOrderDates = rows.Count(x => !x.OrderDate.HasValue);
if (missingOrderDates > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = missingOrderDates > rows.Count / 2 ? "Warning" : "Info",
Title = "Fehlende Durchlaufzeit",
Detail = $"{missingOrderDates} von {rows.Count} Zeilen haben kein Order Date. Time-to-Invoice ist nur eingeschränkt beurteilbar."
});
}
var orderLeadTimes = rows
.Where(x => x.OrderDate.HasValue && x.InvoiceDate.HasValue)
.Select(x => (x.InvoiceDate!.Value - x.OrderDate!.Value).TotalDays)
.Where(x => x >= 0)
.ToList();
if (orderLeadTimes.Count > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = orderLeadTimes.Average() > 120 ? "Warning" : "Info",
Title = "Durchschnittliche Fakturierungszeit",
Detail = $"Zwischen Order Date und Invoice Date liegen im Schnitt {orderLeadTimes.Average():F0} Tage."
});
}
var missingIndustries = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry));
if (missingIndustries > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = missingIndustries > rows.Count / 2 ? "Warning" : "Info",
Title = "Stammdatenlücke Customer Industry",
Detail = $"{missingIndustries} Zeilen haben keine Customer Industry. Marktsegment-Analysen sind dadurch unvollständig."
});
}
var missingIncoterms = rows.Count(x => string.IsNullOrWhiteSpace(x.Incoterms2020));
if (missingIncoterms > 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = missingIncoterms > rows.Count / 2 ? "Info" : "Info",
Title = "Incoterms unvollständig",
Detail = $"{missingIncoterms} Zeilen haben keine Incoterms-Angabe."
});
}
if (findings.Count == 0)
{
findings.Add(new ManagementCockpitFinding
{
Severity = "Info",
Title = "Keine auffälligen Datenqualitätsprobleme",
Detail = "Die Datei ist für eine erste Standortbeurteilung konsistent genug."
});
}
return findings;
}
private static List<ManagementCockpitTopItem> BuildTopItems(
List<CockpitRow> rows,
Func<CockpitRow, string> keySelector,
Func<CockpitRow, decimal> valueSelector)
{
var total = rows.Sum(valueSelector);
return rows
.Select(x => new { Label = keySelector(x), Value = valueSelector(x) })
.Where(x => !string.IsNullOrWhiteSpace(x.Label))
.GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
.Select(g => new ManagementCockpitTopItem
{
Label = g.Key,
Value = g.Sum(x => x.Value),
SharePercent = total == 0 ? 0 : g.Sum(x => x.Value) / total * 100m
})
.OrderByDescending(x => x.Value)
.Take(5)
.ToList();
}
private static Dictionary<string, int> BuildDataQualityCounts(List<CockpitRow> rows)
{
return new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["Fehlende Supplier"] = rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)),
["Fehlende Customer Industry"] = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry)),
["Fehlende Order Date"] = rows.Count(x => !x.OrderDate.HasValue),
["Fehlende Invoice Date"] = rows.Count(x => !x.InvoiceDate.HasValue),
["Null Umsatz/Kosten"] = rows.Count(x => x.SalesValueTotal == 0 || x.StandardCost == 0)
};
}
private static string NormalizeHeader(string value)
{
var chars = value
.ToLowerInvariant()
.Where(char.IsLetterOrDigit)
.ToArray();
return new string(chars);
}
private static string GetText(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
=> headers.TryGetValue(key, out var index) ? row.Cell(index).GetString().Trim() : string.Empty;
private static decimal GetDecimal(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
{
if (!headers.TryGetValue(key, out var index))
return 0m;
var text = row.Cell(index).GetFormattedString().Trim();
if (decimal.TryParse(text, out var direct))
return direct;
if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var invariant))
return invariant;
if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), out var local))
return local;
return 0m;
}
private static DateTime? GetDate(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
{
if (!headers.TryGetValue(key, out var index))
return null;
var cell = row.Cell(index);
if (cell.DataType == XLDataType.DateTime)
return cell.GetDateTime();
var text = cell.GetString().Trim();
if (string.IsNullOrWhiteSpace(text))
return null;
if (DateTime.TryParse(text, out var direct))
return direct;
if (DateTime.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var invariant))
return invariant;
if (DateTime.TryParse(text, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), System.Globalization.DateTimeStyles.AssumeLocal, out var local))
return local;
return null;
}
private class CockpitRow
{
public DateTime? ExtractionDate { get; set; }
public string Tsc { get; set; } = string.Empty;
public string InvoiceNumber { get; set; } = string.Empty;
public string PositionOnInvoice { get; set; } = string.Empty;
public string Material { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string ProductGroup { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public string SupplierNumber { get; set; } = string.Empty;
public string SupplierName { get; set; } = string.Empty;
public string SupplierCountry { get; set; } = string.Empty;
public string CustomerNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerCountry { get; set; } = string.Empty;
public string CustomerIndustry { get; set; } = string.Empty;
public decimal StandardCost { get; set; }
public decimal SalesValueTotal { get; set; }
public string Incoterms2020 { get; set; } = string.Empty;
public string SalesResponsibleEmployee { get; set; } = string.Empty;
public DateTime? InvoiceDate { get; set; }
public DateTime? OrderDate { get; set; }
public string Land { get; set; } = string.Empty;
public decimal EstimatedCostTotal { get; set; }
public decimal EstimatedMarginTotal { get; set; }
}
}
@@ -6,10 +6,12 @@ namespace TrafagSalesExporter.Services;
public class SapCompositionService : ISapCompositionService
{
private readonly ISapGatewayService _sapGatewayService;
private readonly IAppEventLogService _appEventLogService;
public SapCompositionService(ISapGatewayService sapGatewayService)
public SapCompositionService(ISapGatewayService sapGatewayService, IAppEventLogService appEventLogService)
{
_sapGatewayService = sapGatewayService;
_appEventLogService = appEventLogService;
}
public async Task<List<SalesRecord>> BuildSalesRecordsAsync(
@@ -36,25 +38,38 @@ public class SapCompositionService : ISapCompositionService
var sourceRows = new Dictionary<string, List<Dictionary<string, object?>>>(StringComparer.OrdinalIgnoreCase);
foreach (var source in activeSources)
{
await _appEventLogService.WriteDebugAsync("SAP", "Quelle wird gelesen", site.Id, site.Land,
$"Alias={source.Alias} | EntitySet={source.EntitySet}");
var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, source.EntitySet, username, password, cancellationToken);
sourceRows[source.Alias] = rows;
await _appEventLogService.WriteDebugAsync("SAP", "Quelle gelesen", site.Id, site.Land,
$"Alias={source.Alias} | EntitySet={source.EntitySet} | Zeilen={rows.Count}");
}
var composedRows = sourceRows[primarySource.Alias]
.Select(r => PrefixRow(primarySource.Alias, r))
.ToList();
await _appEventLogService.WriteDebugAsync("SAP", "Primärquelle vorbereitet", site.Id, site.Land,
$"Alias={primarySource.Alias} | Startzeilen={composedRows.Count}");
foreach (var join in joins.Where(j => j.IsActive).OrderBy(j => j.SortOrder).ThenBy(j => j.Id))
{
if (!sourceRows.TryGetValue(join.RightAlias, out var rightRows))
continue;
await _appEventLogService.WriteDebugAsync("SAP", "Join gestartet", site.Id, site.Land,
$"{join.LeftAlias}({join.LeftKeys}) -> {join.RightAlias}({join.RightKeys}) | RightRows={rightRows.Count}");
composedRows = ApplyLeftJoin(composedRows, join.LeftAlias, join.LeftKeys, join.RightAlias, join.RightKeys, rightRows);
await _appEventLogService.WriteDebugAsync("SAP", "Join beendet", site.Id, site.Land,
$"{join.LeftAlias} -> {join.RightAlias} | Ergebniszeilen={composedRows.Count}");
}
return composedRows
var result = composedRows
.Select(row => MapToSalesRecord(site, row, mappings))
.ToList();
await _appEventLogService.WriteDebugAsync("SAP", "Mapping ins Zielschema beendet", site.Id, site.Land,
$"SalesRecords={result.Count} | Mappings={mappings.Count(x => x.IsActive)}");
return result;
}
private static Dictionary<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
@@ -9,30 +9,89 @@ public class SapGatewayService : ISapGatewayService
{
private static readonly XNamespace AppNs = "http://www.w3.org/2007/app";
private static readonly XNamespace EdmNs = "http://docs.oasis-open.org/odata/ns/edm";
private readonly IAppEventLogService _appEventLogService;
public SapGatewayService(IAppEventLogService appEventLogService)
{
_appEventLogService = appEventLogService;
}
public async Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
using var response = await client.GetAsync(BuildServiceUri(serviceUrl), cancellationToken);
var baseUrl = BuildServiceUri(serviceUrl);
await _appEventLogService.WriteAsync("SAP", "Gateway-Verbindungstest gestartet", details: baseUrl);
using var response = await client.GetAsync(baseUrl, cancellationToken);
response.EnsureSuccessStatusCode();
await _appEventLogService.WriteAsync("SAP", "Gateway-Verbindungstest erfolgreich", details: $"{baseUrl} | HTTP {(int)response.StatusCode}");
}
public async Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
var baseUrl = BuildServiceUri(serviceUrl);
await _appEventLogService.WriteAsync("SAP", "Entity-Set-Refresh gestartet", details: baseUrl);
var entitySets = await TryReadEntitySetsFromServiceRootAsync(client, baseUrl, cancellationToken);
if (entitySets.Count > 0)
{
await _appEventLogService.WriteAsync("SAP", "Entity Sets aus Service-Root geladen", details: $"{baseUrl} | Count={entitySets.Count}");
return entitySets;
}
return await ReadEntitySetsFromMetadataAsync(client, baseUrl, cancellationToken);
var metadataEntitySets = await ReadEntitySetsFromMetadataAsync(client, baseUrl, cancellationToken);
await _appEventLogService.WriteAsync("SAP", "Entity Sets aus $metadata geladen", details: $"{baseUrl} | Count={metadataEntitySets.Count}");
return metadataEntitySets;
}
public async Task<List<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
var baseUrl = BuildServiceUri(serviceUrl);
await _appEventLogService.WriteDebugAsync("SAP", "Feldliste aus $metadata laden", details: $"{baseUrl} | EntitySet={entitySet}");
using var response = await client.GetAsync($"{baseUrl}$metadata", cancellationToken);
response.EnsureSuccessStatusCode();
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
var document = XDocument.Parse(xml);
var entitySetElement = document
.Descendants()
.FirstOrDefault(x => string.Equals(x.Name.LocalName, "EntitySet", StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Attribute("Name")?.Value, entitySet, StringComparison.OrdinalIgnoreCase));
var entityTypeFullName = entitySetElement?.Attribute("EntityType")?.Value;
if (string.IsNullOrWhiteSpace(entityTypeFullName))
return [];
var typeName = entityTypeFullName.Split('.').LastOrDefault();
if (string.IsNullOrWhiteSpace(typeName))
return [];
var entityTypeElement = document
.Descendants()
.FirstOrDefault(x => string.Equals(x.Name.LocalName, "EntityType", StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Attribute("Name")?.Value, typeName, StringComparison.OrdinalIgnoreCase));
if (entityTypeElement is null)
return [];
return entityTypeElement
.Elements()
.Where(x => string.Equals(x.Name.LocalName, "Property", StringComparison.OrdinalIgnoreCase))
.Select(x => x.Attribute("Name")?.Value ?? string.Empty)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public async Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default)
{
using var client = CreateClient(username, password);
var requestUrl = $"{BuildServiceUri(serviceUrl)}{entitySet}?$format=json";
await _appEventLogService.WriteAsync("SAP", "Entity-Read gestartet", details: requestUrl);
using var response = await client.GetAsync(requestUrl, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -45,6 +104,7 @@ public class SapGatewayService : ISapGatewayService
return [];
var rows = new List<Dictionary<string, object?>>();
var counter = 0;
foreach (var item in resultsNode.EnumerateArray())
{
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
@@ -54,8 +114,15 @@ public class SapGatewayService : ISapGatewayService
}
rows.Add(row);
counter++;
if (counter % 250 == 0)
{
await _appEventLogService.WriteDebugAsync("SAP", "Entity-Read liest Daten",
details: $"{requestUrl} | Bisher gelesene Zeilen={counter}");
}
}
await _appEventLogService.WriteAsync("SAP", "Entity-Read beendet", details: $"{requestUrl} | Zeilen={rows.Count}");
return rows;
}
@@ -15,6 +15,7 @@ public class SiteExportService : ISiteExportService
private readonly ISharePointUploadService _sharePointService;
private readonly IRecordTransformationService _transformationService;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly IAppEventLogService _appEventLogService;
private readonly ILogger<SiteExportService> _logger;
public SiteExportService(
@@ -26,6 +27,7 @@ public class SiteExportService : ISiteExportService
ISharePointUploadService sharePointService,
IRecordTransformationService transformationService,
ICentralSalesRecordService centralSalesRecordService,
IAppEventLogService appEventLogService,
ILogger<SiteExportService> logger)
{
_dbFactory = dbFactory;
@@ -36,6 +38,7 @@ public class SiteExportService : ISiteExportService
_sharePointService = sharePointService;
_transformationService = transformationService;
_centralSalesRecordService = centralSalesRecordService;
_appEventLogService = appEventLogService;
_logger = logger;
}
@@ -52,10 +55,12 @@ public class SiteExportService : ISiteExportService
try
{
await _appEventLogService.WriteAsync("Export", "Export gestartet", siteId: site.Id, land: site.Land,
details: $"Quelle={NormalizeSourceSystem(site.SourceSystem)} | TSC={site.TSC}");
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
var outputDir = ResolveSiteOutputDirectory(settings, site);
var sourceSystem = NormalizeSourceSystem(site.SourceSystem);
var records = new List<SalesRecord>();
string filePath;
@@ -74,14 +79,20 @@ public class SiteExportService : ISiteExportService
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP-Feldmappings.");
updateStatus?.Invoke("SAP Quellen laden...");
await _appEventLogService.WriteAsync("Export", "SAP Quellen laden", siteId: site.Id, land: site.Land,
details: $"Sources={sapSources.Count} | Mappings={sapMappings.Count}");
records = await _sapCompositionService.BuildSalesRecordsAsync(site, sapSources, sapJoins, sapMappings, credentials.Username, credentials.Password);
updateStatus?.Invoke("Transformationen anwenden...");
await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", siteId: site.Id, land: site.Land,
details: $"Records vor Transformation={records.Count}");
var rules = await db.FieldTransformationRules
.Where(r => r.IsActive && r.SourceSystem == sourceSystem)
.OrderBy(r => r.SortOrder)
.ToListAsync();
_transformationService.Apply(records, rules);
updateStatus?.Invoke("Excel erstellen...");
await _appEventLogService.WriteAsync("Export", "Excel erstellen", siteId: site.Id, land: site.Land,
details: $"Records={records.Count}");
filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
log.RowCount = records.Count;
}
@@ -89,10 +100,14 @@ public class SiteExportService : ISiteExportService
{
var exportServer = BuildEffectiveServer(site, settings, sourceSystem);
updateStatus?.Invoke("HANA Abfrage...");
await _appEventLogService.WriteAsync("Export", "HANA Abfrage gestartet", siteId: site.Id, land: site.Land,
details: exportServer.GetConnectionStringPreview());
records = await Task.Run(() => _hanaService.GetSalesRecords(
exportServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
updateStatus?.Invoke("Transformationen anwenden...");
await _appEventLogService.WriteAsync("Export", "Transformationen anwenden", siteId: site.Id, land: site.Land,
details: $"Records vor Transformation={records.Count}");
var rules = await db.FieldTransformationRules
.Where(r => r.IsActive && r.SourceSystem == sourceSystem)
.OrderBy(r => r.SortOrder)
@@ -100,12 +115,16 @@ public class SiteExportService : ISiteExportService
_transformationService.Apply(records, rules);
updateStatus?.Invoke("Excel erstellen...");
await _appEventLogService.WriteAsync("Export", "Excel erstellen", siteId: site.Id, land: site.Land,
details: $"Records={records.Count}");
filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
log.RowCount = records.Count;
}
updateStatus?.Invoke("Zentrale Tabelle aktualisieren...");
await _centralSalesRecordService.ReplaceForSiteAsync(site, records);
await _appEventLogService.WriteAsync("Export", "Zentrale Tabelle aktualisieren", siteId: site.Id, land: site.Land,
details: $"Records={records.Count}");
await _centralSalesRecordService.ReplaceForSiteAsync(site, records, updateStatus);
var fileName = Path.GetFileName(filePath);
@@ -115,6 +134,8 @@ public class SiteExportService : ISiteExportService
!string.IsNullOrWhiteSpace(spConfig.ClientSecret))
{
updateStatus?.Invoke("SharePoint Upload...");
await _appEventLogService.WriteAsync("Export", "SharePoint Upload gestartet", siteId: site.Id, land: site.Land,
details: $"{spConfig.SiteUrl} | {spConfig.ExportFolder}");
await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath);
@@ -123,10 +144,13 @@ public class SiteExportService : ISiteExportService
sw.Stop();
log.Status = "OK";
log.FileName = fileName;
log.FilePath = filePath;
log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s",
site.Land, site.TSC, log.RowCount, sw.Elapsed.TotalSeconds);
await _appEventLogService.WriteAsync("Export", "Export erfolgreich", siteId: site.Id, land: site.Land,
details: $"Rows={log.RowCount} | Datei={fileName} | Pfad={filePath} | Dauer={sw.Elapsed.TotalSeconds:F1}s");
return new SiteExportResult
{
@@ -141,9 +165,12 @@ public class SiteExportService : ISiteExportService
log.Status = "Error";
log.ErrorMessage = ex.Message;
log.FileName = string.Empty;
log.FilePath = string.Empty;
log.DurationSeconds = sw.Elapsed.TotalSeconds;
_logger.LogError(ex, "Export Fehler: {Land} ({TSC})", site.Land, site.TSC);
await _appEventLogService.WriteAsync("Export", "Export fehlgeschlagen", "Error", siteId: site.Id, land: site.Land,
details: ex.ToString());
return new SiteExportResult
{
@@ -207,4 +234,12 @@ public class SiteExportService : ISiteExportService
return string.Empty;
}
private static string ResolveSiteOutputDirectory(ExportSettings settings, Site site)
{
var configured = FirstNonEmpty(site.LocalExportFolderOverride, settings.LocalSiteExportFolder);
return string.IsNullOrWhiteSpace(configured)
? Path.Combine(AppContext.BaseDirectory, "output")
: configured;
}
}