EZB-Tageskurse selbst abrufen, Kopfleiste umbrechen lassen
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s

Behebt #532 und #533.

#532 — Kopfleiste bricht um statt zu scrollen

Mit elf Ansichten brauchte die Leiste 1623 px. Darunter scrollte sie seitlich
weg, ohne dass ein Hinweis darauf sichtbar war: Wer einen rechten Tab anklickte,
verlor "Dashboard" aus dem Blick und fand den Weg zurueck nicht mehr. Statt
overflow-x nun flex-wrap auf Leiste und Kopfzeile. Geprueft von 1920 bis 768 px
— nichts wird mehr abgeschnitten, der Koerper scrollt nie waagerecht.

#533 — Kurse holen, statt EUR still auf null zu setzen

Die Tabelle acc_fx_rates gab es seit A-2, befuellt hat sie nie jemand. Deshalb
war EUR nicht verfuegbar, und der PDF-Export machte daraus wortlos einen Beleg
mit lauter Nullbetraegen.

Neu holt EcbFxRateSource die amtlichen Referenzkurse ueber die EZB-Daten-API
(Reihe EXR/D.USD.EUR.SP00.A, CSV). FxRateSyncService stoesst das beim ersten
Ingest-Lauf des Tages an: erstmalig 400 Tage rueckwirkend, danach nur noch der
Rand. Die Kurse werden gespeichert, nicht bei Bedarf geholt — eine Abrechnung
fuer einen vergangenen Monat muss den Kurs von damals nehmen und beim naechsten
Aufruf dasselbe Ergebnis liefern.

Zwei Fallen, die dabei zu umgehen waren:

- Die Reihe liefert USD je EUR (1 EUR = 1,1652 USD), FxRate.UsdToEur meint die
  Gegenrichtung. Wer das verwechselt, bekommt eine Abrechnung, die um rund 35 %
  danebenliegt und trotzdem plausibel aussieht. Gespeichert wird der Kehrwert.
- Die CSV-Antwort enthaelt in TITLE_COMPL ein Komma innerhalb von
  Anfuehrungszeichen; naives Splitten verschoebe die Spalten.

Die Warnung erscheint, sobald der verwendete Kurs mehr als drei Tage vor dem
Stichtag liegt. Drei Tage, weil genau so gross die planmaessige Wochenendluecke
ist: Der Freitagskurs gilt bis Montag und ist dabei kein Mangel.

Der Beleg bleibt trotzdem erzeugbar — die Warnung steht sichtbar darauf und im
WebUI ueber der Abrechnung. Nur ohne jeden Kurs lehnt der Export jetzt mit 400
ab, statt Nullbetraege auszugeben. PDF und CSV laufen deshalb nicht mehr ueber
window.open: Eine nackte JSON-Fehlermeldung in einem neuen Reiter ist keine
Antwort. downloadExport() holt die Datei, meldet Fehler im Klartext und stoesst
sonst den Download an.

Nebenbei im Testlauf gefunden und behoben: Der manuelle Anstoss aus dem WebUI
und die stuendliche Schleife trafen sich in derselben Sekunde — im Log standen
"280 Kurse uebernommen" und "53 Kurse uebernommen" untereinander, beide hatten
die EZB gefragt. SyncDailyAsync serialisiert jetzt und prueft den Tag ein
zweites Mal innerhalb der Sperre.

Geprueft auf der Linux-Test-VM gegen die echte EZB-API: 280 Kurse vom
06.08.2025 bis 09.09.2026 uebernommen, Umrechnung stimmt (1/1,1652 = 0,858222),
eine Maerz-Abrechnung nimmt den Kurs vom 31.03. und nicht den heutigen, das
Warnbanner erscheint ab dem vierten Tag, der Export verweigert ohne Kurs und
liefert sonst ein gueltiges PDF. 551 von 551 Tests bestanden (18 neue).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-10 15:25:47 +02:00
co-authored by Claude Opus 5
parent dcc764dec1
commit 72ad38518b
12 changed files with 792 additions and 21 deletions
@@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PolyTrader.Modules.Accounting.Models;
using PolyTrader.Modules.Accounting.Persistence;
using PolyTrader.Modules.Accounting.Services;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests.Accounting
{
/// <summary>
/// Prüft das Einlesen der EZB-Kurse und den täglichen Abgleich (#533). Der Abruf selbst liegt
/// hinter <see cref="IFxRateSource"/> und wird hier durch eine Attrappe ersetzt — die Tests
/// laufen ohne Netz.
/// </summary>
public class EcbFxRateSourceTests
{
/// <summary>Echte Antwort der EZB-Daten-API, gekürzt auf die hier interessanten Spalten.</summary>
private const string RealCsv =
"KEY,FREQ,CURRENCY,CURRENCY_DENOM,EXR_TYPE,EXR_SUFFIX,TIME_PERIOD,OBS_VALUE,OBS_STATUS,TITLE_COMPL\n" +
"EXR.D.USD.EUR.SP00.A,D,USD,EUR,SP00,A,2026-09-08,1.1614,A,\"ECB reference exchange rate, US dollar/Euro, 2.15 pm (C.E.T.)\"\n" +
"EXR.D.USD.EUR.SP00.A,D,USD,EUR,SP00,A,2026-09-09,1.1652,A,\"ECB reference exchange rate, US dollar/Euro, 2.15 pm (C.E.T.)\"\n";
[Fact]
public void ParseCsv_kehrt_die_Kursrichtung_um()
{
var rates = EcbFxRateSource.ParseCsv(RealCsv);
Assert.Equal(2, rates.Count);
// Die Reihe liefert USD je EUR (1 EUR = 1,1652 USD). Gespeichert wird USD→EUR.
var newest = rates.Last();
Assert.Equal(new DateTime(2026, 9, 9), newest.Date);
Assert.Equal(Math.Round(1m / 1.1652m, 6), newest.UsdToEur);
Assert.Equal("ECB", newest.Source);
// Gegenprobe: 100 USD sind weniger als 100 EUR, solange der Euro über pari steht.
Assert.True(newest.UsdToEur < 1m);
}
[Fact]
public void ParseCsv_uebergeht_Kommas_in_Anfuehrungszeichen()
{
// TITLE_COMPL enthält Kommas. Naives Splitten verschöbe die Spalten.
var rates = EcbFxRateSource.ParseCsv(RealCsv);
Assert.All(rates, r => Assert.True(r.UsdToEur > 0m));
}
[Theory]
[InlineData("")]
[InlineData("nur,eine,kopfzeile\n")]
public void ParseCsv_liefert_bei_unbrauchbarer_Antwort_nichts(string csv)
{
Assert.Empty(EcbFxRateSource.ParseCsv(csv));
}
[Fact]
public void ParseCsv_ueberspringt_Zeilen_ohne_Beobachtung()
{
string csv =
"TIME_PERIOD,OBS_VALUE\n" +
"2026-09-08,1.1614\n" +
"2026-09-09,\n" + // Feiertag: kein Wert
"2026-09-10,0\n"; // 0 wäre eine Division durch null
var rates = EcbFxRateSource.ParseCsv(csv);
Assert.Single(rates);
Assert.Equal(new DateTime(2026, 9, 8), rates[0].Date);
}
// ---- Abgleich ------------------------------------------------------
private sealed class FakeSource : IFxRateSource
{
public List<FxRate> Rates = new();
public DateTime? AskedFrom;
public Exception? Throw;
public Task<List<FxRate>> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct)
{
AskedFrom = from;
if (Throw != null) throw Throw;
return Task.FromResult(Rates);
}
}
private sealed class FakeRepo : IFxRateRepository
{
public readonly Dictionary<DateTime, FxRate> Store = new();
public void Upsert(FxRate rate) => Store[rate.Date.Date] = rate;
public List<FxRate> GetAll() => Store.Values.OrderBy(r => r.Date).ToList();
}
private static FxRateSyncService Build(FakeSource src, FakeRepo repo) =>
new(src, repo, new TerminalLogger());
[Fact]
public async Task Erster_Lauf_holt_die_Historie()
{
var src = new FakeSource();
var repo = new FakeRepo();
var today = new DateTime(2026, 9, 10);
await Build(src, repo).SyncAsync(today, CancellationToken.None);
Assert.Equal(today.AddDays(-FxRateSyncService.InitialBackfillDays), src.AskedFrom);
}
[Fact]
public async Task Folgelauf_holt_nur_den_Rand_nach()
{
var src = new FakeSource();
var repo = new FakeRepo();
repo.Upsert(new FxRate { Date = new DateTime(2026, 9, 1), UsdToEur = 0.86m, Source = "ECB" });
await Build(src, repo).SyncAsync(new DateTime(2026, 9, 10), CancellationToken.None);
Assert.Equal(new DateTime(2026, 9, 1).AddDays(-FxRateSyncService.IncrementalLookbackDays), src.AskedFrom);
}
[Fact]
public async Task Neue_Kurse_werden_uebernommen_bekannte_nicht_erneut()
{
var src = new FakeSource();
var repo = new FakeRepo();
repo.Upsert(new FxRate { Date = new DateTime(2026, 9, 8), UsdToEur = 0.861030m, Source = "ECB" });
src.Rates = new List<FxRate>
{
new() { Date = new DateTime(2026, 9, 8), UsdToEur = 0.861030m, Source = "ECB" }, // unverändert
new() { Date = new DateTime(2026, 9, 9), UsdToEur = 0.858221m, Source = "ECB" } // neu
};
int written = await Build(src, repo).SyncAsync(new DateTime(2026, 9, 10), CancellationToken.None);
Assert.Equal(1, written);
Assert.Equal(2, repo.Store.Count);
}
[Fact]
public async Task Abruffehler_laesst_den_Bestand_unangetastet()
{
var src = new FakeSource { Throw = new InvalidOperationException("EZB nicht erreichbar") };
var repo = new FakeRepo();
repo.Upsert(new FxRate { Date = new DateTime(2026, 9, 1), UsdToEur = 0.86m, Source = "ECB" });
int written = await Build(src, repo).SyncAsync(new DateTime(2026, 9, 10), CancellationToken.None);
Assert.Equal(0, written);
Assert.Single(repo.Store); // der alte Kurs gilt weiter
}
/// <summary>
/// Der manuelle Anstoss aus dem WebUI und die stuendliche Schleife trafen sich im Testlauf auf
/// der VM in derselben Sekunde — im Log standen „280 Kurse uebernommen" und „53 Kurse
/// uebernommen" untereinander. Beide hatten die EZB gefragt.
/// </summary>
[Fact]
public async Task Gleichzeitige_Anstoesse_fragen_die_EZB_nur_einmal()
{
var repo = new FakeRepo();
var src = new CountingSource();
var svc = new FxRateSyncService(src, repo, new TerminalLogger());
await Task.WhenAll(Enumerable.Range(0, 8)
.Select(_ => svc.SyncDailyAsync(force: false, CancellationToken.None)));
Assert.Equal(1, src.Calls);
}
/// <summary>Quelle, die zaehlt und lange genug haelt, dass Aufrufe sich ueberlappen koennen.</summary>
private sealed class CountingSource : IFxRateSource
{
private int _calls;
public int Calls => _calls;
public async Task<List<FxRate>> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct)
{
Interlocked.Increment(ref _calls);
await Task.Delay(50, ct);
return new List<FxRate>
{
new() { Date = new DateTime(2026, 9, 9), UsdToEur = 0.858221m, Source = "ECB" }
};
}
}
[Fact]
public async Task SyncDaily_laeuft_hoechstens_einmal_je_Tag()
{
var src = new FakeSource();
var repo = new FakeRepo();
var svc = Build(src, repo);
await svc.SyncDailyAsync(force: false, CancellationToken.None);
src.AskedFrom = null;
await svc.SyncDailyAsync(force: false, CancellationToken.None);
Assert.Null(src.AskedFrom); // zweiter Aufruf hat nichts geholt
}
}
}