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>
303 lines
14 KiB
C#
303 lines
14 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|