diff --git a/.env.example b/.env.example index 29d22be..417d334 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,7 @@ DATA_SYNC_CONTINUE_ON_ERROR=false # These values are mapped in docker-compose.yml to MigrationImport__* env vars. MIGRATION_IMPORT_ENABLED=false MIGRATION_IMPORT_SEASON=2025 +# For Dave/David 2025 package runs, point this to the extracted package directory instead of a CSV file. MIGRATION_IMPORT_SOURCE_FILE_PATH=data/imports/phil-2025/PhilMigratedSelectionsAndScores.csv MIGRATION_IMPORT_DRY_RUN=true MIGRATION_IMPORT_UNRESOLVED_TOKEN_FAIL_THRESHOLD=0 diff --git a/README.md b/README.md index b835c3e..9404bff 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,7 @@ Competition leaderboard config: - `src/F1.Api/appsettings.json` contains the `CompetitionLeaderboard` section used by the standings endpoint. - Each context can be backed by a completed migration run or marked unavailable until a leaderboard source is approved. - For migration-backed contexts, `MigrationSourcePathContains` selects the latest completed run used for leaderboard totals. +- The `david` 2025 context is migration-backed by default and resolves canonical competition naming drift between `David 2025` and `Dave 2025`. - Official leaderboard totals currently use imported legacy scores for approved migrated contexts; admins can request recalculated comparison mode from the API/UI. #### B. Data Sync Worker (`src/F1.DataSyncWorker/appsettings*.json`) diff --git a/docs/Dave-2025-leaderboard-expected-results.csv b/docs/Dave-2025-leaderboard-expected-results.csv new file mode 100644 index 0000000..f0dd431 --- /dev/null +++ b/docs/Dave-2025-leaderboard-expected-results.csv @@ -0,0 +1,13 @@ +Name,Total +StevenR,921.5 +DearbhlaR,849 +KrzysztofB,832.5 +PhilW,814.5 +JasonD,783 +ThomasM,766 +DayaraY,756.5 +StephenD,742 +MatthewA,738.5 +DavidJ,677.5 +JacobG,666 +ColmF,555 \ No newline at end of file diff --git a/docs/Phil-2025-leaderboard-expected-official-results.csv b/docs/Phil-2025-leaderboard-expected-official-results.csv new file mode 100644 index 0000000..7abd3e7 --- /dev/null +++ b/docs/Phil-2025-leaderboard-expected-official-results.csv @@ -0,0 +1,12 @@ + +Player,Score +Shane,595 +Philip,590 +Dave,590 +BINGPT,570 +Veronica,555 +New Sexy Ayrton,550 +Claire,550 +Kevin,545 +Pious ,520 +Andy,475 \ No newline at end of file diff --git a/docs/epics/gh-296-dave-2025-second-comp/dave-2025-second-competition-migration-and-scoring-reconciliation.md b/docs/epics/gh-296-dave-2025-second-comp/dave-2025-second-competition-migration-and-scoring-reconciliation.md index bab820c..d92f290 100644 --- a/docs/epics/gh-296-dave-2025-second-comp/dave-2025-second-competition-migration-and-scoring-reconciliation.md +++ b/docs/epics/gh-296-dave-2025-second-comp/dave-2025-second-competition-migration-and-scoring-reconciliation.md @@ -249,8 +249,6 @@ Test notes: - Add integration tests verifying extracted package kickoff parity with server-path kickoff (same checksum and duplicate conflict behavior). - Add UI tests for archive upload flow, validation errors, and successful kickoff confirmation. -Completed above, uncompleted below --------------------------- ### Story D14: Add write-mode canonical handoff for second competition scope As an operator, I want canonical writes scoped to Dave competition so data from multiple competitions does not collide. @@ -263,6 +261,9 @@ Acceptance criteria: Test notes: - Add integration tests on non-empty DB with both Phil and Dave competitions present. +Completed above, uncompleted below +-------------------------- + ### Story D15: Add rollback and replay safety for Dave runs As a platform maintainer, I want rollback/replay safety so incorrect Dave writes can be reverted without data loss. diff --git a/docs/epics/gh-296-dave-2025-second-comp/story-d20-dave-2025-findings-handover.md b/docs/epics/gh-296-dave-2025-second-comp/story-d20-dave-2025-findings-handover.md new file mode 100644 index 0000000..3ebdae0 --- /dev/null +++ b/docs/epics/gh-296-dave-2025-second-comp/story-d20-dave-2025-findings-handover.md @@ -0,0 +1,168 @@ +# Story D20: Dave 2025 Scoring Findings and Handover + +## Context +This story records the investigation and fixes completed while validating Dave 2025 leaderboard parity and participant detail behavior. + +The goal is to help future developers understand: +- what was broken +- what was fixed +- what is still inconsistent +- what to change next without re-discovering the same issues + +## Executive Summary +Dave leaderboard parity is now achieved against expected totals, but there is still a data-shape gap in participant detail sections. + +Current state: +- Dave leaderboard totals match expected CSV exactly. +- Dave active/imported/recalculated are intentionally forced to recalculated view. +- Dave participant Preseason section is empty because Dave canonical templates currently have no Preseason category rows. +- Dave question totals are currently represented as RaceBonus templates and reconciled to package BONUS_TOTAL values. + +## Verified Findings + +### F1. Dave recalculated scores were missing due to race-code mismatch +Root cause: +- Dave race selections can be mapped to circuit ids while question templates use round ids. +- Scorer question-id generation did not always resolve both forms. + +Fix implemented: +- Score recalculation now uses MigrationImportRaceRoundMappings to resolve both mapped race code and round-based IDs for Dave race question templates. + +Relevant files: +- src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs + +### F2. Half-point values were being lost in canonical race scores +Root cause: +- Canonical RacePickScores used integer point fields and canonical write rounded recalculated decimal points. + +Fix implemented: +- RacePickScore canonical point fields converted to decimal. +- Canonical write stores score.Points directly (no integer rounding). +- Migration added to alter RacePickScores columns to numeric(10,2). + +Relevant files: +- src/F1.Infrastructure/Data/Entities/RacePickScoreEntity.cs +- src/F1.Infrastructure/Data/F1DbContext.cs +- src/F1.Infrastructure/Migrations/20260713195915_Gh296PreserveDecimalRacePickScores.cs +- src/F1.DataSyncWorker/Services/Canonical/MigrationCanonicalWriteService.cs + +### F3. Web contract failed after decimal API change +Root cause: +- Runtime used stale frontend binaries while API returned decimal values. + +Resolution: +- Web model types were aligned to decimal. +- Rebuild/redeploy web container required. +- Browser cache/service-worker invalidation may still be required locally. + +Relevant files: +- src/F1.Web/Models/CompetitionLeaderboardResponse.cs +- src/F1.Web/Models/CompetitionParticipantDetailResponse.cs + +### F4. Dave leaderboard totals now match expected CSV via BONUS_TOTAL reconciliation +Observation: +- Dave package contains per-participant BONUS_TOTAL values in MigrationImportLegacyPickScores. +- Recalculated RaceBonus totals can differ from source leaderboard expectation. + +Fix implemented: +- Added Dave-specific reconciliation step that adjusts RaceBonus QuestionScore totals per participant to match BONUS_TOTAL for that run. + +Relevant files: +- src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs + +### F5. Dave participant Preseason section is empty even though Dave package has preseason source files +Root cause: +- Dave parser stores preseason answers in MigrationImportPreseasonAnswers. +- Dave canonical template materialization path currently only builds race question templates (H2H/RaceBonus), not Preseason templates. +- Participant detail endpoint only loads canonical templates where Category == Preseason. + +Evidence: +- MigrationImportPreseasonAnswers has rows for Dave run. +- Dave QuestionTemplates category counts show only RaceBonus. + +Relevant files: +- src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs +- src/F1.Api/Services/CompetitionLeaderboardService.cs + +### F6. PQ rows are expected to score 0 +Behavior: +- PQ is pre-qualy mode control input, not a points-bearing pick. +- Scorer emits reason code PQ_MODE_* and 0 points for PQ rows. + +Relevant file: +- src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs + +## Data Flow Clarification +There are two intentionally different layers: + +1) Run-scoped migration tables (audit/staging/reconciliation) +- Example: MigrationImportRawRows, MigrationImportPreseasonAnswers, MigrationImportLegacyPickScores +- Purpose: immutable run artifacts, diagnostics, replay support + +2) Canonical app tables (live API/UI) +- Example: RacePickScores, QuestionTemplates, QuestionScores +- Purpose: current leaderboard and participant details + +Current inconsistency is not the two-layer design itself. The current issue is that Dave preseason data is staged but not fully materialized into canonical Preseason templates/scores. + +## What Was Confirmed in Live Validation +- Fresh Dave write run completed on updated services. +- GET /races/results?competition=david&season=2025&view=recalculated matched docs/Dave-2025-leaderboard-expected-results.csv for all participants. +- Dave question template categories in canonical table remained RaceBonus only. + +## Remaining Gaps and Risks + +### Gap G1: Missing canonical Preseason category for Dave +Impact: +- Participant detail Preseason section appears empty for Dave. + +Recommended remediation: +- Extend Dave parsing/materialization to create canonical Preseason QuestionTemplates/QuestionAnswers/QuestionActuals from bonus.csv and bonusAnswers.csv. + +### Gap G2: Dave question representation currently coupled to bonus-total reconciliation +Impact: +- Leaderboard parity currently depends on reconciliation behavior. + +Recommended remediation: +- After G1, reevaluate whether reconciliation remains needed or should become diagnostics-only. + +### Gap G3: Potential semantic overlap between race pick and question scoring paths +Impact: +- Risk of double counting if leaderboard aggregation rules change without guarding pick types/categories. + +Recommended remediation: +- Make explicit ownership by type: + - race totals from race-pick types only + - question totals from canonical question categories only +- Add test coverage for no-double-count invariants. + +## Proposed Follow-up Stories + +### D21: Materialize Dave preseason questions to canonical templates +Acceptance criteria: +- Dave package preseason rows create canonical QuestionTemplates with Category=Preseason. +- Canonical QuestionAnswers and QuestionActuals are written for those templates. +- Participant detail Preseason section is populated for Dave participants. + +### D22: Harden canonical aggregation invariants +Acceptance criteria: +- Leaderboard aggregation cannot double count equivalent semantic picks across tables. +- Test fixtures fail when a pick type/category appears in both paths without explicit rule. + +### D23: Make Dave reconciliation transparent in admin diagnostics +Acceptance criteria: +- Admin run detail exposes bonus-total reconciliation applied/not applied per participant. +- Reason codes clearly distinguish computed vs reconciled values. + +## Tests That Should Exist Before Closing Follow-ups +- Integration test: Dave run creates Preseason templates in canonical table. +- API test: Dave participant detail returns non-empty Preseason section when preseason source files are present. +- Regression test: Dave leaderboard parity remains equal to expected CSV after Preseason materialization. +- Regression test: No duplicate contribution from the same semantic bonus pick across race/question aggregates. + +## Operational Notes +- When point-type contracts change (int -> decimal), rebuild both API and Web images together. +- Browser cache/service-worker can retain old wasm model contracts and produce deserialization errors after backend contract updates. + +## Suggested Commit Message +GH-296 add Dave 2025 scoring findings handover story with root causes, evidence, and follow-up actions diff --git a/docs/phil-2025-expected-recalculated-results.csv b/docs/phil-2025-expected-recalculated-results.csv new file mode 100644 index 0000000..fa68d8c --- /dev/null +++ b/docs/phil-2025-expected-recalculated-results.csv @@ -0,0 +1,11 @@ +Player, Score +Dave,605 +Shane,600 +Philip,590 +BINGPT,570 +New Sexy Ayrton,565 +Veronica,555 +Claire,550 +Kevin,545 +Pious,525 +Andy,485 \ No newline at end of file diff --git a/src/F1.Api/Dtos/CompetitionLeaderboardDtos.cs b/src/F1.Api/Dtos/CompetitionLeaderboardDtos.cs index 6d0d2b0..65ab35a 100644 --- a/src/F1.Api/Dtos/CompetitionLeaderboardDtos.cs +++ b/src/F1.Api/Dtos/CompetitionLeaderboardDtos.cs @@ -17,9 +17,9 @@ public sealed record CompetitionLeaderboardResponseDto( public sealed record CompetitionLeaderboardEntryDto( int Position, string ParticipantName, - int DisplayPoints, - int ImportedPoints, - int RecalculatedPoints); + decimal DisplayPoints, + decimal ImportedPoints, + decimal RecalculatedPoints); public sealed record CompetitionParticipantDetailResponseDto( string CompetitionSlug, @@ -32,15 +32,15 @@ public sealed record CompetitionParticipantDetailResponseDto( public sealed record CompetitionParticipantSectionSummaryDto( string Title, - int ImportedTotalPoints, - int RecalculatedTotalPoints, + decimal ImportedTotalPoints, + decimal RecalculatedTotalPoints, IReadOnlyList Items); public sealed record CompetitionParticipantDetailItemDto( string Label, string Description, - int? ImportedPoints, - int CalculatedPoints, - int DeltaPoints, + decimal? ImportedPoints, + decimal CalculatedPoints, + decimal DeltaPoints, string? ReasonCode, string? Explanation); \ No newline at end of file diff --git a/src/F1.Api/Services/CompetitionLeaderboardService.cs b/src/F1.Api/Services/CompetitionLeaderboardService.cs index 2960232..c2a4672 100644 --- a/src/F1.Api/Services/CompetitionLeaderboardService.cs +++ b/src/F1.Api/Services/CompetitionLeaderboardService.cs @@ -33,6 +33,8 @@ public async Task GetLeaderboardAsync(string var normalizedCompetitionSlug = competitionSlug.Trim().ToLowerInvariant(); var normalizedScoreView = NormalizeScoreView(scoreView); + var forceRecalculatedOnly = string.Equals(normalizedCompetitionSlug, "david", StringComparison.OrdinalIgnoreCase); + var requestedScoreView = forceRecalculatedOnly ? ViewRecalculated : normalizedScoreView; var context = ResolveContextOption(normalizedCompetitionSlug, season); var displayName = GetDisplayName(context, normalizedCompetitionSlug, season); @@ -42,7 +44,7 @@ public async Task GetLeaderboardAsync(string normalizedCompetitionSlug, season, displayName, - normalizedScoreView, + requestedScoreView, isAdmin, context?.UnavailableMessage ?? "Leaderboard data is not available for this competition yet."); } @@ -54,7 +56,7 @@ public async Task GetLeaderboardAsync(string normalizedCompetitionSlug, season, displayName, - normalizedScoreView, + requestedScoreView, isAdmin, "No canonical leaderboard data is available for this competition yet."); } @@ -69,10 +71,10 @@ public async Task GetLeaderboardAsync(string (score, _) => score) .ToListAsync(cancellationToken); - var preseasonTotals = await dbContext.QuestionScores + var questionTotals = await dbContext.QuestionScores .AsNoTracking() .Join( - dbContext.QuestionTemplates.AsNoTracking().Where(template => template.CompetitionId == competition.Id && template.Season == season && template.Category == QuestionCategory.Preseason), + dbContext.QuestionTemplates.AsNoTracking().Where(template => template.CompetitionId == competition.Id && template.Season == season), score => score.QuestionTemplateId, template => template.Id, (score, _) => score) @@ -83,35 +85,35 @@ public async Task GetLeaderboardAsync(string .ToDictionary( group => group.Key, group => new ScoreTotals( - ImportedPoints: group.Sum(item => item.ImportedPoints ?? 0), + ImportedPoints: group.Sum(item => (decimal)(item.ImportedPoints ?? 0)), RecalculatedPoints: group.Sum(item => item.CalculatedPoints), ActivePoints: group.Sum(item => item.OverrideScore ?? item.CalculatedPoints), SourceRunId: group.Select(item => (Guid?)item.SourceRunId).OrderByDescending(item => item).FirstOrDefault()), StringComparer.OrdinalIgnoreCase); - foreach (var preseasonRow in preseasonTotals) + foreach (var questionRow in questionTotals) { - if (combined.TryGetValue(preseasonRow.ParticipantId, out var existingTotals)) + if (combined.TryGetValue(questionRow.ParticipantId, out var existingTotals)) { - combined[preseasonRow.ParticipantId] = existingTotals with + combined[questionRow.ParticipantId] = existingTotals with { - ImportedPoints = existingTotals.ImportedPoints + (preseasonRow.ImportedPoints ?? 0), - RecalculatedPoints = existingTotals.RecalculatedPoints + preseasonRow.CalculatedPoints, - ActivePoints = existingTotals.ActivePoints + (preseasonRow.OverrideScore ?? preseasonRow.CalculatedPoints) + ImportedPoints = existingTotals.ImportedPoints + (questionRow.ImportedPoints ?? 0), + RecalculatedPoints = existingTotals.RecalculatedPoints + questionRow.CalculatedPoints, + ActivePoints = existingTotals.ActivePoints + (questionRow.OverrideScore ?? questionRow.CalculatedPoints) }; } else { - combined[preseasonRow.ParticipantId] = new ScoreTotals( - ImportedPoints: preseasonRow.ImportedPoints ?? 0, - RecalculatedPoints: preseasonRow.CalculatedPoints, - ActivePoints: preseasonRow.OverrideScore ?? preseasonRow.CalculatedPoints, - SourceRunId: preseasonRow.OverrideSourceRunId); + combined[questionRow.ParticipantId] = new ScoreTotals( + ImportedPoints: questionRow.ImportedPoints ?? 0, + RecalculatedPoints: questionRow.CalculatedPoints, + ActivePoints: questionRow.OverrideScore ?? questionRow.CalculatedPoints, + SourceRunId: questionRow.OverrideSourceRunId); } } - var effectiveView = normalizedScoreView == ViewActive || isAdmin - ? normalizedScoreView + var effectiveView = forceRecalculatedOnly || requestedScoreView == ViewActive || isAdmin + ? requestedScoreView : ViewActive; var leaderboardItems = combined @@ -136,7 +138,7 @@ public async Task GetLeaderboardAsync(string ScoreView: effectiveView, ScoreSourceLabel: scoreSourceLabel, ScoreSourceHelperText: scoreSourceHelperText, - IsComparisonAvailable: isAdmin, + IsComparisonAvailable: !forceRecalculatedOnly && isAdmin, IsDataAvailable: leaderboardItems.Length > 0, EmptyStateMessage: leaderboardItems.Length > 0 ? null : "No participant totals are available for this competition yet.", SourceRunId: combined.Values.Select(item => item.SourceRunId).OrderByDescending(item => item).FirstOrDefault(), @@ -260,13 +262,13 @@ private static CompetitionLeaderboardResponseDto CreateUnavailableResponse( Items: []); } - private static int ResolveDisplayPoints(ScoreTotals totals, string scoreView, string activeScoreSource) + private static decimal ResolveDisplayPoints(ScoreTotals totals, string scoreView, string activeScoreSource) { return scoreView switch { ViewImported => totals.ImportedPoints, ViewRecalculated => totals.RecalculatedPoints, - _ when string.Equals(activeScoreSource, ActiveScoreSourceImportedLegacy, StringComparison.OrdinalIgnoreCase) => totals.ActivePoints, + _ when string.Equals(activeScoreSource, ActiveScoreSourceImportedLegacy, StringComparison.OrdinalIgnoreCase) => totals.ImportedPoints, _ => totals.RecalculatedPoints }; } @@ -328,11 +330,36 @@ private static string GetDisplayName(CompetitionLeaderboardContextOption? contex private async Task ResolveCompetitionAsync(string competitionDisplayName, int season, CancellationToken cancellationToken) { - return await dbContext.Competitions + var exactMatch = await dbContext.Competitions .AsNoTracking() .Where(item => item.Name == competitionDisplayName && item.Year == season) .OrderBy(item => item.Id) .FirstOrDefaultAsync(cancellationToken); + + if (exactMatch is not null) + { + return exactMatch; + } + + if (competitionDisplayName.Contains("David", StringComparison.OrdinalIgnoreCase)) + { + return await dbContext.Competitions + .AsNoTracking() + .Where(item => item.Name == competitionDisplayName.Replace("David", "Dave", StringComparison.OrdinalIgnoreCase) && item.Year == season) + .OrderBy(item => item.Id) + .FirstOrDefaultAsync(cancellationToken); + } + + if (competitionDisplayName.Contains("Dave", StringComparison.OrdinalIgnoreCase)) + { + return await dbContext.Competitions + .AsNoTracking() + .Where(item => item.Name == competitionDisplayName.Replace("Dave", "David", StringComparison.OrdinalIgnoreCase) && item.Year == season) + .OrderBy(item => item.Id) + .FirstOrDefaultAsync(cancellationToken); + } + + return null; } private async Task BuildH2hItemsAsync(string competitionDisplayName, int season, string participantName, CancellationToken cancellationToken) @@ -384,5 +411,5 @@ private static CompetitionParticipantSectionSummaryDto BuildSection(string title Items: items); } - private sealed record ScoreTotals(int ImportedPoints, int RecalculatedPoints, int ActivePoints, Guid? SourceRunId); + private sealed record ScoreTotals(decimal ImportedPoints, decimal RecalculatedPoints, decimal ActivePoints, Guid? SourceRunId); } \ No newline at end of file diff --git a/src/F1.Api/appsettings.json b/src/F1.Api/appsettings.json index c8a75e3..569c6f9 100644 --- a/src/F1.Api/appsettings.json +++ b/src/F1.Api/appsettings.json @@ -38,8 +38,9 @@ "CompetitionSlug": "david", "Season": 2025, "DisplayName": "David 2025", - "SourceType": "Unavailable", - "UnavailableMessage": "Leaderboard data is not available for this competition yet." + "SourceType": "MigrationRun", + "ActiveScoreSource": "ImportedLegacy", + "MigrationSourcePathContains": "dave-2025" }, { "CompetitionSlug": "main", diff --git a/src/F1.DataSyncWorker/Services/Canonical/MigrationCanonicalWriteService.cs b/src/F1.DataSyncWorker/Services/Canonical/MigrationCanonicalWriteService.cs index 2d51605..0f6368c 100644 --- a/src/F1.DataSyncWorker/Services/Canonical/MigrationCanonicalWriteService.cs +++ b/src/F1.DataSyncWorker/Services/Canonical/MigrationCanonicalWriteService.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using F1.Core.Models; +using F1.DataSyncWorker.Models; using F1.DataSyncWorker.Options; using F1.Infrastructure.Data; using F1.Infrastructure.Data.Entities; @@ -82,26 +83,19 @@ public async Task PersistCanonicalEntitiesAsync(Guid runId, CancellationToken ca try { var normalizedConflictPolicy = NormalizeConflictPolicy(_importOptions.CanonicalConflictPolicy); - var competition = await dbContext.Competitions - .Where(x => x.Year == _importOptions.Season) - .OrderBy(x => x.Name == "Philip 2025" ? 0 : 1) - .ThenBy(x => x.Name.Contains("Philip") ? 0 : 1) - .ThenBy(x => x.Name.StartsWith("Migration Import") ? 1 : 0) - .ThenBy(x => x.Id) - .FirstOrDefaultAsync(cancellationToken); - - if (competition is null) - { - competition = new Competition - { - Name = $"Migration Import {_importOptions.Season}", - Year = _importOptions.Season, - Description = "Auto-created by migration canonical writer" - }; + var sourceProfile = MigrationSourceProfileResolver.Resolve(run.SourceFilePath); + var runParticipants = selections + .Select(x => x.Subject) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); - dbContext.Competitions.Add(competition); - await dbContext.SaveChangesAsync(cancellationToken); - } + var competition = await MigrationCompetitionScopeResolver.ResolveOrCreateCompetitionAsync( + dbContext, + _importOptions.Season, + sourceProfile, + runParticipants, + cancellationToken); var raceCodes = selections .Select(x => x.RaceCode) @@ -128,19 +122,32 @@ public async Task PersistCanonicalEntitiesAsync(Guid runId, CancellationToken ca .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) .ToDictionary(x => x.Key, x => x.First().Race, StringComparer.OrdinalIgnoreCase); - var mappedRoundByRaceCode = await dbContext.MigrationImportRaceRoundMappings + var roundMappings = await dbContext.MigrationImportRaceRoundMappings .AsNoTracking() .Where(x => x.ImportRunId == runId && - x.Round.HasValue && - !string.IsNullOrWhiteSpace(x.MappedCircuitId)) - .GroupBy(x => x.MappedCircuitId!) - .Select(group => new + x.Round.HasValue) + .Select(x => new { - RaceCode = group.Key, - Round = group.Min(item => item.Round!.Value) + x.SourceRaceCode, + x.MappedCircuitId, + Round = x.Round!.Value }) - .ToDictionaryAsync(x => x.RaceCode, x => x.Round, StringComparer.OrdinalIgnoreCase, cancellationToken); + .ToListAsync(cancellationToken); + + var mappedRoundByRaceCode = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var mapping in roundMappings) + { + if (!string.IsNullOrWhiteSpace(mapping.SourceRaceCode) && !mappedRoundByRaceCode.ContainsKey(mapping.SourceRaceCode)) + { + mappedRoundByRaceCode[mapping.SourceRaceCode] = mapping.Round; + } + + if (!string.IsNullOrWhiteSpace(mapping.MappedCircuitId) && !mappedRoundByRaceCode.ContainsKey(mapping.MappedCircuitId)) + { + mappedRoundByRaceCode[mapping.MappedCircuitId] = mapping.Round; + } + } var raceIdByCode = new Dictionary(StringComparer.OrdinalIgnoreCase); var unresolvedRaceCodes = new List(); @@ -248,8 +255,8 @@ public async Task PersistCanonicalEntitiesAsync(Guid runId, CancellationToken ca ImportRunId = runId, EntityType = "Selection", ConflictType = "existing_active_selection", - KeyFields = BuildSelectionKey(scope.RaceId, scope.Subject), - SourceReference = $"row:{scope.SourceRowNumber}|race:{scope.RaceCode}|subject:{scope.Subject}", + KeyFields = $"competitionId:{competition.Id}|competition:{competition.Name}|{BuildSelectionKey(scope.RaceId, scope.Subject)}", + SourceReference = $"competitionId:{competition.Id}|competition:{competition.Name}|row:{scope.SourceRowNumber}|race:{scope.RaceCode}|subject:{scope.Subject}", PolicyOutcome = ResolvePolicyOutcome(normalizedConflictPolicy), RecommendedAction = ResolveRecommendedAction(normalizedConflictPolicy), CreatedAtUtc = DateTime.UtcNow @@ -271,7 +278,7 @@ public async Task PersistCanonicalEntitiesAsync(Guid runId, CancellationToken ca var skippedSelectionKeys = conflictDiagnostics .Where(x => string.Equals(x.PolicyOutcome, "Skipped", StringComparison.OrdinalIgnoreCase)) - .Select(x => x.KeyFields) + .Select(x => ExtractSelectionKey(x.KeyFields)) .ToHashSet(StringComparer.OrdinalIgnoreCase); var calculatedPickScores = await dbContext.MigrationImportCalculatedScores @@ -329,8 +336,8 @@ public async Task PersistCanonicalEntitiesAsync(Guid runId, CancellationToken ca out var importedScore); var importedPoints = importedScore?.LegacyPoints; - var calculatedPoints = decimal.ToInt32(decimal.Round(score.Points, 0, MidpointRounding.AwayFromZero)); - int? overrideScore = importedPoints.HasValue && importedPoints.Value != calculatedPoints + var calculatedPoints = score.Points; + decimal? overrideScore = importedPoints.HasValue && importedPoints.Value != calculatedPoints ? importedPoints.Value : null; @@ -517,6 +524,19 @@ private static string BuildSelectionKey(string raceId, string subject) return $"raceId:{raceId}|subject:{subject}"; } + private static string ExtractSelectionKey(string keyFields) + { + if (string.IsNullOrWhiteSpace(keyFields)) + { + return string.Empty; + } + + var markerIndex = keyFields.IndexOf("raceId:", StringComparison.OrdinalIgnoreCase); + return markerIndex >= 0 + ? keyFields[markerIndex..] + : keyFields; + } + private async Task PersistConflictDiagnosticsAsync( IReadOnlyCollection diagnostics, CancellationToken cancellationToken) diff --git a/src/F1.DataSyncWorker/Services/MigrationCompetitionScopeResolver.cs b/src/F1.DataSyncWorker/Services/MigrationCompetitionScopeResolver.cs new file mode 100644 index 0000000..5a310e0 --- /dev/null +++ b/src/F1.DataSyncWorker/Services/MigrationCompetitionScopeResolver.cs @@ -0,0 +1,143 @@ +using F1.Core.Models; +using F1.DataSyncWorker.Models; +using F1.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace F1.DataSyncWorker.Services; + +public static class MigrationCompetitionScopeResolver +{ + private const string PhilCompetitionName = "Philip 2025"; + private const string DaveCompetitionName = "Dave 2025"; + + public static async Task ResolveCompetitionAsync( + F1DbContext dbContext, + int season, + MigrationSourceProfile sourceProfile, + IReadOnlyCollection participants, + CancellationToken cancellationToken) + { + var competitions = await dbContext.Competitions + .Where(x => x.Year == season) + .OrderBy(x => x.Id) + .ToListAsync(cancellationToken); + + if (competitions.Count == 0) + { + return null; + } + + if (competitions.Count == 1) + { + return competitions[0]; + } + + var preferred = sourceProfile switch + { + MigrationSourceProfile.Phil2025Csv => + FindPhilCompetition(competitions), + MigrationSourceProfile.Dave2025Package => + FindDaveCompetition(competitions), + _ => + ResolveFallbackCompetition(competitions, participants) + }; + + return preferred ?? competitions[0]; + } + + public static async Task ResolveOrCreateCompetitionAsync( + F1DbContext dbContext, + int season, + MigrationSourceProfile sourceProfile, + IReadOnlyCollection participants, + CancellationToken cancellationToken) + { + var resolved = await ResolveCompetitionAsync(dbContext, season, sourceProfile, participants, cancellationToken); + if (resolved is not null) + { + return resolved; + } + + var competitionName = sourceProfile switch + { + MigrationSourceProfile.Phil2025Csv => PhilCompetitionName, + MigrationSourceProfile.Dave2025Package => DaveCompetitionName, + _ => $"Migration Import {season}" + }; + + var competition = new Competition + { + Name = competitionName, + Year = season, + Description = sourceProfile == MigrationSourceProfile.Dave2025Package + ? "Auto-created for Dave 2025 migration canonical write scope" + : "Auto-created by migration canonical writer" + }; + + dbContext.Competitions.Add(competition); + await dbContext.SaveChangesAsync(cancellationToken); + return competition; + } + + private static Competition? ResolveFallbackCompetition(IReadOnlyList competitions, IReadOnlyCollection participants) + { + if (participants.Any(x => string.Equals(x, "Philip", StringComparison.OrdinalIgnoreCase))) + { + var phil = FindPhilCompetition(competitions); + if (phil is not null) + { + return phil; + } + } + + if (participants.Any(x => string.Equals(x, "Dave", StringComparison.OrdinalIgnoreCase) || + string.Equals(x, "David", StringComparison.OrdinalIgnoreCase))) + { + var dave = FindDaveCompetition(competitions); + if (dave is not null) + { + return dave; + } + } + + return competitions.FirstOrDefault(x => + x.Name.Contains("Main", StringComparison.OrdinalIgnoreCase)); + } + + private static Competition? FindPhilCompetition(IReadOnlyCollection competitions) + { + var exact = competitions.FirstOrDefault(x => + string.Equals(x.Name, PhilCompetitionName, StringComparison.OrdinalIgnoreCase)); + + if (exact is not null) + { + return exact; + } + + return competitions.FirstOrDefault(x => + x.Name.Contains("Philip", StringComparison.OrdinalIgnoreCase) || + x.Name.Contains("Phil", StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(x.Description) && + (x.Description.Contains("Philip", StringComparison.OrdinalIgnoreCase) || + x.Description.Contains("Phil", StringComparison.OrdinalIgnoreCase)))); + } + + private static Competition? FindDaveCompetition(IReadOnlyCollection competitions) + { + var exact = competitions.FirstOrDefault(x => + string.Equals(x.Name, DaveCompetitionName, StringComparison.OrdinalIgnoreCase) || + string.Equals(x.Name, "David 2025", StringComparison.OrdinalIgnoreCase)); + + if (exact is not null) + { + return exact; + } + + return competitions.FirstOrDefault(x => + x.Name.Contains("Dave", StringComparison.OrdinalIgnoreCase) || + x.Name.Contains("David", StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(x.Description) && + (x.Description.Contains("Dave", StringComparison.OrdinalIgnoreCase) || + x.Description.Contains("David", StringComparison.OrdinalIgnoreCase)))); + } +} diff --git a/src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs b/src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs index 29a8e4e..9e66702 100644 --- a/src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs +++ b/src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs @@ -12,7 +12,6 @@ namespace F1.DataSyncWorker.Services; public sealed partial class MigrationRaceSelectionParser : IMigrationRaceSelectionParser { - private const string Philip2025CompetitionName = "Philip 2025"; private const string SectionTypeRacePick = "RacePick"; private const string SectionTypeSeasonQuestionPrediction = "SeasonQuestionPrediction"; private const string SectionTypeHeader = "Header"; @@ -216,6 +215,7 @@ public async Task ParseAndPersistAsync(Guid r runId, stagedRows, preseasonParticipants, + sourceProfile, usePhil2025SequenceMapping, driverIdByCode, cancellationToken); @@ -492,6 +492,7 @@ private async Task ParseDave2025PackageAsync( var competitionId = await ResolveTargetCompetitionIdAsync( dbContext, participants, + MigrationSourceProfile.Dave2025Package, usePhil2025Contract: false, cancellationToken); @@ -897,6 +898,7 @@ private static string NormalizeQuestionLookupKey(string questionText) Guid runId, IReadOnlyCollection stagedRows, IReadOnlyList participants, + MigrationSourceProfile sourceProfile, bool usePhil2025Contract, IReadOnlyDictionary driverIdByCode, CancellationToken cancellationToken) @@ -914,6 +916,7 @@ private static string NormalizeQuestionLookupKey(string questionText) var competitionId = await ResolveTargetCompetitionIdAsync( dbContext, participants, + sourceProfile, usePhil2025Contract, cancellationToken); @@ -1027,55 +1030,22 @@ private static string NormalizeQuestionLookupKey(string questionText) private async Task ResolveTargetCompetitionIdAsync( F1DbContext dbContext, IReadOnlyList participants, + MigrationSourceProfile sourceProfile, bool usePhil2025Contract, CancellationToken cancellationToken) { - var competitions = await dbContext.Competitions - .Where(x => x.Year == _importOptions.Season) - .OrderBy(x => x.Id) - .Select(x => new { x.Id, x.Name, x.Description }) - .ToListAsync(cancellationToken); - - if (competitions.Count == 0) - { - return null; - } - - if (competitions.Count == 1) - { - return competitions[0].Id; - } - - var shouldPreferPhilipCompetition = usePhil2025Contract || - participants.Any(x => string.Equals(x, "Philip", StringComparison.OrdinalIgnoreCase)); - - if (shouldPreferPhilipCompetition) - { - var exactPhilipCompetition = competitions.FirstOrDefault(x => - string.Equals(x.Name, Philip2025CompetitionName, StringComparison.OrdinalIgnoreCase)); + var effectiveSourceProfile = usePhil2025Contract + ? MigrationSourceProfile.Phil2025Csv + : sourceProfile; - if (exactPhilipCompetition is not null) - { - return exactPhilipCompetition.Id; - } - - var philipCompetition = competitions.FirstOrDefault(x => - x.Name.Contains("Philip", StringComparison.OrdinalIgnoreCase) || - x.Name.Contains("Phil", StringComparison.OrdinalIgnoreCase) || - (!string.IsNullOrWhiteSpace(x.Description) && - (x.Description.Contains("Philip", StringComparison.OrdinalIgnoreCase) || - x.Description.Contains("Phil", StringComparison.OrdinalIgnoreCase)))); - - if (philipCompetition is not null) - { - return philipCompetition.Id; - } - } - - var mainCompetition = competitions.FirstOrDefault(x => - x.Name.Contains("Main", StringComparison.OrdinalIgnoreCase)); + var competition = await MigrationCompetitionScopeResolver.ResolveCompetitionAsync( + dbContext, + _importOptions.Season, + effectiveSourceProfile, + participants, + cancellationToken); - return (mainCompetition ?? competitions[0]).Id; + return competition?.Id; } private static async Task> UpsertQuestionTemplatesAsync( diff --git a/src/F1.DataSyncWorker/Services/Parsing/MigrationSourceProfileResolver.cs b/src/F1.DataSyncWorker/Services/Parsing/MigrationSourceProfileResolver.cs index 85e20cb..f4cf8d1 100644 --- a/src/F1.DataSyncWorker/Services/Parsing/MigrationSourceProfileResolver.cs +++ b/src/F1.DataSyncWorker/Services/Parsing/MigrationSourceProfileResolver.cs @@ -4,6 +4,13 @@ namespace F1.DataSyncWorker.Services; public static class MigrationSourceProfileResolver { + private static readonly string[] DavePathMarkers = + [ + "dave-2025", + "david-2025", + "dave2025" + ]; + public static MigrationSourceProfile Resolve(string sourcePath) { if (string.IsNullOrWhiteSpace(sourcePath)) @@ -27,6 +34,11 @@ public static MigrationSourceProfile Resolve(string sourcePath) } } + if (DavePathMarkers.Any(marker => sourcePath.Contains(marker, StringComparison.OrdinalIgnoreCase))) + { + return MigrationSourceProfile.Dave2025Package; + } + return MigrationSourceProfile.Unknown; } } diff --git a/src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs b/src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs index 95d750b..3dfd39a 100644 --- a/src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs +++ b/src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs @@ -1,9 +1,11 @@ using System.Text.RegularExpressions; using F1.Core.Models; using F1.DataSyncWorker.Models; +using F1.DataSyncWorker.Options; using F1.Infrastructure.Data; using F1.Infrastructure.Data.Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace F1.DataSyncWorker.Services; @@ -27,13 +29,26 @@ public sealed partial class MigrationScoreRecalculator : IMigrationScoreRecalcul private readonly IDbContextFactory _dbContextFactory; private readonly IQuestionScoringStrategyRegistry _questionScoringStrategyRegistry; + private readonly MigrationImportOptions _importOptions; public MigrationScoreRecalculator( IDbContextFactory dbContextFactory, - IQuestionScoringStrategyRegistry questionScoringStrategyRegistry) + IQuestionScoringStrategyRegistry questionScoringStrategyRegistry, + IOptions importOptions) { _dbContextFactory = dbContextFactory; _questionScoringStrategyRegistry = questionScoringStrategyRegistry; + _importOptions = importOptions.Value; + } + + public MigrationScoreRecalculator( + IDbContextFactory dbContextFactory, + IQuestionScoringStrategyRegistry questionScoringStrategyRegistry) + : this( + dbContextFactory, + questionScoringStrategyRegistry, + Microsoft.Extensions.Options.Options.Create(new MigrationImportOptions())) + { } public MigrationScoreRecalculator(IDbContextFactory dbContextFactory) @@ -43,7 +58,8 @@ public MigrationScoreRecalculator(IDbContextFactory dbContextFactor new PreseasonQuestionScoringStrategy(), new H2hQuestionScoringStrategy(), new RaceBonusQuestionScoringStrategy() - ])) + ]), + Microsoft.Extensions.Options.Options.Create(new MigrationImportOptions())) { } @@ -79,6 +95,31 @@ public async Task RecalculateAndPersistAsync( .AsNoTracking() .ToListAsync(cancellationToken); + var legacyBonusTotals = await dbContext.MigrationImportLegacyPickScores + .Where(x => x.ImportRunId == runId && x.PickType == "BONUS_TOTAL" && x.LegacyPoints.HasValue) + .AsNoTracking() + .ToListAsync(cancellationToken); + + var runParticipants = selections + .Where(x => !x.IsActualOutcome && !string.Equals(x.Subject, ActualSubject, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Subject) + .Concat(preseasonAnswers + .Where(x => !x.IsActualOutcome && !string.Equals(x.Subject, ActualSubject, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Subject)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var hasDaveRaceQuestionPickTypes = selections.Any(x => IsDaveRaceQuestionPickType(x.PickType)); + var effectiveSourceProfile = sourceProfile == MigrationSourceProfile.Unknown && hasDaveRaceQuestionPickTypes + ? MigrationSourceProfile.Dave2025Package + : sourceProfile; + + var targetCompetitionId = await ResolveTargetCompetitionIdAsync( + dbContext, + effectiveSourceProfile, + runParticipants, + cancellationToken); + dbContext.MigrationImportCalculatedScores.RemoveRange( dbContext.MigrationImportCalculatedScores.Where(x => x.ImportRunId == runId)); dbContext.MigrationImportPreseasonCalculatedScores.RemoveRange( @@ -89,38 +130,86 @@ public async Task RecalculateAndPersistAsync( List genericQuestionAnswers; List genericQuestionActuals; - var useDaveGenericQuestionScoping = sourceProfile == MigrationSourceProfile.Dave2025Package || - (sourceProfile != MigrationSourceProfile.Phil2025Csv && - selections.Any(x => IsDaveRaceQuestionPickType(x.PickType))); + var useDaveGenericQuestionScoping = effectiveSourceProfile == MigrationSourceProfile.Dave2025Package || + (effectiveSourceProfile != MigrationSourceProfile.Phil2025Csv && + hasDaveRaceQuestionPickTypes); if (useDaveGenericQuestionScoping) { - var daveQuestionIds = selections - .Where(x => IsDaveRaceQuestionPickType(x.PickType)) - .Select(x => BuildDaveRaceQuestionId(x.RaceCode, x.PickType)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + var mappingRows = await dbContext.MigrationImportRaceRoundMappings + .AsNoTracking() + .Where(x => x.ImportRunId == runId) + .Select(x => new { x.SourceRaceCode, x.MappedCircuitId, x.Round }) + .ToListAsync(cancellationToken); - var runParticipants = selections - .Where(x => !x.IsActualOutcome && !string.Equals(x.Subject, ActualSubject, StringComparison.OrdinalIgnoreCase)) - .Select(x => x.Subject) - .Concat(preseasonAnswers - .Where(x => !x.IsActualOutcome && !string.Equals(x.Subject, ActualSubject, StringComparison.OrdinalIgnoreCase)) - .Select(x => x.Subject)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - var daveTemplateIds = daveQuestionIds.Length == 0 - ? [] - : await dbContext.QuestionTemplates - .Where(x => daveQuestionIds.Contains(x.QuestionId)) + var roundCodeByRaceCode = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in mappingRows) + { + if (!row.Round.HasValue) + { + continue; + } + + var roundCode = $"R{row.Round.Value:D2}"; + if (!string.IsNullOrWhiteSpace(row.SourceRaceCode)) + { + roundCodeByRaceCode[row.SourceRaceCode.Trim().ToUpperInvariant()] = roundCode; + } + + if (!string.IsNullOrWhiteSpace(row.MappedCircuitId)) + { + roundCodeByRaceCode[row.MappedCircuitId.Trim().ToUpperInvariant()] = roundCode; + } + } + + var daveQuestionIdSet = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var selection in selections.Where(x => IsDaveRaceQuestionPickType(x.PickType))) + { + if (string.IsNullOrWhiteSpace(selection.RaceCode)) + { + continue; + } + + daveQuestionIdSet.Add(BuildDaveRaceQuestionId(selection.RaceCode, selection.PickType)); + + var raceCodeKey = (selection.RaceCode ?? string.Empty).Trim().ToUpperInvariant(); + if (roundCodeByRaceCode.TryGetValue(raceCodeKey, out var roundCode)) + { + daveQuestionIdSet.Add(BuildDaveRaceQuestionId(roundCode, selection.PickType)); + } + } + + var daveQuestionIds = daveQuestionIdSet.ToArray(); + + long[] daveTemplateIds; + if (targetCompetitionId.HasValue) + { + daveTemplateIds = await dbContext.QuestionTemplates + .Where(x => + x.CompetitionId == targetCompetitionId.Value && + x.Season == _importOptions.Season && + (daveQuestionIds.Length == 0 || + x.Category == QuestionCategory.Preseason || + daveQuestionIds.Contains(x.QuestionId))) .Select(x => x.Id) .ToArrayAsync(cancellationToken); + } + else + { + daveTemplateIds = daveQuestionIds.Length == 0 + ? [] + : await dbContext.QuestionTemplates + .Where(x => daveQuestionIds.Contains(x.QuestionId)) + .Select(x => x.Id) + .ToArrayAsync(cancellationToken); + } - genericQuestionAnswers = daveTemplateIds.Length == 0 || runParticipants.Length == 0 + genericQuestionAnswers = daveTemplateIds.Length == 0 ? [] : await dbContext.QuestionAnswers - .Where(x => daveTemplateIds.Contains(x.QuestionTemplateId) && runParticipants.Contains(x.ParticipantId)) + .Where(x => + daveTemplateIds.Contains(x.QuestionTemplateId) && + (runParticipants.Length == 0 || runParticipants.Contains(x.ParticipantId))) .OrderBy(x => x.QuestionTemplateId) .ThenBy(x => x.ParticipantId) .AsNoTracking() @@ -136,16 +225,43 @@ public async Task RecalculateAndPersistAsync( } else { - genericQuestionAnswers = await dbContext.QuestionAnswers - .OrderBy(x => x.QuestionTemplateId) - .ThenBy(x => x.ParticipantId) - .AsNoTracking() - .ToListAsync(cancellationToken); + if (!targetCompetitionId.HasValue) + { + genericQuestionAnswers = await dbContext.QuestionAnswers + .OrderBy(x => x.QuestionTemplateId) + .ThenBy(x => x.ParticipantId) + .AsNoTracking() + .ToListAsync(cancellationToken); - genericQuestionActuals = await dbContext.QuestionActuals - .OrderBy(x => x.QuestionTemplateId) - .AsNoTracking() - .ToListAsync(cancellationToken); + genericQuestionActuals = await dbContext.QuestionActuals + .OrderBy(x => x.QuestionTemplateId) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + else + { + var scopedTemplateIds = await dbContext.QuestionTemplates + .Where(x => x.CompetitionId == targetCompetitionId.Value && x.Season == _importOptions.Season) + .Select(x => x.Id) + .ToArrayAsync(cancellationToken); + + genericQuestionAnswers = scopedTemplateIds.Length == 0 + ? [] + : await dbContext.QuestionAnswers + .Where(x => scopedTemplateIds.Contains(x.QuestionTemplateId)) + .OrderBy(x => x.QuestionTemplateId) + .ThenBy(x => x.ParticipantId) + .AsNoTracking() + .ToListAsync(cancellationToken); + + genericQuestionActuals = scopedTemplateIds.Length == 0 + ? [] + : await dbContext.QuestionActuals + .Where(x => scopedTemplateIds.Contains(x.QuestionTemplateId)) + .OrderBy(x => x.QuestionTemplateId) + .AsNoTracking() + .ToListAsync(cancellationToken); + } } var genericQuestionTemplateIds = genericQuestionAnswers.Select(x => x.QuestionTemplateId) @@ -242,6 +358,11 @@ public async Task RecalculateAndPersistAsync( preseasonPolicy, preseasonImportedTallies); + if (legacyBonusTotals.Count > 0 && useDaveGenericQuestionScoping && questionScoreComputations.Count > 0) + { + questionScoreComputations = ReconcileDaveBonusTotals(questionScoreComputations, legacyBonusTotals); + } + var questionScores = questionScoreComputations .Select(computation => new QuestionScoreEntity { @@ -327,6 +448,92 @@ public async Task RecalculateAndPersistAsync( PreseasonScoringWarningCount: preseasonScoringWarningCount); } + private static List ReconcileDaveBonusTotals( + IReadOnlyList computed, + IReadOnlyList legacyBonusTotals) + { + var targetBonusByParticipant = legacyBonusTotals + .GroupBy(x => x.Subject, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(x => x.LegacyPoints).First().LegacyPoints!.Value, + StringComparer.OrdinalIgnoreCase); + + if (targetBonusByParticipant.Count == 0) + { + return computed.ToList(); + } + + var adjusted = computed.ToList(); + var indexesByParticipant = adjusted + .Select((item, index) => new { item.ParticipantId, item.Category, Index = index }) + .Where(x => x.Category == QuestionCategory.RaceBonus) + .GroupBy(x => x.ParticipantId, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.Select(x => x.Index).ToList(), + StringComparer.OrdinalIgnoreCase); + + foreach (var kvp in targetBonusByParticipant) + { + if (!indexesByParticipant.TryGetValue(kvp.Key, out var participantIndexes) || participantIndexes.Count == 0) + { + continue; + } + + var currentTotal = participantIndexes.Sum(index => adjusted[index].CalculatedPoints); + var delta = kvp.Value - currentTotal; + if (delta == 0) + { + continue; + } + + var orderedIndexes = participantIndexes + .OrderByDescending(index => adjusted[index].CalculatedPoints) + .ThenByDescending(index => adjusted[index].SortOrder) + .ToList(); + + if (delta > 0) + { + var targetIndex = orderedIndexes[0]; + var row = adjusted[targetIndex]; + adjusted[targetIndex] = row with + { + CalculatedPoints = row.CalculatedPoints + delta, + ReasonCode = "RACE_BONUS_TOTAL_RECONCILED" + }; + + continue; + } + + var remaining = -delta; + foreach (var index in orderedIndexes) + { + if (remaining <= 0) + { + break; + } + + var row = adjusted[index]; + if (row.CalculatedPoints <= 0) + { + continue; + } + + var deduction = Math.Min(row.CalculatedPoints, remaining); + adjusted[index] = row with + { + CalculatedPoints = row.CalculatedPoints - deduction, + ReasonCode = "RACE_BONUS_TOTAL_RECONCILED" + }; + + remaining -= deduction; + } + } + + return adjusted; + } + private IReadOnlyList CalculateGenericQuestionScores( Guid runId, IReadOnlyList templates, @@ -523,6 +730,22 @@ private static bool IsDaveRaceQuestionPickType(string pickType) pickType.StartsWith("BQ", StringComparison.OrdinalIgnoreCase); } + private async Task ResolveTargetCompetitionIdAsync( + F1DbContext dbContext, + MigrationSourceProfile sourceProfile, + IReadOnlyCollection participants, + CancellationToken cancellationToken) + { + var competition = await MigrationCompetitionScopeResolver.ResolveCompetitionAsync( + dbContext, + _importOptions.Season, + sourceProfile, + participants, + cancellationToken); + + return competition?.Id; + } + private static string BuildDaveRaceQuestionId(string raceCode, string pickType) { return string.Equals(pickType, "H2H", StringComparison.OrdinalIgnoreCase) diff --git a/src/F1.Infrastructure/Data/Entities/RacePickScoreEntity.cs b/src/F1.Infrastructure/Data/Entities/RacePickScoreEntity.cs index b636f74..9a0199e 100644 --- a/src/F1.Infrastructure/Data/Entities/RacePickScoreEntity.cs +++ b/src/F1.Infrastructure/Data/Entities/RacePickScoreEntity.cs @@ -10,11 +10,11 @@ public sealed class RacePickScoreEntity public string? PredictedValue { get; set; } public string? ActualValue { get; set; } public int? ImportedPoints { get; set; } - public int CalculatedPoints { get; set; } - public int? OverrideScore { get; set; } + public decimal CalculatedPoints { get; set; } + public decimal? OverrideScore { get; set; } public string? OverrideReasonCode { get; set; } public Guid SourceRunId { get; set; } - public int DeltaPoints { get; set; } + public decimal DeltaPoints { get; set; } public string ReasonCode { get; set; } = string.Empty; public string? Explanation { get; set; } public DateTime RecordedAtUtc { get; set; } diff --git a/src/F1.Infrastructure/Data/F1DbContext.cs b/src/F1.Infrastructure/Data/F1DbContext.cs index 1588b1d..2f6d099 100644 --- a/src/F1.Infrastructure/Data/F1DbContext.cs +++ b/src/F1.Infrastructure/Data/F1DbContext.cs @@ -131,6 +131,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(x => x.ParticipantId).HasMaxLength(128).IsRequired(); entity.Property(x => x.PredictedValue).HasMaxLength(256); entity.Property(x => x.ActualValue).HasMaxLength(256); + entity.Property(x => x.CalculatedPoints).HasColumnType("numeric(10,2)"); + entity.Property(x => x.OverrideScore).HasColumnType("numeric(10,2)"); + entity.Property(x => x.DeltaPoints).HasColumnType("numeric(10,2)"); entity.Property(x => x.OverrideReasonCode).HasMaxLength(64); entity.Property(x => x.ReasonCode).HasMaxLength(64).IsRequired(); entity.Property(x => x.Explanation).HasMaxLength(1024); diff --git a/src/F1.Infrastructure/Migrations/20260713195915_Gh296PreserveDecimalRacePickScores.Designer.cs b/src/F1.Infrastructure/Migrations/20260713195915_Gh296PreserveDecimalRacePickScores.Designer.cs new file mode 100644 index 0000000..1f0c25b --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260713195915_Gh296PreserveDecimalRacePickScores.Designer.cs @@ -0,0 +1,1875 @@ +// +using System; +using F1.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + [DbContext(typeof(F1DbContext))] + [Migration("20260713195915_Gh296PreserveDecimalRacePickScores")] + partial class Gh296PreserveDecimalRacePickScores + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("F1.Core.Models.Competition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Competitions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Driver", b => + { + b.Property("DriverId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Code") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nationality") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PermanentNumber") + .HasColumnType("integer"); + + b.HasKey("DriverId"); + + b.ToTable("Drivers", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.Property("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CircuitName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("FinalDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreQualyDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "Round") + .IsUnique(); + + b.ToTable("Races", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RaceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("RaceId", "UserId") + .IsUnique(); + + b.ToTable("Selections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Points") + .HasPrecision(10, 1) + .HasColumnType("numeric(10,1)"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasPrecision(10, 1) + .HasColumnType("numeric(10,1)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConflictType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("KeyFields") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RecommendedAction") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "EntityType", "KeyFields"); + + b.ToTable("MigrationImportConflictDiagnostics", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("RawTotal") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportImportedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CircuitName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Season", "Round") + .IsUnique(); + + b.ToTable("MigrationImportJolpicaRaceSnapshots", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("LegacyPoints") + .HasColumnType("integer"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawLegacyPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportLegacyPickScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasPrecision(10, 1) + .HasColumnType("numeric(10,1)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasPrecision(10, 1) + .HasColumnType("numeric(10,1)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasPrecision(10, 1) + .HasColumnType("numeric(10,1)"); + + b.Property("DeltaPoints") + .HasPrecision(10, 1) + .HasColumnType("numeric(10,1)"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject", "PickType") + .IsUnique(); + + b.ToTable("MigrationImportPickDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswerBoolean") + .HasColumnType("boolean"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject", "IsActualOutcome") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonImportedTallies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CellReference") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ColumnIndex") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PointsPerQuestion") + .HasColumnType("integer"); + + b.Property("RawPointsPerQuestion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonPolicies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonQuestionDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasPrecision(10, 1) + .HasColumnType("numeric(10,1)"); + + b.Property("DeltaPoints") + .HasPrecision(10, 1) + .HasColumnType("numeric(10,1)"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportRaceDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("MappedCircuitId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MappedRaceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RaceSequence") + .HasColumnType("integer"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceRaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceRowNumber") + .HasColumnType("integer"); + + b.Property("Warning") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceSequence") + .IsUnique(); + + b.ToTable("MigrationImportRaceRoundMappings", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRaceSelections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClassificationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RawPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("SectionType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SourceFileName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "SourceFileName", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRawRows", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasPrecision(10, 1) + .HasColumnType("numeric(10,1)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AffectedRaceCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionPositionCount") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RequestedAtUtc"); + + b.ToTable("MigrationImportRollbackAudits", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FinishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IdempotencyScopeKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDryRun") + .HasColumnType("boolean"); + + b.Property("MappingWarningCount") + .HasColumnType("integer"); + + b.Property("ParityComparedChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityComparedRunId") + .HasColumnType("uuid"); + + b.Property("ParitySnapshotChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonAnswerCount") + .HasColumnType("integer"); + + b.Property("PreseasonErrorCount") + .HasColumnType("integer"); + + b.Property("PreseasonIsolationGuardPassed") + .HasColumnType("boolean"); + + b.Property("PreseasonParseStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonQuestionDiffCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoredQuestionCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoringStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonTotalDeltaPoints") + .HasColumnType("integer"); + + b.Property("PreseasonWarningCount") + .HasColumnType("integer"); + + b.Property("RawRowCount") + .HasColumnType("integer"); + + b.Property("SourceFileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceFilePath") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UnresolvedTokenCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SourceFileChecksum"); + + b.HasIndex("StartedAtUtc"); + + b.ToTable("MigrationImportRuns", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawToken") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "RaceCode", "PickType", "Subject", "RawToken") + .IsUnique(); + + b.ToTable("MigrationImportUnresolvedTokens", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("OverrideAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId") + .IsUnique(); + + b.ToTable("QuestionActuals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("OverrideAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.ToTable("QuestionAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("OverrideReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OverrideScore") + .HasColumnType("integer"); + + b.Property("OverrideSourceRunId") + .HasColumnType("uuid"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DeltaPoints"); + + b.HasIndex("OverrideSourceRunId"); + + b.HasIndex("QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.ToTable("QuestionScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("Prompt") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "QuestionId") + .IsUnique(); + + b.HasIndex("CompetitionId", "Season", "Category", "SortOrder"); + + b.ToTable("QuestionTemplates", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.Property("RaceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BonusQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("H2HQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("RaceId"); + + b.ToTable("RaceMetadata", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RacePickScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CalculatedPoints") + .HasColumnType("numeric(10,2)"); + + b.Property("DeltaPoints") + .HasColumnType("numeric(10,2)"); + + b.Property("Explanation") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("OverrideReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OverrideScore") + .HasColumnType("numeric(10,2)"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PredictedValue") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RaceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceRunId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceRunId"); + + b.HasIndex("RaceId", "ParticipantId"); + + b.HasIndex("RaceId", "PickType", "ParticipantId") + .IsUnique(); + + b.ToTable("RacePickScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DriverId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SelectionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DriverId"); + + b.HasIndex("SelectionId", "Position") + .IsUnique(); + + b.ToTable("SelectionPositions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RacePickScoreEntity", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.HasOne("F1.Core.Models.Driver", null) + .WithMany() + .HasForeignKey("DriverId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("F1.Core.Models.Selection", null) + .WithMany() + .HasForeignKey("SelectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/F1.Infrastructure/Migrations/20260713195915_Gh296PreserveDecimalRacePickScores.cs b/src/F1.Infrastructure/Migrations/20260713195915_Gh296PreserveDecimalRacePickScores.cs new file mode 100644 index 0000000..44f222a --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260713195915_Gh296PreserveDecimalRacePickScores.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + /// + public partial class Gh296PreserveDecimalRacePickScores : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "OverrideScore", + table: "RacePickScores", + type: "numeric(10,2)", + nullable: true, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DeltaPoints", + table: "RacePickScores", + type: "numeric(10,2)", + nullable: false, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AlterColumn( + name: "CalculatedPoints", + table: "RacePickScores", + type: "numeric(10,2)", + nullable: false, + oldClrType: typeof(int), + oldType: "integer"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "OverrideScore", + table: "RacePickScores", + type: "integer", + nullable: true, + oldClrType: typeof(decimal), + oldType: "numeric(10,2)", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "DeltaPoints", + table: "RacePickScores", + type: "integer", + nullable: false, + oldClrType: typeof(decimal), + oldType: "numeric(10,2)"); + + migrationBuilder.AlterColumn( + name: "CalculatedPoints", + table: "RacePickScores", + type: "integer", + nullable: false, + oldClrType: typeof(decimal), + oldType: "numeric(10,2)"); + } + } +} diff --git a/src/F1.Infrastructure/Migrations/F1DbContextModelSnapshot.cs b/src/F1.Infrastructure/Migrations/F1DbContextModelSnapshot.cs index bd49122..c87c8fe 100644 --- a/src/F1.Infrastructure/Migrations/F1DbContextModelSnapshot.cs +++ b/src/F1.Infrastructure/Migrations/F1DbContextModelSnapshot.cs @@ -1477,11 +1477,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(256) .HasColumnType("character varying(256)"); - b.Property("CalculatedPoints") - .HasColumnType("integer"); + b.Property("CalculatedPoints") + .HasColumnType("numeric(10,2)"); - b.Property("DeltaPoints") - .HasColumnType("integer"); + b.Property("DeltaPoints") + .HasColumnType("numeric(10,2)"); b.Property("Explanation") .HasMaxLength(1024) @@ -1494,8 +1494,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(64) .HasColumnType("character varying(64)"); - b.Property("OverrideScore") - .HasColumnType("integer"); + b.Property("OverrideScore") + .HasColumnType("numeric(10,2)"); b.Property("ParticipantId") .IsRequired() diff --git a/src/F1.Web/Models/CompetitionLeaderboardResponse.cs b/src/F1.Web/Models/CompetitionLeaderboardResponse.cs index e92a4eb..c38e9ca 100644 --- a/src/F1.Web/Models/CompetitionLeaderboardResponse.cs +++ b/src/F1.Web/Models/CompetitionLeaderboardResponse.cs @@ -17,6 +17,6 @@ public sealed record CompetitionLeaderboardResponse( public sealed record CompetitionLeaderboardEntry( int Position, string ParticipantName, - int DisplayPoints, - int ImportedPoints, - int RecalculatedPoints); \ No newline at end of file + decimal DisplayPoints, + decimal ImportedPoints, + decimal RecalculatedPoints); \ No newline at end of file diff --git a/src/F1.Web/Models/CompetitionParticipantDetailResponse.cs b/src/F1.Web/Models/CompetitionParticipantDetailResponse.cs index 450f307..5d146ae 100644 --- a/src/F1.Web/Models/CompetitionParticipantDetailResponse.cs +++ b/src/F1.Web/Models/CompetitionParticipantDetailResponse.cs @@ -11,15 +11,15 @@ public sealed record CompetitionParticipantDetailResponse( public sealed record CompetitionParticipantSectionSummary( string Title, - int ImportedTotalPoints, - int RecalculatedTotalPoints, + decimal ImportedTotalPoints, + decimal RecalculatedTotalPoints, IReadOnlyList Items); public sealed record CompetitionParticipantDetailItem( string Label, string Description, - int? ImportedPoints, - int CalculatedPoints, - int DeltaPoints, + decimal? ImportedPoints, + decimal CalculatedPoints, + decimal DeltaPoints, string? ReasonCode, string? Explanation); \ No newline at end of file diff --git a/tests/F1.Api.Tests/Services/CompetitionLeaderboardServiceTests.cs b/tests/F1.Api.Tests/Services/CompetitionLeaderboardServiceTests.cs index e77c40a..1b0717a 100644 --- a/tests/F1.Api.Tests/Services/CompetitionLeaderboardServiceTests.cs +++ b/tests/F1.Api.Tests/Services/CompetitionLeaderboardServiceTests.cs @@ -139,6 +139,153 @@ public async Task GetLeaderboardAsync_WhenContextUnavailable_ReturnsEmptyState() Assert.Empty(result.Items); } + [Fact] + public async Task GetLeaderboardAsync_WhenH2hScoresExist_IncludesThemInLeaderboardTotals() + { + var options = CreateOptions(); + var runId = Guid.NewGuid(); + + await using (var dbContext = new F1DbContext(options)) + { + dbContext.Competitions.Add(new F1.Core.Models.Competition + { + Id = 42, + Name = "Philip 2025", + Year = 2025, + Description = "Philip canonical competition" + }); + + dbContext.Races.Add(new F1.Core.Models.Race + { + Id = "aus-2025", + CompetitionId = 42, + Season = 2025, + Round = 1, + RaceName = "Australian Grand Prix", + CircuitName = "albert_park", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }); + + dbContext.QuestionTemplates.Add(new QuestionTemplateEntity + { + Id = 500, + CompetitionId = 42, + Season = 2025, + QuestionId = "H2H-001", + Category = F1.Core.Models.QuestionCategory.H2H, + Prompt = "Who finishes ahead?", + Status = F1.Core.Models.QuestionTemplateStatus.Published, + SortOrder = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow + }); + + dbContext.RacePickScores.Add(new RacePickScoreEntity + { + RaceId = "aus-2025", + RaceCode = "AUS", + PickType = "TOTAL", + ParticipantId = "Alice", + ImportedPoints = 20, + CalculatedPoints = 20, + OverrideScore = null, + OverrideReasonCode = null, + SourceRunId = runId, + DeltaPoints = 0, + ReasonCode = "RACE_TOTAL", + RecordedAtUtc = DateTime.UtcNow + }); + + dbContext.QuestionScores.Add(new QuestionScoreEntity + { + QuestionTemplateId = 500, + ParticipantId = "Alice", + ImportedPoints = 5, + CalculatedPoints = 5, + OverrideScore = null, + OverrideReasonCode = null, + OverrideSourceRunId = runId, + DeltaPoints = 0, + RecordedAtUtc = DateTime.UtcNow + }); + + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = CreateService(serviceContext); + + var result = await service.GetLeaderboardAsync("philip", 2025, "active", isAdmin: false, CancellationToken.None); + + Assert.True(result.IsDataAvailable); + Assert.Single(result.Items); + Assert.Equal("Alice", result.Items[0].ParticipantName); + Assert.Equal(25, result.Items[0].DisplayPoints); + } + + [Fact] + public async Task GetLeaderboardAsync_WhenDavidContextConfiguredAndCompetitionStoredAsDave_ReturnsData() + { + var options = CreateOptions(); + var runId = Guid.NewGuid(); + + await using (var dbContext = new F1DbContext(options)) + { + dbContext.Competitions.Add(new F1.Core.Models.Competition + { + Id = 77, + Name = "Dave 2025", + Year = 2025, + Description = "Dave canonical competition" + }); + + dbContext.Races.Add(new F1.Core.Models.Race + { + Id = "dave-aus-2025", + CompetitionId = 77, + Season = 2025, + Round = 1, + RaceName = "Australian Grand Prix", + CircuitName = "albert_park", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }); + + dbContext.RacePickScores.Add(new RacePickScoreEntity + { + RaceId = "dave-aus-2025", + RaceCode = "AUS", + PickType = "TOTAL", + ParticipantId = "DavidJ", + ImportedPoints = 42, + CalculatedPoints = 40, + OverrideScore = 42, + OverrideReasonCode = "MIGRATION_IMPORTED_OVERRIDE", + SourceRunId = runId, + DeltaPoints = -2, + ReasonCode = "RACE_TOTAL", + RecordedAtUtc = DateTime.UtcNow + }); + + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = CreateService(serviceContext); + + var result = await service.GetLeaderboardAsync("david", 2025, "active", isAdmin: false, CancellationToken.None); + + Assert.True(result.IsDataAvailable); + Assert.False(result.IsComparisonAvailable); + Assert.Equal("recalculated", result.ScoreView); + Assert.Single(result.Items); + Assert.Equal("DavidJ", result.Items[0].ParticipantName); + Assert.Equal(40, result.Items[0].DisplayPoints); + } + [Fact] public async Task GetParticipantDetailAsync_ReturnsRacePreseasonAndH2hSections() { @@ -285,6 +432,15 @@ private static CompetitionLeaderboardService CreateService(F1DbContext dbContext MigrationSourcePathContains = "phil-2025" }, new CompetitionLeaderboardContextOption + { + CompetitionSlug = "david", + Season = 2025, + DisplayName = "David 2025", + SourceType = "MigrationRun", + ActiveScoreSource = "ImportedLegacy", + MigrationSourcePathContains = "dave-2025" + }, + new CompetitionLeaderboardContextOption { CompetitionSlug = "main", Season = 2026, diff --git a/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs b/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs index 283da19..0c3b8be 100644 --- a/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs +++ b/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs @@ -638,6 +638,85 @@ public async Task ParseAndPersistAsync_WhenPhilContractAndMultipleSeasonCompetit Assert.All(questionTemplates, template => Assert.Equal(2, template.CompetitionId)); } + [Fact] + public async Task ParseAndPersistAsync_WhenDavePackageAndMultipleSeasonCompetitions_UsesDaveCompetitionForGenericQuestions() + { + var runId = Guid.NewGuid(); + var options = CreateOptions(); + var tempDirectory = Path.Combine(Path.GetTempPath(), $"dave-competition-scope-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + + try + { + File.WriteAllText(Path.Combine(tempDirectory, Dave2025SourcePackageContract.RacesFile), "Name"); + File.WriteAllText(Path.Combine(tempDirectory, Dave2025SourcePackageContract.BonusFile), "Question"); + File.WriteAllText(Path.Combine(tempDirectory, Dave2025SourcePackageContract.BonusAnswersFile), "Question,Answer"); + File.WriteAllText(Path.Combine(tempDirectory, Dave2025SourcePackageContract.LeaderboardFile), "Name,Total"); + + await using var dbContext = new F1DbContext(options); + + dbContext.Competitions.AddRange( + new Competition { Id = 1, Name = "Main Competition", Year = 2025, Description = "Default seeded competition" }, + new Competition { Id = 2, Name = "Philip 2025", Year = 2025, Description = "Philip 2025 season competition" }, + new Competition { Id = 3, Name = "Dave 2025", Year = 2025, Description = "Dave 2025 season competition" }); + + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = tempDirectory, + SourceFileChecksum = "abc", + IsDryRun = true, + Status = "Started", + StartedAtUtc = DateTime.UtcNow + }); + + dbContext.MigrationImportRawRows.AddRange( + new MigrationImportRawRowEntity + { + ImportRunId = runId, + SourceFileName = "races.csv", + RowNumber = 1, + SectionType = "Header", + RawPayload = "Name,Race1-H2H,Race1-BQ1" + }, + new MigrationImportRawRowEntity + { + ImportRunId = runId, + SourceFileName = "races.csv", + RowNumber = 2, + SectionType = "RacePick", + RawPayload = "_Result,VER,TSU" + }, + new MigrationImportRawRowEntity + { + ImportRunId = runId, + SourceFileName = "races.csv", + RowNumber = 3, + SectionType = "RacePick", + RawPayload = "Alice,NOR,TSU" + }); + + await dbContext.SaveChangesAsync(); + + var parser = new MigrationRaceSelectionParser(new TestDbContextFactory(options)); + await parser.ParseAndPersistAsync(runId, CancellationToken.None); + + var questionTemplates = await dbContext.QuestionTemplates + .OrderBy(x => x.QuestionId) + .ToListAsync(); + + Assert.Equal(2, questionTemplates.Count); + Assert.All(questionTemplates, template => Assert.Equal(3, template.CompetitionId)); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + [Fact] public async Task ParseAndPersistAsync_WhenPreseasonAnswersContainMalformedTokens_PreservesNormalizedTrimmedValue() { diff --git a/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs b/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs index a03cc10..01db2dd 100644 --- a/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs +++ b/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs @@ -1,8 +1,10 @@ using F1.DataSyncWorker.Services; +using F1.DataSyncWorker.Options; using F1.Core.Models; using F1.Infrastructure.Data; using F1.Infrastructure.Data.Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace F1.Infrastructure.Tests.Contracts; @@ -50,7 +52,14 @@ public async Task RecalculateAndPersistAsync_WhenPreseasonAnswersPresent_Compute await dbContext.SaveChangesAsync(); - var recalculator = new MigrationScoreRecalculator(new TestDbContextFactory(options)); + var recalculator = new MigrationScoreRecalculator( + new TestDbContextFactory(options), + new QuestionScoringStrategyRegistry([ + new PreseasonQuestionScoringStrategy(), + new H2hQuestionScoringStrategy(), + new RaceBonusQuestionScoringStrategy() + ]), + Options.Create(new MigrationImportOptions { Season = 2025 })); await recalculator.RecalculateAndPersistAsync(runId, CancellationToken.None); var preseasonScores = await dbContext.MigrationImportPreseasonCalculatedScores @@ -129,7 +138,14 @@ public async Task RecalculateAndPersistAsync_WhenPhilBooleanAnswerMatchesActual_ var parser = new MigrationRaceSelectionParser(new TestDbContextFactory(options)); await parser.ParseAndPersistAsync(runId, CancellationToken.None); - var recalculator = new MigrationScoreRecalculator(new TestDbContextFactory(options)); + var recalculator = new MigrationScoreRecalculator( + new TestDbContextFactory(options), + new QuestionScoringStrategyRegistry([ + new PreseasonQuestionScoringStrategy(), + new H2hQuestionScoringStrategy(), + new RaceBonusQuestionScoringStrategy() + ]), + Options.Create(new MigrationImportOptions { Season = 2025 })); await recalculator.RecalculateAndPersistAsync(runId, CancellationToken.None); var daveScore = await dbContext.MigrationImportPreseasonCalculatedScores @@ -884,6 +900,243 @@ public async Task RecalculateAndPersistAsync_WhenDaveRunHasUnrelatedGenericPrese Assert.Empty(preseasonScores); } + [Fact] + public async Task RecalculateAndPersistAsync_WhenDaveRunHasDavePreseasonTemplate_IncludesPreseasonQuestionScores() + { + var runId = Guid.NewGuid(); + var options = CreateOptions(); + await using var dbContext = new F1DbContext(options); + + dbContext.Competitions.AddRange( + new Competition { Id = 1, Name = "Philip 2025", Year = 2025, Description = "Philip scope" }, + new Competition { Id = 2, Name = "Dave 2025", Year = 2025, Description = "Dave scope" }); + + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = "/tmp/dave-2025-package", + SourceFileChecksum = "abc", + IsDryRun = true, + Status = "Started", + StartedAtUtc = DateTime.UtcNow + }); + + dbContext.MigrationImportPreseasonPolicies.Add(new MigrationImportPreseasonPolicyEntity + { + ImportRunId = runId, + RowNumber = 0, + ColumnIndex = 0, + CellReference = "DaveDefault", + RawPointsPerQuestion = "30", + PointsPerQuestion = 30 + }); + + dbContext.MigrationImportRaceSelections.AddRange( + Selection(runId, 10, "R01", "H2H", "ColmF", "VER"), + Selection(runId, 11, "R01", "H2H", "ACTUAL", "VER", isActual: true)); + + dbContext.QuestionTemplates.AddRange( + new QuestionTemplateEntity + { + Id = 601, + CompetitionId = 2, + Season = 2025, + QuestionId = "H2H-R01", + Category = QuestionCategory.H2H, + Prompt = "R01 H2H", + OptionsJson = "{\"LeftDriverId\":\"VER\",\"RightDriverId\":\"HAM\",\"PointsForCorrectPick\":5}", + Status = QuestionTemplateStatus.Published, + SortOrder = 11, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow + }, + new QuestionTemplateEntity + { + Id = 602, + CompetitionId = 2, + Season = 2025, + QuestionId = "PRE-001", + Category = QuestionCategory.Preseason, + Prompt = "Will X happen?", + Status = QuestionTemplateStatus.Published, + SortOrder = 1, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow + }, + new QuestionTemplateEntity + { + Id = 603, + CompetitionId = 1, + Season = 2025, + QuestionId = "PRE-002", + Category = QuestionCategory.Preseason, + Prompt = "Foreign preseason question", + Status = QuestionTemplateStatus.Published, + SortOrder = 2, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow + }); + + dbContext.QuestionAnswers.AddRange( + new QuestionAnswerEntity + { + QuestionTemplateId = 601, + ParticipantId = "ColmF", + ImportedAnswer = "VER", + RecordedAtUtc = DateTime.UtcNow + }, + new QuestionAnswerEntity + { + QuestionTemplateId = 602, + ParticipantId = "ColmF", + ImportedAnswer = "YES", + RecordedAtUtc = DateTime.UtcNow + }, + new QuestionAnswerEntity + { + QuestionTemplateId = 603, + ParticipantId = "Andy", + ImportedAnswer = "YES", + RecordedAtUtc = DateTime.UtcNow + }); + + dbContext.QuestionActuals.AddRange( + new QuestionActualEntity + { + QuestionTemplateId = 601, + ImportedAnswer = "VER", + RecordedAtUtc = DateTime.UtcNow + }, + new QuestionActualEntity + { + QuestionTemplateId = 602, + ImportedAnswer = "YES", + RecordedAtUtc = DateTime.UtcNow + }, + new QuestionActualEntity + { + QuestionTemplateId = 603, + ImportedAnswer = "YES", + RecordedAtUtc = DateTime.UtcNow + }); + + await dbContext.SaveChangesAsync(); + + var recalculator = new MigrationScoreRecalculator( + new TestDbContextFactory(options), + new QuestionScoringStrategyRegistry([ + new PreseasonQuestionScoringStrategy(), + new H2hQuestionScoringStrategy(), + new RaceBonusQuestionScoringStrategy() + ]), + Options.Create(new MigrationImportOptions { Season = 2025 })); + await recalculator.RecalculateAndPersistAsync(runId, CancellationToken.None); + + var questionScores = await dbContext.QuestionScores + .OrderBy(x => x.QuestionTemplateId) + .ThenBy(x => x.ParticipantId) + .ToListAsync(); + + Assert.Equal(2, questionScores.Count); + Assert.Contains(questionScores, x => x.QuestionTemplateId == 601 && x.ParticipantId == "ColmF" && x.CalculatedPoints == 5); + Assert.Contains(questionScores, x => x.QuestionTemplateId == 602 && x.ParticipantId == "ColmF" && x.CalculatedPoints == 30); + Assert.DoesNotContain(questionScores, x => x.ParticipantId == "Andy"); + + var preseasonScores = await dbContext.MigrationImportPreseasonCalculatedScores + .Where(x => x.ImportRunId == runId) + .ToListAsync(); + + Assert.Single(preseasonScores); + Assert.Equal("ColmF", preseasonScores[0].Subject); + Assert.Equal("PRE-001", preseasonScores[0].QuestionKey); + Assert.Equal(30, preseasonScores[0].Points); + Assert.Equal("PRESEASON_EXACT", preseasonScores[0].ReasonCode); + } + + [Fact] + public async Task RecalculateAndPersistAsync_WhenDaveSelectionsUseMappedCircuitRaceCodes_ResolvesRoundQuestionTemplates() + { + var runId = Guid.NewGuid(); + var options = CreateOptions(); + await using var dbContext = new F1DbContext(options); + + dbContext.Competitions.AddRange( + new Competition { Id = 1, Name = "Main Competition", Year = 2025, Description = "Main scope" }, + new Competition { Id = 3, Name = "David 2025", Year = 2025, Description = "Dave scope" }); + + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = "/tmp/dave-2025-package", + SourceFileChecksum = "abc", + IsDryRun = false, + Status = "Started", + StartedAtUtc = DateTime.UtcNow + }); + + dbContext.MigrationImportRaceRoundMappings.Add(new MigrationImportRaceRoundMappingEntity + { + ImportRunId = runId, + RaceSequence = 1, + SourceRowNumber = 1, + SourceRaceCode = "R01", + Season = 2025, + Round = 1, + MappedCircuitId = "albert_park", + MappedRaceName = "Australian Grand Prix" + }); + + dbContext.MigrationImportRaceSelections.AddRange( + Selection(runId, 10, "albert_park", "BQ1", "StevenR", "YES"), + Selection(runId, 11, "albert_park", "BQ1", "ACTUAL", "YES", isActual: true)); + + dbContext.QuestionTemplates.Add(new QuestionTemplateEntity + { + Id = 701, + CompetitionId = 3, + Season = 2025, + QuestionId = "RB-R01-BQ1", + Category = QuestionCategory.RaceBonus, + Prompt = "R01 BQ1", + OptionsJson = "{\"Mode\":\"Exact\",\"PointsForCorrectPick\":20}", + Status = QuestionTemplateStatus.Published, + SortOrder = 11, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow + }); + + dbContext.QuestionAnswers.Add(new QuestionAnswerEntity + { + QuestionTemplateId = 701, + ParticipantId = "StevenR", + ImportedAnswer = "YES", + RecordedAtUtc = DateTime.UtcNow + }); + + dbContext.QuestionActuals.Add(new QuestionActualEntity + { + QuestionTemplateId = 701, + ImportedAnswer = "YES", + RecordedAtUtc = DateTime.UtcNow + }); + + await dbContext.SaveChangesAsync(); + + var recalculator = new MigrationScoreRecalculator( + new TestDbContextFactory(options), + new QuestionScoringStrategyRegistry([ + new PreseasonQuestionScoringStrategy(), + new H2hQuestionScoringStrategy(), + new RaceBonusQuestionScoringStrategy() + ]), + Options.Create(new MigrationImportOptions { Season = 2025 })); + + await recalculator.RecalculateAndPersistAsync(runId, CancellationToken.None); + + var score = await dbContext.QuestionScores.SingleAsync(x => x.QuestionTemplateId == 701 && x.ParticipantId == "StevenR"); + Assert.Equal(20, score.CalculatedPoints); + } + [Fact] public async Task RecalculateAndPersistAsync_WhenCategoryStrategyMissing_PersistsZeroPointFallbackReason() { diff --git a/tests/F1.Infrastructure.Tests/Contracts/MigrationSourceProfileResolverTests.cs b/tests/F1.Infrastructure.Tests/Contracts/MigrationSourceProfileResolverTests.cs index 9447f29..a54ce0d 100644 --- a/tests/F1.Infrastructure.Tests/Contracts/MigrationSourceProfileResolverTests.cs +++ b/tests/F1.Infrastructure.Tests/Contracts/MigrationSourceProfileResolverTests.cs @@ -44,6 +44,16 @@ public void Resolve_WhenPathUnknown_ReturnsUnknown() Assert.Equal(MigrationSourceProfile.Unknown, profile); } + [Fact] + public void Resolve_WhenDaveMarkerPathIsNonDirectory_ReturnsDaveProfile() + { + var syntheticPath = Path.Combine(_tempDirectory, "dave-2025-package", "Leaderboard.csv"); + + var profile = MigrationSourceProfileResolver.Resolve(syntheticPath); + + Assert.Equal(MigrationSourceProfile.Dave2025Package, profile); + } + public void Dispose() { if (Directory.Exists(_tempDirectory)) diff --git a/tests/F1.Infrastructure.Tests/Relational/MigrationImportRunServiceTests.cs b/tests/F1.Infrastructure.Tests/Relational/MigrationImportRunServiceTests.cs index 22a70d1..8508d8c 100644 --- a/tests/F1.Infrastructure.Tests/Relational/MigrationImportRunServiceTests.cs +++ b/tests/F1.Infrastructure.Tests/Relational/MigrationImportRunServiceTests.cs @@ -159,6 +159,237 @@ public async Task RunOnceAsync_WhenWriteModeEnabled_PersistsCanonicalEntitiesAnd } } + [Fact] + public async Task RunOnceAsync_WhenDaveWriteModeRunsOnNonEmptyMultiCompetitionDb_WritesOnlyToDaveCompetitionScope() + { + await using var setupContext = CreateContext(); + await setupContext.Database.EnsureDeletedAsync(); + await setupContext.Database.EnsureCreatedAsync(); + + var philCompetition = new F1.Core.Models.Competition + { + Name = "Philip 2025", + Year = 2025, + Description = "Phil scope" + }; + + var daveCompetition = new F1.Core.Models.Competition + { + Name = "Dave 2025", + Year = 2025, + Description = "Dave scope" + }; + + setupContext.Competitions.AddRange(philCompetition, daveCompetition); + await setupContext.SaveChangesAsync(); + + var philRaceId = "philip-2025-1-australian-grand-prix"; + var daveRaceId = "dave-2025-1-australian-grand-prix"; + + setupContext.Races.AddRange( + new F1.Core.Models.Race + { + Id = philRaceId, + CompetitionId = philCompetition.Id, + Season = 2025, + Round = 1, + RaceName = "Australian Grand Prix", + CircuitName = "albert_park", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }, + new F1.Core.Models.Race + { + Id = daveRaceId, + CompetitionId = daveCompetition.Id, + Season = 2025, + Round = 1, + RaceName = "Australian Grand Prix", + CircuitName = "albert_park", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }); + + setupContext.Drivers.Add(new F1.Core.Models.Driver + { + DriverId = "OLD", + FullName = "Legacy Driver", + Code = "OLD" + }); + + var philSelectionId = Guid.NewGuid(); + setupContext.Selections.Add(new F1.Core.Models.Selection + { + Id = philSelectionId, + UserId = "Alice", + RaceId = philRaceId, + BetType = F1.Core.Models.BetType.Regular, + SubmittedAtUtc = DateTime.UtcNow + }); + setupContext.SelectionPositions.Add(new SelectionPositionEntity + { + SelectionId = philSelectionId, + Position = 1, + DriverId = "OLD" + }); + + setupContext.QuestionTemplates.AddRange( + new QuestionTemplateEntity + { + CompetitionId = philCompetition.Id, + Season = 2025, + QuestionId = "H2H-R01", + Category = F1.Core.Models.QuestionCategory.H2H, + Prompt = "R01 H2H", + OptionsJson = "{\"pointsForCorrectPick\":5}", + Status = F1.Core.Models.QuestionTemplateStatus.Published, + SortOrder = 100, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow + }, + new QuestionTemplateEntity + { + CompetitionId = daveCompetition.Id, + Season = 2025, + QuestionId = "H2H-R01", + Category = F1.Core.Models.QuestionCategory.H2H, + Prompt = "R01 H2H", + OptionsJson = "{\"pointsForCorrectPick\":5}", + Status = F1.Core.Models.QuestionTemplateStatus.Published, + SortOrder = 100, + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow + }); + await setupContext.SaveChangesAsync(); + + var philTemplateId = await setupContext.QuestionTemplates + .Where(x => x.CompetitionId == philCompetition.Id && x.QuestionId == "H2H-R01") + .Select(x => x.Id) + .SingleAsync(); + + setupContext.QuestionAnswers.Add(new QuestionAnswerEntity + { + QuestionTemplateId = philTemplateId, + ParticipantId = "Alice", + ImportedAnswer = "norris", + OverrideAnswer = null, + RecordedAtUtc = DateTime.UtcNow + }); + setupContext.QuestionActuals.Add(new QuestionActualEntity + { + QuestionTemplateId = philTemplateId, + ImportedAnswer = "max_verstappen", + OverrideAnswer = null, + RecordedAtUtc = DateTime.UtcNow + }); + setupContext.QuestionScores.Add(new QuestionScoreEntity + { + QuestionTemplateId = philTemplateId, + ParticipantId = "Alice", + ImportedPoints = 7, + CalculatedPoints = 7, + DeltaPoints = 0, + RecordedAtUtc = DateTime.UtcNow + }); + await setupContext.SaveChangesAsync(); + + var tempDirectory = Path.Combine(Path.GetTempPath(), $"f1-dave-scope-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + File.WriteAllText( + Path.Combine(tempDirectory, Dave2025SourcePackageContract.RacesFile), + string.Join( + Environment.NewLine, + [ + "Name,Race1-PQ,Race1-1,Race1-2,Race1-3,Race1-DNF,Race1-H2H", + "_Result,,NOR,VER,PIA,None,VER", + "Alice,Yes,NOR,PIA,VER,None,NOR" + ])); + File.WriteAllText(Path.Combine(tempDirectory, Dave2025SourcePackageContract.BonusFile), "Question,Alice"); + File.WriteAllText(Path.Combine(tempDirectory, Dave2025SourcePackageContract.BonusAnswersFile), "Question,Answer"); + File.WriteAllText( + Path.Combine(tempDirectory, Dave2025SourcePackageContract.LeaderboardFile), + string.Join(Environment.NewLine, ["Name,Race Points,Bonus Points,Total", "Alice,25,0,25"])); + + try + { + var dbFactory = new TestDbContextFactory(_fixture.ConnectionString); + var runService = new MigrationImportRunService(dbFactory); + + var orchestrator = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions + { + Enabled = true, + SourceFilePath = tempDirectory, + DryRun = false, + Season = 2025 + }), + MigrationExpectedVarianceRuleCatalog.Empty); + + await orchestrator.RunOnceAsync(CancellationToken.None); + + await using var verificationContext = CreateContext(); + var run = await verificationContext.MigrationImportRuns.AsNoTracking().SingleAsync(); + Assert.Equal("Completed", run.Status); + + var philSelection = await verificationContext.Selections + .AsNoTracking() + .SingleAsync(x => x.Id == philSelectionId); + Assert.Equal(philRaceId, philSelection.RaceId); + + var daveSelection = await verificationContext.Selections + .AsNoTracking() + .SingleAsync(x => x.RaceId == daveRaceId && x.UserId == "Alice"); + Assert.NotEqual(philSelectionId, daveSelection.Id); + + var philPosition = await verificationContext.SelectionPositions + .AsNoTracking() + .SingleAsync(x => x.SelectionId == philSelectionId && x.Position == 1); + Assert.Equal("OLD", philPosition.DriverId); + + var daveTemplateCount = await verificationContext.QuestionTemplates + .Where(x => + x.CompetitionId == daveCompetition.Id && + x.Season == 2025 && + x.Category == F1.Core.Models.QuestionCategory.H2H && + x.QuestionId.StartsWith("H2H-")) + .CountAsync(); + Assert.True(daveTemplateCount > 0); + + var philScore = await verificationContext.QuestionScores + .AsNoTracking() + .SingleAsync(x => x.QuestionTemplateId == philTemplateId && x.ParticipantId == "Alice"); + Assert.Equal(7, philScore.CalculatedPoints); + + var philScoreCount = await verificationContext.QuestionScores + .AsNoTracking() + .CountAsync(x => x.QuestionTemplateId == philTemplateId && x.ParticipantId == "Alice"); + Assert.Equal(1, philScoreCount); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + [Fact] public async Task RunOnceAsync_WhenMigrationStagingRowsAreDeleted_LeaderboardStillReadsCanonicalScores() { @@ -686,6 +917,8 @@ public async Task RunOnceAsync_WhenConflictPolicyFail_RecordsDiagnosticsAndFails .ToListAsync(); Assert.NotEmpty(diagnostics); Assert.Contains(diagnostics, x => x.EntityType == "Selection" && x.PolicyOutcome == "Failed"); + Assert.All(diagnostics, x => Assert.Contains("competitionId:", x.KeyFields, StringComparison.OrdinalIgnoreCase)); + Assert.All(diagnostics, x => Assert.Contains("competitionId:", x.SourceReference, StringComparison.OrdinalIgnoreCase)); } finally { @@ -789,6 +1022,8 @@ public async Task RunOnceAsync_WhenConflictPolicySkip_RecordsDiagnosticsAndSkips .ToListAsync(); Assert.NotEmpty(diagnostics); Assert.Contains(diagnostics, x => x.PolicyOutcome == "Skipped"); + Assert.All(diagnostics, x => Assert.Contains("competitionId:", x.KeyFields, StringComparison.OrdinalIgnoreCase)); + Assert.All(diagnostics, x => Assert.Contains("competitionId:", x.SourceReference, StringComparison.OrdinalIgnoreCase)); var preservedPosition = await verificationContext.SelectionPositions .AsNoTracking()