englisch
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
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/";
|
||||
using var client = new HttpClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(20);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{sapUsername}:{sapPassword}")));
|
||||
foreach (var url in new[]{ serviceUrl, serviceUrl + "" })
|
||||
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 + "" })
|
||||
{
|
||||
Console.WriteLine($"URL|{url}");
|
||||
using var response = await client.GetAsync(url);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -1,4 +1,6 @@
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@inject TrafagSalesExporter.Services.IUiTextService UiText
|
||||
|
||||
<MudThemeProvider Theme="_theme" />
|
||||
<MudPopoverProvider />
|
||||
@@ -9,8 +11,18 @@
|
||||
<MudAppBar Elevation="1" Color="Color.Primary">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
|
||||
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 />
|
||||
<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" />
|
||||
</MudAppBar>
|
||||
|
||||
@@ -18,7 +30,7 @@
|
||||
<NavMenu />
|
||||
</MudDrawer>
|
||||
|
||||
<MudMainContent Class="pa-4">
|
||||
<MudMainContent Class="pa-4" @key="UiText.CurrentLanguage">
|
||||
@Body
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
@@ -36,5 +48,27 @@
|
||||
}
|
||||
};
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
UiText.Changed += HandleLanguageChanged;
|
||||
}
|
||||
|
||||
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>
|
||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
|
||||
Dashboard
|
||||
@T("Dashboard", "Dashboard")
|
||||
</MudNavLink>
|
||||
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
|
||||
Standorte
|
||||
@T("Standorte", "Sites")
|
||||
</MudNavLink>
|
||||
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
|
||||
Transformationen
|
||||
@T("Transformationen", "Transformations")
|
||||
</MudNavLink>
|
||||
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Analytics">
|
||||
Management Cockpit
|
||||
@T("Management Cockpit", "Management Cockpit")
|
||||
</MudNavLink>
|
||||
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
|
||||
Settings
|
||||
@T("Settings", "Settings")
|
||||
</MudNavLink>
|
||||
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
|
||||
Logs
|
||||
@T("Logs", "Logs")
|
||||
</MudNavLink>
|
||||
</MudNavMenu>
|
||||
|
||||
@code {
|
||||
private string T(string german, string english) => UiText.Text(german, english);
|
||||
}
|
||||
|
||||
@@ -7,32 +7,33 @@
|
||||
@inject ExportOrchestrationService Orchestrator
|
||||
@inject TimerBackgroundService TimerService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IUiTextService UiText
|
||||
@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">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlayArrow"
|
||||
OnClick="ExportAll" Disabled="_anyRunning">
|
||||
Alle exportieren
|
||||
@T("Alle exportieren", "Export all")
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.TableView"
|
||||
OnClick="ExportConsolidatedOnly" Disabled="_anyRunning">
|
||||
Zentrale Datei neu erzeugen
|
||||
@T("Zentrale Datei neu erzeugen", "Rebuild consolidated file")
|
||||
</MudButton>
|
||||
<MudText Typo="Typo.body1">
|
||||
@if (TimerService.NextRun < DateTime.MaxValue)
|
||||
{
|
||||
<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
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.TimerOff" Size="Size.Small" Class="mr-1" />
|
||||
@("Timer deaktiviert")
|
||||
@T("Timer deaktiviert", "Timer disabled")
|
||||
}
|
||||
</MudText>
|
||||
</MudStack>
|
||||
@@ -40,16 +41,16 @@
|
||||
|
||||
<MudTable Items="_dashboardRows" Dense Hover Striped Loading="_loading">
|
||||
<HeaderContent>
|
||||
<MudTh>Land</MudTh>
|
||||
<MudTh>@T("Land", "Country")</MudTh>
|
||||
<MudTh>TSC</MudTh>
|
||||
<MudTh>Schema</MudTh>
|
||||
<MudTh>Server</MudTh>
|
||||
<MudTh>Status</MudTh>
|
||||
<MudTh>Live-Status</MudTh>
|
||||
<MudTh>Zeilen</MudTh>
|
||||
<MudTh>Letzter Lauf</MudTh>
|
||||
<MudTh>Dauer</MudTh>
|
||||
<MudTh>Aktion</MudTh>
|
||||
<MudTh>@T("Schema", "Schema")</MudTh>
|
||||
<MudTh>@T("Server", "Server")</MudTh>
|
||||
<MudTh>@T("Status", "Status")</MudTh>
|
||||
<MudTh>@T("Live-Status", "Live status")</MudTh>
|
||||
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||
<MudTh>@T("Letzter Lauf", "Last run")</MudTh>
|
||||
<MudTh>@T("Dauer", "Duration")</MudTh>
|
||||
<MudTh>@T("Aktion", "Action")</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Land</MudTd>
|
||||
@@ -106,7 +107,7 @@
|
||||
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||
OnClick="() => OpenExportFile(context)"
|
||||
Disabled="@(!context.HasOpenableFile || Orchestrator.IsExporting(context.SiteId))">
|
||||
Excel öffnen
|
||||
@T("Excel oeffnen", "Open Excel")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
@@ -114,14 +115,14 @@
|
||||
</MudTable>
|
||||
|
||||
<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>
|
||||
<HeaderContent>
|
||||
<MudTh>Datei</MudTh>
|
||||
<MudTh>@T("Datei", "File")</MudTh>
|
||||
<MudTh>Pfad</MudTh>
|
||||
<MudTh>Letzte Änderung</MudTh>
|
||||
<MudTh>Status</MudTh>
|
||||
<MudTh>Aktion</MudTh>
|
||||
<MudTh>@T("Status", "Status")</MudTh>
|
||||
<MudTh>@T("Aktion", "Action")</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Label</MudTd>
|
||||
@@ -143,12 +144,12 @@
|
||||
StartIcon="@Icons.Material.Filled.OpenInNew"
|
||||
OnClick="() => OpenFile(context.FilePath)"
|
||||
Disabled="@(!context.HasOpenableFile)">
|
||||
Excel öffnen
|
||||
@T("Excel oeffnen", "Open Excel")
|
||||
</MudButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<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>
|
||||
</MudTable>
|
||||
</MudPaper>
|
||||
@@ -229,7 +230,7 @@
|
||||
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()
|
||||
@@ -249,15 +250,15 @@
|
||||
if (!string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
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
|
||||
{
|
||||
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)
|
||||
@@ -277,15 +278,15 @@
|
||||
if (result?.Log.Status == "OK" && !string.IsNullOrWhiteSpace(result.FilePath))
|
||||
{
|
||||
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))
|
||||
{
|
||||
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()
|
||||
@@ -322,7 +323,7 @@
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -336,7 +337,7 @@
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private string T(string german, string english) => UiText.Text(german, english);
|
||||
}
|
||||
|
||||
@@ -4,46 +4,47 @@
|
||||
@inject IDbContextFactory<AppDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@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">
|
||||
<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)
|
||||
{
|
||||
<MudSelectItem Value="@land">@land</MudSelectItem>
|
||||
}
|
||||
</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="@("Error")">Error</MudSelectItem>
|
||||
</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"
|
||||
StartIcon="@Icons.Material.Filled.FilterAlt">
|
||||
Filtern
|
||||
@T("Filtern", "Filter")
|
||||
</MudButton>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
|
||||
StartIcon="@Icons.Material.Filled.DeleteSweep">
|
||||
Alte Logs löschen
|
||||
@T("Alte Logs loeschen", "Delete old logs")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<MudTable Items="_logs" Dense Hover Striped Loading="_loading">
|
||||
<HeaderContent>
|
||||
<MudTh>Zeitpunkt</MudTh>
|
||||
<MudTh>Land</MudTh>
|
||||
<MudTh>@T("Zeitpunkt", "Timestamp")</MudTh>
|
||||
<MudTh>@T("Land", "Country")</MudTh>
|
||||
<MudTh>TSC</MudTh>
|
||||
<MudTh>Status</MudTh>
|
||||
<MudTh>Zeilen</MudTh>
|
||||
<MudTh>Dauer</MudTh>
|
||||
<MudTh>Dateiname</MudTh>
|
||||
<MudTh>Fehler</MudTh>
|
||||
<MudTh>@T("Status", "Status")</MudTh>
|
||||
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||
<MudTh>@T("Dauer", "Duration")</MudTh>
|
||||
<MudTh>@T("Dateiname", "File name")</MudTh>
|
||||
<MudTh>@T("Fehler", "Error")</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Timestamp.ToString("dd.MM.yyyy HH:mm:ss")</MudTd>
|
||||
@@ -75,15 +76,15 @@
|
||||
</RowTemplate>
|
||||
</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">
|
||||
<HeaderContent>
|
||||
<MudTh>Zeitpunkt</MudTh>
|
||||
<MudTh>@T("Zeitpunkt", "Timestamp")</MudTh>
|
||||
<MudTh>Level</MudTh>
|
||||
<MudTh>Kategorie</MudTh>
|
||||
<MudTh>Land</MudTh>
|
||||
<MudTh>Meldung</MudTh>
|
||||
<MudTh>@T("Kategorie", "Category")</MudTh>
|
||||
<MudTh>@T("Land", "Country")</MudTh>
|
||||
<MudTh>@T("Meldung", "Message")</MudTh>
|
||||
<MudTh>Details</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
@@ -158,9 +159,9 @@
|
||||
private async Task DeleteOldLogs()
|
||||
{
|
||||
var result = await DialogService.ShowMessageBox(
|
||||
"Alte Logs löschen",
|
||||
"Logs älter als 90 Tage löschen?",
|
||||
yesText: "Löschen", cancelText: "Abbrechen");
|
||||
T("Alte Logs loeschen", "Delete old logs"),
|
||||
T("Logs aelter als 90 Tage loeschen?", "Delete logs older than 90 days?"),
|
||||
yesText: T("Loeschen", "Delete"), cancelText: T("Abbrechen", "Cancel"));
|
||||
|
||||
if (result != true) return;
|
||||
|
||||
@@ -170,6 +171,10 @@
|
||||
db.ExportLogs.RemoveRange(oldLogs);
|
||||
var count = await db.SaveChangesAsync();
|
||||
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
|
||||
@inject IManagementCockpitService CockpitService
|
||||
@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">
|
||||
<MudGrid>
|
||||
<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)
|
||||
{
|
||||
<MudSelectItem Value="@file.Path">@file.DisplayName</MudSelectItem>
|
||||
@@ -22,11 +23,11 @@
|
||||
<MudStack Row Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ReloadFiles"
|
||||
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loadingFiles">
|
||||
Dateien laden
|
||||
@T("Dateien laden", "Load files")
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Analyze"
|
||||
StartIcon="@Icons.Material.Filled.Analytics" Disabled="_analyzing || string.IsNullOrWhiteSpace(_selectedFilePath)">
|
||||
@(_analyzing ? "Analysiere..." : "Cockpit erzeugen")
|
||||
@(_analyzing ? T("Analysiere...", "Analyzing...") : T("Cockpit erzeugen", "Build cockpit"))
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
@@ -34,13 +35,13 @@
|
||||
</MudPaper>
|
||||
|
||||
<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">
|
||||
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>
|
||||
<MudGrid>
|
||||
<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)
|
||||
{
|
||||
<MudSelectItem Value="@year">@year</MudSelectItem>
|
||||
@@ -48,7 +49,7 @@
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<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))
|
||||
{
|
||||
<MudSelectItem Value="@((int?)month)">@($"{month:D2}")</MudSelectItem>
|
||||
@@ -58,7 +59,7 @@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="AnalyzeCentral"
|
||||
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>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
@@ -67,14 +68,14 @@
|
||||
@if (_result is not null)
|
||||
{
|
||||
<MudGrid Class="mb-4">
|
||||
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Land</MudText><MudText Typo="Typo.h6">@_result.Summary.Land</MudText></MudPaper></MudItem>
|
||||
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@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">Umsatz</MudText><MudText Typo="Typo.h6">@_result.Summary.SalesValueTotal.ToString("N2")</MudText></MudPaper></MudItem>
|
||||
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">Geschätzte Marge</MudText><MudText Typo="Typo.h6">@($"{_result.Summary.EstimatedMarginPercent:F1}%")</MudText></MudPaper></MudItem>
|
||||
<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">@T("Geschaetzte Marge", "Estimated margin")</MudText><MudText Typo="Typo.h6">@($"{_result.Summary.EstimatedMarginPercent:F1}%")</MudText></MudPaper></MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Management Aussagen</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@T("Management Aussagen", "Management statements")</MudText>
|
||||
@foreach (var finding in _result.Findings)
|
||||
{
|
||||
<MudAlert Severity="@MapSeverity(finding.Severity)" Dense Variant="Variant.Outlined" Class="mb-2">
|
||||
@@ -86,7 +87,7 @@
|
||||
<MudGrid Class="mb-4">
|
||||
<MudItem xs="12" md="4">
|
||||
<MudPaper Class="pa-4" Elevation="1">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Top Kunden</MudText>
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Kunden", "Top customers")</MudText>
|
||||
@foreach (var item in _result.TopCustomers)
|
||||
{
|
||||
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
|
||||
@@ -95,7 +96,7 @@
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<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)
|
||||
{
|
||||
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
|
||||
@@ -104,7 +105,7 @@
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<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)
|
||||
{
|
||||
<MudText Typo="Typo.body2">@($"{item.Label}: {item.Value:N2} ({item.SharePercent:F1}%)")</MudText>
|
||||
@@ -114,7 +115,7 @@
|
||||
</MudGrid>
|
||||
|
||||
<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))
|
||||
{
|
||||
<MudText Typo="Typo.body2">@($"{entry.Key}: {entry.Value}")</MudText>
|
||||
@@ -125,16 +126,16 @@
|
||||
@if (_centralResult is not null)
|
||||
{
|
||||
<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">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">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">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">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">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("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">@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">@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">@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">@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">@T("Periode", "Period")</MudText><MudText Typo="Typo.h6">@BuildPeriodLabel(_centralResult)</MudText></MudPaper></MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<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)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert>
|
||||
@@ -144,13 +145,13 @@
|
||||
<MudGrid Class="mb-4">
|
||||
<MudItem xs="12" md="6">
|
||||
<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>
|
||||
<HeaderContent>
|
||||
<MudTh>Jahr</MudTh>
|
||||
<MudTh>Währung</MudTh>
|
||||
<MudTh>Umsatz</MudTh>
|
||||
<MudTh>Zeilen</MudTh>
|
||||
<MudTh>@T("Jahr", "Year")</MudTh>
|
||||
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||
<MudTh>@T("Umsatz", "Sales")</MudTh>
|
||||
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Year</MudTd>
|
||||
@@ -163,13 +164,13 @@
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<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>
|
||||
<HeaderContent>
|
||||
<MudTh>Monat</MudTh>
|
||||
<MudTh>Währung</MudTh>
|
||||
<MudTh>Umsatz</MudTh>
|
||||
<MudTh>Zeilen</MudTh>
|
||||
<MudTh>@T("Monat", "Month")</MudTh>
|
||||
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||
<MudTh>@T("Umsatz", "Sales")</MudTh>
|
||||
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Label</MudTd>
|
||||
@@ -185,13 +186,13 @@
|
||||
<MudGrid Class="mb-4">
|
||||
<MudItem xs="12" md="6">
|
||||
<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>
|
||||
<HeaderContent>
|
||||
<MudTh>Tag</MudTh>
|
||||
<MudTh>Währung</MudTh>
|
||||
<MudTh>Umsatz</MudTh>
|
||||
<MudTh>Zeilen</MudTh>
|
||||
<MudTh>@T("Tag", "Day")</MudTh>
|
||||
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||
<MudTh>@T("Umsatz", "Sales")</MudTh>
|
||||
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Label</MudTd>
|
||||
@@ -200,20 +201,20 @@
|
||||
<MudTd>@context.RowCount.ToString("N0")</MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText Typo="Typo.caption">Fü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>
|
||||
</MudTable>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="6">
|
||||
<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>
|
||||
<HeaderContent>
|
||||
<MudTh>Quelle</MudTh>
|
||||
<MudTh>Währung</MudTh>
|
||||
<MudTh>Umsatz</MudTh>
|
||||
<MudTh>Rechnungen</MudTh>
|
||||
<MudTh>@T("Quelle", "Source")</MudTh>
|
||||
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||
<MudTh>@T("Umsatz", "Sales")</MudTh>
|
||||
<MudTh>@T("Rechnungen", "Invoices")</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Label</MudTd>
|
||||
@@ -227,14 +228,14 @@
|
||||
</MudGrid>
|
||||
|
||||
<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>
|
||||
<HeaderContent>
|
||||
<MudTh>Land</MudTh>
|
||||
<MudTh>Währung</MudTh>
|
||||
<MudTh>Umsatz</MudTh>
|
||||
<MudTh>Rechnungen</MudTh>
|
||||
<MudTh>Zeilen</MudTh>
|
||||
<MudTh>@T("Land", "Country")</MudTh>
|
||||
<MudTh>@T("Waehrung", "Currency")</MudTh>
|
||||
<MudTh>@T("Umsatz", "Sales")</MudTh>
|
||||
<MudTh>@T("Rechnungen", "Invoices")</MudTh>
|
||||
<MudTh>@T("Zeilen", "Rows")</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Label</MudTd>
|
||||
@@ -298,7 +299,7 @@
|
||||
}
|
||||
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
|
||||
{
|
||||
@@ -318,7 +319,7 @@
|
||||
}
|
||||
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
|
||||
{
|
||||
@@ -341,3 +342,7 @@
|
||||
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 ISapGatewayService SapGatewayService
|
||||
@inject IConfigTransferService ConfigTransferService
|
||||
@inject IExchangeRateImportService ExchangeRateImportService
|
||||
@inject IJSRuntime JS
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@@ -51,6 +52,11 @@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudTextField @bind-Value="_spConfig.ExportFolder" Label="Export Folder" />
|
||||
</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">
|
||||
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
|
||||
</MudItem>
|
||||
@@ -80,6 +86,15 @@
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</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>
|
||||
</MudPaper>
|
||||
|
||||
@@ -151,6 +166,70 @@
|
||||
</MudGrid>
|
||||
</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 *@
|
||||
<MudText Typo="Typo.h5" Class="mb-2">Export Einstellungen</MudText>
|
||||
<MudPaper Class="pa-4 mb-6" Elevation="1">
|
||||
@@ -210,6 +289,9 @@
|
||||
private bool _includeSecretsInExport;
|
||||
private bool _exportingConfig;
|
||||
private bool _importingConfig;
|
||||
private bool _refreshingExchangeRates;
|
||||
private string _sharePointTestPreview = string.Empty;
|
||||
private List<CurrencyExchangeRate> _exchangeRates = [];
|
||||
private readonly HashSet<string> _testingSystems = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -217,6 +299,11 @@
|
||||
using var db = await DbFactory.CreateDbContextAsync();
|
||||
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
|
||||
_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()
|
||||
@@ -231,6 +318,7 @@
|
||||
{
|
||||
existing.SiteUrl = _spConfig.SiteUrl;
|
||||
existing.ExportFolder = _spConfig.ExportFolder;
|
||||
existing.CentralExportFolder = _spConfig.CentralExportFolder;
|
||||
existing.TenantId = _spConfig.TenantId;
|
||||
existing.ClientId = _spConfig.ClientId;
|
||||
existing.ClientSecret = _spConfig.ClientSecret;
|
||||
@@ -244,8 +332,15 @@
|
||||
_testingSp = true;
|
||||
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(
|
||||
_spConfig.TenantId, _spConfig.ClientId, _spConfig.ClientSecret, _spConfig.SiteUrl);
|
||||
tenantId, clientId, clientSecret, siteUrl);
|
||||
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -287,6 +382,76 @@
|
||||
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()
|
||||
{
|
||||
if (_exportingConfig)
|
||||
@@ -328,6 +493,11 @@
|
||||
using var db = await DbFactory.CreateDbContextAsync();
|
||||
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
|
||||
_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();
|
||||
Snackbar.Add("Konfiguration importiert", Severity.Success);
|
||||
}
|
||||
@@ -466,4 +636,31 @@
|
||||
"SAGE" => _exportSettings.SagePassword,
|
||||
_ => _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))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase)
|
||||
?? [];
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,12 @@
|
||||
@inject IDbContextFactory<AppDbContext> DbFactory
|
||||
@inject ITransformationCatalog TransformationCatalog
|
||||
@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.body1" Class="mb-4">Definiere pro Quellsystem einfache Feldregeln und komplexe record-basierte Strategien.</MudText>
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@T("Transformer Ansicht", "Transformation view")</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">
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mb-3">
|
||||
@@ -20,10 +21,10 @@
|
||||
|
||||
<MudStack Row="true" Spacing="2" Class="mb-3">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
|
||||
Regel hinzufuegen
|
||||
@T("Regel hinzufuegen", "Add rule")
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
|
||||
Alle speichern
|
||||
@T("Alle speichern", "Save all")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@@ -34,9 +35,10 @@
|
||||
<MudTh>Scope</MudTh>
|
||||
<MudTh>Source</MudTh>
|
||||
<MudTh>Target</MudTh>
|
||||
<MudTh>Typ</MudTh>
|
||||
<MudTh>Typ / Klasse</MudTh>
|
||||
<MudTh>Argument</MudTh>
|
||||
<MudTh>Sort</MudTh>
|
||||
<MudTh>Info</MudTh>
|
||||
<MudTh>Aktionen</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
@@ -58,12 +60,19 @@
|
||||
</MudSelect>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudSelect T="string" Value="@context.SourceField" ValueChanged="@(v => context.SourceField = v)" Dense Disabled="@IsRecordScope(context)">
|
||||
@foreach (var field in _recordFields)
|
||||
{
|
||||
<MudSelectItem Value="@field">@field</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (IsRecordScope(context))
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small" Text="Record-Regel" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<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>
|
||||
<MudSelect T="string" Value="@context.TargetField" ValueChanged="@(v => context.TargetField = v)" Dense>
|
||||
@@ -74,12 +83,26 @@
|
||||
</MudSelect>
|
||||
</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>
|
||||
@if (IsRecordScope(context))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mt-1">
|
||||
Hier waehlt man die registrierte C#-Strategie.
|
||||
</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudTextField T="string" Value="@context.Argument" ValueChanged="@(v => context.Argument = v)"
|
||||
@@ -88,6 +111,20 @@
|
||||
<MudTd>
|
||||
<MudNumericField T="int" Value="@context.SortOrder" ValueChanged="@(v => context.SortOrder = v)" Dense />
|
||||
</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>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
|
||||
OnClick="() => RemoveRule(context)" />
|
||||
@@ -96,6 +133,48 @@
|
||||
</MudTable>
|
||||
</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 {
|
||||
private readonly string[] _systems = ["SAP", "BI1", "SAGE", "MANUAL_EXCEL"];
|
||||
private readonly string[] _ruleScopes = ["Value", "Record"];
|
||||
@@ -107,6 +186,10 @@
|
||||
|
||||
private List<FieldTransformationRule> _rules = new();
|
||||
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()
|
||||
{
|
||||
@@ -158,7 +241,7 @@
|
||||
db.FieldTransformationRules.AddRange(_rules);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Snackbar.Add("Transformationsregeln gespeichert.", Severity.Success);
|
||||
Snackbar.Add(T("Transformationsregeln gespeichert.", "Transformation rules saved."), Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -190,6 +273,45 @@
|
||||
string.Equals(x.RuleScope, rule.RuleScope, 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);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public class AppDbContext : DbContext
|
||||
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
|
||||
public DbSet<AppEventLog> AppEventLogs => Set<AppEventLog>();
|
||||
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
|
||||
public DbSet<CurrencyExchangeRate> CurrencyExchangeRates => Set<CurrencyExchangeRate>();
|
||||
public DbSet<SapSourceDefinition> SapSourceDefinitions => Set<SapSourceDefinition>();
|
||||
public DbSet<SapJoinDefinition> SapJoinDefinitions => Set<SapJoinDefinition>();
|
||||
public DbSet<SapFieldMapping> SapFieldMappings => Set<SapFieldMapping>();
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
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
|
||||
|
||||
Die App wurde von einem reinen BI1/HANA-Exporter zu einer kombinierten Plattform erweitert:
|
||||
@@ -211,6 +215,132 @@ Aktuell enthalten:
|
||||
- Datenqualitaetshinweise
|
||||
- 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
|
||||
|
||||
### Modelle
|
||||
@@ -224,6 +354,7 @@ Aktuell enthalten:
|
||||
- `Models/SapFieldMapping.cs`
|
||||
- `Models/ManagementCockpitModels.cs`
|
||||
- `Models/ConfigTransferPackage.cs`
|
||||
- `Models/FieldTransformationRule.cs`
|
||||
|
||||
### Services
|
||||
|
||||
@@ -237,6 +368,10 @@ Aktuell enthalten:
|
||||
- `Services/ManagementCockpitService.cs`
|
||||
- `Services/DatabaseInitializationService.cs`
|
||||
- `Services/ExportOrchestrationService.cs`
|
||||
- `Services/ManualExcelImportService.cs`
|
||||
- `Services/TransformationCatalog.cs`
|
||||
- `Services/RecordTransformationService.cs`
|
||||
- `Services/TransformationStrategies.cs`
|
||||
|
||||
### UI
|
||||
|
||||
@@ -245,8 +380,18 @@ Aktuell enthalten:
|
||||
- `Components/Pages/Dashboard.razor`
|
||||
- `Components/Pages/Logs.razor`
|
||||
- `Components/Pages/ManagementCockpit.razor`
|
||||
- `Components/Pages/Transformations.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
|
||||
|
||||
Viele Aenderungen laufen ueber `DatabaseInitializationService`.
|
||||
@@ -261,11 +406,15 @@ Wichtige neue oder erweiterte Tabellen/Felder:
|
||||
- `SapEntitySetsCache`
|
||||
- `SapEntitySetsRefreshedAtUtc`
|
||||
- `LocalExportFolderOverride`
|
||||
- `ManualImportFilePath`
|
||||
- `ManualImportLastUploadedAtUtc`
|
||||
- `ExportSettings`
|
||||
- zentrale SAP/BI1/SAGE Credentials
|
||||
- `LocalSiteExportFolder`
|
||||
- `LocalConsolidatedExportFolder`
|
||||
- `DebugLoggingEnabled`
|
||||
- `FieldTransformationRules`
|
||||
- `RuleScope`
|
||||
- `ExportLogs`
|
||||
- `FilePath`
|
||||
- neue Tabellen:
|
||||
|
||||
@@ -7,6 +7,7 @@ public class ConfigTransferPackage
|
||||
public bool IncludesSecrets { get; set; }
|
||||
public ConfigTransferSharePoint? SharePointConfig { get; set; }
|
||||
public ConfigTransferExportSettings? ExportSettings { get; set; }
|
||||
public List<ConfigTransferCurrencyExchangeRate> CurrencyExchangeRates { get; set; } = [];
|
||||
public List<ConfigTransferHanaServer> HanaServers { get; set; } = [];
|
||||
public List<ConfigTransferSite> Sites { get; set; } = [];
|
||||
public List<FieldTransformationRule> FieldTransformationRules { get; set; } = [];
|
||||
@@ -19,6 +20,7 @@ public class ConfigTransferSharePoint
|
||||
{
|
||||
public string SiteUrl { 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 ClientId { get; set; } = string.Empty;
|
||||
public string? ClientSecret { get; set; }
|
||||
@@ -41,6 +43,17 @@ public class ConfigTransferExportSettings
|
||||
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 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 string SiteUrl { 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 ClientId { get; set; } = string.Empty;
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
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
|
||||
|
||||
Der Export geht jetzt wieder durch.
|
||||
@@ -36,6 +51,40 @@ Kurz gegenpruefen:
|
||||
- `Excel oeffnen` nach erfolgreichem Export
|
||||
- `Export erfolgreich` inkl. `Pfad=...`
|
||||
- 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
|
||||
|
||||
@@ -68,12 +117,41 @@ Zu testen:
|
||||
- vorhandene Excel-Datei auswaehlbar
|
||||
- Analyse laeuft
|
||||
- Kennzahlen plausibel
|
||||
- Roh-Auswertung aus `CentralSalesRecords` laeuft
|
||||
- Jahr/Monat-Filter funktionieren
|
||||
- Summen nach Quelle / Land plausibel
|
||||
|
||||
Dateien:
|
||||
|
||||
- `Components/Pages/ManagementCockpit.razor`
|
||||
- `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
|
||||
|
||||
Fuer den vollstaendigen Kontext zuerst lesen:
|
||||
|
||||
@@ -9,6 +9,7 @@ builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
builder.Services.AddMudServices();
|
||||
builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
|
||||
|
||||
builder.Services.AddDbContextFactory<AppDbContext>(options =>
|
||||
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, ReplaceTransformationStrategy>();
|
||||
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, ConvertCurrencyRecordTransformationStrategy>();
|
||||
builder.Services.AddSingleton<ITransformationCatalog, TransformationCatalog>();
|
||||
builder.Services.AddSingleton<IRecordTransformationService, RecordTransformationService>();
|
||||
builder.Services.AddSingleton<IAppEventLogService, AppEventLogService>();
|
||||
@@ -37,6 +42,7 @@ builder.Services.AddSingleton<IExportLogService, ExportLogService>();
|
||||
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
|
||||
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
|
||||
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
|
||||
builder.Services.AddSingleton<IUiTextService, UiTextService>();
|
||||
builder.Services.AddSingleton<ExportOrchestrationService>();
|
||||
builder.Services.AddSingleton<TimerBackgroundService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>());
|
||||
|
||||
@@ -20,6 +20,11 @@ public class ConfigTransferService : IConfigTransferService
|
||||
using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var sharePoint = await db.SharePointConfigs.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 sites = await db.Sites.OrderBy(x => x.Land).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,
|
||||
ExportFolder = sharePoint.ExportFolder,
|
||||
CentralExportFolder = sharePoint.CentralExportFolder,
|
||||
TenantId = sharePoint.TenantId,
|
||||
ClientId = sharePoint.ClientId,
|
||||
ClientSecret = includeSecrets ? sharePoint.ClientSecret : null
|
||||
@@ -57,6 +63,16 @@ public class ConfigTransferService : IConfigTransferService
|
||||
SageUsername = includeSecrets ? exportSettings.SageUsername : 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
|
||||
{
|
||||
Key = serverKeyMap[server.Id],
|
||||
@@ -143,6 +159,7 @@ public class ConfigTransferService : IConfigTransferService
|
||||
var existingSharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
|
||||
var existingSettings = await db.ExportSettings.FirstOrDefaultAsync();
|
||||
var existingServers = await db.HanaServers.ToListAsync();
|
||||
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
|
||||
var existingSites = await db.Sites.ToListAsync();
|
||||
var existingRules = await db.FieldTransformationRules.ToListAsync();
|
||||
var existingSapSources = await db.SapSourceDefinitions.ToListAsync();
|
||||
@@ -173,6 +190,7 @@ public class ConfigTransferService : IConfigTransferService
|
||||
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
|
||||
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
|
||||
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 (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
|
||||
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
|
||||
@@ -184,6 +202,7 @@ public class ConfigTransferService : IConfigTransferService
|
||||
{
|
||||
SiteUrl = package.SharePointConfig.SiteUrl,
|
||||
ExportFolder = package.SharePointConfig.ExportFolder,
|
||||
CentralExportFolder = package.SharePointConfig.CentralExportFolder,
|
||||
TenantId = package.SharePointConfig.TenantId,
|
||||
ClientId = package.SharePointConfig.ClientId,
|
||||
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
|
||||
});
|
||||
|
||||
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);
|
||||
foreach (var server in package.HanaServers)
|
||||
{
|
||||
|
||||
@@ -49,9 +49,15 @@ public class ConsolidatedExportService : IConsolidatedExportService
|
||||
!string.IsNullOrWhiteSpace(spConfig.ClientId) &&
|
||||
!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(
|
||||
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
|
||||
spConfig.SiteUrl, spConfig.ExportFolder, "Alle", consolidatedPath);
|
||||
spConfig.SiteUrl, sharePointFolder, landSubfolder, 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);
|
||||
EnsureSchema(db);
|
||||
SeedIfEmpty(db);
|
||||
EnsureRecommendedTransformationRules(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", "LocalSiteExportFolder", "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 ''");
|
||||
EnsureTransformationTable(db);
|
||||
AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'");
|
||||
EnsureCurrencyExchangeRateTable(db);
|
||||
EnsureSapSourceTable(db);
|
||||
EnsureSapJoinTable(db);
|
||||
EnsureSapFieldMappingTable(db);
|
||||
@@ -470,6 +473,27 @@ CREATE TABLE IF NOT EXISTS SapSourceDefinitions (
|
||||
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)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
@@ -601,6 +625,7 @@ CREATE TABLE IF NOT EXISTS AppEventLogs (
|
||||
{
|
||||
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
|
||||
ExportFolder = "/Shared Documents/Exports/",
|
||||
CentralExportFolder = "",
|
||||
TenantId = "",
|
||||
ClientId = "",
|
||||
ClientSecret = ""
|
||||
@@ -619,4 +644,55 @@ CREATE TABLE IF NOT EXISTS AppEventLogs (
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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
|
||||
{
|
||||
List<SalesRecord> GetSalesRecords(HanaServer server, string schema, string tsc, string land, string dateFilter);
|
||||
List<string> GetAvailableSchemas(HanaServer server);
|
||||
ConnectionTestResult TestConnectionDetailed(HanaServer server);
|
||||
void TestConnection(HanaServer server);
|
||||
}
|
||||
|
||||
@@ -11,4 +11,7 @@ public sealed class TransformationCatalogItem
|
||||
public string Key { get; init; } = string.Empty;
|
||||
public string RuleScope { 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 Microsoft.Graph;
|
||||
|
||||
@@ -8,10 +9,17 @@ public class SharePointUploadService : ISharePointUploadService
|
||||
public async Task UploadAsync(string tenantId, string clientId, string clientSecret,
|
||||
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 uri = new Uri(siteUrl);
|
||||
var uri = new Uri(normalizedSiteUrl);
|
||||
var sitePath = uri.AbsolutePath;
|
||||
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.");
|
||||
|
||||
var fileName = Path.GetFileName(localFilePath);
|
||||
var folderPath = exportFolder.Trim('/').Trim();
|
||||
var remotePath = $"{folderPath}/{land}/{fileName}";
|
||||
var remotePath = string.Join("/",
|
||||
new[]
|
||||
{
|
||||
normalizedExportFolder.Trim('/').Trim(),
|
||||
normalizedLand.Trim('/').Trim(),
|
||||
fileName
|
||||
}.Where(segment => !string.IsNullOrWhiteSpace(segment)));
|
||||
|
||||
await using var stream = File.OpenRead(localFilePath);
|
||||
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)
|
||||
{
|
||||
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
|
||||
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
|
||||
var normalizedTenantId = Normalize(tenantId);
|
||||
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 site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
|
||||
|
||||
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)
|
||||
{
|
||||
_items = valueStrategies
|
||||
.Select(x => new TransformationCatalogItem
|
||||
{
|
||||
Key = x.TransformationType,
|
||||
RuleScope = "Value",
|
||||
Description = x.Description
|
||||
})
|
||||
.Concat(recordStrategies.Select(x => new TransformationCatalogItem
|
||||
{
|
||||
Key = x.TransformationType,
|
||||
RuleScope = "Record",
|
||||
Description = x.Description
|
||||
}))
|
||||
.Select(x => BuildItem(x.TransformationType, "Value", x.Description, x.GetType()))
|
||||
.Concat(recordStrategies.Select(x => BuildItem(x.TransformationType, "Record", x.Description, x.GetType())))
|
||||
.OrderBy(x => x.RuleScope, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
@@ -30,4 +20,90 @@ public class TransformationCatalog : ITransformationCatalog
|
||||
=> _items
|
||||
.Where(x => string.Equals(x.RuleScope, ruleScope, StringComparison.OrdinalIgnoreCase))
|
||||
.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 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 string TransformationType => "FirstNonEmpty";
|
||||
@@ -113,3 +163,101 @@ public sealed class FirstNonEmptyRecordTransformationStrategy : IRecordTransform
|
||||
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 |
Reference in New Issue
Block a user