From 90133cd0e2670b4f1a9fb4431f1e9b67b489b422 Mon Sep 17 00:00:00 2001 From: metacube Date: Wed, 15 Apr 2026 11:18:26 +0200 Subject: [PATCH] diverse Aenderungen --- .../Components/Layout/NavMenu.razor | 3 + .../Components/Pages/Dashboard.razor | 196 ++++++++- .../Components/Pages/Logs.razor | 41 ++ .../Components/Pages/ManagementCockpit.razor | 146 +++++++ .../Components/Pages/Settings.razor | 17 + .../Components/Pages/Standorte.razor | 304 +++++++++++++- TrafagSalesExporter/Data/AppDbContext.cs | 1 + TrafagSalesExporter/Models/AppEventLog.cs | 13 + .../Models/ConfigTransferPackage.cs | 4 + TrafagSalesExporter/Models/ExportLog.cs | 1 + TrafagSalesExporter/Models/ExportSettings.cs | 3 + .../Models/ManagementCockpitModels.cs | 50 +++ TrafagSalesExporter/Models/Site.cs | 1 + TrafagSalesExporter/Program.cs | 4 +- .../Services/AppEventLogService.cs | 51 +++ .../Services/CentralSalesRecordService.cs | 217 ++++++++-- .../Services/ConfigTransferService.cs | 8 + .../Services/ConsolidatedExportService.cs | 14 +- .../Services/DatabaseInitializationService.cs | 57 ++- .../Services/ExportOrchestrationService.cs | 6 +- .../Services/HanaQueryService.cs | 56 ++- .../Services/IAppEventLogService.cs | 7 + .../Services/ICentralSalesRecordService.cs | 2 +- .../Services/IManagementCockpitService.cs | 9 + .../Services/ISapGatewayService.cs | 1 + .../Services/ManagementCockpitService.cs | 387 ++++++++++++++++++ .../Services/SapCompositionService.cs | 19 +- .../Services/SapGatewayService.cs | 71 +++- .../Services/SiteExportService.cs | 39 +- 29 files changed, 1651 insertions(+), 77 deletions(-) create mode 100644 TrafagSalesExporter/Components/Pages/ManagementCockpit.razor create mode 100644 TrafagSalesExporter/Models/AppEventLog.cs create mode 100644 TrafagSalesExporter/Models/ManagementCockpitModels.cs create mode 100644 TrafagSalesExporter/Services/AppEventLogService.cs create mode 100644 TrafagSalesExporter/Services/IAppEventLogService.cs create mode 100644 TrafagSalesExporter/Services/IManagementCockpitService.cs create mode 100644 TrafagSalesExporter/Services/ManagementCockpitService.cs 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; + } }