Sicherungscommit vor dem Aufraeumen des Repos, damit nachvollziehbar bleibt, welcher Stand vor der Bereinigung galt. Build gruen, 476 Tests gruen. Zwei Straenge, die sich ueber .csproj, Program.cs und appsettings.json ueberschneiden und darum gemeinsam abgelegt werden: Deploymentcenter-Integration (P3c, Plan D-0 bis D-5 code-seitig fertig): - Deploymentcenter.Client 2.5.0 als lokales Paket, Source-Mapping erweitert - DeploymentcenterOptions, DeploymentcenterErrorReporter, LicenseGate/LicenseCli - WatchdogHeartbeatService auf die Deploymentcenter-API umgestellt (version, os, checks, metrics, status stopped) - Security: MasterKeyResolver, SecretRedactor, FilePermissions - Directory.Build.props mit zentraler Version 0.1.0 (Packager-Versionsdisziplin) - deploy/: Packager-Vorlage und systemd-Unit; echte Zugangsdaten bleiben ueber .gitignore aussen vor - setup.json fuer die Erstinstallation - UMSETZUNGSPLAN-Deploymentcenter-Integration.md; ANALYSE-Linux-Portierung.md verweist auf den neuen Plan Einfenster-Shell (UI-Redesign): - ShellWindow + ShellNavModel als Seitenleisten-Shell - WindowMenuBar und WindowMenuModel entfallen - Modul- und Kernfenster auf die Shell-Einbettung angepasst Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
11 KiB
C#
257 lines
11 KiB
C#
using System;
|
||
using System.Collections.ObjectModel;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Avalonia.Controls;
|
||
using Avalonia.Markup.Xaml;
|
||
using PolyTrader.App.Avalonia.ViewModels;
|
||
using PolyTrader.Core.Modularity;
|
||
using PolyTrader.Modules.Accounting.Logic;
|
||
using PolyTrader.Modules.Accounting.Models;
|
||
using PolyTrader.Modules.Accounting.Persistence;
|
||
using PolyTrader.Modules.Accounting.Services;
|
||
using PolyTraderSharp;
|
||
using PolyTraderSharp.Services;
|
||
|
||
namespace PolyTrader.App.Avalonia.Views.Modules
|
||
{
|
||
/// <summary>
|
||
/// Accounting: Periodenabrechnung mit KPI-Kacheln und Monatsübersicht, Ledger-Ansicht und
|
||
/// Abruf-/Status-Tab. Layout vollständig in AccountingWindow.axaml.
|
||
/// </summary>
|
||
public partial class AccountingWindow : UserControl
|
||
{
|
||
private readonly ObservableCollection<MonthlyRow> _monthly = new();
|
||
private readonly ObservableCollection<LedgerEntry> _ledger = new();
|
||
private readonly ObservableCollection<IngestRun> _runs = new();
|
||
|
||
private readonly TradingState _state = null!;
|
||
private readonly AccountingReportService _reports = null!;
|
||
private readonly ILedgerRepository _ledgerRepo = null!;
|
||
private readonly IIngestRunRepository _runRepo = null!;
|
||
private readonly AccountingIngestService _ingest = null!;
|
||
private readonly TerminalLogger _logger = null!;
|
||
|
||
private PeriodStatement? _statement;
|
||
private CurrencyContext _currency = new("USDC", 1m, true, string.Empty);
|
||
|
||
public AccountingWindow() => AvaloniaXamlLoader.Load(this);
|
||
|
||
public AccountingWindow(IModuleUiHost host, TradingState state, AccountingReportService reports,
|
||
ILedgerRepository ledgerRepo, IIngestRunRepository runRepo,
|
||
AccountingIngestService ingest, TerminalLogger logger) : this()
|
||
{
|
||
_state = state;
|
||
_reports = reports;
|
||
_ledgerRepo = ledgerRepo;
|
||
_runRepo = runRepo;
|
||
_ingest = ingest;
|
||
_logger = logger;
|
||
|
||
this.FindControl<DataGrid>("gridMonthly")!.ItemsSource = _monthly;
|
||
this.FindControl<DataGrid>("gridLedger")!.ItemsSource = _ledger;
|
||
this.FindControl<DataGrid>("gridRuns")!.ItemsSource = _runs;
|
||
|
||
var cbCurrency = this.FindControl<ComboBox>("cbCurrency")!;
|
||
cbCurrency.ItemsSource = new[] { "USDC", "USD", "EUR" };
|
||
cbCurrency.SelectedIndex = 0;
|
||
|
||
// Vorbelegung wie bisher: laufender Monat.
|
||
var now = DateTime.Now;
|
||
this.FindControl<CalendarDatePicker>("dtFrom")!.SelectedDate = new DateTime(now.Year, now.Month, 1);
|
||
this.FindControl<CalendarDatePicker>("dtTo")!.SelectedDate = now.Date;
|
||
|
||
FillAccountCombos();
|
||
|
||
this.FindControl<Button>("btnCompute")!.Click += (_, _) => Compute();
|
||
this.FindControl<Button>("btnPdf")!.Click += async (_, _) => await ExportAsync(pdf: true);
|
||
this.FindControl<Button>("btnCsv")!.Click += async (_, _) => await ExportAsync(pdf: false);
|
||
this.FindControl<Button>("btnLedgerRefresh")!.Click += (_, _) => LoadLedger();
|
||
this.FindControl<Button>("btnStatusRefresh")!.Click += (_, _) => LoadRuns();
|
||
this.FindControl<Button>("btnIngestNow")!.Click += async (_, _) => await RunIngestAsync(backfill: false);
|
||
this.FindControl<Button>("btnBackfill")!.Click += async (_, _) => await RunIngestAsync(backfill: true);
|
||
|
||
Compute();
|
||
LoadLedger();
|
||
LoadRuns();
|
||
}
|
||
|
||
private void Status(string text) => this.FindControl<TextBlock>("lblStatus")!.Text = text;
|
||
|
||
private void FillAccountCombos()
|
||
{
|
||
var items = new List<AccountChoice> { new(null, "Alle Konten") };
|
||
items.AddRange(_state.Accounts.Values.OrderBy(a => a.AccountId)
|
||
.Select(a => new AccountChoice(a.AccountId, $"#{a.AccountId} {a.Name}{(a.IsDemo ? " (Demo)" : "")}")));
|
||
|
||
foreach (var name in new[] { "cbAccount", "cbLedgerAccount", "cbStatusAccount" })
|
||
{
|
||
var cb = this.FindControl<ComboBox>(name)!;
|
||
cb.ItemsSource = items.ToList();
|
||
cb.SelectedIndex = 0;
|
||
}
|
||
}
|
||
|
||
private int? AccountIdOf(string comboName) =>
|
||
(this.FindControl<ComboBox>(comboName)!.SelectedItem as AccountChoice)?.Id;
|
||
|
||
// ===== Übersicht / BWA =====
|
||
|
||
private void Compute()
|
||
{
|
||
try
|
||
{
|
||
DateTime from = this.FindControl<CalendarDatePicker>("dtFrom")!.SelectedDate?.Date ?? DateTime.Today;
|
||
DateTime to = this.FindControl<CalendarDatePicker>("dtTo")!.SelectedDate?.Date ?? DateTime.Today;
|
||
int? accountId = AccountIdOf("cbAccount");
|
||
string code = this.FindControl<ComboBox>("cbCurrency")!.SelectedItem as string ?? "USDC";
|
||
|
||
_currency = _reports.ResolveCurrency(code, to);
|
||
_statement = _reports.BuildStatement(accountId, from, to);
|
||
|
||
string unit = _currency.Code;
|
||
decimal V(decimal usdc) => AccountingReportService.Convert(usdc, _currency);
|
||
|
||
this.FindControl<TextBlock>("kpiNet")!.Text = $"{V(_statement.NetTradingResultUsdc):N2} {unit}";
|
||
this.FindControl<TextBlock>("kpiClosing")!.Text = $"{V(_statement.ClosingBalanceUsdc):N2} {unit}";
|
||
this.FindControl<TextBlock>("kpiDeposits")!.Text = $"{V(_statement.Deposits):N2} {unit}";
|
||
this.FindControl<TextBlock>("kpiWithdrawals")!.Text = $"{V(_statement.Withdrawals):N2} {unit}";
|
||
this.FindControl<TextBlock>("kpiFees")!.Text = $"{V(_statement.Fees):N2} {unit}";
|
||
this.FindControl<TextBlock>("kpiRewards")!.Text = $"{V(_statement.Rewards):N2} {unit}";
|
||
this.FindControl<TextBlock>("kpiVolume")!.Text = $"{V(_statement.TradeVolume):N2} {unit}";
|
||
this.FindControl<TextBlock>("kpiTrades")!.Text = _statement.TradeCount.ToString();
|
||
|
||
this.FindControl<TextBlock>("lblCurrencyNote")!.Text = _currency.Note;
|
||
|
||
_monthly.Clear();
|
||
foreach (var m in _reports.BuildMonthly(accountId, from, to))
|
||
_monthly.Add(new MonthlyRow
|
||
{
|
||
Month = m.From.ToString("MM/yyyy"),
|
||
Net = $"{V(m.NetTradingResultUsdc):N2}",
|
||
Deposits = $"{V(m.Deposits):N2}",
|
||
Withdrawals = $"{V(m.Withdrawals):N2}",
|
||
Fees = $"{V(m.Fees):N2}",
|
||
Volume = $"{V(m.TradeVolume):N2}",
|
||
Trades = m.TradeCount,
|
||
Closing = $"{V(m.ClosingBalanceUsdc):N2}"
|
||
});
|
||
|
||
Status($"Abrechnung berechnet ({from:dd.MM.yyyy}–{to:dd.MM.yyyy}, {unit}).");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Status($"Berechnung fehlgeschlagen: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ===== Export =====
|
||
|
||
private async Task ExportAsync(bool pdf)
|
||
{
|
||
if (_statement == null) { Status("Bitte zuerst berechnen."); return; }
|
||
|
||
try
|
||
{
|
||
// Seit der Umstellung auf Seiten ist „this" kein TopLevel mehr – der Dateidialog
|
||
// gehört dem Shell-Fenster und wird über den Baum aufgelöst.
|
||
var storage = TopLevel.GetTopLevel(this)?.StorageProvider;
|
||
if (storage == null) { Status("Kein Fenster für den Dateidialog verfügbar."); return; }
|
||
|
||
var picker = await storage.SaveFilePickerAsync(new global::Avalonia.Platform.Storage.FilePickerSaveOptions
|
||
{
|
||
Title = pdf ? "PDF-Export" : "CSV-Export",
|
||
SuggestedFileName = $"abrechnung_{_statement.From:yyyyMMdd}_{_statement.To:yyyyMMdd}" + (pdf ? ".pdf" : ".csv"),
|
||
DefaultExtension = pdf ? "pdf" : "csv"
|
||
});
|
||
if (picker == null) return;
|
||
string path = picker.Path.LocalPath;
|
||
|
||
int? accountId = AccountIdOf("cbAccount");
|
||
var entries = _ledgerRepo.Query(accountId, _statement.From, _statement.To, 100000);
|
||
|
||
if (pdf)
|
||
{
|
||
byte[] bytes = PdfExporter.Render(_statement,
|
||
_reports.BuildMonthly(accountId, _statement.From, _statement.To),
|
||
entries, _currency.Code, _currency.Factor, _currency.Note);
|
||
await File.WriteAllBytesAsync(path, bytes);
|
||
}
|
||
else
|
||
{
|
||
await File.WriteAllTextAsync(path, CsvExporter.Statement(_statement) + "\n" + CsvExporter.Ledger(entries));
|
||
}
|
||
|
||
_logger.Info($"Accounting-Export geschrieben: {path}");
|
||
Status($"Export gespeichert: {path}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Status($"Export fehlgeschlagen: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ===== Ledger =====
|
||
|
||
private void LoadLedger()
|
||
{
|
||
try
|
||
{
|
||
_ledger.Clear();
|
||
foreach (var e in _ledgerRepo.Query(AccountIdOf("cbLedgerAccount"), null, null, 5000))
|
||
_ledger.Add(e);
|
||
Status($"{_ledger.Count} Ledger-Sätze geladen.");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Status($"Ledger konnte nicht geladen werden: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ===== Abruf / Status =====
|
||
|
||
private void LoadRuns()
|
||
{
|
||
try
|
||
{
|
||
_runs.Clear();
|
||
foreach (var r in _runRepo.GetRecent(AccountIdOf("cbStatusAccount"), 200)) _runs.Add(r);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Status($"Abrufhistorie konnte nicht geladen werden: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private async Task RunIngestAsync(bool backfill)
|
||
{
|
||
var incremental = this.FindControl<Button>("btnIngestNow")!;
|
||
var full = this.FindControl<Button>("btnBackfill")!;
|
||
incremental.IsEnabled = full.IsEnabled = false;
|
||
try
|
||
{
|
||
Status(backfill ? "Backfill läuft …" : "Inkrementeller Abruf läuft …");
|
||
await _ingest.IngestAllAsync(backfill, CancellationToken.None);
|
||
LoadRuns();
|
||
LoadLedger();
|
||
Status($"Abruf abgeschlossen um {DateTime.Now:HH:mm:ss}.");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Status($"Abruf fehlgeschlagen: {ex.Message}");
|
||
}
|
||
finally
|
||
{
|
||
incremental.IsEnabled = full.IsEnabled = true;
|
||
}
|
||
}
|
||
|
||
private sealed record AccountChoice(int? Id, string Label)
|
||
{
|
||
public override string ToString() => Label;
|
||
}
|
||
}
|
||
}
|