d9d92ea8fd
- MyBets: add a "Find event" MudAutocomplete over upcoming events (loaded once, filtered client-side) that fills the Event ID; the manual ID field stays as a fallback. Backed by IBetJournalService.GetUpcomingEventOptionsAsync. - Add a "Log bet" CTA on the anomaly detail page that deep-links to /my-bets?eventId=<code>; the journal prefills the Event ID from the query. - Render the new SteamMove anomaly kind with a localized label in the card and detail KindLabel switches (was falling through to the raw enum name). - Localization (en+ru) for all new strings.
118 lines
4.5 KiB
C#
118 lines
4.5 KiB
C#
using System.Globalization;
|
|
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 DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
|
|
|
namespace Marathon.UI.Services;
|
|
|
|
/// <summary>
|
|
/// Page-facing implementation of <see cref="IBetJournalService"/>. Composes the
|
|
/// four bet-journal use cases and joins event titles for the table rows.
|
|
/// </summary>
|
|
public sealed class BetJournalService : IBetJournalService
|
|
{
|
|
private readonly BuildBetJournalReportUseCase _build;
|
|
private readonly RecordPlacedBetUseCase _record;
|
|
private readonly ResolvePendingBetsUseCase _resolve;
|
|
private readonly DeletePlacedBetUseCase _delete;
|
|
private readonly IEventRepository _events;
|
|
|
|
public BetJournalService(
|
|
BuildBetJournalReportUseCase build,
|
|
RecordPlacedBetUseCase record,
|
|
ResolvePendingBetsUseCase resolve,
|
|
DeletePlacedBetUseCase delete,
|
|
IEventRepository events)
|
|
{
|
|
_build = build ?? throw new ArgumentNullException(nameof(build));
|
|
_record = record ?? throw new ArgumentNullException(nameof(record));
|
|
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
|
_delete = delete ?? throw new ArgumentNullException(nameof(delete));
|
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
|
}
|
|
|
|
public async Task<BetJournalVm> GetReportAsync(CancellationToken ct)
|
|
{
|
|
var report = await _build.ExecuteAsync(ct).ConfigureAwait(false);
|
|
|
|
if (report.Bets.Count == 0)
|
|
return new BetJournalVm(report.Stats, Array.Empty<BetJournalRowVm>());
|
|
|
|
// Resolve event titles in one batched query — distinct ids only (was N+1).
|
|
// Missing events (pruned by snapshot retention) fall back to the raw id.
|
|
var distinctIds = report.Bets.Select(r => r.Bet.EventId).Distinct().ToList();
|
|
var events = await _events.GetManyAsync(distinctIds, ct).ConfigureAwait(false);
|
|
var titles = new Dictionary<DomainEventId, string>(distinctIds.Count);
|
|
foreach (var id in distinctIds)
|
|
{
|
|
titles[id] = events.TryGetValue(id, out var ev)
|
|
? ev.Title
|
|
: id.Value;
|
|
}
|
|
|
|
var rows = report.Bets
|
|
.Select(r => new BetJournalRowVm(
|
|
Id: r.Bet.Id,
|
|
EventTitle: titles.TryGetValue(r.Bet.EventId, out var t) ? t : r.Bet.EventId.Value,
|
|
Bet: r.Bet,
|
|
ClvProbabilityDelta: r.ClvProbabilityDelta))
|
|
.ToList();
|
|
|
|
return new BetJournalVm(report.Stats, rows);
|
|
}
|
|
|
|
public async Task<Guid> AddAsync(AddBetForm form, CancellationToken ct)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(form);
|
|
|
|
if (!form.IsValid(out var error))
|
|
throw new ArgumentException(error ?? "Invalid form.", nameof(form));
|
|
|
|
var selection = new Bet(
|
|
scope: MatchScope.Instance,
|
|
type: form.Type,
|
|
side: form.Side,
|
|
value: form.Value is { } v ? new OddsValue(v) : null,
|
|
rate: new OddsRate(form.Rate));
|
|
|
|
var bet = new PlacedBet(
|
|
Id: Guid.NewGuid(),
|
|
EventId: new DomainEventId(form.EventId.Trim()),
|
|
Selection: selection,
|
|
Stake: form.Stake,
|
|
PlacedAt: MoscowTime.Now,
|
|
Outcome: BetOutcome.Pending,
|
|
Notes: form.Notes);
|
|
|
|
var stored = await _record.ExecuteAsync(bet, ct).ConfigureAwait(false);
|
|
return stored.Id;
|
|
}
|
|
|
|
public Task DeleteAsync(Guid betId, CancellationToken ct) =>
|
|
_delete.ExecuteAsync(betId, ct);
|
|
|
|
public Task<int> ResolvePendingAsync(CancellationToken ct) =>
|
|
_resolve.ExecuteAsync(ct);
|
|
|
|
public async Task<IReadOnlyList<EventOption>> GetUpcomingEventOptionsAsync(CancellationToken ct)
|
|
{
|
|
// Generous betting window: recently started through a month out. Loaded once
|
|
// by the page; the autocomplete filters this list in memory per keystroke.
|
|
var now = MoscowTime.Now;
|
|
var range = new DateRange(now.AddDays(-7), now.AddDays(30));
|
|
var events = await _events.ListByDateRangeAsync(range, ct).ConfigureAwait(false);
|
|
|
|
return events
|
|
.OrderBy(e => e.ScheduledAt)
|
|
.Select(e => new EventOption(
|
|
e.Id.Value,
|
|
string.Concat(e.Title, " · ", e.ScheduledAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)),
|
|
e.ScheduledAt))
|
|
.ToList();
|
|
}
|
|
}
|