L1a: Core und Module von WinForms entkoppeln - net10.0 statt net10.0-windows

Core, alle drei Module und das Testprojekt tragen keinen UI-Code mehr und
bauen fuer linux-x64. Nur noch IBKRTrader.App ist Windows-gebunden.

UI-Contract toolkit-neutral (Vorbild: PolytraderSharp):
- ModuleView.CreateForm (Func<Form>) -> CreateView (Func<object>)
- ModuleView.Icon (System.Drawing.Image) -> IconKey (string).
  System.Drawing.Common ist seit .NET 7 Windows-only und wirft auf Linux.
- WindowMenu.cs war reine WinForms-Umsetzung -> in die Shell verschoben.

LoggingService haelt keine RichTextBox mehr, sondern meldet Eintraege ueber
event EntryWritten. Einfaerbung und UI-Thread-Wechsel liegen jetzt im
LogPanelController der Shell. Nebenbei: ToUpper() -> ToUpperInvariant()
(tr-TR haette aus "info" ein "İNFO" gemacht) und \r\n -> Environment.NewLine.

Die drei Modul-Fenster liegen jetzt unter UI/Views/Modules/; RegisterUi der
Module ist bewusst leer, die Shell registriert sie zentral ueber
UI/ModuleViews.cs (nur fuer tatsaechlich geladene Module). ViewIcons loest
IconKey gegen die PNG-Ressourcen auf - dieselben Schluessel bekommt spaeter
die Avalonia-Shell.

UiConstructionTests entfernt: die Konstruktionspruefung deckt --smoke-ui ab,
das Testprojekt braucht dafuer keine UI-Referenz mehr. Der Test
RegisterUi_RegistersMainView haelt jetzt das Gegenteil fest - das Modul darf
nichts registrieren, sonst waere es wieder toolkit-gebunden.

Verifiziert: Build 0 Fehler/0 Warnungen, 163 Tests gruen, --smoke-ui
konstruiert alle 7 Fenster, und Core + 3 Module + Tests bauen fuer linux-x64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 22:38:01 +02:00
co-authored by Claude Opus 5
parent 18b1059fa9
commit 123f38ab6f
22 changed files with 302 additions and 259 deletions
+302
View File
@@ -0,0 +1,302 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Modules.Accounting.Logic;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
namespace IBKRTrader.UI.Views.Modules;
/// <summary>
/// Fenster des Accounting-Moduls: Übersicht/BWA, Ledger, Steuer (Platzhalter), Abrechnung/Export,
/// Abruf/Status. Alle DB-Zugriffe laufen NUR auf Nutzer-Interaktion (nicht im Konstruktor) so
/// konstruiert der Smoke-UI-Check das Fenster auch ohne DB fehlerfrei.
/// </summary>
public sealed class AccountingMainForm : Form
{
private readonly ILedgerRepository _ledger;
private readonly IIngestRunRepository _runs;
private readonly AccountingReportService _report;
private readonly AccountingIngestService _ingest;
private readonly LoggingService _logger;
private readonly DateTimePicker _from = new() { Format = DateTimePickerFormat.Short, Width = 110 };
private readonly DateTimePicker _to = new() { Format = DateTimePickerFormat.Short, Width = 110 };
private readonly ComboBox _account = new() { DropDownStyle = ComboBoxStyle.DropDown, Width = 140 };
private readonly ComboBox _currency = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 80 };
private readonly Label _kpis = new() { AutoSize = true, Location = new Point(12, 8) };
private readonly DataGridView _monthly = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
private readonly DataGridView _ledgerGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
private readonly DataGridView _runsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
private readonly Label _status = new() { AutoSize = true, ForeColor = SystemColors.GrayText, Location = new Point(12, 8) };
public AccountingMainForm(
ILedgerRepository ledger, IIngestRunRepository runs, AccountingReportService report,
AccountingIngestService ingest, LoggingService logger)
{
_ledger = ledger;
_runs = runs;
_report = report;
_ingest = ingest;
_logger = logger;
Text = "Accounting";
Width = 1000;
Height = 680;
StartPosition = FormStartPosition.CenterScreen;
MinimumSize = new Size(760, 480);
_from.Value = DateTime.Today.AddMonths(-1);
_to.Value = DateTime.Today;
_currency.Items.AddRange(new object[] { "USD", "EUR" });
_currency.SelectedIndex = 0;
BuildLayout();
}
private void BuildLayout()
{
var tabs = new TabControl { Dock = DockStyle.Fill };
// ── gemeinsame Filterleiste ──
var filter = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) };
filter.Controls.Add(new Label { Text = "Von", AutoSize = true, Margin = new Padding(0, 8, 4, 0) });
filter.Controls.Add(_from);
filter.Controls.Add(new Label { Text = "Bis", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
filter.Controls.Add(_to);
filter.Controls.Add(new Label { Text = "Konto", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
filter.Controls.Add(_account);
filter.Controls.Add(new Label { Text = "Währung", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
filter.Controls.Add(_currency);
var btnRefresh = new Button { Text = "Aktualisieren", Width = 120, Margin = new Padding(12, 3, 0, 0) };
btnRefresh.Click += (_, _) => RefreshAll();
filter.Controls.Add(btnRefresh);
// ── Tab: Übersicht/BWA ──
var tabOverview = new TabPage("Übersicht / BWA");
_monthly.Top = 90;
var overviewPanel = new Panel { Dock = DockStyle.Fill };
overviewPanel.Controls.Add(_monthly);
var kpiPanel = new Panel { Dock = DockStyle.Top, Height = 84 };
kpiPanel.Controls.Add(_kpis);
overviewPanel.Controls.Add(kpiPanel);
tabOverview.Controls.Add(overviewPanel);
// ── Tab: Ledger ──
var tabLedger = new TabPage("Ledger");
tabLedger.Controls.Add(_ledgerGrid);
// ── Tab: Steuer (Platzhalter) ──
var tabTax = new TabPage("Steuer");
tabTax.Controls.Add(new Label
{
Dock = DockStyle.Fill, Padding = new Padding(16),
Text = "Steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt).\n\n" +
"Der neutrale Ledger und die Periodenabrechnung sind davon unabhängig gültig.\n" +
"Eine konkrete Steuerschicht (z. B. DE-Kapitalertragsteuer oder US Form 8949 / Schedule D)\n" +
"wird hier später als klar dokumentierte, prüfbare Rechenschicht ergänzt.\n\n" +
"Hinweis: Dies ist keine Steuerberatung."
});
// ── Tab: Abrechnung / Export ──
var tabExport = new TabPage("Abrechnung / Export");
var exportPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), FlowDirection = FlowDirection.TopDown };
exportPanel.Controls.Add(new Label { AutoSize = true, Text = "Exportiert die aktuelle Auswahl (Zeitraum / Konto / Währung):" });
var btnCsvLedger = new Button { Text = "Ledger als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
btnCsvLedger.Click += (_, _) => ExportCsvLedger();
var btnCsvStmt = new Button { Text = "Abrechnung als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
btnCsvStmt.Click += (_, _) => ExportCsvStatement();
var btnPdf = new Button { Text = "Abrechnung als PDF…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
btnPdf.Click += (_, _) => ExportPdf();
exportPanel.Controls.Add(btnCsvLedger);
exportPanel.Controls.Add(btnCsvStmt);
exportPanel.Controls.Add(btnPdf);
tabExport.Controls.Add(exportPanel);
// ── Tab: Abruf / Status ──
var tabIngest = new TabPage("Abruf / Status");
var ingestButtons = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) };
var btnIncr = new Button { Text = "Inkrementell abrufen", Width = 160 };
btnIncr.Click += async (_, _) => await RunIngest(backfill: false);
var btnBackfill = new Button { Text = "Backfill (voll)", Width = 140, Margin = new Padding(8, 0, 0, 0) };
btnBackfill.Click += async (_, _) => await RunIngest(backfill: true);
ingestButtons.Controls.Add(btnIncr);
ingestButtons.Controls.Add(btnBackfill);
var statusPanel = new Panel { Dock = DockStyle.Top, Height = 40 };
statusPanel.Controls.Add(_status);
_status.Text = "Offline-Standard: keine Live-Quelle registriert → der Ingest bucht nichts (korrekt).";
tabIngest.Controls.Add(_runsGrid);
tabIngest.Controls.Add(statusPanel);
tabIngest.Controls.Add(ingestButtons);
tabs.TabPages.AddRange(new[] { tabOverview, tabLedger, tabTax, tabExport, tabIngest });
Controls.Add(tabs);
Controls.Add(filter);
}
// ── Daten laden (nur auf Interaktion) ──
private string? SelectedAccount()
{
var text = _account.Text?.Trim();
return string.IsNullOrWhiteSpace(text) || text == "(alle)" ? null : text;
}
private void RefreshAll()
{
try
{
LoadAccounts();
LoadOverview();
LoadLedger();
LoadRuns();
}
catch (Exception ex)
{
_logger.Error("Accounting", $"UI-Refresh fehlgeschlagen: {ex.Message}", ex);
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void LoadAccounts()
{
var current = _account.Text;
_account.Items.Clear();
_account.Items.Add("(alle)");
foreach (var a in _ledger.DistinctAccounts()) _account.Items.Add(a);
_account.Text = string.IsNullOrEmpty(current) ? "(alle)" : current;
}
private void LoadOverview()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
var view = _report.GetCurrencyView(_currency.Text, to);
decimal C(decimal v) => Math.Round(v * view.Factor, 2);
_kpis.Text =
$"Netto-Handelsergebnis: {C(stmt.NetTradingResult):N2} {view.Code} " +
$"Handelsvolumen: {C(stmt.TradeVolume):N2} Dividenden: {C(stmt.Dividends):N2} Fees: {C(stmt.Fees):N2}\n" +
$"Einzahlungen: {C(stmt.Deposits):N2} Auszahlungen: {C(stmt.Withdrawals):N2} " +
$"Endsaldo: {C(stmt.ClosingBalance):N2} Trades: {stmt.TradeCount} Buchungen: {stmt.EntryCount}\n" +
$"{view.Note}";
var monthly = _report.BuildMonthly(SelectedAccount(), from, to)
.Select(m => new
{
Monat = m.From.ToString("yyyy-MM"),
Anfang = C(m.OpeningBalance),
Einzahlungen = C(m.Deposits),
Auszahlungen = C(m.Withdrawals),
Volumen = C(m.TradeVolume),
Fees = C(m.Fees),
Ergebnis = C(m.NetTradingResult),
Endsaldo = C(m.ClosingBalance)
}).ToList();
_monthly.DataSource = monthly;
}
private void LoadLedger()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var rows = _ledger.Query(SelectedAccount(), from, to, 2000)
.Select(e => new
{
Zeit = e.Timestamp, e.AccountId, Typ = e.EventType.ToString(), e.Side, e.Symbol,
e.Currency, e.Quantity, Preis = e.PriceNative, Brutto = e.GrossBase, Fee = e.FeeBase,
Netto = e.NetBase, e.TransactionId
}).ToList();
_ledgerGrid.DataSource = rows;
}
private void LoadRuns()
{
var rows = _runs.GetRecent(SelectedAccount(), 100)
.Select(r => new
{
r.AccountId, Start = r.StartedAt, Ende = r.FinishedAt, r.Backfill,
Neu = r.NewEntries, Duplikate = r.DuplicateEntries, r.Success,
Anker = r.BalanceAnchorBase, LedgerNetto = r.LedgerNetBase, Delta = r.BalanceDeltaBase, r.Message
}).ToList();
_runsGrid.DataSource = rows;
}
// ── Export ──
private void ExportCsvLedger()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var entries = _ledger.Query(SelectedAccount(), from, to, 100000);
SaveText("ledger.csv", "CSV|*.csv", CsvExporter.Ledger(entries));
}
private void ExportCsvStatement()
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
SaveText("abrechnung.csv", "CSV|*.csv", CsvExporter.Statement(stmt));
}
private void ExportPdf()
{
try
{
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
var account = SelectedAccount();
var stmt = _report.BuildStatement(account, from, to);
var monthly = _report.BuildMonthly(account, from, to);
var entries = _ledger.Query(account, from, to, 100000).OrderBy(e => e.Timestamp).ToList();
var view = _report.GetCurrencyView(_currency.Text, to);
byte[] pdf = PdfExporter.Render(stmt, monthly, entries, view.Code, view.Factor, view.Note);
using var dlg = new SaveFileDialog { FileName = "abrechnung.pdf", Filter = "PDF|*.pdf" };
if (dlg.ShowDialog(this) == DialogResult.OK)
{
File.WriteAllBytes(dlg.FileName, pdf);
_logger.Info("Accounting", $"PDF-Abrechnung geschrieben: {dlg.FileName}");
}
}
catch (Exception ex)
{
_logger.Error("Accounting", $"PDF-Export fehlgeschlagen: {ex.Message}", ex);
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void SaveText(string suggested, string filter, string content)
{
try
{
using var dlg = new SaveFileDialog { FileName = suggested, Filter = filter };
if (dlg.ShowDialog(this) == DialogResult.OK)
{
File.WriteAllText(dlg.FileName, content);
_logger.Info("Accounting", $"Export geschrieben: {dlg.FileName}");
}
}
catch (Exception ex)
{
_logger.Error("Accounting", $"Export fehlgeschlagen: {ex.Message}", ex);
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
// ── Ingest ──
private async Task RunIngest(bool backfill)
{
try
{
_status.Text = backfill ? "Backfill läuft…" : "Inkrementeller Abruf läuft…";
await _ingest.IngestAllAsync(backfill, CancellationToken.None);
_status.Text = $"Abruf abgeschlossen ({DateTime.Now:HH:mm:ss}).";
LoadRuns();
}
catch (Exception ex)
{
_status.Text = $"Fehler: {ex.Message}";
_logger.Error("Accounting", $"Manueller Ingest fehlgeschlagen: {ex.Message}", ex);
}
}
}