From 88615a95e9288a89eaa2f176b829081d8857ed28 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 13:44:35 +0300 Subject: [PATCH] feat(export): CSV export for the bet journal + forward-test ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CSV export alongside the Excel snapshot export: two buttons on the Export hub write the bet journal and the paper-trading ledger to UTF-8 (BOM) .csv files in the configured export directory and toast the path — mirroring the Excel use case's write-and-return-path contract. CSV needs no third-party library, so it lives in the Application layer behind a pure RFC-4180 formatter. - Csv formatter (RFC 4180 escaping) + ExportToCsvUseCase (journal + ledger, batched title join, returns null when there's nothing to export); registered; Export hub buttons + en/ru resx. - 11 tests: formatter escaping/Document + use-case empty-state + real-file write to a temp dir. --- src/Marathon.Application/ApplicationModule.cs | 1 + src/Marathon.Application/Export/Csv.cs | 47 +++++++ .../UseCases/ExportToCsvUseCase.cs | 132 ++++++++++++++++++ src/Marathon.UI/Pages/Export/ExportHub.razor | 47 +++++++ .../Resources/SharedResource.en.resx | 5 + .../Resources/SharedResource.ru.resx | 5 + .../Export/CsvTests.cs | 41 ++++++ .../UseCases/ExportToCsvUseCaseTests.cs | 100 +++++++++++++ 8 files changed, 378 insertions(+) create mode 100644 src/Marathon.Application/Export/Csv.cs create mode 100644 src/Marathon.Application/UseCases/ExportToCsvUseCase.cs create mode 100644 tests/Marathon.Application.Tests/Export/CsvTests.cs create mode 100644 tests/Marathon.Application.Tests/UseCases/ExportToCsvUseCaseTests.cs diff --git a/src/Marathon.Application/ApplicationModule.cs b/src/Marathon.Application/ApplicationModule.cs index d6180ee..4506ddf 100644 --- a/src/Marathon.Application/ApplicationModule.cs +++ b/src/Marathon.Application/ApplicationModule.cs @@ -29,6 +29,7 @@ public static class ApplicationModule services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Marathon.Application/Export/Csv.cs b/src/Marathon.Application/Export/Csv.cs new file mode 100644 index 0000000..6733c26 --- /dev/null +++ b/src/Marathon.Application/Export/Csv.cs @@ -0,0 +1,47 @@ +using System.Text; + +namespace Marathon.Application.Export; + +/// +/// Minimal RFC 4180 CSV writer — escapes fields and joins rows with CRLF. Pure and +/// allocation-light; used by . +/// +public static class Csv +{ + private static readonly char[] MustQuote = { ',', '"', '\r', '\n' }; + + /// Builds a CSV document from a header row plus data rows (CRLF endings). + public static string Document(IReadOnlyList header, IEnumerable> rows) + { + ArgumentNullException.ThrowIfNull(header); + ArgumentNullException.ThrowIfNull(rows); + + var sb = new StringBuilder(); + AppendLine(sb, header); + foreach (var row in rows) + AppendLine(sb, row); + return sb.ToString(); + } + + private static void AppendLine(StringBuilder sb, IReadOnlyList fields) + { + for (var i = 0; i < fields.Count; i++) + { + if (i > 0) sb.Append(','); + sb.Append(Escape(fields[i])); + } + sb.Append("\r\n"); + } + + /// + /// Quotes a field when it contains a comma, double-quote, CR or LF; inner quotes are + /// doubled. Null is treated as empty. + /// + public static string Escape(string? field) + { + var value = field ?? string.Empty; + if (value.IndexOfAny(MustQuote) < 0) + return value; + return "\"" + value.Replace("\"", "\"\"") + "\""; + } +} diff --git a/src/Marathon.Application/UseCases/ExportToCsvUseCase.cs b/src/Marathon.Application/UseCases/ExportToCsvUseCase.cs new file mode 100644 index 0000000..97444ff --- /dev/null +++ b/src/Marathon.Application/UseCases/ExportToCsvUseCase.cs @@ -0,0 +1,132 @@ +using System.Globalization; +using System.Text; +using Marathon.Application.Abstractions; +using Marathon.Application.Export; +using Marathon.Application.Storage; +using Marathon.Domain.ValueObjects; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using DomainEventId = Marathon.Domain.ValueObjects.EventId; + +namespace Marathon.Application.UseCases; + +/// +/// Exports the bet journal and the paper-trading (forward-test) ledger to CSV files in +/// the configured export directory, returning each file's path (or null when there is +/// nothing to export). Mirrors 's write-and-return-path +/// contract; CSV needs no third-party library so it stays in the Application layer. +/// +public sealed class ExportToCsvUseCase +{ + // BOM so Excel opens UTF-8 (Cyrillic team names) correctly. + private static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); + + private readonly IPlacedBetRepository _bets; + private readonly IPaperBetRepository _paperBets; + private readonly IEventRepository _events; + private readonly IOptions _storage; + private readonly ILogger _logger; + + public ExportToCsvUseCase( + IPlacedBetRepository bets, + IPaperBetRepository paperBets, + IEventRepository events, + IOptions storage, + ILogger logger) + { + _bets = bets ?? throw new ArgumentNullException(nameof(bets)); + _paperBets = paperBets ?? throw new ArgumentNullException(nameof(paperBets)); + _events = events ?? throw new ArgumentNullException(nameof(events)); + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// Writes the bet journal to CSV; returns the path, or null when empty. + public async Task ExportJournalAsync(CancellationToken ct = default) + { + var bets = await _bets.ListAsync(ct).ConfigureAwait(false); + if (bets.Count == 0) + return null; + + var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false); + + var header = new[] + { + "PlacedAt", "Event", "EventId", "Type", "Side", "Value", "Rate", "Stake", "Outcome", "Profit", "Notes", + }; + var rows = bets + .OrderByDescending(b => b.PlacedAt) + .Select(b => (IReadOnlyList)new[] + { + b.PlacedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture), + Title(titles, b.EventId), + b.EventId.Value, + b.Selection.Type.ToString(), + b.Selection.Side.ToString(), + b.Selection.Value?.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + b.Selection.Rate.Value.ToString(CultureInfo.InvariantCulture), + b.Stake.ToString(CultureInfo.InvariantCulture), + b.Outcome.ToString(), + b.NetProfit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + b.Notes ?? string.Empty, + }); + + return await WriteAsync("journal", Csv.Document(header, rows), ct).ConfigureAwait(false); + } + + /// Writes the paper-trading ledger to CSV; returns the path, or null when empty. + public async Task ExportPaperLedgerAsync(CancellationToken ct = default) + { + var bets = await _paperBets.ListAsync(ct).ConfigureAwait(false); + if (bets.Count == 0) + return null; + + var titles = await TitlesAsync(bets.Select(b => b.EventId), ct).ConfigureAwait(false); + + var header = new[] + { + "OpenedAt", "Event", "EventId", "PickedSide", "Rate", "Stake", "Outcome", "Payout", "SettledAt", + }; + var rows = bets + .OrderByDescending(b => b.OpenedAt) + .Select(b => (IReadOnlyList)new[] + { + b.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture), + Title(titles, b.EventId), + b.EventId.Value, + b.PickedSide.ToString(), + b.Rate.ToString(CultureInfo.InvariantCulture), + b.Stake.ToString(CultureInfo.InvariantCulture), + b.Outcome.ToString(), + b.Payout?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + b.SettledAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? string.Empty, + }); + + return await WriteAsync("forward-test", Csv.Document(header, rows), ct).ConfigureAwait(false); + } + + private async Task WriteAsync(string label, string content, CancellationToken ct) + { + var dir = _storage.Value.ExportDirectory; + Directory.CreateDirectory(dir); + var fileName = $"Marathon_{label}_{MoscowTime.Now:yyyy-MM-dd_HHmmss}.csv"; + var path = Path.Combine(dir, fileName); + await File.WriteAllTextAsync(path, content, Utf8Bom, ct).ConfigureAwait(false); + _logger.LogInformation("ExportToCsvUseCase: wrote {Label} CSV → {Path}", label, path); + return path; + } + + private async Task> TitlesAsync( + IEnumerable ids, CancellationToken ct) + { + var distinct = ids.Distinct().ToList(); + var events = await _events.GetManyAsync(distinct, ct).ConfigureAwait(false); + var titles = new Dictionary(events.Count); + foreach (var (id, ev) in events) + titles[id] = ev.Title; + return titles; + } + + private static string Title(IReadOnlyDictionary titles, DomainEventId id) => + titles.TryGetValue(id, out var t) ? t : id.Value; +} diff --git a/src/Marathon.UI/Pages/Export/ExportHub.razor b/src/Marathon.UI/Pages/Export/ExportHub.razor index 729c0f7..cbf7a9b 100644 --- a/src/Marathon.UI/Pages/Export/ExportHub.razor +++ b/src/Marathon.UI/Pages/Export/ExportHub.razor @@ -8,6 +8,8 @@ @inject IStringLocalizer L @inject IDialogService Dialog @inject ISnackbar Snackbar +@inject Marathon.Application.UseCases.ExportToCsvUseCase CsvExport +@inject ILogger Logger @L["App.Title"] · @L["Export.Title"] @@ -30,6 +32,20 @@ @L["Export.Hub.FilenameHint"] + +
+ +
+ @L["Export.Csv.Section"] +
+ + +
+
@code { @@ -55,4 +71,35 @@ Snackbar.Add(string.Format(CultureInfo.CurrentCulture, L["Export.Success"].Value, path), Severity.Success); } } + + private bool _busy; + + private Task ExportJournalCsvAsync() => RunCsvAsync(CsvExport.ExportJournalAsync); + + private Task ExportLedgerCsvAsync() => RunCsvAsync(CsvExport.ExportPaperLedgerAsync); + + private async Task RunCsvAsync(Func> export) + { + if (_busy) return; + _busy = true; + StateHasChanged(); + try + { + var path = await export(CancellationToken.None); + if (path is null) + Snackbar.Add(L["Export.Csv.Empty"].Value, Severity.Info); + else + Snackbar.Add(string.Format(CultureInfo.CurrentCulture, L["Export.Success"].Value, path), Severity.Success); + } + catch (Exception ex) + { + Logger.LogError(ex, "ExportHub: CSV export failed."); + Snackbar.Add(L["Export.Csv.Error"].Value, Severity.Error); + } + finally + { + _busy = false; + StateHasChanged(); + } + } } diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 8bd722c..2018cbe 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -290,6 +290,11 @@ Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first. Configure export Saved as Marathon_<from>_to_<to>.xlsx in the configured export directory. + Journal & ledger (CSV) + Export bet journal + Export forward-test ledger + Nothing to export yet. + CSV export failed — see the log for details. Operations Pipeline health Capture freshness, recent volumes, and worker status at a glance. diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index 8de9c41..e05d613 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -303,6 +303,11 @@ Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие. Настроить экспорт Сохраняется как Marathon_<от>_to_<до>.xlsx в указанной папке экспорта. + Журнал и реестр (CSV) + Экспорт журнала ставок + Экспорт реестра форвард-теста + Пока нечего экспортировать. + Экспорт CSV не удался — подробности в логе. Операции Состояние конвейера Свежесть сбора, недавние объёмы и статус воркеров на одном экране. diff --git a/tests/Marathon.Application.Tests/Export/CsvTests.cs b/tests/Marathon.Application.Tests/Export/CsvTests.cs new file mode 100644 index 0000000..1d31fac --- /dev/null +++ b/tests/Marathon.Application.Tests/Export/CsvTests.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using Marathon.Application.Export; + +namespace Marathon.Application.Tests.Export; + +public sealed class CsvTests +{ + [Theory] + [InlineData("plain", "plain")] + [InlineData("", "")] + [InlineData("a,b", "\"a,b\"")] + [InlineData("he said \"hi\"", "\"he said \"\"hi\"\"\"")] + [InlineData("line1\nline2", "\"line1\nline2\"")] + public void Escape_QuotesOnlyWhenNeeded(string input, string expected) + { + Csv.Escape(input).Should().Be(expected); + } + + [Fact] + public void Escape_Null_IsEmpty() + { + Csv.Escape(null).Should().BeEmpty(); + } + + [Fact] + public void Document_JoinsHeaderAndRows_WithCrlf_AndEscapes() + { + var csv = Csv.Document( + new[] { "Name", "Note" }, + new[] + { + (IReadOnlyList)new[] { "Kelly", "ok" }, + new[] { "Flat, fixed", "say \"hi\"" }, + }); + + csv.Should().Be( + "Name,Note\r\n" + + "Kelly,ok\r\n" + + "\"Flat, fixed\",\"say \"\"hi\"\"\"\r\n"); + } +} diff --git a/tests/Marathon.Application.Tests/UseCases/ExportToCsvUseCaseTests.cs b/tests/Marathon.Application.Tests/UseCases/ExportToCsvUseCaseTests.cs new file mode 100644 index 0000000..719a431 --- /dev/null +++ b/tests/Marathon.Application.Tests/UseCases/ExportToCsvUseCaseTests.cs @@ -0,0 +1,100 @@ +using FluentAssertions; +using Marathon.Application.Abstractions; +using Marathon.Application.Storage; +using Marathon.Application.UseCases; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Marathon.Application.Tests.UseCases; + +public sealed class ExportToCsvUseCaseTests : IDisposable +{ + private static readonly TimeSpan Msk = TimeSpan.FromHours(3); + private static readonly DateTimeOffset T0 = new(2026, 5, 16, 12, 0, 0, Msk); + + private readonly string _tempDir = + Path.Combine(Path.GetTempPath(), "marathon_csv_" + Guid.NewGuid().ToString("N")); + + private readonly IPlacedBetRepository _bets = Substitute.For(); + private readonly IPaperBetRepository _paperBets = Substitute.For(); + private readonly IEventRepository _events = Substitute.For(); + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + private ExportToCsvUseCase CreateSut() + { + var opts = Options.Create(new StorageOptions + { + DatabasePath = "x.db", + ExportDirectory = _tempDir, + SnapshotRetentionDays = 90, + }); + return new ExportToCsvUseCase(_bets, _paperBets, _events, opts, NullLogger.Instance); + } + + [Fact] + public async Task ExportJournalAsync_ReturnsNull_When_NoBets() + { + _bets.ListAsync(Arg.Any()).Returns(Array.Empty()); + + (await CreateSut().ExportJournalAsync()).Should().BeNull(); + } + + [Fact] + public async Task ExportJournalAsync_WritesFile_WithHeaderAndJoinedTitle() + { + var bet = new PlacedBet( + Guid.NewGuid(), + new EventId("evt-1"), + new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.0m)), + 100m, T0, BetOutcome.Pending, "note"); + _bets.ListAsync(Arg.Any()).Returns(new[] { bet }); + _events.GetManyAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary + { + [new EventId("evt-1")] = + new Event(new EventId("evt-1"), new SportCode(11), "GB", "L", "C", T0.AddDays(1), "Home", "Away"), + }); + + var path = await CreateSut().ExportJournalAsync(); + + path.Should().NotBeNull(); + File.Exists(path!).Should().BeTrue(); + var content = await File.ReadAllTextAsync(path!); + content.Should().StartWith("PlacedAt,Event,EventId"); + content.Should().Contain("Home vs Away"); + content.Should().Contain("evt-1"); + } + + [Fact] + public async Task ExportPaperLedgerAsync_ReturnsNull_When_NoBets() + { + _paperBets.ListAsync(Arg.Any()).Returns(Array.Empty()); + + (await CreateSut().ExportPaperLedgerAsync()).Should().BeNull(); + } + + [Fact] + public async Task ExportPaperLedgerAsync_WritesFile_WithHeader() + { + var paper = PaperBet.Open(Guid.NewGuid(), new EventId("evt-2"), Side.Side2, 1.9m, 10m, T0); + _paperBets.ListAsync(Arg.Any()).Returns(new[] { paper }); + _events.GetManyAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); + + var path = await CreateSut().ExportPaperLedgerAsync(); + + path.Should().NotBeNull(); + var content = await File.ReadAllTextAsync(path!); + content.Should().StartWith("OpenedAt,Event,EventId,PickedSide"); + content.Should().Contain("evt-2"); // title falls back to the raw id + } +}