diff --git a/TrafagSalesExporter/Components/Layout/NavMenu.razor b/TrafagSalesExporter/Components/Layout/NavMenu.razor
index ecc7ae1..1b23676 100644
--- a/TrafagSalesExporter/Components/Layout/NavMenu.razor
+++ b/TrafagSalesExporter/Components/Layout/NavMenu.razor
@@ -8,6 +8,9 @@
Transformationen
+
+ Management Cockpit
+
Settings
diff --git a/TrafagSalesExporter/Components/Pages/Dashboard.razor b/TrafagSalesExporter/Components/Pages/Dashboard.razor
index d36fecc..c1ab69a 100644
--- a/TrafagSalesExporter/Components/Pages/Dashboard.razor
+++ b/TrafagSalesExporter/Components/Pages/Dashboard.razor
@@ -1,5 +1,6 @@
@page "/"
@using Microsoft.EntityFrameworkCore
+@using System.Diagnostics
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory DbFactory
@@ -40,6 +41,7 @@
Schema
Server
Status
+ Live-Status
Zeilen
Letzter Lauf
Dauer
@@ -71,16 +73,38 @@
-
}
+
+ @if (!string.IsNullOrWhiteSpace(context.LiveMessage))
+ {
+
+
+ @context.LiveMessage
+
+
+ }
+ else
+ {
+ -
+ }
+
@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")
@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")
@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")
-
- Export
-
+
+
+ Export
+
+
+ Excel öffnen
+
+
@@ -89,6 +113,7 @@
private List _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);
}
}
diff --git a/TrafagSalesExporter/Components/Pages/Logs.razor b/TrafagSalesExporter/Components/Pages/Logs.razor
index f97b51f..1e82c5e 100644
--- a/TrafagSalesExporter/Components/Pages/Logs.razor
+++ b/TrafagSalesExporter/Components/Pages/Logs.razor
@@ -75,8 +75,39 @@
+Technische Logs
+
+
+
+ Zeitpunkt
+ Level
+ Kategorie
+ Land
+ Meldung
+ Details
+
+
+ @context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")
+ @context.Level
+ @context.Category
+ @(string.IsNullOrWhiteSpace(context.Land) ? "-" : context.Land)
+ @context.Message
+
+ @if (!string.IsNullOrWhiteSpace(context.Details))
+ {
+
+
+ @context.Details
+
+
+ }
+
+
+
+
@code {
private List _logs = new();
+ private List _appLogs = new();
private List _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 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;
}
diff --git a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor
new file mode 100644
index 0000000..b4df433
--- /dev/null
+++ b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor
@@ -0,0 +1,146 @@
+@page "/management-cockpit"
+@using TrafagSalesExporter.Models
+@using TrafagSalesExporter.Services
+@inject IManagementCockpitService CockpitService
+@inject ISnackbar Snackbar
+
+Management Cockpit
+
+Management Cockpit
+
+
+
+
+
+ @foreach (var file in _files)
+ {
+ @file.DisplayName
+ }
+
+
+
+
+
+ Dateien laden
+
+
+ @(_analyzing ? "Analysiere..." : "Cockpit erzeugen")
+
+
+
+
+
+
+@if (_result is not null)
+{
+
+ Land@_result.Summary.Land
+ TSC@_result.Summary.Tsc
+ Umsatz@_result.Summary.SalesValueTotal.ToString("N2")
+ Geschätzte Marge@($"{_result.Summary.EstimatedMarginPercent:F1}%")
+
+
+
+ Management Aussagen
+ @foreach (var finding in _result.Findings)
+ {
+
+ @finding.Title: @finding.Detail
+
+ }
+
+
+
+
+
+ Top Kunden
+ @foreach (var item in _result.TopCustomers)
+ {
+ @($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")
+ }
+
+
+
+
+ Top Produktgruppen
+ @foreach (var item in _result.TopProductGroups)
+ {
+ @($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")
+ }
+
+
+
+
+ Top Sales Owner
+ @foreach (var item in _result.TopSalesEmployees)
+ {
+ @($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")
+ }
+
+
+
+
+
+ Datenqualität
+ @foreach (var entry in _result.DataQualityCounts.OrderByDescending(x => x.Value))
+ {
+ @($"{entry.Key}: {entry.Value}")
+ }
+
+}
+
+@code {
+ private List _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
+ };
+}
diff --git a/TrafagSalesExporter/Components/Pages/Settings.razor b/TrafagSalesExporter/Components/Pages/Settings.razor
index 38c900a..7f425b2 100644
--- a/TrafagSalesExporter/Components/Pages/Settings.razor
+++ b/TrafagSalesExporter/Components/Pages/Settings.razor
@@ -168,6 +168,20 @@
+
+
+
+ Schreibt zusätzliche technische Fortschrittsmeldungen für HANA- und SAP-Lesevorgänge ins Dashboard und in die Logs.
+
+
+
+
+
+
+
+
@@ -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;
diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor
index ee4ed52..e75b0fc 100644
--- a/TrafagSalesExporter/Components/Pages/Standorte.razor
+++ b/TrafagSalesExporter/Components/Pages/Standorte.razor
@@ -8,6 +8,7 @@
@inject IDbContextFactory 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." />
+
@@ -216,7 +219,13 @@
SAP Joins
- Join hinzufügen
+
+
+ Auto-Match
+
+ Join hinzufügen
+
@@ -237,7 +246,18 @@
}
-
+
+
+ @foreach (var field in GetAvailableJoinFields(context.LeftAlias, context.LeftKeys))
+ {
+ @field
+ }
+
+
@foreach (var alias in GetSapAliases())
@@ -246,7 +266,18 @@
}
-
+
+
+ @foreach (var field in GetAvailableJoinFields(context.RightAlias, context.RightKeys))
+ {
+ @field
+ }
+
+
Left
@@ -260,8 +291,25 @@
Feldmappings ins zentrale Schema
- Mapping hinzufügen
+
+
+ @if (_refreshingSapSourceFields)
+ {
+
+ @("Lade Felder...")
+ }
+ else
+ {
+ @("Felder aus Quellen laden")
+ }
+
+ Mapping hinzufügen
+
+
+ Source Expressions werden aus den hinzugefügten SAP-Quellen als `Alias.Feldname` gelesen. Vorhandene manuelle Werte bleiben auswählbar.
+
Zielfeld
@@ -279,7 +327,14 @@
}
-
+
+
+ @foreach (var expression in GetAvailableSourceExpressions(context.SourceExpression))
+ {
+ @expression
+ }
+
+
@@ -321,6 +376,8 @@
private List _servers = new();
private List _sites = new();
private List _sapEntitySetsCache = [];
+ private List _sapAvailableSourceExpressions = [];
+ private Dictionary> _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
private List _sapSources = [];
private List _sapJoins = [];
private List _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 { "=SAP" };
+ var sourceFieldMap = new Dictionary>(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 GetAvailableSourceExpressions(string? currentValue)
+ {
+ var expressions = new List(_sapAvailableSourceExpressions);
+ if (!string.IsNullOrWhiteSpace(currentValue) && !expressions.Contains(currentValue, StringComparer.OrdinalIgnoreCase))
+ expressions.Insert(0, currentValue);
+
+ return expressions;
+ }
+
+ private List BuildSourceExpressionsFromMappings()
+ => _sapMappings
+ .Select(m => m.SourceExpression)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ private Dictionary> BuildSourceFieldMapFromJoins()
+ {
+ var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var join in _sapJoins)
+ {
+ AddJoinKeysToFieldMap(result, join.LeftAlias, join.LeftKeys);
+ AddJoinKeysToFieldMap(result, join.RightAlias, join.RightKeys);
+ }
+
+ return result;
+ }
+
+ private static void AddJoinKeysToFieldMap(Dictionary> target, string alias, string keys)
+ {
+ if (string.IsNullOrWhiteSpace(alias))
+ return;
+
+ if (!target.TryGetValue(alias, out var fields))
+ {
+ fields = [];
+ target[alias] = fields;
+ }
+
+ foreach (var key in GetSelectedJoinKeys(keys))
+ {
+ if (!fields.Contains(key, StringComparer.OrdinalIgnoreCase))
+ fields.Add(key);
+ }
+
+ fields.Sort(StringComparer.OrdinalIgnoreCase);
+ }
+
+ private IEnumerable GetAvailableJoinFields(string? alias, string? currentKeys)
+ {
+ var values = new List();
+ if (!string.IsNullOrWhiteSpace(alias) && _sapSourceFieldMap.TryGetValue(alias, out var fields))
+ values.AddRange(fields);
+
+ foreach (var key in GetSelectedJoinKeys(currentKeys))
+ {
+ if (!values.Contains(key, StringComparer.OrdinalIgnoreCase))
+ values.Add(key);
+ }
+
+ return values
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private static HashSet GetSelectedJoinKeys(string? keys)
+ => keys?
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase)
+ ?? [];
}
diff --git a/TrafagSalesExporter/Data/AppDbContext.cs b/TrafagSalesExporter/Data/AppDbContext.cs
index 57f6126..d6a77f8 100644
--- a/TrafagSalesExporter/Data/AppDbContext.cs
+++ b/TrafagSalesExporter/Data/AppDbContext.cs
@@ -12,6 +12,7 @@ public class AppDbContext : DbContext
public DbSet SharePointConfigs => Set();
public DbSet ExportSettings => Set();
public DbSet ExportLogs => Set();
+ public DbSet AppEventLogs => Set();
public DbSet FieldTransformationRules => Set();
public DbSet SapSourceDefinitions => Set();
public DbSet SapJoinDefinitions => Set();
diff --git a/TrafagSalesExporter/Models/AppEventLog.cs b/TrafagSalesExporter/Models/AppEventLog.cs
new file mode 100644
index 0000000..e38a617
--- /dev/null
+++ b/TrafagSalesExporter/Models/AppEventLog.cs
@@ -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;
+}
diff --git a/TrafagSalesExporter/Models/ConfigTransferPackage.cs b/TrafagSalesExporter/Models/ConfigTransferPackage.cs
index 12690aa..cf0600c 100644
--- a/TrafagSalesExporter/Models/ConfigTransferPackage.cs
+++ b/TrafagSalesExporter/Models/ConfigTransferPackage.cs
@@ -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;
diff --git a/TrafagSalesExporter/Models/ExportLog.cs b/TrafagSalesExporter/Models/ExportLog.cs
index 7a9e88b..0571804 100644
--- a/TrafagSalesExporter/Models/ExportLog.cs
+++ b/TrafagSalesExporter/Models/ExportLog.cs
@@ -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; }
}
diff --git a/TrafagSalesExporter/Models/ExportSettings.cs b/TrafagSalesExporter/Models/ExportSettings.cs
index 8e1c506..c5ed501 100644
--- a/TrafagSalesExporter/Models/ExportSettings.cs
+++ b/TrafagSalesExporter/Models/ExportSettings.cs
@@ -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;
diff --git a/TrafagSalesExporter/Models/ManagementCockpitModels.cs b/TrafagSalesExporter/Models/ManagementCockpitModels.cs
new file mode 100644
index 0000000..e47fad3
--- /dev/null
+++ b/TrafagSalesExporter/Models/ManagementCockpitModels.cs
@@ -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 Findings { get; set; } = [];
+ public List TopCustomers { get; set; } = [];
+ public List TopProductGroups { get; set; } = [];
+ public List TopSalesEmployees { get; set; } = [];
+ public Dictionary DataQualityCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+}
diff --git a/TrafagSalesExporter/Models/Site.cs b/TrafagSalesExporter/Models/Site.cs
index f953d80..20d3be0 100644
--- a/TrafagSalesExporter/Models/Site.cs
+++ b/TrafagSalesExporter/Models/Site.cs
@@ -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;
diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs
index 878172c..1cadb5b 100644
--- a/TrafagSalesExporter/Program.cs
+++ b/TrafagSalesExporter/Program.cs
@@ -11,7 +11,7 @@ builder.Services.AddRazorComponents()
builder.Services.AddMudServices();
builder.Services.AddDbContextFactory(options =>
- options.UseSqlite("Data Source=trafag_exporter.db"));
+ options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=10"));
builder.Services.AddSingleton();
builder.Services.AddSingleton();
@@ -26,6 +26,8 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/TrafagSalesExporter/Services/AppEventLogService.cs b/TrafagSalesExporter/Services/AppEventLogService.cs
new file mode 100644
index 0000000..a1a6d3c
--- /dev/null
+++ b/TrafagSalesExporter/Services/AppEventLogService.cs
@@ -0,0 +1,51 @@
+using Microsoft.EntityFrameworkCore;
+using TrafagSalesExporter.Data;
+using TrafagSalesExporter.Models;
+
+namespace TrafagSalesExporter.Services;
+
+public class AppEventLogService : IAppEventLogService
+{
+ private readonly IDbContextFactory _dbFactory;
+
+ public AppEventLogService(IDbContextFactory 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();
+ }
+}
diff --git a/TrafagSalesExporter/Services/CentralSalesRecordService.cs b/TrafagSalesExporter/Services/CentralSalesRecordService.cs
index ebe1744..3a36ecf 100644
--- a/TrafagSalesExporter/Services/CentralSalesRecordService.cs
+++ b/TrafagSalesExporter/Services/CentralSalesRecordService.cs
@@ -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 _dbFactory;
+ private const int BatchSize = 25;
- public CentralSalesRecordService(IDbContextFactory dbFactory)
+ private readonly IDbContextFactory _dbFactory;
+ private readonly IAppEventLogService _appEventLogService;
+
+ public CentralSalesRecordService(IDbContextFactory dbFactory, IAppEventLogService appEventLogService)
{
_dbFactory = dbFactory;
+ _appEventLogService = appEventLogService;
}
- public async Task ReplaceForSiteAsync(Site site, IEnumerable records)
+ public async Task ReplaceForSiteAsync(Site site, IEnumerable records, Action? 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> GetAllAsync()
@@ -94,4 +90,147 @@ public class CentralSalesRecordService : ICentralSalesRecordService
})
.ToListAsync();
}
+
+ private static async Task 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 records,
+ Action? 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;
+ }
}
diff --git a/TrafagSalesExporter/Services/ConfigTransferService.cs b/TrafagSalesExporter/Services/ConfigTransferService.cs
index b339f16..bd3da95 100644
--- a/TrafagSalesExporter/Services/ConfigTransferService.cs
+++ b/TrafagSalesExporter/Services/ConfigTransferService.cs
@@ -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,
diff --git a/TrafagSalesExporter/Services/ConsolidatedExportService.cs b/TrafagSalesExporter/Services/ConsolidatedExportService.cs
index 9e2034c..c4d4e93 100644
--- a/TrafagSalesExporter/Services/ConsolidatedExportService.cs
+++ b/TrafagSalesExporter/Services/ConsolidatedExportService.cs
@@ -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");
+ }
}
diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.cs
index a341f11..1632ee9 100644
--- a/TrafagSalesExporter/Services/DatabaseInitializationService.cs
+++ b/TrafagSalesExporter/Services/DatabaseInitializationService.cs
@@ -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();
diff --git a/TrafagSalesExporter/Services/ExportOrchestrationService.cs b/TrafagSalesExporter/Services/ExportOrchestrationService.cs
index b31c84a..994d1ea 100644
--- a/TrafagSalesExporter/Services/ExportOrchestrationService.cs
+++ b/TrafagSalesExporter/Services/ExportOrchestrationService.cs
@@ -60,12 +60,12 @@ public class ExportOrchestrationService
await _consolidatedExportService.ExportAsync(consolidatedRecords);
}
- public async Task ExportSiteByIdAsync(int siteId)
+ public async Task 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 ExportSiteAsync(Site site)
diff --git a/TrafagSalesExporter/Services/HanaQueryService.cs b/TrafagSalesExporter/Services/HanaQueryService.cs
index 98828a5..69c28b7 100644
--- a/TrafagSalesExporter/Services/HanaQueryService.cs
+++ b/TrafagSalesExporter/Services/HanaQueryService.cs
@@ -5,20 +5,48 @@ namespace TrafagSalesExporter.Services;
public class HanaQueryService : IHanaQueryService
{
+ private readonly IAppEventLogService _appEventLogService;
+
+ public HanaQueryService(IAppEventLogService appEventLogService)
+ {
+ _appEventLogService = appEventLogService;
+ }
+
public List GetSalesRecords(HanaServer server,
string schema, string tsc, string land, string dateFilter)
{
var connectionString = server.BuildConnectionString();
var result = new List();
- 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 ReadRecords(HanaConnection connection, string query, string land)
+ private List ReadRecords(HanaConnection connection, string query, string land, string queryName)
{
var records = new List();
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;
diff --git a/TrafagSalesExporter/Services/IAppEventLogService.cs b/TrafagSalesExporter/Services/IAppEventLogService.cs
new file mode 100644
index 0000000..a3978d6
--- /dev/null
+++ b/TrafagSalesExporter/Services/IAppEventLogService.cs
@@ -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);
+}
diff --git a/TrafagSalesExporter/Services/ICentralSalesRecordService.cs b/TrafagSalesExporter/Services/ICentralSalesRecordService.cs
index 1ef174a..42617d6 100644
--- a/TrafagSalesExporter/Services/ICentralSalesRecordService.cs
+++ b/TrafagSalesExporter/Services/ICentralSalesRecordService.cs
@@ -4,6 +4,6 @@ namespace TrafagSalesExporter.Services;
public interface ICentralSalesRecordService
{
- Task ReplaceForSiteAsync(Site site, IEnumerable records);
+ Task ReplaceForSiteAsync(Site site, IEnumerable records, Action? updateStatus = null);
Task> GetAllAsync();
}
diff --git a/TrafagSalesExporter/Services/IManagementCockpitService.cs b/TrafagSalesExporter/Services/IManagementCockpitService.cs
new file mode 100644
index 0000000..1774d83
--- /dev/null
+++ b/TrafagSalesExporter/Services/IManagementCockpitService.cs
@@ -0,0 +1,9 @@
+using TrafagSalesExporter.Models;
+
+namespace TrafagSalesExporter.Services;
+
+public interface IManagementCockpitService
+{
+ Task> GetAvailableFilesAsync();
+ Task AnalyzeAsync(string filePath);
+}
diff --git a/TrafagSalesExporter/Services/ISapGatewayService.cs b/TrafagSalesExporter/Services/ISapGatewayService.cs
index 3041ab3..510c1c9 100644
--- a/TrafagSalesExporter/Services/ISapGatewayService.cs
+++ b/TrafagSalesExporter/Services/ISapGatewayService.cs
@@ -4,5 +4,6 @@ public interface ISapGatewayService
{
Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
Task> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
+ Task> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
Task>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
}
diff --git a/TrafagSalesExporter/Services/ManagementCockpitService.cs b/TrafagSalesExporter/Services/ManagementCockpitService.cs
new file mode 100644
index 0000000..d3cb297
--- /dev/null
+++ b/TrafagSalesExporter/Services/ManagementCockpitService.cs
@@ -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 _dbFactory;
+
+ public ManagementCockpitService(IDbContextFactory dbFactory)
+ {
+ _dbFactory = dbFactory;
+ }
+
+ public async Task> 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(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 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();
+ 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 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 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 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 BuildFindings(List rows)
+ {
+ var findings = new List();
+ 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 BuildTopItems(
+ List rows,
+ Func keySelector,
+ Func 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 BuildDataQualityCounts(List rows)
+ {
+ return new Dictionary(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 headers, string key)
+ => headers.TryGetValue(key, out var index) ? row.Cell(index).GetString().Trim() : string.Empty;
+
+ private static decimal GetDecimal(IXLRangeRow row, IReadOnlyDictionary 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 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; }
+ }
+}
diff --git a/TrafagSalesExporter/Services/SapCompositionService.cs b/TrafagSalesExporter/Services/SapCompositionService.cs
index c030bfd..ad39c88 100644
--- a/TrafagSalesExporter/Services/SapCompositionService.cs
+++ b/TrafagSalesExporter/Services/SapCompositionService.cs
@@ -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> BuildSalesRecordsAsync(
@@ -36,25 +38,38 @@ public class SapCompositionService : ISapCompositionService
var sourceRows = new Dictionary>>(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 PrefixRow(string alias, Dictionary row)
diff --git a/TrafagSalesExporter/Services/SapGatewayService.cs b/TrafagSalesExporter/Services/SapGatewayService.cs
index c53291c..63774fd 100644
--- a/TrafagSalesExporter/Services/SapGatewayService.cs
+++ b/TrafagSalesExporter/Services/SapGatewayService.cs
@@ -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> 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> 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>> 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>();
+ var counter = 0;
foreach (var item in resultsNode.EnumerateArray())
{
var row = new Dictionary(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;
}
diff --git a/TrafagSalesExporter/Services/SiteExportService.cs b/TrafagSalesExporter/Services/SiteExportService.cs
index c574de7..8368e54 100644
--- a/TrafagSalesExporter/Services/SiteExportService.cs
+++ b/TrafagSalesExporter/Services/SiteExportService.cs
@@ -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 _logger;
public SiteExportService(
@@ -26,6 +27,7 @@ public class SiteExportService : ISiteExportService
ISharePointUploadService sharePointService,
IRecordTransformationService transformationService,
ICentralSalesRecordService centralSalesRecordService,
+ IAppEventLogService appEventLogService,
ILogger 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();
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;
+ }
}