diverse Aenderungen
This commit is contained in:
@@ -8,6 +8,9 @@
|
|||||||
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
|
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
|
||||||
Transformationen
|
Transformationen
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
|
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics">
|
||||||
|
Management Cockpit
|
||||||
|
</MudNavLink>
|
||||||
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
|
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
|
||||||
Settings
|
Settings
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using System.Diagnostics
|
||||||
@using TrafagSalesExporter.Data
|
@using TrafagSalesExporter.Data
|
||||||
@using TrafagSalesExporter.Services
|
@using TrafagSalesExporter.Services
|
||||||
@inject IDbContextFactory<AppDbContext> DbFactory
|
@inject IDbContextFactory<AppDbContext> DbFactory
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<MudTh>Schema</MudTh>
|
<MudTh>Schema</MudTh>
|
||||||
<MudTh>Server</MudTh>
|
<MudTh>Server</MudTh>
|
||||||
<MudTh>Status</MudTh>
|
<MudTh>Status</MudTh>
|
||||||
|
<MudTh>Live-Status</MudTh>
|
||||||
<MudTh>Zeilen</MudTh>
|
<MudTh>Zeilen</MudTh>
|
||||||
<MudTh>Letzter Lauf</MudTh>
|
<MudTh>Letzter Lauf</MudTh>
|
||||||
<MudTh>Dauer</MudTh>
|
<MudTh>Dauer</MudTh>
|
||||||
@@ -71,16 +73,38 @@
|
|||||||
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
||||||
}
|
}
|
||||||
</MudTd>
|
</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(context.LiveMessage))
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.LiveDetails">
|
||||||
|
<MudText Typo="Typo.caption" Style="max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
|
||||||
|
@context.LiveMessage
|
||||||
|
</MudText>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Default">-</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
|
<MudTd>@(context.RowCount > 0 ? context.RowCount.ToString("N0") : "-")</MudTd>
|
||||||
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
|
<MudTd>@(context.LastRun.HasValue ? context.LastRun.Value.ToString("dd.MM.yyyy HH:mm:ss") : "-")</MudTd>
|
||||||
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
|
<MudTd>@(context.DurationSeconds > 0 ? $"{context.DurationSeconds:F1}s" : "-")</MudTd>
|
||||||
<MudTd>
|
<MudTd>
|
||||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
<MudStack Row Spacing="1">
|
||||||
StartIcon="@Icons.Material.Filled.FileDownload"
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
|
||||||
OnClick="() => ExportSingle(context.SiteId)"
|
StartIcon="@Icons.Material.Filled.FileDownload"
|
||||||
Disabled="Orchestrator.IsExporting(context.SiteId)">
|
OnClick="() => ExportSingle(context.SiteId)"
|
||||||
Export
|
Disabled="Orchestrator.IsExporting(context.SiteId)">
|
||||||
</MudButton>
|
Export
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Info"
|
||||||
|
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||||
|
OnClick="() => OpenExportFile(context)"
|
||||||
|
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
|
||||||
|
Excel öffnen
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
</MudTd>
|
</MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
@@ -89,6 +113,7 @@
|
|||||||
private List<DashboardRow> _dashboardRows = new();
|
private List<DashboardRow> _dashboardRows = new();
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private bool _anyRunning;
|
private bool _anyRunning;
|
||||||
|
private CancellationTokenSource? _pollingCts;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -106,10 +131,19 @@
|
|||||||
.GroupBy(l => l.SiteId)
|
.GroupBy(l => l.SiteId)
|
||||||
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
|
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
|
||||||
.ToListAsync();
|
.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 =>
|
_dashboardRows = sites.Select(s =>
|
||||||
{
|
{
|
||||||
var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
|
var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
|
||||||
|
latestAppLogsBySite.TryGetValue(s.Id, out var appLog);
|
||||||
return new DashboardRow
|
return new DashboardRow
|
||||||
{
|
{
|
||||||
SiteId = s.Id,
|
SiteId = s.Id,
|
||||||
@@ -123,7 +157,10 @@
|
|||||||
RowCount = log?.RowCount ?? 0,
|
RowCount = log?.RowCount ?? 0,
|
||||||
LastRun = log?.Timestamp,
|
LastRun = log?.Timestamp,
|
||||||
DurationSeconds = log?.DurationSeconds ?? 0,
|
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();
|
}).ToList();
|
||||||
|
|
||||||
@@ -134,6 +171,8 @@
|
|||||||
private async Task ExportAll()
|
private async Task ExportAll()
|
||||||
{
|
{
|
||||||
_anyRunning = true;
|
_anyRunning = true;
|
||||||
|
await LoadDataAsync();
|
||||||
|
StartPolling();
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await Orchestrator.ExportAllAsync();
|
await Orchestrator.ExportAllAsync();
|
||||||
@@ -148,14 +187,28 @@
|
|||||||
|
|
||||||
private void ExportSingle(int siteId)
|
private void ExportSingle(int siteId)
|
||||||
{
|
{
|
||||||
|
_anyRunning = true;
|
||||||
|
_ = InvokeAsync(async () => await LoadDataAsync());
|
||||||
|
StartPolling();
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await Orchestrator.ExportSiteByIdAsync(siteId);
|
var result = await Orchestrator.ExportSiteByIdAsync(siteId);
|
||||||
await InvokeAsync(async () =>
|
await InvokeAsync(async () =>
|
||||||
{
|
{
|
||||||
await LoadDataAsync();
|
await LoadDataAsync();
|
||||||
StateHasChanged();
|
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);
|
Snackbar.Add("Export gestartet", Severity.Info);
|
||||||
}
|
}
|
||||||
@@ -164,21 +217,136 @@
|
|||||||
{
|
{
|
||||||
await InvokeAsync(async () =>
|
await InvokeAsync(async () =>
|
||||||
{
|
{
|
||||||
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId));
|
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || _dashboardRows.Count == 0;
|
||||||
StateHasChanged();
|
if (_anyRunning)
|
||||||
if (!_anyRunning)
|
|
||||||
{
|
{
|
||||||
await LoadDataAsync();
|
StartPolling();
|
||||||
|
await RefreshLiveDataAsync();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StopPolling();
|
||||||
|
await LoadDataAsync();
|
||||||
|
StateHasChanged();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
StopPolling();
|
||||||
Orchestrator.OnExportStatusChanged -= HandleStatusChanged;
|
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
|
private class DashboardRow
|
||||||
{
|
{
|
||||||
public int SiteId { get; set; }
|
public int SiteId { get; set; }
|
||||||
@@ -191,5 +359,9 @@
|
|||||||
public DateTime? LastRun { get; set; }
|
public DateTime? LastRun { get; set; }
|
||||||
public double DurationSeconds { get; set; }
|
public double DurationSeconds { get; set; }
|
||||||
public string ErrorMessage { get; set; } = "";
|
public string ErrorMessage { get; set; } = "";
|
||||||
|
public string FilePath { get; set; } = "";
|
||||||
|
public string LiveMessage { get; set; } = "";
|
||||||
|
public string LiveDetails { get; set; } = "";
|
||||||
|
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,39 @@
|
|||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h5" Class="mt-6 mb-2">Technische Logs</MudText>
|
||||||
|
|
||||||
|
<MudTable Items="_appLogs" Dense Hover Striped Loading="_loading">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Zeitpunkt</MudTh>
|
||||||
|
<MudTh>Level</MudTh>
|
||||||
|
<MudTh>Kategorie</MudTh>
|
||||||
|
<MudTh>Land</MudTh>
|
||||||
|
<MudTh>Meldung</MudTh>
|
||||||
|
<MudTh>Details</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
|
||||||
|
<MudTd>@context.Level</MudTd>
|
||||||
|
<MudTd>@context.Category</MudTd>
|
||||||
|
<MudTd>@(string.IsNullOrWhiteSpace(context.Land) ? "-" : context.Land)</MudTd>
|
||||||
|
<MudTd>@context.Message</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(context.Details))
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@context.Details">
|
||||||
|
<MudText Typo="Typo.caption" Style="max-width:420px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
|
||||||
|
@context.Details
|
||||||
|
</MudText>
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<ExportLog> _logs = new();
|
private List<ExportLog> _logs = new();
|
||||||
|
private List<AppEventLog> _appLogs = new();
|
||||||
private List<string> _availableLands = new();
|
private List<string> _availableLands = new();
|
||||||
private string? _filterLand;
|
private string? _filterLand;
|
||||||
private string? _filterStatus;
|
private string? _filterStatus;
|
||||||
@@ -106,6 +137,16 @@
|
|||||||
query = query.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
|
query = query.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
|
||||||
|
|
||||||
_logs = await query.Take(500).ToListAsync();
|
_logs = await query.Take(500).ToListAsync();
|
||||||
|
|
||||||
|
IQueryable<AppEventLog> appLogQuery = db.AppEventLogs.OrderByDescending(l => l.Timestamp);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_filterLand))
|
||||||
|
appLogQuery = appLogQuery.Where(l => l.Land == _filterLand);
|
||||||
|
|
||||||
|
if (_filterDate.HasValue)
|
||||||
|
appLogQuery = appLogQuery.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
|
||||||
|
|
||||||
|
_appLogs = await appLogQuery.Take(500).ToListAsync();
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
@page "/management-cockpit"
|
||||||
|
@using TrafagSalesExporter.Models
|
||||||
|
@using TrafagSalesExporter.Services
|
||||||
|
@inject IManagementCockpitService CockpitService
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
<PageTitle>Management Cockpit</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-4">Management Cockpit</MudText>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="8">
|
||||||
|
<MudSelect T="string" @bind-Value="_selectedFilePath" Label="Vorhandene Excel-Datei" Dense>
|
||||||
|
@foreach (var file in _files)
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@file.Path">@file.DisplayName</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudStack Row Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ReloadFiles"
|
||||||
|
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loadingFiles">
|
||||||
|
Dateien laden
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Analyze"
|
||||||
|
StartIcon="@Icons.Material.Filled.Analytics" Disabled="_analyzing || string.IsNullOrWhiteSpace(_selectedFilePath)">
|
||||||
|
@(_analyzing ? "Analysiere..." : "Cockpit erzeugen")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (_result is not null)
|
||||||
|
{
|
||||||
|
<MudGrid Class="mb-4">
|
||||||
|
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Land</MudText><MudText Typo="Typo.h6">@_result.Summary.Land</MudText></MudPaper></MudItem>
|
||||||
|
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">TSC</MudText><MudText Typo="Typo.h6">@_result.Summary.Tsc</MudText></MudPaper></MudItem>
|
||||||
|
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Umsatz</MudText><MudText Typo="Typo.h6">@_result.Summary.SalesValueTotal.ToString("N2")</MudText></MudPaper></MudItem>
|
||||||
|
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Geschätzte Marge</MudText><MudText Typo="Typo.h6">@($"{_result.Summary.EstimatedMarginPercent:F1}%")</MudText></MudPaper></MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">Management Aussagen</MudText>
|
||||||
|
@foreach (var finding in _result.Findings)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="@MapSeverity(finding.Severity)" Dense Variant="Variant.Outlined" Class="mb-2">
|
||||||
|
<b>@finding.Title:</b> @finding.Detail
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudGrid Class="mb-4">
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">Top Kunden</MudText>
|
||||||
|
@foreach (var item in _result.TopCustomers)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">Top Produktgruppen</MudText>
|
||||||
|
@foreach (var item in _result.TopProductGroups)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">Top Sales Owner</MudText>
|
||||||
|
@foreach (var item in _result.TopSalesEmployees)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="1">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">Datenqualität</MudText>
|
||||||
|
@foreach (var entry in _result.DataQualityCounts.OrderByDescending(x => x.Value))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@($"{entry.Key}: {entry.Value}")</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<ManagementCockpitFileOption> _files = [];
|
||||||
|
private string? _selectedFilePath;
|
||||||
|
private ManagementCockpitResult? _result;
|
||||||
|
private bool _loadingFiles;
|
||||||
|
private bool _analyzing;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await ReloadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadFiles()
|
||||||
|
{
|
||||||
|
_loadingFiles = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_files = await CockpitService.GetAvailableFilesAsync();
|
||||||
|
_selectedFilePath ??= _files.FirstOrDefault()?.Path;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loadingFiles = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Analyze()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_selectedFilePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_analyzing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_result = await CockpitService.AnalyzeAsync(_selectedFilePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Cockpit konnte nicht erzeugt werden: {ex.Message}", Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_analyzing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Severity MapSeverity(string severity) => severity switch
|
||||||
|
{
|
||||||
|
"Warning" => Severity.Warning,
|
||||||
|
"Error" => Severity.Error,
|
||||||
|
_ => Severity.Info
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -168,6 +168,20 @@
|
|||||||
<MudItem xs="12" md="4">
|
<MudItem xs="12" md="4">
|
||||||
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="Timer aktiviert" Color="Color.Primary" />
|
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="Timer aktiviert" Color="Color.Primary" />
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudSwitch @bind-Value="_exportSettings.DebugLoggingEnabled" Label="Debug Live-Logging" Color="Color.Warning" />
|
||||||
|
<MudText Typo="Typo.caption">
|
||||||
|
Schreibt zusätzliche technische Fortschrittsmeldungen für HANA- und SAP-Lesevorgänge ins Dashboard und in die Logs.
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_exportSettings.LocalSiteExportFolder" Label="Lokaler Standardpfad Standort-Dateien"
|
||||||
|
HelperText="Wenn leer, wird ./output unter dem Programmverzeichnis verwendet." />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudTextField @bind-Value="_exportSettings.LocalConsolidatedExportFolder" Label="Lokaler Pfad Zentrale Datei"
|
||||||
|
HelperText="Optional. Wenn leer, wird der Standardpfad der Standort-Dateien verwendet." />
|
||||||
|
</MudItem>
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
|
||||||
StartIcon="@Icons.Material.Filled.Save">
|
StartIcon="@Icons.Material.Filled.Save">
|
||||||
@@ -258,6 +272,9 @@
|
|||||||
existing.TimerHour = _exportSettings.TimerHour;
|
existing.TimerHour = _exportSettings.TimerHour;
|
||||||
existing.TimerMinute = _exportSettings.TimerMinute;
|
existing.TimerMinute = _exportSettings.TimerMinute;
|
||||||
existing.TimerEnabled = _exportSettings.TimerEnabled;
|
existing.TimerEnabled = _exportSettings.TimerEnabled;
|
||||||
|
existing.DebugLoggingEnabled = _exportSettings.DebugLoggingEnabled;
|
||||||
|
existing.LocalSiteExportFolder = _exportSettings.LocalSiteExportFolder;
|
||||||
|
existing.LocalConsolidatedExportFolder = _exportSettings.LocalConsolidatedExportFolder;
|
||||||
existing.SapUsername = _exportSettings.SapUsername;
|
existing.SapUsername = _exportSettings.SapUsername;
|
||||||
existing.SapPassword = _exportSettings.SapPassword;
|
existing.SapPassword = _exportSettings.SapPassword;
|
||||||
existing.Bi1Username = _exportSettings.Bi1Username;
|
existing.Bi1Username = _exportSettings.Bi1Username;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
@inject IDbContextFactory<AppDbContext> DbFactory
|
@inject IDbContextFactory<AppDbContext> DbFactory
|
||||||
@inject IHanaQueryService HanaService
|
@inject IHanaQueryService HanaService
|
||||||
@inject ISapGatewayService SapGatewayService
|
@inject ISapGatewayService SapGatewayService
|
||||||
|
@inject IAppEventLogService AppEventLogService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject IDialogService DialogService
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
@@ -149,6 +150,8 @@
|
|||||||
HelperText="Optional. Wenn leer, wird der zentrale Username des Quellsystems verwendet." />
|
HelperText="Optional. Wenn leer, wird der zentrale Username des Quellsystems verwendet." />
|
||||||
<MudTextField @bind-Value="_editingSite.PasswordOverride" Label="Password Override" InputType="InputType.Password"
|
<MudTextField @bind-Value="_editingSite.PasswordOverride" Label="Password Override" InputType="InputType.Password"
|
||||||
HelperText="Optional. Wenn leer, wird das zentrale Passwort des Quellsystems verwendet." />
|
HelperText="Optional. Wenn leer, wird das zentrale Passwort des Quellsystems verwendet." />
|
||||||
|
<MudTextField @bind-Value="_editingSite.LocalExportFolderOverride" Label="Lokaler Exportpfad Override"
|
||||||
|
HelperText="Optional. Wenn leer, wird der zentrale Standardpfad für Standort-Dateien verwendet." />
|
||||||
<MudCheckBox @bind-Value="_editingSite.IsActive" Label="Aktiv" />
|
<MudCheckBox @bind-Value="_editingSite.IsActive" Label="Aktiv" />
|
||||||
|
|
||||||
<MudDivider Class="my-4" />
|
<MudDivider Class="my-4" />
|
||||||
@@ -216,7 +219,13 @@
|
|||||||
<MudDivider Class="my-4" />
|
<MudDivider Class="my-4" />
|
||||||
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
|
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
|
||||||
<MudText Typo="Typo.h6">SAP Joins</MudText>
|
<MudText Typo="Typo.h6">SAP Joins</MudText>
|
||||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapJoin">Join hinzufügen</MudButton>
|
<MudStack Row Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||||
|
OnClick="AutoMatchSapJoins">
|
||||||
|
Auto-Match
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapJoin">Join hinzufügen</MudButton>
|
||||||
|
</MudStack>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
<MudTable Items="_sapJoins" Dense Hover Striped>
|
<MudTable Items="_sapJoins" Dense Hover Striped>
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
@@ -237,7 +246,18 @@
|
|||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
</MudTd>
|
</MudTd>
|
||||||
<MudTd><MudTextField @bind-Value="context.LeftKeys" Dense Placeholder="VBELN,POSNR" /></MudTd>
|
<MudTd>
|
||||||
|
<MudSelect T="string"
|
||||||
|
SelectedValues="GetSelectedJoinKeys(context.LeftKeys)"
|
||||||
|
SelectedValuesChanged="@(values => context.LeftKeys = string.Join(',', values))"
|
||||||
|
MultiSelection="true"
|
||||||
|
Dense>
|
||||||
|
@foreach (var field in GetAvailableJoinFields(context.LeftAlias, context.LeftKeys))
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@field">@field</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
<MudTd>
|
<MudTd>
|
||||||
<MudSelect @bind-Value="context.RightAlias" Dense>
|
<MudSelect @bind-Value="context.RightAlias" Dense>
|
||||||
@foreach (var alias in GetSapAliases())
|
@foreach (var alias in GetSapAliases())
|
||||||
@@ -246,7 +266,18 @@
|
|||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
</MudTd>
|
</MudTd>
|
||||||
<MudTd><MudTextField @bind-Value="context.RightKeys" Dense Placeholder="VBELN,POSNR" /></MudTd>
|
<MudTd>
|
||||||
|
<MudSelect T="string"
|
||||||
|
SelectedValues="GetSelectedJoinKeys(context.RightKeys)"
|
||||||
|
SelectedValuesChanged="@(values => context.RightKeys = string.Join(',', values))"
|
||||||
|
MultiSelection="true"
|
||||||
|
Dense>
|
||||||
|
@foreach (var field in GetAvailableJoinFields(context.RightAlias, context.RightKeys))
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@field">@field</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
<MudTd>
|
<MudTd>
|
||||||
<MudSelect @bind-Value="context.JoinType" Dense>
|
<MudSelect @bind-Value="context.JoinType" Dense>
|
||||||
<MudSelectItem Value="@("Left")">Left</MudSelectItem>
|
<MudSelectItem Value="@("Left")">Left</MudSelectItem>
|
||||||
@@ -260,8 +291,25 @@
|
|||||||
<MudDivider Class="my-4" />
|
<MudDivider Class="my-4" />
|
||||||
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
|
<MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-2">
|
||||||
<MudText Typo="Typo.h6">Feldmappings ins zentrale Schema</MudText>
|
<MudText Typo="Typo.h6">Feldmappings ins zentrale Schema</MudText>
|
||||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapMapping">Mapping hinzufügen</MudButton>
|
<MudStack Row Spacing="2">
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Info" StartIcon="@Icons.Material.Filled.Schema"
|
||||||
|
OnClick="RefreshSapSourceFields" Disabled="_refreshingSapSourceFields">
|
||||||
|
@if (_refreshingSapSourceFields)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate Class="mr-2" />
|
||||||
|
@("Lade Felder...")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@("Felder aus Quellen laden")
|
||||||
|
}
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="AddSapMapping">Mapping hinzufügen</MudButton>
|
||||||
|
</MudStack>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
<MudText Typo="Typo.caption" Class="mb-2">
|
||||||
|
Source Expressions werden aus den hinzugefügten SAP-Quellen als `Alias.Feldname` gelesen. Vorhandene manuelle Werte bleiben auswählbar.
|
||||||
|
</MudText>
|
||||||
<MudTable Items="_sapMappings" Dense Hover Striped>
|
<MudTable Items="_sapMappings" Dense Hover Striped>
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh>Zielfeld</MudTh>
|
<MudTh>Zielfeld</MudTh>
|
||||||
@@ -279,7 +327,14 @@
|
|||||||
}
|
}
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
</MudTd>
|
</MudTd>
|
||||||
<MudTd><MudTextField @bind-Value="context.SourceExpression" Dense Placeholder="VBAK.VBELN oder =SAP" /></MudTd>
|
<MudTd>
|
||||||
|
<MudSelect T="string" @bind-Value="context.SourceExpression" Dense>
|
||||||
|
@foreach (var expression in GetAvailableSourceExpressions(context.SourceExpression))
|
||||||
|
{
|
||||||
|
<MudSelectItem Value="@expression">@expression</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudTd>
|
||||||
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense /></MudTd>
|
<MudTd><MudCheckBox @bind-Value="context.IsRequired" Dense /></MudTd>
|
||||||
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
|
<MudTd><MudCheckBox @bind-Value="context.IsActive" Dense /></MudTd>
|
||||||
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSapMapping(context)" /></MudTd>
|
<MudTd><MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" OnClick="() => RemoveSapMapping(context)" /></MudTd>
|
||||||
@@ -321,6 +376,8 @@
|
|||||||
private List<HanaServer> _servers = new();
|
private List<HanaServer> _servers = new();
|
||||||
private List<Site> _sites = new();
|
private List<Site> _sites = new();
|
||||||
private List<string> _sapEntitySetsCache = [];
|
private List<string> _sapEntitySetsCache = [];
|
||||||
|
private List<string> _sapAvailableSourceExpressions = [];
|
||||||
|
private Dictionary<string, List<string>> _sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private List<SapSourceDefinition> _sapSources = [];
|
private List<SapSourceDefinition> _sapSources = [];
|
||||||
private List<SapJoinDefinition> _sapJoins = [];
|
private List<SapJoinDefinition> _sapJoins = [];
|
||||||
private List<SapFieldMapping> _sapMappings = [];
|
private List<SapFieldMapping> _sapMappings = [];
|
||||||
@@ -334,6 +391,7 @@
|
|||||||
private bool _serverDialogVisible;
|
private bool _serverDialogVisible;
|
||||||
private bool _siteDialogVisible;
|
private bool _siteDialogVisible;
|
||||||
private bool _refreshingSapEntitySets;
|
private bool _refreshingSapEntitySets;
|
||||||
|
private bool _refreshingSapSourceFields;
|
||||||
private bool _savingServer;
|
private bool _savingServer;
|
||||||
private bool _savingSite;
|
private bool _savingSite;
|
||||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
@@ -426,6 +484,8 @@
|
|||||||
|
|
||||||
private async Task TestServerConnection(HanaServer server)
|
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));
|
var result = await Task.Run(() => HanaService.TestConnectionDetailed(server));
|
||||||
_connectionStatus[server.Id] = result;
|
_connectionStatus[server.Id] = result;
|
||||||
|
|
||||||
@@ -457,6 +517,8 @@
|
|||||||
HanaServerId = null
|
HanaServerId = null
|
||||||
};
|
};
|
||||||
_sapEntitySetsCache = [];
|
_sapEntitySetsCache = [];
|
||||||
|
_sapAvailableSourceExpressions = [];
|
||||||
|
_sapSourceFieldMap = new(StringComparer.OrdinalIgnoreCase);
|
||||||
_sapSources = [];
|
_sapSources = [];
|
||||||
_sapJoins = [];
|
_sapJoins = [];
|
||||||
_sapMappings = [];
|
_sapMappings = [];
|
||||||
@@ -476,6 +538,7 @@
|
|||||||
SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem,
|
SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem,
|
||||||
UsernameOverride = site.UsernameOverride,
|
UsernameOverride = site.UsernameOverride,
|
||||||
PasswordOverride = site.PasswordOverride,
|
PasswordOverride = site.PasswordOverride,
|
||||||
|
LocalExportFolderOverride = site.LocalExportFolderOverride,
|
||||||
SapServiceUrl = site.SapServiceUrl,
|
SapServiceUrl = site.SapServiceUrl,
|
||||||
SapEntitySet = site.SapEntitySet,
|
SapEntitySet = site.SapEntitySet,
|
||||||
SapEntitySetsCache = site.SapEntitySetsCache,
|
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();
|
_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();
|
_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();
|
_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
|
_editingSiteServer = site.HanaServer is null
|
||||||
? CreateDefaultSiteServer(site)
|
? CreateDefaultSiteServer(site)
|
||||||
: CloneServer(site.HanaServer);
|
: CloneServer(site.HanaServer);
|
||||||
@@ -522,6 +587,7 @@
|
|||||||
existing.SourceSystem = _editingSite.SourceSystem;
|
existing.SourceSystem = _editingSite.SourceSystem;
|
||||||
existing.UsernameOverride = _editingSite.UsernameOverride;
|
existing.UsernameOverride = _editingSite.UsernameOverride;
|
||||||
existing.PasswordOverride = _editingSite.PasswordOverride;
|
existing.PasswordOverride = _editingSite.PasswordOverride;
|
||||||
|
existing.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride;
|
||||||
existing.SapServiceUrl = _editingSite.SapServiceUrl;
|
existing.SapServiceUrl = _editingSite.SapServiceUrl;
|
||||||
existing.SapEntitySet = _editingSite.SapEntitySet;
|
existing.SapEntitySet = _editingSite.SapEntitySet;
|
||||||
existing.SapEntitySetsCache = _editingSite.SapEntitySetsCache;
|
existing.SapEntitySetsCache = _editingSite.SapEntitySetsCache;
|
||||||
@@ -629,6 +695,7 @@
|
|||||||
: _editingSiteServer.Name.Trim();
|
: _editingSiteServer.Name.Trim();
|
||||||
_editingSite.UsernameOverride = _editingSite.UsernameOverride.Trim();
|
_editingSite.UsernameOverride = _editingSite.UsernameOverride.Trim();
|
||||||
_editingSite.PasswordOverride = _editingSite.PasswordOverride.Trim();
|
_editingSite.PasswordOverride = _editingSite.PasswordOverride.Trim();
|
||||||
|
_editingSite.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride.Trim();
|
||||||
_editingSite.SapServiceUrl = _editingSite.SapServiceUrl.Trim();
|
_editingSite.SapServiceUrl = _editingSite.SapServiceUrl.Trim();
|
||||||
_editingSite.SapEntitySet = _editingSite.SapEntitySet.Trim();
|
_editingSite.SapEntitySet = _editingSite.SapEntitySet.Trim();
|
||||||
_editingSiteServer.Host = _editingSiteServer.Host.Trim();
|
_editingSiteServer.Host = _editingSiteServer.Host.Trim();
|
||||||
@@ -698,6 +765,8 @@
|
|||||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
|
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());
|
var entitySets = await SapGatewayService.GetEntitySetsAsync(_editingSite.SapServiceUrl, username.Trim(), password.Trim());
|
||||||
_sapEntitySetsCache = entitySets;
|
_sapEntitySetsCache = entitySets;
|
||||||
_editingSite.SapEntitySetsCache = SerializeSapEntitySets(entitySets);
|
_editingSite.SapEntitySetsCache = SerializeSapEntitySets(entitySets);
|
||||||
@@ -710,10 +779,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
Snackbar.Add($"{entitySets.Count} SAP Entity Sets geladen.", Severity.Success);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add(ex.Message, Severity.Error);
|
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
|
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)
|
private void RemoveSapJoin(SapJoinDefinition join)
|
||||||
{
|
{
|
||||||
_sapJoins.Remove(join);
|
_sapJoins.Remove(join);
|
||||||
@@ -792,6 +942,7 @@
|
|||||||
_sapMappings.Add(new SapFieldMapping
|
_sapMappings.Add(new SapFieldMapping
|
||||||
{
|
{
|
||||||
TargetField = _salesRecordFields.First(),
|
TargetField = _salesRecordFields.First(),
|
||||||
|
SourceExpression = _sapAvailableSourceExpressions.FirstOrDefault() ?? "=SAP",
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
SortOrder = _sapMappings.Count
|
SortOrder = _sapMappings.Count
|
||||||
});
|
});
|
||||||
@@ -847,4 +998,147 @@
|
|||||||
if (_sapSources.Count > 0 && _sapSources.All(s => !s.IsPrimary))
|
if (_sapSources.Count > 0 && _sapSources.All(s => !s.IsPrimary))
|
||||||
_sapSources[0].IsPrimary = true;
|
_sapSources[0].IsPrimary = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task RefreshSapSourceFields()
|
||||||
|
{
|
||||||
|
if (_refreshingSapSourceFields)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_refreshingSapSourceFields = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_editingSite.SapServiceUrl))
|
||||||
|
throw new InvalidOperationException("SAP Service URL muss gesetzt sein.");
|
||||||
|
|
||||||
|
var activeSources = _sapSources
|
||||||
|
.Where(s => s.IsActive && !string.IsNullOrWhiteSpace(s.Alias) && !string.IsNullOrWhiteSpace(s.EntitySet))
|
||||||
|
.OrderBy(s => s.SortOrder)
|
||||||
|
.ThenBy(s => s.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (activeSources.Count == 0)
|
||||||
|
throw new InvalidOperationException("Es gibt keine aktiven SAP-Quellen mit Alias und Entity Set.");
|
||||||
|
|
||||||
|
using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new();
|
||||||
|
var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride) ? settings.SapUsername : _editingSite.UsernameOverride;
|
||||||
|
var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride) ? settings.SapPassword : _editingSite.PasswordOverride;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
|
throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
|
||||||
|
|
||||||
|
var expressions = new List<string> { "=SAP" };
|
||||||
|
var sourceFieldMap = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var source in activeSources)
|
||||||
|
{
|
||||||
|
var fieldNames = await SapGatewayService.GetEntityFieldNamesAsync(_editingSite.SapServiceUrl, source.EntitySet, username.Trim(), password.Trim());
|
||||||
|
sourceFieldMap[source.Alias] = fieldNames;
|
||||||
|
expressions.AddRange(fieldNames.Select(field => $"{source.Alias}.{field}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
_sapAvailableSourceExpressions = expressions
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
_sapSourceFieldMap = sourceFieldMap;
|
||||||
|
|
||||||
|
foreach (var current in BuildSourceExpressionsFromMappings())
|
||||||
|
{
|
||||||
|
if (!_sapAvailableSourceExpressions.Contains(current, StringComparer.OrdinalIgnoreCase))
|
||||||
|
_sapAvailableSourceExpressions.Add(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
_sapAvailableSourceExpressions = _sapAvailableSourceExpressions
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Snackbar.Add($"{_sapAvailableSourceExpressions.Count} Source Expressions geladen.", Severity.Success);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(ex.Message, Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_refreshingSapSourceFields = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetAvailableSourceExpressions(string? currentValue)
|
||||||
|
{
|
||||||
|
var expressions = new List<string>(_sapAvailableSourceExpressions);
|
||||||
|
if (!string.IsNullOrWhiteSpace(currentValue) && !expressions.Contains(currentValue, StringComparer.OrdinalIgnoreCase))
|
||||||
|
expressions.Insert(0, currentValue);
|
||||||
|
|
||||||
|
return expressions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> BuildSourceExpressionsFromMappings()
|
||||||
|
=> _sapMappings
|
||||||
|
.Select(m => m.SourceExpression)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private Dictionary<string, List<string>> BuildSourceFieldMapFromJoins()
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var join in _sapJoins)
|
||||||
|
{
|
||||||
|
AddJoinKeysToFieldMap(result, join.LeftAlias, join.LeftKeys);
|
||||||
|
AddJoinKeysToFieldMap(result, join.RightAlias, join.RightKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddJoinKeysToFieldMap(Dictionary<string, List<string>> target, string alias, string keys)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(alias))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!target.TryGetValue(alias, out var fields))
|
||||||
|
{
|
||||||
|
fields = [];
|
||||||
|
target[alias] = fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var key in GetSelectedJoinKeys(keys))
|
||||||
|
{
|
||||||
|
if (!fields.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
fields.Add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.Sort(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetAvailableJoinFields(string? alias, string? currentKeys)
|
||||||
|
{
|
||||||
|
var values = new List<string>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(alias) && _sapSourceFieldMap.TryGetValue(alias, out var fields))
|
||||||
|
values.AddRange(fields);
|
||||||
|
|
||||||
|
foreach (var key in GetSelectedJoinKeys(currentKeys))
|
||||||
|
{
|
||||||
|
if (!values.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
values.Add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> GetSelectedJoinKeys(string? keys)
|
||||||
|
=> keys?
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase)
|
||||||
|
?? [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
|
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
|
||||||
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
|
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
|
||||||
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
|
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
|
||||||
|
public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>();
|
||||||
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
|
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
|
||||||
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
|
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
|
||||||
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
|
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class AppEventLog
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
public string Level { get; set; } = "Info";
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public int? SiteId { get; set; }
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public string Details { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ public class ConfigTransferExportSettings
|
|||||||
public int TimerHour { get; set; } = 3;
|
public int TimerHour { get; set; } = 3;
|
||||||
public int TimerMinute { get; set; }
|
public int TimerMinute { get; set; }
|
||||||
public bool TimerEnabled { get; set; } = true;
|
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? SapUsername { get; set; }
|
||||||
public string? SapPassword { get; set; }
|
public string? SapPassword { get; set; }
|
||||||
public string? Bi1Username { get; set; }
|
public string? Bi1Username { get; set; }
|
||||||
@@ -62,6 +65,7 @@ public class ConfigTransferSite
|
|||||||
public string SourceSystem { get; set; } = "SAP";
|
public string SourceSystem { get; set; } = "SAP";
|
||||||
public string? UsernameOverride { get; set; }
|
public string? UsernameOverride { get; set; }
|
||||||
public string? PasswordOverride { get; set; }
|
public string? PasswordOverride { get; set; }
|
||||||
|
public string LocalExportFolderOverride { get; set; } = string.Empty;
|
||||||
public string SapServiceUrl { get; set; } = string.Empty;
|
public string SapServiceUrl { get; set; } = string.Empty;
|
||||||
public string SapEntitySet { get; set; } = string.Empty;
|
public string SapEntitySet { get; set; } = string.Empty;
|
||||||
public string SapEntitySetsCache { get; set; } = string.Empty;
|
public string SapEntitySetsCache { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ public class ExportLog
|
|||||||
public int RowCount { get; set; }
|
public int RowCount { get; set; }
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
public string FileName { get; set; } = string.Empty;
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
public double DurationSeconds { get; set; }
|
public double DurationSeconds { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ public class ExportSettings
|
|||||||
public int TimerHour { get; set; } = 3;
|
public int TimerHour { get; set; } = 3;
|
||||||
public int TimerMinute { get; set; }
|
public int TimerMinute { get; set; }
|
||||||
public bool TimerEnabled { get; set; } = true;
|
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 SapUsername { get; set; } = string.Empty;
|
||||||
public string SapPassword { get; set; } = string.Empty;
|
public string SapPassword { get; set; } = string.Empty;
|
||||||
public string Bi1Username { get; set; } = string.Empty;
|
public string Bi1Username { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
public class ManagementCockpitFileOption
|
||||||
|
{
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitSummary
|
||||||
|
{
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public string Tsc { get; set; } = string.Empty;
|
||||||
|
public DateTime? ExtractionDate { get; set; }
|
||||||
|
public int RowCount { get; set; }
|
||||||
|
public int InvoiceCount { get; set; }
|
||||||
|
public int CustomerCount { get; set; }
|
||||||
|
public decimal SalesValueTotal { get; set; }
|
||||||
|
public decimal EstimatedCostTotal { get; set; }
|
||||||
|
public decimal EstimatedMarginTotal { get; set; }
|
||||||
|
public decimal EstimatedMarginPercent { get; set; }
|
||||||
|
public decimal ServiceSharePercent { get; set; }
|
||||||
|
public decimal MissingOrderDatePercent { get; set; }
|
||||||
|
public decimal MissingSupplierPercent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitFinding
|
||||||
|
{
|
||||||
|
public string Severity { get; set; } = "Info";
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Detail { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitTopItem
|
||||||
|
{
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
public decimal SharePercent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManagementCockpitResult
|
||||||
|
{
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public ManagementCockpitSummary Summary { get; set; } = new();
|
||||||
|
public List<ManagementCockpitFinding> Findings { get; set; } = [];
|
||||||
|
public List<ManagementCockpitTopItem> TopCustomers { get; set; } = [];
|
||||||
|
public List<ManagementCockpitTopItem> TopProductGroups { get; set; } = [];
|
||||||
|
public List<ManagementCockpitTopItem> TopSalesEmployees { get; set; } = [];
|
||||||
|
public Dictionary<string, int> DataQualityCounts { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ public class Site
|
|||||||
public string UsernameOverride { get; set; } = string.Empty;
|
public string UsernameOverride { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string PasswordOverride { 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;
|
public string SapServiceUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ builder.Services.AddRazorComponents()
|
|||||||
builder.Services.AddMudServices();
|
builder.Services.AddMudServices();
|
||||||
|
|
||||||
builder.Services.AddDbContextFactory<AppDbContext>(options =>
|
builder.Services.AddDbContextFactory<AppDbContext>(options =>
|
||||||
options.UseSqlite("Data Source=trafag_exporter.db"));
|
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=10"));
|
||||||
|
|
||||||
builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
|
builder.Services.AddSingleton<IHanaQueryService, HanaQueryService>();
|
||||||
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
|
builder.Services.AddSingleton<IExcelExportService, ExcelExportService>();
|
||||||
@@ -26,6 +26,8 @@ builder.Services.AddSingleton<ITransformationStrategy, SuffixTransformationStrat
|
|||||||
builder.Services.AddSingleton<ITransformationStrategy, ReplaceTransformationStrategy>();
|
builder.Services.AddSingleton<ITransformationStrategy, ReplaceTransformationStrategy>();
|
||||||
builder.Services.AddSingleton<ITransformationStrategy, ConstantTransformationStrategy>();
|
builder.Services.AddSingleton<ITransformationStrategy, ConstantTransformationStrategy>();
|
||||||
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
|
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
|
||||||
|
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
|
||||||
|
builder.Services.AddSingleton<IManagementCockpitService, ManagementCockpitService>();
|
||||||
builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
|
builder.Services.AddSingleton<ISiteExportService, SiteExportService>();
|
||||||
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
|
builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportService>();
|
||||||
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class AppEventLogService : IAppEventLogService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
|
||||||
|
public AppEventLogService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null)
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
db.AppEventLogs.Add(new AppEventLog
|
||||||
|
{
|
||||||
|
Timestamp = DateTime.Now,
|
||||||
|
Level = string.IsNullOrWhiteSpace(level) ? "Info" : level.Trim(),
|
||||||
|
Category = category?.Trim() ?? string.Empty,
|
||||||
|
SiteId = siteId,
|
||||||
|
Land = land?.Trim() ?? string.Empty,
|
||||||
|
Message = message?.Trim() ?? string.Empty,
|
||||||
|
Details = details?.Trim() ?? string.Empty
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null)
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var settings = await db.ExportSettings.FirstOrDefaultAsync();
|
||||||
|
if (settings is null || !settings.DebugLoggingEnabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
db.AppEventLogs.Add(new AppEventLog
|
||||||
|
{
|
||||||
|
Timestamp = DateTime.Now,
|
||||||
|
Level = "Debug",
|
||||||
|
Category = category?.Trim() ?? string.Empty,
|
||||||
|
SiteId = siteId,
|
||||||
|
Land = land?.Trim() ?? string.Empty,
|
||||||
|
Message = message?.Trim() ?? string.Empty,
|
||||||
|
Details = details?.Trim() ?? string.Empty
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TrafagSalesExporter.Data;
|
using TrafagSalesExporter.Data;
|
||||||
using TrafagSalesExporter.Models;
|
using TrafagSalesExporter.Models;
|
||||||
@@ -6,55 +7,50 @@ namespace TrafagSalesExporter.Services;
|
|||||||
|
|
||||||
public class CentralSalesRecordService : ICentralSalesRecordService
|
public class CentralSalesRecordService : ICentralSalesRecordService
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
private const int BatchSize = 25;
|
||||||
|
|
||||||
public CentralSalesRecordService(IDbContextFactory<AppDbContext> dbFactory)
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
|
|
||||||
|
public CentralSalesRecordService(IDbContextFactory<AppDbContext> dbFactory, IAppEventLogService appEventLogService)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
|
_appEventLogService = appEventLogService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records)
|
public async Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records, Action<string>? updateStatus = null)
|
||||||
{
|
{
|
||||||
using var db = await _dbFactory.CreateDbContextAsync();
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
var existing = await db.CentralSalesRecords.Where(r => r.SiteId == site.Id).ToListAsync();
|
var recordList = records.ToList();
|
||||||
if (existing.Count > 0)
|
|
||||||
db.CentralSalesRecords.RemoveRange(existing);
|
|
||||||
|
|
||||||
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
|
await db.Database.OpenConnectionAsync();
|
||||||
db.CentralSalesRecords.AddRange(records.Select(record => new CentralSalesRecord
|
var connection = (SqliteConnection)db.Database.GetDbConnection();
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
StoredAtUtc = DateTime.UtcNow,
|
updateStatus?.Invoke("Zentrale Tabelle: bestehende Saetze zaehlen...");
|
||||||
SiteId = site.Id,
|
var existingCount = await CountExistingAsync(connection, 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
|
|
||||||
}));
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
if (existingCount > 0)
|
||||||
|
{
|
||||||
|
updateStatus?.Invoke("Zentrale Tabelle: alte Saetze loeschen...");
|
||||||
|
await DeleteExistingAsync(connection, site.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus?.Invoke("Zentrale Tabelle: neue Saetze vorbereiten...");
|
||||||
|
await InsertRecordsInCommittedBatchesAsync(connection, site, recordList, updateStatus);
|
||||||
|
|
||||||
|
await _appEventLogService.WriteAsync(
|
||||||
|
"Export",
|
||||||
|
"Zentrale Tabelle aktualisiert",
|
||||||
|
siteId: site.Id,
|
||||||
|
land: site.Land,
|
||||||
|
details: $"Geloescht={existingCount} | Neu={recordList.Count}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await db.Database.CloseConnectionAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<SalesRecord>> GetAllAsync()
|
public async Task<List<SalesRecord>> GetAllAsync()
|
||||||
@@ -94,4 +90,147 @@ public class CentralSalesRecordService : ICentralSalesRecordService
|
|||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<int> CountExistingAsync(SqliteConnection connection, int siteId)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT COUNT(1) FROM CentralSalesRecords WHERE SiteId = $siteId;";
|
||||||
|
command.Parameters.AddWithValue("$siteId", siteId);
|
||||||
|
var scalar = await command.ExecuteScalarAsync();
|
||||||
|
return scalar is null or DBNull ? 0 : Convert.ToInt32(scalar);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DeleteExistingAsync(SqliteConnection connection, int siteId)
|
||||||
|
{
|
||||||
|
await using var transaction = connection.BeginTransaction();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = "DELETE FROM CentralSalesRecords WHERE SiteId = $siteId;";
|
||||||
|
command.Parameters.AddWithValue("$siteId", siteId);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task InsertRecordsInCommittedBatchesAsync(
|
||||||
|
SqliteConnection connection,
|
||||||
|
Site site,
|
||||||
|
IReadOnlyList<SalesRecord> records,
|
||||||
|
Action<string>? updateStatus)
|
||||||
|
{
|
||||||
|
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
|
||||||
|
var total = records.Count;
|
||||||
|
var totalBatches = Math.Max(1, (int)Math.Ceiling(total / (double)BatchSize));
|
||||||
|
var processed = 0;
|
||||||
|
|
||||||
|
for (var batchIndex = 0; batchIndex < totalBatches; batchIndex++)
|
||||||
|
{
|
||||||
|
updateStatus?.Invoke($"Zentrale Tabelle: Batch {batchIndex + 1}/{totalBatches} speichern...");
|
||||||
|
|
||||||
|
await using var transaction = connection.BeginTransaction();
|
||||||
|
await using var command = CreateInsertCommand(connection, transaction);
|
||||||
|
|
||||||
|
var batchRecords = records
|
||||||
|
.Skip(batchIndex * BatchSize)
|
||||||
|
.Take(BatchSize);
|
||||||
|
|
||||||
|
foreach (var record in batchRecords)
|
||||||
|
{
|
||||||
|
SetInsertParameters(command, site, sourceSystem, record);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus?.Invoke($"Zentrale Tabelle: Batch {batchIndex + 1}/{totalBatches} abschliessen...");
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus?.Invoke($"Zentrale Tabelle: {processed} Datensaetze gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SqliteCommand CreateInsertCommand(SqliteConnection connection, SqliteTransaction transaction)
|
||||||
|
{
|
||||||
|
var command = connection.CreateCommand();
|
||||||
|
command.Transaction = transaction;
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO CentralSalesRecords (
|
||||||
|
StoredAtUtc, SiteId, SourceSystem, ExtractionDate, Tsc, InvoiceNumber, PositionOnInvoice,
|
||||||
|
Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry,
|
||||||
|
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
|
||||||
|
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
|
||||||
|
SalesResponsibleEmployee, InvoiceDate, OrderDate, Land, DocumentType
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $invoiceNumber, $positionOnInvoice,
|
||||||
|
$material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry,
|
||||||
|
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
|
||||||
|
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
|
||||||
|
$salesResponsibleEmployee, $invoiceDate, $orderDate, $land, $documentType
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
|
||||||
|
command.Parameters.Add("$storedAtUtc", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$siteId", SqliteType.Integer);
|
||||||
|
command.Parameters.Add("$sourceSystem", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$extractionDate", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$tsc", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$invoiceNumber", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$positionOnInvoice", SqliteType.Integer);
|
||||||
|
command.Parameters.Add("$material", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$name", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$productGroup", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$quantity", SqliteType.Real);
|
||||||
|
command.Parameters.Add("$supplierNumber", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$supplierName", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$supplierCountry", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$customerNumber", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$customerName", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$customerCountry", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$customerIndustry", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$standardCost", SqliteType.Real);
|
||||||
|
command.Parameters.Add("$standardCostCurrency", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$purchaseOrderNumber", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$salesPriceValue", SqliteType.Real);
|
||||||
|
command.Parameters.Add("$salesCurrency", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$incoterms2020", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$invoiceDate", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$orderDate", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$land", SqliteType.Text);
|
||||||
|
command.Parameters.Add("$documentType", SqliteType.Text);
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetInsertParameters(SqliteCommand command, Site site, string sourceSystem, SalesRecord record)
|
||||||
|
{
|
||||||
|
command.Parameters["$storedAtUtc"].Value = DateTime.UtcNow.ToString("O");
|
||||||
|
command.Parameters["$siteId"].Value = site.Id;
|
||||||
|
command.Parameters["$sourceSystem"].Value = sourceSystem;
|
||||||
|
command.Parameters["$extractionDate"].Value = record.ExtractionDate.ToString("O");
|
||||||
|
command.Parameters["$tsc"].Value = record.Tsc ?? string.Empty;
|
||||||
|
command.Parameters["$invoiceNumber"].Value = record.InvoiceNumber ?? string.Empty;
|
||||||
|
command.Parameters["$positionOnInvoice"].Value = record.PositionOnInvoice;
|
||||||
|
command.Parameters["$material"].Value = record.Material ?? string.Empty;
|
||||||
|
command.Parameters["$name"].Value = record.Name ?? string.Empty;
|
||||||
|
command.Parameters["$productGroup"].Value = record.ProductGroup ?? string.Empty;
|
||||||
|
command.Parameters["$quantity"].Value = record.Quantity;
|
||||||
|
command.Parameters["$supplierNumber"].Value = record.SupplierNumber ?? string.Empty;
|
||||||
|
command.Parameters["$supplierName"].Value = record.SupplierName ?? string.Empty;
|
||||||
|
command.Parameters["$supplierCountry"].Value = record.SupplierCountry ?? string.Empty;
|
||||||
|
command.Parameters["$customerNumber"].Value = record.CustomerNumber ?? string.Empty;
|
||||||
|
command.Parameters["$customerName"].Value = record.CustomerName ?? string.Empty;
|
||||||
|
command.Parameters["$customerCountry"].Value = record.CustomerCountry ?? string.Empty;
|
||||||
|
command.Parameters["$customerIndustry"].Value = record.CustomerIndustry ?? string.Empty;
|
||||||
|
command.Parameters["$standardCost"].Value = record.StandardCost;
|
||||||
|
command.Parameters["$standardCostCurrency"].Value = record.StandardCostCurrency ?? string.Empty;
|
||||||
|
command.Parameters["$purchaseOrderNumber"].Value = record.PurchaseOrderNumber ?? string.Empty;
|
||||||
|
command.Parameters["$salesPriceValue"].Value = record.SalesPriceValue;
|
||||||
|
command.Parameters["$salesCurrency"].Value = record.SalesCurrency ?? string.Empty;
|
||||||
|
command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty;
|
||||||
|
command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty;
|
||||||
|
command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value;
|
||||||
|
command.Parameters["$orderDate"].Value = record.OrderDate?.ToString("O") ?? (object)DBNull.Value;
|
||||||
|
command.Parameters["$land"].Value = record.Land ?? string.Empty;
|
||||||
|
command.Parameters["$documentType"].Value = record.DocumentType ?? string.Empty;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
TimerHour = exportSettings.TimerHour,
|
TimerHour = exportSettings.TimerHour,
|
||||||
TimerMinute = exportSettings.TimerMinute,
|
TimerMinute = exportSettings.TimerMinute,
|
||||||
TimerEnabled = exportSettings.TimerEnabled,
|
TimerEnabled = exportSettings.TimerEnabled,
|
||||||
|
DebugLoggingEnabled = exportSettings.DebugLoggingEnabled,
|
||||||
|
LocalSiteExportFolder = exportSettings.LocalSiteExportFolder,
|
||||||
|
LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder,
|
||||||
SapUsername = includeSecrets ? exportSettings.SapUsername : null,
|
SapUsername = includeSecrets ? exportSettings.SapUsername : null,
|
||||||
SapPassword = includeSecrets ? exportSettings.SapPassword : null,
|
SapPassword = includeSecrets ? exportSettings.SapPassword : null,
|
||||||
Bi1Username = includeSecrets ? exportSettings.Bi1Username : null,
|
Bi1Username = includeSecrets ? exportSettings.Bi1Username : null,
|
||||||
@@ -77,6 +80,7 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
SourceSystem = site.SourceSystem,
|
SourceSystem = site.SourceSystem,
|
||||||
UsernameOverride = includeSecrets ? site.UsernameOverride : null,
|
UsernameOverride = includeSecrets ? site.UsernameOverride : null,
|
||||||
PasswordOverride = includeSecrets ? site.PasswordOverride : null,
|
PasswordOverride = includeSecrets ? site.PasswordOverride : null,
|
||||||
|
LocalExportFolderOverride = site.LocalExportFolderOverride,
|
||||||
SapServiceUrl = site.SapServiceUrl,
|
SapServiceUrl = site.SapServiceUrl,
|
||||||
SapEntitySet = site.SapEntitySet,
|
SapEntitySet = site.SapEntitySet,
|
||||||
SapEntitySetsCache = site.SapEntitySetsCache,
|
SapEntitySetsCache = site.SapEntitySetsCache,
|
||||||
@@ -190,6 +194,9 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
TimerHour = importedSettings.TimerHour,
|
TimerHour = importedSettings.TimerHour,
|
||||||
TimerMinute = importedSettings.TimerMinute,
|
TimerMinute = importedSettings.TimerMinute,
|
||||||
TimerEnabled = importedSettings.TimerEnabled,
|
TimerEnabled = importedSettings.TimerEnabled,
|
||||||
|
DebugLoggingEnabled = importedSettings.DebugLoggingEnabled,
|
||||||
|
LocalSiteExportFolder = importedSettings.LocalSiteExportFolder,
|
||||||
|
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder,
|
||||||
SapUsername = package.IncludesSecrets ? importedSettings.SapUsername ?? string.Empty : preservedSecrets.SapUsername ?? string.Empty,
|
SapUsername = package.IncludesSecrets ? importedSettings.SapUsername ?? string.Empty : preservedSecrets.SapUsername ?? string.Empty,
|
||||||
SapPassword = package.IncludesSecrets ? importedSettings.SapPassword ?? string.Empty : preservedSecrets.SapPassword ?? string.Empty,
|
SapPassword = package.IncludesSecrets ? importedSettings.SapPassword ?? string.Empty : preservedSecrets.SapPassword ?? string.Empty,
|
||||||
Bi1Username = package.IncludesSecrets ? importedSettings.Bi1Username ?? string.Empty : preservedSecrets.Bi1Username ?? string.Empty,
|
Bi1Username = package.IncludesSecrets ? importedSettings.Bi1Username ?? string.Empty : preservedSecrets.Bi1Username ?? string.Empty,
|
||||||
@@ -234,6 +241,7 @@ public class ConfigTransferService : IConfigTransferService
|
|||||||
SourceSystem = site.SourceSystem,
|
SourceSystem = site.SourceSystem,
|
||||||
UsernameOverride = package.IncludesSecrets ? site.UsernameOverride ?? string.Empty : preserved.UsernameOverride ?? string.Empty,
|
UsernameOverride = package.IncludesSecrets ? site.UsernameOverride ?? string.Empty : preserved.UsernameOverride ?? string.Empty,
|
||||||
PasswordOverride = package.IncludesSecrets ? site.PasswordOverride ?? string.Empty : preserved.PasswordOverride ?? string.Empty,
|
PasswordOverride = package.IncludesSecrets ? site.PasswordOverride ?? string.Empty : preserved.PasswordOverride ?? string.Empty,
|
||||||
|
LocalExportFolderOverride = site.LocalExportFolderOverride,
|
||||||
SapServiceUrl = site.SapServiceUrl,
|
SapServiceUrl = site.SapServiceUrl,
|
||||||
SapEntitySet = site.SapEntitySet,
|
SapEntitySet = site.SapEntitySet,
|
||||||
SapEntitySetsCache = site.SapEntitySetsCache,
|
SapEntitySetsCache = site.SapEntitySetsCache,
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ public class ConsolidatedExportService : IConsolidatedExportService
|
|||||||
|
|
||||||
using var db = await _dbFactory.CreateDbContextAsync();
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
|
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(
|
var consolidatedPath = _excelService.CreateConsolidatedExcelFile(
|
||||||
outputDir,
|
outputDir,
|
||||||
DateTime.UtcNow.Date,
|
DateTime.UtcNow.Date,
|
||||||
@@ -55,4 +56,15 @@ public class ConsolidatedExportService : IConsolidatedExportService
|
|||||||
|
|
||||||
return consolidatedPath;
|
return consolidatedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ResolveConsolidatedOutputDirectory(ExportSettings settings)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
|
||||||
|
return settings.LocalConsolidatedExportFolder.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
|
||||||
|
return settings.LocalSiteExportFolder.Trim();
|
||||||
|
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "output");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,30 @@ public class DatabaseInitializationService : IDatabaseInitializationService
|
|||||||
{
|
{
|
||||||
using var db = await _dbFactory.CreateDbContextAsync();
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
await db.Database.EnsureCreatedAsync();
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
ConfigureSqlite(db);
|
||||||
EnsureSchema(db);
|
EnsureSchema(db);
|
||||||
SeedIfEmpty(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)
|
private static void EnsureSchema(AppDbContext db)
|
||||||
{
|
{
|
||||||
EnsureSitesTableSupportsOptionalHanaServer(db);
|
EnsureSitesTableSupportsOptionalHanaServer(db);
|
||||||
@@ -32,6 +52,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService
|
|||||||
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
|
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
|
||||||
AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''");
|
AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''");
|
||||||
AddColumnIfMissing(db, "Sites", "PasswordOverride", "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", "SapServiceUrl", "TEXT NOT NULL DEFAULT ''");
|
||||||
AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''");
|
AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''");
|
||||||
AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "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", "Bi1Password", "TEXT NOT NULL DEFAULT ''");
|
||||||
AddColumnIfMissing(db, "ExportSettings", "SageUsername", "TEXT NOT NULL DEFAULT ''");
|
AddColumnIfMissing(db, "ExportSettings", "SageUsername", "TEXT NOT NULL DEFAULT ''");
|
||||||
AddColumnIfMissing(db, "ExportSettings", "SagePassword", "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);
|
EnsureTransformationTable(db);
|
||||||
EnsureSapSourceTable(db);
|
EnsureSapSourceTable(db);
|
||||||
EnsureSapJoinTable(db);
|
EnsureSapJoinTable(db);
|
||||||
EnsureSapFieldMappingTable(db);
|
EnsureSapFieldMappingTable(db);
|
||||||
EnsureCentralSalesRecordTable(db);
|
EnsureCentralSalesRecordTable(db);
|
||||||
|
EnsureAppEventLogTable(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db)
|
private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db)
|
||||||
@@ -100,6 +126,7 @@ CREATE TABLE Sites (
|
|||||||
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
|
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
|
||||||
UsernameOverride TEXT NOT NULL DEFAULT '',
|
UsernameOverride TEXT NOT NULL DEFAULT '',
|
||||||
PasswordOverride TEXT NOT NULL DEFAULT '',
|
PasswordOverride TEXT NOT NULL DEFAULT '',
|
||||||
|
LocalExportFolderOverride TEXT NOT NULL DEFAULT '',
|
||||||
SapServiceUrl TEXT NOT NULL DEFAULT '',
|
SapServiceUrl TEXT NOT NULL DEFAULT '',
|
||||||
SapEntitySet TEXT NOT NULL DEFAULT '',
|
SapEntitySet TEXT NOT NULL DEFAULT '',
|
||||||
SapEntitySetsCache TEXT NOT NULL DEFAULT '',
|
SapEntitySetsCache TEXT NOT NULL DEFAULT '',
|
||||||
@@ -116,7 +143,7 @@ CREATE TABLE Sites (
|
|||||||
copy.CommandText = @"
|
copy.CommandText = @"
|
||||||
INSERT INTO Sites (
|
INSERT INTO Sites (
|
||||||
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
|
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
|
||||||
UsernameOverride, PasswordOverride, SapServiceUrl, SapEntitySet,
|
UsernameOverride, PasswordOverride, LocalExportFolderOverride, SapServiceUrl, SapEntitySet,
|
||||||
SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive
|
SapEntitySetsCache, SapEntitySetsRefreshedAtUtc, IsActive
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -124,6 +151,7 @@ SELECT
|
|||||||
COALESCE(SourceSystem, 'SAP'),
|
COALESCE(SourceSystem, 'SAP'),
|
||||||
COALESCE(UsernameOverride, ''),
|
COALESCE(UsernameOverride, ''),
|
||||||
COALESCE(PasswordOverride, ''),
|
COALESCE(PasswordOverride, ''),
|
||||||
|
COALESCE(LocalExportFolderOverride, ''),
|
||||||
COALESCE(SapServiceUrl, ''),
|
COALESCE(SapServiceUrl, ''),
|
||||||
COALESCE(SapEntitySet, ''),
|
COALESCE(SapEntitySet, ''),
|
||||||
COALESCE(SapEntitySetsCache, ''),
|
COALESCE(SapEntitySetsCache, ''),
|
||||||
@@ -306,6 +334,28 @@ CREATE TABLE IF NOT EXISTS CentralSalesRecords (
|
|||||||
cmd.ExecuteNonQuery();
|
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)
|
private static void SeedIfEmpty(AppDbContext db)
|
||||||
{
|
{
|
||||||
if (db.HanaServers.Any())
|
if (db.HanaServers.Any())
|
||||||
@@ -337,7 +387,10 @@ CREATE TABLE IF NOT EXISTS CentralSalesRecords (
|
|||||||
DateFilter = "2025-01-01",
|
DateFilter = "2025-01-01",
|
||||||
TimerHour = 3,
|
TimerHour = 3,
|
||||||
TimerMinute = 0,
|
TimerMinute = 0,
|
||||||
TimerEnabled = true
|
TimerEnabled = true,
|
||||||
|
DebugLoggingEnabled = false,
|
||||||
|
LocalSiteExportFolder = "",
|
||||||
|
LocalConsolidatedExportFolder = ""
|
||||||
});
|
});
|
||||||
|
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
|
|||||||
@@ -60,12 +60,12 @@ public class ExportOrchestrationService
|
|||||||
await _consolidatedExportService.ExportAsync(consolidatedRecords);
|
await _consolidatedExportService.ExportAsync(consolidatedRecords);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ExportSiteByIdAsync(int siteId)
|
public async Task<SiteExportResult?> ExportSiteByIdAsync(int siteId)
|
||||||
{
|
{
|
||||||
using var db = await _dbFactory.CreateDbContextAsync();
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
|
var site = await db.Sites.Include(s => s.HanaServer).FirstOrDefaultAsync(s => s.Id == siteId);
|
||||||
if (site is null) return;
|
if (site is null) return null;
|
||||||
await ExportSiteAsync(site);
|
return await ExportSiteAsync(site);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SiteExportResult?> ExportSiteAsync(Site site)
|
private async Task<SiteExportResult?> ExportSiteAsync(Site site)
|
||||||
|
|||||||
@@ -5,20 +5,48 @@ namespace TrafagSalesExporter.Services;
|
|||||||
|
|
||||||
public class HanaQueryService : IHanaQueryService
|
public class HanaQueryService : IHanaQueryService
|
||||||
{
|
{
|
||||||
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
|
|
||||||
|
public HanaQueryService(IAppEventLogService appEventLogService)
|
||||||
|
{
|
||||||
|
_appEventLogService = appEventLogService;
|
||||||
|
}
|
||||||
|
|
||||||
public List<SalesRecord> GetSalesRecords(HanaServer server,
|
public List<SalesRecord> GetSalesRecords(HanaServer server,
|
||||||
string schema, string tsc, string land, string dateFilter)
|
string schema, string tsc, string land, string dateFilter)
|
||||||
{
|
{
|
||||||
var connectionString = server.BuildConnectionString();
|
var connectionString = server.BuildConnectionString();
|
||||||
var result = new List<SalesRecord>();
|
var result = new List<SalesRecord>();
|
||||||
|
|
||||||
using var connection = new HanaConnection(connectionString);
|
try
|
||||||
connection.Open();
|
{
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Verbindungsaufbau gestartet", land: land,
|
||||||
|
details: $"Server={server.GetConnectionStringPreview()} | Schema={schema} | TSC={tsc}").GetAwaiter().GetResult();
|
||||||
|
|
||||||
var invoiceQuery = GetInvoiceQuery(schema, tsc, dateFilter);
|
using var connection = new HanaConnection(connectionString);
|
||||||
var creditNoteQuery = GetCreditNoteQuery(schema, tsc, dateFilter);
|
connection.Open();
|
||||||
|
|
||||||
result.AddRange(ReadRecords(connection, invoiceQuery, land));
|
_appEventLogService.WriteAsync("HANA", "Verbindung erfolgreich", land: land,
|
||||||
result.AddRange(ReadRecords(connection, creditNoteQuery, 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)
|
foreach (var record in result)
|
||||||
{
|
{
|
||||||
@@ -43,6 +71,8 @@ public class HanaQueryService : IHanaQueryService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Verbindungstest gestartet",
|
||||||
|
details: testResult.ConnectionStringPreview).GetAwaiter().GetResult();
|
||||||
var connectionString = server.BuildConnectionString();
|
var connectionString = server.BuildConnectionString();
|
||||||
using var connection = new HanaConnection(connectionString);
|
using var connection = new HanaConnection(connectionString);
|
||||||
connection.Open();
|
connection.Open();
|
||||||
@@ -53,6 +83,8 @@ public class HanaQueryService : IHanaQueryService
|
|||||||
|
|
||||||
testResult.Success = true;
|
testResult.Success = true;
|
||||||
testResult.Stage = "OK";
|
testResult.Stage = "OK";
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Verbindungstest erfolgreich",
|
||||||
|
details: testResult.ConnectionStringPreview).GetAwaiter().GetResult();
|
||||||
return testResult;
|
return testResult;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -60,6 +92,8 @@ public class HanaQueryService : IHanaQueryService
|
|||||||
testResult.Success = false;
|
testResult.Success = false;
|
||||||
testResult.ErrorMessage = ex.Message;
|
testResult.ErrorMessage = ex.Message;
|
||||||
testResult.ExceptionType = ex.GetType().Name;
|
testResult.ExceptionType = ex.GetType().Name;
|
||||||
|
_appEventLogService.WriteAsync("HANA", "Verbindungstest fehlgeschlagen", "Error",
|
||||||
|
details: $"{testResult.ConnectionStringPreview}{Environment.NewLine}{ex}").GetAwaiter().GetResult();
|
||||||
return testResult;
|
return testResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,12 +105,13 @@ public class HanaQueryService : IHanaQueryService
|
|||||||
connection.Open();
|
connection.Open();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land)
|
private List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land, string queryName)
|
||||||
{
|
{
|
||||||
var records = new List<SalesRecord>();
|
var records = new List<SalesRecord>();
|
||||||
|
|
||||||
using var command = new HanaCommand(query, connection);
|
using var command = new HanaCommand(query, connection);
|
||||||
using var reader = command.ExecuteReader();
|
using var reader = command.ExecuteReader();
|
||||||
|
var counter = 0;
|
||||||
|
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
@@ -109,6 +144,13 @@ public class HanaQueryService : IHanaQueryService
|
|||||||
Land = land,
|
Land = land,
|
||||||
DocumentType = reader["doc_type"]?.ToString() ?? string.Empty
|
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;
|
return records;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public interface IAppEventLogService
|
||||||
|
{
|
||||||
|
Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null);
|
||||||
|
Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null);
|
||||||
|
}
|
||||||
@@ -4,6 +4,6 @@ namespace TrafagSalesExporter.Services;
|
|||||||
|
|
||||||
public interface ICentralSalesRecordService
|
public interface ICentralSalesRecordService
|
||||||
{
|
{
|
||||||
Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records);
|
Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records, Action<string>? updateStatus = null);
|
||||||
Task<List<SalesRecord>> GetAllAsync();
|
Task<List<SalesRecord>> GetAllAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public interface IManagementCockpitService
|
||||||
|
{
|
||||||
|
Task<List<ManagementCockpitFileOption>> GetAvailableFilesAsync();
|
||||||
|
Task<ManagementCockpitResult> AnalyzeAsync(string filePath);
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ public interface ISapGatewayService
|
|||||||
{
|
{
|
||||||
Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
|
Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
|
||||||
Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
|
Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default);
|
||||||
|
Task<List<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
|
||||||
Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
|
Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TrafagSalesExporter.Data;
|
||||||
|
using TrafagSalesExporter.Models;
|
||||||
|
|
||||||
|
namespace TrafagSalesExporter.Services;
|
||||||
|
|
||||||
|
public class ManagementCockpitService : IManagementCockpitService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _dbFactory;
|
||||||
|
|
||||||
|
public ManagementCockpitService(IDbContextFactory<AppDbContext> dbFactory)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ManagementCockpitFileOption>> GetAvailableFilesAsync()
|
||||||
|
{
|
||||||
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
|
||||||
|
var exportLogs = await db.ExportLogs
|
||||||
|
.Where(x => x.Status == "OK" && !string.IsNullOrWhiteSpace(x.FilePath))
|
||||||
|
.OrderByDescending(x => x.Timestamp)
|
||||||
|
.Take(200)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var files = new Dictionary<string, ManagementCockpitFileOption>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var log in exportLogs)
|
||||||
|
{
|
||||||
|
if (!File.Exists(log.FilePath))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
files[log.FilePath] = new ManagementCockpitFileOption
|
||||||
|
{
|
||||||
|
Path = log.FilePath,
|
||||||
|
DisplayName = $"{log.Land} | {log.TSC} | {Path.GetFileName(log.FilePath)}",
|
||||||
|
LastModified = File.GetLastWriteTime(log.FilePath)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var directory in GetCandidateDirectories(settings))
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (var file in Directory.EnumerateFiles(directory, "*.xlsx", SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
if (files.ContainsKey(file))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var fileName = Path.GetFileName(file);
|
||||||
|
files[file] = new ManagementCockpitFileOption
|
||||||
|
{
|
||||||
|
Path = file,
|
||||||
|
DisplayName = fileName,
|
||||||
|
LastModified = File.GetLastWriteTime(file)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files.Values
|
||||||
|
.OrderByDescending(x => x.LastModified)
|
||||||
|
.ThenBy(x => x.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ManagementCockpitResult> AnalyzeAsync(string filePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||||
|
throw new InvalidOperationException("Die ausgewählte Excel-Datei wurde nicht gefunden.");
|
||||||
|
|
||||||
|
using var workbook = new XLWorkbook(filePath);
|
||||||
|
var worksheet = workbook.Worksheets.First();
|
||||||
|
var usedRange = worksheet.RangeUsed() ?? throw new InvalidOperationException("Die Excel-Datei enthält keine Daten.");
|
||||||
|
|
||||||
|
var headerRow = usedRange.FirstRow();
|
||||||
|
var headers = headerRow.Cells()
|
||||||
|
.Select((cell, index) => new { Index = index + 1, Header = NormalizeHeader(cell.GetString()) })
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x.Header))
|
||||||
|
.ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var rows = new List<CockpitRow>();
|
||||||
|
foreach (var row in usedRange.RowsUsed().Skip(1))
|
||||||
|
{
|
||||||
|
if (row.CellsUsed().All(c => string.IsNullOrWhiteSpace(c.GetString())))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
rows.Add(ReadRow(row, headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.Count == 0)
|
||||||
|
throw new InvalidOperationException("Die Excel-Datei enthält keine auswertbaren Datenzeilen.");
|
||||||
|
|
||||||
|
var result = new ManagementCockpitResult
|
||||||
|
{
|
||||||
|
FilePath = filePath,
|
||||||
|
Summary = BuildSummary(rows),
|
||||||
|
Findings = BuildFindings(rows),
|
||||||
|
TopCustomers = BuildTopItems(rows, x => x.CustomerName, x => x.SalesValueTotal),
|
||||||
|
TopProductGroups = BuildTopItems(rows, x => x.ProductGroup, x => x.SalesValueTotal),
|
||||||
|
TopSalesEmployees = BuildTopItems(rows, x => x.SalesResponsibleEmployee, x => x.SalesValueTotal),
|
||||||
|
DataQualityCounts = BuildDataQualityCounts(rows)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetCandidateDirectories(ExportSettings settings)
|
||||||
|
{
|
||||||
|
yield return Path.Combine(AppContext.BaseDirectory, "output");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
|
||||||
|
yield return settings.LocalSiteExportFolder.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
|
||||||
|
yield return settings.LocalConsolidatedExportFolder.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CockpitRow ReadRow(IXLRangeRow row, IReadOnlyDictionary<string, int> headers)
|
||||||
|
{
|
||||||
|
var quantity = GetDecimal(row, headers, "quantity");
|
||||||
|
var standardCost = GetDecimal(row, headers, "standardcost");
|
||||||
|
var salesValue = GetDecimal(row, headers, "salespricevalue");
|
||||||
|
var estimatedCostTotal = quantity > 0 ? quantity * standardCost : standardCost;
|
||||||
|
|
||||||
|
return new CockpitRow
|
||||||
|
{
|
||||||
|
ExtractionDate = GetDate(row, headers, "extractiondate"),
|
||||||
|
Tsc = GetText(row, headers, "tsc"),
|
||||||
|
InvoiceNumber = GetText(row, headers, "invoicenumber"),
|
||||||
|
PositionOnInvoice = GetText(row, headers, "positiononinvoice"),
|
||||||
|
Material = GetText(row, headers, "material"),
|
||||||
|
Name = GetText(row, headers, "name"),
|
||||||
|
ProductGroup = GetText(row, headers, "productgroup"),
|
||||||
|
Quantity = quantity,
|
||||||
|
SupplierNumber = GetText(row, headers, "suppliernumber"),
|
||||||
|
SupplierName = GetText(row, headers, "suppliername"),
|
||||||
|
SupplierCountry = GetText(row, headers, "suppliercountry"),
|
||||||
|
CustomerNumber = GetText(row, headers, "customernumber"),
|
||||||
|
CustomerName = GetText(row, headers, "customername"),
|
||||||
|
CustomerCountry = GetText(row, headers, "customercountry"),
|
||||||
|
CustomerIndustry = GetText(row, headers, "customerindustry"),
|
||||||
|
StandardCost = standardCost,
|
||||||
|
SalesValueTotal = salesValue,
|
||||||
|
Incoterms2020 = GetText(row, headers, "incoterms2020"),
|
||||||
|
SalesResponsibleEmployee = GetText(row, headers, "salesresponsibleemployee"),
|
||||||
|
InvoiceDate = GetDate(row, headers, "invoicedate"),
|
||||||
|
OrderDate = GetDate(row, headers, "orderdate"),
|
||||||
|
Land = GetText(row, headers, "land"),
|
||||||
|
EstimatedCostTotal = estimatedCostTotal,
|
||||||
|
EstimatedMarginTotal = salesValue - estimatedCostTotal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ManagementCockpitSummary BuildSummary(List<CockpitRow> rows)
|
||||||
|
{
|
||||||
|
var salesTotal = rows.Sum(x => x.SalesValueTotal);
|
||||||
|
var costTotal = rows.Sum(x => x.EstimatedCostTotal);
|
||||||
|
var marginTotal = rows.Sum(x => x.EstimatedMarginTotal);
|
||||||
|
var serviceRows = rows.Where(x =>
|
||||||
|
x.ProductGroup.Contains("service", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
x.Name.Contains("port", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
x.Name.Contains("zeugnis", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
|
return new ManagementCockpitSummary
|
||||||
|
{
|
||||||
|
Land = rows.Select(x => x.Land).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-",
|
||||||
|
Tsc = rows.Select(x => x.Tsc).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "-",
|
||||||
|
ExtractionDate = rows.Select(x => x.ExtractionDate).FirstOrDefault(x => x.HasValue),
|
||||||
|
RowCount = rows.Count,
|
||||||
|
InvoiceCount = rows.Select(x => x.InvoiceNumber).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||||
|
CustomerCount = rows.Select(x => x.CustomerName).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||||
|
SalesValueTotal = salesTotal,
|
||||||
|
EstimatedCostTotal = costTotal,
|
||||||
|
EstimatedMarginTotal = marginTotal,
|
||||||
|
EstimatedMarginPercent = salesTotal == 0 ? 0 : marginTotal / salesTotal * 100m,
|
||||||
|
ServiceSharePercent = salesTotal == 0 ? 0 : serviceRows.Sum(x => x.SalesValueTotal) / salesTotal * 100m,
|
||||||
|
MissingOrderDatePercent = rows.Count == 0 ? 0 : rows.Count(x => !x.OrderDate.HasValue) * 100m / rows.Count,
|
||||||
|
MissingSupplierPercent = rows.Count == 0 ? 0 : rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)) * 100m / rows.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ManagementCockpitFinding> BuildFindings(List<CockpitRow> rows)
|
||||||
|
{
|
||||||
|
var findings = new List<ManagementCockpitFinding>();
|
||||||
|
var salesTotal = rows.Sum(x => x.SalesValueTotal);
|
||||||
|
var topCustomer = rows
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x.CustomerName))
|
||||||
|
.GroupBy(x => x.CustomerName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => new { Customer = g.Key, Sales = g.Sum(x => x.SalesValueTotal) })
|
||||||
|
.OrderByDescending(x => x.Sales)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (topCustomer is not null && salesTotal > 0)
|
||||||
|
{
|
||||||
|
var share = topCustomer.Sales / salesTotal * 100m;
|
||||||
|
findings.Add(new ManagementCockpitFinding
|
||||||
|
{
|
||||||
|
Severity = share >= 50 ? "Warning" : "Info",
|
||||||
|
Title = "Kundenkonzentration",
|
||||||
|
Detail = $"{topCustomer.Customer} trägt {share:F1}% des Umsatzes."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var zeroValueRows = rows.Where(x => x.SalesValueTotal == 0 || x.StandardCost == 0).ToList();
|
||||||
|
if (zeroValueRows.Count > 0)
|
||||||
|
{
|
||||||
|
findings.Add(new ManagementCockpitFinding
|
||||||
|
{
|
||||||
|
Severity = zeroValueRows.Count >= Math.Max(3, rows.Count / 10) ? "Warning" : "Info",
|
||||||
|
Title = "Nullwerte in Kosten oder Umsatz",
|
||||||
|
Detail = $"{zeroValueRows.Count} Zeilen haben 0 in Umsatz oder Standard Cost und sollten fachlich geprüft werden."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var missingOrderDates = rows.Count(x => !x.OrderDate.HasValue);
|
||||||
|
if (missingOrderDates > 0)
|
||||||
|
{
|
||||||
|
findings.Add(new ManagementCockpitFinding
|
||||||
|
{
|
||||||
|
Severity = missingOrderDates > rows.Count / 2 ? "Warning" : "Info",
|
||||||
|
Title = "Fehlende Durchlaufzeit",
|
||||||
|
Detail = $"{missingOrderDates} von {rows.Count} Zeilen haben kein Order Date. Time-to-Invoice ist nur eingeschränkt beurteilbar."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderLeadTimes = rows
|
||||||
|
.Where(x => x.OrderDate.HasValue && x.InvoiceDate.HasValue)
|
||||||
|
.Select(x => (x.InvoiceDate!.Value - x.OrderDate!.Value).TotalDays)
|
||||||
|
.Where(x => x >= 0)
|
||||||
|
.ToList();
|
||||||
|
if (orderLeadTimes.Count > 0)
|
||||||
|
{
|
||||||
|
findings.Add(new ManagementCockpitFinding
|
||||||
|
{
|
||||||
|
Severity = orderLeadTimes.Average() > 120 ? "Warning" : "Info",
|
||||||
|
Title = "Durchschnittliche Fakturierungszeit",
|
||||||
|
Detail = $"Zwischen Order Date und Invoice Date liegen im Schnitt {orderLeadTimes.Average():F0} Tage."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var missingIndustries = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry));
|
||||||
|
if (missingIndustries > 0)
|
||||||
|
{
|
||||||
|
findings.Add(new ManagementCockpitFinding
|
||||||
|
{
|
||||||
|
Severity = missingIndustries > rows.Count / 2 ? "Warning" : "Info",
|
||||||
|
Title = "Stammdatenlücke Customer Industry",
|
||||||
|
Detail = $"{missingIndustries} Zeilen haben keine Customer Industry. Marktsegment-Analysen sind dadurch unvollständig."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var missingIncoterms = rows.Count(x => string.IsNullOrWhiteSpace(x.Incoterms2020));
|
||||||
|
if (missingIncoterms > 0)
|
||||||
|
{
|
||||||
|
findings.Add(new ManagementCockpitFinding
|
||||||
|
{
|
||||||
|
Severity = missingIncoterms > rows.Count / 2 ? "Info" : "Info",
|
||||||
|
Title = "Incoterms unvollständig",
|
||||||
|
Detail = $"{missingIncoterms} Zeilen haben keine Incoterms-Angabe."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findings.Count == 0)
|
||||||
|
{
|
||||||
|
findings.Add(new ManagementCockpitFinding
|
||||||
|
{
|
||||||
|
Severity = "Info",
|
||||||
|
Title = "Keine auffälligen Datenqualitätsprobleme",
|
||||||
|
Detail = "Die Datei ist für eine erste Standortbeurteilung konsistent genug."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ManagementCockpitTopItem> BuildTopItems(
|
||||||
|
List<CockpitRow> rows,
|
||||||
|
Func<CockpitRow, string> keySelector,
|
||||||
|
Func<CockpitRow, decimal> valueSelector)
|
||||||
|
{
|
||||||
|
var total = rows.Sum(valueSelector);
|
||||||
|
return rows
|
||||||
|
.Select(x => new { Label = keySelector(x), Value = valueSelector(x) })
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x.Label))
|
||||||
|
.GroupBy(x => x.Label, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => new ManagementCockpitTopItem
|
||||||
|
{
|
||||||
|
Label = g.Key,
|
||||||
|
Value = g.Sum(x => x.Value),
|
||||||
|
SharePercent = total == 0 ? 0 : g.Sum(x => x.Value) / total * 100m
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Value)
|
||||||
|
.Take(5)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, int> BuildDataQualityCounts(List<CockpitRow> rows)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Fehlende Supplier"] = rows.Count(x => string.IsNullOrWhiteSpace(x.SupplierName) && string.IsNullOrWhiteSpace(x.SupplierNumber)),
|
||||||
|
["Fehlende Customer Industry"] = rows.Count(x => string.IsNullOrWhiteSpace(x.CustomerIndustry)),
|
||||||
|
["Fehlende Order Date"] = rows.Count(x => !x.OrderDate.HasValue),
|
||||||
|
["Fehlende Invoice Date"] = rows.Count(x => !x.InvoiceDate.HasValue),
|
||||||
|
["Null Umsatz/Kosten"] = rows.Count(x => x.SalesValueTotal == 0 || x.StandardCost == 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeHeader(string value)
|
||||||
|
{
|
||||||
|
var chars = value
|
||||||
|
.ToLowerInvariant()
|
||||||
|
.Where(char.IsLetterOrDigit)
|
||||||
|
.ToArray();
|
||||||
|
return new string(chars);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetText(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
|
||||||
|
=> headers.TryGetValue(key, out var index) ? row.Cell(index).GetString().Trim() : string.Empty;
|
||||||
|
|
||||||
|
private static decimal GetDecimal(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
|
||||||
|
{
|
||||||
|
if (!headers.TryGetValue(key, out var index))
|
||||||
|
return 0m;
|
||||||
|
|
||||||
|
var text = row.Cell(index).GetFormattedString().Trim();
|
||||||
|
if (decimal.TryParse(text, out var direct))
|
||||||
|
return direct;
|
||||||
|
if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var invariant))
|
||||||
|
return invariant;
|
||||||
|
if (decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), out var local))
|
||||||
|
return local;
|
||||||
|
return 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime? GetDate(IXLRangeRow row, IReadOnlyDictionary<string, int> headers, string key)
|
||||||
|
{
|
||||||
|
if (!headers.TryGetValue(key, out var index))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var cell = row.Cell(index);
|
||||||
|
if (cell.DataType == XLDataType.DateTime)
|
||||||
|
return cell.GetDateTime();
|
||||||
|
|
||||||
|
var text = cell.GetString().Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (DateTime.TryParse(text, out var direct))
|
||||||
|
return direct;
|
||||||
|
if (DateTime.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var invariant))
|
||||||
|
return invariant;
|
||||||
|
if (DateTime.TryParse(text, System.Globalization.CultureInfo.GetCultureInfo("de-CH"), System.Globalization.DateTimeStyles.AssumeLocal, out var local))
|
||||||
|
return local;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CockpitRow
|
||||||
|
{
|
||||||
|
public DateTime? ExtractionDate { get; set; }
|
||||||
|
public string Tsc { get; set; } = string.Empty;
|
||||||
|
public string InvoiceNumber { get; set; } = string.Empty;
|
||||||
|
public string PositionOnInvoice { get; set; } = string.Empty;
|
||||||
|
public string Material { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string ProductGroup { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public string SupplierNumber { get; set; } = string.Empty;
|
||||||
|
public string SupplierName { get; set; } = string.Empty;
|
||||||
|
public string SupplierCountry { get; set; } = string.Empty;
|
||||||
|
public string CustomerNumber { get; set; } = string.Empty;
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
public string CustomerCountry { get; set; } = string.Empty;
|
||||||
|
public string CustomerIndustry { get; set; } = string.Empty;
|
||||||
|
public decimal StandardCost { get; set; }
|
||||||
|
public decimal SalesValueTotal { get; set; }
|
||||||
|
public string Incoterms2020 { get; set; } = string.Empty;
|
||||||
|
public string SalesResponsibleEmployee { get; set; } = string.Empty;
|
||||||
|
public DateTime? InvoiceDate { get; set; }
|
||||||
|
public DateTime? OrderDate { get; set; }
|
||||||
|
public string Land { get; set; } = string.Empty;
|
||||||
|
public decimal EstimatedCostTotal { get; set; }
|
||||||
|
public decimal EstimatedMarginTotal { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ namespace TrafagSalesExporter.Services;
|
|||||||
public class SapCompositionService : ISapCompositionService
|
public class SapCompositionService : ISapCompositionService
|
||||||
{
|
{
|
||||||
private readonly ISapGatewayService _sapGatewayService;
|
private readonly ISapGatewayService _sapGatewayService;
|
||||||
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
|
|
||||||
public SapCompositionService(ISapGatewayService sapGatewayService)
|
public SapCompositionService(ISapGatewayService sapGatewayService, IAppEventLogService appEventLogService)
|
||||||
{
|
{
|
||||||
_sapGatewayService = sapGatewayService;
|
_sapGatewayService = sapGatewayService;
|
||||||
|
_appEventLogService = appEventLogService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<SalesRecord>> BuildSalesRecordsAsync(
|
public async Task<List<SalesRecord>> BuildSalesRecordsAsync(
|
||||||
@@ -36,25 +38,38 @@ public class SapCompositionService : ISapCompositionService
|
|||||||
var sourceRows = new Dictionary<string, List<Dictionary<string, object?>>>(StringComparer.OrdinalIgnoreCase);
|
var sourceRows = new Dictionary<string, List<Dictionary<string, object?>>>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var source in activeSources)
|
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);
|
var rows = await _sapGatewayService.GetEntityRowsAsync(site.SapServiceUrl, source.EntitySet, username, password, cancellationToken);
|
||||||
sourceRows[source.Alias] = rows;
|
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]
|
var composedRows = sourceRows[primarySource.Alias]
|
||||||
.Select(r => PrefixRow(primarySource.Alias, r))
|
.Select(r => PrefixRow(primarySource.Alias, r))
|
||||||
.ToList();
|
.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))
|
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))
|
if (!sourceRows.TryGetValue(join.RightAlias, out var rightRows))
|
||||||
continue;
|
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);
|
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))
|
.Select(row => MapToSalesRecord(site, row, mappings))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
await _appEventLogService.WriteDebugAsync("SAP", "Mapping ins Zielschema beendet", site.Id, site.Land,
|
||||||
|
$"SalesRecords={result.Count} | Mappings={mappings.Count(x => x.IsActive)}");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
|
private static Dictionary<string, object?> PrefixRow(string alias, Dictionary<string, object?> row)
|
||||||
|
|||||||
@@ -9,30 +9,89 @@ public class SapGatewayService : ISapGatewayService
|
|||||||
{
|
{
|
||||||
private static readonly XNamespace AppNs = "http://www.w3.org/2007/app";
|
private static readonly XNamespace AppNs = "http://www.w3.org/2007/app";
|
||||||
private static readonly XNamespace EdmNs = "http://docs.oasis-open.org/odata/ns/edm";
|
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)
|
public async Task TestConnectionAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var client = CreateClient(username, password);
|
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();
|
response.EnsureSuccessStatusCode();
|
||||||
|
await _appEventLogService.WriteAsync("SAP", "Gateway-Verbindungstest erfolgreich", details: $"{baseUrl} | HTTP {(int)response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default)
|
public async Task<List<string>> GetEntitySetsAsync(string serviceUrl, string username, string password, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var client = CreateClient(username, password);
|
using var client = CreateClient(username, password);
|
||||||
var baseUrl = BuildServiceUri(serviceUrl);
|
var baseUrl = BuildServiceUri(serviceUrl);
|
||||||
|
await _appEventLogService.WriteAsync("SAP", "Entity-Set-Refresh gestartet", details: baseUrl);
|
||||||
|
|
||||||
var entitySets = await TryReadEntitySetsFromServiceRootAsync(client, baseUrl, cancellationToken);
|
var entitySets = await TryReadEntitySetsFromServiceRootAsync(client, baseUrl, cancellationToken);
|
||||||
if (entitySets.Count > 0)
|
if (entitySets.Count > 0)
|
||||||
|
{
|
||||||
|
await _appEventLogService.WriteAsync("SAP", "Entity Sets aus Service-Root geladen", details: $"{baseUrl} | Count={entitySets.Count}");
|
||||||
return entitySets;
|
return entitySets;
|
||||||
|
}
|
||||||
|
|
||||||
return await ReadEntitySetsFromMetadataAsync(client, baseUrl, cancellationToken);
|
var metadataEntitySets = await ReadEntitySetsFromMetadataAsync(client, baseUrl, cancellationToken);
|
||||||
|
await _appEventLogService.WriteAsync("SAP", "Entity Sets aus $metadata geladen", details: $"{baseUrl} | Count={metadataEntitySets.Count}");
|
||||||
|
return metadataEntitySets;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<string>> GetEntityFieldNamesAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var client = CreateClient(username, password);
|
||||||
|
var baseUrl = BuildServiceUri(serviceUrl);
|
||||||
|
await _appEventLogService.WriteDebugAsync("SAP", "Feldliste aus $metadata laden", details: $"{baseUrl} | EntitySet={entitySet}");
|
||||||
|
|
||||||
|
using var response = await client.GetAsync($"{baseUrl}$metadata", cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var document = XDocument.Parse(xml);
|
||||||
|
|
||||||
|
var entitySetElement = document
|
||||||
|
.Descendants()
|
||||||
|
.FirstOrDefault(x => string.Equals(x.Name.LocalName, "EntitySet", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(x.Attribute("Name")?.Value, entitySet, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var entityTypeFullName = entitySetElement?.Attribute("EntityType")?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(entityTypeFullName))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var typeName = entityTypeFullName.Split('.').LastOrDefault();
|
||||||
|
if (string.IsNullOrWhiteSpace(typeName))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var entityTypeElement = document
|
||||||
|
.Descendants()
|
||||||
|
.FirstOrDefault(x => string.Equals(x.Name.LocalName, "EntityType", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(x.Attribute("Name")?.Value, typeName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (entityTypeElement is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return entityTypeElement
|
||||||
|
.Elements()
|
||||||
|
.Where(x => string.Equals(x.Name.LocalName, "Property", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(x => x.Attribute("Name")?.Value ?? string.Empty)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default)
|
public async Task<List<Dictionary<string, object?>>> GetEntityRowsAsync(string serviceUrl, string entitySet, string username, string password, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var client = CreateClient(username, password);
|
using var client = CreateClient(username, password);
|
||||||
var requestUrl = $"{BuildServiceUri(serviceUrl)}{entitySet}?$format=json";
|
var requestUrl = $"{BuildServiceUri(serviceUrl)}{entitySet}?$format=json";
|
||||||
|
await _appEventLogService.WriteAsync("SAP", "Entity-Read gestartet", details: requestUrl);
|
||||||
using var response = await client.GetAsync(requestUrl, cancellationToken);
|
using var response = await client.GetAsync(requestUrl, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
@@ -45,6 +104,7 @@ public class SapGatewayService : ISapGatewayService
|
|||||||
return [];
|
return [];
|
||||||
|
|
||||||
var rows = new List<Dictionary<string, object?>>();
|
var rows = new List<Dictionary<string, object?>>();
|
||||||
|
var counter = 0;
|
||||||
foreach (var item in resultsNode.EnumerateArray())
|
foreach (var item in resultsNode.EnumerateArray())
|
||||||
{
|
{
|
||||||
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -54,8 +114,15 @@ public class SapGatewayService : ISapGatewayService
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows.Add(row);
|
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;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public class SiteExportService : ISiteExportService
|
|||||||
private readonly ISharePointUploadService _sharePointService;
|
private readonly ISharePointUploadService _sharePointService;
|
||||||
private readonly IRecordTransformationService _transformationService;
|
private readonly IRecordTransformationService _transformationService;
|
||||||
private readonly ICentralSalesRecordService _centralSalesRecordService;
|
private readonly ICentralSalesRecordService _centralSalesRecordService;
|
||||||
|
private readonly IAppEventLogService _appEventLogService;
|
||||||
private readonly ILogger<SiteExportService> _logger;
|
private readonly ILogger<SiteExportService> _logger;
|
||||||
|
|
||||||
public SiteExportService(
|
public SiteExportService(
|
||||||
@@ -26,6 +27,7 @@ public class SiteExportService : ISiteExportService
|
|||||||
ISharePointUploadService sharePointService,
|
ISharePointUploadService sharePointService,
|
||||||
IRecordTransformationService transformationService,
|
IRecordTransformationService transformationService,
|
||||||
ICentralSalesRecordService centralSalesRecordService,
|
ICentralSalesRecordService centralSalesRecordService,
|
||||||
|
IAppEventLogService appEventLogService,
|
||||||
ILogger<SiteExportService> logger)
|
ILogger<SiteExportService> logger)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -36,6 +38,7 @@ public class SiteExportService : ISiteExportService
|
|||||||
_sharePointService = sharePointService;
|
_sharePointService = sharePointService;
|
||||||
_transformationService = transformationService;
|
_transformationService = transformationService;
|
||||||
_centralSalesRecordService = centralSalesRecordService;
|
_centralSalesRecordService = centralSalesRecordService;
|
||||||
|
_appEventLogService = appEventLogService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,10 +55,12 @@ public class SiteExportService : ISiteExportService
|
|||||||
|
|
||||||
try
|
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();
|
using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
|
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
|
||||||
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
|
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
|
||||||
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
|
var outputDir = ResolveSiteOutputDirectory(settings, site);
|
||||||
var sourceSystem = NormalizeSourceSystem(site.SourceSystem);
|
var sourceSystem = NormalizeSourceSystem(site.SourceSystem);
|
||||||
var records = new List<SalesRecord>();
|
var records = new List<SalesRecord>();
|
||||||
string filePath;
|
string filePath;
|
||||||
@@ -74,14 +79,20 @@ public class SiteExportService : ISiteExportService
|
|||||||
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP-Feldmappings.");
|
throw new InvalidOperationException($"Standort '{site.Land}' hat keine SAP-Feldmappings.");
|
||||||
|
|
||||||
updateStatus?.Invoke("SAP Quellen laden...");
|
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);
|
records = await _sapCompositionService.BuildSalesRecordsAsync(site, sapSources, sapJoins, sapMappings, credentials.Username, credentials.Password);
|
||||||
updateStatus?.Invoke("Transformationen anwenden...");
|
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
|
var rules = await db.FieldTransformationRules
|
||||||
.Where(r => r.IsActive && r.SourceSystem == sourceSystem)
|
.Where(r => r.IsActive && r.SourceSystem == sourceSystem)
|
||||||
.OrderBy(r => r.SortOrder)
|
.OrderBy(r => r.SortOrder)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
_transformationService.Apply(records, rules);
|
_transformationService.Apply(records, rules);
|
||||||
updateStatus?.Invoke("Excel erstellen...");
|
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);
|
filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
|
||||||
log.RowCount = records.Count;
|
log.RowCount = records.Count;
|
||||||
}
|
}
|
||||||
@@ -89,10 +100,14 @@ public class SiteExportService : ISiteExportService
|
|||||||
{
|
{
|
||||||
var exportServer = BuildEffectiveServer(site, settings, sourceSystem);
|
var exportServer = BuildEffectiveServer(site, settings, sourceSystem);
|
||||||
updateStatus?.Invoke("HANA Abfrage...");
|
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(
|
records = await Task.Run(() => _hanaService.GetSalesRecords(
|
||||||
exportServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
|
exportServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
|
||||||
|
|
||||||
updateStatus?.Invoke("Transformationen anwenden...");
|
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
|
var rules = await db.FieldTransformationRules
|
||||||
.Where(r => r.IsActive && r.SourceSystem == sourceSystem)
|
.Where(r => r.IsActive && r.SourceSystem == sourceSystem)
|
||||||
.OrderBy(r => r.SortOrder)
|
.OrderBy(r => r.SortOrder)
|
||||||
@@ -100,12 +115,16 @@ public class SiteExportService : ISiteExportService
|
|||||||
_transformationService.Apply(records, rules);
|
_transformationService.Apply(records, rules);
|
||||||
|
|
||||||
updateStatus?.Invoke("Excel erstellen...");
|
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);
|
filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
|
||||||
log.RowCount = records.Count;
|
log.RowCount = records.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStatus?.Invoke("Zentrale Tabelle aktualisieren...");
|
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);
|
var fileName = Path.GetFileName(filePath);
|
||||||
|
|
||||||
@@ -115,6 +134,8 @@ public class SiteExportService : ISiteExportService
|
|||||||
!string.IsNullOrWhiteSpace(spConfig.ClientSecret))
|
!string.IsNullOrWhiteSpace(spConfig.ClientSecret))
|
||||||
{
|
{
|
||||||
updateStatus?.Invoke("SharePoint Upload...");
|
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(
|
await _sharePointService.UploadAsync(
|
||||||
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
|
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
|
||||||
spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath);
|
spConfig.SiteUrl, spConfig.ExportFolder, site.Land, filePath);
|
||||||
@@ -123,10 +144,13 @@ public class SiteExportService : ISiteExportService
|
|||||||
sw.Stop();
|
sw.Stop();
|
||||||
log.Status = "OK";
|
log.Status = "OK";
|
||||||
log.FileName = fileName;
|
log.FileName = fileName;
|
||||||
|
log.FilePath = filePath;
|
||||||
log.DurationSeconds = sw.Elapsed.TotalSeconds;
|
log.DurationSeconds = sw.Elapsed.TotalSeconds;
|
||||||
|
|
||||||
_logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s",
|
_logger.LogInformation("Export OK: {Land} ({TSC}) - {Rows} Zeilen in {Duration:F1}s",
|
||||||
site.Land, site.TSC, log.RowCount, sw.Elapsed.TotalSeconds);
|
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
|
return new SiteExportResult
|
||||||
{
|
{
|
||||||
@@ -141,9 +165,12 @@ public class SiteExportService : ISiteExportService
|
|||||||
log.Status = "Error";
|
log.Status = "Error";
|
||||||
log.ErrorMessage = ex.Message;
|
log.ErrorMessage = ex.Message;
|
||||||
log.FileName = string.Empty;
|
log.FileName = string.Empty;
|
||||||
|
log.FilePath = string.Empty;
|
||||||
log.DurationSeconds = sw.Elapsed.TotalSeconds;
|
log.DurationSeconds = sw.Elapsed.TotalSeconds;
|
||||||
|
|
||||||
_logger.LogError(ex, "Export Fehler: {Land} ({TSC})", site.Land, site.TSC);
|
_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
|
return new SiteExportResult
|
||||||
{
|
{
|
||||||
@@ -207,4 +234,12 @@ public class SiteExportService : ISiteExportService
|
|||||||
|
|
||||||
return string.Empty;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user