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 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 ## Nachtrag 2026-04-16
Seit dem letzten Handoff wurden weitere Funktionen umgesetzt, die unten im alten Stand noch nicht voll enthalten sind. 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 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 ## Nachtrag 2026-04-16
Seit dem letzten Stand kamen mehrere groessere Erweiterungen dazu. Die offenen Punkte unten muessen deshalb im neuen Kontext gelesen werden. 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: Noch nicht final umsetzen ohne Rueckmeldung Fachseite:
- Intercompany-Filter - Intercompany-Filter
- CHF-Umrechnung / Wechselkurse - fachliche Nutzung der CHF-Umrechnung in Cockpit / Reports
- Budgetvergleich - Budgetvergleich
- Gruppenlogik - Gruppenlogik
- Spartenlogik - 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: Wenn weiter in Tests investiert wird, sind die naechsten Kandidaten:
- `ExportOrchestrationService` - `ExportOrchestrationService`
- spaeter End-to-End-Tests fuer den Wechselkurs-/Transformationspfad
- spaeter evtl. SQLite-nahe Integrationstests fuer `DatabaseInitializationService` - spaeter evtl. SQLite-nahe Integrationstests fuer `DatabaseInitializationService`
Aktueller Teststatus: Aktueller Teststatus:
- `dotnet test TrafagSalesExporter.sln --verbosity minimal` - `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal`
- erfolgreich - erfolgreich
- `18/18` Tests gruen - `31/31` Tests gruen
## 7. Referenzdatei ## 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 = IRecordTransformationStrategy[] recordStrategies =
[ [
new FirstNonEmptyRecordTransformationStrategy() new FirstNonEmptyRecordTransformationStrategy(),
new ConvertCurrencyRecordTransformationStrategy(new FakeCurrencyExchangeRateService())
]; ];
_service = new RecordTransformationService(valueStrategies, recordStrategies); _service = new RecordTransformationService(valueStrategies, recordStrategies);
@@ -141,4 +142,43 @@ public class RecordTransformationServiceTests
Assert.Equal(42, records[0].PositionOnInvoice); 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 = ITransformationStrategy[] valueStrategies =
[ [
new CopyTransformationStrategy(), new CopyTransformationStrategy(),
new ConstantTransformationStrategy() new ConstantTransformationStrategy(),
new NormalizeCurrencyCodeTransformationStrategy()
]; ];
IRecordTransformationStrategy[] recordStrategies = IRecordTransformationStrategy[] recordStrategies =
[ [
new FirstNonEmptyRecordTransformationStrategy() new FirstNonEmptyRecordTransformationStrategy(),
new ConvertCurrencyRecordTransformationStrategy(new FakeCurrencyExchangeRateService())
]; ];
var catalog = new TransformationCatalog(valueStrategies, recordStrategies); 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 == "Copy");
Assert.Contains(all, x => x.RuleScope == "Value" && x.Key == "Constant"); 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 == "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); 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()
};
}
}
} }