englisch
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user