This commit is contained in:
2026-04-17 07:08:04 +02:00
parent ca91af9682
commit 0d3bd47f7a
34 changed files with 17503 additions and 160 deletions
@@ -1,5 +1,3 @@
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
var conn = new SqliteConnection(@"Data Source=C:\Users\koi\source\repos\Ai\TrafagSalesExporter\trafag_exporter.db"); var conn = new SqliteConnection(@"Data Source=C:\Users\koi\source\repos\Ai\TrafagSalesExporter\trafag_exporter.db");
@@ -19,8 +17,8 @@ if (string.IsNullOrWhiteSpace(sapUsername) || string.IsNullOrWhiteSpace(sapPassw
var serviceUrl = @"http://travt762.sap.trafag.com:8000/sap/opu/odata/sap/ZPOWERBI_EINKAUF_SRV/"; var serviceUrl = @"http://travt762.sap.trafag.com:8000/sap/opu/odata/sap/ZPOWERBI_EINKAUF_SRV/";
using var client = new HttpClient(); using var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(20); client.Timeout = TimeSpan.FromSeconds(20);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{sapUsername}:{sapPassword}"))); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{sapUsername}:{sapPassword}")));
foreach (var url in new[]{ serviceUrl, serviceUrl + "" }) foreach (var url in new[] { serviceUrl, serviceUrl + "" })
{ {
Console.WriteLine($"URL|{url}"); Console.WriteLine($"URL|{url}");
using var response = await client.GetAsync(url); using var response = await client.GetAsync(url);
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

@@ -1,4 +1,6 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable
@inject TrafagSalesExporter.Services.IUiTextService UiText
<MudThemeProvider Theme="_theme" /> <MudThemeProvider Theme="_theme" />
<MudPopoverProvider /> <MudPopoverProvider />
@@ -9,8 +11,18 @@
<MudAppBar Elevation="1" Color="Color.Primary"> <MudAppBar Elevation="1" Color="Color.Primary">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" <MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
OnClick="ToggleDrawer" /> OnClick="ToggleDrawer" />
<MudText Typo="Typo.h6" Class="ml-3 app-title">Trafag Finanze/Sales Management Cockpit</MudText> <MudText Typo="Typo.h6" Class="ml-3 app-title">@T("Trafag Finanze/Sales Management Cockpit", "Trafag Finance/Sales Management Cockpit")</MudText>
<MudSpacer /> <MudSpacer />
<MudSelect T="string"
Value="@UiText.CurrentLanguage"
ValueChanged="ChangeLanguage"
Dense
Variant="Variant.Outlined"
Class="mr-3"
Style="min-width:100px; color:white;">
<MudSelectItem Value="@("de")">DE</MudSelectItem>
<MudSelectItem Value="@("en")">EN</MudSelectItem>
</MudSelect>
<img src="trafag.jpg" alt="Trafag" class="app-logo" /> <img src="trafag.jpg" alt="Trafag" class="app-logo" />
</MudAppBar> </MudAppBar>
@@ -18,7 +30,7 @@
<NavMenu /> <NavMenu />
</MudDrawer> </MudDrawer>
<MudMainContent Class="pa-4"> <MudMainContent Class="pa-4" @key="UiText.CurrentLanguage">
@Body @Body
</MudMainContent> </MudMainContent>
</MudLayout> </MudLayout>
@@ -36,5 +48,27 @@
} }
}; };
protected override void OnInitialized()
{
UiText.Changed += HandleLanguageChanged;
}
private void ToggleDrawer() => _drawerOpen = !_drawerOpen; private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
private void ChangeLanguage(string language)
{
UiText.SetLanguage(language);
}
private void HandleLanguageChanged()
{
InvokeAsync(StateHasChanged);
}
private string T(string german, string english) => UiText.Text(german, english);
public void Dispose()
{
UiText.Changed -= HandleLanguageChanged;
}
} }
@@ -1,20 +1,26 @@
@inject TrafagSalesExporter.Services.IUiTextService UiText
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard"> <MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
Dashboard @T("Dashboard", "Dashboard")
</MudNavLink> </MudNavLink>
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn"> <MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
Standorte @T("Standorte", "Sites")
</MudNavLink> </MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform"> <MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
Transformationen @T("Transformationen", "Transformations")
</MudNavLink> </MudNavLink>
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics"> <MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics">
Management Cockpit @T("Management Cockpit", "Management Cockpit")
</MudNavLink> </MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings"> <MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
Settings @T("Settings", "Settings")
</MudNavLink> </MudNavLink>
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List"> <MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
Logs @T("Logs", "Logs")
</MudNavLink> </MudNavLink>
</MudNavMenu> </MudNavMenu>
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -7,32 +7,33 @@
@inject ExportOrchestrationService Orchestrator @inject ExportOrchestrationService Orchestrator
@inject TimerBackgroundService TimerService @inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IUiTextService UiText
@implements IDisposable @implements IDisposable
<PageTitle>Dashboard</PageTitle> <PageTitle>@T("Dashboard", "Dashboard")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Dashboard</MudText> <MudText Typo="Typo.h4" Class="mb-4">@T("Dashboard", "Dashboard")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4"> <MudStack Row AlignItems="AlignItems.Center" Spacing="4">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow" <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="ExportAll" Disabled="_anyRunning"> OnClick="ExportAll" Disabled="_anyRunning">
Alle exportieren @T("Alle exportieren", "Export all")
</MudButton> </MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView" <MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning"> OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
Zentrale Datei neu erzeugen @T("Zentrale Datei neu erzeugen", "Rebuild consolidated file")
</MudButton> </MudButton>
<MudText Typo="Typo.body1"> <MudText Typo="Typo.body1">
@if (TimerService.NextRun < DateTime.MaxValue) @if (TimerService.NextRun < DateTime.MaxValue)
{ {
<MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" /> <MudIcon Icon="@Icons.Material.Filled.Schedule" Size="Size.Small" Class="mr-1" />
@($"Nächster automatischer Lauf: {TimerService.NextRun:dd.MM.yyyy HH:mm}") @(string.Format(T("Naechster automatischer Lauf: {0}", "Next automatic run: {0}"), TimerService.NextRun.ToString("dd.MM.yyyy HH:mm")))
} }
else else
{ {
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" /> <MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
@("Timer deaktiviert") @T("Timer deaktiviert", "Timer disabled")
} }
</MudText> </MudText>
</MudStack> </MudStack>
@@ -40,16 +41,16 @@
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading"> <MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
<HeaderContent> <HeaderContent>
<MudTh>Land</MudTh> <MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</MudTh> <MudTh>TSC</MudTh>
<MudTh>Schema</MudTh> <MudTh>@T("Schema", "Schema")</MudTh>
<MudTh>Server</MudTh> <MudTh>@T("Server", "Server")</MudTh>
<MudTh>Status</MudTh> <MudTh>@T("Status", "Status")</MudTh>
<MudTh>Live-Status</MudTh> <MudTh>@T("Live-Status", "Live status")</MudTh>
<MudTh>Zeilen</MudTh> <MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>Letzter Lauf</MudTh> <MudTh>@T("Letzter Lauf", "Last run")</MudTh>
<MudTh>Dauer</MudTh> <MudTh>@T("Dauer", "Duration")</MudTh>
<MudTh>Aktion</MudTh> <MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Land</MudTd> <MudTd>@context.Land</MudTd>
@@ -106,7 +107,7 @@
StartIcon="@Icons.Material.Filled.OpenInNew" StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenExportFile(context)" OnClick="() => OpenExportFile(context)"
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))"> Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
Excel öffnen @T("Excel oeffnen", "Open Excel")
</MudButton> </MudButton>
</MudStack> </MudStack>
</MudTd> </MudTd>
@@ -114,14 +115,14 @@
</MudTable> </MudTable>
<MudPaper Class="pa-4 mt-4" Elevation="1"> <MudPaper Class="pa-4 mt-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">Zentrale Datei</MudText> <MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Datei", "Consolidated file")</MudText>
<MudTable Items="_consolidatedRows" Dense Hover Striped> <MudTable Items="_consolidatedRows" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>Datei</MudTh> <MudTh>@T("Datei", "File")</MudTh>
<MudTh>Pfad</MudTh> <MudTh>Pfad</MudTh>
<MudTh>Letzte Änderung</MudTh> <MudTh>Letzte Änderung</MudTh>
<MudTh>Status</MudTh> <MudTh>@T("Status", "Status")</MudTh>
<MudTh>Aktion</MudTh> <MudTh>@T("Aktion", "Action")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Label</MudTd> <MudTd>@context.Label</MudTd>
@@ -143,12 +144,12 @@
StartIcon="@Icons.Material.Filled.OpenInNew" StartIcon="@Icons.Material.Filled.OpenInNew"
OnClick="() => OpenFile(context.FilePath)" OnClick="() => OpenFile(context.FilePath)"
Disabled="@(!context.HasOpenableFile)"> Disabled="@(!context.HasOpenableFile)">
Excel öffnen @T("Excel oeffnen", "Open Excel")
</MudButton> </MudButton>
</MudTd> </MudTd>
</RowTemplate> </RowTemplate>
<NoRecordsContent> <NoRecordsContent>
<MudText Typo="Typo.caption">Keine zentrale Excel-Datei gefunden.</MudText> <MudText Typo="Typo.caption">@T("Keine zentrale Excel-Datei gefunden.", "No consolidated Excel file found.")</MudText>
</NoRecordsContent> </NoRecordsContent>
</MudTable> </MudTable>
</MudPaper> </MudPaper>
@@ -229,7 +230,7 @@
StateHasChanged(); StateHasChanged();
}); });
}); });
Snackbar.Add("Export für alle Standorte gestartet", Severity.Info); Snackbar.Add(T("Export fuer alle Standorte gestartet", "Export started for all sites"), Severity.Info);
} }
private async Task ExportConsolidatedOnly() private async Task ExportConsolidatedOnly()
@@ -249,15 +250,15 @@
if (!string.IsNullOrWhiteSpace(filePath)) if (!string.IsNullOrWhiteSpace(filePath))
{ {
await InvokeAsync(() => await InvokeAsync(() =>
Snackbar.Add($"Zentrale Datei erzeugt: {filePath}", Severity.Success)); Snackbar.Add(string.Format(T("Zentrale Datei erzeugt: {0}", "Consolidated file created: {0}"), filePath), Severity.Success));
} }
else else
{ {
await InvokeAsync(() => await InvokeAsync(() =>
Snackbar.Add("Zentrale Datei konnte nicht erzeugt werden.", Severity.Warning)); Snackbar.Add(T("Zentrale Datei konnte nicht erzeugt werden.", "Consolidated file could not be created."), Severity.Warning));
} }
}); });
Snackbar.Add("Zentrale Datei wird erzeugt", Severity.Info); Snackbar.Add(T("Zentrale Datei wird erzeugt", "Building consolidated file"), Severity.Info);
} }
private void ExportSingle(int siteId) private void ExportSingle(int siteId)
@@ -277,15 +278,15 @@
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath)) if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
{ {
await InvokeAsync(() => await InvokeAsync(() =>
Snackbar.Add($"Export gespeichert: {result.FilePath}", Severity.Success)); Snackbar.Add(string.Format(T("Export gespeichert: {0}", "Export saved: {0}"), result.FilePath), Severity.Success));
} }
else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage)) else if (result?.Log.Status == "Error" && !string.IsNullOrWhiteSpace(result.Log.ErrorMessage))
{ {
await InvokeAsync(() => await InvokeAsync(() =>
Snackbar.Add($"Export fehlgeschlagen: {result.Log.ErrorMessage}", Severity.Error)); Snackbar.Add(string.Format(T("Export fehlgeschlagen: {0}", "Export failed: {0}"), result.Log.ErrorMessage), Severity.Error));
} }
}); });
Snackbar.Add("Export gestartet", Severity.Info); Snackbar.Add(T("Export gestartet", "Export started"), Severity.Info);
} }
private async void HandleStatusChanged() private async void HandleStatusChanged()
@@ -322,7 +323,7 @@
{ {
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
{ {
Snackbar.Add("Exportdatei nicht gefunden.", Severity.Warning); Snackbar.Add(T("Exportdatei nicht gefunden.", "Export file not found."), Severity.Warning);
return; return;
} }
@@ -336,7 +337,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"Datei konnte nicht geöffnet werden: {ex.Message}", Severity.Error); Snackbar.Add(string.Format(T("Datei konnte nicht geoeffnet werden: {0}", "Could not open file: {0}"), ex.Message), Severity.Error);
} }
} }
@@ -463,3 +464,7 @@
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath); public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath);
} }
} }
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
+28 -23
View File
@@ -4,46 +4,47 @@
@inject IDbContextFactory<AppDbContext> DbFactory @inject IDbContextFactory<AppDbContext> DbFactory
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@inject TrafagSalesExporter.Services.IUiTextService UiText
<PageTitle>Logs</PageTitle> <PageTitle>@T("Logs", "Logs")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Export Logs</MudText> <MudText Typo="Typo.h4" Class="mb-4">@T("Export Logs", "Export Logs")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudStack Row AlignItems="AlignItems.Center" Spacing="3"> <MudStack Row AlignItems="AlignItems.Center" Spacing="3">
<MudSelect @bind-Value="_filterLand" Label="Land" Clearable Dense Style="max-width:200px;"> <MudSelect @bind-Value="_filterLand" Label="@T("Land", "Country")" Clearable Dense Style="max-width:200px;">
@foreach (var land in _availableLands) @foreach (var land in _availableLands)
{ {
<MudSelectItem Value="@land">@land</MudSelectItem> <MudSelectItem Value="@land">@land</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect @bind-Value="_filterStatus" Label="Status" Clearable Dense Style="max-width:150px;"> <MudSelect @bind-Value="_filterStatus" Label="@T("Status", "Status")" Clearable Dense Style="max-width:150px;">
<MudSelectItem Value="@("OK")">OK</MudSelectItem> <MudSelectItem Value="@("OK")">OK</MudSelectItem>
<MudSelectItem Value="@("Error")">Error</MudSelectItem> <MudSelectItem Value="@("Error")">Error</MudSelectItem>
</MudSelect> </MudSelect>
<MudDatePicker @bind-Date="_filterDate" Label="Datum" Clearable Dense Style="max-width:200px;" /> <MudDatePicker @bind-Date="_filterDate" Label="@T("Datum", "Date")" Clearable Dense Style="max-width:200px;" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter" <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter"
StartIcon="@Icons.Material.Filled.FilterAlt"> StartIcon="@Icons.Material.Filled.FilterAlt">
Filtern @T("Filtern", "Filter")
</MudButton> </MudButton>
<MudSpacer /> <MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs" <MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
StartIcon="@Icons.Material.Filled.DeleteSweep"> StartIcon="@Icons.Material.Filled.DeleteSweep">
Alte Logs löschen @T("Alte Logs loeschen", "Delete old logs")
</MudButton> </MudButton>
</MudStack> </MudStack>
</MudPaper> </MudPaper>
<MudTable Items="_logs" Dense Hover Striped Loading="_loading"> <MudTable Items="_logs" Dense Hover Striped Loading="_loading">
<HeaderContent> <HeaderContent>
<MudTh>Zeitpunkt</MudTh> <MudTh>@T("Zeitpunkt", "Timestamp")</MudTh>
<MudTh>Land</MudTh> <MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</MudTh> <MudTh>TSC</MudTh>
<MudTh>Status</MudTh> <MudTh>@T("Status", "Status")</MudTh>
<MudTh>Zeilen</MudTh> <MudTh>@T("Zeilen", "Rows")</MudTh>
<MudTh>Dauer</MudTh> <MudTh>@T("Dauer", "Duration")</MudTh>
<MudTh>Dateiname</MudTh> <MudTh>@T("Dateiname", "File name")</MudTh>
<MudTh>Fehler</MudTh> <MudTh>@T("Fehler", "Error")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd> <MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
@@ -75,15 +76,15 @@
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
<MudText Typo="Typo.h5" Class="mt-6 mb-2">Technische Logs</MudText> <MudText Typo="Typo.h5" Class="mt-6 mb-2">@T("Technische Logs", "Technical logs")</MudText>
<MudTable Items="_appLogs" Dense Hover Striped Loading="_loading"> <MudTable Items="_appLogs" Dense Hover Striped Loading="_loading">
<HeaderContent> <HeaderContent>
<MudTh>Zeitpunkt</MudTh> <MudTh>@T("Zeitpunkt", "Timestamp")</MudTh>
<MudTh>Level</MudTh> <MudTh>Level</MudTh>
<MudTh>Kategorie</MudTh> <MudTh>@T("Kategorie", "Category")</MudTh>
<MudTh>Land</MudTh> <MudTh>@T("Land", "Country")</MudTh>
<MudTh>Meldung</MudTh> <MudTh>@T("Meldung", "Message")</MudTh>
<MudTh>Details</MudTh> <MudTh>Details</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
@@ -158,9 +159,9 @@
private async Task DeleteOldLogs() private async Task DeleteOldLogs()
{ {
var result = await DialogService.ShowMessageBox( var result = await DialogService.ShowMessageBox(
"Alte Logs löschen", T("Alte Logs loeschen", "Delete old logs"),
"Logs älter als 90 Tage löschen?", T("Logs aelter als 90 Tage loeschen?", "Delete logs older than 90 days?"),
yesText: "Löschen", cancelText: "Abbrechen"); yesText: T("Loeschen", "Delete"), cancelText: T("Abbrechen", "Cancel"));
if (result != true) return; if (result != true) return;
@@ -170,6 +171,10 @@
db.ExportLogs.RemoveRange(oldLogs); db.ExportLogs.RemoveRange(oldLogs);
var count = await db.SaveChangesAsync(); var count = await db.SaveChangesAsync();
await LoadLogsAsync(); await LoadLogsAsync();
Snackbar.Add($"{oldLogs.Count} alte Logs gelöscht", Severity.Info); Snackbar.Add(string.Format(T("{0} alte Logs geloescht", "{0} old logs deleted"), oldLogs.Count), Severity.Info);
} }
} }
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -3,15 +3,16 @@
@using TrafagSalesExporter.Services @using TrafagSalesExporter.Services
@inject IManagementCockpitService CockpitService @inject IManagementCockpitService CockpitService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>Management Cockpit</PageTitle> <PageTitle>@T("Management Cockpit", "Management Cockpit")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Management Cockpit</MudText> <MudText Typo="Typo.h4" Class="mb-4">@T("Management Cockpit", "Management Cockpit")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid> <MudGrid>
<MudItem xs="12" md="8"> <MudItem xs="12" md="8">
<MudSelect T="string" @bind-Value="_selectedFilePath" Label="Vorhandene Excel-Datei" Dense> <MudSelect T="string" @bind-Value="_selectedFilePath" Label="@T("Vorhandene Excel-Datei", "Available Excel file")" Dense>
@foreach (var file in _files) @foreach (var file in _files)
{ {
<MudSelectItem Value="@file.Path">@file.DisplayName</MudSelectItem> <MudSelectItem Value="@file.Path">@file.DisplayName</MudSelectItem>
@@ -22,11 +23,11 @@
<MudStack Row Spacing="2"> <MudStack Row Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ReloadFiles" <MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ReloadFiles"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loadingFiles"> StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loadingFiles">
Dateien laden @T("Dateien laden", "Load files")
</MudButton> </MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Analyze" <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Analyze"
StartIcon="@Icons.Material.Filled.Analytics" Disabled="_analyzing || string.IsNullOrWhiteSpace(_selectedFilePath)"> StartIcon="@Icons.Material.Filled.Analytics" Disabled="_analyzing || string.IsNullOrWhiteSpace(_selectedFilePath)">
@(_analyzing ? "Analysiere..." : "Cockpit erzeugen") @(_analyzing ? T("Analysiere...", "Analyzing...") : T("Cockpit erzeugen", "Build cockpit"))
</MudButton> </MudButton>
</MudStack> </MudStack>
</MudItem> </MudItem>
@@ -34,13 +35,13 @@
</MudPaper> </MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">Zentrale Roh-Auswertung</MudText> <MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Roh-Auswertung", "Central raw analysis")</MudText>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-3"> <MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-3">
Diese Sicht arbeitet direkt auf `CentralSalesRecords` und zeigt nur fachlich neutrale Rohkennzahlen. Kein Intercompany-Filter, keine CHF-Umrechnung, kein Budget, keine Spartenlogik. @T("Diese Sicht arbeitet direkt auf `CentralSalesRecords` und zeigt nur fachlich neutrale Rohkennzahlen. Kein Intercompany-Filter, keine CHF-Umrechnung, kein Budget, keine Spartenlogik.", "This view works directly on `CentralSalesRecords` and shows only neutral raw metrics. No intercompany filter, no CHF conversion, no budget, no divisional logic.")
</MudAlert> </MudAlert>
<MudGrid> <MudGrid>
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudSelect T="int" @bind-Value="_selectedCentralYear" Label="Jahr" Dense> <MudSelect T="int" @bind-Value="_selectedCentralYear" Label='@T("Jahr", "Year")' Dense>
@foreach (var year in _centralYears) @foreach (var year in _centralYears)
{ {
<MudSelectItem Value="@year">@year</MudSelectItem> <MudSelectItem Value="@year">@year</MudSelectItem>
@@ -48,7 +49,7 @@
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudSelect T="int?" @bind-Value="_selectedCentralMonth" Label="Monat (optional)" Dense Clearable> <MudSelect T="int?" @bind-Value="_selectedCentralMonth" Label='@T("Monat (optional)", "Month (optional)")' Dense Clearable>
@foreach (var month in Enumerable.Range(1, 12)) @foreach (var month in Enumerable.Range(1, 12))
{ {
<MudSelectItem Value="@((int?)month)">@($"{month:D2}")</MudSelectItem> <MudSelectItem Value="@((int?)month)">@($"{month:D2}")</MudSelectItem>
@@ -58,7 +59,7 @@
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="AnalyzeCentral" <MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="AnalyzeCentral"
StartIcon="@Icons.Material.Filled.QueryStats" Disabled="_analyzingCentral || _selectedCentralYear == 0"> StartIcon="@Icons.Material.Filled.QueryStats" Disabled="_analyzingCentral || _selectedCentralYear == 0">
@(_analyzingCentral ? "Analysiere..." : "Zentrale Auswertung laden") @(_analyzingCentral ? T("Analysiere...", "Analyzing...") : T("Zentrale Auswertung laden", "Load central analysis"))
</MudButton> </MudButton>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
@@ -67,14 +68,14 @@
@if (_result is not null) @if (_result is not null)
{ {
<MudGrid Class="mb-4"> <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">@T("Land", "Country")</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">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">@T("Umsatz", "Sales")</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> <MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Geschaetzte Marge", "Estimated margin")</MudText><MudText Typo="Typo.h6">@($"{_result.Summary.EstimatedMarginPercent:F1}%")</MudText></MudPaper></MudItem>
</MudGrid> </MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Management Aussagen</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Management Aussagen", "Management statements")</MudText>
@foreach (var finding in _result.Findings) @foreach (var finding in _result.Findings)
{ {
<MudAlert Severity="@MapSeverity(finding.Severity)" Dense Variant="Variant.Outlined" Class="mb-2"> <MudAlert Severity="@MapSeverity(finding.Severity)" Dense Variant="Variant.Outlined" Class="mb-2">
@@ -86,7 +87,7 @@
<MudGrid Class="mb-4"> <MudGrid Class="mb-4">
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Top Kunden</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Top Kunden", "Top customers")</MudText>
@foreach (var item in _result.TopCustomers) @foreach (var item in _result.TopCustomers)
{ {
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText> <MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
@@ -95,7 +96,7 @@
</MudItem> </MudItem>
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Top Produktgruppen</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Top Produktgruppen", "Top product groups")</MudText>
@foreach (var item in _result.TopProductGroups) @foreach (var item in _result.TopProductGroups)
{ {
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText> <MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
@@ -104,7 +105,7 @@
</MudItem> </MudItem>
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Top Sales Owner</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Top Sales Owner", "Top sales owner")</MudText>
@foreach (var item in _result.TopSalesEmployees) @foreach (var item in _result.TopSalesEmployees)
{ {
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText> <MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
@@ -114,7 +115,7 @@
</MudGrid> </MudGrid>
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Datenqualität</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Datenqualitaet", "Data quality")</MudText>
@foreach (var entry in _result.DataQualityCounts.OrderByDescending(x => x.Value)) @foreach (var entry in _result.DataQualityCounts.OrderByDescending(x => x.Value))
{ {
<MudText Typo="Typo.body2">@($"{entry.Key}: {entry.Value}")</MudText> <MudText Typo="Typo.body2">@($"{entry.Key}: {entry.Value}")</MudText>
@@ -125,16 +126,16 @@
@if (_centralResult is not null) @if (_centralResult is not null)
{ {
<MudGrid Class="mb-4"> <MudGrid Class="mb-4">
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Zeilen</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.RowCount.ToString("N0")</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Zeilen", "Rows")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.RowCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Rechnungen</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.InvoiceCount.ToString("N0")</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Rechnungen", "Invoices")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.InvoiceCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Standorte</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.SiteCount.ToString("N0")</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Standorte", "Sites")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.SiteCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Länder</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CountryCount.ToString("N0")</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Laender", "Countries")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CountryCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Währungen</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CurrencyCount.ToString("N0")</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Waehrungen", "Currencies")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CurrencyCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Periode</MudText><MudText Typo="Typo.h6">@BuildPeriodLabel(_centralResult)</MudText></MudPaper></MudItem> <MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Periode", "Period")</MudText><MudText Typo="Typo.h6">@BuildPeriodLabel(_centralResult)</MudText></MudPaper></MudItem>
</MudGrid> </MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Hinweise</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Hinweise", "Notes")</MudText>
@foreach (var notice in _centralResult.Notices) @foreach (var notice in _centralResult.Notices)
{ {
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert> <MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert>
@@ -144,13 +145,13 @@
<MudGrid Class="mb-4"> <MudGrid Class="mb-4">
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Jahresumsatz 2025/2026</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Jahresumsatz 2025/2026", "Yearly sales 2025/2026")</MudText>
<MudTable Items="_centralResult.YearlyTotals" Dense Hover Striped> <MudTable Items="_centralResult.YearlyTotals" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>Jahr</MudTh> <MudTh>@T("Jahr", "Year")</MudTh>
<MudTh>Währung</MudTh> <MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>Umsatz</MudTh> <MudTh>@T("Umsatz", "Sales")</MudTh>
<MudTh>Zeilen</MudTh> <MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Year</MudTd> <MudTd>@context.Year</MudTd>
@@ -163,13 +164,13 @@
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Monatsumsatz</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Monatsumsatz", "Monthly sales")</MudText>
<MudTable Items="_centralResult.MonthlyTotals" Dense Hover Striped> <MudTable Items="_centralResult.MonthlyTotals" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>Monat</MudTh> <MudTh>@T("Monat", "Month")</MudTh>
<MudTh>Währung</MudTh> <MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>Umsatz</MudTh> <MudTh>@T("Umsatz", "Sales")</MudTh>
<MudTh>Zeilen</MudTh> <MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Label</MudTd> <MudTd>@context.Label</MudTd>
@@ -185,13 +186,13 @@
<MudGrid Class="mb-4"> <MudGrid Class="mb-4">
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Tagesumsatz im ausgewählten Monat</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Tagesumsatz im ausgewaehlten Monat", "Daily sales in selected month")</MudText>
<MudTable Items="_centralResult.DailyTotals" Dense Hover Striped> <MudTable Items="_centralResult.DailyTotals" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>Tag</MudTh> <MudTh>@T("Tag", "Day")</MudTh>
<MudTh>Währung</MudTh> <MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>Umsatz</MudTh> <MudTh>@T("Umsatz", "Sales")</MudTh>
<MudTh>Zeilen</MudTh> <MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Label</MudTd> <MudTd>@context.Label</MudTd>
@@ -200,20 +201,20 @@
<MudTd>@context.RowCount.ToString("N0")</MudTd> <MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate> </RowTemplate>
<NoRecordsContent> <NoRecordsContent>
<MudText Typo="Typo.caption">r die Tagessicht bitte zusätzlich einen Monat wählen.</MudText> <MudText Typo="Typo.caption">@T("Fuer die Tagessicht bitte zusaetzlich einen Monat waehlen.", "Please select a month as well for the daily view.")</MudText>
</NoRecordsContent> </NoRecordsContent>
</MudTable> </MudTable>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Umsatz nach Quelle</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Umsatz nach Quelle", "Sales by source")</MudText>
<MudTable Items="_centralResult.SourceSystemTotals" Dense Hover Striped> <MudTable Items="_centralResult.SourceSystemTotals" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>Quelle</MudTh> <MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>Währung</MudTh> <MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>Umsatz</MudTh> <MudTh>@T("Umsatz", "Sales")</MudTh>
<MudTh>Rechnungen</MudTh> <MudTh>@T("Rechnungen", "Invoices")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Label</MudTd> <MudTd>@context.Label</MudTd>
@@ -227,14 +228,14 @@
</MudGrid> </MudGrid>
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">Umsatz nach Land</MudText> <MudText Typo="Typo.h6" Class="mb-2">@T("Umsatz nach Land", "Sales by country")</MudText>
<MudTable Items="_centralResult.CountryTotals" Dense Hover Striped> <MudTable Items="_centralResult.CountryTotals" Dense Hover Striped>
<HeaderContent> <HeaderContent>
<MudTh>Land</MudTh> <MudTh>@T("Land", "Country")</MudTh>
<MudTh>Währung</MudTh> <MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>Umsatz</MudTh> <MudTh>@T("Umsatz", "Sales")</MudTh>
<MudTh>Rechnungen</MudTh> <MudTh>@T("Rechnungen", "Invoices")</MudTh>
<MudTh>Zeilen</MudTh> <MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Label</MudTd> <MudTd>@context.Label</MudTd>
@@ -298,7 +299,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"Cockpit konnte nicht erzeugt werden: {ex.Message}", Severity.Error); Snackbar.Add(string.Format(T("Cockpit konnte nicht erzeugt werden: {0}", "Could not build cockpit: {0}"), ex.Message), Severity.Error);
} }
finally finally
{ {
@@ -318,7 +319,7 @@
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"Zentrale Auswertung konnte nicht erzeugt werden: {ex.Message}", Severity.Error); Snackbar.Add(string.Format(T("Zentrale Auswertung konnte nicht erzeugt werden: {0}", "Could not build central analysis: {0}"), ex.Message), Severity.Error);
} }
finally finally
{ {
@@ -341,3 +342,7 @@
return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}"; return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}";
} }
} }
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -9,6 +9,7 @@
@inject IHanaQueryService HanaService @inject IHanaQueryService HanaService
@inject ISapGatewayService SapGatewayService @inject ISapGatewayService SapGatewayService
@inject IConfigTransferService ConfigTransferService @inject IConfigTransferService ConfigTransferService
@inject IExchangeRateImportService ExchangeRateImportService
@inject IJSRuntime JS @inject IJSRuntime JS
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -51,6 +52,11 @@
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" /> <MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" />
</MudItem> </MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.CentralExportFolder"
Label="Central Export Folder"
HelperText="Optional. Wenn leer, wird weiterhin Export Folder/Alle verwendet." />
</MudItem>
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" /> <MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
</MudItem> </MudItem>
@@ -80,6 +86,15 @@
</MudButton> </MudButton>
</MudStack> </MudStack>
</MudItem> </MudItem>
@if (!string.IsNullOrWhiteSpace(_sharePointTestPreview))
{
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mt-3">
<div><b>Test Preview</b></div>
<div style="white-space: pre-wrap">@_sharePointTestPreview</div>
</MudAlert>
</MudItem>
}
</MudGrid> </MudGrid>
</MudPaper> </MudPaper>
@@ -151,6 +166,70 @@
</MudGrid> </MudGrid>
</MudPaper> </MudPaper>
<MudText Typo="Typo.h5" Class="mb-2">Wechselkurse</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudText Typo="Typo.body2" Class="mb-3">
Diese Kurstabelle wird von der Transformation <b>ConvertCurrency</b> verwendet. Gleiche Waehrung rechnet automatisch mit Faktor 1.
</MudText>
<MudStack Row Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="AddExchangeRate"
StartIcon="@Icons.Material.Filled.Add">
Kurs hinzufuegen
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshEcbRates"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingExchangeRates">
@(_refreshingExchangeRates ? "Aktualisiere ECB-Kurse..." : "Refresh Kurse")
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExchangeRates"
StartIcon="@Icons.Material.Filled.Save">
Kurse speichern
</MudButton>
</MudStack>
<MudTable Items="_exchangeRates" Hover="true" Breakpoint="Breakpoint.Md">
<HeaderContent>
<MudTh>Von</MudTh>
<MudTh>Nach</MudTh>
<MudTh>Kurs</MudTh>
<MudTh>Gueltig ab</MudTh>
<MudTh>Gueltig bis</MudTh>
<MudTh>Notiz</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudTextField @bind-Value="context.FromCurrency" Immediate="true" />
</MudTd>
<MudTd>
<MudTextField @bind-Value="context.ToCurrency" Immediate="true" />
</MudTd>
<MudTd>
<MudNumericField T="decimal" @bind-Value="context.Rate" Immediate="true" />
</MudTd>
<MudTd>
<MudDatePicker Date="context.ValidFrom"
DateChanged="@(value => context.ValidFrom = value ?? context.ValidFrom)"
Editable="true" />
</MudTd>
<MudTd>
<MudDatePicker Date="context.ValidTo"
DateChanged="@(value => context.ValidTo = value)"
Editable="true"
Clearable="true" />
</MudTd>
<MudTd>
<MudTextField @bind-Value="context.Notes" Immediate="true" />
</MudTd>
<MudTd>
<MudCheckBox @bind-Value="context.IsActive" />
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="@(() => RemoveExchangeRate(context))" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@* Export Settings *@ @* Export Settings *@
<MudText Typo="Typo.h5" Class="mb-2">Export Einstellungen</MudText> <MudText Typo="Typo.h5" Class="mb-2">Export Einstellungen</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1"> <MudPaper Class="pa-4 mb-6" Elevation="1">
@@ -210,6 +289,9 @@
private bool _includeSecretsInExport; private bool _includeSecretsInExport;
private bool _exportingConfig; private bool _exportingConfig;
private bool _importingConfig; private bool _importingConfig;
private bool _refreshingExchangeRates;
private string _sharePointTestPreview = string.Empty;
private List<CurrencyExchangeRate> _exchangeRates = [];
private readonly HashSet<string> _testingSystems = []; private readonly HashSet<string> _testingSystems = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -217,6 +299,11 @@
using var db = await DbFactory.CreateDbContextAsync(); using var db = await DbFactory.CreateDbContextAsync();
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig(); _spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
_exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(); _exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
_exchangeRates = await db.CurrencyExchangeRates
.OrderBy(x => x.FromCurrency)
.ThenBy(x => x.ToCurrency)
.ThenByDescending(x => x.ValidFrom)
.ToListAsync();
} }
private async Task SaveSharePoint() private async Task SaveSharePoint()
@@ -231,6 +318,7 @@
{ {
existing.SiteUrl = _spConfig.SiteUrl; existing.SiteUrl = _spConfig.SiteUrl;
existing.ExportFolder = _spConfig.ExportFolder; existing.ExportFolder = _spConfig.ExportFolder;
existing.CentralExportFolder = _spConfig.CentralExportFolder;
existing.TenantId = _spConfig.TenantId; existing.TenantId = _spConfig.TenantId;
existing.ClientId = _spConfig.ClientId; existing.ClientId = _spConfig.ClientId;
existing.ClientSecret = _spConfig.ClientSecret; existing.ClientSecret = _spConfig.ClientSecret;
@@ -244,8 +332,15 @@
_testingSp = true; _testingSp = true;
try try
{ {
var tenantId = NormalizeConfigValue(_spConfig.TenantId);
var clientId = NormalizeConfigValue(_spConfig.ClientId);
var clientSecret = NormalizeConfigValue(_spConfig.ClientSecret);
var siteUrl = NormalizeConfigValue(_spConfig.SiteUrl);
_sharePointTestPreview = BuildSharePointTestPreview(tenantId, clientId, clientSecret, siteUrl);
await SpService.TestConnectionAsync( await SpService.TestConnectionAsync(
_spConfig.TenantId, _spConfig.ClientId, _spConfig.ClientSecret, _spConfig.SiteUrl); tenantId, clientId, clientSecret, siteUrl);
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success); Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
} }
catch (Exception ex) catch (Exception ex)
@@ -287,6 +382,76 @@
Snackbar.Add("Export Einstellungen gespeichert", Severity.Success); Snackbar.Add("Export Einstellungen gespeichert", Severity.Success);
} }
private void AddExchangeRate()
{
_exchangeRates.Add(new CurrencyExchangeRate
{
FromCurrency = "USD",
ToCurrency = "EUR",
Rate = 1m,
ValidFrom = DateTime.Today,
IsActive = true
});
}
private void RemoveExchangeRate(CurrencyExchangeRate rate)
{
_exchangeRates.Remove(rate);
}
private async Task SaveExchangeRates()
{
using var db = await DbFactory.CreateDbContextAsync();
var existingRates = await db.CurrencyExchangeRates.ToListAsync();
if (existingRates.Count > 0)
db.CurrencyExchangeRates.RemoveRange(existingRates);
db.CurrencyExchangeRates.AddRange(_exchangeRates.Select(rate => new CurrencyExchangeRate
{
FromCurrency = NormalizeConfigValue(rate.FromCurrency).ToUpperInvariant(),
ToCurrency = NormalizeConfigValue(rate.ToCurrency).ToUpperInvariant(),
Rate = rate.Rate,
ValidFrom = rate.ValidFrom.Date,
ValidTo = rate.ValidTo?.Date,
Notes = NormalizeConfigValue(rate.Notes),
IsActive = rate.IsActive
}).Where(rate => !string.IsNullOrWhiteSpace(rate.FromCurrency)
&& !string.IsNullOrWhiteSpace(rate.ToCurrency)
&& rate.Rate > 0m));
await db.SaveChangesAsync();
_exchangeRates = await db.CurrencyExchangeRates
.OrderBy(x => x.FromCurrency)
.ThenBy(x => x.ToCurrency)
.ThenByDescending(x => x.ValidFrom)
.ToListAsync();
Snackbar.Add("Wechselkurse gespeichert", Severity.Success);
}
private async Task RefreshEcbRates()
{
if (_refreshingExchangeRates)
return;
_refreshingExchangeRates = true;
try
{
var result = await ExchangeRateImportService.RefreshEcbRatesAsync();
_exchangeRates = await LoadExchangeRatesAsync();
Snackbar.Add($"ECB-Kurse aktualisiert: {result.ImportedCount} Kurse vom {result.RateDate:yyyy-MM-dd}.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"ECB-Kursimport fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_refreshingExchangeRates = false;
}
}
private async Task ExportConfiguration() private async Task ExportConfiguration()
{ {
if (_exportingConfig) if (_exportingConfig)
@@ -328,6 +493,11 @@
using var db = await DbFactory.CreateDbContextAsync(); using var db = await DbFactory.CreateDbContextAsync();
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig(); _spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
_exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(); _exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
_exchangeRates = await db.CurrencyExchangeRates
.OrderBy(x => x.FromCurrency)
.ThenBy(x => x.ToCurrency)
.ThenByDescending(x => x.ValidFrom)
.ToListAsync();
TimerService.Recalculate(); TimerService.Recalculate();
Snackbar.Add("Konfiguration importiert", Severity.Success); Snackbar.Add("Konfiguration importiert", Severity.Success);
} }
@@ -466,4 +636,31 @@
"SAGE" => _exportSettings.SagePassword, "SAGE" => _exportSettings.SagePassword,
_ => _exportSettings.SapPassword _ => _exportSettings.SapPassword
}; };
private static string NormalizeConfigValue(string? value) => value?.Trim() ?? string.Empty;
private static string BuildSharePointTestPreview(string tenantId, string clientId, string clientSecret, string siteUrl)
{
var maskedSecret = string.IsNullOrEmpty(clientSecret)
? "<leer>"
: $"{new string('*', Math.Min(clientSecret.Length, 8))} (len={clientSecret.Length})";
return string.Join(Environment.NewLine,
[
$"Tenant ID: {tenantId}",
$"Client ID: {clientId}",
$"Client Secret: {maskedSecret}",
$"Site URL: {siteUrl}"
]);
}
private async Task<List<CurrencyExchangeRate>> LoadExchangeRatesAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
return await db.CurrencyExchangeRates
.OrderBy(x => x.FromCurrency)
.ThenBy(x => x.ToCurrency)
.ThenByDescending(x => x.ValidFrom)
.ToListAsync();
}
} }
@@ -0,0 +1,134 @@
@page "/source-viewer"
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.WebUtilities
@inject IWebHostEnvironment Environment
@inject NavigationManager Navigation
@inject TrafagSalesExporter.Services.IUiTextService UiText
<PageTitle>@T("Source Viewer", "Source Viewer")</PageTitle>
<MudStack Spacing="2">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">@T("Source Viewer", "Source Viewer")</MudText>
<MudButton Variant="Variant.Outlined" Href="/transformations">
@T("Zurueck zur Transformation", "Back to transformations")
</MudButton>
</MudStack>
@if (!string.IsNullOrWhiteSpace(_requestedPath))
{
<MudText Typo="Typo.body2">
@T("Datei:", "File:")
<MudText Inline="true" Typo="Typo.body2"><code>@_requestedPath</code></MudText>
</MudText>
}
@if (!string.IsNullOrWhiteSpace(_requestedType))
{
<MudText Typo="Typo.body2">
@T("Klasse:", "Class:")
<MudText Inline="true" Typo="Typo.body2"><code>@_requestedType</code></MudText>
@if (_highlightLineNumber is not null)
{
<span> @T("bei Zeile", "at line") @_highlightLineNumber</span>
}
</MudText>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">@_error</MudAlert>
}
else if (string.IsNullOrWhiteSpace(_content))
{
<MudProgressCircular Indeterminate="true" />
}
else
{
<MudPaper Class="pa-4">
<div style="font-family: Consolas, monospace; font-size: 0.9rem;">
@foreach (var line in _lines)
{
<div id="@GetLineAnchor(line.Number)"
style="@GetLineStyle(line.Number)">
<span style="display:inline-block; width:4rem; color:#666;">@line.Number.ToString("0000")</span>
<span>@line.Text</span>
</div>
}
</div>
</MudPaper>
@if (_highlightLineNumber is not null)
{
<script>
location.hash = '@GetLineAnchor(_highlightLineNumber.Value)';
</script>
}
}
</MudStack>
@code {
private string? _requestedPath;
private string? _requestedType;
private string? _content;
private string? _error;
private List<SourceLine> _lines = [];
private int? _highlightLineNumber;
protected override void OnInitialized()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
_requestedPath = query.TryGetValue("path", out var value) ? value.ToString() : null;
_requestedType = query.TryGetValue("type", out var typeValue) ? typeValue.ToString() : null;
if (string.IsNullOrWhiteSpace(_requestedPath))
{
_error = T("Kein Dateipfad angegeben.", "No file path provided.");
return;
}
if (_requestedPath.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(_requestedPath))
{
_error = T("Ungueltiger Dateipfad.", "Invalid file path.");
return;
}
var fullPath = Path.Combine(Environment.ContentRootPath, _requestedPath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(fullPath))
{
_error = string.Format(T("Datei nicht gefunden: {0}", "File not found: {0}"), _requestedPath);
return;
}
_content = File.ReadAllText(fullPath);
_lines = _content
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Split('\n')
.Select((text, index) => new SourceLine(index + 1, text))
.ToList();
if (!string.IsNullOrWhiteSpace(_requestedType))
{
_highlightLineNumber = _lines
.FirstOrDefault(x => x.Text.Contains($"class {_requestedType}", StringComparison.Ordinal) ||
x.Text.Contains($"sealed class {_requestedType}", StringComparison.Ordinal) ||
x.Text.Contains($"public class {_requestedType}", StringComparison.Ordinal) ||
x.Text.Contains($"public sealed class {_requestedType}", StringComparison.Ordinal))
?.Number;
}
}
private static string GetLineAnchor(int lineNumber) => $"line-{lineNumber}";
private string GetLineStyle(int lineNumber)
{
var highlight = _highlightLineNumber == lineNumber;
return highlight
? "background-color:#fff3cd; white-space:pre-wrap;"
: "white-space:pre-wrap;";
}
private sealed record SourceLine(int Number, string Text);
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -1250,4 +1250,4 @@
.Where(x => !string.IsNullOrWhiteSpace(x)) .Where(x => !string.IsNullOrWhiteSpace(x))
.ToHashSet(StringComparer.OrdinalIgnoreCase) .ToHashSet(StringComparer.OrdinalIgnoreCase)
?? []; ?? [];
} }
@@ -7,11 +7,12 @@
@inject IDbContextFactory<AppDbContext> DbFactory @inject IDbContextFactory<AppDbContext> DbFactory
@inject ITransformationCatalog TransformationCatalog @inject ITransformationCatalog TransformationCatalog
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>Transformationen</PageTitle> <PageTitle>@T("Transformationen", "Transformations")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Transformer Ansicht</MudText> <MudText Typo="Typo.h4" Class="mb-4">@T("Transformer Ansicht", "Transformation view")</MudText>
<MudText Typo="Typo.body1" Class="mb-4">Definiere pro Quellsystem einfache Feldregeln und komplexe record-basierte Strategien.</MudText> <MudText Typo="Typo.body1" Class="mb-4">@T("Definiere pro Quellsystem einfache Feldregeln und komplexe record-basierte Strategien.", "Define simple field rules and complex record-based strategies per source system.")</MudText>
<MudPaper Class="pa-4" Elevation="1"> <MudPaper Class="pa-4" Elevation="1">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3"> <MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
@@ -20,10 +21,10 @@
<MudStack Row="true" Spacing="2" Class="mb-3"> <MudStack Row="true" Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule"> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
Regel hinzufuegen @T("Regel hinzufuegen", "Add rule")
</MudButton> </MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync"> <MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
Alle speichern @T("Alle speichern", "Save all")
</MudButton> </MudButton>
</MudStack> </MudStack>
@@ -34,9 +35,10 @@
<MudTh>Scope</MudTh> <MudTh>Scope</MudTh>
<MudTh>Source</MudTh> <MudTh>Source</MudTh>
<MudTh>Target</MudTh> <MudTh>Target</MudTh>
<MudTh>Typ</MudTh> <MudTh>Typ / Klasse</MudTh>
<MudTh>Argument</MudTh> <MudTh>Argument</MudTh>
<MudTh>Sort</MudTh> <MudTh>Sort</MudTh>
<MudTh>Info</MudTh>
<MudTh>Aktionen</MudTh> <MudTh>Aktionen</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
@@ -58,12 +60,19 @@
</MudSelect> </MudSelect>
</MudTd> </MudTd>
<MudTd> <MudTd>
<MudSelect T="string" Value="@context.SourceField" ValueChanged="@(v => context.SourceField = v)" Dense Disabled="@IsRecordScope(context)"> @if (IsRecordScope(context))
@foreach (var field in _recordFields) {
{ <MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small" Text="Record-Regel" />
<MudSelectItem Value="@field">@field</MudSelectItem> }
} else
</MudSelect> {
<MudSelect T="string" Value="@context.SourceField" ValueChanged="@(v => context.SourceField = v)" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
}
</MudTd> </MudTd>
<MudTd> <MudTd>
<MudSelect T="string" Value="@context.TargetField" ValueChanged="@(v => context.TargetField = v)" Dense> <MudSelect T="string" Value="@context.TargetField" ValueChanged="@(v => context.TargetField = v)" Dense>
@@ -74,12 +83,26 @@
</MudSelect> </MudSelect>
</MudTd> </MudTd>
<MudTd> <MudTd>
<MudSelect T="string" Value="@context.TransformationType" ValueChanged="@(v => context.TransformationType = v)" Dense> @{
@foreach (var type in GetTypesForScope(context.RuleScope)) var availableTypes = GetTypesForScope(context.RuleScope);
}
<MudSelect T="string"
@key="@GetTypeSelectKey(context)"
Value="@context.TransformationType"
ValueChanged="@(v => context.TransformationType = v)"
Dense
HelperText="@GetTypeHelperText(context)">
@foreach (var type in availableTypes)
{ {
<MudSelectItem Value="@type.Key">@type.Key</MudSelectItem> <MudSelectItem Value="@type.Key">@(IsRecordScope(context) ? $"Klasse: {type.Key}" : type.Key)</MudSelectItem>
} }
</MudSelect> </MudSelect>
@if (IsRecordScope(context))
{
<MudText Typo="Typo.caption" Class="mt-1">
Hier waehlt man die registrierte C#-Strategie.
</MudText>
}
</MudTd> </MudTd>
<MudTd> <MudTd>
<MudTextField T="string" Value="@context.Argument" ValueChanged="@(v => context.Argument = v)" <MudTextField T="string" Value="@context.Argument" ValueChanged="@(v => context.Argument = v)"
@@ -88,6 +111,20 @@
<MudTd> <MudTd>
<MudNumericField T="int" Value="@context.SortOrder" ValueChanged="@(v => context.SortOrder = v)" Dense /> <MudNumericField T="int" Value="@context.SortOrder" ValueChanged="@(v => context.SortOrder = v)" Dense />
</MudTd> </MudTd>
<MudTd>
@{
var catalogItem = GetCatalogItem(context);
}
<MudStack Spacing="1">
<MudText Typo="Typo.caption">@((catalogItem?.Description ?? T("Keine Beschreibung.", "No description.")) )</MudText>
<MudButton Variant="Variant.Text" Color="Color.Info" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Code"
Disabled="@(catalogItem is null)"
OnClick="() => ShowCode(context)">
@T("Code anzeigen", "Show code")
</MudButton>
</MudStack>
</MudTd>
<MudTd> <MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small" <MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="() => RemoveRule(context)" /> OnClick="() => RemoveRule(context)" />
@@ -96,6 +133,48 @@
</MudTable> </MudTable>
</MudPaper> </MudPaper>
<MudDialog @bind-Visible="_codeDialogVisible" Options="_codeDialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@T("Transformationscode", "Transformation code")</MudText>
</TitleContent>
<DialogContent>
@if (_selectedCatalogItem is not null)
{
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle2">@_selectedCatalogItem.Key (@_selectedCatalogItem.RuleScope)</MudText>
<MudText Typo="Typo.body2">@_selectedCatalogItem.Description</MudText>
<MudText Typo="Typo.caption">Klasse: @_selectedCatalogItem.TypeName</MudText>
<MudText Typo="Typo.caption">
Datei:
<MudLink Href="@GetSourceViewerUrl(_selectedCatalogItem.SourceFile, _selectedCatalogItem.TypeName)" Target="_blank">
@_selectedCatalogItem.SourceFile
</MudLink>
</MudText>
<MudPaper Class="pa-3">
<MudText Typo="Typo.caption">Snippet</MudText>
<pre style="margin:0; white-space:pre-wrap;">@_selectedCatalogItem.CodeSnippet</pre>
</MudPaper>
@if (_selectedRule is not null)
{
<MudPaper Class="pa-3">
<MudText Typo="Typo.caption">Aktuelle Regel</MudText>
<MudText Typo="Typo.body2">System: @_selectedRule.SourceSystem</MudText>
<MudText Typo="Typo.body2">Target: @_selectedRule.TargetField</MudText>
@if (!string.IsNullOrWhiteSpace(_selectedRule.SourceField))
{
<MudText Typo="Typo.body2">Source: @_selectedRule.SourceField</MudText>
}
<MudText Typo="Typo.body2">Argument: @(string.IsNullOrWhiteSpace(_selectedRule.Argument) ? "-" : _selectedRule.Argument)</MudText>
</MudPaper>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" OnClick="CloseCodeDialog">@T("Schliessen", "Close")</MudButton>
</DialogActions>
</MudDialog>
@code { @code {
private readonly string[] _systems = ["SAP", "BI1", "SAGE", "MANUAL_EXCEL"]; private readonly string[] _systems = ["SAP", "BI1", "SAGE", "MANUAL_EXCEL"];
private readonly string[] _ruleScopes = ["Value", "Record"]; private readonly string[] _ruleScopes = ["Value", "Record"];
@@ -107,6 +186,10 @@
private List<FieldTransformationRule> _rules = new(); private List<FieldTransformationRule> _rules = new();
private IReadOnlyList<TransformationCatalogItem> _catalogItems = []; private IReadOnlyList<TransformationCatalogItem> _catalogItems = [];
private bool _codeDialogVisible;
private FieldTransformationRule? _selectedRule;
private TransformationCatalogItem? _selectedCatalogItem;
private readonly DialogOptions _codeDialogOptions = new() { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -158,7 +241,7 @@
db.FieldTransformationRules.AddRange(_rules); db.FieldTransformationRules.AddRange(_rules);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
Snackbar.Add("Transformationsregeln gespeichert.", Severity.Success); Snackbar.Add(T("Transformationsregeln gespeichert.", "Transformation rules saved."), Severity.Success);
await LoadAsync(); await LoadAsync();
} }
@@ -190,6 +273,45 @@
string.Equals(x.RuleScope, rule.RuleScope, StringComparison.OrdinalIgnoreCase) && string.Equals(x.RuleScope, rule.RuleScope, StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Key, rule.TransformationType, StringComparison.OrdinalIgnoreCase)); string.Equals(x.Key, rule.TransformationType, StringComparison.OrdinalIgnoreCase));
return item?.Description ?? "Optionales Argument."; return item?.Description ?? T("Optionales Argument.", "Optional argument.");
}
private TransformationCatalogItem? GetCatalogItem(FieldTransformationRule rule)
=> _catalogItems.FirstOrDefault(x =>
string.Equals(x.RuleScope, rule.RuleScope, StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Key, rule.TransformationType, StringComparison.OrdinalIgnoreCase));
private void ShowCode(FieldTransformationRule rule)
{
_selectedRule = rule;
_selectedCatalogItem = GetCatalogItem(rule);
_codeDialogVisible = _selectedCatalogItem is not null;
}
private void CloseCodeDialog()
{
_codeDialogVisible = false;
_selectedRule = null;
_selectedCatalogItem = null;
}
private static string GetSourceViewerUrl(string sourceFile, string typeName)
=> $"/source-viewer?path={Uri.EscapeDataString(sourceFile)}&type={Uri.EscapeDataString(typeName)}";
private static string GetTypeSelectKey(FieldTransformationRule rule)
=> $"{rule.Id}:{rule.RuleScope}:{rule.TransformationType}";
private string GetTypeHelperText(FieldTransformationRule rule)
{
var types = GetTypesForScope(rule.RuleScope);
return types.Count == 0
? T("Keine Typen fuer diesen Scope registriert.", "No types registered for this scope.")
: IsRecordScope(rule)
? string.Format(T("Verfuegbare Klassen: {0}", "Available classes: {0}"), string.Join(", ", types.Select(x => x.Key)))
: string.Format(T("Verfuegbare Typen: {0}", "Available types: {0}"), string.Join(", ", types.Select(x => x.Key)));
} }
} }
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
+1
View File
@@ -14,6 +14,7 @@ public class AppDbContext : DbContext
public DbSet<ExportLog> ExportLogs => Set<ExportLog>(); public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>(); public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>(); public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
public DbSet<CurrencyExchangeRate> CurrencyExchangeRates => Set<CurrencyExchangeRate>();
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>(); public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>(); public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>(); public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
+149
View File
@@ -2,6 +2,10 @@
Stand: 2026-04-15 Stand: 2026-04-15
## Nachtrag 2026-04-16
Seit dem letzten Handoff wurden weitere Funktionen umgesetzt, die unten im alten Stand noch nicht voll enthalten sind.
## Zielbild ## Zielbild
Die App wurde von einem reinen BI1/HANA-Exporter zu einer kombinierten Plattform erweitert: Die App wurde von einem reinen BI1/HANA-Exporter zu einer kombinierten Plattform erweitert:
@@ -211,6 +215,132 @@ Aktuell enthalten:
- Datenqualitaetshinweise - Datenqualitaetshinweise
- automatische Management-Aussagen - automatische Management-Aussagen
### 11. Manueller Excel-Import pro Standort
Es gibt jetzt einen vierten `SourceSystem`-Typ:
- `MANUAL_EXCEL`
Gedanke:
- Standort ohne Netz-/Systemanbindung liefert nur Excel
- Datei wird im Standort hochgeladen
- Export liest diese Datei statt SAP/HANA
- Daten werden in `CentralSalesRecords` fuer diesen Standort ersetzt
- der zentrale Export liest weiter nur aus `CentralSalesRecords`
Neue Site-Felder:
- `ManualImportFilePath`
- `ManualImportLastUploadedAtUtc`
Wichtig:
- das ist kein Excel-zu-Excel-Merge
- die App importiert ins zentrale Schema und erzeugt danach die zentrale Datei neu
### 12. Dashboard erweitert
Im Dashboard gibt es jetzt zusaetzlich:
- separaten Bereich `Zentrale Datei`
- `Excel oeffnen` fuer die neueste zentrale Datei `Sales_All_*.xlsx`
- Button `Alle exportieren`
- Button `Zentrale Datei neu erzeugen`
Bedeutung:
- `Alle exportieren` liest alle Quellen neu und erzeugt danach die zentrale Datei
- `Zentrale Datei neu erzeugen` schreibt nur aus `CentralSalesRecords` eine neue zentrale Excel
### 13. Management Cockpit Roh-Auswertung aus Zentraldaten
Zusaetzlich zur dateibasierten Cockpit-Analyse gibt es jetzt eine Roh-Auswertung direkt aus `CentralSalesRecords`.
Aktuell umgesetzt:
- Auswahl Jahr
- optional Auswahl Monat
- Jahresumsatz
- Monatsumsatz
- Tagesumsatz im gewaehlten Monat
- Umsatz nach Quelle
- Umsatz nach Land
- Periodenabdeckung / Zeilen / Rechnungen / Standorte / Laender / Waehrungen
Bewusst noch nicht enthalten:
- kein Intercompany-Filter
- keine CHF-Umrechnung
- kein Budgetvergleich
- keine Spartenlogik
- keine Gruppenlogik
- keine Margenlogik
### 14. Transformationssystem erweitert
Das Transformationssystem kann jetzt zwei Ebenen:
- `Value` fuer einfache feldweise Regeln aus der GUI
- `Record` fuer komplexere C#-Strategien per Strategy Pattern
Umgesetzt:
- neues Feld `RuleScope` auf `FieldTransformationRule`
- dynamischer Strategiekatalog
- GUI liest verfuegbare Typen aus dem Katalog
- erste `Record`-Strategie: `FirstNonEmpty`
Beispiel:
- `TargetField = CustomerName`
- `TransformationType = FirstNonEmpty`
- `Argument = CustomerName|SupplierName|Name`
### 15. Schema-Lookup fuer HANA-Standorte
Im Standortdialog fuer HANA-basierte Standorte gibt es jetzt:
- Button `Schemas laden`
- Lookup mit gueltigen Schemas aus HANA
Die Liste wird nicht blind aus allen Schemas gelesen, sondern auf typische B1-Schemas eingeschraenkt, in denen z. B. Tabellen wie
- `OINV`
- `INV1`
- `ORIN`
- `RIN1`
- `OCRD`
- `OITM`
vorhanden sind.
Wichtig:
- manuelle Eingabe bleibt moeglich
- fuer `BI1` und `SAGE` werden beim Lookup die effektiven Credentials inkl. zentraler Zugangsdaten / Overrides verwendet
- das reduziert Fehler wie `invalid schema name`
### 16. Testabdeckung ausgebaut
Es gibt jetzt ein separates Testprojekt:
- `TrafagSalesExporter.Tests`
Automatisiert getestet werden aktuell:
- Transformationsstrategien
- `RecordTransformationService`
- `TransformationCatalog`
- `ManualExcelImportService`
- `ManagementCockpitService`
- `ConfigTransferService`
Wichtiger bereits gefundener Bug:
- deutsches Dezimalformat wie `1,50` wurde im manuellen Excel-Import falsch interpretiert
- Parsing wurde korrigiert
## Wichtige Dateien ## Wichtige Dateien
### Modelle ### Modelle
@@ -224,6 +354,7 @@ Aktuell enthalten:
- `Models/SapFieldMapping.cs` - `Models/SapFieldMapping.cs`
- `Models/ManagementCockpitModels.cs` - `Models/ManagementCockpitModels.cs`
- `Models/ConfigTransferPackage.cs` - `Models/ConfigTransferPackage.cs`
- `Models/FieldTransformationRule.cs`
### Services ### Services
@@ -237,6 +368,10 @@ Aktuell enthalten:
- `Services/ManagementCockpitService.cs` - `Services/ManagementCockpitService.cs`
- `Services/DatabaseInitializationService.cs` - `Services/DatabaseInitializationService.cs`
- `Services/ExportOrchestrationService.cs` - `Services/ExportOrchestrationService.cs`
- `Services/ManualExcelImportService.cs`
- `Services/TransformationCatalog.cs`
- `Services/RecordTransformationService.cs`
- `Services/TransformationStrategies.cs`
### UI ### UI
@@ -245,8 +380,18 @@ Aktuell enthalten:
- `Components/Pages/Dashboard.razor` - `Components/Pages/Dashboard.razor`
- `Components/Pages/Logs.razor` - `Components/Pages/Logs.razor`
- `Components/Pages/ManagementCockpit.razor` - `Components/Pages/ManagementCockpit.razor`
- `Components/Pages/Transformations.razor`
- `Components/Layout/NavMenu.razor` - `Components/Layout/NavMenu.razor`
### Tests
- `TrafagSalesExporter.Tests/TransformationStrategiesTests.cs`
- `TrafagSalesExporter.Tests/RecordTransformationServiceTests.cs`
- `TrafagSalesExporter.Tests/TransformationCatalogTests.cs`
- `TrafagSalesExporter.Tests/ManualExcelImportServiceTests.cs`
- `TrafagSalesExporter.Tests/ManagementCockpitServiceTests.cs`
- `TrafagSalesExporter.Tests/ConfigTransferServiceTests.cs`
## Datenbank / Migrationen ## Datenbank / Migrationen
Viele Aenderungen laufen ueber `DatabaseInitializationService`. Viele Aenderungen laufen ueber `DatabaseInitializationService`.
@@ -261,11 +406,15 @@ Wichtige neue oder erweiterte Tabellen/Felder:
- `SapEntitySetsCache` - `SapEntitySetsCache`
- `SapEntitySetsRefreshedAtUtc` - `SapEntitySetsRefreshedAtUtc`
- `LocalExportFolderOverride` - `LocalExportFolderOverride`
- `ManualImportFilePath`
- `ManualImportLastUploadedAtUtc`
- `ExportSettings` - `ExportSettings`
- zentrale SAP/BI1/SAGE Credentials - zentrale SAP/BI1/SAGE Credentials
- `LocalSiteExportFolder` - `LocalSiteExportFolder`
- `LocalConsolidatedExportFolder` - `LocalConsolidatedExportFolder`
- `DebugLoggingEnabled` - `DebugLoggingEnabled`
- `FieldTransformationRules`
- `RuleScope`
- `ExportLogs` - `ExportLogs`
- `FilePath` - `FilePath`
- neue Tabellen: - neue Tabellen:
@@ -7,6 +7,7 @@ public class ConfigTransferPackage
public bool IncludesSecrets { get; set; } public bool IncludesSecrets { get; set; }
public ConfigTransferSharePoint? SharePointConfig { get; set; } public ConfigTransferSharePoint? SharePointConfig { get; set; }
public ConfigTransferExportSettings? ExportSettings { get; set; } public ConfigTransferExportSettings? ExportSettings { get; set; }
public List<ConfigTransferCurrencyExchangeRate> CurrencyExchangeRates { get; set; } = [];
public List<ConfigTransferHanaServer> HanaServers { get; set; } = []; public List<ConfigTransferHanaServer> HanaServers { get; set; } = [];
public List<ConfigTransferSite> Sites { get; set; } = []; public List<ConfigTransferSite> Sites { get; set; } = [];
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = []; public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
@@ -19,6 +20,7 @@ public class ConfigTransferSharePoint
{ {
public string SiteUrl { get; set; } = string.Empty; public string SiteUrl { get; set; } = string.Empty;
public string ExportFolder { get; set; } = string.Empty; public string ExportFolder { get; set; } = string.Empty;
public string CentralExportFolder { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty; public string TenantId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty; public string ClientId { get; set; } = string.Empty;
public string? ClientSecret { get; set; } public string? ClientSecret { get; set; }
@@ -41,6 +43,17 @@ public class ConfigTransferExportSettings
public string? SagePassword { get; set; } public string? SagePassword { get; set; }
} }
public class ConfigTransferCurrencyExchangeRate
{
public string FromCurrency { get; set; } = string.Empty;
public string ToCurrency { get; set; } = string.Empty;
public decimal Rate { get; set; }
public DateTime ValidFrom { get; set; }
public DateTime? ValidTo { get; set; }
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
public class ConfigTransferHanaServer public class ConfigTransferHanaServer
{ {
public string Key { get; set; } = Guid.NewGuid().ToString("N"); public string Key { get; set; } = Guid.NewGuid().ToString("N");
@@ -0,0 +1,13 @@
namespace TrafagSalesExporter.Models;
public class CurrencyExchangeRate
{
public int Id { get; set; }
public string FromCurrency { get; set; } = string.Empty;
public string ToCurrency { get; set; } = string.Empty;
public decimal Rate { get; set; }
public DateTime ValidFrom { get; set; } = DateTime.UtcNow.Date;
public DateTime? ValidTo { get; set; }
public string Notes { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
@@ -5,6 +5,7 @@ public class SharePointConfig
public int Id { get; set; } public int Id { get; set; }
public string SiteUrl { get; set; } = string.Empty; public string SiteUrl { get; set; } = string.Empty;
public string ExportFolder { get; set; } = string.Empty; public string ExportFolder { get; set; } = string.Empty;
public string CentralExportFolder { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty; public string TenantId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty; public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty; public string ClientSecret { get; set; } = string.Empty;
@@ -2,6 +2,21 @@
Stand: 2026-04-15 Stand: 2026-04-15
## Nachtrag 2026-04-16
Seit dem letzten Stand kamen mehrere groessere Erweiterungen dazu. Die offenen Punkte unten muessen deshalb im neuen Kontext gelesen werden.
## 0. Neuer Ist-Stand
Zusaetzlich zum alten Stand ist jetzt vorhanden:
- manueller Standort-Import ueber `MANUAL_EXCEL`
- Dashboard mit `Alle exportieren`, `Zentrale Datei neu erzeugen` und zentralem `Excel oeffnen`
- Roh-Auswertung im `Management Cockpit` direkt aus `CentralSalesRecords`
- erweitertes Transformationssystem mit `Value`- und `Record`-Regeln
- HANA-Schema-Lookup im Standortdialog
- Testprojekt mit aktuell 18 gruenden Tests
## 1. Status ## 1. Status
Der Export geht jetzt wieder durch. Der Export geht jetzt wieder durch.
@@ -36,6 +51,40 @@ Kurz gegenpruefen:
- `Excel oeffnen` nach erfolgreichem Export - `Excel oeffnen` nach erfolgreichem Export
- `Export erfolgreich` inkl. `Pfad=...` - `Export erfolgreich` inkl. `Pfad=...`
- Dashboard-Live-Status setzt sich nach Abschluss sauber zurueck - Dashboard-Live-Status setzt sich nach Abschluss sauber zurueck
- `Alle exportieren`
- `Zentrale Datei neu erzeugen`
- zentrale Datei im Dashboard oeffnen
## 3a. Manuellen Excel-Import pruefen
Zu testen:
- Standort auf `MANUAL_EXCEL` stellen
- Excel im Standort hochladen
- Standort exportieren
- pruefen, ob `CentralSalesRecords` fuer diesen Standort ersetzt wurden
- pruefen, ob der zentrale Export den Standort korrekt enthaelt
Dateien:
- `Components/Pages/Standorte.razor`
- `Services/ManualExcelImportService.cs`
- `Services/SiteExportService.cs`
## 3b. HANA-Schema-Lookup pruefen
Zu testen:
- bei `BI1`-Standort `Schemas laden`
- bei `SAGE`-Standort `Schemas laden`
- wird ein plausibles B1-Schema angeboten?
- funktioniert danach Export ohne manuelle Schema-Eingabe?
- zeigt England / Spezialstandort jetzt schneller, wenn Schema oder Rechte nicht passen?
Dateien:
- `Components/Pages/Standorte.razor`
- `Services/HanaQueryService.cs`
## 4. Falls wieder ein Fehler auftritt ## 4. Falls wieder ein Fehler auftritt
@@ -68,12 +117,41 @@ Zu testen:
- vorhandene Excel-Datei auswaehlbar - vorhandene Excel-Datei auswaehlbar
- Analyse laeuft - Analyse laeuft
- Kennzahlen plausibel - Kennzahlen plausibel
- Roh-Auswertung aus `CentralSalesRecords` laeuft
- Jahr/Monat-Filter funktionieren
- Summen nach Quelle / Land plausibel
Dateien: Dateien:
- `Components/Pages/ManagementCockpit.razor` - `Components/Pages/ManagementCockpit.razor`
- `Services/ManagementCockpitService.cs` - `Services/ManagementCockpitService.cs`
## 6a. Fachlich bewusst noch offen
Noch nicht final umsetzen ohne Rueckmeldung Fachseite:
- Intercompany-Filter
- CHF-Umrechnung / Wechselkurse
- Budgetvergleich
- Gruppenlogik
- Spartenlogik
- Margenlogik
Diese Punkte sollen spaeter moeglichst dynamisch auf dem neuen Transformations-/Mapping-Ansatz aufsetzen, aber aktuell nicht hart geraten werden.
## 6b. Naechste sinnvolle Testkandidaten
Wenn weiter in Tests investiert wird, sind die naechsten Kandidaten:
- `ExportOrchestrationService`
- spaeter evtl. SQLite-nahe Integrationstests fuer `DatabaseInitializationService`
Aktueller Teststatus:
- `dotnet test TrafagSalesExporter.sln --verbosity minimal`
- erfolgreich
- `18/18` Tests gruen
## 7. Referenzdatei ## 7. Referenzdatei
Fuer den vollstaendigen Kontext zuerst lesen: Fuer den vollstaendigen Kontext zuerst lesen:
+6
View File
@@ -9,6 +9,7 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
builder.Services.AddMudServices(); builder.Services.AddMudServices();
builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
builder.Services.AddDbContextFactory<AppDbContext>(options => builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60")); options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
@@ -25,7 +26,11 @@ builder.Services.AddSingleton<ITransformationStrategy, PrefixTransformationStrat
builder.Services.AddSingleton<ITransformationStrategy, SuffixTransformationStrategy>(); builder.Services.AddSingleton<ITransformationStrategy, SuffixTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, ReplaceTransformationStrategy>(); builder.Services.AddSingleton<ITransformationStrategy, ReplaceTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, ConstantTransformationStrategy>(); builder.Services.AddSingleton<ITransformationStrategy, ConstantTransformationStrategy>();
builder.Services.AddSingleton<ITransformationStrategy, NormalizeCurrencyCodeTransformationStrategy>();
builder.Services.AddSingleton<ICurrencyExchangeRateService, CurrencyExchangeRateService>();
builder.Services.AddSingleton<IExchangeRateImportService, ExchangeRateImportService>();
builder.Services.AddSingleton<IRecordTransformationStrategy, FirstNonEmptyRecordTransformationStrategy>(); builder.Services.AddSingleton<IRecordTransformationStrategy, FirstNonEmptyRecordTransformationStrategy>();
builder.Services.AddSingleton<IRecordTransformationStrategy, ConvertCurrencyRecordTransformationStrategy>();
builder.Services.AddSingleton<ITransformationCatalog, TransformationCatalog>(); builder.Services.AddSingleton<ITransformationCatalog, TransformationCatalog>();
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>(); builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>(); builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
@@ -37,6 +42,7 @@ builder.Services.AddSingleton<IExportLogService, ExportLogService>();
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>(); builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>(); builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>(); builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
builder.Services.AddSingleton<IUiTextService, UiTextService>();
builder.Services.AddSingleton<ExportOrchestrationService>(); builder.Services.AddSingleton<ExportOrchestrationService>();
builder.Services.AddSingleton<TimerBackgroundService>(); builder.Services.AddSingleton<TimerBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>());
@@ -20,6 +20,11 @@ public class ConfigTransferService : IConfigTransferService
using var db = await _dbFactory.CreateDbContextAsync(); using var db = await _dbFactory.CreateDbContextAsync();
var sharePoint = await db.SharePointConfigs.FirstOrDefaultAsync(); var sharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
var exportSettings = await db.ExportSettings.FirstOrDefaultAsync(); var exportSettings = await db.ExportSettings.FirstOrDefaultAsync();
var exchangeRates = await db.CurrencyExchangeRates
.OrderBy(x => x.FromCurrency)
.ThenBy(x => x.ToCurrency)
.ThenByDescending(x => x.ValidFrom)
.ToListAsync();
var hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync(); var hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
var sites = await db.Sites.OrderBy(x => x.Land).ToListAsync(); var sites = await db.Sites.OrderBy(x => x.Land).ToListAsync();
var rules = await db.FieldTransformationRules.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(); var rules = await db.FieldTransformationRules.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
@@ -37,6 +42,7 @@ public class ConfigTransferService : IConfigTransferService
{ {
SiteUrl = sharePoint.SiteUrl, SiteUrl = sharePoint.SiteUrl,
ExportFolder = sharePoint.ExportFolder, ExportFolder = sharePoint.ExportFolder,
CentralExportFolder = sharePoint.CentralExportFolder,
TenantId = sharePoint.TenantId, TenantId = sharePoint.TenantId,
ClientId = sharePoint.ClientId, ClientId = sharePoint.ClientId,
ClientSecret = includeSecrets ? sharePoint.ClientSecret : null ClientSecret = includeSecrets ? sharePoint.ClientSecret : null
@@ -57,6 +63,16 @@ public class ConfigTransferService : IConfigTransferService
SageUsername = includeSecrets ? exportSettings.SageUsername : null, SageUsername = includeSecrets ? exportSettings.SageUsername : null,
SagePassword = includeSecrets ? exportSettings.SagePassword : null SagePassword = includeSecrets ? exportSettings.SagePassword : null
}, },
CurrencyExchangeRates = exchangeRates.Select(rate => new ConfigTransferCurrencyExchangeRate
{
FromCurrency = rate.FromCurrency,
ToCurrency = rate.ToCurrency,
Rate = rate.Rate,
ValidFrom = rate.ValidFrom,
ValidTo = rate.ValidTo,
Notes = rate.Notes,
IsActive = rate.IsActive
}).ToList(),
HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer
{ {
Key = serverKeyMap[server.Id], Key = serverKeyMap[server.Id],
@@ -143,6 +159,7 @@ public class ConfigTransferService : IConfigTransferService
var existingSharePoint = await db.SharePointConfigs.FirstOrDefaultAsync(); var existingSharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
var existingSettings = await db.ExportSettings.FirstOrDefaultAsync(); var existingSettings = await db.ExportSettings.FirstOrDefaultAsync();
var existingServers = await db.HanaServers.ToListAsync(); var existingServers = await db.HanaServers.ToListAsync();
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
var existingSites = await db.Sites.ToListAsync(); var existingSites = await db.Sites.ToListAsync();
var existingRules = await db.FieldTransformationRules.ToListAsync(); var existingRules = await db.FieldTransformationRules.ToListAsync();
var existingSapSources = await db.SapSourceDefinitions.ToListAsync(); var existingSapSources = await db.SapSourceDefinitions.ToListAsync();
@@ -173,6 +190,7 @@ public class ConfigTransferService : IConfigTransferService
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins); if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources); if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules); if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules);
if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates);
if (existingCentralRecords.Count > 0) db.CentralSalesRecords.RemoveRange(existingCentralRecords); if (existingCentralRecords.Count > 0) db.CentralSalesRecords.RemoveRange(existingCentralRecords);
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites); if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers); if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
@@ -184,6 +202,7 @@ public class ConfigTransferService : IConfigTransferService
{ {
SiteUrl = package.SharePointConfig.SiteUrl, SiteUrl = package.SharePointConfig.SiteUrl,
ExportFolder = package.SharePointConfig.ExportFolder, ExportFolder = package.SharePointConfig.ExportFolder,
CentralExportFolder = package.SharePointConfig.CentralExportFolder,
TenantId = package.SharePointConfig.TenantId, TenantId = package.SharePointConfig.TenantId,
ClientId = package.SharePointConfig.ClientId, ClientId = package.SharePointConfig.ClientId,
ClientSecret = package.IncludesSecrets ? package.SharePointConfig.ClientSecret ?? string.Empty : preservedSharePointSecret ClientSecret = package.IncludesSecrets ? package.SharePointConfig.ClientSecret ?? string.Empty : preservedSharePointSecret
@@ -208,6 +227,20 @@ public class ConfigTransferService : IConfigTransferService
SagePassword = package.IncludesSecrets ? importedSettings.SagePassword ?? string.Empty : preservedSecrets.SagePassword ?? string.Empty SagePassword = package.IncludesSecrets ? importedSettings.SagePassword ?? string.Empty : preservedSecrets.SagePassword ?? string.Empty
}); });
if (package.CurrencyExchangeRates.Count > 0)
{
db.CurrencyExchangeRates.AddRange(package.CurrencyExchangeRates.Select(rate => new CurrencyExchangeRate
{
FromCurrency = rate.FromCurrency,
ToCurrency = rate.ToCurrency,
Rate = rate.Rate,
ValidFrom = rate.ValidFrom,
ValidTo = rate.ValidTo,
Notes = rate.Notes,
IsActive = rate.IsActive
}));
}
var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var server in package.HanaServers) foreach (var server in package.HanaServers)
{ {
@@ -49,9 +49,15 @@ public class ConsolidatedExportService : IConsolidatedExportService
!string.IsNullOrWhiteSpace(spConfig.ClientId) && !string.IsNullOrWhiteSpace(spConfig.ClientId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientSecret)) !string.IsNullOrWhiteSpace(spConfig.ClientSecret))
{ {
var centralFolderConfigured = !string.IsNullOrWhiteSpace(spConfig.CentralExportFolder);
var sharePointFolder = centralFolderConfigured
? spConfig.CentralExportFolder
: spConfig.ExportFolder;
var landSubfolder = centralFolderConfigured ? string.Empty : "Alle";
await _sharePointService.UploadAsync( await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, spConfig.ExportFolder, "Alle", consolidatedPath); spConfig.SiteUrl, sharePointFolder, landSubfolder, consolidatedPath);
} }
return consolidatedPath; return consolidatedPath;
@@ -0,0 +1,121 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public class CurrencyExchangeRateService : ICurrencyExchangeRateService
{
private static readonly Dictionary<string, string> BuiltInCurrencyAliases = new(StringComparer.OrdinalIgnoreCase)
{
["$"] = "USD",
["US$"] = "USD",
["USD"] = "USD",
["€"] = "EUR",
["EUR"] = "EUR",
["CHF"] = "CHF",
["SFR"] = "CHF",
["INR"] = "INR",
["RS"] = "INR",
["GBP"] = "GBP",
["CAD"] = "CAD"
};
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public CurrencyExchangeRateService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public decimal? ResolveRate(string fromCurrency, string toCurrency, DateTime? effectiveDate)
{
var normalizedFrom = NormalizeCurrencyCode(fromCurrency);
var normalizedTo = NormalizeCurrencyCode(toCurrency);
if (string.IsNullOrWhiteSpace(normalizedFrom) || string.IsNullOrWhiteSpace(normalizedTo))
return null;
if (string.Equals(normalizedFrom, normalizedTo, StringComparison.OrdinalIgnoreCase))
return 1m;
var date = (effectiveDate ?? DateTime.UtcNow).Date;
using var db = _dbFactory.CreateDbContext();
var directRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == normalizedFrom
&& x.ToCurrency.ToUpper() == normalizedTo
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (directRate is not null)
return directRate.Rate;
var inverseRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == normalizedTo
&& x.ToCurrency.ToUpper() == normalizedFrom
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (inverseRate is not null && inverseRate.Rate != 0m)
return 1m / inverseRate.Rate;
var fromToEur = ResolveDirectOrInverseRate(db, normalizedFrom, "EUR", date);
var eurToTarget = ResolveDirectOrInverseRate(db, "EUR", normalizedTo, date);
if (fromToEur.HasValue && eurToTarget.HasValue)
return fromToEur.Value * eurToTarget.Value;
return null;
}
public string NormalizeCurrencyCode(string? currencyCode)
{
var normalized = currencyCode?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(normalized))
return string.Empty;
return BuiltInCurrencyAliases.TryGetValue(normalized, out var mapped)
? mapped
: normalized.ToUpperInvariant();
}
private static decimal? ResolveDirectOrInverseRate(AppDbContext db, string fromCurrency, string toCurrency, DateTime date)
{
if (string.Equals(fromCurrency, toCurrency, StringComparison.OrdinalIgnoreCase))
return 1m;
var directRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == fromCurrency
&& x.ToCurrency.ToUpper() == toCurrency
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (directRate is not null)
return directRate.Rate;
var inverseRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == toCurrency
&& x.ToCurrency.ToUpper() == fromCurrency
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (inverseRate is not null && inverseRate.Rate != 0m)
return 1m / inverseRate.Rate;
return null;
}
}
@@ -21,6 +21,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService
ConfigureSqlite(db); ConfigureSqlite(db);
EnsureSchema(db); EnsureSchema(db);
SeedIfEmpty(db); SeedIfEmpty(db);
EnsureRecommendedTransformationRules(db);
} }
private static void ConfigureSqlite(AppDbContext db) private static void ConfigureSqlite(AppDbContext db)
@@ -69,9 +70,11 @@ public class DatabaseInitializationService : IDatabaseInitializationService
AddColumnIfMissing(db, "ExportSettings", "DebugLoggingEnabled", "INTEGER NOT NULL DEFAULT 0"); AddColumnIfMissing(db, "ExportSettings", "DebugLoggingEnabled", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "ExportSettings", "LocalSiteExportFolder", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "ExportSettings", "LocalSiteExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "LocalConsolidatedExportFolder", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "ExportSettings", "LocalConsolidatedExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "SharePointConfigs", "CentralExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''");
EnsureTransformationTable(db); EnsureTransformationTable(db);
AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'"); AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'");
EnsureCurrencyExchangeRateTable(db);
EnsureSapSourceTable(db); EnsureSapSourceTable(db);
EnsureSapJoinTable(db); EnsureSapJoinTable(db);
EnsureSapFieldMappingTable(db); EnsureSapFieldMappingTable(db);
@@ -470,6 +473,27 @@ CREATE TABLE IF NOT EXISTS SapSourceDefinitions (
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
private static void EnsureCurrencyExchangeRateTable(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 CurrencyExchangeRates (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
FromCurrency TEXT NOT NULL,
ToCurrency TEXT NOT NULL,
Rate REAL NOT NULL,
ValidFrom TEXT NOT NULL,
ValidTo TEXT NULL,
Notes TEXT NOT NULL DEFAULT '',
IsActive INTEGER NOT NULL DEFAULT 1
);";
cmd.ExecuteNonQuery();
}
private static void EnsureSapJoinTable(AppDbContext db) private static void EnsureSapJoinTable(AppDbContext db)
{ {
var conn = db.Database.GetDbConnection(); var conn = db.Database.GetDbConnection();
@@ -601,6 +625,7 @@ CREATE TABLE IF NOT EXISTS AppEventLogs (
{ {
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform", SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
ExportFolder = "/Shared Documents/Exports/", ExportFolder = "/Shared Documents/Exports/",
CentralExportFolder = "",
TenantId = "", TenantId = "",
ClientId = "", ClientId = "",
ClientSecret = "" ClientSecret = ""
@@ -619,4 +644,55 @@ CREATE TABLE IF NOT EXISTS AppEventLogs (
db.SaveChanges(); db.SaveChanges();
} }
private static void EnsureRecommendedTransformationRules(AppDbContext db)
{
var recommendedRules = new[]
{
new FieldTransformationRule
{
SourceSystem = "MANUAL_EXCEL",
SourceField = nameof(SalesRecord.SalesCurrency),
TargetField = nameof(SalesRecord.SalesCurrency),
TransformationType = "Replace",
RuleScope = "Value",
Argument = "$=>USD",
SortOrder = 100,
IsActive = true
},
new FieldTransformationRule
{
SourceSystem = "MANUAL_EXCEL",
SourceField = nameof(SalesRecord.StandardCostCurrency),
TargetField = nameof(SalesRecord.StandardCostCurrency),
TransformationType = "Replace",
RuleScope = "Value",
Argument = "$=>USD",
SortOrder = 110,
IsActive = true
}
};
var hasChanges = false;
foreach (var rule in recommendedRules)
{
var exists = db.FieldTransformationRules.Any(existing =>
existing.SourceSystem == rule.SourceSystem &&
existing.RuleScope == rule.RuleScope &&
existing.SourceField == rule.SourceField &&
existing.TargetField == rule.TargetField &&
existing.TransformationType == rule.TransformationType &&
existing.Argument == rule.Argument);
if (exists)
continue;
db.FieldTransformationRules.Add(rule);
hasChanges = true;
}
if (hasChanges)
db.SaveChanges();
}
} }
@@ -0,0 +1,93 @@
using System.Globalization;
using System.Xml.Linq;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ExchangeRateImportService : IExchangeRateImportService
{
private const string EcbXmlUrl = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
private const string EcbSourceNote = "ECB daily reference rate";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public ExchangeRateImportService(IHttpClientFactory httpClientFactory, IDbContextFactory<AppDbContext> dbFactory)
{
_httpClientFactory = httpClientFactory;
_dbFactory = dbFactory;
}
public async Task<ExchangeRateImportResult> RefreshEcbRatesAsync(CancellationToken cancellationToken = default)
{
var client = _httpClientFactory.CreateClient(nameof(ExchangeRateImportService));
using var response = await client.GetAsync(EcbXmlUrl, cancellationToken);
response.EnsureSuccessStatusCode();
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
var document = XDocument.Parse(xml);
var rateEntries = ParseRates(document);
if (rateEntries.Count == 0)
throw new InvalidOperationException("ECB response did not contain any exchange rates.");
var rateDate = rateEntries[0].RateDate;
using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
var existingRates = await db.CurrencyExchangeRates
.Where(x => x.Notes == EcbSourceNote && x.ValidFrom == rateDate)
.ToListAsync(cancellationToken);
if (existingRates.Count > 0)
db.CurrencyExchangeRates.RemoveRange(existingRates);
db.CurrencyExchangeRates.AddRange(rateEntries.Select(entry => new CurrencyExchangeRate
{
FromCurrency = "EUR",
ToCurrency = entry.Currency,
Rate = entry.Rate,
ValidFrom = entry.RateDate,
ValidTo = null,
Notes = EcbSourceNote,
IsActive = true
}));
await db.SaveChangesAsync(cancellationToken);
return new ExchangeRateImportResult
{
ImportedCount = rateEntries.Count,
RateDate = rateDate,
SourceName = "ECB"
};
}
private static List<EcbRateEntry> ParseRates(XDocument document)
{
var cubes = document
.Descendants()
.Where(x => x.Name.LocalName == "Cube")
.ToList();
var datedCube = cubes.FirstOrDefault(x => x.Attribute("time") is not null)
?? throw new InvalidOperationException("ECB response did not contain a dated rate section.");
var dateText = datedCube.Attribute("time")?.Value
?? throw new InvalidOperationException("ECB rate date is missing.");
var rateDate = DateTime.ParseExact(dateText, "yyyy-MM-dd", CultureInfo.InvariantCulture);
return datedCube.Elements()
.Where(x => x.Name.LocalName == "Cube")
.Select(x => new EcbRateEntry(
Currency: (x.Attribute("currency")?.Value ?? string.Empty).Trim().ToUpperInvariant(),
Rate: decimal.Parse(x.Attribute("rate")?.Value ?? "0", CultureInfo.InvariantCulture),
RateDate: rateDate))
.Where(x => !string.IsNullOrWhiteSpace(x.Currency) && x.Rate > 0m)
.ToList();
}
private sealed record EcbRateEntry(string Currency, decimal Rate, DateTime RateDate);
}
@@ -105,6 +105,38 @@ public class HanaQueryService : IHanaQueryService
connection.Open(); connection.Open();
} }
public List<string> GetAvailableSchemas(HanaServer server)
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
const string query = """
SELECT schema_name
FROM (
SELECT schema_name, COUNT(DISTINCT table_name) AS required_table_count
FROM sys.tables
WHERE table_name IN ('OINV', 'INV1', 'ORIN', 'RIN1', 'OCRD', 'OITM')
GROUP BY schema_name
) t
WHERE required_table_count >= 4
ORDER BY schema_name;
""";
using var command = new HanaCommand(query, connection);
using var reader = command.ExecuteReader();
var schemas = new List<string>();
while (reader.Read())
{
var schema = reader["schema_name"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(schema))
schemas.Add(schema);
}
return schemas;
}
private List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land, string queryName) private List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land, string queryName)
{ {
var records = new List<SalesRecord>(); var records = new List<SalesRecord>();
@@ -0,0 +1,7 @@
namespace TrafagSalesExporter.Services;
public interface ICurrencyExchangeRateService
{
decimal? ResolveRate(string fromCurrency, string toCurrency, DateTime? effectiveDate);
string NormalizeCurrencyCode(string? currencyCode);
}
@@ -0,0 +1,13 @@
namespace TrafagSalesExporter.Services;
public interface IExchangeRateImportService
{
Task<ExchangeRateImportResult> RefreshEcbRatesAsync(CancellationToken cancellationToken = default);
}
public sealed class ExchangeRateImportResult
{
public int ImportedCount { get; init; }
public DateTime RateDate { get; init; }
public string SourceName { get; init; } = string.Empty;
}
@@ -5,6 +5,7 @@ namespace TrafagSalesExporter.Services;
public interface IHanaQueryService public interface IHanaQueryService
{ {
List<SalesRecord> GetSalesRecords(HanaServer server, string schema, string tsc, string land, string dateFilter); List<SalesRecord> GetSalesRecords(HanaServer server, string schema, string tsc, string land, string dateFilter);
List<string> GetAvailableSchemas(HanaServer server);
ConnectionTestResult TestConnectionDetailed(HanaServer server); ConnectionTestResult TestConnectionDetailed(HanaServer server);
void TestConnection(HanaServer server); void TestConnection(HanaServer server);
} }
@@ -11,4 +11,7 @@ public sealed class TransformationCatalogItem
public string Key { get; init; } = string.Empty; public string Key { get; init; } = string.Empty;
public string RuleScope { get; init; } = string.Empty; public string RuleScope { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty; public string Description { get; init; } = string.Empty;
public string TypeName { get; init; } = string.Empty;
public string SourceFile { get; init; } = string.Empty;
public string CodeSnippet { get; init; } = string.Empty;
} }
@@ -1,3 +1,4 @@
using Azure.Core;
using Azure.Identity; using Azure.Identity;
using Microsoft.Graph; using Microsoft.Graph;
@@ -8,10 +9,17 @@ public class SharePointUploadService : ISharePointUploadService
public async Task UploadAsync(string tenantId, string clientId, string clientSecret, public async Task UploadAsync(string tenantId, string clientId, string clientSecret,
string siteUrl, string exportFolder, string land, string localFilePath) string siteUrl, string exportFolder, string land, string localFilePath)
{ {
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret); var normalizedTenantId = Normalize(tenantId);
var normalizedClientId = Normalize(clientId);
var normalizedClientSecret = Normalize(clientSecret);
var normalizedSiteUrl = Normalize(siteUrl);
var normalizedExportFolder = Normalize(exportFolder);
var normalizedLand = Normalize(land);
var credential = new ClientSecretCredential(normalizedTenantId, normalizedClientId, normalizedClientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var uri = new Uri(siteUrl); var uri = new Uri(normalizedSiteUrl);
var sitePath = uri.AbsolutePath; var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync(); var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
@@ -23,8 +31,13 @@ public class SharePointUploadService : ISharePointUploadService
throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden."); throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden.");
var fileName = Path.GetFileName(localFilePath); var fileName = Path.GetFileName(localFilePath);
var folderPath = exportFolder.Trim('/').Trim(); var remotePath = string.Join("/",
var remotePath = $"{folderPath}/{land}/{fileName}"; new[]
{
normalizedExportFolder.Trim('/').Trim(),
normalizedLand.Trim('/').Trim(),
fileName
}.Where(segment => !string.IsNullOrWhiteSpace(segment)));
await using var stream = File.OpenRead(localFilePath); await using var stream = File.OpenRead(localFilePath);
await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream); await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
@@ -32,14 +45,53 @@ public class SharePointUploadService : ISharePointUploadService
public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl) public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
{ {
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret); var normalizedTenantId = Normalize(tenantId);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]); var normalizedClientId = Normalize(clientId);
var normalizedClientSecret = Normalize(clientSecret);
var normalizedSiteUrl = Normalize(siteUrl);
var inputPreview = BuildInputPreview(normalizedTenantId, normalizedClientId, normalizedClientSecret, normalizedSiteUrl);
var uri = new Uri(siteUrl); if (string.IsNullOrWhiteSpace(normalizedTenantId))
throw new InvalidOperationException($"Tenant ID fehlt. {inputPreview}");
if (string.IsNullOrWhiteSpace(normalizedClientId))
throw new InvalidOperationException($"Client ID fehlt. {inputPreview}");
if (string.IsNullOrWhiteSpace(normalizedClientSecret))
throw new InvalidOperationException($"Client Secret fehlt. {inputPreview}");
if (string.IsNullOrWhiteSpace(normalizedSiteUrl))
throw new InvalidOperationException($"Site URL fehlt. {inputPreview}");
var credential = new ClientSecretCredential(normalizedTenantId, normalizedClientId, normalizedClientSecret);
try
{
await credential.GetTokenAsync(
new TokenRequestContext(["https://graph.microsoft.com/.default"]),
CancellationToken.None);
}
catch (AuthenticationFailedException ex)
{
throw new InvalidOperationException(
$"ClientSecretCredential authentication failed: {ex.Message}{Environment.NewLine}{inputPreview}",
ex);
}
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var uri = new Uri(normalizedSiteUrl);
var sitePath = uri.AbsolutePath; var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync(); var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null) if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden."); throw new InvalidOperationException($"SharePoint Site konnte nicht gefunden werden. {inputPreview}");
}
private static string Normalize(string value) => value?.Trim() ?? string.Empty;
private static string BuildInputPreview(string tenantId, string clientId, string clientSecret, string siteUrl)
{
var maskedSecret = string.IsNullOrEmpty(clientSecret)
? "<leer>"
: $"{new string('*', Math.Min(clientSecret.Length, 8))} (len={clientSecret.Length})";
return $"Uebergeben: TenantId='{tenantId}', ClientId='{clientId}', ClientSecret={maskedSecret}, SiteUrl='{siteUrl}'";
} }
} }
@@ -7,18 +7,8 @@ public class TransformationCatalog : ITransformationCatalog
public TransformationCatalog(IEnumerable<ITransformationStrategy> valueStrategies, IEnumerable<IRecordTransformationStrategy> recordStrategies) public TransformationCatalog(IEnumerable<ITransformationStrategy> valueStrategies, IEnumerable<IRecordTransformationStrategy> recordStrategies)
{ {
_items = valueStrategies _items = valueStrategies
.Select(x => new TransformationCatalogItem .Select(x => BuildItem(x.TransformationType, "Value", x.Description, x.GetType()))
{ .Concat(recordStrategies.Select(x => BuildItem(x.TransformationType, "Record", x.Description, x.GetType())))
Key = x.TransformationType,
RuleScope = "Value",
Description = x.Description
})
.Concat(recordStrategies.Select(x => new TransformationCatalogItem
{
Key = x.TransformationType,
RuleScope = "Record",
Description = x.Description
}))
.OrderBy(x => x.RuleScope, StringComparer.OrdinalIgnoreCase) .OrderBy(x => x.RuleScope, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase) .ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
@@ -30,4 +20,90 @@ public class TransformationCatalog : ITransformationCatalog
=> _items => _items
.Where(x => string.Equals(x.RuleScope, ruleScope, StringComparison.OrdinalIgnoreCase)) .Where(x => string.Equals(x.RuleScope, ruleScope, StringComparison.OrdinalIgnoreCase))
.ToList(); .ToList();
private static TransformationCatalogItem BuildItem(string key, string ruleScope, string description, Type implementationType)
=> new()
{
Key = key,
RuleScope = ruleScope,
Description = description,
TypeName = implementationType.Name,
SourceFile = implementationType == typeof(FirstNonEmptyRecordTransformationStrategy)
? "Services/TransformationStrategies.cs"
: "Services/TransformationStrategies.cs",
CodeSnippet = GetCodeSnippet(key, ruleScope)
};
private static string GetCodeSnippet(string key, string ruleScope)
=> (ruleScope, key) switch
{
("Value", "Copy") => """
public object? Transform(object? sourceValue, string? argument)
=> sourceValue;
""",
("Value", "Uppercase") => """
public object? Transform(object? sourceValue, string? argument)
=> sourceValue?.ToString()?.ToUpperInvariant();
""",
("Value", "Lowercase") => """
public object? Transform(object? sourceValue, string? argument)
=> sourceValue?.ToString()?.ToLowerInvariant();
""",
("Value", "Prefix") => """
public object? Transform(object? sourceValue, string? argument)
=> $"{argument}{sourceValue}";
""",
("Value", "Suffix") => """
public object? Transform(object? sourceValue, string? argument)
=> $"{sourceValue}{argument}";
""",
("Value", "Replace") => """
public object? Transform(object? sourceValue, string? argument)
{
var input = sourceValue?.ToString();
var parts = argument?.Split("=>", 2, StringSplitOptions.TrimEntries);
return parts?.Length == 2
? input?.Replace(parts[0], parts[1], StringComparison.OrdinalIgnoreCase)
: input;
}
""",
("Value", "Constant") => """
public object? Transform(object? sourceValue, string? argument)
=> argument;
""",
("Value", "NormalizeCurrencyCode") => """
public object? Transform(object? sourceValue, string? argument)
{
var input = sourceValue?.ToString()?.Trim();
return aliases.TryGetValue(input ?? "", out var mapped)
? mapped
: input?.ToUpperInvariant();
}
""",
("Record", "FirstNonEmpty") => """
public void Transform(SalesRecord record, FieldTransformationRule rule)
{
var sourceFields = rule.Argument.Split(['|', ',', ';'], StringSplitOptions.TrimEntries);
foreach (var sourceField in sourceFields)
{
var value = sourceProperty.GetValue(record);
if (IsMeaningfulValue(value))
{
SetPropertyValue(record, targetProperty, value);
return;
}
}
}
""",
("Record", "ConvertCurrency") => """
public void Transform(SalesRecord record, FieldTransformationRule rule)
{
var options = ParseOptions(rule.Argument);
var rate = exchangeRateService.ResolveRate(sourceCurrency, targetCurrency, effectiveDate);
if (rate.HasValue)
SetPropertyValue(record, targetAmountProperty, sourceAmount * rate.Value);
}
""",
_ => "// Kein Snippet hinterlegt."
};
} }
@@ -66,6 +66,56 @@ public sealed class ConstantTransformationStrategy : ITransformationStrategy
public object? Transform(object? sourceValue, string? argument) => argument; public object? Transform(object? sourceValue, string? argument) => argument;
} }
public sealed class NormalizeCurrencyCodeTransformationStrategy : ITransformationStrategy
{
private static readonly Dictionary<string, string> BuiltInAliases = new(StringComparer.OrdinalIgnoreCase)
{
["$"] = "USD",
["US$"] = "USD",
["USD"] = "USD",
["€"] = "EUR",
["EUR"] = "EUR",
["CHF"] = "CHF",
["SFR"] = "CHF",
["INR"] = "INR",
["RS"] = "INR",
["GBP"] = "GBP",
["CAD"] = "CAD"
};
public string TransformationType => "NormalizeCurrencyCode";
public string Description => "Normalisiert Waehrungscodes wie $, EUR, CHF, INR auf ISO-Codes. Optionale Aliase im Argument mit alt=>neu|alt2=>neu2.";
public object? Transform(object? sourceValue, string? argument)
{
var input = sourceValue?.ToString()?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(input))
return string.Empty;
var aliases = new Dictionary<string, string>(BuiltInAliases, StringComparer.OrdinalIgnoreCase);
foreach (var mapping in ParseMappings(argument))
aliases[mapping.Key] = mapping.Value;
return aliases.TryGetValue(input, out var mapped)
? mapped
: input.ToUpperInvariant();
}
private static IEnumerable<KeyValuePair<string, string>> ParseMappings(string? argument)
{
if (string.IsNullOrWhiteSpace(argument))
yield break;
var mappings = argument.Split(['|', ';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var mapping in mappings)
{
var parts = mapping.Split("=>", 2, StringSplitOptions.TrimEntries);
if (parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[0]) && !string.IsNullOrWhiteSpace(parts[1]))
yield return new KeyValuePair<string, string>(parts[0], parts[1].ToUpperInvariant());
}
}
}
public sealed class FirstNonEmptyRecordTransformationStrategy : IRecordTransformationStrategy public sealed class FirstNonEmptyRecordTransformationStrategy : IRecordTransformationStrategy
{ {
public string TransformationType => "FirstNonEmpty"; public string TransformationType => "FirstNonEmpty";
@@ -113,3 +163,101 @@ public sealed class FirstNonEmptyRecordTransformationStrategy : IRecordTransform
return true; return true;
} }
} }
public sealed class ConvertCurrencyRecordTransformationStrategy : IRecordTransformationStrategy
{
private readonly ICurrencyExchangeRateService _exchangeRateService;
public ConvertCurrencyRecordTransformationStrategy(ICurrencyExchangeRateService exchangeRateService)
{
_exchangeRateService = exchangeRateService;
}
public string TransformationType => "ConvertCurrency";
public string Description => "Record-Strategie: rechnet einen Betrag ueber die Kurstabelle in eine Zielwaehrung um. Argument z.B. amountField=SalesPriceValue;currencyField=SalesCurrency;targetCurrency=EUR;dateField=InvoiceDate;targetCurrencyField=SalesCurrency;round=2";
public void Transform(SalesRecord record, FieldTransformationRule rule)
{
if (string.IsNullOrWhiteSpace(rule.TargetField) || string.IsNullOrWhiteSpace(rule.Argument))
return;
var propertyMap = RecordTransformationService.PropertyMap;
if (!propertyMap.TryGetValue(rule.TargetField, out var targetAmountProperty))
return;
var options = ParseOptions(rule.Argument);
if (!options.TryGetValue("amountField", out var amountField)
|| !options.TryGetValue("currencyField", out var currencyField)
|| !options.TryGetValue("targetCurrency", out var targetCurrency)
|| !propertyMap.TryGetValue(amountField, out var sourceAmountProperty)
|| !propertyMap.TryGetValue(currencyField, out var sourceCurrencyProperty))
return;
var sourceAmount = ReadDecimal(record, sourceAmountProperty);
if (sourceAmount is null)
return;
var sourceCurrency = _exchangeRateService.NormalizeCurrencyCode(sourceCurrencyProperty.GetValue(record)?.ToString());
var normalizedTargetCurrency = _exchangeRateService.NormalizeCurrencyCode(targetCurrency);
if (string.IsNullOrWhiteSpace(sourceCurrency) || string.IsNullOrWhiteSpace(normalizedTargetCurrency))
return;
var effectiveDate = ResolveEffectiveDate(record, options, propertyMap);
var rate = _exchangeRateService.ResolveRate(sourceCurrency, normalizedTargetCurrency, effectiveDate);
if (!rate.HasValue)
return;
var convertedAmount = sourceAmount.Value * rate.Value;
if (options.TryGetValue("round", out var roundValue) && int.TryParse(roundValue, out var digits))
convertedAmount = Math.Round(convertedAmount, digits, MidpointRounding.AwayFromZero);
RecordTransformationService.SetPropertyValue(record, targetAmountProperty, convertedAmount);
if (options.TryGetValue("targetCurrencyField", out var targetCurrencyField)
&& propertyMap.TryGetValue(targetCurrencyField, out var targetCurrencyProperty))
{
RecordTransformationService.SetPropertyValue(record, targetCurrencyProperty, normalizedTargetCurrency);
}
}
private static Dictionary<string, string> ParseOptions(string argument)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var parts = argument.Split([';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var pair = part.Split('=', 2, StringSplitOptions.TrimEntries);
if (pair.Length == 2 && !string.IsNullOrWhiteSpace(pair[0]))
result[pair[0]] = pair[1];
}
return result;
}
private static decimal? ReadDecimal(SalesRecord record, System.Reflection.PropertyInfo property)
{
var value = property.GetValue(record);
if (value is decimal decimalValue)
return decimalValue;
return decimal.TryParse(value?.ToString(), out var parsed)
? parsed
: null;
}
private static DateTime? ResolveEffectiveDate(
SalesRecord record,
IReadOnlyDictionary<string, string> options,
IReadOnlyDictionary<string, System.Reflection.PropertyInfo> propertyMap)
{
if (options.TryGetValue("dateField", out var dateField)
&& propertyMap.TryGetValue(dateField, out var configuredDateProperty))
{
var configuredDate = configuredDateProperty.GetValue(record);
if (configuredDate is DateTime date)
return date;
}
return record.InvoiceDate ?? record.OrderDate ?? record.ExtractionDate;
}
}
@@ -0,0 +1,31 @@
namespace TrafagSalesExporter.Services;
public interface IUiTextService
{
string CurrentLanguage { get; }
event Action? Changed;
void SetLanguage(string language);
string Text(string german, string english);
}
public sealed class UiTextService : IUiTextService
{
private string _currentLanguage = "de";
public string CurrentLanguage => _currentLanguage;
public event Action? Changed;
public void SetLanguage(string language)
{
var normalized = string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "de";
if (string.Equals(_currentLanguage, normalized, StringComparison.OrdinalIgnoreCase))
return;
_currentLanguage = normalized;
Changed?.Invoke();
}
public string Text(string german, string english)
=> string.Equals(_currentLanguage, "en", StringComparison.OrdinalIgnoreCase) ? english : german;
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB