umfangreiches refactoring

This commit is contained in:
2026-04-17 13:56:41 +02:00
parent eb187cdc15
commit 2a56ba53ba
21 changed files with 2401 additions and 1905 deletions
@@ -1,9 +1,7 @@
@page "/"
@using Microsoft.EntityFrameworkCore
@using System.Diagnostics
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject IDashboardPageService DashboardPageActions
@inject ExportOrchestrationService Orchestrator
@inject TimerBackgroundService TimerService
@inject ISnackbar Snackbar
@@ -170,49 +168,9 @@
private async Task LoadDataAsync()
{
_loading = true;
using var db = await DbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
var sourceSystems = await db.SourceSystemDefinitions.AsNoTracking().ToListAsync();
var logs = await db.ExportLogs
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync();
var appLogs = await db.AppEventLogs
.Where(l => l.SiteId != null)
.OrderByDescending(l => l.Timestamp)
.Take(1000)
.ToListAsync();
var latestAppLogsBySite = appLogs
.GroupBy(l => l.SiteId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Timestamp).First());
_dashboardRows = sites.Select(s =>
{
var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
latestAppLogsBySite.TryGetValue(s.Id, out var appLog);
var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, s.SourceSystem, StringComparison.OrdinalIgnoreCase));
return new DashboardRow
{
SiteId = s.Id,
Land = s.Land,
TSC = s.TSC,
Schema = s.Schema,
ServerName = string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)
? ResolveDashboardSapServiceUrl(s, sourceSystems)
: s.HanaServer?.Name ?? "",
LastStatus = log?.Status ?? "",
RowCount = log?.RowCount ?? 0,
LastRun = log?.Timestamp,
DurationSeconds = log?.DurationSeconds ?? 0,
ErrorMessage = log?.ErrorMessage ?? "",
FilePath = log?.FilePath ?? "",
LiveMessage = appLog is null ? string.Empty : $"{appLog.Category}: {appLog.Message}",
LiveDetails = appLog?.Details ?? ""
};
}).ToList();
_consolidatedRows = BuildConsolidatedRows(settings: await db.ExportSettings.FirstOrDefaultAsync() ?? new());
var state = await DashboardPageActions.LoadAsync();
_dashboardRows = state.DashboardRows;
_consolidatedRows = state.ConsolidatedRows;
_anyRunning = _dashboardRows.Any(r => Orchestrator.IsExporting(r.SiteId)) || Orchestrator.IsConsolidatedExporting();
_loading = false;
@@ -321,15 +279,6 @@
OpenFile(row.FilePath);
}
private static string ResolveDashboardSapServiceUrl(Site site, List<SourceSystemDefinition> sourceSystems)
{
if (!string.IsNullOrWhiteSpace(site.SapServiceUrl))
return site.SapServiceUrl;
var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, site.SourceSystem, StringComparison.OrdinalIgnoreCase));
return string.IsNullOrWhiteSpace(sourceSystem?.CentralServiceUrl) ? "SAP Gateway" : sourceSystem.CentralServiceUrl;
}
private void OpenFile(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
@@ -417,63 +366,6 @@
return Task.CompletedTask;
}
private static List<ConsolidatedDashboardRow> BuildConsolidatedRows(ExportSettings settings)
{
var outputDirectory = ResolveConsolidatedOutputDirectory(settings);
if (!Directory.Exists(outputDirectory))
return [];
return Directory.GetFiles(outputDirectory, "Sales_All_*.xlsx")
.Select(path => new FileInfo(path))
.OrderByDescending(file => file.LastWriteTime)
.Take(1)
.Select(file => new ConsolidatedDashboardRow
{
Label = "Konsolidierter Export",
FilePath = file.FullName,
DisplayPath = file.FullName,
LastModified = file.LastWriteTime
})
.ToList();
}
private static string ResolveConsolidatedOutputDirectory(ExportSettings settings)
{
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
return settings.LocalConsolidatedExportFolder.Trim();
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
return settings.LocalSiteExportFolder.Trim();
return Path.Combine(AppContext.BaseDirectory, "output");
}
private class DashboardRow
{
public int SiteId { get; set; }
public string Land { get; set; } = "";
public string TSC { get; set; } = "";
public string Schema { get; set; } = "";
public string ServerName { get; set; } = "";
public string LastStatus { get; set; } = "";
public int RowCount { get; set; }
public DateTime? LastRun { get; set; }
public double DurationSeconds { get; set; }
public string ErrorMessage { get; set; } = "";
public string FilePath { get; set; } = "";
public string LiveMessage { get; set; } = "";
public string LiveDetails { get; set; } = "";
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath);
}
private class ConsolidatedDashboardRow
{
public string Label { get; set; } = "";
public string FilePath { get; set; } = "";
public string DisplayPath { get; set; } = "";
public DateTime? LastModified { get; set; }
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath);
}
}
@code {
@@ -1,7 +1,6 @@
@page "/logs"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@inject IDbContextFactory<AppDbContext> DbFactory
@using TrafagSalesExporter.Services
@inject ILogsPageService LogsPageActions
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject TrafagSalesExporter.Services.IUiTextService UiText
@@ -117,37 +116,16 @@
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_availableLands = await db.ExportLogs.Select(l => l.Land).Distinct().OrderBy(l => l).ToListAsync();
await LoadLogsAsync();
}
private async Task LoadLogsAsync()
{
_loading = true;
using var db = await DbFactory.CreateDbContextAsync();
IQueryable<ExportLog> query = db.ExportLogs.OrderByDescending(l => l.Timestamp);
if (!string.IsNullOrEmpty(_filterLand))
query = query.Where(l => l.Land == _filterLand);
if (!string.IsNullOrEmpty(_filterStatus))
query = query.Where(l => l.Status == _filterStatus);
if (_filterDate.HasValue)
query = query.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
_logs = await query.Take(500).ToListAsync();
IQueryable<AppEventLog> appLogQuery = db.AppEventLogs.OrderByDescending(l => l.Timestamp);
if (!string.IsNullOrEmpty(_filterLand))
appLogQuery = appLogQuery.Where(l => l.Land == _filterLand);
if (_filterDate.HasValue)
appLogQuery = appLogQuery.Where(l => l.Timestamp.Date == _filterDate.Value.Date);
_appLogs = await appLogQuery.Take(500).ToListAsync();
var state = await LogsPageActions.LoadAsync(_filterLand, _filterStatus, _filterDate);
_availableLands = state.AvailableLands;
_logs = state.Logs;
_appLogs = state.AppLogs;
_loading = false;
}
@@ -165,13 +143,9 @@
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var cutoff = DateTime.Now.AddDays(-90);
var oldLogs = await db.ExportLogs.Where(l => l.Timestamp < cutoff).ToListAsync();
db.ExportLogs.RemoveRange(oldLogs);
var count = await db.SaveChangesAsync();
var deletedCount = await LogsPageActions.DeleteOldLogsAsync(90);
await LoadLogsAsync();
Snackbar.Add(string.Format(T("{0} alte Logs geloescht", "{0} old logs deleted"), oldLogs.Count), Severity.Info);
Snackbar.Add(string.Format(T("{0} alte Logs geloescht", "{0} old logs deleted"), deletedCount), Severity.Info);
}
}
@@ -1,7 +1,7 @@
@page "/management-cockpit"
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IManagementCockpitService CockpitService
@inject IManagementCockpitPageService CockpitPageService
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@@ -335,8 +335,11 @@
protected override async Task OnInitializedAsync()
{
await ReloadFiles();
await ReloadCentralYears();
var state = await CockpitPageService.InitializeAsync(_selectedFilePath, _selectedCentralYear);
_files = state.Files;
_centralYears = state.CentralYears;
_selectedFilePath = state.SelectedFilePath;
_selectedCentralYear = state.SelectedCentralYear;
}
private async Task ReloadFiles()
@@ -344,7 +347,7 @@
_loadingFiles = true;
try
{
_files = await CockpitService.GetAvailableFilesAsync();
_files = await CockpitPageService.LoadFilesAsync();
_selectedFilePath ??= _files.FirstOrDefault()?.Path;
}
finally
@@ -355,7 +358,7 @@
private async Task ReloadCentralYears()
{
_centralYears = await CockpitService.GetAvailableCentralYearsAsync();
_centralYears = await CockpitPageService.LoadCentralYearsAsync();
if (_selectedCentralYear == 0)
_selectedCentralYear = _centralYears.LastOrDefault();
}
@@ -368,7 +371,7 @@
_analyzing = true;
try
{
_result = await CockpitService.AnalyzeAsync(_selectedFilePath);
_result = await CockpitPageService.AnalyzeAsync(_selectedFilePath);
}
catch (Exception ex)
{
@@ -388,7 +391,7 @@
_analyzingCentral = true;
try
{
_centralResult = await CockpitService.AnalyzeCentralAsync(_selectedCentralYear, _selectedCentralMonth);
_centralResult = await CockpitPageService.AnalyzeCentralAsync(_selectedCentralYear, _selectedCentralMonth);
}
catch (Exception ex)
{
@@ -1,15 +1,7 @@
@page "/settings"
@using Microsoft.EntityFrameworkCore
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ISharePointUploadService SpService
@inject TimerBackgroundService TimerService
@inject IHanaQueryService HanaService
@inject ISapGatewayService SapGatewayService
@inject IConfigTransferService ConfigTransferService
@inject IExchangeRateImportService ExchangeRateImportService
@inject ISettingsPageService SettingsPageActions
@inject IJSRuntime JS
@inject ISnackbar Snackbar
@@ -328,35 +320,16 @@
protected override async Task OnInitializedAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
_exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
_sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync();
_exchangeRates = await db.CurrencyExchangeRates
.OrderBy(x => x.FromCurrency)
.ThenBy(x => x.ToCurrency)
.ThenByDescending(x => x.ValidFrom)
.ToListAsync();
var state = await SettingsPageActions.LoadAsync();
_spConfig = state.SharePointConfig;
_exportSettings = state.ExportSettings;
_sourceSystems = state.SourceSystems;
_exchangeRates = state.ExchangeRates;
}
private async Task SaveSharePoint()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.SharePointConfigs.FirstOrDefaultAsync();
if (existing is null)
{
db.SharePointConfigs.Add(_spConfig);
}
else
{
existing.SiteUrl = _spConfig.SiteUrl;
existing.ExportFolder = _spConfig.ExportFolder;
existing.CentralExportFolder = _spConfig.CentralExportFolder;
existing.TenantId = _spConfig.TenantId;
existing.ClientId = _spConfig.ClientId;
existing.ClientSecret = _spConfig.ClientSecret;
}
await db.SaveChangesAsync();
await SettingsPageActions.SaveSharePointAsync(_spConfig);
Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success);
}
@@ -365,15 +338,7 @@
_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(
tenantId, clientId, clientSecret, siteUrl);
_sharePointTestPreview = await SettingsPageActions.BuildSharePointTestPreviewAsync(_spConfig);
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
}
catch (Exception ex)
@@ -388,24 +353,7 @@
private async Task SaveExportSettings()
{
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.ExportSettings.FirstOrDefaultAsync();
if (existing is null)
{
db.ExportSettings.Add(_exportSettings);
}
else
{
existing.DateFilter = _exportSettings.DateFilter;
existing.TimerHour = _exportSettings.TimerHour;
existing.TimerMinute = _exportSettings.TimerMinute;
existing.TimerEnabled = _exportSettings.TimerEnabled;
existing.DebugLoggingEnabled = _exportSettings.DebugLoggingEnabled;
existing.LocalSiteExportFolder = _exportSettings.LocalSiteExportFolder;
existing.LocalConsolidatedExportFolder = _exportSettings.LocalConsolidatedExportFolder;
}
await db.SaveChangesAsync();
TimerService.Recalculate();
await SettingsPageActions.SaveExportSettingsAsync(_exportSettings);
Snackbar.Add("Export Einstellungen gespeichert", Severity.Success);
}
@@ -493,47 +441,16 @@
private async Task SaveSourceSystems()
{
var normalized = _sourceSystems
.Select(x => new SourceSystemDefinition
try
{
Id = x.Id,
Code = NormalizeSourceSystemCode(x.Code),
DisplayName = NormalizeConfigValue(x.DisplayName),
ConnectionKind = NormalizeConnectionKind(x.ConnectionKind),
IsActive = x.IsActive,
CentralServiceUrl = NormalizeConfigValue(x.CentralServiceUrl),
CentralUsername = NormalizeConfigValue(x.CentralUsername),
CentralPassword = x.CentralPassword ?? string.Empty
})
.Where(x => !string.IsNullOrWhiteSpace(x.Code))
.ToList();
if (normalized.Any(x => string.IsNullOrWhiteSpace(x.DisplayName)))
{
Snackbar.Add("Jedes Quellsystem braucht einen Anzeigenamen.", Severity.Warning);
return;
}
var duplicates = normalized
.GroupBy(x => x.Code)
.FirstOrDefault(g => g.Count() > 1);
if (duplicates is not null)
{
Snackbar.Add($"Quellsystem-Code doppelt vorhanden: {duplicates.Key}", Severity.Warning);
return;
}
using var db = await DbFactory.CreateDbContextAsync();
var existing = await db.SourceSystemDefinitions.ToListAsync();
if (existing.Count > 0)
db.SourceSystemDefinitions.RemoveRange(existing);
db.SourceSystemDefinitions.AddRange(normalized);
await db.SaveChangesAsync();
_sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync();
_sourceSystems = await SettingsPageActions.SaveSourceSystemsAsync(_sourceSystems);
Snackbar.Add("Quellsysteme gespeichert", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Warning);
}
}
private void AddExchangeRate()
{
@@ -554,32 +471,7 @@
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();
_exchangeRates = await SettingsPageActions.SaveExchangeRatesAsync(_exchangeRates);
Snackbar.Add("Wechselkurse gespeichert", Severity.Success);
}
@@ -591,8 +483,8 @@
_refreshingExchangeRates = true;
try
{
var result = await ExchangeRateImportService.RefreshEcbRatesAsync();
_exchangeRates = await LoadExchangeRatesAsync();
var result = await SettingsPageActions.RefreshEcbRatesAsync();
_exchangeRates = result.ExchangeRates;
Snackbar.Add($"ECB-Kurse aktualisiert: {result.ImportedCount} Kurse vom {result.RateDate:yyyy-MM-dd}.", Severity.Success);
}
catch (Exception ex)
@@ -613,7 +505,7 @@
_exportingConfig = true;
try
{
var json = await ConfigTransferService.ExportJsonAsync(_includeSecretsInExport);
var json = await SettingsPageActions.ExportConfigurationAsync(_includeSecretsInExport);
var suffix = _includeSecretsInExport ? "with-secrets" : "without-secrets";
var fileName = $"trafag-config-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{suffix}.json";
await JS.InvokeVoidAsync("trafagDownload.saveTextFile", fileName, json, "application/json;charset=utf-8");
@@ -641,18 +533,11 @@
await using var stream = file.OpenReadStream(5 * 1024 * 1024);
using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync();
await ConfigTransferService.ImportJsonAsync(json);
using var db = await DbFactory.CreateDbContextAsync();
_spConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig();
_exportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
_sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync();
_exchangeRates = await db.CurrencyExchangeRates
.OrderBy(x => x.FromCurrency)
.ThenBy(x => x.ToCurrency)
.ThenByDescending(x => x.ValidFrom)
.ToListAsync();
TimerService.Recalculate();
var state = await SettingsPageActions.ImportConfigurationAsync(json);
_spConfig = state.SharePointConfig;
_exportSettings = state.ExportSettings;
_sourceSystems = state.SourceSystems;
_exchangeRates = state.ExchangeRates;
Snackbar.Add("Konfiguration importiert", Severity.Success);
}
catch (Exception ex)
@@ -674,70 +559,13 @@
return;
}
if (string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
{
await TestCentralSapCredentials(definition);
return;
}
if (string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase))
{
await TestCentralHanaCredentials(definition);
}
}
private async Task TestCentralHanaCredentials(SourceSystemDefinition definition)
{
var sourceSystem = definition.Code;
if (!_testingSystems.Add(sourceSystem))
return;
try
{
var username = definition.CentralUsername;
var password = definition.CentralPassword;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
Snackbar.Add($"Für {sourceSystem} sind keine zentralen Zugangsdaten gepflegt.", Severity.Warning);
return;
}
using var db = await DbFactory.CreateDbContextAsync();
var centralServer = await db.HanaServers
.Where(s => s.SourceSystem == sourceSystem)
.OrderBy(s => s.Id)
.FirstOrDefaultAsync();
if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host))
{
Snackbar.Add($"Keine zentrale HANA-Konfiguration fuer {sourceSystem} gefunden.", Severity.Warning);
return;
}
var testServer = new HanaServer
{
SourceSystem = sourceSystem,
Name = $"{sourceSystem} Central Test",
Host = centralServer.Host,
Port = centralServer.Port,
Username = username.Trim(),
Password = password.Trim(),
DatabaseName = centralServer.DatabaseName,
UseSsl = centralServer.UseSsl,
ValidateCertificate = centralServer.ValidateCertificate,
AdditionalParams = centralServer.AdditionalParams
};
var result = await Task.Run(() => HanaService.TestConnectionDetailed(testServer));
if (result.Success)
{
Snackbar.Add($"{sourceSystem}: Zentrale HANA-Verbindung erfolgreich.", Severity.Success);
}
else
{
Snackbar.Add($"{sourceSystem}: {result.ExceptionType} - {result.ErrorMessage}", Severity.Error);
}
var result = await SettingsPageActions.TestCentralCredentialsAsync(definition);
Snackbar.Add(result.Message, result.Success ? Severity.Success : result.Warning ? Severity.Warning : Severity.Error);
}
finally
{
@@ -745,48 +573,9 @@
}
}
private async Task TestCentralSapCredentials(SourceSystemDefinition definition)
{
var sourceSystem = definition.Code;
if (!_testingSystems.Add(sourceSystem))
return;
private static string NormalizeSourceSystemCode(string? code) => Services.SettingsPageService.NormalizeSourceSystemCode(code);
try
{
var username = definition.CentralUsername;
var password = definition.CentralPassword;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
Snackbar.Add("Für SAP sind keine zentralen Gateway-Zugangsdaten gepflegt.", Severity.Warning);
return;
}
if (string.IsNullOrWhiteSpace(definition.CentralServiceUrl))
{
Snackbar.Add($"Fuer {sourceSystem} ist keine zentrale SAP Service URL gepflegt.", Severity.Warning);
return;
}
await SapGatewayService.TestConnectionAsync(definition.CentralServiceUrl, username.Trim(), password.Trim());
Snackbar.Add($"{sourceSystem}: Zentrale SAP Gateway-Verbindung erfolgreich.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"{sourceSystem}: {ex.Message}", Severity.Error);
}
finally
{
_testingSystems.Remove(sourceSystem);
}
}
private static string NormalizeSourceSystemCode(string? code) => NormalizeConfigValue(code).ToUpperInvariant();
private static string NormalizeConnectionKind(string? connectionKind)
=> SourceSystemConnectionKinds.All.Contains(connectionKind ?? string.Empty, StringComparer.OrdinalIgnoreCase)
? (connectionKind ?? string.Empty).Trim().ToUpperInvariant()
: SourceSystemConnectionKinds.Hana;
private static string NormalizeConnectionKind(string? connectionKind) => Services.SettingsPageService.NormalizeConnectionKind(connectionKind);
private static string GetConnectionKindLabel(string connectionKind) => connectionKind switch
{
@@ -808,31 +597,6 @@
private static string GetUsernameSummary(SourceSystemDefinition definition)
=> string.IsNullOrWhiteSpace(definition.CentralUsername) ? "-" : definition.CentralUsername;
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();
}
private static string NormalizeConfigValue(string? value) => Services.SettingsPageService.NormalizeConfigValue(value);
}
@@ -1,16 +1,11 @@
@page "/standorte"
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using System.Text.Json
@using System.Reflection
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject IHanaQueryService HanaService
@inject ISapGatewayService SapGatewayService
@inject ISharePointUploadService SharePointService
@inject IAppEventLogService AppEventLogService
@inject IStandortePageService StandortePageService
@inject IStandorteSapEditorService SapEditorService
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -466,16 +461,10 @@
private async Task LoadDataAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_sourceSystemDefinitions = await db.SourceSystemDefinitions
.OrderBy(x => x.Code)
.ToListAsync();
_servers = await db.HanaServers
.Where(s => GetHanaSourceSystemCodes().Contains(s.SourceSystem))
.OrderBy(s => s.SourceSystem)
.ThenBy(s => s.Name)
.ToListAsync();
_sites = await db.Sites.Include(s => s.HanaServer).OrderBy(s => s.Land).ToListAsync();
var state = await StandortePageService.LoadAsync();
_sourceSystemDefinitions = state.SourceSystems;
_servers = state.Servers;
_sites = state.Sites;
}
private void EditServer(HanaServer server)
@@ -492,58 +481,7 @@
_savingServer = true;
try
{
_editingServer.SourceSystem = string.IsNullOrWhiteSpace(_editingServer.SourceSystem)
? GetHanaSourceSystemCodes().FirstOrDefault() ?? string.Empty
: _editingServer.SourceSystem.Trim().ToUpperInvariant();
_editingServer.Name = string.IsNullOrWhiteSpace(_editingServer.Name) ? _editingServer.SourceSystem : _editingServer.Name.Trim();
_editingServer.Host = _editingServer.Host.Trim();
_editingServer.DatabaseName = _editingServer.DatabaseName.Trim();
_editingServer.AdditionalParams = _editingServer.AdditionalParams.Trim();
_editingServer.Username = string.Empty;
_editingServer.Password = string.Empty;
using var db = await DbFactory.CreateDbContextAsync();
if (_editingServer.Id == 0)
{
var existingForSourceSystem = await db.HanaServers
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.SourceSystem == _editingServer.SourceSystem);
if (existingForSourceSystem is null)
{
db.HanaServers.Add(_editingServer);
}
else
{
existingForSourceSystem.Name = _editingServer.Name;
existingForSourceSystem.Host = _editingServer.Host;
existingForSourceSystem.Port = _editingServer.Port;
existingForSourceSystem.Username = string.Empty;
existingForSourceSystem.Password = string.Empty;
existingForSourceSystem.DatabaseName = _editingServer.DatabaseName;
existingForSourceSystem.UseSsl = _editingServer.UseSsl;
existingForSourceSystem.ValidateCertificate = _editingServer.ValidateCertificate;
existingForSourceSystem.AdditionalParams = _editingServer.AdditionalParams;
}
}
else
{
var existing = await db.HanaServers.FindAsync(_editingServer.Id);
if (existing is not null)
{
existing.SourceSystem = _editingServer.SourceSystem;
existing.Name = _editingServer.Name;
existing.Host = _editingServer.Host;
existing.Port = _editingServer.Port;
existing.Username = string.Empty;
existing.Password = string.Empty;
existing.DatabaseName = _editingServer.DatabaseName;
existing.UseSsl = _editingServer.UseSsl;
existing.ValidateCertificate = _editingServer.ValidateCertificate;
existing.AdditionalParams = _editingServer.AdditionalParams;
}
}
await db.SaveChangesAsync();
await StandortePageService.SaveServerAsync(_editingServer, GetHanaSourceSystemCodes());
_serverDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Server gespeichert", Severity.Success);
@@ -571,27 +509,7 @@
try
{
using var db = await DbFactory.CreateDbContextAsync();
var linkedSites = await db.Sites
.Where(s => s.HanaServerId == server.Id)
.OrderBy(s => s.Land)
.Select(s => $"{s.Land} ({s.TSC})")
.ToListAsync();
if (linkedSites.Count > 0)
{
Snackbar.Add(
$"Server kann nicht gelöscht werden. Noch verknüpfte Standorte: {string.Join(", ", linkedSites)}",
Severity.Warning);
return;
}
var entity = await db.HanaServers.FindAsync(server.Id);
if (entity is not null)
{
db.HanaServers.Remove(entity);
await db.SaveChangesAsync();
}
await StandortePageService.DeleteServerAsync(server);
}
catch (Exception ex)
{
@@ -605,50 +523,19 @@
private async Task TestServerConnection(HanaServer server)
{
using var db = await DbFactory.CreateDbContextAsync();
var sourceDefinition = await db.SourceSystemDefinitions
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.Code == server.SourceSystem);
if (sourceDefinition is null)
try
{
Snackbar.Add($"Quellsystem '{server.SourceSystem}' nicht gefunden.", Severity.Warning);
return;
}
if (string.IsNullOrWhiteSpace(sourceDefinition.CentralUsername) || string.IsNullOrWhiteSpace(sourceDefinition.CentralPassword))
{
Snackbar.Add($"Fuer {server.SourceSystem} sind keine zentralen Zugangsdaten im Quellsystem gepflegt.", Severity.Warning);
return;
}
var testServer = new HanaServer
{
Id = server.Id,
SourceSystem = server.SourceSystem,
Name = server.Name,
Host = server.Host,
Port = server.Port,
Username = sourceDefinition.CentralUsername.Trim(),
Password = sourceDefinition.CentralPassword,
DatabaseName = server.DatabaseName,
UseSsl = server.UseSsl,
ValidateCertificate = server.ValidateCertificate,
AdditionalParams = server.AdditionalParams
};
await AppEventLogService.WriteAsync("HANA", "Server-Test aus UI gestartet",
details: testServer.GetConnectionStringPreview());
var result = await Task.Run(() => HanaService.TestConnectionDetailed(testServer));
var result = await StandortePageService.TestServerConnectionAsync(server);
_connectionStatus[server.Id] = result;
if (result.Success)
{
Snackbar.Add($"Verbindung zu '{server.Name}' erfolgreich.", Severity.Success);
Snackbar.Add(
result.Success
? $"Verbindung zu '{server.Name}' erfolgreich."
: $"{server.Name}: {result.ExceptionType} - {result.ErrorMessage}",
result.Success ? Severity.Success : Severity.Error);
}
else
catch (Exception ex)
{
Snackbar.Add($"{server.Name}: {result.ExceptionType} - {result.ErrorMessage}", Severity.Error);
Snackbar.Add(ex.Message, Severity.Warning);
}
}
@@ -682,33 +569,18 @@
private void EditSite(Site site)
{
_editingSite = new Site
_ = EditSiteAsync(site);
}
private async Task EditSiteAsync(Site site)
{
Id = site.Id,
HanaServerId = site.HanaServerId,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem)
? GetAvailableSourceSystems().FirstOrDefault()?.Code ?? "SAP"
: site.SourceSystem,
UsernameOverride = site.UsernameOverride,
PasswordOverride = site.PasswordOverride,
LocalExportFolderOverride = site.LocalExportFolderOverride,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
IsActive = site.IsActive
};
var editorState = await StandortePageService.LoadSiteEditorAsync(site, GetAvailableSourceSystems());
_editingSite = editorState.Site;
_availableSchemas = [];
_sapEntitySetsCache = ParseSapEntitySets(site.SapEntitySetsCache);
using var db = DbFactory.CreateDbContext();
_sapSources = db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).OrderBy(s => s.SortOrder).ThenBy(s => s.Id).ToList();
_sapJoins = db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).OrderBy(j => j.SortOrder).ThenBy(j => j.Id).ToList();
_sapMappings = db.SapFieldMappings.Where(m => m.SiteId == site.Id).OrderBy(m => m.SortOrder).ThenBy(m => m.Id).ToList();
_sapEntitySetsCache = editorState.SapEntitySets;
_sapSources = editorState.SapSources;
_sapJoins = editorState.SapJoins;
_sapMappings = editorState.SapMappings;
_sapAvailableSourceExpressions = BuildSourceExpressionsFromMappings();
_sapSourceFieldMap = BuildSourceFieldMapFromJoins();
_siteDialogVisible = true;
@@ -722,40 +594,7 @@
_savingSite = true;
try
{
using var db = await DbFactory.CreateDbContextAsync();
var serverId = UsesHanaConnection() ? await ResolveCentralHanaServerIdAsync(db, _editingSite.SourceSystem) : (int?)null;
_editingSite.HanaServerId = serverId;
_editingSite.SapEntitySetsCache = SerializeSapEntitySets(_sapEntitySetsCache);
if (_editingSite.Id == 0)
{
db.Sites.Add(_editingSite);
}
else
{
var existing = await db.Sites.FindAsync(_editingSite.Id);
if (existing is not null)
{
existing.HanaServerId = serverId;
existing.Schema = _editingSite.Schema;
existing.TSC = _editingSite.TSC;
existing.Land = _editingSite.Land;
existing.SourceSystem = _editingSite.SourceSystem;
existing.UsernameOverride = _editingSite.UsernameOverride;
existing.PasswordOverride = _editingSite.PasswordOverride;
existing.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride;
existing.ManualImportFilePath = _editingSite.ManualImportFilePath;
existing.ManualImportLastUploadedAtUtc = _editingSite.ManualImportLastUploadedAtUtc;
existing.SapServiceUrl = _editingSite.SapServiceUrl;
existing.SapEntitySet = _editingSite.SapEntitySet;
existing.SapEntitySetsCache = _editingSite.SapEntitySetsCache;
existing.SapEntitySetsRefreshedAtUtc = _editingSite.SapEntitySetsRefreshedAtUtc;
existing.IsActive = _editingSite.IsActive;
}
}
await db.SaveChangesAsync();
await SaveSapConfigurationAsync(db, _editingSite.Id);
await StandortePageService.SaveSiteAsync(_editingSite, UsesHanaConnection(), IsSapSite(), _sapSources, _sapJoins, _sapMappings, _sapEntitySetsCache);
_siteDialogVisible = false;
await LoadDataAsync();
Snackbar.Add("Standort gespeichert", Severity.Success);
@@ -779,22 +618,7 @@
if (result != true) return;
using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.Sites.FindAsync(site.Id);
if (entity is not null)
{
var sources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).ToListAsync();
var joins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).ToListAsync();
var mappings = await db.SapFieldMappings.Where(m => m.SiteId == site.Id).ToListAsync();
var centralRows = await db.CentralSalesRecords.Where(r => r.SiteId == site.Id).ToListAsync();
if (sources.Count > 0) db.SapSourceDefinitions.RemoveRange(sources);
if (joins.Count > 0) db.SapJoinDefinitions.RemoveRange(joins);
if (mappings.Count > 0) db.SapFieldMappings.RemoveRange(mappings);
if (centralRows.Count > 0) db.CentralSalesRecords.RemoveRange(centralRows);
db.Sites.Remove(entity);
await db.SaveChangesAsync();
}
await StandortePageService.DeleteSiteAsync(site);
await LoadDataAsync();
Snackbar.Add("Standort gelöscht", Severity.Info);
}
@@ -825,26 +649,6 @@
};
}
private async Task<int> ResolveCentralHanaServerIdAsync(AppDbContext db, string sourceSystem)
{
_editingSite.UsernameOverride = _editingSite.UsernameOverride.Trim();
_editingSite.PasswordOverride = _editingSite.PasswordOverride.Trim();
_editingSite.LocalExportFolderOverride = _editingSite.LocalExportFolderOverride.Trim();
_editingSite.ManualImportFilePath = _editingSite.ManualImportFilePath.Trim();
_editingSite.SapServiceUrl = _editingSite.SapServiceUrl.Trim();
_editingSite.SapEntitySet = _editingSite.SapEntitySet.Trim();
var normalizedSourceSystem = string.IsNullOrWhiteSpace(sourceSystem) ? string.Empty : sourceSystem.Trim().ToUpperInvariant();
var centralServer = await db.HanaServers
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.SourceSystem == normalizedSourceSystem);
if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host))
throw new InvalidOperationException($"Fuer Quellsystem '{normalizedSourceSystem}' ist keine gueltige zentrale HANA-Konfiguration vorhanden.");
return centralServer.Id;
}
private Task OnSchemaSelected(string schema)
{
_editingSite.Schema = schema;
@@ -939,52 +743,7 @@
_loadingSchemas = true;
try
{
using var db = await DbFactory.CreateDbContextAsync();
var sourceDefinition = await db.SourceSystemDefinitions
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.Code == _editingSite.SourceSystem);
if (sourceDefinition is null)
throw new InvalidOperationException($"Quellsystem '{_editingSite.SourceSystem}' nicht gefunden.");
var centralServer = await db.HanaServers
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.SourceSystem == _editingSite.SourceSystem);
if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host))
throw new InvalidOperationException($"Fuer {_editingSite.SourceSystem} ist keine gueltige zentrale HANA-Konfiguration vorhanden.");
var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride)
? sourceDefinition.CentralUsername ?? string.Empty
: _editingSite.UsernameOverride;
var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride)
? sourceDefinition.CentralPassword ?? string.Empty
: _editingSite.PasswordOverride;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException($"Fuer {_editingSite.SourceSystem} sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
var lookupServer = new HanaServer
{
Id = centralServer.Id,
SourceSystem = centralServer.SourceSystem,
Name = centralServer.Name,
Host = centralServer.Host,
Port = centralServer.Port,
Username = username.Trim(),
Password = password,
DatabaseName = centralServer.DatabaseName,
UseSsl = centralServer.UseSsl,
ValidateCertificate = centralServer.ValidateCertificate,
AdditionalParams = centralServer.AdditionalParams
};
var schemas = await Task.Run(() => HanaService.GetAvailableSchemas(lookupServer));
_availableSchemas = schemas
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
_availableSchemas = await StandortePageService.LoadAvailableSchemasAsync(_editingSite);
if (_availableSchemas.Count == 0)
{
@@ -1018,31 +777,10 @@
_refreshingSapEntitySets = true;
try
{
using var db = await DbFactory.CreateDbContextAsync();
var sourceDefinition = await db.SourceSystemDefinitions
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.Code == _editingSite.SourceSystem);
var serviceUrl = string.IsNullOrWhiteSpace(_editingSite.SapServiceUrl)
? sourceDefinition?.CentralServiceUrl ?? string.Empty
: _editingSite.SapServiceUrl;
if (string.IsNullOrWhiteSpace(serviceUrl))
throw new InvalidOperationException("Es ist weder eine zentrale SAP Service URL noch ein Standort-Override gesetzt.");
var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride)
? sourceDefinition?.CentralUsername ?? string.Empty
: _editingSite.UsernameOverride;
var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride)
? sourceDefinition?.CentralPassword ?? string.Empty
: _editingSite.PasswordOverride;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
await AppEventLogService.WriteAsync("SAP", "Refresh aus UI gestartet", siteId: _editingSite.Id, land: _editingSite.Land,
details: serviceUrl);
var entitySets = await SapGatewayService.GetEntitySetsAsync(serviceUrl, username.Trim(), password.Trim());
_sapEntitySetsCache = entitySets;
_editingSite.SapEntitySetsCache = SerializeSapEntitySets(entitySets);
_editingSite.SapEntitySetsRefreshedAtUtc = DateTime.UtcNow;
var result = await StandortePageService.RefreshSapEntitySetsAsync(_editingSite);
_sapEntitySetsCache = result.EntitySets;
_editingSite.SapEntitySetsCache = SerializeSapEntitySets(result.EntitySets);
_editingSite.SapEntitySetsRefreshedAtUtc = result.RefreshedAtUtc;
if (!string.IsNullOrWhiteSpace(_editingSite.SapEntitySet) &&
!_sapEntitySetsCache.Contains(_editingSite.SapEntitySet, StringComparer.OrdinalIgnoreCase))
@@ -1050,15 +788,11 @@
_editingSite.SapEntitySet = string.Empty;
}
Snackbar.Add($"{entitySets.Count} SAP Entity Sets geladen.", Severity.Success);
await AppEventLogService.WriteAsync("SAP", "Refresh aus UI erfolgreich", siteId: _editingSite.Id, land: _editingSite.Land,
details: $"EntitySets={entitySets.Count}");
Snackbar.Add($"{result.EntitySets.Count} SAP Entity Sets geladen.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Error);
await AppEventLogService.WriteAsync("SAP", "Refresh aus UI fehlgeschlagen", "Error", siteId: _editingSite.Id, land: _editingSite.Land,
details: ex.ToString());
}
finally
{
@@ -1119,12 +853,10 @@
_editingSite.ManualImportFilePath = targetPath;
_editingSite.ManualImportLastUploadedAtUtc = DateTime.UtcNow;
Snackbar.Add("Excel-Datei hochgeladen.", Severity.Success);
await AppEventLogService.WriteAsync("ManualImport", "Excel-Datei hochgeladen", siteId: _editingSite.Id, land: _editingSite.Land, details: targetPath);
}
catch (Exception ex)
{
Snackbar.Add($"Upload fehlgeschlagen: {ex.Message}", Severity.Error);
await AppEventLogService.WriteAsync("ManualImport", "Excel-Upload fehlgeschlagen", "Error", siteId: _editingSite.Id, land: _editingSite.Land, details: ex.ToString());
}
finally
{
@@ -1136,55 +868,12 @@
{
try
{
_editingSite.ManualImportFilePath = _editingSite.ManualImportFilePath.Trim();
if (string.IsNullOrWhiteSpace(_editingSite.ManualImportFilePath))
throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen.");
if (!string.Equals(Path.GetExtension(_editingSite.ManualImportFilePath), ".xlsx", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx angeben.");
if (File.Exists(_editingSite.ManualImportFilePath))
{
_editingSite.ManualImportLastUploadedAtUtc = File.GetLastWriteTimeUtc(_editingSite.ManualImportFilePath);
}
else if (LooksLikeSharePointReference(_editingSite.ManualImportFilePath))
{
using var db = await DbFactory.CreateDbContextAsync();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
if (spConfig is null ||
string.IsNullOrWhiteSpace(spConfig.TenantId) ||
string.IsNullOrWhiteSpace(spConfig.ClientId) ||
string.IsNullOrWhiteSpace(spConfig.ClientSecret) ||
string.IsNullOrWhiteSpace(spConfig.SiteUrl))
{
throw new InvalidOperationException("Fuer SharePoint-Pruefung fehlt eine vollstaendige SharePoint-Konfiguration in Settings.");
}
var tempPath = await SharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, _editingSite.ManualImportFilePath);
try
{
_editingSite.ManualImportLastUploadedAtUtc = File.GetLastWriteTimeUtc(tempPath);
}
finally
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
}
else
{
throw new InvalidOperationException($"Datei nicht gefunden oder nicht erreichbar: {_editingSite.ManualImportFilePath}");
}
_editingSite.ManualImportLastUploadedAtUtc = await StandortePageService.ValidateManualImportPathAsync(_editingSite.ManualImportFilePath);
Snackbar.Add("Dateipfad ist gueltig und die Excel-Datei ist erreichbar.", Severity.Success);
await AppEventLogService.WriteAsync("ManualImport", "Dateipfad erfolgreich geprueft", siteId: _editingSite.Id, land: _editingSite.Land, details: _editingSite.ManualImportFilePath);
}
catch (Exception ex)
{
Snackbar.Add($"Pfadpruefung fehlgeschlagen: {ex.Message}", Severity.Error);
await AppEventLogService.WriteAsync("ManualImport", "Dateipfadpruefung fehlgeschlagen", "Error", siteId: _editingSite.Id, land: _editingSite.Land, details: ex.ToString());
}
}
@@ -1206,182 +895,48 @@
private static string SerializeSapEntitySets(List<string> entitySets)
=> JsonSerializer.Serialize(entitySets);
private static bool LooksLikeSharePointReference(string path)
=> path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase);
private void AddSapSource()
{
_sapSources.Add(new SapSourceDefinition
{
Alias = $"SRC{_sapSources.Count + 1}",
EntitySet = _sapEntitySetsCache.FirstOrDefault() ?? string.Empty,
IsActive = true,
IsPrimary = _sapSources.Count == 0,
SortOrder = _sapSources.Count
});
SapEditorService.AddSapSource(_sapSources, _sapEntitySetsCache);
}
private void RemoveSapSource(SapSourceDefinition source)
{
_sapSources.Remove(source);
SapEditorService.RemoveSapSource(_sapSources, source);
}
private void AddSapJoin()
{
_sapJoins.Add(new SapJoinDefinition
{
JoinType = "Left",
IsActive = true,
SortOrder = _sapJoins.Count
});
SapEditorService.AddSapJoin(_sapJoins);
}
private void AutoMatchSapJoins()
{
var activeSources = _sapSources
.Where(s => s.IsActive && !string.IsNullOrWhiteSpace(s.Alias))
.OrderBy(s => s.SortOrder)
.ThenBy(s => s.Id)
.ToList();
if (activeSources.Count < 2)
{
Snackbar.Add("Für Auto-Match werden mindestens zwei aktive SAP-Quellen benötigt.", Severity.Warning);
return;
}
if (_sapSourceFieldMap.Count == 0)
{
Snackbar.Add("Bitte zuerst 'Felder aus Quellen laden' ausführen.", Severity.Warning);
return;
}
var primary = activeSources.FirstOrDefault(s => s.IsPrimary) ?? activeSources.First();
var createdOrUpdated = 0;
foreach (var source in activeSources.Where(s => !string.Equals(s.Alias, primary.Alias, StringComparison.OrdinalIgnoreCase)))
{
if (!_sapSourceFieldMap.TryGetValue(primary.Alias, out var leftFields) || leftFields.Count == 0)
continue;
if (!_sapSourceFieldMap.TryGetValue(source.Alias, out var rightFields) || rightFields.Count == 0)
continue;
var matchingFields = leftFields
.Intersect(rightFields, StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
if (matchingFields.Count == 0)
continue;
var existingJoin = _sapJoins.FirstOrDefault(j =>
string.Equals(j.LeftAlias, primary.Alias, StringComparison.OrdinalIgnoreCase) &&
string.Equals(j.RightAlias, source.Alias, StringComparison.OrdinalIgnoreCase));
var keyList = string.Join(',', matchingFields);
if (existingJoin is null)
{
_sapJoins.Add(new SapJoinDefinition
{
LeftAlias = primary.Alias,
RightAlias = source.Alias,
LeftKeys = keyList,
RightKeys = keyList,
JoinType = "Left",
IsActive = true,
SortOrder = _sapJoins.Count
});
}
else
{
existingJoin.LeftKeys = keyList;
existingJoin.RightKeys = keyList;
existingJoin.JoinType = "Left";
existingJoin.IsActive = true;
}
createdOrUpdated++;
}
if (createdOrUpdated == 0)
{
Snackbar.Add("Kein passender Join-Vorschlag gefunden.", Severity.Info);
return;
}
NormalizeSapConfigCollections();
Snackbar.Add($"{createdOrUpdated} Join-Vorschläge gesetzt.", Severity.Success);
var result = SapEditorService.AutoMatchSapJoins(_sapSources, _sapJoins, _sapSourceFieldMap);
SapEditorService.NormalizeSapConfigCollections(_sapSources, _sapJoins, _sapMappings);
Snackbar.Add(result.Message, result.Success ? Severity.Success : result.Warning ? Severity.Warning : Severity.Info);
}
private void RemoveSapJoin(SapJoinDefinition join)
{
_sapJoins.Remove(join);
SapEditorService.RemoveSapJoin(_sapJoins, join);
}
private void AddSapMapping()
{
_sapMappings.Add(new SapFieldMapping
{
TargetField = _salesRecordFields.First(),
SourceExpression = _sapAvailableSourceExpressions.FirstOrDefault() ?? "=SAP",
IsActive = true,
SortOrder = _sapMappings.Count
});
SapEditorService.AddSapMapping(_sapMappings, _salesRecordFields, _sapAvailableSourceExpressions);
}
private void RemoveSapMapping(SapFieldMapping mapping)
{
_sapMappings.Remove(mapping);
SapEditorService.RemoveSapMapping(_sapMappings, mapping);
}
private IEnumerable<string> GetSapAliases()
=> _sapSources.Where(s => !string.IsNullOrWhiteSpace(s.Alias)).Select(s => s.Alias).Distinct(StringComparer.OrdinalIgnoreCase);
private async Task SaveSapConfigurationAsync(AppDbContext db, int siteId)
{
var oldSources = await db.SapSourceDefinitions.Where(s => s.SiteId == siteId).ToListAsync();
var oldJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == siteId).ToListAsync();
var oldMappings = await db.SapFieldMappings.Where(m => m.SiteId == siteId).ToListAsync();
if (oldSources.Count > 0) db.SapSourceDefinitions.RemoveRange(oldSources);
if (oldJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(oldJoins);
if (oldMappings.Count > 0) db.SapFieldMappings.RemoveRange(oldMappings);
if (IsSapSite())
{
NormalizeSapConfigCollections();
foreach (var source in _sapSources)
source.SiteId = siteId;
foreach (var join in _sapJoins)
join.SiteId = siteId;
foreach (var mapping in _sapMappings)
mapping.SiteId = siteId;
db.SapSourceDefinitions.AddRange(_sapSources);
db.SapJoinDefinitions.AddRange(_sapJoins);
db.SapFieldMappings.AddRange(_sapMappings);
}
await db.SaveChangesAsync();
}
=> SapEditorService.GetSapAliases(_sapSources);
private void NormalizeSapConfigCollections()
{
for (var i = 0; i < _sapSources.Count; i++)
_sapSources[i].SortOrder = i;
for (var i = 0; i < _sapJoins.Count; i++)
_sapJoins[i].SortOrder = i;
for (var i = 0; i < _sapMappings.Count; i++)
_sapMappings[i].SortOrder = i;
var selectedPrimaryIndex = _sapSources.FindIndex(s => s.IsPrimary);
var primarySource = selectedPrimaryIndex >= 0 ? _sapSources[selectedPrimaryIndex] : _sapSources.FirstOrDefault();
foreach (var source in _sapSources)
source.IsPrimary = primarySource is not null && ReferenceEquals(source, primarySource);
if (_sapSources.Count > 0 && _sapSources.All(s => !s.IsPrimary))
_sapSources[0].IsPrimary = true;
}
=> SapEditorService.NormalizeSapConfigCollections(_sapSources, _sapJoins, _sapMappings);
private async Task RefreshSapSourceFields()
{
@@ -1400,51 +955,9 @@
if (activeSources.Count == 0)
throw new InvalidOperationException("Es gibt keine aktiven SAP-Quellen mit Alias und Entity Set.");
using var db = await DbFactory.CreateDbContextAsync();
var sourceDefinition = await db.SourceSystemDefinitions
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.Code == _editingSite.SourceSystem);
var serviceUrl = string.IsNullOrWhiteSpace(_editingSite.SapServiceUrl)
? sourceDefinition?.CentralServiceUrl ?? string.Empty
: _editingSite.SapServiceUrl;
if (string.IsNullOrWhiteSpace(serviceUrl))
throw new InvalidOperationException("Es ist weder eine zentrale SAP Service URL noch ein Standort-Override gesetzt.");
var username = string.IsNullOrWhiteSpace(_editingSite.UsernameOverride)
? sourceDefinition?.CentralUsername ?? string.Empty
: _editingSite.UsernameOverride;
var password = string.IsNullOrWhiteSpace(_editingSite.PasswordOverride)
? sourceDefinition?.CentralPassword ?? string.Empty
: _editingSite.PasswordOverride;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException("Für SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
var expressions = new List<string> { "=SAP" };
var sourceFieldMap = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var source in activeSources)
{
var fieldNames = await SapGatewayService.GetEntityFieldNamesAsync(serviceUrl, source.EntitySet, username.Trim(), password.Trim());
sourceFieldMap[source.Alias] = fieldNames;
expressions.AddRange(fieldNames.Select(field => $"{source.Alias}.{field}"));
}
_sapAvailableSourceExpressions = expressions
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
_sapSourceFieldMap = sourceFieldMap;
foreach (var current in BuildSourceExpressionsFromMappings())
{
if (!_sapAvailableSourceExpressions.Contains(current, StringComparer.OrdinalIgnoreCase))
_sapAvailableSourceExpressions.Add(current);
}
_sapAvailableSourceExpressions = _sapAvailableSourceExpressions
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
var result = await StandortePageService.RefreshSapSourceFieldsAsync(_editingSite, activeSources, _sapMappings);
_sapAvailableSourceExpressions = result.SourceExpressions;
_sapSourceFieldMap = result.SourceFieldMap;
Snackbar.Add($"{_sapAvailableSourceExpressions.Count} Source Expressions geladen.", Severity.Success);
}
@@ -1459,73 +972,16 @@
}
private IEnumerable<string> GetAvailableSourceExpressions(string? currentValue)
{
var expressions = new List<string>(_sapAvailableSourceExpressions);
if (!string.IsNullOrWhiteSpace(currentValue) && !expressions.Contains(currentValue, StringComparer.OrdinalIgnoreCase))
expressions.Insert(0, currentValue);
return expressions;
}
=> SapEditorService.GetAvailableSourceExpressions(_sapAvailableSourceExpressions, currentValue);
private List<string> BuildSourceExpressionsFromMappings()
=> _sapMappings
.Select(m => m.SourceExpression)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
=> SapEditorService.BuildSourceExpressionsFromMappings(_sapMappings);
private Dictionary<string, List<string>> BuildSourceFieldMapFromJoins()
{
var result = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var join in _sapJoins)
{
AddJoinKeysToFieldMap(result, join.LeftAlias, join.LeftKeys);
AddJoinKeysToFieldMap(result, join.RightAlias, join.RightKeys);
}
return result;
}
private static void AddJoinKeysToFieldMap(Dictionary<string, List<string>> target, string alias, string keys)
{
if (string.IsNullOrWhiteSpace(alias))
return;
if (!target.TryGetValue(alias, out var fields))
{
fields = [];
target[alias] = fields;
}
foreach (var key in GetSelectedJoinKeys(keys))
{
if (!fields.Contains(key, StringComparer.OrdinalIgnoreCase))
fields.Add(key);
}
fields.Sort(StringComparer.OrdinalIgnoreCase);
}
=> SapEditorService.BuildSourceFieldMapFromJoins(_sapJoins);
private IEnumerable<string> GetAvailableJoinFields(string? alias, string? currentKeys)
{
var values = new List<string>();
if (!string.IsNullOrWhiteSpace(alias) && _sapSourceFieldMap.TryGetValue(alias, out var fields))
values.AddRange(fields);
foreach (var key in GetSelectedJoinKeys(currentKeys))
{
if (!values.Contains(key, StringComparer.OrdinalIgnoreCase))
values.Add(key);
}
return values
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
=> SapEditorService.GetAvailableJoinFields(_sapSourceFieldMap, alias, currentKeys);
private static HashSet<string> GetSelectedJoinKeys(string? keys)
=> keys?
@@ -1,10 +1,8 @@
@page "/transformations"
@using Microsoft.EntityFrameworkCore
@using System.Reflection
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ITransformationsPageService TransformationsPageActions
@inject ITransformationCatalog TransformationCatalog
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@@ -199,9 +197,9 @@
private async Task LoadAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
_sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync();
_rules = await db.FieldTransformationRules.OrderBy(r => r.SortOrder).ThenBy(r => r.Id).ToListAsync();
var state = await TransformationsPageActions.LoadAsync();
_sourceSystems = state.SourceSystems;
_rules = state.Rules;
foreach (var rule in _rules)
{
@@ -235,12 +233,7 @@
private async Task SaveAllAsync()
{
using var db = await DbFactory.CreateDbContextAsync();
db.FieldTransformationRules.RemoveRange(db.FieldTransformationRules);
await db.SaveChangesAsync();
db.FieldTransformationRules.AddRange(_rules);
await db.SaveChangesAsync();
_rules = await TransformationsPageActions.SaveAllAsync(_rules);
Snackbar.Add(T("Transformationsregeln gespeichert.", "Transformation rules saved."), Severity.Success);
await LoadAsync();
+9
View File
@@ -41,7 +41,16 @@ builder.Services.AddSingleton<IConsolidatedExportService, ConsolidatedExportServ
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
builder.Services.AddSingleton<IConfigTransferService, ConfigTransferService>();
builder.Services.AddSingleton<IDatabaseSchemaMaintenanceService, DatabaseSchemaMaintenanceService>();
builder.Services.AddSingleton<IDatabaseSeedService, DatabaseSeedService>();
builder.Services.AddSingleton<IDatabaseInitializationService, DatabaseInitializationService>();
builder.Services.AddSingleton<ISettingsPageService, SettingsPageService>();
builder.Services.AddSingleton<IStandortePageService, StandortePageService>();
builder.Services.AddSingleton<IStandorteSapEditorService, StandorteSapEditorService>();
builder.Services.AddSingleton<IManagementCockpitPageService, ManagementCockpitPageService>();
builder.Services.AddSingleton<IDashboardPageService, DashboardPageService>();
builder.Services.AddSingleton<ILogsPageService, LogsPageService>();
builder.Services.AddSingleton<ITransformationsPageService, TransformationsPageService>();
builder.Services.AddSingleton<IUiTextService, UiTextService>();
builder.Services.AddSingleton<ExportOrchestrationService>();
builder.Services.AddSingleton<TimerBackgroundService>();
@@ -0,0 +1,144 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IDashboardPageService
{
Task<DashboardPageState> LoadAsync();
}
public sealed class DashboardPageService : IDashboardPageService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public DashboardPageService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<DashboardPageState> LoadAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
var sourceSystems = await db.SourceSystemDefinitions.AsNoTracking().ToListAsync();
var logs = await db.ExportLogs
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync();
var appLogs = await db.AppEventLogs
.Where(l => l.SiteId != null)
.OrderByDescending(l => l.Timestamp)
.Take(1000)
.ToListAsync();
var latestAppLogsBySite = appLogs
.GroupBy(l => l.SiteId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Timestamp).First());
var rows = sites.Select(s =>
{
var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
latestAppLogsBySite.TryGetValue(s.Id, out var appLog);
var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, s.SourceSystem, StringComparison.OrdinalIgnoreCase));
return new DashboardRow
{
SiteId = s.Id,
Land = s.Land,
TSC = s.TSC,
Schema = s.Schema,
ServerName = string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)
? ResolveDashboardSapServiceUrl(s, sourceSystems)
: s.HanaServer?.Name ?? string.Empty,
LastStatus = log?.Status ?? string.Empty,
RowCount = log?.RowCount ?? 0,
LastRun = log?.Timestamp,
DurationSeconds = log?.DurationSeconds ?? 0,
ErrorMessage = log?.ErrorMessage ?? string.Empty,
FilePath = log?.FilePath ?? string.Empty,
LiveMessage = appLog is null ? string.Empty : $"{appLog.Category}: {appLog.Message}",
LiveDetails = appLog?.Details ?? string.Empty
};
}).ToList();
return new DashboardPageState
{
DashboardRows = rows,
ConsolidatedRows = BuildConsolidatedRows(await db.ExportSettings.FirstOrDefaultAsync() ?? new())
};
}
private static string ResolveDashboardSapServiceUrl(Site site, List<SourceSystemDefinition> sourceSystems)
{
if (!string.IsNullOrWhiteSpace(site.SapServiceUrl))
return site.SapServiceUrl;
var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, site.SourceSystem, StringComparison.OrdinalIgnoreCase));
return string.IsNullOrWhiteSpace(sourceSystem?.CentralServiceUrl) ? "SAP Gateway" : sourceSystem.CentralServiceUrl;
}
private static List<ConsolidatedDashboardRow> BuildConsolidatedRows(ExportSettings settings)
{
var outputDirectory = ResolveConsolidatedOutputDirectory(settings);
if (!Directory.Exists(outputDirectory))
return [];
return Directory.GetFiles(outputDirectory, "Sales_All_*.xlsx")
.Select(path => new FileInfo(path))
.OrderByDescending(file => file.LastWriteTime)
.Take(1)
.Select(file => new ConsolidatedDashboardRow
{
Label = "Konsolidierter Export",
FilePath = file.FullName,
DisplayPath = file.FullName,
LastModified = file.LastWriteTime
})
.ToList();
}
private static string ResolveConsolidatedOutputDirectory(ExportSettings settings)
{
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
return settings.LocalConsolidatedExportFolder.Trim();
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
return settings.LocalSiteExportFolder.Trim();
return Path.Combine(AppContext.BaseDirectory, "output");
}
}
public sealed class DashboardPageState
{
public List<DashboardRow> DashboardRows { get; set; } = [];
public List<ConsolidatedDashboardRow> ConsolidatedRows { get; set; } = [];
}
public sealed class DashboardRow
{
public int SiteId { get; set; }
public string Land { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public string Schema { get; set; } = string.Empty;
public string ServerName { get; set; } = string.Empty;
public string LastStatus { get; set; } = string.Empty;
public int RowCount { get; set; }
public DateTime? LastRun { get; set; }
public double DurationSeconds { get; set; }
public string ErrorMessage { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public string LiveMessage { get; set; } = string.Empty;
public string LiveDetails { get; set; } = string.Empty;
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath);
}
public sealed class ConsolidatedDashboardRow
{
public string Label { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public string DisplayPath { get; set; } = string.Empty;
public DateTime? LastModified { get; set; }
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath);
}
@@ -0,0 +1,152 @@
namespace TrafagSalesExporter.Services;
internal static class DatabaseSchemaSql
{
internal static string GetExportLogsCreateSql() => @"
CREATE TABLE ExportLogs (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Timestamp TEXT NOT NULL,
SiteId INTEGER NOT NULL,
Land TEXT NOT NULL,
TSC TEXT NOT NULL,
Status TEXT NOT NULL,
RowCount INTEGER NOT NULL,
ErrorMessage TEXT NULL,
FileName TEXT NOT NULL DEFAULT '',
FilePath TEXT NOT NULL DEFAULT '',
DurationSeconds REAL NOT NULL,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
internal static string GetExportSettingsCreateSql() => @"
CREATE TABLE ExportSettings (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
DateFilter TEXT NOT NULL,
TimerHour INTEGER NOT NULL,
TimerMinute INTEGER NOT NULL,
TimerEnabled INTEGER NOT NULL,
DebugLoggingEnabled INTEGER NOT NULL DEFAULT 0,
LocalSiteExportFolder TEXT NOT NULL DEFAULT '',
LocalConsolidatedExportFolder TEXT NOT NULL DEFAULT ''
);";
internal static string GetHanaServersCreateSql() => @"
CREATE TABLE HanaServers (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SourceSystem TEXT NOT NULL,
Name TEXT NOT NULL,
Host TEXT NOT NULL,
Port INTEGER NOT NULL,
DatabaseName TEXT NOT NULL DEFAULT '',
UseSsl INTEGER NOT NULL DEFAULT 0,
ValidateCertificate INTEGER NOT NULL DEFAULT 0,
AdditionalParams TEXT NOT NULL DEFAULT ''
);";
internal static string GetSitesCreateSql() => @"
CREATE TABLE Sites (
Id INTEGER NOT NULL CONSTRAINT PK_Sites PRIMARY KEY AUTOINCREMENT,
HanaServerId INTEGER NULL,
Schema TEXT NOT NULL,
TSC TEXT NOT NULL,
Land TEXT NOT NULL,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
UsernameOverride TEXT NOT NULL DEFAULT '',
PasswordOverride TEXT NOT NULL DEFAULT '',
LocalExportFolderOverride TEXT NOT NULL DEFAULT '',
ManualImportFilePath TEXT NOT NULL DEFAULT '',
ManualImportLastUploadedAtUtc TEXT NULL,
SapServiceUrl TEXT NOT NULL DEFAULT '',
SapEntitySet TEXT NOT NULL DEFAULT '',
SapEntitySetsCache TEXT NOT NULL DEFAULT '',
SapEntitySetsRefreshedAtUtc TEXT NULL,
IsActive INTEGER NOT NULL,
CONSTRAINT FK_Sites_HanaServers_HanaServerId FOREIGN KEY (HanaServerId) REFERENCES HanaServers (Id)
);";
internal static string GetAppEventLogsCreateSql() => @"
CREATE TABLE AppEventLogs (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Timestamp TEXT NOT NULL,
Level TEXT NOT NULL,
Category TEXT NOT NULL,
SiteId INTEGER NULL,
Land TEXT NOT NULL,
Message TEXT NOT NULL,
Details TEXT NOT NULL,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
internal static string GetCentralSalesRecordsCreateSql() => @"
CREATE TABLE CentralSalesRecords (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
StoredAtUtc TEXT NOT NULL,
SiteId INTEGER NOT NULL,
SourceSystem TEXT NOT NULL,
ExtractionDate TEXT NOT NULL,
Tsc TEXT NOT NULL,
InvoiceNumber TEXT NOT NULL,
PositionOnInvoice INTEGER NOT NULL,
Material TEXT NOT NULL,
Name TEXT NOT NULL,
ProductGroup TEXT NOT NULL,
Quantity TEXT NOT NULL,
SupplierNumber TEXT NOT NULL,
SupplierName TEXT NOT NULL,
SupplierCountry TEXT NOT NULL,
CustomerNumber TEXT NOT NULL,
CustomerName TEXT NOT NULL,
CustomerCountry TEXT NOT NULL,
CustomerIndustry TEXT NOT NULL,
StandardCost TEXT NOT NULL,
StandardCostCurrency TEXT NOT NULL,
PurchaseOrderNumber TEXT NOT NULL,
SalesPriceValue TEXT NOT NULL,
SalesCurrency TEXT NOT NULL,
Incoterms2020 TEXT NOT NULL,
SalesResponsibleEmployee TEXT NOT NULL,
InvoiceDate TEXT NULL,
OrderDate TEXT NULL,
Land TEXT NOT NULL,
DocumentType TEXT NOT NULL,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
internal static string GetSapSourceDefinitionsCreateSql() => @"
CREATE TABLE SapSourceDefinitions (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
Alias TEXT NOT NULL,
EntitySet TEXT NOT NULL,
IsPrimary INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
internal static string GetSapJoinDefinitionsCreateSql() => @"
CREATE TABLE SapJoinDefinitions (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
LeftAlias TEXT NOT NULL,
RightAlias TEXT NOT NULL,
LeftKeys TEXT NOT NULL,
RightKeys TEXT NOT NULL,
JoinType TEXT NOT NULL DEFAULT 'Left',
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
internal static string GetSapFieldMappingsCreateSql() => @"
CREATE TABLE SapFieldMappings (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
TargetField TEXT NOT NULL,
SourceExpression TEXT NOT NULL,
IsRequired INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
}
@@ -1,17 +1,23 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class DatabaseInitializationService : IDatabaseInitializationService
public partial class DatabaseInitializationService : IDatabaseInitializationService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IDatabaseSchemaMaintenanceService _schemaMaintenanceService;
private readonly IDatabaseSeedService _seedService;
public DatabaseInitializationService(IDbContextFactory<AppDbContext> dbFactory)
public DatabaseInitializationService(
IDbContextFactory<AppDbContext> dbFactory,
IDatabaseSchemaMaintenanceService schemaMaintenanceService,
IDatabaseSeedService seedService)
{
_dbFactory = dbFactory;
_schemaMaintenanceService = schemaMaintenanceService;
_seedService = seedService;
}
public async Task InitializeAsync()
@@ -19,9 +25,8 @@ public class DatabaseInitializationService : IDatabaseInitializationService
using var db = await _dbFactory.CreateDbContextAsync();
await db.Database.EnsureCreatedAsync();
ConfigureSqlite(db);
EnsureSchema(db);
SeedIfEmpty(db);
EnsureRecommendedTransformationRules(db);
_schemaMaintenanceService.EnsureSchema(db);
_seedService.SeedDefaults(db);
}
private static void ConfigureSqlite(AppDbContext db)
@@ -42,869 +47,4 @@ public class DatabaseInitializationService : IDatabaseInitializationService
timeout.ExecuteNonQuery();
}
}
private static void EnsureSchema(AppDbContext db)
{
EnsureSitesTableSupportsOptionalHanaServer(db);
EnsureExportSettingsTableSupportsCurrentSchema(db);
EnsureHanaServersTableSupportsCurrentSchema(db);
RepairBrokenForeignKeys(db);
AddColumnIfMissing(db, "HanaServers", "SourceSystem", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "AdditionalParams", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "PasswordOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "LocalExportFolderOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "ManualImportFilePath", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "ManualImportLastUploadedAtUtc", "TEXT NULL");
AddColumnIfMissing(db, "Sites", "SapServiceUrl", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySetsRefreshedAtUtc", "TEXT NULL");
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);
EnsureSourceSystemDefinitionTable(db);
AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''");
EnsureSapSourceTable(db);
EnsureSapJoinTable(db);
EnsureSapFieldMappingTable(db);
EnsureCentralSalesRecordTable(db);
EnsureAppEventLogTable(db);
EnsureSourceSystemDefinitions(db);
EnsureCentralHanaServerRecords(db);
}
private static void EnsureExportSettingsTableSupportsCurrentSchema(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
var columns = GetTableColumns(conn, transaction: null, "ExportSettings");
if (columns.Count == 0)
return;
var legacyColumns = new[]
{
"SapUsername",
"SapPassword",
"Bi1Username",
"Bi1Password",
"SageUsername",
"SagePassword"
};
if (!legacyColumns.Any(columns.Contains))
return;
RebuildTable(conn, "ExportSettings", GetExportSettingsCreateSql());
}
private static void EnsureHanaServersTableSupportsCurrentSchema(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
var columns = GetTableColumns(conn, transaction: null, "HanaServers");
if (columns.Count == 0)
return;
if (!columns.Contains("Username") && !columns.Contains("Password"))
return;
RebuildTable(conn, "HanaServers", GetHanaServersCreateSql());
}
private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
var hanaServerIdIsRequired = false;
{
using var pragma = conn.CreateCommand();
pragma.CommandText = "PRAGMA table_info(Sites)";
using var reader = pragma.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), "HanaServerId", StringComparison.OrdinalIgnoreCase))
{
hanaServerIdIsRequired = Convert.ToInt32(reader["notnull"]) == 1;
break;
}
}
}
if (!hanaServerIdIsRequired)
return;
using var disableFk = conn.CreateCommand();
disableFk.CommandText = "PRAGMA foreign_keys = OFF;";
disableFk.ExecuteNonQuery();
using var transaction = conn.BeginTransaction();
using (var rename = conn.CreateCommand())
{
rename.Transaction = transaction;
rename.CommandText = "ALTER TABLE Sites RENAME TO Sites_old;";
rename.ExecuteNonQuery();
}
using (var create = conn.CreateCommand())
{
create.Transaction = transaction;
create.CommandText = GetSitesCreateSql();
create.ExecuteNonQuery();
}
using (var copy = conn.CreateCommand())
{
copy.Transaction = transaction;
copy.CommandText = @"
INSERT INTO Sites (
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
UsernameOverride, PasswordOverride, LocalExportFolderOverride, ManualImportFilePath,
ManualImportLastUploadedAtUtc, SapServiceUrl, SapEntitySet, SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc, IsActive
)
SELECT
Id, HanaServerId, Schema, TSC, Land,
COALESCE(SourceSystem, 'SAP'),
COALESCE(UsernameOverride, ''),
COALESCE(PasswordOverride, ''),
COALESCE(LocalExportFolderOverride, ''),
COALESCE(ManualImportFilePath, ''),
ManualImportLastUploadedAtUtc,
COALESCE(SapServiceUrl, ''),
COALESCE(SapEntitySet, ''),
COALESCE(SapEntitySetsCache, ''),
SapEntitySetsRefreshedAtUtc,
IsActive
FROM Sites_old;";
copy.ExecuteNonQuery();
}
using (var drop = conn.CreateCommand())
{
drop.Transaction = transaction;
drop.CommandText = "DROP TABLE Sites_old;";
drop.ExecuteNonQuery();
}
transaction.Commit();
using var enableFk = conn.CreateCommand();
enableFk.CommandText = "PRAGMA foreign_keys = ON;";
enableFk.ExecuteNonQuery();
}
private static void RepairBrokenForeignKeys(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
var siteDependentTables = new[]
{
("ExportLogs", GetExportLogsCreateSql()),
("AppEventLogs", GetAppEventLogsCreateSql()),
("CentralSalesRecords", GetCentralSalesRecordsCreateSql()),
("SapSourceDefinitions", GetSapSourceDefinitionsCreateSql()),
("SapJoinDefinitions", GetSapJoinDefinitionsCreateSql()),
("SapFieldMappings", GetSapFieldMappingsCreateSql())
};
foreach (var (tableName, createSql) in siteDependentTables)
{
if (TableReferences(conn, tableName, "Sites_old"))
RebuildTable(conn, tableName, createSql);
}
if (TableReferences(conn, "Sites", "HanaServers_repair_old"))
RebuildTable(conn, "Sites", GetSitesCreateSql());
}
private static bool TableReferences(System.Data.Common.DbConnection connection, string tableName, string referencedTableName)
{
using var command = connection.CreateCommand();
command.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = $tableName;";
var parameter = command.CreateParameter();
parameter.ParameterName = "$tableName";
parameter.Value = tableName;
command.Parameters.Add(parameter);
var sql = command.ExecuteScalar()?.ToString() ?? string.Empty;
return sql.Contains(referencedTableName, StringComparison.OrdinalIgnoreCase);
}
private static void RebuildTable(System.Data.Common.DbConnection connection, string tableName, string createSql)
{
using var disableFk = connection.CreateCommand();
disableFk.CommandText = "PRAGMA foreign_keys = OFF;";
disableFk.ExecuteNonQuery();
using var transaction = connection.BeginTransaction();
var tempTableName = $"{tableName}_repair_old";
using (var rename = connection.CreateCommand())
{
rename.Transaction = transaction;
rename.CommandText = $"ALTER TABLE {tableName} RENAME TO {tempTableName};";
rename.ExecuteNonQuery();
}
using (var create = connection.CreateCommand())
{
create.Transaction = transaction;
create.CommandText = createSql;
create.ExecuteNonQuery();
}
var columns = GetSharedColumns(connection, transaction, tableName, tempTableName);
if (columns.Count > 0)
{
var columnList = string.Join(", ", columns);
using var copy = connection.CreateCommand();
copy.Transaction = transaction;
copy.CommandText = $"INSERT INTO {tableName} ({columnList}) SELECT {columnList} FROM {tempTableName};";
copy.ExecuteNonQuery();
}
using (var drop = connection.CreateCommand())
{
drop.Transaction = transaction;
drop.CommandText = $"DROP TABLE {tempTableName};";
drop.ExecuteNonQuery();
}
transaction.Commit();
using var enableFk = connection.CreateCommand();
enableFk.CommandText = "PRAGMA foreign_keys = ON;";
enableFk.ExecuteNonQuery();
}
private static List<string> GetSharedColumns(System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction? transaction, string newTableName, string oldTableName)
{
var newColumns = GetTableColumns(connection, transaction, newTableName);
var oldColumns = GetTableColumns(connection, transaction, oldTableName);
return newColumns.Where(oldColumns.Contains).ToList();
}
private static HashSet<string> GetTableColumns(System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction? transaction, string tableName)
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"PRAGMA table_info({tableName})";
using var reader = command.ExecuteReader();
while (reader.Read())
{
var name = reader["name"]?.ToString();
if (!string.IsNullOrWhiteSpace(name))
columns.Add(name);
}
return columns;
}
private static string GetExportLogsCreateSql() => @"
CREATE TABLE ExportLogs (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Timestamp TEXT NOT NULL,
SiteId INTEGER NOT NULL,
Land TEXT NOT NULL,
TSC TEXT NOT NULL,
Status TEXT NOT NULL,
RowCount INTEGER NOT NULL,
ErrorMessage TEXT NULL,
FileName TEXT NOT NULL DEFAULT '',
FilePath TEXT NOT NULL DEFAULT '',
DurationSeconds REAL NOT NULL,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
private static string GetExportSettingsCreateSql() => @"
CREATE TABLE ExportSettings (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
DateFilter TEXT NOT NULL,
TimerHour INTEGER NOT NULL,
TimerMinute INTEGER NOT NULL,
TimerEnabled INTEGER NOT NULL,
DebugLoggingEnabled INTEGER NOT NULL DEFAULT 0,
LocalSiteExportFolder TEXT NOT NULL DEFAULT '',
LocalConsolidatedExportFolder TEXT NOT NULL DEFAULT ''
);";
private static string GetHanaServersCreateSql() => @"
CREATE TABLE HanaServers (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SourceSystem TEXT NOT NULL,
Name TEXT NOT NULL,
Host TEXT NOT NULL,
Port INTEGER NOT NULL,
DatabaseName TEXT NOT NULL DEFAULT '',
UseSsl INTEGER NOT NULL DEFAULT 0,
ValidateCertificate INTEGER NOT NULL DEFAULT 0,
AdditionalParams TEXT NOT NULL DEFAULT ''
);";
private static string GetSitesCreateSql() => @"
CREATE TABLE Sites (
Id INTEGER NOT NULL CONSTRAINT PK_Sites PRIMARY KEY AUTOINCREMENT,
HanaServerId INTEGER NULL,
Schema TEXT NOT NULL,
TSC TEXT NOT NULL,
Land TEXT NOT NULL,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
UsernameOverride TEXT NOT NULL DEFAULT '',
PasswordOverride TEXT NOT NULL DEFAULT '',
LocalExportFolderOverride TEXT NOT NULL DEFAULT '',
ManualImportFilePath TEXT NOT NULL DEFAULT '',
ManualImportLastUploadedAtUtc TEXT NULL,
SapServiceUrl TEXT NOT NULL DEFAULT '',
SapEntitySet TEXT NOT NULL DEFAULT '',
SapEntitySetsCache TEXT NOT NULL DEFAULT '',
SapEntitySetsRefreshedAtUtc TEXT NULL,
IsActive INTEGER NOT NULL,
CONSTRAINT FK_Sites_HanaServers_HanaServerId FOREIGN KEY (HanaServerId) REFERENCES HanaServers (Id)
);";
private static string GetAppEventLogsCreateSql() => @"
CREATE TABLE AppEventLogs (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Timestamp TEXT NOT NULL,
Level TEXT NOT NULL,
Category TEXT NOT NULL,
SiteId INTEGER NULL,
Land TEXT NOT NULL,
Message TEXT NOT NULL,
Details TEXT NOT NULL,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
private static string GetCentralSalesRecordsCreateSql() => @"
CREATE TABLE CentralSalesRecords (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
StoredAtUtc TEXT NOT NULL,
SiteId INTEGER NOT NULL,
SourceSystem TEXT NOT NULL,
ExtractionDate TEXT NOT NULL,
Tsc TEXT NOT NULL,
InvoiceNumber TEXT NOT NULL,
PositionOnInvoice INTEGER NOT NULL,
Material TEXT NOT NULL,
Name TEXT NOT NULL,
ProductGroup TEXT NOT NULL,
Quantity TEXT NOT NULL,
SupplierNumber TEXT NOT NULL,
SupplierName TEXT NOT NULL,
SupplierCountry TEXT NOT NULL,
CustomerNumber TEXT NOT NULL,
CustomerName TEXT NOT NULL,
CustomerCountry TEXT NOT NULL,
CustomerIndustry TEXT NOT NULL,
StandardCost TEXT NOT NULL,
StandardCostCurrency TEXT NOT NULL,
PurchaseOrderNumber TEXT NOT NULL,
SalesPriceValue TEXT NOT NULL,
SalesCurrency TEXT NOT NULL,
Incoterms2020 TEXT NOT NULL,
SalesResponsibleEmployee TEXT NOT NULL,
InvoiceDate TEXT NULL,
OrderDate TEXT NULL,
Land TEXT NOT NULL,
DocumentType TEXT NOT NULL,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
private static string GetSapSourceDefinitionsCreateSql() => @"
CREATE TABLE SapSourceDefinitions (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
Alias TEXT NOT NULL,
EntitySet TEXT NOT NULL,
IsPrimary INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
private static string GetSapJoinDefinitionsCreateSql() => @"
CREATE TABLE SapJoinDefinitions (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
LeftAlias TEXT NOT NULL,
RightAlias TEXT NOT NULL,
LeftKeys TEXT NOT NULL,
RightKeys TEXT NOT NULL,
JoinType TEXT NOT NULL DEFAULT 'Left',
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
private static string GetSapFieldMappingsCreateSql() => @"
CREATE TABLE SapFieldMappings (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
TargetField TEXT NOT NULL,
SourceExpression TEXT NOT NULL,
IsRequired INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
var exists = false;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"PRAGMA table_info({table})";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), column, StringComparison.OrdinalIgnoreCase))
{
exists = true;
break;
}
}
}
if (!exists)
{
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
alter.ExecuteNonQuery();
}
}
private static void EnsureTransformationTable(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 FieldTransformationRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
SourceField TEXT NOT NULL,
TargetField TEXT NOT NULL,
TransformationType TEXT NOT NULL,
RuleScope TEXT NOT NULL DEFAULT 'Value',
Argument TEXT NOT NULL DEFAULT '',
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
cmd.ExecuteNonQuery();
}
private static void EnsureSapSourceTable(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 SapSourceDefinitions (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
Alias TEXT NOT NULL,
EntitySet TEXT NOT NULL,
IsPrimary INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
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();
if (conn.State != ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS SapJoinDefinitions (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
LeftAlias TEXT NOT NULL,
RightAlias TEXT NOT NULL,
LeftKeys TEXT NOT NULL,
RightKeys TEXT NOT NULL,
JoinType TEXT NOT NULL DEFAULT 'Left',
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
cmd.ExecuteNonQuery();
}
private static void EnsureSapFieldMappingTable(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 SapFieldMappings (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SiteId INTEGER NOT NULL,
TargetField TEXT NOT NULL,
SourceExpression TEXT NOT NULL,
IsRequired INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
cmd.ExecuteNonQuery();
}
private static void EnsureCentralSalesRecordTable(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 CentralSalesRecords (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
StoredAtUtc TEXT NOT NULL,
SiteId INTEGER NOT NULL,
SourceSystem TEXT NOT NULL,
ExtractionDate TEXT NOT NULL,
Tsc TEXT NOT NULL,
InvoiceNumber TEXT NOT NULL,
PositionOnInvoice INTEGER NOT NULL,
Material TEXT NOT NULL,
Name TEXT NOT NULL,
ProductGroup TEXT NOT NULL,
Quantity TEXT NOT NULL,
SupplierNumber TEXT NOT NULL,
SupplierName TEXT NOT NULL,
SupplierCountry TEXT NOT NULL,
CustomerNumber TEXT NOT NULL,
CustomerName TEXT NOT NULL,
CustomerCountry TEXT NOT NULL,
CustomerIndustry TEXT NOT NULL,
StandardCost TEXT NOT NULL,
StandardCostCurrency TEXT NOT NULL,
PurchaseOrderNumber TEXT NOT NULL,
SalesPriceValue TEXT NOT NULL,
SalesCurrency TEXT NOT NULL,
Incoterms2020 TEXT NOT NULL,
SalesResponsibleEmployee TEXT NOT NULL,
InvoiceDate TEXT NULL,
OrderDate TEXT NULL,
Land TEXT NOT NULL,
DocumentType TEXT NOT NULL,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
cmd.ExecuteNonQuery();
}
private static void EnsureAppEventLogTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS AppEventLogs (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Timestamp TEXT NOT NULL,
Level TEXT NOT NULL,
Category TEXT NOT NULL,
SiteId INTEGER NULL,
Land TEXT NOT NULL,
Message TEXT NOT NULL,
Details TEXT NOT NULL,
FOREIGN KEY (SiteId) REFERENCES Sites (Id)
);";
cmd.ExecuteNonQuery();
}
private static void EnsureSourceSystemDefinitionTable(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 SourceSystemDefinitions (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Code TEXT NOT NULL,
DisplayName TEXT NOT NULL,
ConnectionKind TEXT NOT NULL,
IsActive INTEGER NOT NULL DEFAULT 1,
CentralServiceUrl TEXT NOT NULL DEFAULT '',
CentralUsername TEXT NOT NULL DEFAULT '',
CentralPassword TEXT NOT NULL DEFAULT ''
);";
cmd.ExecuteNonQuery();
}
private static void SeedIfEmpty(AppDbContext db)
{
if (db.Sites.Any() || db.HanaServers.Any() || db.SharePointConfigs.Any() || db.ExportSettings.Any())
return;
var serverBi1 = new HanaServer { SourceSystem = "BI1", Name = "BI1", Host = "travtrp0", Port = 30015, Username = "", Password = "" };
var serverSage = new HanaServer { SourceSystem = "SAGE", Name = "SAGE", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" };
db.HanaServers.AddRange(serverBi1, serverSage);
db.SaveChanges();
db.Sites.AddRange(
new Site { HanaServerId = serverBi1.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", SourceSystem = "BI1", IsActive = true },
new Site { HanaServerId = serverBi1.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", SourceSystem = "BI1", IsActive = true },
new Site { HanaServerId = serverBi1.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", SourceSystem = "BI1", IsActive = true },
new Site { HanaServerId = serverSage.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", SourceSystem = "SAGE", IsActive = true }
);
db.SharePointConfigs.Add(new SharePointConfig
{
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
ExportFolder = "/Shared Documents/Exports/",
CentralExportFolder = "",
TenantId = "",
ClientId = "",
ClientSecret = ""
});
db.ExportSettings.Add(new ExportSettings
{
DateFilter = "2025-01-01",
TimerHour = 3,
TimerMinute = 0,
TimerEnabled = true,
DebugLoggingEnabled = false,
LocalSiteExportFolder = "",
LocalConsolidatedExportFolder = ""
});
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();
}
private static void EnsureCentralHanaServerRecords(AppDbContext db)
{
var centralSystems = db.SourceSystemDefinitions
.AsNoTracking()
.Where(x => x.ConnectionKind == SourceSystemConnectionKinds.Hana)
.OrderBy(x => x.Code)
.Select(x => x.Code)
.ToList();
var changed = false;
foreach (var sourceSystem in centralSystems)
{
var existingCentral = db.HanaServers
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.SourceSystem == sourceSystem);
if (existingCentral is not null)
{
if (string.IsNullOrWhiteSpace(existingCentral.Name))
{
existingCentral.Name = sourceSystem;
changed = true;
}
continue;
}
var linkedServer = db.Sites
.Include(x => x.HanaServer)
.Where(x => x.SourceSystem == sourceSystem && x.HanaServerId != null && x.HanaServer != null)
.Select(x => x.HanaServer!)
.OrderBy(x => x.Id)
.FirstOrDefault();
if (linkedServer is not null)
{
linkedServer.SourceSystem = sourceSystem;
if (string.IsNullOrWhiteSpace(linkedServer.Name))
linkedServer.Name = sourceSystem;
changed = true;
continue;
}
db.HanaServers.Add(new HanaServer
{
SourceSystem = sourceSystem,
Name = sourceSystem,
Host = string.Empty,
Port = 30015,
Username = string.Empty,
Password = string.Empty,
DatabaseName = string.Empty,
AdditionalParams = string.Empty
});
changed = true;
}
if (changed)
db.SaveChanges();
}
private static void EnsureSourceSystemDefinitions(AppDbContext db)
{
var defaults = new[]
{
new SourceSystemDefinition { Code = "SAP", DisplayName = "SAP", ConnectionKind = SourceSystemConnectionKinds.SapGateway, IsActive = true },
new SourceSystemDefinition { Code = "BI1", DisplayName = "BI1", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
new SourceSystemDefinition { Code = "SAGE", DisplayName = "SAGE", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
new SourceSystemDefinition { Code = "MANUAL_EXCEL", DisplayName = "Manual Excel", ConnectionKind = SourceSystemConnectionKinds.ManualExcel, IsActive = true }
};
var existing = db.SourceSystemDefinitions.ToList();
var changed = false;
foreach (var item in defaults)
{
var current = existing.FirstOrDefault(x => x.Code == item.Code);
if (current is null)
{
db.SourceSystemDefinitions.Add(item);
existing.Add(item);
changed = true;
continue;
}
if (string.IsNullOrWhiteSpace(current.DisplayName))
{
current.DisplayName = item.DisplayName;
changed = true;
}
if (string.IsNullOrWhiteSpace(current.ConnectionKind))
{
current.ConnectionKind = item.ConnectionKind;
changed = true;
}
if (string.IsNullOrWhiteSpace(current.CentralServiceUrl) &&
string.Equals(current.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
{
var sapSite = db.Sites
.Where(x => x.SourceSystem == current.Code && !string.IsNullOrWhiteSpace(x.SapServiceUrl))
.OrderBy(x => x.Id)
.FirstOrDefault();
if (sapSite is not null)
{
current.CentralServiceUrl = sapSite.SapServiceUrl;
changed = true;
}
}
}
if (changed)
db.SaveChanges();
}
}
@@ -0,0 +1,440 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public class DatabaseSchemaMaintenanceService : IDatabaseSchemaMaintenanceService
{
public void EnsureSchema(AppDbContext db)
{
EnsureSitesTableSupportsOptionalHanaServer(db);
EnsureExportSettingsTableSupportsCurrentSchema(db);
EnsureHanaServersTableSupportsCurrentSchema(db);
RepairBrokenForeignKeys(db);
AddColumnIfMissing(db, "HanaServers", "SourceSystem", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "DatabaseName", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "HanaServers", "UseSsl", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "ValidateCertificate", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "HanaServers", "AdditionalParams", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SourceSystem", "TEXT NOT NULL DEFAULT 'SAP'");
AddColumnIfMissing(db, "Sites", "UsernameOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "PasswordOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "LocalExportFolderOverride", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "ManualImportFilePath", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "ManualImportLastUploadedAtUtc", "TEXT NULL");
AddColumnIfMissing(db, "Sites", "SapServiceUrl", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySet", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySetsCache", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "Sites", "SapEntitySetsRefreshedAtUtc", "TEXT NULL");
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);
EnsureSourceSystemDefinitionTable(db);
AddColumnIfMissing(db, "SourceSystemDefinitions", "CentralServiceUrl", "TEXT NOT NULL DEFAULT ''");
EnsureSapSourceTable(db);
EnsureSapJoinTable(db);
EnsureSapFieldMappingTable(db);
EnsureCentralSalesRecordTable(db);
EnsureAppEventLogTable(db);
}
private static void EnsureExportSettingsTableSupportsCurrentSchema(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
var columns = DatabaseSchemaTools.GetTableColumns(conn, transaction: null, "ExportSettings");
if (columns.Count == 0)
return;
var legacyColumns = new[]
{
"SapUsername",
"SapPassword",
"Bi1Username",
"Bi1Password",
"SageUsername",
"SagePassword"
};
if (!legacyColumns.Any(columns.Contains))
return;
DatabaseSchemaTools.RebuildTable(conn, "ExportSettings", DatabaseSchemaSql.GetExportSettingsCreateSql());
}
private static void EnsureHanaServersTableSupportsCurrentSchema(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
var columns = DatabaseSchemaTools.GetTableColumns(conn, transaction: null, "HanaServers");
if (columns.Count == 0)
return;
if (!columns.Contains("Username") && !columns.Contains("Password"))
return;
DatabaseSchemaTools.RebuildTable(conn, "HanaServers", DatabaseSchemaSql.GetHanaServersCreateSql());
}
private static void EnsureSitesTableSupportsOptionalHanaServer(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
var hanaServerIdIsRequired = false;
{
using var pragma = conn.CreateCommand();
pragma.CommandText = "PRAGMA table_info(Sites)";
using var reader = pragma.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), "HanaServerId", StringComparison.OrdinalIgnoreCase))
{
hanaServerIdIsRequired = Convert.ToInt32(reader["notnull"]) == 1;
break;
}
}
}
if (!hanaServerIdIsRequired)
return;
using var disableFk = conn.CreateCommand();
disableFk.CommandText = "PRAGMA foreign_keys = OFF;";
disableFk.ExecuteNonQuery();
using var transaction = conn.BeginTransaction();
using (var rename = conn.CreateCommand())
{
rename.Transaction = transaction;
rename.CommandText = "ALTER TABLE Sites RENAME TO Sites_old;";
rename.ExecuteNonQuery();
}
using (var create = conn.CreateCommand())
{
create.Transaction = transaction;
create.CommandText = DatabaseSchemaSql.GetSitesCreateSql();
create.ExecuteNonQuery();
}
using (var copy = conn.CreateCommand())
{
copy.Transaction = transaction;
copy.CommandText = @"
INSERT INTO Sites (
Id, HanaServerId, Schema, TSC, Land, SourceSystem,
UsernameOverride, PasswordOverride, LocalExportFolderOverride, ManualImportFilePath,
ManualImportLastUploadedAtUtc, SapServiceUrl, SapEntitySet, SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc, IsActive
)
SELECT
Id, HanaServerId, Schema, TSC, Land,
COALESCE(SourceSystem, 'SAP'),
COALESCE(UsernameOverride, ''),
COALESCE(PasswordOverride, ''),
COALESCE(LocalExportFolderOverride, ''),
COALESCE(ManualImportFilePath, ''),
ManualImportLastUploadedAtUtc,
COALESCE(SapServiceUrl, ''),
COALESCE(SapEntitySet, ''),
COALESCE(SapEntitySetsCache, ''),
SapEntitySetsRefreshedAtUtc,
IsActive
FROM Sites_old;";
copy.ExecuteNonQuery();
}
using (var drop = conn.CreateCommand())
{
drop.Transaction = transaction;
drop.CommandText = "DROP TABLE Sites_old;";
drop.ExecuteNonQuery();
}
transaction.Commit();
using var enableFk = conn.CreateCommand();
enableFk.CommandText = "PRAGMA foreign_keys = ON;";
enableFk.ExecuteNonQuery();
}
private static void RepairBrokenForeignKeys(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
var siteDependentTables = new[]
{
("ExportLogs", DatabaseSchemaSql.GetExportLogsCreateSql()),
("AppEventLogs", DatabaseSchemaSql.GetAppEventLogsCreateSql()),
("CentralSalesRecords", DatabaseSchemaSql.GetCentralSalesRecordsCreateSql()),
("SapSourceDefinitions", DatabaseSchemaSql.GetSapSourceDefinitionsCreateSql()),
("SapJoinDefinitions", DatabaseSchemaSql.GetSapJoinDefinitionsCreateSql()),
("SapFieldMappings", DatabaseSchemaSql.GetSapFieldMappingsCreateSql())
};
foreach (var (tableName, createSql) in siteDependentTables)
{
if (DatabaseSchemaTools.TableReferences(conn, tableName, "Sites_old"))
DatabaseSchemaTools.RebuildTable(conn, tableName, createSql);
}
if (DatabaseSchemaTools.TableReferences(conn, "Sites", "HanaServers_repair_old"))
DatabaseSchemaTools.RebuildTable(conn, "Sites", DatabaseSchemaSql.GetSitesCreateSql());
}
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
var exists = false;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"PRAGMA table_info({table})";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader["name"]?.ToString(), column, StringComparison.OrdinalIgnoreCase))
{
exists = true;
break;
}
}
}
if (!exists)
{
using var alter = conn.CreateCommand();
alter.CommandText = $"ALTER TABLE {table} ADD COLUMN {column} {type}";
alter.ExecuteNonQuery();
}
}
private static void EnsureTransformationTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS FieldTransformationRules (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
SourceSystem TEXT NOT NULL DEFAULT 'SAP',
SourceField TEXT NOT NULL,
TargetField TEXT NOT NULL,
TransformationType TEXT NOT NULL,
RuleScope TEXT NOT NULL DEFAULT 'Value',
Argument TEXT NOT NULL DEFAULT '',
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1
);";
cmd.ExecuteNonQuery();
}
private static void EnsureSapSourceTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetSapSourceDefinitionsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureCurrencyExchangeRateTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.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();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetSapJoinDefinitionsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureSapFieldMappingTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetSapFieldMappingsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureCentralSalesRecordTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetCentralSalesRecordsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureAppEventLogTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = DatabaseSchemaSql.GetAppEventLogsCreateSql().Replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS");
cmd.ExecuteNonQuery();
}
private static void EnsureSourceSystemDefinitionTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS SourceSystemDefinitions (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
Code TEXT NOT NULL,
DisplayName TEXT NOT NULL,
ConnectionKind TEXT NOT NULL,
IsActive INTEGER NOT NULL DEFAULT 1,
CentralServiceUrl TEXT NOT NULL DEFAULT '',
CentralUsername TEXT NOT NULL DEFAULT '',
CentralPassword TEXT NOT NULL DEFAULT ''
);";
cmd.ExecuteNonQuery();
}
}
internal static class DatabaseSchemaTools
{
internal static bool TableReferences(System.Data.Common.DbConnection connection, string tableName, string referencedTableName)
{
using var command = connection.CreateCommand();
command.CommandText = "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = $tableName;";
var parameter = command.CreateParameter();
parameter.ParameterName = "$tableName";
parameter.Value = tableName;
command.Parameters.Add(parameter);
var sql = command.ExecuteScalar()?.ToString() ?? string.Empty;
return sql.Contains(referencedTableName, StringComparison.OrdinalIgnoreCase);
}
internal static void RebuildTable(System.Data.Common.DbConnection connection, string tableName, string createSql)
{
using var disableFk = connection.CreateCommand();
disableFk.CommandText = "PRAGMA foreign_keys = OFF;";
disableFk.ExecuteNonQuery();
using var transaction = connection.BeginTransaction();
var tempTableName = $"{tableName}_repair_old";
using (var rename = connection.CreateCommand())
{
rename.Transaction = transaction;
rename.CommandText = $"ALTER TABLE {tableName} RENAME TO {tempTableName};";
rename.ExecuteNonQuery();
}
using (var create = connection.CreateCommand())
{
create.Transaction = transaction;
create.CommandText = createSql;
create.ExecuteNonQuery();
}
var columns = GetSharedColumns(connection, transaction, tableName, tempTableName);
if (columns.Count > 0)
{
var columnList = string.Join(", ", columns);
using var copy = connection.CreateCommand();
copy.Transaction = transaction;
copy.CommandText = $"INSERT INTO {tableName} ({columnList}) SELECT {columnList} FROM {tempTableName};";
copy.ExecuteNonQuery();
}
using (var drop = connection.CreateCommand())
{
drop.Transaction = transaction;
drop.CommandText = $"DROP TABLE {tempTableName};";
drop.ExecuteNonQuery();
}
transaction.Commit();
using var enableFk = connection.CreateCommand();
enableFk.CommandText = "PRAGMA foreign_keys = ON;";
enableFk.ExecuteNonQuery();
}
internal static List<string> GetSharedColumns(System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction? transaction, string newTableName, string oldTableName)
{
var newColumns = GetTableColumns(connection, transaction, newTableName);
var oldColumns = GetTableColumns(connection, transaction, oldTableName);
return newColumns.Where(oldColumns.Contains).ToList();
}
internal static HashSet<string> GetTableColumns(System.Data.Common.DbConnection connection, System.Data.Common.DbTransaction? transaction, string tableName)
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"PRAGMA table_info({tableName})";
using var reader = command.ExecuteReader();
while (reader.Read())
{
var name = reader["name"]?.ToString();
if (!string.IsNullOrWhiteSpace(name))
columns.Add(name);
}
return columns;
}
}
@@ -0,0 +1,225 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class DatabaseSeedService : IDatabaseSeedService
{
public void SeedDefaults(AppDbContext db)
{
SeedIfEmpty(db);
EnsureRecommendedTransformationRules(db);
EnsureSourceSystemDefinitions(db);
EnsureCentralHanaServerRecords(db);
}
private static void SeedIfEmpty(AppDbContext db)
{
if (db.Sites.Any() || db.HanaServers.Any() || db.SharePointConfigs.Any() || db.ExportSettings.Any())
return;
var serverBi1 = new HanaServer { SourceSystem = "BI1", Name = "BI1", Host = "travtrp0", Port = 30015, Username = "", Password = "" };
var serverSage = new HanaServer { SourceSystem = "SAGE", Name = "SAGE", Host = "20.197.20.60", Port = 30015, Username = "", Password = "" };
db.HanaServers.AddRange(serverBi1, serverSage);
db.SaveChanges();
db.Sites.AddRange(
new Site { HanaServerId = serverBi1.Id, Schema = "fr01_p", TSC = "TRFR", Land = "Frankreich", SourceSystem = "BI1", IsActive = true },
new Site { HanaServerId = serverBi1.Id, Schema = "it01_p", TSC = "TRIT", Land = "Italien", SourceSystem = "BI1", IsActive = true },
new Site { HanaServerId = serverBi1.Id, Schema = "us01_p", TSC = "TRUS", Land = "USA", SourceSystem = "BI1", IsActive = true },
new Site { HanaServerId = serverSage.Id, Schema = "TRAFAG_LIVE", TSC = "TRIN", Land = "Indien", SourceSystem = "SAGE", IsActive = true }
);
db.SharePointConfigs.Add(new SharePointConfig
{
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
ExportFolder = "/Shared Documents/Exports/",
CentralExportFolder = "",
TenantId = "",
ClientId = "",
ClientSecret = ""
});
db.ExportSettings.Add(new ExportSettings
{
DateFilter = "2025-01-01",
TimerHour = 3,
TimerMinute = 0,
TimerEnabled = true,
DebugLoggingEnabled = false,
LocalSiteExportFolder = "",
LocalConsolidatedExportFolder = ""
});
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();
}
private static void EnsureCentralHanaServerRecords(AppDbContext db)
{
var centralSystems = db.SourceSystemDefinitions
.AsNoTracking()
.Where(x => x.ConnectionKind == SourceSystemConnectionKinds.Hana)
.OrderBy(x => x.Code)
.Select(x => x.Code)
.ToList();
var changed = false;
foreach (var sourceSystem in centralSystems)
{
var existingCentral = db.HanaServers
.OrderBy(x => x.Id)
.FirstOrDefault(x => x.SourceSystem == sourceSystem);
if (existingCentral is not null)
{
if (string.IsNullOrWhiteSpace(existingCentral.Name))
{
existingCentral.Name = sourceSystem;
changed = true;
}
continue;
}
var linkedServer = db.Sites
.Include(x => x.HanaServer)
.Where(x => x.SourceSystem == sourceSystem && x.HanaServerId != null && x.HanaServer != null)
.Select(x => x.HanaServer!)
.OrderBy(x => x.Id)
.FirstOrDefault();
if (linkedServer is not null)
{
linkedServer.SourceSystem = sourceSystem;
if (string.IsNullOrWhiteSpace(linkedServer.Name))
linkedServer.Name = sourceSystem;
changed = true;
continue;
}
db.HanaServers.Add(new HanaServer
{
SourceSystem = sourceSystem,
Name = sourceSystem,
Host = string.Empty,
Port = 30015,
Username = string.Empty,
Password = string.Empty,
DatabaseName = string.Empty,
AdditionalParams = string.Empty
});
changed = true;
}
if (changed)
db.SaveChanges();
}
private static void EnsureSourceSystemDefinitions(AppDbContext db)
{
var defaults = new[]
{
new SourceSystemDefinition { Code = "SAP", DisplayName = "SAP", ConnectionKind = SourceSystemConnectionKinds.SapGateway, IsActive = true },
new SourceSystemDefinition { Code = "BI1", DisplayName = "BI1", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
new SourceSystemDefinition { Code = "SAGE", DisplayName = "SAGE", ConnectionKind = SourceSystemConnectionKinds.Hana, IsActive = true },
new SourceSystemDefinition { Code = "MANUAL_EXCEL", DisplayName = "Manual Excel", ConnectionKind = SourceSystemConnectionKinds.ManualExcel, IsActive = true }
};
var existing = db.SourceSystemDefinitions.ToList();
var changed = false;
foreach (var item in defaults)
{
var current = existing.FirstOrDefault(x => x.Code == item.Code);
if (current is null)
{
db.SourceSystemDefinitions.Add(item);
existing.Add(item);
changed = true;
continue;
}
if (string.IsNullOrWhiteSpace(current.DisplayName))
{
current.DisplayName = item.DisplayName;
changed = true;
}
if (string.IsNullOrWhiteSpace(current.ConnectionKind))
{
current.ConnectionKind = item.ConnectionKind;
changed = true;
}
if (string.IsNullOrWhiteSpace(current.CentralServiceUrl) &&
string.Equals(current.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
{
var sapSite = db.Sites
.Where(x => x.SourceSystem == current.Code && !string.IsNullOrWhiteSpace(x.SapServiceUrl))
.OrderBy(x => x.Id)
.FirstOrDefault();
if (sapSite is not null)
{
current.CentralServiceUrl = sapSite.SapServiceUrl;
changed = true;
}
}
}
if (changed)
db.SaveChanges();
}
}
@@ -0,0 +1,8 @@
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public interface IDatabaseSchemaMaintenanceService
{
void EnsureSchema(AppDbContext db);
}
@@ -0,0 +1,8 @@
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public interface IDatabaseSeedService
{
void SeedDefaults(AppDbContext db);
}
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface ILogsPageService
{
Task<LogsPageState> LoadAsync(string? filterLand, string? filterStatus, DateTime? filterDate);
Task<int> DeleteOldLogsAsync(int olderThanDays);
}
public sealed class LogsPageService : ILogsPageService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public LogsPageService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<LogsPageState> LoadAsync(string? filterLand, string? filterStatus, DateTime? filterDate)
{
await using var db = await _dbFactory.CreateDbContextAsync();
IQueryable<ExportLog> query = db.ExportLogs.OrderByDescending(l => l.Timestamp);
if (!string.IsNullOrEmpty(filterLand))
query = query.Where(l => l.Land == filterLand);
if (!string.IsNullOrEmpty(filterStatus))
query = query.Where(l => l.Status == filterStatus);
if (filterDate.HasValue)
query = query.Where(l => l.Timestamp.Date == filterDate.Value.Date);
IQueryable<AppEventLog> appLogQuery = db.AppEventLogs.OrderByDescending(l => l.Timestamp);
if (!string.IsNullOrEmpty(filterLand))
appLogQuery = appLogQuery.Where(l => l.Land == filterLand);
if (filterDate.HasValue)
appLogQuery = appLogQuery.Where(l => l.Timestamp.Date == filterDate.Value.Date);
return new LogsPageState
{
AvailableLands = await db.ExportLogs.Select(l => l.Land).Distinct().OrderBy(l => l).ToListAsync(),
Logs = await query.Take(500).ToListAsync(),
AppLogs = await appLogQuery.Take(500).ToListAsync()
};
}
public async Task<int> DeleteOldLogsAsync(int olderThanDays)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var cutoff = DateTime.Now.AddDays(-olderThanDays);
var oldLogs = await db.ExportLogs.Where(l => l.Timestamp < cutoff).ToListAsync();
db.ExportLogs.RemoveRange(oldLogs);
await db.SaveChangesAsync();
return oldLogs.Count;
}
}
public sealed class LogsPageState
{
public List<ExportLog> Logs { get; set; } = [];
public List<AppEventLog> AppLogs { get; set; } = [];
public List<string> AvailableLands { get; set; } = [];
}
@@ -0,0 +1,56 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IManagementCockpitPageService
{
Task<ManagementCockpitPageState> InitializeAsync(string? selectedFilePath, int selectedCentralYear);
Task<List<ManagementCockpitFileOption>> LoadFilesAsync();
Task<List<int>> LoadCentralYearsAsync();
Task<ManagementCockpitResult> AnalyzeAsync(string filePath);
Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month);
}
public sealed class ManagementCockpitPageService : IManagementCockpitPageService
{
private readonly IManagementCockpitService _cockpitService;
public ManagementCockpitPageService(IManagementCockpitService cockpitService)
{
_cockpitService = cockpitService;
}
public async Task<ManagementCockpitPageState> InitializeAsync(string? selectedFilePath, int selectedCentralYear)
{
var files = await _cockpitService.GetAvailableFilesAsync();
var years = await _cockpitService.GetAvailableCentralYearsAsync();
return new ManagementCockpitPageState
{
Files = files,
CentralYears = years,
SelectedFilePath = selectedFilePath ?? files.FirstOrDefault()?.Path,
SelectedCentralYear = selectedCentralYear == 0 ? years.LastOrDefault() : selectedCentralYear
};
}
public Task<List<ManagementCockpitFileOption>> LoadFilesAsync()
=> _cockpitService.GetAvailableFilesAsync();
public Task<List<int>> LoadCentralYearsAsync()
=> _cockpitService.GetAvailableCentralYearsAsync();
public Task<ManagementCockpitResult> AnalyzeAsync(string filePath)
=> _cockpitService.AnalyzeAsync(filePath);
public Task<ManagementCockpitCentralResult> AnalyzeCentralAsync(int year, int? month)
=> _cockpitService.AnalyzeCentralAsync(year, month);
}
public sealed class ManagementCockpitPageState
{
public List<ManagementCockpitFileOption> Files { get; set; } = [];
public List<int> CentralYears { get; set; } = [];
public string? SelectedFilePath { get; set; }
public int SelectedCentralYear { get; set; }
}
@@ -0,0 +1,324 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface ISettingsPageService
{
Task<SettingsPageState> LoadAsync();
Task SaveSharePointAsync(SharePointConfig config);
Task<string> BuildSharePointTestPreviewAsync(SharePointConfig config);
Task SaveExportSettingsAsync(ExportSettings settings);
Task<List<SourceSystemDefinition>> SaveSourceSystemsAsync(List<SourceSystemDefinition> sourceSystems);
Task<List<CurrencyExchangeRate>> SaveExchangeRatesAsync(List<CurrencyExchangeRate> exchangeRates);
Task<SettingsExchangeRateRefreshResult> RefreshEcbRatesAsync();
Task<string> ExportConfigurationAsync(bool includeSecrets);
Task<SettingsPageState> ImportConfigurationAsync(string json);
Task<PageActionResult> TestCentralCredentialsAsync(SourceSystemDefinition definition);
}
public sealed class SettingsPageService : ISettingsPageService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ISharePointUploadService _sharePointService;
private readonly TimerBackgroundService _timerService;
private readonly IHanaQueryService _hanaService;
private readonly ISapGatewayService _sapGatewayService;
private readonly IConfigTransferService _configTransferService;
private readonly IExchangeRateImportService _exchangeRateImportService;
public SettingsPageService(
IDbContextFactory<AppDbContext> dbFactory,
ISharePointUploadService sharePointService,
TimerBackgroundService timerService,
IHanaQueryService hanaService,
ISapGatewayService sapGatewayService,
IConfigTransferService configTransferService,
IExchangeRateImportService exchangeRateImportService)
{
_dbFactory = dbFactory;
_sharePointService = sharePointService;
_timerService = timerService;
_hanaService = hanaService;
_sapGatewayService = sapGatewayService;
_configTransferService = configTransferService;
_exchangeRateImportService = exchangeRateImportService;
}
public async Task<SettingsPageState> LoadAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
return new SettingsPageState
{
SharePointConfig = await db.SharePointConfigs.FirstOrDefaultAsync() ?? new SharePointConfig(),
ExportSettings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings(),
SourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync(),
ExchangeRates = await LoadExchangeRatesAsync(db)
};
}
public async Task SaveSharePointAsync(SharePointConfig config)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var existing = await db.SharePointConfigs.FirstOrDefaultAsync();
if (existing is null)
{
db.SharePointConfigs.Add(config);
}
else
{
existing.SiteUrl = config.SiteUrl;
existing.ExportFolder = config.ExportFolder;
existing.CentralExportFolder = config.CentralExportFolder;
existing.TenantId = config.TenantId;
existing.ClientId = config.ClientId;
existing.ClientSecret = config.ClientSecret;
}
await db.SaveChangesAsync();
}
public async Task<string> BuildSharePointTestPreviewAsync(SharePointConfig config)
{
var tenantId = NormalizeConfigValue(config.TenantId);
var clientId = NormalizeConfigValue(config.ClientId);
var clientSecret = NormalizeConfigValue(config.ClientSecret);
var siteUrl = NormalizeConfigValue(config.SiteUrl);
await _sharePointService.TestConnectionAsync(tenantId, clientId, clientSecret, siteUrl);
return BuildSharePointTestPreview(tenantId, clientId, clientSecret, siteUrl);
}
public async Task SaveExportSettingsAsync(ExportSettings settings)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var existing = await db.ExportSettings.FirstOrDefaultAsync();
if (existing is null)
{
db.ExportSettings.Add(settings);
}
else
{
existing.DateFilter = settings.DateFilter;
existing.TimerHour = settings.TimerHour;
existing.TimerMinute = settings.TimerMinute;
existing.TimerEnabled = settings.TimerEnabled;
existing.DebugLoggingEnabled = settings.DebugLoggingEnabled;
existing.LocalSiteExportFolder = settings.LocalSiteExportFolder;
existing.LocalConsolidatedExportFolder = settings.LocalConsolidatedExportFolder;
}
await db.SaveChangesAsync();
_timerService.Recalculate();
}
public async Task<List<SourceSystemDefinition>> SaveSourceSystemsAsync(List<SourceSystemDefinition> sourceSystems)
{
var normalized = sourceSystems
.Select(x => new SourceSystemDefinition
{
Id = x.Id,
Code = NormalizeSourceSystemCode(x.Code),
DisplayName = NormalizeConfigValue(x.DisplayName),
ConnectionKind = NormalizeConnectionKind(x.ConnectionKind),
IsActive = x.IsActive,
CentralServiceUrl = NormalizeConfigValue(x.CentralServiceUrl),
CentralUsername = NormalizeConfigValue(x.CentralUsername),
CentralPassword = x.CentralPassword ?? string.Empty
})
.Where(x => !string.IsNullOrWhiteSpace(x.Code))
.ToList();
if (normalized.Any(x => string.IsNullOrWhiteSpace(x.DisplayName)))
throw new InvalidOperationException("Jedes Quellsystem braucht einen Anzeigenamen.");
var duplicates = normalized.GroupBy(x => x.Code).FirstOrDefault(g => g.Count() > 1);
if (duplicates is not null)
throw new InvalidOperationException($"Quellsystem-Code doppelt vorhanden: {duplicates.Key}");
await using var db = await _dbFactory.CreateDbContextAsync();
var existing = await db.SourceSystemDefinitions.ToListAsync();
if (existing.Count > 0)
db.SourceSystemDefinitions.RemoveRange(existing);
db.SourceSystemDefinitions.AddRange(normalized);
await db.SaveChangesAsync();
return await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync();
}
public async Task<List<CurrencyExchangeRate>> SaveExchangeRatesAsync(List<CurrencyExchangeRate> exchangeRates)
{
await 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();
return await LoadExchangeRatesAsync(db);
}
public async Task<SettingsExchangeRateRefreshResult> RefreshEcbRatesAsync()
{
var result = await _exchangeRateImportService.RefreshEcbRatesAsync();
await using var db = await _dbFactory.CreateDbContextAsync();
return new SettingsExchangeRateRefreshResult
{
ImportedCount = result.ImportedCount,
RateDate = result.RateDate,
ExchangeRates = await LoadExchangeRatesAsync(db)
};
}
public Task<string> ExportConfigurationAsync(bool includeSecrets)
=> _configTransferService.ExportJsonAsync(includeSecrets);
public async Task<SettingsPageState> ImportConfigurationAsync(string json)
{
await _configTransferService.ImportJsonAsync(json);
_timerService.Recalculate();
return await LoadAsync();
}
public async Task<PageActionResult> TestCentralCredentialsAsync(SourceSystemDefinition definition)
{
if (string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
return await TestCentralSapCredentialsAsync(definition);
if (string.Equals(definition.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase))
return await TestCentralHanaCredentialsAsync(definition);
return PageActionResult.WarningResult($"Quellsystem '{definition.Code}' hat keinen testbaren Verbindungstyp.");
}
private async Task<PageActionResult> TestCentralHanaCredentialsAsync(SourceSystemDefinition definition)
{
var sourceSystem = definition.Code;
var username = definition.CentralUsername;
var password = definition.CentralPassword;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return PageActionResult.WarningResult($"Fuer {sourceSystem} sind keine zentralen Zugangsdaten gepflegt.");
await using var db = await _dbFactory.CreateDbContextAsync();
var centralServer = await db.HanaServers
.Where(s => s.SourceSystem == sourceSystem)
.OrderBy(s => s.Id)
.FirstOrDefaultAsync();
if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host))
return PageActionResult.WarningResult($"Keine zentrale HANA-Konfiguration fuer {sourceSystem} gefunden.");
var testServer = new HanaServer
{
SourceSystem = sourceSystem,
Name = $"{sourceSystem} Central Test",
Host = centralServer.Host,
Port = centralServer.Port,
Username = username.Trim(),
Password = password.Trim(),
DatabaseName = centralServer.DatabaseName,
UseSsl = centralServer.UseSsl,
ValidateCertificate = centralServer.ValidateCertificate,
AdditionalParams = centralServer.AdditionalParams
};
var result = await Task.Run(() => _hanaService.TestConnectionDetailed(testServer));
return result.Success
? PageActionResult.SuccessResult($"{sourceSystem}: Zentrale HANA-Verbindung erfolgreich.")
: PageActionResult.ErrorResult($"{sourceSystem}: {result.ExceptionType} - {result.ErrorMessage}");
}
private async Task<PageActionResult> TestCentralSapCredentialsAsync(SourceSystemDefinition definition)
{
var sourceSystem = definition.Code;
var username = definition.CentralUsername;
var password = definition.CentralPassword;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return PageActionResult.WarningResult("Fuer SAP sind keine zentralen Gateway-Zugangsdaten gepflegt.");
if (string.IsNullOrWhiteSpace(definition.CentralServiceUrl))
return PageActionResult.WarningResult($"Fuer {sourceSystem} ist keine zentrale SAP Service URL gepflegt.");
try
{
await _sapGatewayService.TestConnectionAsync(definition.CentralServiceUrl, username.Trim(), password.Trim());
return PageActionResult.SuccessResult($"{sourceSystem}: Zentrale SAP Gateway-Verbindung erfolgreich.");
}
catch (Exception ex)
{
return PageActionResult.ErrorResult($"{sourceSystem}: {ex.Message}");
}
}
private static async Task<List<CurrencyExchangeRate>> LoadExchangeRatesAsync(AppDbContext db)
=> await db.CurrencyExchangeRates
.OrderBy(x => x.FromCurrency)
.ThenBy(x => x.ToCurrency)
.ThenByDescending(x => x.ValidFrom)
.ToListAsync();
public static string NormalizeSourceSystemCode(string? code) => NormalizeConfigValue(code).ToUpperInvariant();
public static string NormalizeConnectionKind(string? connectionKind)
=> SourceSystemConnectionKinds.All.Contains(connectionKind ?? string.Empty, StringComparer.OrdinalIgnoreCase)
? (connectionKind ?? string.Empty).Trim().ToUpperInvariant()
: SourceSystemConnectionKinds.Hana;
public static string NormalizeConfigValue(string? value) => value?.Trim() ?? string.Empty;
public 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}"
]);
}
}
public sealed class SettingsPageState
{
public SharePointConfig SharePointConfig { get; set; } = new();
public ExportSettings ExportSettings { get; set; } = new();
public List<SourceSystemDefinition> SourceSystems { get; set; } = [];
public List<CurrencyExchangeRate> ExchangeRates { get; set; } = [];
}
public sealed class SettingsExchangeRateRefreshResult
{
public int ImportedCount { get; set; }
public DateTime RateDate { get; set; }
public List<CurrencyExchangeRate> ExchangeRates { get; set; } = [];
}
public sealed class PageActionResult
{
public bool Success { get; init; }
public bool Warning { get; init; }
public string Message { get; init; } = string.Empty;
public static PageActionResult SuccessResult(string message) => new() { Success = true, Message = message };
public static PageActionResult WarningResult(string message) => new() { Warning = true, Message = message };
public static PageActionResult ErrorResult(string message) => new() { Message = message };
}
@@ -0,0 +1,522 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IStandortePageService
{
Task<StandortePageState> LoadAsync();
Task SaveServerAsync(HanaServer server, IEnumerable<string> hanaSourceSystemCodes);
Task DeleteServerAsync(HanaServer server);
Task<ConnectionTestResult> TestServerConnectionAsync(HanaServer server);
Task<StandortEditorState> LoadSiteEditorAsync(Site site, IEnumerable<SourceSystemDefinition> sourceSystems);
Task SaveSiteAsync(Site site, bool usesHanaConnection, bool isSapSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings, List<string> sapEntitySetsCache);
Task DeleteSiteAsync(Site site);
Task<List<string>> LoadAvailableSchemasAsync(Site site);
Task<SapEntitySetRefreshResult> RefreshSapEntitySetsAsync(Site site);
Task<SapSourceFieldRefreshResult> RefreshSapSourceFieldsAsync(Site site, List<SapSourceDefinition> sapSources, List<SapFieldMapping> sapMappings);
Task<DateTime> ValidateManualImportPathAsync(string manualImportFilePath);
}
public sealed class StandortePageService : IStandortePageService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IHanaQueryService _hanaService;
private readonly ISapGatewayService _sapGatewayService;
private readonly ISharePointUploadService _sharePointService;
private readonly IAppEventLogService _appEventLogService;
public StandortePageService(
IDbContextFactory<AppDbContext> dbFactory,
IHanaQueryService hanaService,
ISapGatewayService sapGatewayService,
ISharePointUploadService sharePointService,
IAppEventLogService appEventLogService)
{
_dbFactory = dbFactory;
_hanaService = hanaService;
_sapGatewayService = sapGatewayService;
_sharePointService = sharePointService;
_appEventLogService = appEventLogService;
}
public async Task<StandortePageState> LoadAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
var sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync();
var hanaSourceSystemCodes = sourceSystems
.Where(x => string.Equals(x.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase))
.Select(x => x.Code)
.ToList();
return new StandortePageState
{
SourceSystems = sourceSystems,
Servers = await db.HanaServers
.Where(s => hanaSourceSystemCodes.Contains(s.SourceSystem))
.OrderBy(s => s.SourceSystem)
.ThenBy(s => s.Name)
.ToListAsync(),
Sites = await db.Sites.Include(s => s.HanaServer).OrderBy(s => s.Land).ToListAsync()
};
}
public async Task SaveServerAsync(HanaServer server, IEnumerable<string> hanaSourceSystemCodes)
{
server.SourceSystem = string.IsNullOrWhiteSpace(server.SourceSystem)
? hanaSourceSystemCodes.FirstOrDefault() ?? string.Empty
: server.SourceSystem.Trim().ToUpperInvariant();
server.Name = string.IsNullOrWhiteSpace(server.Name) ? server.SourceSystem : server.Name.Trim();
server.Host = server.Host.Trim();
server.DatabaseName = server.DatabaseName.Trim();
server.AdditionalParams = server.AdditionalParams.Trim();
server.Username = string.Empty;
server.Password = string.Empty;
await using var db = await _dbFactory.CreateDbContextAsync();
if (server.Id == 0)
{
var existingForSourceSystem = await db.HanaServers
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.SourceSystem == server.SourceSystem);
if (existingForSourceSystem is null)
{
db.HanaServers.Add(server);
}
else
{
ApplyServer(existingForSourceSystem, server);
}
}
else
{
var existing = await db.HanaServers.FindAsync(server.Id);
if (existing is not null)
ApplyServer(existing, server);
}
await db.SaveChangesAsync();
}
public async Task DeleteServerAsync(HanaServer server)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var linkedSites = await db.Sites
.Where(s => s.HanaServerId == server.Id)
.OrderBy(s => s.Land)
.Select(s => $"{s.Land} ({s.TSC})")
.ToListAsync();
if (linkedSites.Count > 0)
throw new InvalidOperationException($"Server kann nicht geloescht werden. Noch verknuepfte Standorte: {string.Join(", ", linkedSites)}");
var entity = await db.HanaServers.FindAsync(server.Id);
if (entity is not null)
{
db.HanaServers.Remove(entity);
await db.SaveChangesAsync();
}
}
public async Task<ConnectionTestResult> TestServerConnectionAsync(HanaServer server)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var sourceDefinition = await db.SourceSystemDefinitions
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(x => x.Code == server.SourceSystem);
if (sourceDefinition is null)
throw new InvalidOperationException($"Quellsystem '{server.SourceSystem}' nicht gefunden.");
if (string.IsNullOrWhiteSpace(sourceDefinition.CentralUsername) || string.IsNullOrWhiteSpace(sourceDefinition.CentralPassword))
throw new InvalidOperationException($"Fuer {server.SourceSystem} sind keine zentralen Zugangsdaten im Quellsystem gepflegt.");
var testServer = new HanaServer
{
Id = server.Id,
SourceSystem = server.SourceSystem,
Name = server.Name,
Host = server.Host,
Port = server.Port,
Username = sourceDefinition.CentralUsername.Trim(),
Password = sourceDefinition.CentralPassword,
DatabaseName = server.DatabaseName,
UseSsl = server.UseSsl,
ValidateCertificate = server.ValidateCertificate,
AdditionalParams = server.AdditionalParams
};
await _appEventLogService.WriteAsync("HANA", "Server-Test aus UI gestartet", details: testServer.GetConnectionStringPreview());
return await Task.Run(() => _hanaService.TestConnectionDetailed(testServer));
}
public async Task<StandortEditorState> LoadSiteEditorAsync(Site site, IEnumerable<SourceSystemDefinition> sourceSystems)
{
var effectiveSourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem)
? sourceSystems.FirstOrDefault()?.Code ?? "SAP"
: site.SourceSystem;
await using var db = await _dbFactory.CreateDbContextAsync();
var sapSources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).OrderBy(s => s.SortOrder).ThenBy(s => s.Id).ToListAsync();
var sapJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).OrderBy(j => j.SortOrder).ThenBy(j => j.Id).ToListAsync();
var sapMappings = await db.SapFieldMappings.Where(m => m.SiteId == site.Id).OrderBy(m => m.SortOrder).ThenBy(m => m.Id).ToListAsync();
return new StandortEditorState
{
Site = new Site
{
Id = site.Id,
HanaServerId = site.HanaServerId,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
SourceSystem = effectiveSourceSystem,
UsernameOverride = site.UsernameOverride,
PasswordOverride = site.PasswordOverride,
LocalExportFolderOverride = site.LocalExportFolderOverride,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
IsActive = site.IsActive
},
SapEntitySets = ParseSapEntitySets(site.SapEntitySetsCache),
SapSources = sapSources,
SapJoins = sapJoins,
SapMappings = sapMappings
};
}
public async Task SaveSiteAsync(Site site, bool usesHanaConnection, bool isSapSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings, List<string> sapEntitySetsCache)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var serverId = usesHanaConnection ? await ResolveCentralHanaServerIdAsync(db, site) : (int?)null;
site.HanaServerId = serverId;
site.SapEntitySetsCache = JsonSerializer.Serialize(sapEntitySetsCache);
if (site.Id == 0)
{
db.Sites.Add(site);
}
else
{
var existing = await db.Sites.FindAsync(site.Id);
if (existing is not null)
ApplySite(existing, site);
}
await db.SaveChangesAsync();
await SaveSapConfigurationAsync(db, site.Id, isSapSite, sapSources, sapJoins, sapMappings);
}
public async Task DeleteSiteAsync(Site site)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Sites.FindAsync(site.Id);
if (entity is null)
return;
var sources = await db.SapSourceDefinitions.Where(s => s.SiteId == site.Id).ToListAsync();
var joins = await db.SapJoinDefinitions.Where(j => j.SiteId == site.Id).ToListAsync();
var mappings = await db.SapFieldMappings.Where(m => m.SiteId == site.Id).ToListAsync();
var centralRows = await db.CentralSalesRecords.Where(r => r.SiteId == site.Id).ToListAsync();
if (sources.Count > 0) db.SapSourceDefinitions.RemoveRange(sources);
if (joins.Count > 0) db.SapJoinDefinitions.RemoveRange(joins);
if (mappings.Count > 0) db.SapFieldMappings.RemoveRange(mappings);
if (centralRows.Count > 0) db.CentralSalesRecords.RemoveRange(centralRows);
db.Sites.Remove(entity);
await db.SaveChangesAsync();
}
public async Task<List<string>> LoadAvailableSchemasAsync(Site site)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var sourceDefinition = await db.SourceSystemDefinitions.OrderBy(x => x.Id).FirstOrDefaultAsync(x => x.Code == site.SourceSystem)
?? throw new InvalidOperationException($"Quellsystem '{site.SourceSystem}' nicht gefunden.");
var centralServer = await db.HanaServers.OrderBy(x => x.Id).FirstOrDefaultAsync(x => x.SourceSystem == site.SourceSystem);
if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host))
throw new InvalidOperationException($"Fuer {site.SourceSystem} ist keine gueltige zentrale HANA-Konfiguration vorhanden.");
var username = string.IsNullOrWhiteSpace(site.UsernameOverride) ? sourceDefinition.CentralUsername ?? string.Empty : site.UsernameOverride;
var password = string.IsNullOrWhiteSpace(site.PasswordOverride) ? sourceDefinition.CentralPassword ?? string.Empty : site.PasswordOverride;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException($"Fuer {site.SourceSystem} sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
var lookupServer = new HanaServer
{
Id = centralServer.Id,
SourceSystem = centralServer.SourceSystem,
Name = centralServer.Name,
Host = centralServer.Host,
Port = centralServer.Port,
Username = username.Trim(),
Password = password,
DatabaseName = centralServer.DatabaseName,
UseSsl = centralServer.UseSsl,
ValidateCertificate = centralServer.ValidateCertificate,
AdditionalParams = centralServer.AdditionalParams
};
return await Task.Run(() => _hanaService.GetAvailableSchemas(lookupServer))
.ContinueWith(task => task.Result
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList());
}
public async Task<SapEntitySetRefreshResult> RefreshSapEntitySetsAsync(Site site)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var sourceDefinition = await db.SourceSystemDefinitions.OrderBy(x => x.Id).FirstOrDefaultAsync(x => x.Code == site.SourceSystem);
var serviceUrl = string.IsNullOrWhiteSpace(site.SapServiceUrl) ? sourceDefinition?.CentralServiceUrl ?? string.Empty : site.SapServiceUrl;
if (string.IsNullOrWhiteSpace(serviceUrl))
throw new InvalidOperationException("Es ist weder eine zentrale SAP Service URL noch ein Standort-Override gesetzt.");
var username = string.IsNullOrWhiteSpace(site.UsernameOverride) ? sourceDefinition?.CentralUsername ?? string.Empty : site.UsernameOverride;
var password = string.IsNullOrWhiteSpace(site.PasswordOverride) ? sourceDefinition?.CentralPassword ?? string.Empty : site.PasswordOverride;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException("Fuer SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
await _appEventLogService.WriteAsync("SAP", "Refresh aus UI gestartet", siteId: site.Id, land: site.Land, details: serviceUrl);
var entitySets = await _sapGatewayService.GetEntitySetsAsync(serviceUrl, username.Trim(), password.Trim());
await _appEventLogService.WriteAsync("SAP", "Refresh aus UI erfolgreich", siteId: site.Id, land: site.Land, details: $"EntitySets={entitySets.Count}");
return new SapEntitySetRefreshResult
{
EntitySets = entitySets,
RefreshedAtUtc = DateTime.UtcNow
};
}
public async Task<SapSourceFieldRefreshResult> RefreshSapSourceFieldsAsync(Site site, List<SapSourceDefinition> sapSources, List<SapFieldMapping> sapMappings)
{
var activeSources = sapSources
.Where(s => s.IsActive && !string.IsNullOrWhiteSpace(s.Alias) && !string.IsNullOrWhiteSpace(s.EntitySet))
.OrderBy(s => s.SortOrder)
.ThenBy(s => s.Id)
.ToList();
if (activeSources.Count == 0)
throw new InvalidOperationException("Es gibt keine aktiven SAP-Quellen mit Alias und Entity Set.");
await using var db = await _dbFactory.CreateDbContextAsync();
var sourceDefinition = await db.SourceSystemDefinitions.OrderBy(x => x.Id).FirstOrDefaultAsync(x => x.Code == site.SourceSystem);
var serviceUrl = string.IsNullOrWhiteSpace(site.SapServiceUrl) ? sourceDefinition?.CentralServiceUrl ?? string.Empty : site.SapServiceUrl;
if (string.IsNullOrWhiteSpace(serviceUrl))
throw new InvalidOperationException("Es ist weder eine zentrale SAP Service URL noch ein Standort-Override gesetzt.");
var username = string.IsNullOrWhiteSpace(site.UsernameOverride) ? sourceDefinition?.CentralUsername ?? string.Empty : site.UsernameOverride;
var password = string.IsNullOrWhiteSpace(site.PasswordOverride) ? sourceDefinition?.CentralPassword ?? string.Empty : site.PasswordOverride;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException("Fuer SAP sind weder zentrale Zugangsdaten noch Standort-Overrides gesetzt.");
var expressions = new List<string> { "=SAP" };
var sourceFieldMap = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var source in activeSources)
{
var fieldNames = await _sapGatewayService.GetEntityFieldNamesAsync(serviceUrl, source.EntitySet, username.Trim(), password.Trim());
sourceFieldMap[source.Alias] = fieldNames;
expressions.AddRange(fieldNames.Select(field => $"{source.Alias}.{field}"));
}
foreach (var current in sapMappings.Select(m => m.SourceExpression).Where(x => !string.IsNullOrWhiteSpace(x)))
{
if (!expressions.Contains(current, StringComparer.OrdinalIgnoreCase))
expressions.Add(current);
}
return new SapSourceFieldRefreshResult
{
SourceFieldMap = sourceFieldMap,
SourceExpressions = expressions
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
public async Task<DateTime> ValidateManualImportPathAsync(string manualImportFilePath)
{
var trimmedPath = manualImportFilePath.Trim();
if (string.IsNullOrWhiteSpace(trimmedPath))
throw new InvalidOperationException("Bitte zuerst einen Dateipfad eintragen.");
if (!string.Equals(Path.GetExtension(trimmedPath), ".xlsx", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Bitte eine Excel-Datei mit Endung .xlsx angeben.");
if (File.Exists(trimmedPath))
return File.GetLastWriteTimeUtc(trimmedPath);
if (!LooksLikeSharePointReference(trimmedPath))
throw new InvalidOperationException($"Datei nicht gefunden oder nicht erreichbar: {trimmedPath}");
await using var db = await _dbFactory.CreateDbContextAsync();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
if (spConfig is null ||
string.IsNullOrWhiteSpace(spConfig.TenantId) ||
string.IsNullOrWhiteSpace(spConfig.ClientId) ||
string.IsNullOrWhiteSpace(spConfig.ClientSecret) ||
string.IsNullOrWhiteSpace(spConfig.SiteUrl))
{
throw new InvalidOperationException("Fuer SharePoint-Pruefung fehlt eine vollstaendige SharePoint-Konfiguration in Settings.");
}
var tempPath = await _sharePointService.DownloadToTempFileAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret, spConfig.SiteUrl, trimmedPath);
try
{
return File.GetLastWriteTimeUtc(tempPath);
}
finally
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
}
private static void ApplyServer(HanaServer target, HanaServer source)
{
target.SourceSystem = source.SourceSystem;
target.Name = source.Name;
target.Host = source.Host;
target.Port = source.Port;
target.Username = string.Empty;
target.Password = string.Empty;
target.DatabaseName = source.DatabaseName;
target.UseSsl = source.UseSsl;
target.ValidateCertificate = source.ValidateCertificate;
target.AdditionalParams = source.AdditionalParams;
}
private static void ApplySite(Site target, Site source)
{
target.HanaServerId = source.HanaServerId;
target.Schema = source.Schema;
target.TSC = source.TSC;
target.Land = source.Land;
target.SourceSystem = source.SourceSystem;
target.UsernameOverride = source.UsernameOverride;
target.PasswordOverride = source.PasswordOverride;
target.LocalExportFolderOverride = source.LocalExportFolderOverride;
target.ManualImportFilePath = source.ManualImportFilePath;
target.ManualImportLastUploadedAtUtc = source.ManualImportLastUploadedAtUtc;
target.SapServiceUrl = source.SapServiceUrl;
target.SapEntitySet = source.SapEntitySet;
target.SapEntitySetsCache = source.SapEntitySetsCache;
target.SapEntitySetsRefreshedAtUtc = source.SapEntitySetsRefreshedAtUtc;
target.IsActive = source.IsActive;
}
private static List<string> ParseSapEntitySets(string json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
private static bool LooksLikeSharePointReference(string path)
=> path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/Shared Documents/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("Shared Documents/", StringComparison.OrdinalIgnoreCase);
private static void NormalizeSapConfigCollections(List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings)
{
for (var i = 0; i < sapSources.Count; i++)
sapSources[i].SortOrder = i;
for (var i = 0; i < sapJoins.Count; i++)
sapJoins[i].SortOrder = i;
for (var i = 0; i < sapMappings.Count; i++)
sapMappings[i].SortOrder = i;
var selectedPrimaryIndex = sapSources.FindIndex(s => s.IsPrimary);
var primarySource = selectedPrimaryIndex >= 0 ? sapSources[selectedPrimaryIndex] : sapSources.FirstOrDefault();
foreach (var source in sapSources)
source.IsPrimary = primarySource is not null && ReferenceEquals(source, primarySource);
if (sapSources.Count > 0 && sapSources.All(s => !s.IsPrimary))
sapSources[0].IsPrimary = true;
}
private static async Task SaveSapConfigurationAsync(AppDbContext db, int siteId, bool isSapSite, List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings)
{
var oldSources = await db.SapSourceDefinitions.Where(s => s.SiteId == siteId).ToListAsync();
var oldJoins = await db.SapJoinDefinitions.Where(j => j.SiteId == siteId).ToListAsync();
var oldMappings = await db.SapFieldMappings.Where(m => m.SiteId == siteId).ToListAsync();
if (oldSources.Count > 0) db.SapSourceDefinitions.RemoveRange(oldSources);
if (oldJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(oldJoins);
if (oldMappings.Count > 0) db.SapFieldMappings.RemoveRange(oldMappings);
if (isSapSite)
{
NormalizeSapConfigCollections(sapSources, sapJoins, sapMappings);
foreach (var source in sapSources) source.SiteId = siteId;
foreach (var join in sapJoins) join.SiteId = siteId;
foreach (var mapping in sapMappings) mapping.SiteId = siteId;
db.SapSourceDefinitions.AddRange(sapSources);
db.SapJoinDefinitions.AddRange(sapJoins);
db.SapFieldMappings.AddRange(sapMappings);
}
await db.SaveChangesAsync();
}
private static async Task<int> ResolveCentralHanaServerIdAsync(AppDbContext db, Site site)
{
site.UsernameOverride = site.UsernameOverride.Trim();
site.PasswordOverride = site.PasswordOverride.Trim();
site.LocalExportFolderOverride = site.LocalExportFolderOverride.Trim();
site.ManualImportFilePath = site.ManualImportFilePath.Trim();
site.SapServiceUrl = site.SapServiceUrl.Trim();
site.SapEntitySet = site.SapEntitySet.Trim();
var normalizedSourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? string.Empty : site.SourceSystem.Trim().ToUpperInvariant();
var centralServer = await db.HanaServers.OrderBy(x => x.Id).FirstOrDefaultAsync(x => x.SourceSystem == normalizedSourceSystem);
if (centralServer is null || string.IsNullOrWhiteSpace(centralServer.Host))
throw new InvalidOperationException($"Fuer Quellsystem '{normalizedSourceSystem}' ist keine gueltige zentrale HANA-Konfiguration vorhanden.");
return centralServer.Id;
}
}
public sealed class StandortePageState
{
public List<SourceSystemDefinition> SourceSystems { get; set; } = [];
public List<HanaServer> Servers { get; set; } = [];
public List<Site> Sites { get; set; } = [];
}
public sealed class StandortEditorState
{
public Site Site { get; set; } = new();
public List<string> SapEntitySets { get; set; } = [];
public List<SapSourceDefinition> SapSources { get; set; } = [];
public List<SapJoinDefinition> SapJoins { get; set; } = [];
public List<SapFieldMapping> SapMappings { get; set; } = [];
}
public sealed class SapEntitySetRefreshResult
{
public List<string> EntitySets { get; set; } = [];
public DateTime RefreshedAtUtc { get; set; }
}
public sealed class SapSourceFieldRefreshResult
{
public List<string> SourceExpressions { get; set; } = [];
public Dictionary<string, List<string>> SourceFieldMap { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
@@ -0,0 +1,240 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IStandorteSapEditorService
{
void AddSapSource(List<SapSourceDefinition> sapSources, List<string> sapEntitySetsCache);
void RemoveSapSource(List<SapSourceDefinition> sapSources, SapSourceDefinition source);
void AddSapJoin(List<SapJoinDefinition> sapJoins);
SapAutoMatchResult AutoMatchSapJoins(List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, Dictionary<string, List<string>> sapSourceFieldMap);
void RemoveSapJoin(List<SapJoinDefinition> sapJoins, SapJoinDefinition join);
void AddSapMapping(List<SapFieldMapping> sapMappings, IReadOnlyList<string> salesRecordFields, List<string> sapAvailableSourceExpressions);
void RemoveSapMapping(List<SapFieldMapping> sapMappings, SapFieldMapping mapping);
List<string> BuildSourceExpressionsFromMappings(List<SapFieldMapping> sapMappings);
Dictionary<string, List<string>> BuildSourceFieldMapFromJoins(List<SapJoinDefinition> sapJoins);
IEnumerable<string> GetSapAliases(List<SapSourceDefinition> sapSources);
IEnumerable<string> GetAvailableSourceExpressions(List<string> sapAvailableSourceExpressions, string? currentValue);
IEnumerable<string> GetAvailableJoinFields(Dictionary<string, List<string>> sapSourceFieldMap, string? alias, string? currentKeys);
void NormalizeSapConfigCollections(List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings);
}
public sealed class StandorteSapEditorService : IStandorteSapEditorService
{
public void AddSapSource(List<SapSourceDefinition> sapSources, List<string> sapEntitySetsCache)
{
sapSources.Add(new SapSourceDefinition
{
Alias = $"SRC{sapSources.Count + 1}",
EntitySet = sapEntitySetsCache.FirstOrDefault() ?? string.Empty,
IsActive = true,
IsPrimary = sapSources.Count == 0,
SortOrder = sapSources.Count
});
}
public void RemoveSapSource(List<SapSourceDefinition> sapSources, SapSourceDefinition source)
=> sapSources.Remove(source);
public void AddSapJoin(List<SapJoinDefinition> sapJoins)
{
sapJoins.Add(new SapJoinDefinition
{
JoinType = "Left",
IsActive = true,
SortOrder = sapJoins.Count
});
}
public SapAutoMatchResult AutoMatchSapJoins(List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, Dictionary<string, List<string>> sapSourceFieldMap)
{
var activeSources = sapSources
.Where(s => s.IsActive && !string.IsNullOrWhiteSpace(s.Alias))
.OrderBy(s => s.SortOrder)
.ThenBy(s => s.Id)
.ToList();
if (activeSources.Count < 2)
return SapAutoMatchResult.WarningResult("Fuer Auto-Match werden mindestens zwei aktive SAP-Quellen benoetigt.");
if (sapSourceFieldMap.Count == 0)
return SapAutoMatchResult.WarningResult("Bitte zuerst 'Felder aus Quellen laden' ausfuehren.");
var primary = activeSources.FirstOrDefault(s => s.IsPrimary) ?? activeSources.First();
var createdOrUpdated = 0;
foreach (var source in activeSources.Where(s => !string.Equals(s.Alias, primary.Alias, StringComparison.OrdinalIgnoreCase)))
{
if (!sapSourceFieldMap.TryGetValue(primary.Alias, out var leftFields) || leftFields.Count == 0)
continue;
if (!sapSourceFieldMap.TryGetValue(source.Alias, out var rightFields) || rightFields.Count == 0)
continue;
var matchingFields = leftFields
.Intersect(rightFields, StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
if (matchingFields.Count == 0)
continue;
var existingJoin = sapJoins.FirstOrDefault(j =>
string.Equals(j.LeftAlias, primary.Alias, StringComparison.OrdinalIgnoreCase) &&
string.Equals(j.RightAlias, source.Alias, StringComparison.OrdinalIgnoreCase));
var keyList = string.Join(',', matchingFields);
if (existingJoin is null)
{
sapJoins.Add(new SapJoinDefinition
{
LeftAlias = primary.Alias,
RightAlias = source.Alias,
LeftKeys = keyList,
RightKeys = keyList,
JoinType = "Left",
IsActive = true,
SortOrder = sapJoins.Count
});
}
else
{
existingJoin.LeftKeys = keyList;
existingJoin.RightKeys = keyList;
existingJoin.JoinType = "Left";
existingJoin.IsActive = true;
}
createdOrUpdated++;
}
if (createdOrUpdated == 0)
return SapAutoMatchResult.InfoResult("Kein passender Join-Vorschlag gefunden.");
NormalizeSapConfigCollections(sapSources, sapJoins, []);
return SapAutoMatchResult.SuccessResult($"{createdOrUpdated} Join-Vorschlaege gesetzt.");
}
public void RemoveSapJoin(List<SapJoinDefinition> sapJoins, SapJoinDefinition join)
=> sapJoins.Remove(join);
public void AddSapMapping(List<SapFieldMapping> sapMappings, IReadOnlyList<string> salesRecordFields, List<string> sapAvailableSourceExpressions)
{
sapMappings.Add(new SapFieldMapping
{
TargetField = salesRecordFields.First(),
SourceExpression = sapAvailableSourceExpressions.FirstOrDefault() ?? "=SAP",
IsActive = true,
SortOrder = sapMappings.Count
});
}
public void RemoveSapMapping(List<SapFieldMapping> sapMappings, SapFieldMapping mapping)
=> sapMappings.Remove(mapping);
public List<string> BuildSourceExpressionsFromMappings(List<SapFieldMapping> sapMappings)
=> sapMappings
.Select(m => m.SourceExpression)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
public Dictionary<string, List<string>> BuildSourceFieldMapFromJoins(List<SapJoinDefinition> sapJoins)
{
var result = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var join in sapJoins)
{
AddJoinKeysToFieldMap(result, join.LeftAlias, join.LeftKeys);
AddJoinKeysToFieldMap(result, join.RightAlias, join.RightKeys);
}
return result;
}
public IEnumerable<string> GetSapAliases(List<SapSourceDefinition> sapSources)
=> sapSources.Where(s => !string.IsNullOrWhiteSpace(s.Alias)).Select(s => s.Alias).Distinct(StringComparer.OrdinalIgnoreCase);
public IEnumerable<string> GetAvailableSourceExpressions(List<string> sapAvailableSourceExpressions, string? currentValue)
{
var expressions = new List<string>(sapAvailableSourceExpressions);
if (!string.IsNullOrWhiteSpace(currentValue) && !expressions.Contains(currentValue, StringComparer.OrdinalIgnoreCase))
expressions.Insert(0, currentValue);
return expressions;
}
public IEnumerable<string> GetAvailableJoinFields(Dictionary<string, List<string>> sapSourceFieldMap, string? alias, string? currentKeys)
{
var values = new List<string>();
if (!string.IsNullOrWhiteSpace(alias) && sapSourceFieldMap.TryGetValue(alias, out var fields))
values.AddRange(fields);
foreach (var key in GetSelectedJoinKeys(currentKeys))
{
if (!values.Contains(key, StringComparer.OrdinalIgnoreCase))
values.Add(key);
}
return values
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public void NormalizeSapConfigCollections(List<SapSourceDefinition> sapSources, List<SapJoinDefinition> sapJoins, List<SapFieldMapping> sapMappings)
{
for (var i = 0; i < sapSources.Count; i++)
sapSources[i].SortOrder = i;
for (var i = 0; i < sapJoins.Count; i++)
sapJoins[i].SortOrder = i;
for (var i = 0; i < sapMappings.Count; i++)
sapMappings[i].SortOrder = i;
var selectedPrimaryIndex = sapSources.FindIndex(s => s.IsPrimary);
var primarySource = selectedPrimaryIndex >= 0 ? sapSources[selectedPrimaryIndex] : sapSources.FirstOrDefault();
foreach (var source in sapSources)
source.IsPrimary = primarySource is not null && ReferenceEquals(source, primarySource);
if (sapSources.Count > 0 && sapSources.All(s => !s.IsPrimary))
sapSources[0].IsPrimary = true;
}
private static void AddJoinKeysToFieldMap(Dictionary<string, List<string>> target, string alias, string keys)
{
if (string.IsNullOrWhiteSpace(alias))
return;
if (!target.TryGetValue(alias, out var fields))
{
fields = [];
target[alias] = fields;
}
foreach (var key in GetSelectedJoinKeys(keys))
{
if (!fields.Contains(key, StringComparer.OrdinalIgnoreCase))
fields.Add(key);
}
fields.Sort(StringComparer.OrdinalIgnoreCase);
}
private static HashSet<string> GetSelectedJoinKeys(string? keys)
=> keys?
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToHashSet(StringComparer.OrdinalIgnoreCase)
?? [];
}
public sealed class SapAutoMatchResult
{
public bool Success { get; init; }
public bool Warning { get; init; }
public bool Info { get; init; }
public string Message { get; init; } = string.Empty;
public static SapAutoMatchResult WarningResult(string message) => new() { Warning = true, Message = message };
public static SapAutoMatchResult InfoResult(string message) => new() { Info = true, Message = message };
public static SapAutoMatchResult SuccessResult(string message) => new() { Success = true, Message = message };
}
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface ITransformationsPageService
{
Task<TransformationsPageState> LoadAsync();
Task<List<FieldTransformationRule>> SaveAllAsync(List<FieldTransformationRule> rules);
}
public sealed class TransformationsPageService : ITransformationsPageService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public TransformationsPageService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<TransformationsPageState> LoadAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
var rules = await db.FieldTransformationRules.OrderBy(r => r.SortOrder).ThenBy(r => r.Id).ToListAsync();
foreach (var rule in rules)
rule.RuleScope = string.IsNullOrWhiteSpace(rule.RuleScope) ? "Value" : rule.RuleScope;
return new TransformationsPageState
{
SourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync(),
Rules = rules
};
}
public async Task<List<FieldTransformationRule>> SaveAllAsync(List<FieldTransformationRule> rules)
{
await using var db = await _dbFactory.CreateDbContextAsync();
db.FieldTransformationRules.RemoveRange(db.FieldTransformationRules);
await db.SaveChangesAsync();
db.FieldTransformationRules.AddRange(rules);
await db.SaveChangesAsync();
return await db.FieldTransformationRules.OrderBy(r => r.SortOrder).ThenBy(r => r.Id).ToListAsync();
}
}
public sealed class TransformationsPageState
{
public List<FieldTransformationRule> Rules { get; set; } = [];
public List<SourceSystemDefinition> SourceSystems { get; set; } = [];
}
@@ -1,6 +1,7 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
using TrafagSalesExporter.Services;
namespace TrafagSalesExporter.Tests;
@@ -37,7 +38,7 @@ public class DatabaseInitializationServiceTests : IDisposable
{
await PrepareLegacySitesTableAsync();
var service = new DatabaseInitializationService(_dbFactory);
var service = CreateService();
await service.InitializeAsync();
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -59,7 +60,7 @@ public class DatabaseInitializationServiceTests : IDisposable
{
await PrepareBrokenHanaServerForeignKeyAsync();
var service = new DatabaseInitializationService(_dbFactory);
var service = CreateService();
await service.InitializeAsync();
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -72,6 +73,25 @@ public class DatabaseInitializationServiceTests : IDisposable
Assert.DoesNotContain("HanaServers_repair_old", tableSql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task InitializeAsync_Seeds_Default_SourceSystems_And_Central_HanaServers()
{
var service = CreateService();
await service.InitializeAsync();
await using var db = await _dbFactory.CreateDbContextAsync();
Assert.Contains(db.SourceSystemDefinitions, x => x.Code == "SAP" && x.ConnectionKind == SourceSystemConnectionKinds.SapGateway);
Assert.Contains(db.SourceSystemDefinitions, x => x.Code == "BI1" && x.ConnectionKind == SourceSystemConnectionKinds.Hana);
Assert.Contains(db.SourceSystemDefinitions, x => x.Code == "SAGE" && x.ConnectionKind == SourceSystemConnectionKinds.Hana);
Assert.Contains(db.SourceSystemDefinitions, x => x.Code == "MANUAL_EXCEL" && x.ConnectionKind == SourceSystemConnectionKinds.ManualExcel);
Assert.Contains(db.HanaServers, x => x.SourceSystem == "BI1");
Assert.Contains(db.HanaServers, x => x.SourceSystem == "SAGE");
Assert.Equal(2, db.FieldTransformationRules.Count(x => x.SourceSystem == "MANUAL_EXCEL"));
}
private async Task PrepareLegacySitesTableAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
@@ -179,6 +199,9 @@ VALUES (
return (await command.ExecuteScalarAsync())?.ToString() ?? string.Empty;
}
private DatabaseInitializationService CreateService()
=> new(_dbFactory, new DatabaseSchemaMaintenanceService(), new DatabaseSeedService());
private sealed class TestDbContextFactory : IDbContextFactory<AppDbContext>
{
private readonly DbContextOptions<AppDbContext> _options;