L1b/2: Kultur und Dateisystem plattformunabhaengig machen
Kultur - Ausgaben und Parsen haengen nicht mehr am Host:
- PdfExporter formatierte Betraege mit ToString("N2") ohne Formatanbieter,
also CurrentCulture. Auf dem deutschen Desktop "1.234,56", in einem
Container mit LANG=C "1,234.56" - dieselbe Zahl, fuer einen Leser eine
andere. Fuer ein ausdruecklich pruefbares Dokument jetzt fest de-DE.
- CapitolTradesScraper.ParseDate nutzte DateOnly.TryParse ohne
Formatanbieter. Das ist nicht theoretisch: gemessen wurde aus
"2026-08-04" unter th-TH das Jahr 1483 (buddhistischer Kalender), unter
fa-IR das Jahr 2647 (persischer Kalender), unter ar-SA schlug das Parsen
ganz fehl. de-DE und en-US kommen mit ISO klar - genau deshalb faellt so
etwas auf dem Entwicklungsrechner nie auf. Jetzt TryParseExact mit
InvariantCulture; ein Formatwechsel der Quelle faellt damit auf, statt
still ein falsches Datum zu erzeugen. Regressionstest ueber vier Kulturen.
- IBKRGatewayService baute den Query-Parameter mit .ToString().ToLower()
(Tuerkisch-I) - jetzt fest "true"/"false".
PDF-Schriften: PDFsharp 6 loest auf Nicht-Windows-Plattformen nichts von
selbst auf, "Segoe UI" gibt es dort nicht - der Export waere zur Laufzeit
gescheitert. Neuer DocumentFontResolver: unter Windows bleibt die Plattform
zustaendig (unveraenderte Optik), auf Linux wird eine freie Systemschrift
gesucht (DejaVu/Liberation/Noto/FreeSans). Bewusst keine Schrift im Repo -
das erspart eine Lizenzfrage; fehlt sie, nennt die Fehlermeldung das zu
installierende Paket.
BackupWorker:
- Suchte "mysqldump.exe" in C:\Program Files\... und splittete PATH mit ';'.
Auf Linux ist das Trennzeichen ':' - der gesamte PATH waere als ein
Eintrag gelesen worden. Jetzt Path.PathSeparator, plattformabhaengige
Suchpfade und zusaetzlich "mariadb-dump" (MariaDB hat mysqldump ab 10.5
umbenannt).
- Das DB-Passwort stand als Kommandozeilenargument im Prozessbaum. Unter
Linux ist /proc/<pid>/cmdline fuer jeden lokalen Nutzer lesbar - das waere
eine neue Offenlegung gewesen, die es unter Windows so nicht gab. Jetzt
ueber MYSQL_PWD, nur an den Kindprozess vererbt. Argumente einzeln statt
als Zeichenkette (kein Quoting-Problem bei Pfaden mit Leerzeichen).
Verifiziert: 188 Tests gruen (+5), Build 0 Fehler/0 Warnungen, Core + 3
Module + Tests bauen fuer linux-x64, --smoke-ui konstruiert alle 7 Fenster.
Offen aus L1b und nach L2 verschoben: IAppPaths (Logs/Backups/settings.json/
master.key liegen neben der Binaerdatei; unter /opt ist das nicht schreibbar).
Gehoert zum Daemon, wo die Pfade tatsaechlich gebraucht werden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -244,7 +244,7 @@ public class IBKRGatewayService
|
|||||||
var url = $"iserver/marketdata/history?conid={conid}" +
|
var url = $"iserver/marketdata/history?conid={conid}" +
|
||||||
$"&period={Uri.EscapeDataString(period)}" +
|
$"&period={Uri.EscapeDataString(period)}" +
|
||||||
$"&bar={Uri.EscapeDataString(bar)}" +
|
$"&bar={Uri.EscapeDataString(bar)}" +
|
||||||
$"&outsideRth={outsideRth.ToString().ToLower()}";
|
$"&outsideRth={(outsideRth ? "true" : "false")}";
|
||||||
|
|
||||||
var response = await _http.GetAsync(url, ct);
|
var response = await _http.GetAsync(url, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using IBKRTrader.Core.Time;
|
using IBKRTrader.Core.Time;
|
||||||
using IBKRTrader.Core.Persistence.Ef;
|
using IBKRTrader.Core.Persistence.Ef;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -8,9 +9,9 @@ namespace IBKRTrader.Core.Workers.BuiltIn;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// BackupWorker (Core) – läuft alle 30 Minuten (konfigurierbar).
|
/// BackupWorker (Core) – läuft alle 30 Minuten (konfigurierbar).
|
||||||
/// 1. Ruft mysqldump.exe auf → SQL-Dump in Backups\DB\
|
/// 1. Ruft mariadb-dump bzw. mysqldump auf → SQL-Dump nach Backups/DB/
|
||||||
/// 2. Kopiert Logs\ → Backups\Logs\
|
/// 2. Kopiert Logs/ → Backups/Logs/
|
||||||
/// Falls mysqldump nicht gefunden: Warnung und Überspringen.
|
/// Wird das Werkzeug nicht gefunden: Warnung und Überspringen, nie ein harter Fehler.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BackupWorker : WorkerBase
|
public class BackupWorker : WorkerBase
|
||||||
{
|
{
|
||||||
@@ -53,39 +54,50 @@ public class BackupWorker : WorkerBase
|
|||||||
|
|
||||||
private async Task RunMysqlDumpAsync(string dbDir, string timestamp, CancellationToken ct)
|
private async Task RunMysqlDumpAsync(string dbDir, string timestamp, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var dump = FindMysqldump();
|
var dump = FindDumpTool();
|
||||||
if (dump == null)
|
if (dump == null)
|
||||||
{
|
{
|
||||||
Logger.Warn(Module, "mysqldump.exe nicht gefunden – DB-Backup übersprungen.");
|
Logger.Warn(Module, $"{string.Join(" / ", ToolNames())} nicht gefunden – DB-Backup übersprungen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var db = _settings.Settings.Database;
|
var db = _settings.Settings.Database;
|
||||||
var dumpFile = Path.Combine(dbDir, $"{db.Database}_{timestamp}.sql");
|
var dumpFile = Path.Combine(dbDir, $"{db.Database}_{timestamp}.sql");
|
||||||
|
|
||||||
var args = $"--host={db.Host} --port={db.Port} " +
|
|
||||||
$"--user={db.User} --password={db.Password} " +
|
|
||||||
$"--single-transaction --routines --triggers " +
|
|
||||||
$"{db.Database} --result-file=\"{dumpFile}\"";
|
|
||||||
|
|
||||||
var psi = new System.Diagnostics.ProcessStartInfo
|
var psi = new System.Diagnostics.ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = dump,
|
FileName = dump,
|
||||||
Arguments = args,
|
|
||||||
RedirectStandardError = true,
|
RedirectStandardError = true,
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
CreateNoWindow = true
|
CreateNoWindow = true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Argumente einzeln statt als eine Zeichenkette: das erspart das Quoting von Pfaden mit
|
||||||
|
// Leerzeichen und ist auf beiden Plattformen dasselbe.
|
||||||
|
psi.ArgumentList.Add($"--host={db.Host}");
|
||||||
|
psi.ArgumentList.Add($"--port={db.Port.ToString(CultureInfo.InvariantCulture)}");
|
||||||
|
psi.ArgumentList.Add($"--user={db.User}");
|
||||||
|
psi.ArgumentList.Add("--single-transaction");
|
||||||
|
psi.ArgumentList.Add("--routines");
|
||||||
|
psi.ArgumentList.Add("--triggers");
|
||||||
|
psi.ArgumentList.Add(db.Database);
|
||||||
|
psi.ArgumentList.Add($"--result-file={dumpFile}");
|
||||||
|
|
||||||
|
// Passwort über die Umgebung statt über die Kommandozeile. Auf Linux ist
|
||||||
|
// /proc/<pid>/cmdline für JEDEN lokalen Nutzer lesbar – das Passwort hätte dort für die
|
||||||
|
// Dauer des Dumps offen im Prozessbaum gestanden. MYSQL_PWD ist der von MySQL/MariaDB
|
||||||
|
// dafür vorgesehene Weg und wird nur an diesen Kindprozess vererbt.
|
||||||
|
psi.Environment["MYSQL_PWD"] = db.Password;
|
||||||
|
|
||||||
using var proc = System.Diagnostics.Process.Start(psi)
|
using var proc = System.Diagnostics.Process.Start(psi)
|
||||||
?? throw new InvalidOperationException("mysqldump konnte nicht gestartet werden.");
|
?? throw new InvalidOperationException($"{Path.GetFileName(dump)} konnte nicht gestartet werden.");
|
||||||
|
|
||||||
await proc.WaitForExitAsync(ct);
|
await proc.WaitForExitAsync(ct);
|
||||||
|
|
||||||
if (proc.ExitCode != 0)
|
if (proc.ExitCode != 0)
|
||||||
{
|
{
|
||||||
var err = await proc.StandardError.ReadToEndAsync(ct);
|
var err = await proc.StandardError.ReadToEndAsync(ct);
|
||||||
Logger.Warn(Module, $"mysqldump Exitcode {proc.ExitCode}: {err}");
|
Logger.Warn(Module, $"{Path.GetFileName(dump)} Exitcode {proc.ExitCode}: {err}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -93,27 +105,57 @@ public class BackupWorker : WorkerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? FindMysqldump()
|
/// <summary>
|
||||||
|
/// Namen des Dump-Werkzeugs. MariaDB hat <c>mysqldump</c> ab 10.5 in <c>mariadb-dump</c>
|
||||||
|
/// umbenannt und pflegt den alten Namen nur noch als Alias – auf neueren Distributionen ist
|
||||||
|
/// oft nur der neue vorhanden. Die Dateiendung gibt es nur unter Windows.
|
||||||
|
/// </summary>
|
||||||
|
private static string[] ToolNames() =>
|
||||||
|
OperatingSystem.IsWindows()
|
||||||
|
? ["mariadb-dump.exe", "mysqldump.exe"]
|
||||||
|
: ["mariadb-dump", "mysqldump"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sucht das Dump-Werkzeug: erst die üblichen Installationspfade der jeweiligen Plattform,
|
||||||
|
/// dann PATH. Findet es nichts, wird das Backup übersprungen (nie ein harter Fehler).
|
||||||
|
/// </summary>
|
||||||
|
private static string? FindDumpTool()
|
||||||
{
|
{
|
||||||
// Häufige Installationspfade auf Windows-Server
|
var names = ToolNames();
|
||||||
var candidates = new[]
|
|
||||||
{
|
|
||||||
"mysqldump.exe",
|
|
||||||
@"C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqldump.exe",
|
|
||||||
@"C:\Program Files\MySQL\MySQL Server 8.4\bin\mysqldump.exe",
|
|
||||||
@"C:\xampp\mysql\bin\mysqldump.exe"
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var c in candidates)
|
// Übliche Installationspfade – plattformabhängig.
|
||||||
if (File.Exists(c)) return c;
|
string[] dirs = OperatingSystem.IsWindows()
|
||||||
|
?
|
||||||
|
[
|
||||||
|
@"C:\Program Files\MariaDB\bin",
|
||||||
|
@"C:\Program Files\MySQL\MySQL Server 8.0\bin",
|
||||||
|
@"C:\Program Files\MySQL\MySQL Server 8.4\bin",
|
||||||
|
@"C:\xampp\mysql\bin",
|
||||||
|
]
|
||||||
|
:
|
||||||
|
[
|
||||||
|
"/usr/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"/opt/mariadb/bin",
|
||||||
|
"/opt/homebrew/bin",
|
||||||
|
];
|
||||||
|
|
||||||
// PATH-Suche
|
foreach (var name in names)
|
||||||
|
foreach (var dir in dirs)
|
||||||
|
{
|
||||||
|
var full = Path.Combine(dir, name);
|
||||||
|
if (File.Exists(full)) return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATH-Suche. Das Trennzeichen ist plattformabhängig: ';' unter Windows, ':' unter Linux –
|
||||||
|
// ein fest verdrahtetes ';' hätte auf Linux den gesamten PATH als einen Eintrag gelesen.
|
||||||
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
|
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||||
foreach (var dir in pathVar.Split(';'))
|
foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||||
{
|
foreach (var name in names)
|
||||||
var full = Path.Combine(dir.Trim(), "mysqldump.exe");
|
{
|
||||||
if (File.Exists(full)) return full;
|
var full = Path.Combine(dir.Trim(), name);
|
||||||
}
|
if (File.Exists(full)) return full;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using PdfSharp.Fonts;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Modules.Accounting.Logic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schriftauflösung für den PDF-Export.
|
||||||
|
///
|
||||||
|
/// <para><b>Warum das nötig ist:</b> Der Export gab MigraDoc bisher „Segoe UI" vor – eine
|
||||||
|
/// Windows-Schrift. PDFsharp 6 löst Schriften auf Nicht-Windows-Plattformen nicht von selbst auf;
|
||||||
|
/// ohne einen eigenen <see cref="IFontResolver"/> scheitert der Export auf Linux zur Laufzeit.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Vorgehen:</b> Unter Windows übernimmt weiterhin die Plattform (unveränderte Optik) –
|
||||||
|
/// wir geben <c>null</c> zurück und setzen <see cref="GlobalFontSettings.UseWindowsFontsUnderWindows"/>.
|
||||||
|
/// Auf Linux wird eine der üblichen freien Schriften aus dem System genommen. Bewusst <b>keine</b>
|
||||||
|
/// Schrift im Repository: das erspart uns eine Lizenzfrage und hält das Paket klein – die
|
||||||
|
/// Abhängigkeit ist dafür ausdrücklich dokumentiert und die Fehlermeldung nennt das Paket.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DocumentFontResolver : IFontResolver
|
||||||
|
{
|
||||||
|
/// <summary>Logischer Familienname, den der Export verwendet.</summary>
|
||||||
|
public const string FamilyName = "IBKRTrader Sans";
|
||||||
|
|
||||||
|
/// <summary>Windows-Entsprechung – hält die Optik der bisherigen Exporte.</summary>
|
||||||
|
private const string WindowsFamily = "Segoe UI";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Freie Sans-Serif-Schriften in Vorzugsreihenfolge, je (regular, bold). DejaVu ist auf
|
||||||
|
/// praktisch jeder Distribution verfügbar, Liberation und Noto sind die üblichen Alternativen.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly (string Regular, string Bold)[] Candidates =
|
||||||
|
[
|
||||||
|
("DejaVuSans.ttf", "DejaVuSans-Bold.ttf"),
|
||||||
|
("LiberationSans-Regular.ttf", "LiberationSans-Bold.ttf"),
|
||||||
|
("NotoSans-Regular.ttf", "NotoSans-Bold.ttf"),
|
||||||
|
("FreeSans.ttf", "FreeSansBold.ttf"),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] SearchDirs =
|
||||||
|
[
|
||||||
|
"/usr/share/fonts",
|
||||||
|
"/usr/local/share/fonts",
|
||||||
|
"/run/host/usr/share/fonts", // Flatpak/Toolbox
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly ConcurrentDictionary<string, byte[]> Cache = new();
|
||||||
|
private static readonly Lazy<(string Regular, string Bold)?> Found = new(Locate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Richtet die Schriftauflösung einmalig ein. Mehrfachaufrufe sind unschädlich –
|
||||||
|
/// PDFsharp lässt den Resolver nur einmal setzen.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsureConfigured()
|
||||||
|
{
|
||||||
|
if (GlobalFontSettings.FontResolver is not null) return;
|
||||||
|
|
||||||
|
GlobalFontSettings.UseWindowsFontsUnderWindows = true;
|
||||||
|
GlobalFontSettings.FontResolver = new DocumentFontResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
public FontResolverInfo? ResolveTypeface(string familyName, bool bold, bool italic)
|
||||||
|
{
|
||||||
|
// Unter Windows die Plattform machen lassen: null bedeutet „nicht zuständig".
|
||||||
|
if (OperatingSystem.IsWindows()) return null;
|
||||||
|
|
||||||
|
if (Found.Value is not { } files)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Für den PDF-Export wurde keine Schriftart gefunden. Auf dem System muss eine freie " +
|
||||||
|
"Sans-Serif-Schrift installiert sein – unter Debian/Ubuntu z. B. per " +
|
||||||
|
"\"apt-get install fonts-dejavu-core\", unter Alpine \"apk add font-dejavu\". " +
|
||||||
|
$"Gesucht wurde in: {string.Join(", ", SearchDirs)}.");
|
||||||
|
|
||||||
|
// Kursiv wird von PDFsharp simuliert; wir liefern nur normal und fett.
|
||||||
|
var path = bold ? files.Bold : files.Regular;
|
||||||
|
return new FontResolverInfo(path, mustSimulateBold: false, mustSimulateItalic: italic);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[]? GetFont(string faceName) =>
|
||||||
|
Cache.GetOrAdd(faceName, File.ReadAllBytes);
|
||||||
|
|
||||||
|
/// <summary>Sucht das erste vollständige Paar (regular + bold) in den üblichen Verzeichnissen.</summary>
|
||||||
|
private static (string Regular, string Bold)? Locate()
|
||||||
|
{
|
||||||
|
var existing = SearchDirs.Where(Directory.Exists).ToArray();
|
||||||
|
if (existing.Length == 0) return null;
|
||||||
|
|
||||||
|
foreach (var (regular, bold) in Candidates)
|
||||||
|
{
|
||||||
|
var regularPath = FindFile(existing, regular);
|
||||||
|
var boldPath = FindFile(existing, bold);
|
||||||
|
|
||||||
|
// Ohne Fettschnitt bleibt der Bericht lesbar – dann eben beides normal.
|
||||||
|
if (regularPath is not null) return (regularPath, boldPath ?? regularPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindFile(string[] dirs, string fileName)
|
||||||
|
{
|
||||||
|
foreach (var dir in dirs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hit = Directory.EnumerateFiles(dir, fileName, SearchOption.AllDirectories).FirstOrDefault();
|
||||||
|
if (hit is not null) return hit;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException) { /* Verzeichnis nicht lesbar – nächstes */ }
|
||||||
|
catch (DirectoryNotFoundException) { /* zwischenzeitlich weg – nächstes */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Der Familienname, den das Dokument setzen soll.</summary>
|
||||||
|
public static string DocumentFamily => OperatingSystem.IsWindows() ? WindowsFamily : FamilyName;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using IBKRTrader.Core.Time;
|
using IBKRTrader.Core.Time;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -16,6 +17,17 @@ namespace IBKRTrader.Modules.Accounting.Logic;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class PdfExporter
|
public static class PdfExporter
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Zahlenformat des Dokuments – <b>fest deutsch</b>, nicht die Kultur des Hosts.
|
||||||
|
///
|
||||||
|
/// <para>Vorher stand hier <c>ToString("N2")</c> ohne Formatanbieter, also CurrentCulture. Auf
|
||||||
|
/// dem deutschen Windows-Desktop kam "1.234,56" heraus; in einem Linux-Container mit
|
||||||
|
/// <c>LANG=C</c> oder <c>InvariantGlobalization</c> wäre daraus "1,234.56" geworden – dieselbe
|
||||||
|
/// Zahl, für einen Leser aber eine andere. Für ein Dokument, das ausdrücklich als prüfbare
|
||||||
|
/// Aufstellung gedacht ist, darf das Format nicht vom Rechner abhängen, auf dem es entsteht.</para>
|
||||||
|
/// </summary>
|
||||||
|
private static readonly CultureInfo DocumentCulture = CultureInfo.GetCultureInfo("de-DE");
|
||||||
|
|
||||||
public static byte[] Render(
|
public static byte[] Render(
|
||||||
PeriodStatement statement,
|
PeriodStatement statement,
|
||||||
IReadOnlyList<PeriodStatement> monthly,
|
IReadOnlyList<PeriodStatement> monthly,
|
||||||
@@ -23,12 +35,16 @@ public static class PdfExporter
|
|||||||
string currencyCode, decimal currencyFactor, string currencyNote)
|
string currencyCode, decimal currencyFactor, string currencyNote)
|
||||||
{
|
{
|
||||||
decimal V(decimal baseAmount) => Math.Round(baseAmount * currencyFactor, 2, MidpointRounding.AwayFromZero);
|
decimal V(decimal baseAmount) => Math.Round(baseAmount * currencyFactor, 2, MidpointRounding.AwayFromZero);
|
||||||
string M(decimal baseAmount) => V(baseAmount).ToString("N2") + " " + currencyCode;
|
string M(decimal baseAmount) => V(baseAmount).ToString("N2", DocumentCulture) + " " + currencyCode;
|
||||||
|
|
||||||
|
// Schriftauflösung: auf Linux gibt es kein "Segoe UI", und PDFsharp löst dort nichts von
|
||||||
|
// selbst auf – ohne das scheitert der Export zur Laufzeit. Siehe DocumentFontResolver.
|
||||||
|
DocumentFontResolver.EnsureConfigured();
|
||||||
|
|
||||||
var doc = new Document();
|
var doc = new Document();
|
||||||
doc.Info.Title = "Buchhalterische Abrechnung";
|
doc.Info.Title = "Buchhalterische Abrechnung";
|
||||||
var style = doc.Styles["Normal"]!;
|
var style = doc.Styles["Normal"]!;
|
||||||
style.Font.Name = "Segoe UI";
|
style.Font.Name = DocumentFontResolver.DocumentFamily;
|
||||||
style.Font.Size = 9;
|
style.Font.Size = 9;
|
||||||
|
|
||||||
var section = doc.AddSection();
|
var section = doc.AddSection();
|
||||||
@@ -64,8 +80,8 @@ public static class PdfExporter
|
|||||||
AddKeyValue(agg, "Quellensteuer", M(statement.TaxWithheld));
|
AddKeyValue(agg, "Quellensteuer", M(statement.TaxWithheld));
|
||||||
AddKeyValue(agg, "Netto-Handelsergebnis (Cash-Basis)", M(statement.NetTradingResult));
|
AddKeyValue(agg, "Netto-Handelsergebnis (Cash-Basis)", M(statement.NetTradingResult));
|
||||||
AddKeyValue(agg, "Endsaldo", M(statement.ClosingBalance));
|
AddKeyValue(agg, "Endsaldo", M(statement.ClosingBalance));
|
||||||
AddKeyValue(agg, "Anzahl Trades", statement.TradeCount.ToString());
|
AddKeyValue(agg, "Anzahl Trades", statement.TradeCount.ToString(DocumentCulture));
|
||||||
AddKeyValue(agg, "Anzahl Buchungen", statement.EntryCount.ToString());
|
AddKeyValue(agg, "Anzahl Buchungen", statement.EntryCount.ToString(DocumentCulture));
|
||||||
|
|
||||||
// ---- Monatsvergleich ----
|
// ---- Monatsvergleich ----
|
||||||
if (monthly.Count > 1)
|
if (monthly.Count > 1)
|
||||||
@@ -84,7 +100,7 @@ public static class PdfExporter
|
|||||||
HeaderRow(lt, "Zeit (UTC)", "Typ", "Side", "Symbol", "Menge", "Preis", "Netto", "Transaktion");
|
HeaderRow(lt, "Zeit (UTC)", "Typ", "Side", "Symbol", "Menge", "Preis", "Netto", "Transaktion");
|
||||||
foreach (var e in periodEntries.OrderBy(e => e.Timestamp))
|
foreach (var e in periodEntries.OrderBy(e => e.Timestamp))
|
||||||
DataRow(lt, e.Timestamp.ToString("yyyy-MM-dd HH:mm"), e.EventType.ToString(), e.Side,
|
DataRow(lt, e.Timestamp.ToString("yyyy-MM-dd HH:mm"), e.EventType.ToString(), e.Side,
|
||||||
Trim(e.Symbol, 12), e.Quantity.ToString("0.###"), e.PriceNative.ToString("0.###"),
|
Trim(e.Symbol, 12), e.Quantity.ToString("0.###", DocumentCulture), e.PriceNative.ToString("0.###", DocumentCulture),
|
||||||
M(e.NetBase), Trim(e.TransactionId, 22));
|
M(e.NetBase), Trim(e.TransactionId, 22));
|
||||||
|
|
||||||
// ---- Methodik / Nachweis ----
|
// ---- Methodik / Nachweis ----
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
@@ -121,7 +122,7 @@ public class CapitolTradesScraper
|
|||||||
|
|
||||||
if (match.Success)
|
if (match.Success)
|
||||||
{
|
{
|
||||||
var total = int.Parse(match.Groups[1].Value);
|
var total = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||||
_logger.Info("CT", $"GetTotalPagesAsync: {total} Seiten gefunden.");
|
_logger.Info("CT", $"GetTotalPagesAsync: {total} Seiten gefunden.");
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
@@ -326,11 +327,18 @@ public class CapitolTradesScraper
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Datum aus dem RSC-Stream ("2026-08-04T…"). Bewusst <c>TryParseExact</c> mit fester
|
||||||
|
/// Invariant-Kultur: <c>TryParse</c> ohne Formatanbieter hätte die Kultur des Rechners benutzt
|
||||||
|
/// und damit je nach Host anders geraten. Ändert die Quelle ihr Format, soll das <b>auffallen</b>
|
||||||
|
/// (null → Satz wird verworfen) statt still zu einem falschen Datum zu werden.
|
||||||
|
/// </summary>
|
||||||
private static DateOnly? ParseDate(string? s)
|
private static DateOnly? ParseDate(string? s)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(s)) return null;
|
if (string.IsNullOrEmpty(s)) return null;
|
||||||
var datePart = s.Length > 10 ? s[..10] : s;
|
var datePart = s.Length > 10 ? s[..10] : s;
|
||||||
return DateOnly.TryParse(datePart, out var d) ? d : null;
|
return DateOnly.TryParseExact(datePart, "yyyy-MM-dd", CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None, out var d) ? d : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CapFirst(string s)
|
private static string CapFirst(string s)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using IBKRTrader.Core.Logging;
|
using IBKRTrader.Core.Logging;
|
||||||
using IBKRTrader.Modules.CongressTrading.Scraper;
|
using IBKRTrader.Modules.CongressTrading.Scraper;
|
||||||
@@ -44,6 +45,41 @@ public class CapitolTradesScraperTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("de-DE")] // Tag.Monat.Jahr
|
||||||
|
[InlineData("th-TH")] // buddhistischer Kalender
|
||||||
|
[InlineData("fa-IR")] // persischer Kalender
|
||||||
|
[InlineData("ar-SA")] // Hidschri-Kalender
|
||||||
|
[InlineData("")] // Invariant – so verhält sich ein Container ohne LANG
|
||||||
|
public void ParseTradesFromHtml_LiefertDieselbenDaten_UnabhaengigVonDerHostKultur(string culture)
|
||||||
|
{
|
||||||
|
// Die Datumsfelder kamen ueber DateOnly.TryParse OHNE Formatanbieter herein, also mit der
|
||||||
|
// Kultur des Rechners. Bei Kulturen mit eigenem Kalender ist das nicht theoretisch:
|
||||||
|
// aus "2026-08-04" wurde unter th-TH das Jahr 1483 und unter fa-IR das Jahr 2647,
|
||||||
|
// unter ar-SA schlug das Parsen ganz fehl. de-DE und en-US kommen mit ISO klar - genau
|
||||||
|
// deshalb faellt so ein Fehler auf dem Entwicklungsrechner nie auf.
|
||||||
|
var previous = CultureInfo.CurrentCulture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
|
||||||
|
|
||||||
|
var (trades, _) = new CapitolTradesScraper(new LoggingService()).ParseTradesFromHtml(LoadFixture());
|
||||||
|
|
||||||
|
trades.Should().NotBeEmpty();
|
||||||
|
trades.Should().Contain(t => t.TradeDate != null,
|
||||||
|
"die Fixture enthaelt Handelsdaten im Format yyyy-MM-dd");
|
||||||
|
|
||||||
|
// Alle geparsten Daten muessen plausibel sein – ein kulturbedingter Tag/Monat-Dreher
|
||||||
|
// faellt hier auf, weil Tage > 12 sonst gar nicht parsen wuerden.
|
||||||
|
foreach (var t in trades.Where(t => t.TradeDate != null))
|
||||||
|
t.TradeDate!.Value.Year.Should().BeInRange(2000, 2100);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CultureInfo.CurrentCulture = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParseTradesFromHtml_EmptyHtml_ReturnsNoTrades()
|
public void ParseTradesFromHtml_EmptyHtml_ReturnsNoTrades()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user