diff --git a/TrafagSalesExporter/.gitignore b/TrafagSalesExporter/.gitignore
new file mode 100644
index 0000000..4d6b02d
--- /dev/null
+++ b/TrafagSalesExporter/.gitignore
@@ -0,0 +1,8 @@
+# Build artifacts
+bin/
+obj/
+
+# Visual Studio user/IDE files
+.vs/
+*.user
+*.suo
diff --git a/TrafagSalesExporter/Components/Layout/NavMenu.razor b/TrafagSalesExporter/Components/Layout/NavMenu.razor
index 27b1476..ecc7ae1 100644
--- a/TrafagSalesExporter/Components/Layout/NavMenu.razor
+++ b/TrafagSalesExporter/Components/Layout/NavMenu.razor
@@ -5,6 +5,9 @@
Standorte
+
+ Transformationen
+
Settings
diff --git a/TrafagSalesExporter/Components/Pages/Standorte.razor b/TrafagSalesExporter/Components/Pages/Standorte.razor
index 2495b8e..d9f1486 100644
--- a/TrafagSalesExporter/Components/Pages/Standorte.razor
+++ b/TrafagSalesExporter/Components/Pages/Standorte.razor
@@ -11,7 +11,6 @@
Standorte
-@* HANA Server Section *@
HANA Server
Host
Port
Username
+ Verbindungsstatus
Aktionen
@@ -32,6 +32,20 @@
@context.Host
@context.Port
@context.Username
+
+ @if (_connectionStatus.TryGetValue(context.Id, out var status))
+ {
+
+
+ @(status.Success ? "OK" : "Fehler") - @status.Stage
+
+
+ }
+ else
+ {
+ Nicht getestet
+ }
+
@@ -44,7 +58,6 @@
-@* Sites Section *@
Standorte (Sites)
Land
TSC
Schema
+ Quellsystem
Server
Aktiv
Aktionen
@@ -65,6 +79,7 @@
@context.Land
@context.TSC
@context.Schema
+ @context.SourceSystem
@(context.HanaServer?.Name ?? "-")
@if (context.IsActive)
@@ -86,7 +101,6 @@
-@* Server Dialog *@
@(_editingServer.Id == 0 ? "Server hinzufügen" : "Server bearbeiten")
@@ -113,7 +127,6 @@
-@* Site Dialog *@
@(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten")
@@ -128,6 +141,12 @@
+
+ @foreach (var system in _sourceSystems)
+ {
+ @system
+ }
+
@@ -137,6 +156,8 @@
@code {
+ private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE"];
+ private readonly Dictionary _connectionStatus = new();
private List _servers = new();
private List _sites = new();
private HanaServer _editingServer = new();
@@ -157,7 +178,6 @@
_sites = await db.Sites.Include(s => s.HanaServer).OrderBy(s => s.Land).ToListAsync();
}
- // Server CRUD
private void AddServer()
{
_editingServer = new HanaServer { Port = 30015 };
@@ -205,6 +225,7 @@
existing.AdditionalParams = _editingServer.AdditionalParams;
}
}
+
await db.SaveChangesAsync();
_serverDialogVisible = false;
await LoadDataAsync();
@@ -227,29 +248,41 @@
db.HanaServers.Remove(entity);
await db.SaveChangesAsync();
}
+
await LoadDataAsync();
Snackbar.Add("Server gelöscht", Severity.Info);
}
private async Task TestServerConnection(HanaServer server)
{
- try
+ var result = await Task.Run(() => HanaService.TestConnectionDetailed(server));
+ _connectionStatus[server.Id] = result;
+
+ if (result.Success)
{
- await Task.Run(() => HanaService.TestConnection(server));
- Snackbar.Add($"Verbindung zu '{server.Name}' erfolgreich!", Severity.Success);
+ Snackbar.Add($"Verbindung zu '{server.Name}' erfolgreich.", Severity.Success);
}
- catch (Exception ex)
+ else
{
- Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error);
+ Snackbar.Add($"{server.Name}: {result.ExceptionType} - {result.ErrorMessage}", Severity.Error);
}
}
- // Site CRUD
+ private static string BuildStatusTooltip(ConnectionTestResult status)
+ {
+ var stamp = status.TestedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
+ if (status.Success)
+ return $"Letzter Test: {stamp}\nStage: {status.Stage}\n{status.ConnectionStringPreview}";
+
+ return $"Letzter Test: {stamp}\nStage: {status.Stage}\nFehler: {status.ErrorMessage}\n{status.ConnectionStringPreview}";
+ }
+
private void AddSite()
{
_editingSite = new Site
{
IsActive = true,
+ SourceSystem = "SAP",
HanaServerId = _servers.FirstOrDefault()?.Id ?? 0
};
_siteDialogVisible = true;
@@ -264,6 +297,7 @@
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
+ SourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem,
IsActive = site.IsActive
};
_siteDialogVisible = true;
@@ -285,9 +319,11 @@
existing.Schema = _editingSite.Schema;
existing.TSC = _editingSite.TSC;
existing.Land = _editingSite.Land;
+ existing.SourceSystem = _editingSite.SourceSystem;
existing.IsActive = _editingSite.IsActive;
}
}
+
await db.SaveChangesAsync();
_siteDialogVisible = false;
await LoadDataAsync();
@@ -310,6 +346,7 @@
db.Sites.Remove(entity);
await db.SaveChangesAsync();
}
+
await LoadDataAsync();
Snackbar.Add("Standort gelöscht", Severity.Info);
}
diff --git a/TrafagSalesExporter/Components/Pages/Transformations.razor b/TrafagSalesExporter/Components/Pages/Transformations.razor
new file mode 100644
index 0000000..96086a3
--- /dev/null
+++ b/TrafagSalesExporter/Components/Pages/Transformations.razor
@@ -0,0 +1,137 @@
+@page "/transformations"
+@using Microsoft.EntityFrameworkCore
+@using System.Reflection
+@using TrafagSalesExporter.Data
+@using TrafagSalesExporter.Models
+@inject IDbContextFactory DbFactory
+@inject ISnackbar Snackbar
+
+Transformationen
+
+Transformer Ansicht
+Definiere pro Quellsystem (SAP, BI1, SAGE) Feld-Remapping und Transformationen.
+
+
+
+
+ Regel hinzufügen
+
+
+ Alle speichern
+
+
+
+
+
+ Aktiv
+ System
+ Source
+ Target
+ Typ
+ Argument
+ Sort
+ Aktionen
+
+
+
+
+
+ @foreach (var system in _systems)
+ {
+ @system
+ }
+
+
+
+
+ @foreach (var field in _recordFields)
+ {
+ @field
+ }
+
+
+
+
+ @foreach (var field in _recordFields)
+ {
+ @field
+ }
+
+
+
+
+ @foreach (var type in _types)
+ {
+ @type
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ private readonly string[] _systems = ["SAP", "BI1", "SAGE"];
+ private readonly string[] _types = ["Copy", "Uppercase", "Lowercase", "Prefix", "Suffix", "Replace", "Constant"];
+ private readonly string[] _recordFields = typeof(SalesRecord)
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Select(p => p.Name)
+ .OrderBy(n => n)
+ .ToArray();
+
+ private List _rules = new();
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadAsync();
+ }
+
+ private async Task LoadAsync()
+ {
+ using var db = await DbFactory.CreateDbContextAsync();
+ _rules = await db.FieldTransformationRules.OrderBy(r => r.SortOrder).ThenBy(r => r.Id).ToListAsync();
+ }
+
+ private void AddRule()
+ {
+ var nextSort = _rules.Count == 0 ? 10 : _rules.Max(r => r.SortOrder) + 10;
+ _rules.Add(new FieldTransformationRule
+ {
+ SourceSystem = "SAP",
+ SourceField = nameof(SalesRecord.Material),
+ TargetField = nameof(SalesRecord.Material),
+ TransformationType = "Copy",
+ SortOrder = nextSort,
+ IsActive = true
+ });
+ }
+
+ private void RemoveRule(FieldTransformationRule rule)
+ {
+ _rules.Remove(rule);
+ }
+
+ 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();
+
+ Snackbar.Add("Transformationsregeln gespeichert.", Severity.Success);
+ await LoadAsync();
+ }
+}
diff --git a/TrafagSalesExporter/Data/AppDbContext.cs b/TrafagSalesExporter/Data/AppDbContext.cs
index 6f2decd..f40ed85 100644
--- a/TrafagSalesExporter/Data/AppDbContext.cs
+++ b/TrafagSalesExporter/Data/AppDbContext.cs
@@ -13,6 +13,7 @@ public class AppDbContext : DbContext
public DbSet SharePointConfigs => Set();
public DbSet ExportSettings => Set();
public DbSet ExportLogs => Set();
+ public DbSet FieldTransformationRules => Set();
///
/// Fügt Spalten zu existierenden Tabellen hinzu, die bei neueren Versionen
@@ -24,6 +25,8 @@ public class AppDbContext : DbContext
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'");
+ EnsureTransformationTable(db);
}
private static void AddColumnIfMissing(AppDbContext db, string table, string column, string type)
@@ -54,6 +57,26 @@ public class AppDbContext : DbContext
}
}
+ 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,
+ Argument TEXT NOT NULL DEFAULT '',
+ SortOrder INTEGER NOT NULL DEFAULT 0,
+ IsActive INTEGER NOT NULL DEFAULT 1
+);";
+ cmd.ExecuteNonQuery();
+ }
+
public static void SeedIfEmpty(AppDbContext db)
{
if (db.HanaServers.Any()) return;
diff --git a/TrafagSalesExporter/Models/FieldTransformationRule.cs b/TrafagSalesExporter/Models/FieldTransformationRule.cs
new file mode 100644
index 0000000..8a099ce
--- /dev/null
+++ b/TrafagSalesExporter/Models/FieldTransformationRule.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace TrafagSalesExporter.Models;
+
+public class FieldTransformationRule
+{
+ public int Id { get; set; }
+
+ [Required]
+ public string SourceSystem { get; set; } = "SAP";
+
+ [Required]
+ public string SourceField { get; set; } = nameof(SalesRecord.Material);
+
+ [Required]
+ public string TargetField { get; set; } = nameof(SalesRecord.Material);
+
+ [Required]
+ public string TransformationType { get; set; } = "Copy";
+
+ public string Argument { get; set; } = string.Empty;
+
+ public int SortOrder { get; set; }
+
+ public bool IsActive { get; set; } = true;
+}
diff --git a/TrafagSalesExporter/Models/HanaServer.cs b/TrafagSalesExporter/Models/HanaServer.cs
index a77b146..36fd579 100644
--- a/TrafagSalesExporter/Models/HanaServer.cs
+++ b/TrafagSalesExporter/Models/HanaServer.cs
@@ -62,4 +62,23 @@ public class HanaServer
return string.Join(";", parts);
}
+
+ public string GetConnectionStringPreview()
+ {
+ var pwdMasked = string.IsNullOrEmpty(Password) ? "" : "***";
+ var copy = new HanaServer
+ {
+ Host = Host,
+ Port = Port,
+ Username = Username,
+ Password = pwdMasked,
+ DatabaseName = DatabaseName,
+ UseSsl = UseSsl,
+ ValidateCertificate = ValidateCertificate,
+ AdditionalParams = AdditionalParams
+ };
+
+ return copy.BuildConnectionString();
+ }
}
+
diff --git a/TrafagSalesExporter/Models/Site.cs b/TrafagSalesExporter/Models/Site.cs
index 42df2d3..cb97303 100644
--- a/TrafagSalesExporter/Models/Site.cs
+++ b/TrafagSalesExporter/Models/Site.cs
@@ -21,5 +21,8 @@ public class Site
[Required]
public string Land { get; set; } = string.Empty;
+ [Required]
+ public string SourceSystem { get; set; } = "SAP";
+
public bool IsActive { get; set; } = true;
}
diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs
index 609affa..879d8e2 100644
--- a/TrafagSalesExporter/Program.cs
+++ b/TrafagSalesExporter/Program.cs
@@ -16,6 +16,7 @@ builder.Services.AddDbContextFactory(options =>
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService(sp => sp.GetRequiredService());
diff --git a/TrafagSalesExporter/Services/ExportOrchestrationService.cs b/TrafagSalesExporter/Services/ExportOrchestrationService.cs
index ae13bd1..bd3d650 100644
--- a/TrafagSalesExporter/Services/ExportOrchestrationService.cs
+++ b/TrafagSalesExporter/Services/ExportOrchestrationService.cs
@@ -11,6 +11,7 @@ public class ExportOrchestrationService
private readonly HanaQueryService _hanaService;
private readonly ExcelExportService _excelService;
private readonly SharePointUploadService _sharePointService;
+ private readonly RecordTransformationService _transformationService;
private readonly ILogger _logger;
public event Action? OnExportStatusChanged;
@@ -23,12 +24,14 @@ public class ExportOrchestrationService
HanaQueryService hanaService,
ExcelExportService excelService,
SharePointUploadService sharePointService,
+ RecordTransformationService transformationService,
ILogger logger)
{
_dbFactory = dbFactory;
_hanaService = hanaService;
_excelService = excelService;
_sharePointService = sharePointService;
+ _transformationService = transformationService;
_logger = logger;
}
@@ -96,6 +99,13 @@ public class ExportOrchestrationService
var records = await Task.Run(() => _hanaService.GetSalesRecords(
site.HanaServer, site.Schema, site.TSC, site.Land, settings.DateFilter));
+ UpdateStatus(site.Id, "Transformationen anwenden...");
+ var rules = await db.FieldTransformationRules
+ .Where(r => r.IsActive && r.SourceSystem == (string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem))
+ .OrderBy(r => r.SortOrder)
+ .ToListAsync();
+ _transformationService.Apply(records, rules);
+
UpdateStatus(site.Id, "Excel erstellen...");
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
var filePath = _excelService.CreateExcelFile(outputDir, site.TSC, DateTime.UtcNow.Date, records);
diff --git a/TrafagSalesExporter/Services/HanaQueryService.cs b/TrafagSalesExporter/Services/HanaQueryService.cs
index 4c2a9e1..3038fe8 100644
--- a/TrafagSalesExporter/Services/HanaQueryService.cs
+++ b/TrafagSalesExporter/Services/HanaQueryService.cs
@@ -32,6 +32,38 @@ public class HanaQueryService
return result;
}
+ public ConnectionTestResult TestConnectionDetailed(HanaServer server)
+ {
+ var testResult = new ConnectionTestResult
+ {
+ TestedAtUtc = DateTime.UtcNow,
+ ConnectionStringPreview = server.GetConnectionStringPreview(),
+ Stage = "Verbindungsaufbau"
+ };
+
+ try
+ {
+ var connectionString = server.BuildConnectionString();
+ using var connection = new HanaConnection(connectionString);
+ connection.Open();
+
+ testResult.Stage = "Ping-Query";
+ using var command = new HanaCommand("SELECT 1 FROM DUMMY", connection);
+ command.ExecuteScalar();
+
+ testResult.Success = true;
+ testResult.Stage = "OK";
+ return testResult;
+ }
+ catch (Exception ex)
+ {
+ testResult.Success = false;
+ testResult.ErrorMessage = ex.Message;
+ testResult.ExceptionType = ex.GetType().Name;
+ return testResult;
+ }
+ }
+
public void TestConnection(HanaServer server)
{
var connectionString = server.BuildConnectionString();
@@ -173,3 +205,13 @@ LEFT JOIN {schema}.""OSLP"" emp ON h.""SlpCode"" = emp.""SlpCode""
WHERE h.""CANCELED"" = 'N' AND h.""DocDate"" >= '{dateFilter}'
ORDER BY h.""DocDate"" DESC, h.""DocNum"", p.""LineNum""";
}
+
+public class ConnectionTestResult
+{
+ public bool Success { get; set; }
+ public DateTime TestedAtUtc { get; set; }
+ public string Stage { get; set; } = string.Empty;
+ public string ErrorMessage { get; set; } = string.Empty;
+ public string ExceptionType { get; set; } = string.Empty;
+ public string ConnectionStringPreview { get; set; } = string.Empty;
+}
diff --git a/TrafagSalesExporter/Services/RecordTransformationService.cs b/TrafagSalesExporter/Services/RecordTransformationService.cs
new file mode 100644
index 0000000..71ed4d1
--- /dev/null
+++ b/TrafagSalesExporter/Services/RecordTransformationService.cs
@@ -0,0 +1,92 @@
+using System.Reflection;
+using TrafagSalesExporter.Models;
+
+namespace TrafagSalesExporter.Services;
+
+public class RecordTransformationService
+{
+ private static readonly Dictionary PropertyMap = typeof(SalesRecord)
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);
+
+ public void Apply(List records, IEnumerable rules)
+ {
+ var orderedRules = rules.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToList();
+ if (orderedRules.Count == 0 || records.Count == 0) return;
+
+ foreach (var record in records)
+ {
+ foreach (var rule in orderedRules)
+ {
+ ApplyRule(record, rule);
+ }
+ }
+ }
+
+ private static void ApplyRule(SalesRecord record, FieldTransformationRule rule)
+ {
+ if (!PropertyMap.TryGetValue(rule.SourceField, out var sourceProp)) return;
+ if (!PropertyMap.TryGetValue(rule.TargetField, out var targetProp)) return;
+
+ var sourceValue = sourceProp.GetValue(record);
+ object? result = rule.TransformationType switch
+ {
+ "Copy" => sourceValue,
+ "Uppercase" => sourceValue?.ToString()?.ToUpperInvariant(),
+ "Lowercase" => sourceValue?.ToString()?.ToLowerInvariant(),
+ "Prefix" => $"{rule.Argument}{sourceValue}",
+ "Suffix" => $"{sourceValue}{rule.Argument}",
+ "Replace" => ApplyReplace(sourceValue?.ToString(), rule.Argument),
+ "Constant" => rule.Argument,
+ _ => sourceValue
+ };
+
+ SetPropertyValue(record, targetProp, result);
+ }
+
+ private static string ApplyReplace(string? input, string? argument)
+ {
+ if (string.IsNullOrEmpty(input)) return string.Empty;
+ if (string.IsNullOrWhiteSpace(argument)) return input;
+
+ var parts = argument.Split("=>", 2, StringSplitOptions.TrimEntries);
+ if (parts.Length != 2) return input;
+ return input.Replace(parts[0], parts[1], StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static void SetPropertyValue(SalesRecord record, PropertyInfo property, object? value)
+ {
+ try
+ {
+ if (property.PropertyType == typeof(string))
+ {
+ property.SetValue(record, value?.ToString() ?? string.Empty);
+ return;
+ }
+
+ if (property.PropertyType == typeof(int))
+ {
+ if (int.TryParse(value?.ToString(), out var parsedInt)) property.SetValue(record, parsedInt);
+ return;
+ }
+
+ if (property.PropertyType == typeof(decimal))
+ {
+ if (decimal.TryParse(value?.ToString(), out var parsedDecimal)) property.SetValue(record, parsedDecimal);
+ return;
+ }
+
+ if (property.PropertyType == typeof(DateTime?) || property.PropertyType == typeof(DateTime))
+ {
+ if (DateTime.TryParse(value?.ToString(), out var parsedDate)) property.SetValue(record, parsedDate);
+ return;
+ }
+
+ property.SetValue(record, value);
+ }
+ catch
+ {
+ // skip invalid conversion to keep export running
+ }
+ }
+}