diff --git a/TrafagSalesExporter/HANDOFF_2026-04-15.md b/TrafagSalesExporter/HANDOFF_2026-04-15.md index d1895f5..64d9474 100644 --- a/TrafagSalesExporter/HANDOFF_2026-04-15.md +++ b/TrafagSalesExporter/HANDOFF_2026-04-15.md @@ -2,6 +2,47 @@ Stand: 2026-04-15 +## Nachtrag 2026-04-17 + +Der dokumentierte Stand in diesem Handoff war bei der Waehrungslogik nicht mehr aktuell. + +Inzwischen gilt: + +- Kurstabellen fuer `CurrencyExchangeRates` sind im System vorhanden +- `Settings` enthaelt bereits eine Pflegeoberflaeche fuer Wechselkurse +- `ExchangeRateImportService` importiert ECB-Tageskurse nach `CurrencyExchangeRates` +- `NormalizeCurrencyCode` ist als Value-Transformation vorhanden +- `ConvertCurrency` ist als Record-Transformation vorhanden +- `Program.cs` registriert beide Strategien sowie `CurrencyExchangeRateService` und `ExchangeRateImportService` + +Wichtig: + +- die Roh-Auswertung im `Management Cockpit` rechnet Stand heute weiterhin bewusst **nicht** in CHF um +- dort bleibt der Umsatz weiterhin in `Sales Currency` +- die Waehrungsumrechnung ist aktuell Teil des allgemeinen Transformations-/Mapping-Systems, nicht der Cockpit-Rohsicht + +Zusatzlich wurden am 2026-04-17 fehlende Unit-Tests fuer die Waehrungslogik nachgezogen: + +- `CurrencyExchangeRateServiceTests` +- `ExchangeRateImportServiceTests` +- Erweiterungen in + - `TransformationStrategiesTests` + - `RecordTransformationServiceTests` + - `TransformationCatalogTests` + +Aktueller Teststatus nach diesem Nachtrag: + +```text +dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal +``` + +Ergebnis: + +- erfolgreich +- `31/31` Tests gruen +- bekannte Warnung bleibt: + - SAP HANA Architekturwarnung `MSB3270` + ## Nachtrag 2026-04-16 Seit dem letzten Handoff wurden weitere Funktionen umgesetzt, die unten im alten Stand noch nicht voll enthalten sind. diff --git a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md index d5acc45..9be48f3 100644 --- a/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md +++ b/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md @@ -2,6 +2,31 @@ Stand: 2026-04-15 +## Nachtrag 2026-04-17 + +Der Punkt `CHF-Umrechnung / Wechselkurse` ist nicht mehr komplett offen. + +Der aktuelle Ist-Stand ist: + +- `CurrencyExchangeRateService` ist implementiert +- `ExchangeRateImportService` importiert ECB-Kurse +- `NormalizeCurrencyCode` und `ConvertCurrency` sind im Transformationssystem registriert +- fehlende Unit-Tests dafuer wurden am 2026-04-17 ergaenzt + +Neuer Teststand: + +- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal` +- erfolgreich +- `31/31` Tests gruen + +Was fuer Waehrungen trotzdem noch offen bleibt: + +- fachlicher Einsatz der `ConvertCurrency`-Regeln in echten Standortkonfigurationen pruefen +- UI-Flow fuer Wechselkurspflege in `Settings.razor` manuell gegenpruefen +- ECB-Import einmal real ueber die UI bzw. App-Funktion pruefen +- bestaetigen, fuer welche Sichten CHF die Zielwaehrung sein soll +- Management-Cockpit-Rohsicht nur dann auf CHF umstellen, wenn fachlich gewuenscht + ## Nachtrag 2026-04-16 Seit dem letzten Stand kamen mehrere groessere Erweiterungen dazu. Die offenen Punkte unten muessen deshalb im neuen Kontext gelesen werden. @@ -131,7 +156,7 @@ Dateien: Noch nicht final umsetzen ohne Rueckmeldung Fachseite: - Intercompany-Filter -- CHF-Umrechnung / Wechselkurse +- fachliche Nutzung der CHF-Umrechnung in Cockpit / Reports - Budgetvergleich - Gruppenlogik - Spartenlogik @@ -144,13 +169,14 @@ Diese Punkte sollen spaeter moeglichst dynamisch auf dem neuen Transformations-/ Wenn weiter in Tests investiert wird, sind die naechsten Kandidaten: - `ExportOrchestrationService` +- spaeter End-to-End-Tests fuer den Wechselkurs-/Transformationspfad - spaeter evtl. SQLite-nahe Integrationstests fuer `DatabaseInitializationService` Aktueller Teststatus: -- `dotnet test TrafagSalesExporter.sln --verbosity minimal` +- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal` - erfolgreich -- `18/18` Tests gruen +- `31/31` Tests gruen ## 7. Referenzdatei diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/CurrencyExchangeRateServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/CurrencyExchangeRateServiceTests.cs new file mode 100644 index 0000000..4c224d4 --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/CurrencyExchangeRateServiceTests.cs @@ -0,0 +1,139 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Models; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public class CurrencyExchangeRateServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly TestDbContextFactory _dbFactory; + private readonly CurrencyExchangeRateService _service; + + public CurrencyExchangeRateServiceTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using var db = new AppDbContext(options); + db.Database.EnsureCreated(); + + _dbFactory = new TestDbContextFactory(options); + _service = new CurrencyExchangeRateService(_dbFactory); + } + + public void Dispose() + { + _connection.Dispose(); + } + + [Fact] + public async Task ResolveRate_Returns_Direct_Rate_For_Valid_Date() + { + await SeedRatesAsync(new CurrencyExchangeRate + { + FromCurrency = "USD", + ToCurrency = "EUR", + Rate = 0.92m, + ValidFrom = new DateTime(2026, 1, 1), + ValidTo = null, + IsActive = true + }); + + var rate = _service.ResolveRate("USD", "EUR", new DateTime(2026, 4, 1)); + + Assert.Equal(0.92m, rate); + } + + [Fact] + public async Task ResolveRate_Uses_Inverse_Rate_When_Only_Reverse_Rate_Exists() + { + await SeedRatesAsync(new CurrencyExchangeRate + { + FromCurrency = "EUR", + ToCurrency = "CHF", + Rate = 1.10m, + ValidFrom = new DateTime(2026, 1, 1), + ValidTo = null, + IsActive = true + }); + + var rate = _service.ResolveRate("CHF", "EUR", new DateTime(2026, 4, 1)); + + Assert.NotNull(rate); + Assert.Equal(1m / 1.10m, rate!.Value, 6); + } + + [Fact] + public async Task ResolveRate_Uses_Eur_Cross_Rate_When_No_Direct_Rate_Exists() + { + await SeedRatesAsync( + new CurrencyExchangeRate + { + FromCurrency = "CHF", + ToCurrency = "EUR", + Rate = 0.95m, + ValidFrom = new DateTime(2026, 1, 1), + ValidTo = null, + IsActive = true + }, + new CurrencyExchangeRate + { + FromCurrency = "EUR", + ToCurrency = "USD", + Rate = 1.08m, + ValidFrom = new DateTime(2026, 1, 1), + ValidTo = null, + IsActive = true + }); + + var rate = _service.ResolveRate("CHF", "USD", new DateTime(2026, 4, 1)); + + Assert.NotNull(rate); + Assert.Equal(1.026m, rate!.Value, 6); + } + + [Theory] + [InlineData("$", "USD")] + [InlineData("US$", "USD")] + [InlineData("EUR", "EUR")] + [InlineData("sfr", "CHF")] + [InlineData("cad", "CAD")] + [InlineData("xyz", "XYZ")] + public void NormalizeCurrencyCode_Normalizes_Known_And_Unknown_Codes(string input, string expected) + { + var normalized = _service.NormalizeCurrencyCode(input); + + Assert.Equal(expected, normalized); + } + + private async Task SeedRatesAsync(params CurrencyExchangeRate[] rates) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + db.CurrencyExchangeRates.RemoveRange(db.CurrencyExchangeRates); + await db.SaveChangesAsync(); + db.CurrencyExchangeRates.AddRange(rates); + await db.SaveChangesAsync(); + } + + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) + { + _options = options; + } + + public AppDbContext CreateDbContext() => new(_options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new AppDbContext(_options)); + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/ExchangeRateImportServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/ExchangeRateImportServiceTests.cs new file mode 100644 index 0000000..41e078d --- /dev/null +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/ExchangeRateImportServiceTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using TrafagSalesExporter.Data; +using TrafagSalesExporter.Services; + +namespace TrafagSalesExporter.Tests; + +public class ExchangeRateImportServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly TestDbContextFactory _dbFactory; + + public ExchangeRateImportServiceTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using var db = new AppDbContext(options); + db.Database.EnsureCreated(); + + _dbFactory = new TestDbContextFactory(options); + } + + public void Dispose() + { + _connection.Dispose(); + } + + [Fact] + public async Task RefreshEcbRatesAsync_Imports_Rates_And_Replaces_Previous_Ecb_Rows_For_Same_Day() + { + const string xml = """ + + + + + + + + + """; + + await using (var db = await _dbFactory.CreateDbContextAsync()) + { + db.CurrencyExchangeRates.Add(new() + { + FromCurrency = "EUR", + ToCurrency = "USD", + Rate = 1.10m, + ValidFrom = new DateTime(2026, 4, 17), + Notes = "ECB daily reference rate", + IsActive = true + }); + await db.SaveChangesAsync(); + } + + var service = new ExchangeRateImportService( + new FakeHttpClientFactory(xml), + _dbFactory); + + var result = await service.RefreshEcbRatesAsync(); + + Assert.Equal(2, result.ImportedCount); + Assert.Equal(new DateTime(2026, 4, 17), result.RateDate); + Assert.Equal("ECB", result.SourceName); + + await using var verifyDb = await _dbFactory.CreateDbContextAsync(); + var rates = await verifyDb.CurrencyExchangeRates + .OrderBy(x => x.ToCurrency) + .ToListAsync(); + + Assert.Equal(2, rates.Count); + Assert.Collection(rates, + chf => + { + Assert.Equal("EUR", chf.FromCurrency); + Assert.Equal("CHF", chf.ToCurrency); + Assert.Equal(0.97m, chf.Rate); + }, + usd => + { + Assert.Equal("EUR", usd.FromCurrency); + Assert.Equal("USD", usd.ToCurrency); + Assert.Equal(1.12m, usd.Rate); + }); + } + + private sealed class TestDbContextFactory : IDbContextFactory + { + private readonly DbContextOptions _options; + + public TestDbContextFactory(DbContextOptions options) + { + _options = options; + } + + public AppDbContext CreateDbContext() => new(_options); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new AppDbContext(_options)); + } + + private sealed class FakeHttpClientFactory : IHttpClientFactory + { + private readonly string _xml; + + public FakeHttpClientFactory(string xml) + { + _xml = xml; + } + + public HttpClient CreateClient(string name) + { + return new HttpClient(new FakeHttpMessageHandler(_xml)); + } + } + + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly string _xml; + + public FakeHttpMessageHandler(string xml) + { + _xml = xml; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(_xml, Encoding.UTF8, "application/xml") + }); + } + } +} diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/RecordTransformationServiceTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/RecordTransformationServiceTests.cs index 1d864d6..60a7c27 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/RecordTransformationServiceTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/RecordTransformationServiceTests.cs @@ -21,7 +21,8 @@ public class RecordTransformationServiceTests ]; IRecordTransformationStrategy[] recordStrategies = [ - new FirstNonEmptyRecordTransformationStrategy() + new FirstNonEmptyRecordTransformationStrategy(), + new ConvertCurrencyRecordTransformationStrategy(new FakeCurrencyExchangeRateService()) ]; _service = new RecordTransformationService(valueStrategies, recordStrategies); @@ -141,4 +142,43 @@ public class RecordTransformationServiceTests Assert.Equal(42, records[0].PositionOnInvoice); } + + [Fact] + public void Apply_Uses_ConvertCurrency_Record_Strategy() + { + var records = new List + { + new() + { + SalesPriceValue = 100m, + SalesCurrency = "CHF", + InvoiceDate = new DateTime(2026, 4, 17) + } + }; + var rules = new[] + { + new FieldTransformationRule + { + IsActive = true, + RuleScope = "Record", + TargetField = nameof(SalesRecord.SalesPriceValue), + TransformationType = "ConvertCurrency", + Argument = "amountField=SalesPriceValue;currencyField=SalesCurrency;targetCurrency=EUR;dateField=InvoiceDate;targetCurrencyField=SalesCurrency", + SortOrder = 10 + } + }; + + _service.Apply(records, rules); + + Assert.Equal(95m, records[0].SalesPriceValue); + Assert.Equal("EUR", records[0].SalesCurrency); + } + + private sealed class FakeCurrencyExchangeRateService : ICurrencyExchangeRateService + { + public decimal? ResolveRate(string fromCurrency, string toCurrency, DateTime? effectiveDate) => 0.95m; + + public string NormalizeCurrencyCode(string? currencyCode) + => currencyCode?.Trim().ToUpperInvariant() ?? string.Empty; + } } diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationCatalogTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationCatalogTests.cs index 1d387a1..3c31b0f 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationCatalogTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationCatalogTests.cs @@ -10,11 +10,13 @@ public class TransformationCatalogTests ITransformationStrategy[] valueStrategies = [ new CopyTransformationStrategy(), - new ConstantTransformationStrategy() + new ConstantTransformationStrategy(), + new NormalizeCurrencyCodeTransformationStrategy() ]; IRecordTransformationStrategy[] recordStrategies = [ - new FirstNonEmptyRecordTransformationStrategy() + new FirstNonEmptyRecordTransformationStrategy(), + new ConvertCurrencyRecordTransformationStrategy(new FakeCurrencyExchangeRateService()) ]; var catalog = new TransformationCatalog(valueStrategies, recordStrategies); @@ -23,6 +25,16 @@ public class TransformationCatalogTests Assert.Contains(all, x => x.RuleScope == "Value" && x.Key == "Copy"); Assert.Contains(all, x => x.RuleScope == "Value" && x.Key == "Constant"); + Assert.Contains(all, x => x.RuleScope == "Value" && x.Key == "NormalizeCurrencyCode"); Assert.Contains(all, x => x.RuleScope == "Record" && x.Key == "FirstNonEmpty"); + Assert.Contains(all, x => x.RuleScope == "Record" && x.Key == "ConvertCurrency"); + } + + private sealed class FakeCurrencyExchangeRateService : ICurrencyExchangeRateService + { + public decimal? ResolveRate(string fromCurrency, string toCurrency, DateTime? effectiveDate) => 1m; + + public string NormalizeCurrencyCode(string? currencyCode) + => currencyCode?.Trim().ToUpperInvariant() ?? string.Empty; } } diff --git a/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationStrategiesTests.cs b/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationStrategiesTests.cs index 7116b94..61bee5f 100644 --- a/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationStrategiesTests.cs +++ b/TrafagSalesExporter/TrafagSalesExporter.Tests/TransformationStrategiesTests.cs @@ -47,4 +47,79 @@ public class TransformationStrategiesTests Assert.Equal("Fallback Supplier", record.CustomerName); } + + [Fact] + public void NormalizeCurrencyCodeStrategy_Uses_BuiltIn_And_Custom_Aliases() + { + var strategy = new NormalizeCurrencyCodeTransformationStrategy(); + + var normalizedDollar = strategy.Transform("$", null); + var normalizedRupee = strategy.Transform("rs", null); + var normalizedCustom = strategy.Transform("fr.", "fr.=>CHF"); + + Assert.Equal("USD", normalizedDollar); + Assert.Equal("INR", normalizedRupee); + Assert.Equal("CHF", normalizedCustom); + } + + [Fact] + public void ConvertCurrencyRecordStrategy_Converts_Amount_And_Updates_Target_Currency() + { + var exchangeRateService = new FakeCurrencyExchangeRateService(rate: 0.95m); + var strategy = new ConvertCurrencyRecordTransformationStrategy(exchangeRateService); + var record = new SalesRecord + { + SalesPriceValue = 100m, + SalesCurrency = "CHF", + InvoiceDate = new DateTime(2026, 4, 17) + }; + var rule = new FieldTransformationRule + { + RuleScope = "Record", + TargetField = nameof(SalesRecord.SalesPriceValue), + TransformationType = "ConvertCurrency", + Argument = "amountField=SalesPriceValue;currencyField=SalesCurrency;targetCurrency=EUR;dateField=InvoiceDate;targetCurrencyField=SalesCurrency;round=2" + }; + + strategy.Transform(record, rule); + + Assert.Equal("CHF", exchangeRateService.LastFromCurrency); + Assert.Equal("EUR", exchangeRateService.LastToCurrency); + Assert.Equal(new DateTime(2026, 4, 17), exchangeRateService.LastEffectiveDate); + Assert.Equal(95.00m, record.SalesPriceValue); + Assert.Equal("EUR", record.SalesCurrency); + } + + private sealed class FakeCurrencyExchangeRateService : ICurrencyExchangeRateService + { + private readonly decimal? _rate; + + public FakeCurrencyExchangeRateService(decimal? rate) + { + _rate = rate; + } + + public string LastFromCurrency { get; private set; } = string.Empty; + public string LastToCurrency { get; private set; } = string.Empty; + public DateTime? LastEffectiveDate { get; private set; } + + public decimal? ResolveRate(string fromCurrency, string toCurrency, DateTime? effectiveDate) + { + LastFromCurrency = fromCurrency; + LastToCurrency = toCurrency; + LastEffectiveDate = effectiveDate; + return _rate; + } + + public string NormalizeCurrencyCode(string? currencyCode) + { + var trimmed = currencyCode?.Trim() ?? string.Empty; + return trimmed switch + { + "$" => "USD", + "SFR" => "CHF", + _ => trimmed.ToUpperInvariant() + }; + } + } }