This commit is contained in:
2026-04-17 07:08:04 +02:00
parent ca91af9682
commit 0d3bd47f7a
34 changed files with 17503 additions and 160 deletions
@@ -20,6 +20,11 @@ public class ConfigTransferService : IConfigTransferService
using var db = await _dbFactory.CreateDbContextAsync();
var sharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
var exportSettings = await db.ExportSettings.FirstOrDefaultAsync();
var exchangeRates = await db.CurrencyExchangeRates
.OrderBy(x => x.FromCurrency)
.ThenBy(x => x.ToCurrency)
.ThenByDescending(x => x.ValidFrom)
.ToListAsync();
var hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
var sites = await db.Sites.OrderBy(x => x.Land).ToListAsync();
var rules = await db.FieldTransformationRules.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
@@ -37,6 +42,7 @@ public class ConfigTransferService : IConfigTransferService
{
SiteUrl = sharePoint.SiteUrl,
ExportFolder = sharePoint.ExportFolder,
CentralExportFolder = sharePoint.CentralExportFolder,
TenantId = sharePoint.TenantId,
ClientId = sharePoint.ClientId,
ClientSecret = includeSecrets ? sharePoint.ClientSecret : null
@@ -57,6 +63,16 @@ public class ConfigTransferService : IConfigTransferService
SageUsername = includeSecrets ? exportSettings.SageUsername : null,
SagePassword = includeSecrets ? exportSettings.SagePassword : null
},
CurrencyExchangeRates = exchangeRates.Select(rate => new ConfigTransferCurrencyExchangeRate
{
FromCurrency = rate.FromCurrency,
ToCurrency = rate.ToCurrency,
Rate = rate.Rate,
ValidFrom = rate.ValidFrom,
ValidTo = rate.ValidTo,
Notes = rate.Notes,
IsActive = rate.IsActive
}).ToList(),
HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer
{
Key = serverKeyMap[server.Id],
@@ -143,6 +159,7 @@ public class ConfigTransferService : IConfigTransferService
var existingSharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
var existingSettings = await db.ExportSettings.FirstOrDefaultAsync();
var existingServers = await db.HanaServers.ToListAsync();
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
var existingSites = await db.Sites.ToListAsync();
var existingRules = await db.FieldTransformationRules.ToListAsync();
var existingSapSources = await db.SapSourceDefinitions.ToListAsync();
@@ -173,6 +190,7 @@ public class ConfigTransferService : IConfigTransferService
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules);
if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates);
if (existingCentralRecords.Count > 0) db.CentralSalesRecords.RemoveRange(existingCentralRecords);
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
@@ -184,6 +202,7 @@ public class ConfigTransferService : IConfigTransferService
{
SiteUrl = package.SharePointConfig.SiteUrl,
ExportFolder = package.SharePointConfig.ExportFolder,
CentralExportFolder = package.SharePointConfig.CentralExportFolder,
TenantId = package.SharePointConfig.TenantId,
ClientId = package.SharePointConfig.ClientId,
ClientSecret = package.IncludesSecrets ? package.SharePointConfig.ClientSecret ?? string.Empty : preservedSharePointSecret
@@ -208,6 +227,20 @@ public class ConfigTransferService : IConfigTransferService
SagePassword = package.IncludesSecrets ? importedSettings.SagePassword ?? string.Empty : preservedSecrets.SagePassword ?? string.Empty
});
if (package.CurrencyExchangeRates.Count > 0)
{
db.CurrencyExchangeRates.AddRange(package.CurrencyExchangeRates.Select(rate => new CurrencyExchangeRate
{
FromCurrency = rate.FromCurrency,
ToCurrency = rate.ToCurrency,
Rate = rate.Rate,
ValidFrom = rate.ValidFrom,
ValidTo = rate.ValidTo,
Notes = rate.Notes,
IsActive = rate.IsActive
}));
}
var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var server in package.HanaServers)
{
@@ -49,9 +49,15 @@ public class ConsolidatedExportService : IConsolidatedExportService
!string.IsNullOrWhiteSpace(spConfig.ClientId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientSecret))
{
var centralFolderConfigured = !string.IsNullOrWhiteSpace(spConfig.CentralExportFolder);
var sharePointFolder = centralFolderConfigured
? spConfig.CentralExportFolder
: spConfig.ExportFolder;
var landSubfolder = centralFolderConfigured ? string.Empty : "Alle";
await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, spConfig.ExportFolder, "Alle", consolidatedPath);
spConfig.SiteUrl, sharePointFolder, landSubfolder, consolidatedPath);
}
return consolidatedPath;
@@ -0,0 +1,121 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public class CurrencyExchangeRateService : ICurrencyExchangeRateService
{
private static readonly Dictionary<string, string> BuiltInCurrencyAliases = new(StringComparer.OrdinalIgnoreCase)
{
["$"] = "USD",
["US$"] = "USD",
["USD"] = "USD",
["€"] = "EUR",
["EUR"] = "EUR",
["CHF"] = "CHF",
["SFR"] = "CHF",
["INR"] = "INR",
["RS"] = "INR",
["GBP"] = "GBP",
["CAD"] = "CAD"
};
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public CurrencyExchangeRateService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public decimal? ResolveRate(string fromCurrency, string toCurrency, DateTime? effectiveDate)
{
var normalizedFrom = NormalizeCurrencyCode(fromCurrency);
var normalizedTo = NormalizeCurrencyCode(toCurrency);
if (string.IsNullOrWhiteSpace(normalizedFrom) || string.IsNullOrWhiteSpace(normalizedTo))
return null;
if (string.Equals(normalizedFrom, normalizedTo, StringComparison.OrdinalIgnoreCase))
return 1m;
var date = (effectiveDate ?? DateTime.UtcNow).Date;
using var db = _dbFactory.CreateDbContext();
var directRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == normalizedFrom
&& x.ToCurrency.ToUpper() == normalizedTo
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (directRate is not null)
return directRate.Rate;
var inverseRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == normalizedTo
&& x.ToCurrency.ToUpper() == normalizedFrom
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (inverseRate is not null && inverseRate.Rate != 0m)
return 1m / inverseRate.Rate;
var fromToEur = ResolveDirectOrInverseRate(db, normalizedFrom, "EUR", date);
var eurToTarget = ResolveDirectOrInverseRate(db, "EUR", normalizedTo, date);
if (fromToEur.HasValue && eurToTarget.HasValue)
return fromToEur.Value * eurToTarget.Value;
return null;
}
public string NormalizeCurrencyCode(string? currencyCode)
{
var normalized = currencyCode?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(normalized))
return string.Empty;
return BuiltInCurrencyAliases.TryGetValue(normalized, out var mapped)
? mapped
: normalized.ToUpperInvariant();
}
private static decimal? ResolveDirectOrInverseRate(AppDbContext db, string fromCurrency, string toCurrency, DateTime date)
{
if (string.Equals(fromCurrency, toCurrency, StringComparison.OrdinalIgnoreCase))
return 1m;
var directRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == fromCurrency
&& x.ToCurrency.ToUpper() == toCurrency
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (directRate is not null)
return directRate.Rate;
var inverseRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == toCurrency
&& x.ToCurrency.ToUpper() == fromCurrency
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (inverseRate is not null && inverseRate.Rate != 0m)
return 1m / inverseRate.Rate;
return null;
}
}
@@ -21,6 +21,7 @@ public class DatabaseInitializationService : IDatabaseInitializationService
ConfigureSqlite(db);
EnsureSchema(db);
SeedIfEmpty(db);
EnsureRecommendedTransformationRules(db);
}
private static void ConfigureSqlite(AppDbContext db)
@@ -69,9 +70,11 @@ public class DatabaseInitializationService : IDatabaseInitializationService
AddColumnIfMissing(db, "ExportSettings", "DebugLoggingEnabled", "INTEGER NOT NULL DEFAULT 0");
AddColumnIfMissing(db, "ExportSettings", "LocalSiteExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportSettings", "LocalConsolidatedExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "SharePointConfigs", "CentralExportFolder", "TEXT NOT NULL DEFAULT ''");
AddColumnIfMissing(db, "ExportLogs", "FilePath", "TEXT NOT NULL DEFAULT ''");
EnsureTransformationTable(db);
AddColumnIfMissing(db, "FieldTransformationRules", "RuleScope", "TEXT NOT NULL DEFAULT 'Value'");
EnsureCurrencyExchangeRateTable(db);
EnsureSapSourceTable(db);
EnsureSapJoinTable(db);
EnsureSapFieldMappingTable(db);
@@ -470,6 +473,27 @@ CREATE TABLE IF NOT EXISTS SapSourceDefinitions (
cmd.ExecuteNonQuery();
}
private static void EnsureCurrencyExchangeRateTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS CurrencyExchangeRates (
Id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
FromCurrency TEXT NOT NULL,
ToCurrency TEXT NOT NULL,
Rate REAL NOT NULL,
ValidFrom TEXT NOT NULL,
ValidTo TEXT NULL,
Notes TEXT NOT NULL DEFAULT '',
IsActive INTEGER NOT NULL DEFAULT 1
);";
cmd.ExecuteNonQuery();
}
private static void EnsureSapJoinTable(AppDbContext db)
{
var conn = db.Database.GetDbConnection();
@@ -601,6 +625,7 @@ CREATE TABLE IF NOT EXISTS AppEventLogs (
{
SiteUrl = "https://trafagag.sharepoint.com/sites/WorldwideBIPlatform",
ExportFolder = "/Shared Documents/Exports/",
CentralExportFolder = "",
TenantId = "",
ClientId = "",
ClientSecret = ""
@@ -619,4 +644,55 @@ CREATE TABLE IF NOT EXISTS AppEventLogs (
db.SaveChanges();
}
private static void EnsureRecommendedTransformationRules(AppDbContext db)
{
var recommendedRules = new[]
{
new FieldTransformationRule
{
SourceSystem = "MANUAL_EXCEL",
SourceField = nameof(SalesRecord.SalesCurrency),
TargetField = nameof(SalesRecord.SalesCurrency),
TransformationType = "Replace",
RuleScope = "Value",
Argument = "$=>USD",
SortOrder = 100,
IsActive = true
},
new FieldTransformationRule
{
SourceSystem = "MANUAL_EXCEL",
SourceField = nameof(SalesRecord.StandardCostCurrency),
TargetField = nameof(SalesRecord.StandardCostCurrency),
TransformationType = "Replace",
RuleScope = "Value",
Argument = "$=>USD",
SortOrder = 110,
IsActive = true
}
};
var hasChanges = false;
foreach (var rule in recommendedRules)
{
var exists = db.FieldTransformationRules.Any(existing =>
existing.SourceSystem == rule.SourceSystem &&
existing.RuleScope == rule.RuleScope &&
existing.SourceField == rule.SourceField &&
existing.TargetField == rule.TargetField &&
existing.TransformationType == rule.TransformationType &&
existing.Argument == rule.Argument);
if (exists)
continue;
db.FieldTransformationRules.Add(rule);
hasChanges = true;
}
if (hasChanges)
db.SaveChanges();
}
}
@@ -0,0 +1,93 @@
using System.Globalization;
using System.Xml.Linq;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ExchangeRateImportService : IExchangeRateImportService
{
private const string EcbXmlUrl = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
private const string EcbSourceNote = "ECB daily reference rate";
private readonly IHttpClientFactory _httpClientFactory;
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public ExchangeRateImportService(IHttpClientFactory httpClientFactory, IDbContextFactory<AppDbContext> dbFactory)
{
_httpClientFactory = httpClientFactory;
_dbFactory = dbFactory;
}
public async Task<ExchangeRateImportResult> RefreshEcbRatesAsync(CancellationToken cancellationToken = default)
{
var client = _httpClientFactory.CreateClient(nameof(ExchangeRateImportService));
using var response = await client.GetAsync(EcbXmlUrl, cancellationToken);
response.EnsureSuccessStatusCode();
var xml = await response.Content.ReadAsStringAsync(cancellationToken);
var document = XDocument.Parse(xml);
var rateEntries = ParseRates(document);
if (rateEntries.Count == 0)
throw new InvalidOperationException("ECB response did not contain any exchange rates.");
var rateDate = rateEntries[0].RateDate;
using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
var existingRates = await db.CurrencyExchangeRates
.Where(x => x.Notes == EcbSourceNote && x.ValidFrom == rateDate)
.ToListAsync(cancellationToken);
if (existingRates.Count > 0)
db.CurrencyExchangeRates.RemoveRange(existingRates);
db.CurrencyExchangeRates.AddRange(rateEntries.Select(entry => new CurrencyExchangeRate
{
FromCurrency = "EUR",
ToCurrency = entry.Currency,
Rate = entry.Rate,
ValidFrom = entry.RateDate,
ValidTo = null,
Notes = EcbSourceNote,
IsActive = true
}));
await db.SaveChangesAsync(cancellationToken);
return new ExchangeRateImportResult
{
ImportedCount = rateEntries.Count,
RateDate = rateDate,
SourceName = "ECB"
};
}
private static List<EcbRateEntry> ParseRates(XDocument document)
{
var cubes = document
.Descendants()
.Where(x => x.Name.LocalName == "Cube")
.ToList();
var datedCube = cubes.FirstOrDefault(x => x.Attribute("time") is not null)
?? throw new InvalidOperationException("ECB response did not contain a dated rate section.");
var dateText = datedCube.Attribute("time")?.Value
?? throw new InvalidOperationException("ECB rate date is missing.");
var rateDate = DateTime.ParseExact(dateText, "yyyy-MM-dd", CultureInfo.InvariantCulture);
return datedCube.Elements()
.Where(x => x.Name.LocalName == "Cube")
.Select(x => new EcbRateEntry(
Currency: (x.Attribute("currency")?.Value ?? string.Empty).Trim().ToUpperInvariant(),
Rate: decimal.Parse(x.Attribute("rate")?.Value ?? "0", CultureInfo.InvariantCulture),
RateDate: rateDate))
.Where(x => !string.IsNullOrWhiteSpace(x.Currency) && x.Rate > 0m)
.ToList();
}
private sealed record EcbRateEntry(string Currency, decimal Rate, DateTime RateDate);
}
@@ -105,6 +105,38 @@ public class HanaQueryService : IHanaQueryService
connection.Open();
}
public List<string> GetAvailableSchemas(HanaServer server)
{
var connectionString = server.BuildConnectionString();
using var connection = new HanaConnection(connectionString);
connection.Open();
const string query = """
SELECT schema_name
FROM (
SELECT schema_name, COUNT(DISTINCT table_name) AS required_table_count
FROM sys.tables
WHERE table_name IN ('OINV', 'INV1', 'ORIN', 'RIN1', 'OCRD', 'OITM')
GROUP BY schema_name
) t
WHERE required_table_count >= 4
ORDER BY schema_name;
""";
using var command = new HanaCommand(query, connection);
using var reader = command.ExecuteReader();
var schemas = new List<string>();
while (reader.Read())
{
var schema = reader["schema_name"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(schema))
schemas.Add(schema);
}
return schemas;
}
private List<SalesRecord> ReadRecords(HanaConnection connection, string query, string land, string queryName)
{
var records = new List<SalesRecord>();
@@ -0,0 +1,7 @@
namespace TrafagSalesExporter.Services;
public interface ICurrencyExchangeRateService
{
decimal? ResolveRate(string fromCurrency, string toCurrency, DateTime? effectiveDate);
string NormalizeCurrencyCode(string? currencyCode);
}
@@ -0,0 +1,13 @@
namespace TrafagSalesExporter.Services;
public interface IExchangeRateImportService
{
Task<ExchangeRateImportResult> RefreshEcbRatesAsync(CancellationToken cancellationToken = default);
}
public sealed class ExchangeRateImportResult
{
public int ImportedCount { get; init; }
public DateTime RateDate { get; init; }
public string SourceName { get; init; } = string.Empty;
}
@@ -5,6 +5,7 @@ namespace TrafagSalesExporter.Services;
public interface IHanaQueryService
{
List<SalesRecord> GetSalesRecords(HanaServer server, string schema, string tsc, string land, string dateFilter);
List<string> GetAvailableSchemas(HanaServer server);
ConnectionTestResult TestConnectionDetailed(HanaServer server);
void TestConnection(HanaServer server);
}
@@ -11,4 +11,7 @@ public sealed class TransformationCatalogItem
public string Key { get; init; } = string.Empty;
public string RuleScope { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string TypeName { get; init; } = string.Empty;
public string SourceFile { get; init; } = string.Empty;
public string CodeSnippet { get; init; } = string.Empty;
}
@@ -1,3 +1,4 @@
using Azure.Core;
using Azure.Identity;
using Microsoft.Graph;
@@ -8,10 +9,17 @@ public class SharePointUploadService : ISharePointUploadService
public async Task UploadAsync(string tenantId, string clientId, string clientSecret,
string siteUrl, string exportFolder, string land, string localFilePath)
{
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var normalizedTenantId = Normalize(tenantId);
var normalizedClientId = Normalize(clientId);
var normalizedClientSecret = Normalize(clientSecret);
var normalizedSiteUrl = Normalize(siteUrl);
var normalizedExportFolder = Normalize(exportFolder);
var normalizedLand = Normalize(land);
var credential = new ClientSecretCredential(normalizedTenantId, normalizedClientId, normalizedClientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var uri = new Uri(siteUrl);
var uri = new Uri(normalizedSiteUrl);
var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
@@ -23,8 +31,13 @@ public class SharePointUploadService : ISharePointUploadService
throw new InvalidOperationException("SharePoint Dokumentenbibliothek konnte nicht gefunden werden.");
var fileName = Path.GetFileName(localFilePath);
var folderPath = exportFolder.Trim('/').Trim();
var remotePath = $"{folderPath}/{land}/{fileName}";
var remotePath = string.Join("/",
new[]
{
normalizedExportFolder.Trim('/').Trim(),
normalizedLand.Trim('/').Trim(),
fileName
}.Where(segment => !string.IsNullOrWhiteSpace(segment)));
await using var stream = File.OpenRead(localFilePath);
await graphClient.Drives[drive.Id].Root.ItemWithPath(remotePath).Content.PutAsync(stream);
@@ -32,14 +45,53 @@ public class SharePointUploadService : ISharePointUploadService
public async Task TestConnectionAsync(string tenantId, string clientId, string clientSecret, string siteUrl)
{
var credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var normalizedTenantId = Normalize(tenantId);
var normalizedClientId = Normalize(clientId);
var normalizedClientSecret = Normalize(clientSecret);
var normalizedSiteUrl = Normalize(siteUrl);
var inputPreview = BuildInputPreview(normalizedTenantId, normalizedClientId, normalizedClientSecret, normalizedSiteUrl);
var uri = new Uri(siteUrl);
if (string.IsNullOrWhiteSpace(normalizedTenantId))
throw new InvalidOperationException($"Tenant ID fehlt. {inputPreview}");
if (string.IsNullOrWhiteSpace(normalizedClientId))
throw new InvalidOperationException($"Client ID fehlt. {inputPreview}");
if (string.IsNullOrWhiteSpace(normalizedClientSecret))
throw new InvalidOperationException($"Client Secret fehlt. {inputPreview}");
if (string.IsNullOrWhiteSpace(normalizedSiteUrl))
throw new InvalidOperationException($"Site URL fehlt. {inputPreview}");
var credential = new ClientSecretCredential(normalizedTenantId, normalizedClientId, normalizedClientSecret);
try
{
await credential.GetTokenAsync(
new TokenRequestContext(["https://graph.microsoft.com/.default"]),
CancellationToken.None);
}
catch (AuthenticationFailedException ex)
{
throw new InvalidOperationException(
$"ClientSecretCredential authentication failed: {ex.Message}{Environment.NewLine}{inputPreview}",
ex);
}
var graphClient = new GraphServiceClient(credential, ["https://graph.microsoft.com/.default"]);
var uri = new Uri(normalizedSiteUrl);
var sitePath = uri.AbsolutePath;
var site = await graphClient.Sites[$"{uri.Host}:{sitePath}"].GetAsync();
if (site?.Id is null)
throw new InvalidOperationException("SharePoint Site konnte nicht gefunden werden.");
throw new InvalidOperationException($"SharePoint Site konnte nicht gefunden werden. {inputPreview}");
}
private static string Normalize(string value) => value?.Trim() ?? string.Empty;
private static string BuildInputPreview(string tenantId, string clientId, string clientSecret, string siteUrl)
{
var maskedSecret = string.IsNullOrEmpty(clientSecret)
? "<leer>"
: $"{new string('*', Math.Min(clientSecret.Length, 8))} (len={clientSecret.Length})";
return $"Uebergeben: TenantId='{tenantId}', ClientId='{clientId}', ClientSecret={maskedSecret}, SiteUrl='{siteUrl}'";
}
}
@@ -7,18 +7,8 @@ public class TransformationCatalog : ITransformationCatalog
public TransformationCatalog(IEnumerable<ITransformationStrategy> valueStrategies, IEnumerable<IRecordTransformationStrategy> recordStrategies)
{
_items = valueStrategies
.Select(x => new TransformationCatalogItem
{
Key = x.TransformationType,
RuleScope = "Value",
Description = x.Description
})
.Concat(recordStrategies.Select(x => new TransformationCatalogItem
{
Key = x.TransformationType,
RuleScope = "Record",
Description = x.Description
}))
.Select(x => BuildItem(x.TransformationType, "Value", x.Description, x.GetType()))
.Concat(recordStrategies.Select(x => BuildItem(x.TransformationType, "Record", x.Description, x.GetType())))
.OrderBy(x => x.RuleScope, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.ToList();
@@ -30,4 +20,90 @@ public class TransformationCatalog : ITransformationCatalog
=> _items
.Where(x => string.Equals(x.RuleScope, ruleScope, StringComparison.OrdinalIgnoreCase))
.ToList();
private static TransformationCatalogItem BuildItem(string key, string ruleScope, string description, Type implementationType)
=> new()
{
Key = key,
RuleScope = ruleScope,
Description = description,
TypeName = implementationType.Name,
SourceFile = implementationType == typeof(FirstNonEmptyRecordTransformationStrategy)
? "Services/TransformationStrategies.cs"
: "Services/TransformationStrategies.cs",
CodeSnippet = GetCodeSnippet(key, ruleScope)
};
private static string GetCodeSnippet(string key, string ruleScope)
=> (ruleScope, key) switch
{
("Value", "Copy") => """
public object? Transform(object? sourceValue, string? argument)
=> sourceValue;
""",
("Value", "Uppercase") => """
public object? Transform(object? sourceValue, string? argument)
=> sourceValue?.ToString()?.ToUpperInvariant();
""",
("Value", "Lowercase") => """
public object? Transform(object? sourceValue, string? argument)
=> sourceValue?.ToString()?.ToLowerInvariant();
""",
("Value", "Prefix") => """
public object? Transform(object? sourceValue, string? argument)
=> $"{argument}{sourceValue}";
""",
("Value", "Suffix") => """
public object? Transform(object? sourceValue, string? argument)
=> $"{sourceValue}{argument}";
""",
("Value", "Replace") => """
public object? Transform(object? sourceValue, string? argument)
{
var input = sourceValue?.ToString();
var parts = argument?.Split("=>", 2, StringSplitOptions.TrimEntries);
return parts?.Length == 2
? input?.Replace(parts[0], parts[1], StringComparison.OrdinalIgnoreCase)
: input;
}
""",
("Value", "Constant") => """
public object? Transform(object? sourceValue, string? argument)
=> argument;
""",
("Value", "NormalizeCurrencyCode") => """
public object? Transform(object? sourceValue, string? argument)
{
var input = sourceValue?.ToString()?.Trim();
return aliases.TryGetValue(input ?? "", out var mapped)
? mapped
: input?.ToUpperInvariant();
}
""",
("Record", "FirstNonEmpty") => """
public void Transform(SalesRecord record, FieldTransformationRule rule)
{
var sourceFields = rule.Argument.Split(['|', ',', ';'], StringSplitOptions.TrimEntries);
foreach (var sourceField in sourceFields)
{
var value = sourceProperty.GetValue(record);
if (IsMeaningfulValue(value))
{
SetPropertyValue(record, targetProperty, value);
return;
}
}
}
""",
("Record", "ConvertCurrency") => """
public void Transform(SalesRecord record, FieldTransformationRule rule)
{
var options = ParseOptions(rule.Argument);
var rate = exchangeRateService.ResolveRate(sourceCurrency, targetCurrency, effectiveDate);
if (rate.HasValue)
SetPropertyValue(record, targetAmountProperty, sourceAmount * rate.Value);
}
""",
_ => "// Kein Snippet hinterlegt."
};
}
@@ -66,6 +66,56 @@ public sealed class ConstantTransformationStrategy : ITransformationStrategy
public object? Transform(object? sourceValue, string? argument) => argument;
}
public sealed class NormalizeCurrencyCodeTransformationStrategy : ITransformationStrategy
{
private static readonly Dictionary<string, string> BuiltInAliases = new(StringComparer.OrdinalIgnoreCase)
{
["$"] = "USD",
["US$"] = "USD",
["USD"] = "USD",
["€"] = "EUR",
["EUR"] = "EUR",
["CHF"] = "CHF",
["SFR"] = "CHF",
["INR"] = "INR",
["RS"] = "INR",
["GBP"] = "GBP",
["CAD"] = "CAD"
};
public string TransformationType => "NormalizeCurrencyCode";
public string Description => "Normalisiert Waehrungscodes wie $, EUR, CHF, INR auf ISO-Codes. Optionale Aliase im Argument mit alt=>neu|alt2=>neu2.";
public object? Transform(object? sourceValue, string? argument)
{
var input = sourceValue?.ToString()?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(input))
return string.Empty;
var aliases = new Dictionary<string, string>(BuiltInAliases, StringComparer.OrdinalIgnoreCase);
foreach (var mapping in ParseMappings(argument))
aliases[mapping.Key] = mapping.Value;
return aliases.TryGetValue(input, out var mapped)
? mapped
: input.ToUpperInvariant();
}
private static IEnumerable<KeyValuePair<string, string>> ParseMappings(string? argument)
{
if (string.IsNullOrWhiteSpace(argument))
yield break;
var mappings = argument.Split(['|', ';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var mapping in mappings)
{
var parts = mapping.Split("=>", 2, StringSplitOptions.TrimEntries);
if (parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[0]) && !string.IsNullOrWhiteSpace(parts[1]))
yield return new KeyValuePair<string, string>(parts[0], parts[1].ToUpperInvariant());
}
}
}
public sealed class FirstNonEmptyRecordTransformationStrategy : IRecordTransformationStrategy
{
public string TransformationType => "FirstNonEmpty";
@@ -113,3 +163,101 @@ public sealed class FirstNonEmptyRecordTransformationStrategy : IRecordTransform
return true;
}
}
public sealed class ConvertCurrencyRecordTransformationStrategy : IRecordTransformationStrategy
{
private readonly ICurrencyExchangeRateService _exchangeRateService;
public ConvertCurrencyRecordTransformationStrategy(ICurrencyExchangeRateService exchangeRateService)
{
_exchangeRateService = exchangeRateService;
}
public string TransformationType => "ConvertCurrency";
public string Description => "Record-Strategie: rechnet einen Betrag ueber die Kurstabelle in eine Zielwaehrung um. Argument z.B. amountField=SalesPriceValue;currencyField=SalesCurrency;targetCurrency=EUR;dateField=InvoiceDate;targetCurrencyField=SalesCurrency;round=2";
public void Transform(SalesRecord record, FieldTransformationRule rule)
{
if (string.IsNullOrWhiteSpace(rule.TargetField) || string.IsNullOrWhiteSpace(rule.Argument))
return;
var propertyMap = RecordTransformationService.PropertyMap;
if (!propertyMap.TryGetValue(rule.TargetField, out var targetAmountProperty))
return;
var options = ParseOptions(rule.Argument);
if (!options.TryGetValue("amountField", out var amountField)
|| !options.TryGetValue("currencyField", out var currencyField)
|| !options.TryGetValue("targetCurrency", out var targetCurrency)
|| !propertyMap.TryGetValue(amountField, out var sourceAmountProperty)
|| !propertyMap.TryGetValue(currencyField, out var sourceCurrencyProperty))
return;
var sourceAmount = ReadDecimal(record, sourceAmountProperty);
if (sourceAmount is null)
return;
var sourceCurrency = _exchangeRateService.NormalizeCurrencyCode(sourceCurrencyProperty.GetValue(record)?.ToString());
var normalizedTargetCurrency = _exchangeRateService.NormalizeCurrencyCode(targetCurrency);
if (string.IsNullOrWhiteSpace(sourceCurrency) || string.IsNullOrWhiteSpace(normalizedTargetCurrency))
return;
var effectiveDate = ResolveEffectiveDate(record, options, propertyMap);
var rate = _exchangeRateService.ResolveRate(sourceCurrency, normalizedTargetCurrency, effectiveDate);
if (!rate.HasValue)
return;
var convertedAmount = sourceAmount.Value * rate.Value;
if (options.TryGetValue("round", out var roundValue) && int.TryParse(roundValue, out var digits))
convertedAmount = Math.Round(convertedAmount, digits, MidpointRounding.AwayFromZero);
RecordTransformationService.SetPropertyValue(record, targetAmountProperty, convertedAmount);
if (options.TryGetValue("targetCurrencyField", out var targetCurrencyField)
&& propertyMap.TryGetValue(targetCurrencyField, out var targetCurrencyProperty))
{
RecordTransformationService.SetPropertyValue(record, targetCurrencyProperty, normalizedTargetCurrency);
}
}
private static Dictionary<string, string> ParseOptions(string argument)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var parts = argument.Split([';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var pair = part.Split('=', 2, StringSplitOptions.TrimEntries);
if (pair.Length == 2 && !string.IsNullOrWhiteSpace(pair[0]))
result[pair[0]] = pair[1];
}
return result;
}
private static decimal? ReadDecimal(SalesRecord record, System.Reflection.PropertyInfo property)
{
var value = property.GetValue(record);
if (value is decimal decimalValue)
return decimalValue;
return decimal.TryParse(value?.ToString(), out var parsed)
? parsed
: null;
}
private static DateTime? ResolveEffectiveDate(
SalesRecord record,
IReadOnlyDictionary<string, string> options,
IReadOnlyDictionary<string, System.Reflection.PropertyInfo> propertyMap)
{
if (options.TryGetValue("dateField", out var dateField)
&& propertyMap.TryGetValue(dateField, out var configuredDateProperty))
{
var configuredDate = configuredDateProperty.GetValue(record);
if (configuredDate is DateTime date)
return date;
}
return record.InvoiceDate ?? record.OrderDate ?? record.ExtractionDate;
}
}
@@ -0,0 +1,31 @@
namespace TrafagSalesExporter.Services;
public interface IUiTextService
{
string CurrentLanguage { get; }
event Action? Changed;
void SetLanguage(string language);
string Text(string german, string english);
}
public sealed class UiTextService : IUiTextService
{
private string _currentLanguage = "de";
public string CurrentLanguage => _currentLanguage;
public event Action? Changed;
public void SetLanguage(string language)
{
var normalized = string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "de";
if (string.Equals(_currentLanguage, normalized, StringComparison.OrdinalIgnoreCase))
return;
_currentLanguage = normalized;
Changed?.Invoke();
}
public string Text(string german, string english)
=> string.Equals(_currentLanguage, "en", StringComparison.OrdinalIgnoreCase) ? english : german;
}