feat(export): CSV export for the bet journal + forward-test ledger
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.
This commit is contained in:
@@ -29,6 +29,7 @@ public static class ApplicationModule
|
||||
services.AddScoped<PullLiveOddsUseCase>();
|
||||
services.AddScoped<PullResultsUseCase>();
|
||||
services.AddScoped<ExportToExcelUseCase>();
|
||||
services.AddScoped<ExportToCsvUseCase>();
|
||||
services.AddScoped<DetectAnomaliesUseCase>();
|
||||
services.AddScoped<EvaluateAnomalyOutcomesUseCase>();
|
||||
services.AddScoped<GetPendingAnomalyNotificationsUseCase>();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Marathon.Application.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal RFC 4180 CSV writer — escapes fields and joins rows with CRLF. Pure and
|
||||
/// allocation-light; used by <see cref="UseCases.ExportToCsvUseCase"/>.
|
||||
/// </summary>
|
||||
public static class Csv
|
||||
{
|
||||
private static readonly char[] MustQuote = { ',', '"', '\r', '\n' };
|
||||
|
||||
/// <summary>Builds a CSV document from a header row plus data rows (CRLF endings).</summary>
|
||||
public static string Document(IReadOnlyList<string> header, IEnumerable<IReadOnlyList<string>> 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<string> fields)
|
||||
{
|
||||
for (var i = 0; i < fields.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
sb.Append(Escape(fields[i]));
|
||||
}
|
||||
sb.Append("\r\n");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quotes a field when it contains a comma, double-quote, CR or LF; inner quotes are
|
||||
/// doubled. Null is treated as empty.
|
||||
/// </summary>
|
||||
public static string Escape(string? field)
|
||||
{
|
||||
var value = field ?? string.Empty;
|
||||
if (value.IndexOfAny(MustQuote) < 0)
|
||||
return value;
|
||||
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="ExportToExcelUseCase"/>'s write-and-return-path
|
||||
/// contract; CSV needs no third-party library so it stays in the Application layer.
|
||||
/// </summary>
|
||||
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<StorageOptions> _storage;
|
||||
private readonly ILogger<ExportToCsvUseCase> _logger;
|
||||
|
||||
public ExportToCsvUseCase(
|
||||
IPlacedBetRepository bets,
|
||||
IPaperBetRepository paperBets,
|
||||
IEventRepository events,
|
||||
IOptions<StorageOptions> storage,
|
||||
ILogger<ExportToCsvUseCase> 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));
|
||||
}
|
||||
|
||||
/// <summary>Writes the bet journal to CSV; returns the path, or null when empty.</summary>
|
||||
public async Task<string?> 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<string>)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);
|
||||
}
|
||||
|
||||
/// <summary>Writes the paper-trading ledger to CSV; returns the path, or null when empty.</summary>
|
||||
public async Task<string?> 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<string>)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<string> 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<IReadOnlyDictionary<DomainEventId, string>> TitlesAsync(
|
||||
IEnumerable<DomainEventId> ids, CancellationToken ct)
|
||||
{
|
||||
var distinct = ids.Distinct().ToList();
|
||||
var events = await _events.GetManyAsync(distinct, ct).ConfigureAwait(false);
|
||||
var titles = new Dictionary<DomainEventId, string>(events.Count);
|
||||
foreach (var (id, ev) in events)
|
||||
titles[id] = ev.Title;
|
||||
return titles;
|
||||
}
|
||||
|
||||
private static string Title(IReadOnlyDictionary<DomainEventId, string> titles, DomainEventId id) =>
|
||||
titles.TryGetValue(id, out var t) ? t : id.Value;
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IDialogService Dialog
|
||||
@inject ISnackbar Snackbar
|
||||
@inject Marathon.Application.UseCases.ExportToCsvUseCase CsvExport
|
||||
@inject ILogger<ExportHub> Logger
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Export.Title"]</PageTitle>
|
||||
|
||||
@@ -30,6 +32,20 @@
|
||||
@L["Export.Hub.FilenameHint"]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="m-rule" />
|
||||
|
||||
<div class="m-rise m-rise-3" style="display: grid; gap: var(--m-space-3);">
|
||||
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">@L["Export.Csv.Section"]</span>
|
||||
<div style="display: flex; gap: var(--m-space-3); flex-wrap: wrap;">
|
||||
<button type="button" class="m-chip" @onclick="ExportJournalCsvAsync" disabled="@_busy" data-test="export-journal-csv">
|
||||
@L["Export.Csv.Journal"]
|
||||
</button>
|
||||
<button type="button" class="m-chip" @onclick="ExportLedgerCsvAsync" disabled="@_busy" data-test="export-ledger-csv">
|
||||
@L["Export.Csv.Ledger"]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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<CancellationToken, Task<string?>> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,11 @@
|
||||
<data name="Export.Hub.Lede"><value>Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first.</value></data>
|
||||
<data name="Export.Hub.Action"><value>Configure export</value></data>
|
||||
<data name="Export.Hub.FilenameHint"><value>Saved as Marathon_<from>_to_<to>.xlsx in the configured export directory.</value></data>
|
||||
<data name="Export.Csv.Section"><value>Journal & ledger (CSV)</value></data>
|
||||
<data name="Export.Csv.Journal"><value>Export bet journal</value></data>
|
||||
<data name="Export.Csv.Ledger"><value>Export forward-test ledger</value></data>
|
||||
<data name="Export.Csv.Empty"><value>Nothing to export yet.</value></data>
|
||||
<data name="Export.Csv.Error"><value>CSV export failed — see the log for details.</value></data>
|
||||
<data name="Health.Kicker"><value>Operations</value></data>
|
||||
<data name="Health.Title"><value>Pipeline health</value></data>
|
||||
<data name="Health.Lede"><value>Capture freshness, recent volumes, and worker status at a glance.</value></data>
|
||||
|
||||
@@ -303,6 +303,11 @@
|
||||
<data name="Export.Hub.Lede"><value>Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие.</value></data>
|
||||
<data name="Export.Hub.Action"><value>Настроить экспорт</value></data>
|
||||
<data name="Export.Hub.FilenameHint"><value>Сохраняется как Marathon_<от>_to_<до>.xlsx в указанной папке экспорта.</value></data>
|
||||
<data name="Export.Csv.Section"><value>Журнал и реестр (CSV)</value></data>
|
||||
<data name="Export.Csv.Journal"><value>Экспорт журнала ставок</value></data>
|
||||
<data name="Export.Csv.Ledger"><value>Экспорт реестра форвард-теста</value></data>
|
||||
<data name="Export.Csv.Empty"><value>Пока нечего экспортировать.</value></data>
|
||||
<data name="Export.Csv.Error"><value>Экспорт CSV не удался — подробности в логе.</value></data>
|
||||
<data name="Health.Kicker"><value>Операции</value></data>
|
||||
<data name="Health.Title"><value>Состояние конвейера</value></data>
|
||||
<data name="Health.Lede"><value>Свежесть сбора, недавние объёмы и статус воркеров на одном экране.</value></data>
|
||||
|
||||
@@ -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<string>)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");
|
||||
}
|
||||
}
|
||||
@@ -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<IPlacedBetRepository>();
|
||||
private readonly IPaperBetRepository _paperBets = Substitute.For<IPaperBetRepository>();
|
||||
private readonly IEventRepository _events = Substitute.For<IEventRepository>();
|
||||
|
||||
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<ExportToCsvUseCase>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportJournalAsync_ReturnsNull_When_NoBets()
|
||||
{
|
||||
_bets.ListAsync(Arg.Any<CancellationToken>()).Returns(Array.Empty<PlacedBet>());
|
||||
|
||||
(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<CancellationToken>()).Returns(new[] { bet });
|
||||
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<EventId, Event>
|
||||
{
|
||||
[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<CancellationToken>()).Returns(Array.Empty<PaperBet>());
|
||||
|
||||
(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<CancellationToken>()).Returns(new[] { paper });
|
||||
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<EventId, Event>());
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user