88615a95e9
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.
133 lines
5.8 KiB
C#
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;
|
|
}
|