Files
maraphon-app/src/Marathon.Application/UseCases/ExportToCsvUseCase.cs
T
alexei.dolgolyov 88615a95e9 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.
2026-05-29 13:44:35 +03:00

133 lines
5.8 KiB
C#

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;
}