EZB-Tageskurse selbst abrufen, Kopfleiste umbrechen lassen
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:
@@ -37,6 +37,13 @@ namespace PolyTrader.Modules.Accounting
|
||||
// A-2: neutrale Periodenabrechnung/BWA + Währungs-View (USDC/USD/EUR).
|
||||
services.AddSingleton<Services.AccountingReportService>();
|
||||
|
||||
// EZB-Tageskurse (#533): Ohne sie ist EUR nicht darstellbar. Der Abruf haengt an einem
|
||||
// eigenen HttpClient mit knappem Zeitlimit — eine haengende Kursabfrage darf den
|
||||
// stuendlichen Ingest-Lauf nicht aufhalten.
|
||||
services.AddSingleton<IFxRateSource>(_ => new EcbFxRateSource(
|
||||
new System.Net.Http.HttpClient { Timeout = System.TimeSpan.FromSeconds(30) }));
|
||||
services.AddSingleton<FxRateSyncService>();
|
||||
|
||||
// Ingest-Quellen: offline Null-Stubs (das Modul läuft ohne Live-Anbindung und bucht korrekt
|
||||
// nichts). Im Zielland werden die echten Quellen (Polymarket /activity, Alchemy-Transfers,
|
||||
// GetUsdcBalanceAsync) + die System-Contract-Whitelist registriert. Muster wie RF.
|
||||
|
||||
@@ -39,11 +39,12 @@ namespace PolyTrader.Modules.Accounting.Services
|
||||
private readonly IBalanceAnchorSource _balance;
|
||||
private readonly AccountingSystemContracts _contracts;
|
||||
private readonly TerminalLogger _logger;
|
||||
private readonly FxRateSyncService _fxSync;
|
||||
|
||||
public AccountingIngestService(
|
||||
TradingState state, ILedgerRepository ledger, IIngestRunRepository runs, IRawSnapshotRepository raw,
|
||||
IActivitySource activity, ITransferSource transfers, IBalanceAnchorSource balance,
|
||||
AccountingSystemContracts contracts, TerminalLogger logger)
|
||||
AccountingSystemContracts contracts, TerminalLogger logger, FxRateSyncService fxSync)
|
||||
{
|
||||
_state = state;
|
||||
_ledger = ledger;
|
||||
@@ -54,6 +55,7 @@ namespace PolyTrader.Modules.Accounting.Services
|
||||
_balance = balance;
|
||||
_contracts = contracts;
|
||||
_logger = logger;
|
||||
_fxSync = fxSync;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -75,6 +77,11 @@ namespace PolyTrader.Modules.Accounting.Services
|
||||
/// <summary>Ein Durchlauf über alle Live-Accounts (mit gesetzter WalletAddress).</summary>
|
||||
public async Task IngestAllAsync(bool backfill, CancellationToken ct)
|
||||
{
|
||||
// Zuerst die EZB-Tageskurse (#533): Sie gelten fuer alle Konten und werden auch dann
|
||||
// gebraucht, wenn kein einziger Live-Account eingerichtet ist. Ein Fehlschlag beim Abruf
|
||||
// ist protokolliert und haelt den Ingest nicht auf.
|
||||
await _fxSync.SyncDailyAsync(force: backfill, ct);
|
||||
|
||||
var liveAccounts = _state.Accounts.Values
|
||||
.Where(a => !a.IsDemo && !string.IsNullOrWhiteSpace(a.WalletAddress))
|
||||
.OrderBy(a => a.AccountId)
|
||||
|
||||
@@ -9,8 +9,23 @@ namespace PolyTrader.Modules.Accounting.Services
|
||||
/// <summary>
|
||||
/// Anzeige-Währung einer Abrechnung. USDC/USD sind immer verfügbar (USD über die dokumentierte
|
||||
/// 1:1-Annahme), EUR nur, wenn ein amtlicher Tageskurs geladen ist.
|
||||
///
|
||||
/// <para><paramref name="Warning"/> ist gesetzt, wenn der verwendete Kurs zwar gültig, aber
|
||||
/// veraltet ist (#533). Der Wert ist dann weiterhin benutzbar — nur eben mit Vorbehalt, und der
|
||||
/// gehört auf den Beleg.</para>
|
||||
/// </summary>
|
||||
public sealed record CurrencyContext(string Code, decimal Factor, bool Available, string Note);
|
||||
public sealed record CurrencyContext(
|
||||
string Code,
|
||||
decimal Factor,
|
||||
bool Available,
|
||||
string Note,
|
||||
DateTime? RateDate = null,
|
||||
int RateAgeDays = 0,
|
||||
string? Warning = null)
|
||||
{
|
||||
/// <summary>Der Kurs ist älter als erlaubt — Zahlen stimmen nur noch ungefähr.</summary>
|
||||
public bool IsStale => Warning != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report-Service (A-2): baut neutrale Periodenabrechnungen aus dem Ledger und stellt die
|
||||
@@ -25,6 +40,13 @@ namespace PolyTrader.Modules.Accounting.Services
|
||||
/// <summary>USDC→USD-Faktor (dokumentierte 1:1-Annahme; Zielland ggf. über Settings umstellbar).</summary>
|
||||
public decimal UsdcToUsd { get; set; } = FxConverter.DefaultUsdcToUsd;
|
||||
|
||||
/// <summary>
|
||||
/// Ab welchem Alter ein EZB-Kurs als veraltet gilt (#533). Drei Tage, weil genau so groß die
|
||||
/// planmäßige Lücke über ein Wochenende ist: Der Freitagskurs gilt bis Montag, und das ist
|
||||
/// keine Störung, sondern der Normalfall. Erst darüber hinaus fehlt tatsächlich etwas.
|
||||
/// </summary>
|
||||
public const int MaxRateAgeDays = 3;
|
||||
|
||||
public AccountingReportService(ILedgerRepository ledger, IFxRateRepository fx)
|
||||
{
|
||||
_ledger = ledger;
|
||||
@@ -45,11 +67,26 @@ namespace PolyTrader.Modules.Accounting.Services
|
||||
case "USD":
|
||||
return new CurrencyContext("USD", UsdcToUsd, true, $"USDC≈USD (1:1-Annahme, Faktor {UsdcToUsd}).");
|
||||
case "EUR":
|
||||
var rate = _fx != null ? FxConverter.NearestOnOrBefore(_fx.GetAll(), asOf) : null;
|
||||
List<FxRate> rates;
|
||||
try { rates = _fx != null ? _fx.GetAll() : new List<FxRate>(); }
|
||||
catch { rates = new List<FxRate>(); }
|
||||
|
||||
var rate = FxConverter.NearestOnOrBefore(rates, asOf);
|
||||
if (rate == null)
|
||||
return new CurrencyContext("EUR", 0m, false, "Keine EZB-Tageskurse geladen – EUR nicht verfügbar.");
|
||||
return new CurrencyContext("EUR", UsdcToUsd * rate.UsdToEur, true,
|
||||
$"USD→EUR {rate.UsdToEur} ({rate.Date:yyyy-MM-dd}, {rate.Source}); USDC≈USD 1:1.");
|
||||
return new CurrencyContext("EUR", 0m, false,
|
||||
"Keine EZB-Tageskurse geladen – EUR nicht verfügbar.");
|
||||
|
||||
int ageDays = (int)Math.Round((asOf.Date - rate.Date.Date).TotalDays);
|
||||
string note = $"USD→EUR {rate.UsdToEur} ({rate.Date:yyyy-MM-dd}, {rate.Source}); USDC≈USD 1:1.";
|
||||
|
||||
// Freitag → Montag sind drei Tage; erst darüber hinaus fehlt wirklich etwas.
|
||||
string? warning = ageDays > MaxRateAgeDays
|
||||
? $"Der verwendete EZB-Kurs ist vom {rate.Date:dd.MM.yyyy} und damit {ageDays} Tage " +
|
||||
$"alt (Stichtag {asOf.Date:dd.MM.yyyy}). Die EUR-Beträge sind nur Näherungen."
|
||||
: null;
|
||||
|
||||
return new CurrencyContext("EUR", UsdcToUsd * rate.UsdToEur, true, note,
|
||||
rate.Date.Date, ageDays, warning);
|
||||
default:
|
||||
return new CurrencyContext("USDC", 1.0m, true, "Native Buchungswährung.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PolyTrader.Modules.Accounting.Models;
|
||||
|
||||
namespace PolyTrader.Modules.Accounting.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Quelle amtlicher USD→EUR-Tageskurse. Hinter einem Interface, damit das Modul offline läuft
|
||||
/// (Null-Quelle) und der Abruf testbar bleibt — dasselbe Muster wie bei den Ingest-Quellen.
|
||||
/// </summary>
|
||||
public interface IFxRateSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Kurse im Zeitraum [from, to]. Wochenenden und Feiertage fehlen darin — die EZB stellt an
|
||||
/// diesen Tagen keinen Referenzkurs fest. Leere Liste, wenn nichts abrufbar war.
|
||||
/// </summary>
|
||||
Task<List<FxRate>> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Offline-Vorgabe: ruft nichts ab. Das Modul bucht dann korrekt keine Kurse.</summary>
|
||||
public sealed class NullFxRateSource : IFxRateSource
|
||||
{
|
||||
public Task<List<FxRate>> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct) =>
|
||||
Task.FromResult(new List<FxRate>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holt die EZB-Referenzkurse über die offizielle Daten-API (SDMX 2.1, CSV-Ausgabe).
|
||||
///
|
||||
/// <para><b>Die Reihe liefert USD je EUR, nicht EUR je USD.</b> OBS_VALUE = 1,1652 heißt
|
||||
/// „1 EUR = 1,1652 USD". Gespeichert wird der Kehrwert, weil <see cref="FxRate.UsdToEur"/> die
|
||||
/// Richtung USD→EUR meint — das Ledger führt USDC, gerechnet wird nach EUR. Wer das verwechselt,
|
||||
/// bekommt eine Abrechnung, die um rund 35 % danebenliegt und trotzdem plausibel aussieht.</para>
|
||||
///
|
||||
/// Doku: https://data.ecb.europa.eu/help/api/data-examples
|
||||
/// </summary>
|
||||
public sealed class EcbFxRateSource : IFxRateSource
|
||||
{
|
||||
/// <summary>Tageskurs (D) USD gegen EUR, Referenzkurs (SP00), Durchschnitt (A).</summary>
|
||||
public const string SeriesKey = "EXR/D.USD.EUR.SP00.A";
|
||||
|
||||
public const string BaseUrl = "https://data-api.ecb.europa.eu/service/data/";
|
||||
|
||||
public const string SourceName = "ECB";
|
||||
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public EcbFxRateSource(HttpClient http)
|
||||
{
|
||||
_http = http;
|
||||
}
|
||||
|
||||
public async Task<List<FxRate>> GetRatesAsync(DateTime from, DateTime to, CancellationToken ct)
|
||||
{
|
||||
if (to.Date < from.Date)
|
||||
return new List<FxRate>();
|
||||
|
||||
string url = $"{BaseUrl}{SeriesKey}?format=csvdata" +
|
||||
$"&startPeriod={from:yyyy-MM-dd}&endPeriod={to:yyyy-MM-dd}";
|
||||
|
||||
using var response = await _http.GetAsync(url, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
string csv = await response.Content.ReadAsStringAsync(ct);
|
||||
return ParseCsv(csv);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wandelt die CSV-Antwort in Kurse. Öffentlich und seiteneffektfrei, damit das Format ohne
|
||||
/// Netzzugriff geprüft werden kann.
|
||||
/// </summary>
|
||||
public static List<FxRate> ParseCsv(string csv)
|
||||
{
|
||||
var result = new List<FxRate>();
|
||||
if (string.IsNullOrWhiteSpace(csv))
|
||||
return result;
|
||||
|
||||
var lines = csv.Split('\n');
|
||||
if (lines.Length < 2)
|
||||
return result;
|
||||
|
||||
var header = SplitCsvLine(lines[0]);
|
||||
int idxDate = Array.IndexOf(header, "TIME_PERIOD");
|
||||
int idxValue = Array.IndexOf(header, "OBS_VALUE");
|
||||
if (idxDate < 0 || idxValue < 0)
|
||||
return result;
|
||||
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lines[i])) continue;
|
||||
var cells = SplitCsvLine(lines[i]);
|
||||
if (cells.Length <= Math.Max(idxDate, idxValue)) continue;
|
||||
|
||||
if (!DateTime.TryParseExact(cells[idxDate].Trim(), "yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
|
||||
continue;
|
||||
|
||||
if (!decimal.TryParse(cells[idxValue].Trim(), NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out var usdPerEur))
|
||||
continue;
|
||||
|
||||
// Fehlende Beobachtung (Feiertag) kommt als leerer Wert; 0 wäre eine Division durch null.
|
||||
if (usdPerEur <= 0m) continue;
|
||||
|
||||
result.Add(new FxRate
|
||||
{
|
||||
Date = date.Date,
|
||||
UsdToEur = Math.Round(1m / usdPerEur, 6, MidpointRounding.AwayFromZero),
|
||||
Source = SourceName
|
||||
});
|
||||
}
|
||||
|
||||
return result.OrderBy(r => r.Date).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zerlegt eine CSV-Zeile und achtet dabei auf Anführungszeichen: Die EZB liefert in
|
||||
/// TITLE_COMPL einen Text mit Komma darin.
|
||||
/// </summary>
|
||||
internal static string[] SplitCsvLine(string line)
|
||||
{
|
||||
var cells = new List<string>();
|
||||
var current = new System.Text.StringBuilder();
|
||||
bool inQuotes = false;
|
||||
|
||||
foreach (char c in line.TrimEnd('\r'))
|
||||
{
|
||||
if (c == '"') { inQuotes = !inQuotes; continue; }
|
||||
if (c == ',' && !inQuotes) { cells.Add(current.ToString()); current.Clear(); continue; }
|
||||
current.Append(c);
|
||||
}
|
||||
|
||||
cells.Add(current.ToString());
|
||||
return cells.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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 PolyTraderSharp.Services;
|
||||
|
||||
namespace PolyTrader.Modules.Accounting.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Hält die amtlichen USD→EUR-Tageskurse in acc_fx_rates aktuell (#533).
|
||||
///
|
||||
/// <para>Die Kurse werden <b>gespeichert, nicht bei Bedarf geholt</b>: Eine Abrechnung für einen
|
||||
/// vergangenen Monat muss den Kurs von damals verwenden und beim nächsten Aufruf dasselbe Ergebnis
|
||||
/// liefern. Ein Abruf zur Anzeigezeit würde rückwirkend andere Zahlen erzeugen — für einen
|
||||
/// Buchhaltungsbeleg ist das unbrauchbar.</para>
|
||||
///
|
||||
/// <para>Angestoßen wird der Abgleich beim ersten Ingest-Lauf des Tages
|
||||
/// (<see cref="AccountingIngestService"/>); weitere Läufe desselben Tages tun nichts.</para>
|
||||
/// </summary>
|
||||
public sealed class FxRateSyncService
|
||||
{
|
||||
/// <summary>
|
||||
/// Wie weit beim allerersten Lauf zurück geholt wird, wenn noch kein einziger Kurs vorliegt.
|
||||
/// Deckt die Historie ab, für die es überhaupt Buchungen geben kann.
|
||||
/// </summary>
|
||||
public const int InitialBackfillDays = 400;
|
||||
|
||||
/// <summary>
|
||||
/// Überlappung beim laufenden Abgleich: Die EZB stellt den Kurs erst nachmittags fest, und
|
||||
/// nach einem langen Wochenende fehlen mehrere Tage. Lieber ein paar Tage doppelt abrufen —
|
||||
/// der Upsert ist idempotent.
|
||||
/// </summary>
|
||||
public const int IncrementalLookbackDays = 10;
|
||||
|
||||
private readonly IFxRateSource _source;
|
||||
private readonly IFxRateRepository _repo;
|
||||
private readonly TerminalLogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Der manuelle Anstoss ueber das WebUI und die stuendliche Schleife koennen sich treffen.
|
||||
/// Ohne Sperre laufen dann zwei Abgleiche gleichzeitig: Beide lesen den Bestand, bevor einer
|
||||
/// geschrieben hat, beide halten dieselben Kurse fuer neu und beide fragen die EZB. Genau das
|
||||
/// war im Testlauf zu sehen — „280 Kurse uebernommen" und „53 Kurse uebernommen" in derselben
|
||||
/// Sekunde.
|
||||
/// </summary>
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private DateTime _lastSyncedDay = DateTime.MinValue;
|
||||
|
||||
public FxRateSyncService(IFxRateSource source, IFxRateRepository repo, TerminalLogger logger)
|
||||
{
|
||||
_source = source;
|
||||
_repo = repo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gleicht höchstens einmal je Kalendertag ab. <paramref name="force"/> übergeht die Sperre
|
||||
/// (Backfill-Lauf, manueller Anstoß).
|
||||
/// </summary>
|
||||
public async Task<int> SyncDailyAsync(bool force, CancellationToken ct)
|
||||
{
|
||||
DateTime today = DateTime.UtcNow.Date;
|
||||
if (!force && _lastSyncedDay == today)
|
||||
return 0;
|
||||
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// Zweite Pruefung innerhalb der Sperre: Wer hier gewartet hat, waehrend ein anderer
|
||||
// Lauf gerade fertig wurde, hat nichts mehr zu tun.
|
||||
if (!force && _lastSyncedDay == today)
|
||||
return 0;
|
||||
|
||||
int written = await SyncAsync(today, ct);
|
||||
_lastSyncedDay = today;
|
||||
return written;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der testbare Kern: bestimmt den fehlenden Zeitraum, holt ihn und schreibt ihn fort.
|
||||
/// Gibt die Zahl der übernommenen Kurse zurück; Fehler beim Abruf brechen den Ingest nicht ab.
|
||||
/// </summary>
|
||||
public async Task<int> SyncAsync(DateTime today, CancellationToken ct)
|
||||
{
|
||||
List<FxRate> existing;
|
||||
try { existing = _repo.GetAll(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"FX-Abgleich: Kurse nicht lesbar – {ex.Message}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
DateTime from = existing.Count == 0
|
||||
? today.AddDays(-InitialBackfillDays)
|
||||
: existing.Max(r => r.Date).Date.AddDays(-IncrementalLookbackDays);
|
||||
|
||||
if (from > today) from = today;
|
||||
|
||||
List<FxRate> fetched;
|
||||
try
|
||||
{
|
||||
fetched = await _source.GetRatesAsync(from, today, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Kein Netz, EZB nicht erreichbar: Der Bestand bleibt, wie er ist. Die Abrechnung
|
||||
// warnt dann von sich aus über das Alter des zuletzt bekannten Kurses.
|
||||
_logger.Warning($"FX-Abgleich fehlgeschlagen ({ex.Message}). Bestehende Kurse bleiben gültig.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (fetched.Count == 0)
|
||||
return 0;
|
||||
|
||||
var known = existing.ToDictionary(r => r.Date.Date, r => r.UsdToEur);
|
||||
int written = 0;
|
||||
|
||||
foreach (var rate in fetched)
|
||||
{
|
||||
if (known.TryGetValue(rate.Date.Date, out var have) && have == rate.UsdToEur)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
_repo.Upsert(rate);
|
||||
written++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"FX-Abgleich: {rate.Date:yyyy-MM-dd} nicht speicherbar – {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (written > 0)
|
||||
{
|
||||
var newest = fetched.Max(r => r.Date);
|
||||
_logger.Info($"💱 [Accounting] {written} EZB-Tageskurse übernommen (bis {newest:yyyy-MM-dd}).");
|
||||
}
|
||||
|
||||
return written;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,11 @@ namespace PolyTrader.Web.Endpoints
|
||||
code = currCtx.Code,
|
||||
factor = currCtx.Factor,
|
||||
available = currCtx.Available,
|
||||
note = currCtx.Note
|
||||
note = currCtx.Note,
|
||||
rateDate = currCtx.RateDate.HasValue ? currCtx.RateDate.Value.ToString("yyyy-MM-dd") : null,
|
||||
rateAgeDays = currCtx.RateAgeDays,
|
||||
isStale = currCtx.IsStale,
|
||||
warning = currCtx.Warning
|
||||
},
|
||||
statement = new
|
||||
{
|
||||
@@ -114,19 +118,37 @@ namespace PolyTrader.Web.Endpoints
|
||||
string currCode = string.IsNullOrWhiteSpace(currency) ? "USDC" : currency.Trim().ToUpperInvariant();
|
||||
|
||||
var currCtx = reports.ResolveCurrency(currCode, toDate);
|
||||
|
||||
// Ohne gueltigen Kurs ist der Umrechnungsfaktor 0 — jeder Betrag im Beleg waere
|
||||
// dann 0,00, ohne als Fehler erkennbar zu sein. Ein Buchhaltungsbeleg, den man
|
||||
// weiterreichen kann, darf so nicht entstehen (#533).
|
||||
if (!currCtx.Available)
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = $"Waehrung {currCtx.Code} ist nicht verfuegbar: {currCtx.Note} " +
|
||||
"Es wurde kein Dokument erzeugt.",
|
||||
currency = currCtx.Code,
|
||||
note = currCtx.Note
|
||||
});
|
||||
}
|
||||
|
||||
var statement = reports.BuildStatement(accId, fromDate, toDate);
|
||||
var monthly = reports.BuildMonthly(accId, fromDate, toDate);
|
||||
var periodEntries = ledgerRepo.Query(accId, fromDate, toDate, 20000);
|
||||
|
||||
try
|
||||
{
|
||||
// Ein veralteter Kurs sperrt den Beleg nicht — er gehoert nur sichtbar darauf.
|
||||
string note = currCtx.IsStale ? currCtx.Note + " ACHTUNG: " + currCtx.Warning : currCtx.Note;
|
||||
|
||||
byte[] pdfBytes = PdfExporter.Render(
|
||||
statement,
|
||||
monthly,
|
||||
periodEntries,
|
||||
currCtx.Code,
|
||||
currCtx.Factor,
|
||||
currCtx.Note);
|
||||
note);
|
||||
|
||||
string fileName = $"Abrechnung_{currCtx.Code}_{fromDate:yyyyMMdd}_{toDate:yyyyMMdd}.pdf";
|
||||
return Results.File(pdfBytes, "application/pdf", fileName);
|
||||
|
||||
@@ -38,6 +38,7 @@ body {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 16px;
|
||||
min-height: 56px;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -60,14 +61,14 @@ body {
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.header-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
/* Bei 11 Ansichten passt die Leiste unter ~1620 px nicht mehr in eine Zeile.
|
||||
Sie bricht deshalb in eine zweite um, statt seitlich wegzuscrollen: ein
|
||||
Tab, den man nicht sieht, ist ein Tab, den niemand findet (#532). */
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
row-gap: 4px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@@ -1002,3 +1003,21 @@ tr:hover td {
|
||||
|
||||
|
||||
|
||||
|
||||
/* Veralteter EZB-Kurs (#533): Die Zahlen bleiben benutzbar, der Vorbehalt muss aber
|
||||
am Beleg stehen und nicht nur im Kleingedruckten. */
|
||||
.rate-warning {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--warning);
|
||||
background: rgba(234, 179, 8, 0.12);
|
||||
color: var(--warning);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rate-warning::before {
|
||||
content: "⚠️";
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@@ -905,6 +905,7 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accRateWarning" class="rate-warning" hidden></div>
|
||||
<div id="accCurrencyNote" style="margin-top: 10px; font-size: 11.5px; color: var(--muted); font-style: italic;"></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2124,6 +2124,21 @@ async function loadAccountingBwa() {
|
||||
noteEl.textContent = data.currency?.note ? `* ${data.currency.note}` : '';
|
||||
}
|
||||
|
||||
// Veralteter oder fehlender EZB-Kurs (#533)
|
||||
const warnEl = document.getElementById('accRateWarning');
|
||||
if (warnEl) {
|
||||
const c = data.currency || {};
|
||||
if (c.warning) {
|
||||
warnEl.textContent = c.warning;
|
||||
warnEl.hidden = false;
|
||||
} else if (c.available === false) {
|
||||
warnEl.textContent = `${c.note} Umrechnung und Export in ${c.code} sind deshalb gesperrt.`;
|
||||
warnEl.hidden = false;
|
||||
} else {
|
||||
warnEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Monthly Table
|
||||
const tbody = document.getElementById('accMonthlyTbody');
|
||||
if (tbody) {
|
||||
@@ -2158,7 +2173,7 @@ async function loadAccountingBwa() {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function exportAccountingPdf() {
|
||||
async function exportAccountingPdf() {
|
||||
const accId = document.getElementById('accFilterAccount')?.value || '';
|
||||
const fromVal = document.getElementById('accFilterFrom')?.value || '';
|
||||
const toVal = document.getElementById('accFilterTo')?.value || '';
|
||||
@@ -2168,10 +2183,42 @@ function exportAccountingPdf() {
|
||||
if (fromVal) url += `&from=${encodeURIComponent(fromVal)}`;
|
||||
if (toVal) url += `&to=${encodeURIComponent(toVal)}`;
|
||||
|
||||
window.open(url, '_blank');
|
||||
// Nicht mit window.open: Der Endpunkt lehnt eine nicht verfügbare Währung mit 400 ab,
|
||||
// und die nackte JSON-Fehlermeldung in einem neuen Reiter ist keine Antwort (#533).
|
||||
await downloadExport(url, 'PDF');
|
||||
}
|
||||
|
||||
function exportAccountingCsv() {
|
||||
/** Holt einen Export, meldet Fehler im Klartext und stößt sonst den Download an. */
|
||||
async function downloadExport(url, label) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try { msg = (await res.json()).error || msg; } catch (e) { /* kein JSON */ }
|
||||
alert(`${label}-Export nicht möglich:
|
||||
|
||||
${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const name = (res.headers.get('Content-Disposition') || '').match(/filename="?([^";]+)"?/)?.[1]
|
||||
|| `export.${label.toLowerCase()}`;
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
} catch (e) {
|
||||
alert(`${label}-Export fehlgeschlagen: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportAccountingCsv() {
|
||||
const accId = document.getElementById('accFilterAccount')?.value || '';
|
||||
const fromVal = document.getElementById('accFilterFrom')?.value || '';
|
||||
const toVal = document.getElementById('accFilterTo')?.value || '';
|
||||
@@ -2181,7 +2228,7 @@ function exportAccountingCsv() {
|
||||
if (fromVal) url += `&from=${encodeURIComponent(fromVal)}`;
|
||||
if (toVal) url += `&to=${encodeURIComponent(toVal)}`;
|
||||
|
||||
window.open(url, '_blank');
|
||||
downloadExport(url, 'CSV');
|
||||
}
|
||||
|
||||
async function loadAccLedger(page = 1) {
|
||||
|
||||
Reference in New Issue
Block a user