diff --git a/YD_Event.Application/Event/Dtos/ArrangementDtos.cs b/YD_Event.Application/Event/Dtos/ArrangementDtos.cs index c65d3b5..a907baa 100644 --- a/YD_Event.Application/Event/Dtos/ArrangementDtos.cs +++ b/YD_Event.Application/Event/Dtos/ArrangementDtos.cs @@ -187,7 +187,7 @@ public class ArrangementPreviewOutput public string UnitName { get; set; } /// - /// 场次索引 + /// 场次索引(同一赛事内按项目顺序全局连续) /// public int SessionIndex { get; set; } diff --git a/YD_Event.Application/Event/EventArrangementService.cs b/YD_Event.Application/Event/EventArrangementService.cs index 9bcacc5..2179b6f 100644 --- a/YD_Event.Application/Event/EventArrangementService.cs +++ b/YD_Event.Application/Event/EventArrangementService.cs @@ -118,7 +118,7 @@ namespace YD_Event.Application.Event; var projects = await _projectRepository.GetListAsync(x => projectIds.Contains(x.Id)); // 按照 SortIndex 对 projects 排序 - var sortedProjects = projects.OrderBy(x => x.SortIndex).ToList(); + var sortedProjects = projects.OrderBy(x => x.SortIndex).ThenBy(x => x.Id).ToList(); var groups = await _groupRepository.GetListAsync(x => projectIds.Contains(x.ProjectId)); @@ -238,14 +238,24 @@ namespace YD_Event.Application.Event; { // 团队项目:(队伍数 ÷ 场地数) × 项目时长 int teamCount = totalTeams > 0 ? totalTeams : 1; - int rounds = (int)Math.Ceiling((double)teamCount / venueCount); + var groupLoads = projGroups + .Select(group => teamCounts.FirstOrDefault(x => + x.ProjectId == eventProj.Id && x.GroupName == group.GroupName)?.Count ?? 0); + int rounds = IsFreestyleProject(proj.ProjectType) + ? CalculateBalancedVenueLoad(groupLoads, venueCount) + : (int)Math.Ceiling((double)teamCount / venueCount); totalDurationSeconds = rounds * proj.GetTotalDurationInSeconds(); } else { // 个人项目:(报名人数 ÷ 场地数) × 项目时长 int participantCount = totalParticipants > 0 ? totalParticipants : 1; - int rounds = (int)Math.Ceiling((double)participantCount / venueCount); + var groupLoads = projGroups + .Select(group => counts.FirstOrDefault(x => + x.ProjectId == eventProj.Id && x.GroupName == group.GroupName)?.Count ?? 0); + int rounds = IsFreestyleProject(proj.ProjectType) + ? CalculateBalancedVenueLoad(groupLoads, venueCount) + : (int)Math.Ceiling((double)participantCount / venueCount); totalDurationSeconds = rounds * proj.GetTotalDurationInSeconds(); } @@ -297,7 +307,7 @@ namespace YD_Event.Application.Event; var projects = await _projectRepository.GetListAsync(x => projectIds.Contains(x.Id)); // 按照 SortIndex 对 projects 排序 - var sortedProjects = projects.OrderBy(x => x.SortIndex).ToList(); + var sortedProjects = projects.OrderBy(x => x.SortIndex).ThenBy(x => x.Id).ToList(); var groups = await _groupRepository.GetListAsync(x => projectIds.Contains(x.ProjectId)); // 统计各组别报名人数 @@ -333,6 +343,7 @@ namespace YD_Event.Application.Event; var result = new List(); // 2. 生成排期 - 按照排序后的项目顺序处理 + var nextSessionIndex = 1; foreach (var proj in sortedProjects) { var eventProj = eventProjects.FirstOrDefault(x => x.ProjectId == proj.Id); @@ -419,20 +430,29 @@ namespace YD_Event.Application.Event; if (proj.IsTeamProject()) { int teamCount = totalTeams > 0 ? totalTeams : 1; - int rounds = (int)Math.Ceiling((double)teamCount / venueCount); + var groupLoads = projGroups + .Select(group => teamCounts.FirstOrDefault(x => + x.ProjectId == eventProj.Id && x.GroupName == group.GroupName)?.Count ?? 0); + int rounds = IsFreestyleProject(proj.ProjectType) + ? CalculateBalancedVenueLoad(groupLoads, venueCount) + : (int)Math.Ceiling((double)teamCount / venueCount); projectTotalDurationSeconds = rounds * proj.GetTotalDurationInSeconds(); } else { int participantCount = totalParticipants > 0 ? totalParticipants : 1; - int rounds = (int)Math.Ceiling((double)participantCount / venueCount); + var groupLoads = projGroups + .Select(group => counts.FirstOrDefault(x => + x.ProjectId == eventProj.Id && x.GroupName == group.GroupName)?.Count ?? 0); + int rounds = IsFreestyleProject(proj.ProjectType) + ? CalculateBalancedVenueLoad(groupLoads, venueCount) + : (int)Math.Ceiling((double)participantCount / venueCount); projectTotalDurationSeconds = rounds * proj.GetTotalDurationInSeconds(); } string projectTotalDuration = FormatDurationSymbol(projectTotalDurationSeconds); - int currentRound = 1; - int currentVenue = 1; + var projectEntries = new List(); foreach (var group in projGroups) { @@ -454,7 +474,7 @@ namespace YD_Event.Application.Event; var firstPerson = teamGroup.First(); string displayUnitName = GetDisplayUnitName(firstPerson); - result.Add(new ArrangementPreviewOutput + projectEntries.Add(new ArrangementPreviewOutput { EventProjectId = eventProj.Id, ProjectId = proj.Id, @@ -470,18 +490,9 @@ namespace YD_Event.Application.Event; TeamName = teamGroup.Key.TeamName, AthleteName = combinedAthleteNames, UnitName = displayUnitName, - SessionIndex = currentRound, - VenueIndex = currentVenue, ProjectTotalDurationSeconds = projectTotalDurationSeconds, ProjectTotalDuration = projectTotalDuration }); - - currentVenue++; - if (currentVenue > venueCount) - { - currentVenue = 1; - currentRound++; - } } } else @@ -490,7 +501,7 @@ namespace YD_Event.Application.Event; { string displayUnitName = GetDisplayUnitName(person); - result.Add(new ArrangementPreviewOutput + projectEntries.Add(new ArrangementPreviewOutput { EventProjectId = eventProj.Id, ProjectId = proj.Id, @@ -504,26 +515,173 @@ namespace YD_Event.Application.Event; TeamName = person.TeamName, AthleteName = person.Name, UnitName = displayUnitName, - SessionIndex = currentRound, - VenueIndex = currentVenue, ProjectTotalDurationSeconds = projectTotalDurationSeconds, ProjectTotalDuration = projectTotalDuration }); - - currentVenue++; - if (currentVenue > venueCount) - { - currentVenue = 1; - currentRound++; - } } } } + + if (!projectEntries.Any()) continue; + + if (IsFreestyleProject(proj.ProjectType)) + { + AssignFreestyleVenues(projectEntries, projGroups, venueCount, nextSessionIndex); + } + else + { + AssignSpeedSessions(projectEntries, venueCount, nextSessionIndex, eventId, eventProj.Id); + } + + result.AddRange(projectEntries + .OrderBy(x => x.SessionIndex) + .ThenBy(x => x.VenueIndex)); + nextSessionIndex = projectEntries.Max(x => x.SessionIndex) + 1; } return result; } + /// + /// 速度项目按组别交叉单位,再依次填入同一场次的各场地。 + /// 随机种子由赛事和赛事项目确定,避免预览、导出与确认结果反复变化。 + /// + private static void AssignSpeedSessions( + List entries, + int venueCount, + int firstSessionIndex, + long eventId, + long eventProjectId) + { + var random = new Random(CreateStableRandomSeed(eventId, eventProjectId)); + var orderedEntries = new List(entries.Count); + string? lastUnitKey = null; + + foreach (var groupEntries in entries.GroupBy(x => x.GroupName)) + { + var unitQueues = groupEntries + .GroupBy(GetArrangementUnitKey) + .ToDictionary( + x => x.Key, + x => new Queue(x.OrderBy(_ => random.Next()))); + + while (unitQueues.Count > 0) + { + var candidates = unitQueues + .Where(x => x.Key != lastUnitKey) + .ToList(); + if (candidates.Count == 0) + { + candidates = unitQueues.ToList(); + } + + var maxRemaining = candidates.Max(x => x.Value.Count); + var preferredCandidates = candidates + .Where(x => x.Value.Count == maxRemaining) + .ToList(); + var selected = preferredCandidates[random.Next(preferredCandidates.Count)]; + + orderedEntries.Add(selected.Value.Dequeue()); + lastUnitKey = selected.Key; + if (selected.Value.Count == 0) + { + unitQueues.Remove(selected.Key); + } + } + } + + for (var index = 0; index < orderedEntries.Count; index++) + { + orderedEntries[index].SessionIndex = firstSessionIndex + index / venueCount; + orderedEntries[index].VenueIndex = index % venueCount + 1; + } + } + + /// + /// 花样项目以整个组别为不可拆分单元分配场地,优先将大组分配给当前负载最小的场地。 + /// 同一场地内的组别串行,且每个组别的所有出场记录连续。 + /// + private static void AssignFreestyleVenues( + List entries, + IReadOnlyCollection projectGroups, + int venueCount, + int firstSessionIndex) + { + var groupOrder = projectGroups + .Select((group, index) => new { group.GroupName, Index = index }) + .ToDictionary(x => x.GroupName, x => x.Index); + var groups = entries + .GroupBy(x => x.GroupName) + .Select(x => new + { + GroupName = x.Key, + Entries = x.ToList(), + Order = groupOrder.GetValueOrDefault(x.Key, int.MaxValue) + }) + .OrderByDescending(x => x.Entries.Count) + .ThenBy(x => x.Order) + .ToList(); + + var venueLoads = new int[venueCount]; + var venueGroups = Enumerable.Range(0, venueCount) + .ToDictionary(x => x, _ => new List<(int Order, List Entries)>()); + + foreach (var group in groups) + { + var venueIndex = Enumerable.Range(0, venueCount) + .OrderBy(x => venueLoads[x]) + .ThenBy(x => x) + .First(); + venueGroups[venueIndex].Add((group.Order, group.Entries)); + venueLoads[venueIndex] += group.Entries.Count; + } + + foreach (var venue in venueGroups) + { + var sessionIndex = firstSessionIndex; + foreach (var group in venue.Value.OrderBy(x => x.Order)) + { + foreach (var entry in group.Entries) + { + entry.VenueIndex = venue.Key + 1; + entry.SessionIndex = sessionIndex++; + } + } + } + } + + private static bool IsFreestyleProject(ProjectTypeEnum projectType) + { + return projectType is ProjectTypeEnum.PersonalFreestyle or ProjectTypeEnum.TeamFreestyle; + } + + private static string GetArrangementUnitKey(ArrangementPreviewOutput entry) + { + return !string.IsNullOrWhiteSpace(entry.UnitName) + ? entry.UnitName.Trim() + : $"__ENTRY_{entry.RegistrationItemId}"; + } + + private static int CalculateBalancedVenueLoad(IEnumerable groupLoads, int venueCount) + { + var venueLoads = new int[venueCount]; + foreach (var groupLoad in groupLoads.Where(x => x > 0).OrderByDescending(x => x)) + { + var venueIndex = Enumerable.Range(0, venueCount) + .OrderBy(x => venueLoads[x]) + .ThenBy(x => x) + .First(); + venueLoads[venueIndex] += groupLoad; + } + + return venueLoads.Max(); + } + + private static int CreateStableRandomSeed(long eventId, long eventProjectId) + { + return unchecked((int)(eventId ^ (eventId >> 32) ^ eventProjectId ^ (eventProjectId >> 32))); + } + /// 确认编排并固化场地、场次及出场记录。 [HttpPost("confirm/{eventId}"), DisplayName("确认赛事编排"), UnitOfWork] public async Task Confirm(long eventId) @@ -557,13 +715,35 @@ namespace YD_Event.Application.Event; var sessions = new List(); var entries = new List(); var participationRecords = await _participationRepository.AsQueryable().Where(x => x.EventId == eventId).ToListAsync(); var projectMap = projects.ToDictionary(x => x.Id); - foreach (var group in preview.GroupBy(x => new { x.EventProjectId, x.VenueIndex })) + var sessionGroups = preview + .GroupBy(x => new + { + x.EventProjectId, + x.VenueIndex, + EventProjectGroupId = IsFreestyleProject(projectMap[x.ProjectId].ProjectType) + ? x.EventProjectGroupId + : null, + GroupName = IsFreestyleProject(projectMap[x.ProjectId].ProjectType) + ? x.GroupName + : null + }) + .OrderBy(x => projectMap[x.First().ProjectId].SortIndex) + .ThenBy(x => x.Min(item => item.SessionIndex)) + .ThenBy(x => x.Key.VenueIndex); + foreach (var group in sessionGroups) { var first = group.First(); var project = projectMap[first.ProjectId]; var venue = venues.First(x => x.Sort == group.Key.VenueIndex); + var isFreestyle = IsFreestyleProject(project.ProjectType); var session = new EventSession { Id = _idGenerator.NextId(), EventId = eventId, EventProjectId = group.Key.EventProjectId, - EventProjectGroupId = group.Select(x => x.EventProjectGroupId).Distinct().Count() == 1 ? first.EventProjectGroupId : null, - VenueId = venue.Id, ScoreRuleId = project.ScoreRuleId!.Value, SessionNo = $"EP{group.Key.EventProjectId}-{group.Key.VenueIndex:00}", - SessionName = $"{project.Name}-{venue.VenueName}", SessionStatus = 0, Version = 1, Status = AvailabilityStatus.Enable, + EventProjectGroupId = group.Key.EventProjectGroupId, + VenueId = venue.Id, ScoreRuleId = project.ScoreRuleId!.Value, + SessionNo = isFreestyle + ? $"EP{group.Key.EventProjectId}-{group.Key.VenueIndex:00}-{group.Min(x => x.SessionIndex):000}" + : $"EP{group.Key.EventProjectId}-{group.Key.VenueIndex:00}", + SessionName = isFreestyle + ? $"{project.Name}-{first.GroupName}-{venue.VenueName}" + : $"{project.Name}-{venue.VenueName}", + SessionStatus = 0, Version = 1, Status = AvailabilityStatus.Enable, CreatedUserId = userId, CreatedTime = now }; sessions.Add(session); var order = 1; diff --git a/YD_Event.Application/Workbench/WorkbenchController.cs b/YD_Event.Application/Workbench/WorkbenchController.cs index 420a08c..268b53b 100644 --- a/YD_Event.Application/Workbench/WorkbenchController.cs +++ b/YD_Event.Application/Workbench/WorkbenchController.cs @@ -1,5 +1,6 @@ using YD_Event.Application.Auth; using YD_Event.Application.Scoring; +using YD_Event.Application.Scoring.Dtos; using YD_Event.Application.Workbench.Dtos; using YD_Event.Core.Entities; using Newtonsoft.Json.Linq; @@ -683,7 +684,7 @@ public class WorkbenchController : IDynamicApiController ?? throw Oops.Bah("所选组别不属于当前赛事项目"); var groupName = eventProjectGroup.GroupName; - var results = await _resultRepository.AsQueryable() + var rankingQuery = _resultRepository.AsQueryable() .InnerJoin((r, e) => r.SessionEntryId == e.Id) .InnerJoin((r, e, s) => e.SessionId == s.Id) .Where((r, e, s) => !r.DeleteMark && !e.DeleteMark && !s.DeleteMark && @@ -693,19 +694,34 @@ public class WorkbenchController : IDynamicApiController e.GroupName == groupName) && // 只投屏有效成绩(已确认20/已投屏30/已更正40),排除DQ成绩 (r.ResultStatus == 20 || r.ResultStatus == 30 || r.ResultStatus == 40) && - !r.IsDisqualified) + !r.IsDisqualified); + + // 排名按大屏对应场地隔离,避免同项目同组别在多个场地比赛时串榜。 + // 使用显式分支和值类型局部变量,避免 nullable 条件被 SqlSugar 翻译成非法 SQL。 + if (input.VenueId.HasValue) + { + var venueId = input.VenueId.Value; + rankingQuery = rankingQuery.Where((r, e, s) => s.VenueId == venueId); + } + + var results = await rankingQuery .OrderBy((r, e, s) => r.Rank) .Select((r, e, s) => new { r.Id, r.ResultVersion, + SessionEntryId = e.Id, + s.ScoreRuleId, e.EntryNo, e.ParticipantName, e.TeamName, e.GroupName, + r.DifficultyScore, + r.PerformanceScore, + r.RequiredScore, + r.DeductionScore, r.TotalScore, r.Score, - r.Rank, r.IsDisqualified }) .ToListAsync(); @@ -713,7 +729,38 @@ public class WorkbenchController : IDynamicApiController if (!results.Any()) throw Oops.Bah("当前项目组别没有可投屏的已确认成绩"); - var payload = results.Select(r => new + // 落库名次可能是成绩刚生成时连同“待复核”成绩一起计算的。 + // 投屏时必须以上面已经过状态筛选的有效成绩重新排名,避免待复核成绩隐形占位。 + var scoreRuleId = results.Select(r => r.ScoreRuleId).First(); + var scoreRule = await _scoreRuleRepository.GetByIdAsync(scoreRuleId); + var rankCandidates = results + .GroupBy(r => r.SessionEntryId) + .Select(group => + { + var row = group.First(); + return new RankCandidate + { + SessionEntryId = group.Key, + TotalScore = row.TotalScore ?? 0m, + DifficultyScore = row.DifficultyScore, + PerformanceScore = row.PerformanceScore, + RequiredScore = row.RequiredScore, + DeductionScore = row.DeductionScore, + IsDisqualified = row.IsDisqualified, + IsRanked = row.TotalScore.HasValue + }; + }) + .ToList(); + EntryResultCalculationService.AssignRanks(rankCandidates, + ScoreRuleConfigService.Parse(scoreRule?.RuleConfigJson)); + var screenRankByEntry = rankCandidates.ToDictionary(x => x.SessionEntryId, x => x.Rank); + + var payload = results + .OrderBy(r => screenRankByEntry[r.SessionEntryId] == 0 + ? int.MaxValue + : screenRankByEntry[r.SessionEntryId]) + .ThenBy(r => r.EntryNo) + .Select(r => new { r.EntryNo, r.ParticipantName, @@ -721,7 +768,7 @@ public class WorkbenchController : IDynamicApiController r.GroupName, r.TotalScore, r.Score, - r.Rank, + Rank = screenRankByEntry[r.SessionEntryId], r.IsDisqualified }).ToList(); diff --git a/YD_Event.Application/YD_Event.Application.xml b/YD_Event.Application/YD_Event.Application.xml index ff1732b..b0bb70c 100644 --- a/YD_Event.Application/YD_Event.Application.xml +++ b/YD_Event.Application/YD_Event.Application.xml @@ -4876,7 +4876,7 @@ - 场次索引 + 场次索引(同一赛事内按项目顺序全局连续) @@ -9463,6 +9463,18 @@ 编排预览 + + + 速度项目按组别交叉单位,再依次填入同一场次的各场地。 + 随机种子由赛事和赛事项目确定,避免预览、导出与确认结果反复变化。 + + + + + 花样项目以整个组别为不可拆分单元分配场地,优先将大组分配给当前负载最小的场地。 + 同一场地内的组别串行,且每个组别的所有出场记录连续。 + + 确认编排并固化场地、场次及出场记录。 diff --git a/YD_Event.Application/applicationsettings.json b/YD_Event.Application/applicationsettings.json index b143542..f9a477c 100644 --- a/YD_Event.Application/applicationsettings.json +++ b/YD_Event.Application/applicationsettings.json @@ -54,7 +54,6 @@ "dbconfig": { "ConnectionStrings": [ "8.153.108.90,Password=yd708090,SyncTimeout=15000,ConnectTimeout=15000,ResponseTimeout=15000,defaultDatabase=7,poolsize=100,ssl=false,writeBuffer=10240,idleTimeout=30000,connectRetry=3,asyncPipeline=false" - //"8.153.108.90,Password=yd708090,SyncTimeout=15000,ConnectTimeout=15000,ResponseTimeout=15000,defaultDatabase=8,poolsize=100,ssl=false,writeBuffer=10240,idleTimeout=30000,connectRetry=3,asyncPipeline=false" ], //"Sentinels": [ // "192.169.1.10:26379", diff --git a/YD_Event.Core/InitData/sysmenu.txt b/YD_Event.Core/InitData/sysmenu.txt index 8818eb8..a7a7fa3 100644 --- a/YD_Event.Core/InitData/sysmenu.txt +++ b/YD_Event.Core/InitData/sysmenu.txt @@ -2128,7 +2128,7 @@ "ParentId": 1086, "RouteName": "scoringCast", "Path": "/scoring/cast", - "Component": "scoring/workbench/index", + "Component": "scoring/screen/index", "Redirect": "", "Icon": "ele-DataLine", "IsIframe": "0", @@ -2138,7 +2138,7 @@ "IsFixed": "0", "Status": 0, "Sort": 4, - "Remark": "成绩投屏操作台", + "Remark": "成绩投屏大屏", "DeleteMark": false, "CreatedUserId": 1000, "CreatedTime": "2023-06-01T00:00:00"