Fix MudBlazor generic/value callback compile errors

This commit is contained in:
2026-04-13 12:19:42 +02:00
parent af40d87213
commit 97e598fe3b
12 changed files with 413 additions and 12 deletions
+8
View File
@@ -0,0 +1,8 @@
# Build artifacts
bin/
obj/
# Visual Studio user/IDE files
.vs/
*.user
*.suo
@@ -5,6 +5,9 @@
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
Standorte
</MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
Transformationen
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
Settings
</MudNavLink>
@@ -11,7 +11,6 @@
<MudText Typo="Typo.h4" Class="mb-4">Standorte</MudText>
@* HANA Server Section *@
<MudText Typo="Typo.h5" Class="mb-2">HANA Server</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
@@ -25,6 +24,7 @@
<MudTh>Host</MudTh>
<MudTh>Port</MudTh>
<MudTh>Username</MudTh>
<MudTh>Verbindungsstatus</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
@@ -32,6 +32,20 @@
<MudTd>@context.Host</MudTd>
<MudTd>@context.Port</MudTd>
<MudTd>@context.Username</MudTd>
<MudTd>
@if (_connectionStatus.TryGetValue(context.Id, out var status))
{
<MudTooltip Text="@BuildStatusTooltip(status)">
<MudChip T="string" Color="@(status.Success ? Color.Success : Color.Error)" Variant="Variant.Filled" Size="Size.Small">
@(status.Success ? "OK" : "Fehler") - @status.Stage
</MudChip>
</MudTooltip>
}
else
{
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">Nicht getestet</MudChip>
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small"
OnClick="() => EditServer(context)" />
@@ -44,7 +58,6 @@
</MudTable>
</MudPaper>
@* Sites Section *@
<MudText Typo="Typo.h5" Class="mb-2">Standorte (Sites)</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
@@ -57,6 +70,7 @@
<MudTh>Land</MudTh>
<MudTh>TSC</MudTh>
<MudTh>Schema</MudTh>
<MudTh>Quellsystem</MudTh>
<MudTh>Server</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>Aktionen</MudTh>
@@ -65,6 +79,7 @@
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>@context.Schema</MudTd>
<MudTd>@context.SourceSystem</MudTd>
<MudTd>@(context.HanaServer?.Name ?? "-")</MudTd>
<MudTd>
@if (context.IsActive)
@@ -86,7 +101,6 @@
</MudTable>
</MudPaper>
@* Server Dialog *@
<MudDialog @bind-Visible="_serverDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingServer.Id == 0 ? "Server hinzufügen" : "Server bearbeiten")</MudText>
@@ -113,7 +127,6 @@
</DialogActions>
</MudDialog>
@* Site Dialog *@
<MudDialog @bind-Visible="_siteDialogVisible" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingSite.Id == 0 ? "Standort hinzufügen" : "Standort bearbeiten")</MudText>
@@ -122,12 +135,18 @@
<MudSelect @bind-Value="_editingSite.HanaServerId" Label="Server" Required>
@foreach (var s in _servers)
{
<MudSelectItem Value="s.Id">@s.Name</MudSelectItem>
<MudSelectItem Value="@s.Id">@s.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_editingSite.Schema" Label="Schema" Required />
<MudTextField @bind-Value="_editingSite.TSC" Label="TSC" Required />
<MudTextField @bind-Value="_editingSite.Land" Label="Land" Required />
<MudSelect @bind-Value="_editingSite.SourceSystem" Label="Quellsystem" Required>
@foreach (var system in _sourceSystems)
{
<MudSelectItem Value="@system">@system</MudSelectItem>
}
</MudSelect>
<MudCheckBox @bind-Value="_editingSite.IsActive" Label="Aktiv" />
</DialogContent>
<DialogActions>
@@ -137,6 +156,8 @@
</MudDialog>
@code {
private readonly string[] _sourceSystems = ["SAP", "BI1", "SAGE"];
private readonly Dictionary<int, ConnectionTestResult> _connectionStatus = new();
private List<HanaServer> _servers = new();
private List<Site> _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);
}
@@ -0,0 +1,137 @@
@page "/transformations"
@using Microsoft.EntityFrameworkCore
@using System.Reflection
@using TrafagSalesExporter.Data
@using TrafagSalesExporter.Models
@inject IDbContextFactory<AppDbContext> DbFactory
@inject ISnackbar Snackbar
<PageTitle>Transformationen</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Transformer Ansicht</MudText>
<MudText Typo="Typo.body1" Class="mb-4">Definiere pro Quellsystem (SAP, BI1, SAGE) Feld-Remapping und Transformationen.</MudText>
<MudPaper Class="pa-4" Elevation="1">
<MudStack Row="true" Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRule">
Regel hinzufügen
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.Save" OnClick="SaveAllAsync">
Alle speichern
</MudButton>
</MudStack>
<MudTable Items="_rules" Dense Hover Striped>
<HeaderContent>
<MudTh>Aktiv</MudTh>
<MudTh>System</MudTh>
<MudTh>Source</MudTh>
<MudTh>Target</MudTh>
<MudTh>Typ</MudTh>
<MudTh>Argument</MudTh>
<MudTh>Sort</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd><MudCheckBox @bind-Value="context.IsActive" /></MudTd>
<MudTd>
<MudSelect T="string" Value="@context.SourceSystem" ValueChanged="@((string v) => { context.SourceSystem = v; })" Dense>
@foreach (var system in _systems)
{
<MudSelectItem Value="@system">@system</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.SourceField" ValueChanged="@((string v) => { context.SourceField = v; })" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TargetField" ValueChanged="@((string v) => { context.TargetField = v; })" Dense>
@foreach (var field in _recordFields)
{
<MudSelectItem Value="@field">@field</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudSelect T="string" Value="@context.TransformationType" ValueChanged="@((string v) => { context.TransformationType = v; })" Dense>
@foreach (var type in _types)
{
<MudSelectItem Value="@type">@type</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd>
<MudTextField Value="@context.Argument" ValueChanged="@((string v) => { context.Argument = v; })" Dense
HelperText="Replace: alt=>neu" />
</MudTd>
<MudTd>
<MudNumericField T="int" Value="@context.SortOrder" ValueChanged="@((int v) => { context.SortOrder = v; })" Dense />
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
OnClick="() => RemoveRule(context)" />
</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
@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<FieldTransformationRule> _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();
}
}
+23
View File
@@ -13,6 +13,7 @@ public class AppDbContext : DbContext
public DbSet<SharePointConfig> SharePointConfigs => Set<SharePointConfig>();
public DbSet<ExportSettings> ExportSettings => Set<ExportSettings>();
public DbSet<ExportLog> ExportLogs => Set<ExportLog>();
public DbSet<FieldTransformationRule> FieldTransformationRules => Set<FieldTransformationRule>();
/// <summary>
/// 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;
@@ -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;
}
+19
View File
@@ -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();
}
}
+3
View File
@@ -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;
}
+1
View File
@@ -16,6 +16,7 @@ builder.Services.AddDbContextFactory<AppDbContext>(options =>
builder.Services.AddSingleton<HanaQueryService>();
builder.Services.AddSingleton<ExcelExportService>();
builder.Services.AddSingleton<SharePointUploadService>();
builder.Services.AddSingleton<RecordTransformationService>();
builder.Services.AddSingleton<ExportOrchestrationService>();
builder.Services.AddSingleton<TimerBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TimerBackgroundService>());
@@ -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<ExportOrchestrationService> _logger;
public event Action? OnExportStatusChanged;
@@ -23,12 +24,14 @@ public class ExportOrchestrationService
HanaQueryService hanaService,
ExcelExportService excelService,
SharePointUploadService sharePointService,
RecordTransformationService transformationService,
ILogger<ExportOrchestrationService> 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);
@@ -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;
}
@@ -0,0 +1,92 @@
using System.Reflection;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class RecordTransformationService
{
private static readonly Dictionary<string, PropertyInfo> PropertyMap = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);
public void Apply(List<SalesRecord> records, IEnumerable<FieldTransformationRule> 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
}
}
}