From a25e5900c7adc54a9e81e7faf69f1f11c707480d Mon Sep 17 00:00:00 2001 From: metacube Date: Thu, 16 Apr 2026 08:47:13 +0200 Subject: [PATCH] Regelsteuerung grafisch und per C# Templates --- .../Components/Pages/Transformations.razor | 80 ++++++++++++++++--- .../Models/FieldTransformationRule.cs | 3 + TrafagSalesExporter/Program.cs | 2 + .../Services/ConfigTransferService.cs | 2 + .../Services/DatabaseInitializationService.cs | 2 + .../Services/IRecordTransformationStrategy.cs | 10 +++ .../Services/ITransformationCatalog.cs | 14 ++++ .../Services/ITransformationStrategy.cs | 1 + .../Services/RecordTransformationService.cs | 15 +++- .../Services/TransformationCatalog.cs | 33 ++++++++ .../Services/TransformationStrategies.cs | 57 +++++++++++++ 11 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 TrafagSalesExporter/Services/IRecordTransformationStrategy.cs create mode 100644 TrafagSalesExporter/Services/ITransformationCatalog.cs create mode 100644 TrafagSalesExporter/Services/TransformationCatalog.cs diff --git a/TrafagSalesExporter/Components/Pages/Transformations.razor b/TrafagSalesExporter/Components/Pages/Transformations.razor index 5a05580..4a7ec66 100644 --- a/TrafagSalesExporter/Components/Pages/Transformations.razor +++ b/TrafagSalesExporter/Components/Pages/Transformations.razor @@ -3,18 +3,24 @@ @using System.Reflection @using TrafagSalesExporter.Data @using TrafagSalesExporter.Models +@using TrafagSalesExporter.Services @inject IDbContextFactory DbFactory +@inject ITransformationCatalog TransformationCatalog @inject ISnackbar Snackbar Transformationen Transformer Ansicht -Definiere pro Quellsystem (SAP, BI1, SAGE) Feld-Remapping und Transformationen. +Definiere pro Quellsystem einfache Feldregeln und komplexe record-basierte Strategien. + + `Value`-Regeln arbeiten feldweise. `Record`-Regeln rufen eine registrierte C#-Strategie auf und koennen mehrere Felder eines Datensatzes verwenden. + + - Regel hinzufügen + Regel hinzufuegen Alle speichern @@ -25,6 +31,7 @@ Aktiv System + Scope Source Target Typ @@ -38,15 +45,23 @@ @foreach (var system in _systems) { - @system + @system } - + + @foreach (var scope in _ruleScopes) + { + @scope + } + + + + @foreach (var field in _recordFields) { - @field + @field } @@ -54,21 +69,21 @@ @foreach (var field in _recordFields) { - @field + @field } - @foreach (var type in _types) + @foreach (var type in GetTypesForScope(context.RuleScope)) { - @type + @type.Key } + HelperText="@GetArgumentHelperText(context)" /> @@ -82,8 +97,8 @@ @code { - private readonly string[] _systems = ["SAP", "BI1", "SAGE"]; - private readonly string[] _types = ["Copy", "Uppercase", "Lowercase", "Prefix", "Suffix", "Replace", "Constant"]; + private readonly string[] _systems = ["SAP", "BI1", "SAGE", "MANUAL_EXCEL"]; + private readonly string[] _ruleScopes = ["Value", "Record"]; private readonly string[] _recordFields = typeof(SalesRecord) .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Select(p => p.Name) @@ -91,9 +106,11 @@ .ToArray(); private List _rules = new(); + private IReadOnlyList _catalogItems = []; protected override async Task OnInitializedAsync() { + _catalogItems = TransformationCatalog.GetAll(); await LoadAsync(); } @@ -101,6 +118,15 @@ { using var db = await DbFactory.CreateDbContextAsync(); _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; + if (!GetTypesForScope(rule.RuleScope).Any(x => string.Equals(x.Key, rule.TransformationType, StringComparison.OrdinalIgnoreCase))) + { + rule.TransformationType = GetTypesForScope(rule.RuleScope).FirstOrDefault()?.Key ?? "Copy"; + } + } } private void AddRule() @@ -109,6 +135,7 @@ _rules.Add(new FieldTransformationRule { SourceSystem = "SAP", + RuleScope = "Value", SourceField = nameof(SalesRecord.Material), TargetField = nameof(SalesRecord.Material), TransformationType = "Copy", @@ -134,4 +161,35 @@ Snackbar.Add("Transformationsregeln gespeichert.", Severity.Success); await LoadAsync(); } + + private IReadOnlyList GetTypesForScope(string? ruleScope) + { + var scope = string.IsNullOrWhiteSpace(ruleScope) ? "Value" : ruleScope; + return TransformationCatalog.GetByScope(scope); + } + + private static bool IsRecordScope(FieldTransformationRule rule) + => string.Equals(rule.RuleScope, "Record", StringComparison.OrdinalIgnoreCase); + + private void ChangeRuleScope(FieldTransformationRule rule, string scope) + { + rule.RuleScope = scope; + var firstType = GetTypesForScope(scope).FirstOrDefault()?.Key; + if (!string.IsNullOrWhiteSpace(firstType)) + rule.TransformationType = firstType; + + if (IsRecordScope(rule)) + rule.SourceField = string.Empty; + else if (string.IsNullOrWhiteSpace(rule.SourceField)) + rule.SourceField = nameof(SalesRecord.Material); + } + + private string GetArgumentHelperText(FieldTransformationRule rule) + { + var item = _catalogItems.FirstOrDefault(x => + string.Equals(x.RuleScope, rule.RuleScope, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Key, rule.TransformationType, StringComparison.OrdinalIgnoreCase)); + + return item?.Description ?? "Optionales Argument."; + } } diff --git a/TrafagSalesExporter/Models/FieldTransformationRule.cs b/TrafagSalesExporter/Models/FieldTransformationRule.cs index 8a099ce..c50b653 100644 --- a/TrafagSalesExporter/Models/FieldTransformationRule.cs +++ b/TrafagSalesExporter/Models/FieldTransformationRule.cs @@ -18,6 +18,9 @@ public class FieldTransformationRule [Required] public string TransformationType { get; set; } = "Copy"; + [Required] + public string RuleScope { get; set; } = "Value"; + public string Argument { get; set; } = string.Empty; public int SortOrder { get; set; } diff --git a/TrafagSalesExporter/Program.cs b/TrafagSalesExporter/Program.cs index bebbb07..6edf0a6 100644 --- a/TrafagSalesExporter/Program.cs +++ b/TrafagSalesExporter/Program.cs @@ -25,6 +25,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/TrafagSalesExporter/Services/ConfigTransferService.cs b/TrafagSalesExporter/Services/ConfigTransferService.cs index 5bc6f11..736c915 100644 --- a/TrafagSalesExporter/Services/ConfigTransferService.cs +++ b/TrafagSalesExporter/Services/ConfigTransferService.cs @@ -95,6 +95,7 @@ public class ConfigTransferService : IConfigTransferService SourceField = r.SourceField, TargetField = r.TargetField, TransformationType = r.TransformationType, + RuleScope = r.RuleScope, Argument = r.Argument, SortOrder = r.SortOrder, IsActive = r.IsActive @@ -265,6 +266,7 @@ public class ConfigTransferService : IConfigTransferService SourceField = r.SourceField, TargetField = r.TargetField, TransformationType = r.TransformationType, + RuleScope = r.RuleScope, Argument = r.Argument, SortOrder = r.SortOrder, IsActive = r.IsActive diff --git a/TrafagSalesExporter/Services/DatabaseInitializationService.cs b/TrafagSalesExporter/Services/DatabaseInitializationService.cs index a00e415..cff974f 100644 --- a/TrafagSalesExporter/Services/DatabaseInitializationService.cs +++ b/TrafagSalesExporter/Services/DatabaseInitializationService.cs @@ -71,6 +71,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService AddColumnIfMissing(db, "ExportSettings", "LocalConsolidatedExportFolder", "TEXT NOT NULL DEFAULT ''"); AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''"); EnsureTransformationTable(db); + AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'"); EnsureSapSourceTable(db); EnsureSapJoinTable(db); EnsureSapFieldMappingTable(db); @@ -440,6 +441,7 @@ CREATE TABLE IF NOT EXISTS FieldTransformationRules ( 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 diff --git a/TrafagSalesExporter/Services/IRecordTransformationStrategy.cs b/TrafagSalesExporter/Services/IRecordTransformationStrategy.cs new file mode 100644 index 0000000..58ad676 --- /dev/null +++ b/TrafagSalesExporter/Services/IRecordTransformationStrategy.cs @@ -0,0 +1,10 @@ +using TrafagSalesExporter.Models; + +namespace TrafagSalesExporter.Services; + +public interface IRecordTransformationStrategy +{ + string TransformationType { get; } + string Description => string.Empty; + void Transform(SalesRecord record, FieldTransformationRule rule); +} diff --git a/TrafagSalesExporter/Services/ITransformationCatalog.cs b/TrafagSalesExporter/Services/ITransformationCatalog.cs new file mode 100644 index 0000000..382da11 --- /dev/null +++ b/TrafagSalesExporter/Services/ITransformationCatalog.cs @@ -0,0 +1,14 @@ +namespace TrafagSalesExporter.Services; + +public interface ITransformationCatalog +{ + IReadOnlyList GetAll(); + IReadOnlyList GetByScope(string ruleScope); +} + +public sealed class TransformationCatalogItem +{ + public string Key { get; init; } = string.Empty; + public string RuleScope { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; +} diff --git a/TrafagSalesExporter/Services/ITransformationStrategy.cs b/TrafagSalesExporter/Services/ITransformationStrategy.cs index e40c94f..fc3b0aa 100644 --- a/TrafagSalesExporter/Services/ITransformationStrategy.cs +++ b/TrafagSalesExporter/Services/ITransformationStrategy.cs @@ -3,5 +3,6 @@ namespace TrafagSalesExporter.Services; public interface ITransformationStrategy { string TransformationType { get; } + string Description => string.Empty; object? Transform(object? sourceValue, string? argument); } diff --git a/TrafagSalesExporter/Services/RecordTransformationService.cs b/TrafagSalesExporter/Services/RecordTransformationService.cs index 4957374..79f6b58 100644 --- a/TrafagSalesExporter/Services/RecordTransformationService.cs +++ b/TrafagSalesExporter/Services/RecordTransformationService.cs @@ -5,15 +5,17 @@ namespace TrafagSalesExporter.Services; public class RecordTransformationService : IRecordTransformationService { - private static readonly Dictionary PropertyMap = typeof(SalesRecord) + internal static readonly Dictionary PropertyMap = typeof(SalesRecord) .GetProperties(BindingFlags.Public | BindingFlags.Instance) .ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase); private readonly IReadOnlyDictionary _strategies; + private readonly IReadOnlyDictionary _recordStrategies; - public RecordTransformationService(IEnumerable strategies) + public RecordTransformationService(IEnumerable strategies, IEnumerable recordStrategies) { _strategies = strategies.ToDictionary(s => s.TransformationType, StringComparer.OrdinalIgnoreCase); + _recordStrategies = recordStrategies.ToDictionary(s => s.TransformationType, StringComparer.OrdinalIgnoreCase); } public void Apply(List records, IEnumerable rules) @@ -32,6 +34,13 @@ public class RecordTransformationService : IRecordTransformationService private void ApplyRule(SalesRecord record, FieldTransformationRule rule) { + if (string.Equals(rule.RuleScope, "Record", StringComparison.OrdinalIgnoreCase)) + { + if (_recordStrategies.TryGetValue(rule.TransformationType, out var recordStrategy)) + recordStrategy.Transform(record, rule); + return; + } + if (!PropertyMap.TryGetValue(rule.SourceField, out var sourceProp)) return; if (!PropertyMap.TryGetValue(rule.TargetField, out var targetProp)) return; @@ -43,7 +52,7 @@ public class RecordTransformationService : IRecordTransformationService SetPropertyValue(record, targetProp, result); } - private static void SetPropertyValue(SalesRecord record, PropertyInfo property, object? value) + internal static void SetPropertyValue(SalesRecord record, PropertyInfo property, object? value) { try { diff --git a/TrafagSalesExporter/Services/TransformationCatalog.cs b/TrafagSalesExporter/Services/TransformationCatalog.cs new file mode 100644 index 0000000..f157a49 --- /dev/null +++ b/TrafagSalesExporter/Services/TransformationCatalog.cs @@ -0,0 +1,33 @@ +namespace TrafagSalesExporter.Services; + +public class TransformationCatalog : ITransformationCatalog +{ + private readonly IReadOnlyList _items; + + public TransformationCatalog(IEnumerable valueStrategies, IEnumerable recordStrategies) + { + _items = valueStrategies + .Select(x => new TransformationCatalogItem + { + Key = x.TransformationType, + RuleScope = "Value", + Description = x.Description + }) + .Concat(recordStrategies.Select(x => new TransformationCatalogItem + { + Key = x.TransformationType, + RuleScope = "Record", + Description = x.Description + })) + .OrderBy(x => x.RuleScope, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public IReadOnlyList GetAll() => _items; + + public IReadOnlyList GetByScope(string ruleScope) + => _items + .Where(x => string.Equals(x.RuleScope, ruleScope, StringComparison.OrdinalIgnoreCase)) + .ToList(); +} diff --git a/TrafagSalesExporter/Services/TransformationStrategies.cs b/TrafagSalesExporter/Services/TransformationStrategies.cs index eaa561b..f60e439 100644 --- a/TrafagSalesExporter/Services/TransformationStrategies.cs +++ b/TrafagSalesExporter/Services/TransformationStrategies.cs @@ -1,38 +1,46 @@ +using TrafagSalesExporter.Models; + namespace TrafagSalesExporter.Services; public sealed class CopyTransformationStrategy : ITransformationStrategy { public string TransformationType => "Copy"; + public string Description => "Kopiert Source nach Target."; public object? Transform(object? sourceValue, string? argument) => sourceValue; } public sealed class UppercaseTransformationStrategy : ITransformationStrategy { public string TransformationType => "Uppercase"; + public string Description => "Wandelt Text in Grossbuchstaben."; public object? Transform(object? sourceValue, string? argument) => sourceValue?.ToString()?.ToUpperInvariant(); } public sealed class LowercaseTransformationStrategy : ITransformationStrategy { public string TransformationType => "Lowercase"; + public string Description => "Wandelt Text in Kleinbuchstaben."; public object? Transform(object? sourceValue, string? argument) => sourceValue?.ToString()?.ToLowerInvariant(); } public sealed class PrefixTransformationStrategy : ITransformationStrategy { public string TransformationType => "Prefix"; + public string Description => "Stellt Argument vor den Source-Wert."; public object? Transform(object? sourceValue, string? argument) => $"{argument}{sourceValue}"; } public sealed class SuffixTransformationStrategy : ITransformationStrategy { public string TransformationType => "Suffix"; + public string Description => "Haengt Argument an den Source-Wert."; public object? Transform(object? sourceValue, string? argument) => $"{sourceValue}{argument}"; } public sealed class ReplaceTransformationStrategy : ITransformationStrategy { public string TransformationType => "Replace"; + public string Description => "Ersetzt in Text mit Syntax alt=>neu."; public object? Transform(object? sourceValue, string? argument) { @@ -54,5 +62,54 @@ public sealed class ReplaceTransformationStrategy : ITransformationStrategy public sealed class ConstantTransformationStrategy : ITransformationStrategy { public string TransformationType => "Constant"; + public string Description => "Setzt das Target auf einen konstanten Wert aus Argument."; public object? Transform(object? sourceValue, string? argument) => argument; } + +public sealed class FirstNonEmptyRecordTransformationStrategy : IRecordTransformationStrategy +{ + public string TransformationType => "FirstNonEmpty"; + public string Description => "Record-Strategie: setzt Target aus dem ersten nicht-leeren Feld aus Argument, z.B. CustomerName|SupplierName|Name."; + + public void Transform(SalesRecord record, FieldTransformationRule rule) + { + if (string.IsNullOrWhiteSpace(rule.TargetField) || string.IsNullOrWhiteSpace(rule.Argument)) + return; + + var propertyMap = RecordTransformationService.PropertyMap; + if (!propertyMap.TryGetValue(rule.TargetField, out var targetProperty)) + return; + + var sourceFields = rule.Argument + .Split(['|', ',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var sourceField in sourceFields) + { + if (!propertyMap.TryGetValue(sourceField, out var sourceProperty)) + continue; + + var value = sourceProperty.GetValue(record); + if (IsMeaningfulValue(value)) + { + RecordTransformationService.SetPropertyValue(record, targetProperty, value); + return; + } + } + } + + private static bool IsMeaningfulValue(object? value) + { + if (value is null) + return false; + if (value is string text) + return !string.IsNullOrWhiteSpace(text); + if (value is DateTime date) + return date != default; + if (value is decimal decimalNumber) + return decimalNumber != 0m; + if (value is int intNumber) + return intNumber != 0; + + return true; + } +}