tests wähurngsverwaltung

This commit is contained in:
2026-04-17 08:09:31 +02:00
parent 0d3bd47f7a
commit 83a400a90e
7 changed files with 481 additions and 6 deletions
+41
View File
@@ -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.
+29 -3
View File
@@ -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
@@ -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<AppDbContext>()
.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<AppDbContext>
{
private readonly DbContextOptions<AppDbContext> _options;
public TestDbContextFactory(DbContextOptions<AppDbContext> options)
{
_options = options;
}
public AppDbContext CreateDbContext() => new(_options);
public Task<AppDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new AppDbContext(_options));
}
}
@@ -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<AppDbContext>()
.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 = """
<gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01" xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
<Cube>
<Cube time="2026-04-17">
<Cube currency="USD" rate="1.1200" />
<Cube currency="CHF" rate="0.9700" />
</Cube>
</Cube>
</gesmes:Envelope>
""";
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<AppDbContext>
{
private readonly DbContextOptions<AppDbContext> _options;
public TestDbContextFactory(DbContextOptions<AppDbContext> options)
{
_options = options;
}
public AppDbContext CreateDbContext() => new(_options);
public Task<AppDbContext> 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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(_xml, Encoding.UTF8, "application/xml")
});
}
}
}
@@ -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<SalesRecord>
{
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;
}
}
@@ -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;
}
}
@@ -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()
};
}
}
}