| | | 1 | | using System.Globalization; |
| | | 2 | | using CsvHelper; |
| | | 3 | | using CsvHelper.Configuration; |
| | | 4 | | using EHonda.KicktippAi.Core; |
| | | 5 | | using Microsoft.Extensions.Logging; |
| | | 6 | | using Orchestrator.Infrastructure; |
| | | 7 | | using Orchestrator.Infrastructure.Factories; |
| | | 8 | | using Spectre.Console; |
| | | 9 | | using Spectre.Console.Cli; |
| | | 10 | | |
| | | 11 | | namespace Orchestrator.Commands.Operations.CollectContext; |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// Command for uploading WM26 lineup context and KPI documents. |
| | | 15 | | /// </summary> |
| | | 16 | | public sealed class CollectContextLineupsCommand : AsyncCommand<CollectContextLineupsSettings> |
| | | 17 | | { |
| | | 18 | | private const string LineupsDocumentName = "lineups"; |
| | | 19 | | private const string LineupsDescription = |
| | | 20 | | "WM26 lineups for all participants, used for the top scorer team bonus question."; |
| | | 21 | | private static readonly IReadOnlyList<string> LineupColumns = |
| | | 22 | | [ |
| | | 23 | | "Team", |
| | | 24 | | "Data_Collected_At", |
| | | 25 | | "Role", |
| | | 26 | | "Name", |
| | | 27 | | "Age", |
| | | 28 | | "Position", |
| | | 29 | | "Market_Value_EUR" |
| | | 30 | | ]; |
| | | 31 | | |
| | | 32 | | private readonly IAnsiConsole _console; |
| | | 33 | | private readonly IFirebaseServiceFactory _firebaseServiceFactory; |
| | | 34 | | private readonly IWm26LineupSource _lineupSource; |
| | | 35 | | private readonly TimeProvider _timeProvider; |
| | | 36 | | private readonly ILogger<CollectContextLineupsCommand> _logger; |
| | | 37 | | |
| | | 38 | | public CollectContextLineupsCommand( |
| | | 39 | | IAnsiConsole console, |
| | | 40 | | IFirebaseServiceFactory firebaseServiceFactory, |
| | | 41 | | IWm26LineupSource lineupSource, |
| | | 42 | | TimeProvider timeProvider, |
| | | 43 | | ILogger<CollectContextLineupsCommand> logger) |
| | | 44 | | { |
| | | 45 | | _console = console; |
| | | 46 | | _firebaseServiceFactory = firebaseServiceFactory; |
| | | 47 | | _lineupSource = lineupSource; |
| | | 48 | | _timeProvider = timeProvider; |
| | | 49 | | _logger = logger; |
| | | 50 | | } |
| | | 51 | | |
| | | 52 | | protected override async Task<int> ExecuteAsync( |
| | | 53 | | CommandContext context, |
| | | 54 | | CollectContextLineupsSettings settings, |
| | | 55 | | CancellationToken cancellationToken) |
| | | 56 | | { |
| | | 57 | | return await ExecuteWithSettingsAsync(settings, cancellationToken); |
| | | 58 | | } |
| | | 59 | | |
| | | 60 | | internal async Task<int> ExecuteWithSettingsAsync( |
| | | 61 | | CollectContextLineupsSettings settings, |
| | | 62 | | CancellationToken cancellationToken = default) |
| | | 63 | | { |
| | | 64 | | try |
| | | 65 | | { |
| | | 66 | | if (string.IsNullOrWhiteSpace(settings.CommunityContext)) |
| | | 67 | | { |
| | | 68 | | _console.MarkupLine("[red]Error: Community context is required[/]"); |
| | | 69 | | return 1; |
| | | 70 | | } |
| | | 71 | | |
| | | 72 | | var communityContext = settings.CommunityContext.Trim(); |
| | | 73 | | var competition = CompetitionResolver.ResolveCompetition(settings.Competition, communityContext, communityCo |
| | | 74 | | var repositoryCompetition = CompetitionResolver.ToRepositoryCompetitionArgument(competition); |
| | | 75 | | |
| | | 76 | | _console.MarkupLine("[green]Collect-context lineups command initialized[/]"); |
| | | 77 | | _console.MarkupLine($"[blue]Using community context:[/] [yellow]{Markup.Escape(communityContext)}[/]"); |
| | | 78 | | _console.MarkupLine($"[blue]Using competition:[/] [yellow]{Markup.Escape(competition)}[/]"); |
| | | 79 | | _console.MarkupLine($"[blue]Using lineup seed:[/] [yellow]{Markup.Escape(settings.Seed)}[/]"); |
| | | 80 | | _console.MarkupLine($"[blue]Using team manifest:[/] [yellow]{Markup.Escape(settings.Teams)}[/]"); |
| | | 81 | | |
| | | 82 | | if (settings.Verbose) |
| | | 83 | | { |
| | | 84 | | _console.MarkupLine("[dim]Verbose mode enabled[/]"); |
| | | 85 | | } |
| | | 86 | | |
| | | 87 | | if (settings.DryRun) |
| | | 88 | | { |
| | | 89 | | _console.MarkupLine("[magenta]Dry run mode enabled - no changes will be made to database[/]"); |
| | | 90 | | } |
| | | 91 | | |
| | | 92 | | var source = await _lineupSource.CollectAsync( |
| | | 93 | | new Wm26LineupSourceRequest(settings.Seed, settings.Teams, settings.DuckDbPath), |
| | | 94 | | cancellationToken); |
| | | 95 | | |
| | | 96 | | _console.MarkupLine($"[blue]Resolved lineup seed:[/] [yellow]{Markup.Escape(source.SeedPath)}[/]"); |
| | | 97 | | _console.MarkupLine($"[blue]Resolved team manifest:[/] [yellow]{Markup.Escape(source.TeamsPath)}[/]"); |
| | | 98 | | _console.MarkupLine($"[blue]Using Transfermarkt DuckDB:[/] [yellow]{Markup.Escape(source.DuckDbPath)}[/]"); |
| | | 99 | | _console.MarkupLine($"[blue]Seed rows:[/] [yellow]{source.SeedRowCount}[/]"); |
| | | 100 | | _console.MarkupLine($"[blue]Generated lineup context documents:[/] [yellow]{source.ContextDocuments.Count}[/ |
| | | 101 | | PrintHeaderOnlyReport(source); |
| | | 102 | | PrintMissingSourceDataReport(source); |
| | | 103 | | |
| | | 104 | | var contextRepository = _firebaseServiceFactory.CreateContextRepository(repositoryCompetition); |
| | | 105 | | var collectionDate = DateOnly.FromDateTime(_timeProvider.GetUtcNow().UtcDateTime); |
| | | 106 | | var freshenedSource = await ApplyFreshnessDatesAsync( |
| | | 107 | | source, |
| | | 108 | | contextRepository, |
| | | 109 | | communityContext, |
| | | 110 | | collectionDate, |
| | | 111 | | cancellationToken); |
| | | 112 | | |
| | | 113 | | if (settings.DryRun) |
| | | 114 | | { |
| | | 115 | | foreach (var document in freshenedSource.ContextDocuments) |
| | | 116 | | { |
| | | 117 | | _console.MarkupLine($"[magenta] Dry run - would save context document:[/] {Markup.Escape(document.D |
| | | 118 | | } |
| | | 119 | | |
| | | 120 | | _console.MarkupLine($"[magenta] Dry run - would save KPI document:[/] {LineupsDocumentName}"); |
| | | 121 | | _console.MarkupLine($"[magenta]✓ Dry run completed - would have processed {freshenedSource.ContextDocume |
| | | 122 | | return 0; |
| | | 123 | | } |
| | | 124 | | |
| | | 125 | | var kpiRepository = _firebaseServiceFactory.CreateKpiRepository(repositoryCompetition); |
| | | 126 | | |
| | | 127 | | var savedContextCount = 0; |
| | | 128 | | var skippedContextCount = 0; |
| | | 129 | | foreach (var document in freshenedSource.ContextDocuments) |
| | | 130 | | { |
| | | 131 | | var savedVersion = await contextRepository.SaveContextDocumentAsync( |
| | | 132 | | document.DocumentName, |
| | | 133 | | document.Content, |
| | | 134 | | communityContext, |
| | | 135 | | cancellationToken); |
| | | 136 | | |
| | | 137 | | if (savedVersion.HasValue) |
| | | 138 | | { |
| | | 139 | | savedContextCount++; |
| | | 140 | | if (settings.Verbose) |
| | | 141 | | { |
| | | 142 | | _console.MarkupLine($"[green] ✓ Saved {Markup.Escape(document.DocumentName)} as version {savedV |
| | | 143 | | } |
| | | 144 | | } |
| | | 145 | | else |
| | | 146 | | { |
| | | 147 | | skippedContextCount++; |
| | | 148 | | if (settings.Verbose) |
| | | 149 | | { |
| | | 150 | | _console.MarkupLine($"[dim] - Skipped {Markup.Escape(document.DocumentName)} (content unchanged |
| | | 151 | | } |
| | | 152 | | } |
| | | 153 | | } |
| | | 154 | | |
| | | 155 | | var existingKpiDocument = await kpiRepository.GetKpiDocumentAsync( |
| | | 156 | | LineupsDocumentName, |
| | | 157 | | communityContext, |
| | | 158 | | cancellationToken); |
| | | 159 | | var savedKpiVersion = await kpiRepository.SaveKpiDocumentAsync( |
| | | 160 | | LineupsDocumentName, |
| | | 161 | | freshenedSource.KpiContent, |
| | | 162 | | LineupsDescription, |
| | | 163 | | communityContext, |
| | | 164 | | cancellationToken); |
| | | 165 | | var kpiChanged = existingKpiDocument is null |
| | | 166 | | || !string.Equals(existingKpiDocument.Content, freshenedSource.KpiContent, StringComparison |
| | | 167 | | |
| | | 168 | | _console.MarkupLine("[green]✓ WM26 lineup context collection completed![/]"); |
| | | 169 | | _console.MarkupLine($"[green] Saved: {savedContextCount} context documents[/]"); |
| | | 170 | | _console.MarkupLine($"[dim] Skipped: {skippedContextCount} context documents (unchanged)[/]"); |
| | | 171 | | _console.MarkupLine(kpiChanged |
| | | 172 | | ? $"[green] KPI document {LineupsDocumentName} saved as version {savedKpiVersion}[/]" |
| | | 173 | | : $"[dim] KPI document {LineupsDocumentName} unchanged at version {savedKpiVersion}[/]"); |
| | | 174 | | |
| | | 175 | | return 0; |
| | | 176 | | } |
| | | 177 | | catch (Exception ex) |
| | | 178 | | { |
| | | 179 | | _logger.LogError(ex, "Error executing collect-context lineups command"); |
| | | 180 | | _console.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}"); |
| | | 181 | | return 1; |
| | | 182 | | } |
| | | 183 | | } |
| | | 184 | | |
| | | 185 | | private static async Task<FreshenedLineupCollection> ApplyFreshnessDatesAsync( |
| | | 186 | | Wm26LineupCollection source, |
| | | 187 | | IContextRepository contextRepository, |
| | | 188 | | string communityContext, |
| | | 189 | | DateOnly collectionDate, |
| | | 190 | | CancellationToken cancellationToken) |
| | | 191 | | { |
| | | 192 | | var documents = new List<Wm26LineupDocument>(); |
| | | 193 | | var aggregateRows = new List<LineupCsvRow>(); |
| | | 194 | | var collectionDateText = collectionDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); |
| | | 195 | | |
| | | 196 | | foreach (var document in source.ContextDocuments) |
| | | 197 | | { |
| | | 198 | | var currentRows = ReadLineupRows(document.DocumentName, document.Content, "Generated lineup context document |
| | | 199 | | var existingDocument = await contextRepository.GetLatestContextDocumentAsync( |
| | | 200 | | document.DocumentName, |
| | | 201 | | communityContext, |
| | | 202 | | cancellationToken); |
| | | 203 | | var adjustedRows = existingDocument is null |
| | | 204 | | ? currentRows |
| | | 205 | | : ApplyExistingFreshnessDates( |
| | | 206 | | currentRows, |
| | | 207 | | ReadLineupRows(document.DocumentName, existingDocument.Content, "Existing lineup context document"), |
| | | 208 | | collectionDateText); |
| | | 209 | | |
| | | 210 | | aggregateRows.AddRange(adjustedRows); |
| | | 211 | | documents.Add(document with { Content = RenderLineupRows(adjustedRows) }); |
| | | 212 | | } |
| | | 213 | | |
| | | 214 | | return new FreshenedLineupCollection(documents, RenderLineupRows(aggregateRows)); |
| | | 215 | | } |
| | | 216 | | |
| | | 217 | | private static IReadOnlyList<LineupCsvRow> ApplyExistingFreshnessDates( |
| | | 218 | | IReadOnlyList<LineupCsvRow> currentRows, |
| | | 219 | | IReadOnlyList<LineupCsvRow> existingRows, |
| | | 220 | | string collectionDate) |
| | | 221 | | { |
| | | 222 | | var existingEntries = existingRows |
| | | 223 | | .Select((row, index) => new ExistingLineupRow(row, index)) |
| | | 224 | | .ToList(); |
| | | 225 | | var existingEntriesByKey = existingEntries |
| | | 226 | | .GroupBy(entry => GetRowKey(entry.Row)) |
| | | 227 | | .ToDictionary( |
| | | 228 | | group => group.Key, |
| | | 229 | | group => new Queue<ExistingLineupRow>(group)); |
| | | 230 | | var matchedExistingIndexes = new HashSet<int>(); |
| | | 231 | | var adjustedRows = new List<LineupCsvRow>(currentRows.Count); |
| | | 232 | | |
| | | 233 | | for (var index = 0; index < currentRows.Count; index++) |
| | | 234 | | { |
| | | 235 | | var currentRow = currentRows[index]; |
| | | 236 | | var existingEntry = FindExistingEntry(currentRow, index); |
| | | 237 | | if (existingEntry is null) |
| | | 238 | | { |
| | | 239 | | adjustedRows.Add(currentRow); |
| | | 240 | | continue; |
| | | 241 | | } |
| | | 242 | | |
| | | 243 | | var existingRow = existingEntry.Value.Row; |
| | | 244 | | adjustedRows.Add(HasNonDateChange(currentRow, existingRow) |
| | | 245 | | ? currentRow with { DataCollectedAt = collectionDate } |
| | | 246 | | : currentRow with { DataCollectedAt = existingRow.DataCollectedAt }); |
| | | 247 | | } |
| | | 248 | | |
| | | 249 | | return adjustedRows; |
| | | 250 | | |
| | | 251 | | static LineupRowKey GetRowKey(LineupCsvRow row) |
| | | 252 | | { |
| | | 253 | | return new LineupRowKey(row.Team, row.Role, row.Name); |
| | | 254 | | } |
| | | 255 | | |
| | | 256 | | ExistingLineupRow? FindExistingEntry(LineupCsvRow currentRow, int index) |
| | | 257 | | { |
| | | 258 | | if (existingEntriesByKey.TryGetValue(GetRowKey(currentRow), out var candidates)) |
| | | 259 | | { |
| | | 260 | | while (candidates.Count > 0) |
| | | 261 | | { |
| | | 262 | | var candidate = candidates.Dequeue(); |
| | | 263 | | if (matchedExistingIndexes.Add(candidate.Index)) |
| | | 264 | | { |
| | | 265 | | return candidate; |
| | | 266 | | } |
| | | 267 | | } |
| | | 268 | | } |
| | | 269 | | |
| | | 270 | | if (currentRows.Count == existingRows.Count |
| | | 271 | | && index < existingRows.Count |
| | | 272 | | && matchedExistingIndexes.Add(index)) |
| | | 273 | | { |
| | | 274 | | var candidateRow = existingRows[index]; |
| | | 275 | | if (string.Equals(currentRow.Team, candidateRow.Team, StringComparison.Ordinal) |
| | | 276 | | && string.Equals(currentRow.Role, candidateRow.Role, StringComparison.Ordinal)) |
| | | 277 | | { |
| | | 278 | | return new ExistingLineupRow(candidateRow, index); |
| | | 279 | | } |
| | | 280 | | |
| | | 281 | | matchedExistingIndexes.Remove(index); |
| | | 282 | | } |
| | | 283 | | |
| | | 284 | | return null; |
| | | 285 | | } |
| | | 286 | | } |
| | | 287 | | |
| | | 288 | | private static bool HasNonDateChange(LineupCsvRow currentRow, LineupCsvRow existingRow) |
| | | 289 | | { |
| | | 290 | | return !string.Equals(currentRow.Team, existingRow.Team, StringComparison.Ordinal) |
| | | 291 | | || !string.Equals(currentRow.Role, existingRow.Role, StringComparison.Ordinal) |
| | | 292 | | || !string.Equals(currentRow.Name, existingRow.Name, StringComparison.Ordinal) |
| | | 293 | | || !string.Equals(currentRow.Age, existingRow.Age, StringComparison.Ordinal) |
| | | 294 | | || !string.Equals(currentRow.Position, existingRow.Position, StringComparison.Ordinal) |
| | | 295 | | || !string.Equals(currentRow.MarketValueEur, existingRow.MarketValueEur, StringComparison.Ordinal); |
| | | 296 | | } |
| | | 297 | | |
| | | 298 | | private static IReadOnlyList<LineupCsvRow> ReadLineupRows( |
| | | 299 | | string documentName, |
| | | 300 | | string content, |
| | | 301 | | string label) |
| | | 302 | | { |
| | | 303 | | try |
| | | 304 | | { |
| | | 305 | | using var reader = new StringReader(content); |
| | | 306 | | using var csv = new CsvReader( |
| | | 307 | | reader, |
| | | 308 | | new CsvConfiguration(CultureInfo.InvariantCulture) |
| | | 309 | | { |
| | | 310 | | BadDataFound = null, |
| | | 311 | | MissingFieldFound = null, |
| | | 312 | | TrimOptions = TrimOptions.Trim |
| | | 313 | | }); |
| | | 314 | | |
| | | 315 | | if (!csv.Read()) |
| | | 316 | | { |
| | | 317 | | throw new InvalidOperationException("missing header row"); |
| | | 318 | | } |
| | | 319 | | |
| | | 320 | | csv.ReadHeader(); |
| | | 321 | | ValidateLineupColumns(csv.HeaderRecord ?? [], label, documentName); |
| | | 322 | | |
| | | 323 | | var rows = new List<LineupCsvRow>(); |
| | | 324 | | while (csv.Read()) |
| | | 325 | | { |
| | | 326 | | var row = new LineupCsvRow( |
| | | 327 | | GetTrimmedField(csv, "Team"), |
| | | 328 | | GetTrimmedField(csv, "Data_Collected_At"), |
| | | 329 | | GetTrimmedField(csv, "Role"), |
| | | 330 | | GetTrimmedField(csv, "Name"), |
| | | 331 | | GetTrimmedField(csv, "Age"), |
| | | 332 | | GetTrimmedField(csv, "Position"), |
| | | 333 | | GetTrimmedField(csv, "Market_Value_EUR")); |
| | | 334 | | ValidateLineupRow(row, csv.Context?.Parser?.Row ?? 0, label, documentName); |
| | | 335 | | rows.Add(row); |
| | | 336 | | } |
| | | 337 | | |
| | | 338 | | return rows; |
| | | 339 | | } |
| | | 340 | | catch (Exception ex) when (ex is CsvHelperException or InvalidOperationException) |
| | | 341 | | { |
| | | 342 | | throw new InvalidOperationException($"{label} {documentName} is malformed: {ex.Message}", ex); |
| | | 343 | | } |
| | | 344 | | } |
| | | 345 | | |
| | | 346 | | private static string RenderLineupRows(IEnumerable<LineupCsvRow> rows) |
| | | 347 | | { |
| | | 348 | | using var writer = new StringWriter(CultureInfo.InvariantCulture); |
| | | 349 | | using var csv = new CsvWriter( |
| | | 350 | | writer, |
| | | 351 | | new CsvConfiguration(CultureInfo.InvariantCulture) |
| | | 352 | | { |
| | | 353 | | NewLine = "\r\n" |
| | | 354 | | }); |
| | | 355 | | |
| | | 356 | | foreach (var column in LineupColumns) |
| | | 357 | | { |
| | | 358 | | csv.WriteField(column); |
| | | 359 | | } |
| | | 360 | | |
| | | 361 | | csv.NextRecord(); |
| | | 362 | | |
| | | 363 | | foreach (var row in rows) |
| | | 364 | | { |
| | | 365 | | csv.WriteField(row.Team); |
| | | 366 | | csv.WriteField(row.DataCollectedAt); |
| | | 367 | | csv.WriteField(row.Role); |
| | | 368 | | csv.WriteField(row.Name); |
| | | 369 | | csv.WriteField(row.Age); |
| | | 370 | | csv.WriteField(row.Position); |
| | | 371 | | csv.WriteField(row.MarketValueEur); |
| | | 372 | | csv.NextRecord(); |
| | | 373 | | } |
| | | 374 | | |
| | | 375 | | return writer.ToString(); |
| | | 376 | | } |
| | | 377 | | |
| | | 378 | | private static void ValidateLineupColumns( |
| | | 379 | | IReadOnlyList<string> headers, |
| | | 380 | | string label, |
| | | 381 | | string documentName) |
| | | 382 | | { |
| | | 383 | | var missing = LineupColumns |
| | | 384 | | .Where(column => !headers.Contains(column, StringComparer.Ordinal)) |
| | | 385 | | .ToList(); |
| | | 386 | | |
| | | 387 | | if (missing.Count > 0) |
| | | 388 | | { |
| | | 389 | | throw new InvalidOperationException( |
| | | 390 | | $"{label} {documentName} is missing required column(s): {string.Join(", ", missing)}"); |
| | | 391 | | } |
| | | 392 | | } |
| | | 393 | | |
| | | 394 | | private static void ValidateLineupRow( |
| | | 395 | | LineupCsvRow row, |
| | | 396 | | int lineNumber, |
| | | 397 | | string label, |
| | | 398 | | string documentName) |
| | | 399 | | { |
| | | 400 | | foreach (var (column, value) in new[] |
| | | 401 | | { |
| | | 402 | | ("Team", row.Team), |
| | | 403 | | ("Data_Collected_At", row.DataCollectedAt), |
| | | 404 | | ("Role", row.Role), |
| | | 405 | | ("Name", row.Name) |
| | | 406 | | }) |
| | | 407 | | { |
| | | 408 | | if (string.IsNullOrWhiteSpace(value)) |
| | | 409 | | { |
| | | 410 | | throw new InvalidOperationException( |
| | | 411 | | $"{label} {documentName} line {lineNumber}: missing {column}"); |
| | | 412 | | } |
| | | 413 | | } |
| | | 414 | | |
| | | 415 | | if (!DateOnly.TryParseExact( |
| | | 416 | | row.DataCollectedAt, |
| | | 417 | | "yyyy-MM-dd", |
| | | 418 | | CultureInfo.InvariantCulture, |
| | | 419 | | DateTimeStyles.None, |
| | | 420 | | out _)) |
| | | 421 | | { |
| | | 422 | | throw new InvalidOperationException( |
| | | 423 | | $"{label} {documentName} line {lineNumber}: Data_Collected_At must use YYYY-MM-DD, got {row.DataCollecte |
| | | 424 | | } |
| | | 425 | | } |
| | | 426 | | |
| | | 427 | | private static string GetTrimmedField(CsvReader csv, string column) |
| | | 428 | | { |
| | | 429 | | return (csv.GetField(column) ?? string.Empty).Trim(); |
| | | 430 | | } |
| | | 431 | | |
| | | 432 | | private void PrintHeaderOnlyReport(Wm26LineupCollection source) |
| | | 433 | | { |
| | | 434 | | if (source.HeaderOnlyTeams.Count == 0) |
| | | 435 | | { |
| | | 436 | | _console.MarkupLine("[green]Header-only lineup context payloads: none[/]"); |
| | | 437 | | return; |
| | | 438 | | } |
| | | 439 | | |
| | | 440 | | _console.MarkupLine($"[yellow]Header-only lineup context payloads:[/] {source.HeaderOnlyTeams.Count}"); |
| | | 441 | | foreach (var team in source.HeaderOnlyTeams) |
| | | 442 | | { |
| | | 443 | | _console.MarkupLine($"[yellow] - {Markup.Escape(team.Name)} ({Markup.Escape(team.Slug)})[/]"); |
| | | 444 | | } |
| | | 445 | | } |
| | | 446 | | |
| | | 447 | | private void PrintMissingSourceDataReport(Wm26LineupCollection source) |
| | | 448 | | { |
| | | 449 | | if (source.MissingSourceData.Count == 0) |
| | | 450 | | { |
| | | 451 | | _console.MarkupLine("[green]Missing lineup source data: none[/]"); |
| | | 452 | | return; |
| | | 453 | | } |
| | | 454 | | |
| | | 455 | | _console.MarkupLine("[yellow]Missing lineup source data detected:[/]"); |
| | | 456 | | foreach (var group in source.MissingSourceData.GroupBy(item => (item.TeamSlug, item.TeamName))) |
| | | 457 | | { |
| | | 458 | | var players = string.Join( |
| | | 459 | | ", ", |
| | | 460 | | group.Select(item => $"{item.PlayerName} ({string.Join(", ", item.Fields)})")); |
| | | 461 | | var plural = group.Count() == 1 ? "player" : "players"; |
| | | 462 | | _console.MarkupLine( |
| | | 463 | | $"[yellow] - {Markup.Escape(group.Key.TeamName)} ({Markup.Escape(group.Key.TeamSlug)}): supplemental da |
| | | 464 | | } |
| | | 465 | | } |
| | | 466 | | |
| | | 467 | | private sealed record FreshenedLineupCollection( |
| | | 468 | | IReadOnlyList<Wm26LineupDocument> ContextDocuments, |
| | | 469 | | string KpiContent); |
| | | 470 | | |
| | 1 | 471 | | private sealed record LineupCsvRow( |
| | 1 | 472 | | string Team, |
| | 1 | 473 | | string DataCollectedAt, |
| | 1 | 474 | | string Role, |
| | 1 | 475 | | string Name, |
| | 1 | 476 | | string Age, |
| | 1 | 477 | | string Position, |
| | 1 | 478 | | string MarketValueEur); |
| | | 479 | | |
| | | 480 | | private readonly record struct LineupRowKey( |
| | | 481 | | string Team, |
| | | 482 | | string Role, |
| | | 483 | | string Name); |
| | | 484 | | |
| | | 485 | | private readonly record struct ExistingLineupRow( |
| | | 486 | | LineupCsvRow Row, |
| | | 487 | | int Index); |
| | | 488 | | } |