This commit is contained in:
ltx 2026-09-16 15:20:20 +08:00
commit 93567c00b8
6 changed files with 280 additions and 42 deletions

View File

@ -187,7 +187,7 @@ public class ArrangementPreviewOutput
public string UnitName { get; set; }
/// <summary>
/// 场次索引
/// 场次索引(同一赛事内按项目顺序全局连续)
/// </summary>
public int SessionIndex { get; set; }

View File

@ -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<ArrangementPreviewOutput>();
// 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<ArrangementPreviewOutput>();
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;
}
/// <summary>
/// 速度项目按组别交叉单位,再依次填入同一场次的各场地。
/// 随机种子由赛事和赛事项目确定,避免预览、导出与确认结果反复变化。
/// </summary>
private static void AssignSpeedSessions(
List<ArrangementPreviewOutput> entries,
int venueCount,
int firstSessionIndex,
long eventId,
long eventProjectId)
{
var random = new Random(CreateStableRandomSeed(eventId, eventProjectId));
var orderedEntries = new List<ArrangementPreviewOutput>(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<ArrangementPreviewOutput>(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;
}
}
/// <summary>
/// 花样项目以整个组别为不可拆分单元分配场地,优先将大组分配给当前负载最小的场地。
/// 同一场地内的组别串行,且每个组别的所有出场记录连续。
/// </summary>
private static void AssignFreestyleVenues(
List<ArrangementPreviewOutput> entries,
IReadOnlyCollection<ProjectGroup> 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<ArrangementPreviewOutput> 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<int> 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)));
}
/// <summary>确认编排并固化场地、场次及出场记录。</summary>
[HttpPost("confirm/{eventId}"), DisplayName("确认赛事编排"), UnitOfWork]
public async Task<ConfirmArrangementOutput> Confirm(long eventId)
@ -557,13 +715,35 @@ namespace YD_Event.Application.Event;
var sessions = new List<EventSession>(); var entries = new List<EventSessionEntry>();
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;

View File

@ -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<EventSessionEntry>((r, e) => r.SessionEntryId == e.Id)
.InnerJoin<EventSession>((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();

View File

@ -4876,7 +4876,7 @@
</member>
<member name="P:YD_Event.Application.Event.Dtos.ArrangementPreviewOutput.SessionIndex">
<summary>
场次索引
场次索引(同一赛事内按项目顺序全局连续)
</summary>
</member>
<member name="P:YD_Event.Application.Event.Dtos.ArrangementPreviewOutput.VenueIndex">
@ -9463,6 +9463,18 @@
编排预览
</summary>
</member>
<member name="M:YD_Event.Application.Event.EventArrangementService.AssignSpeedSessions(System.Collections.Generic.List{YD_Event.Application.Event.Dtos.ArrangementPreviewOutput},System.Int32,System.Int32,System.Int64,System.Int64)">
<summary>
速度项目按组别交叉单位,再依次填入同一场次的各场地。
随机种子由赛事和赛事项目确定,避免预览、导出与确认结果反复变化。
</summary>
</member>
<member name="M:YD_Event.Application.Event.EventArrangementService.AssignFreestyleVenues(System.Collections.Generic.List{YD_Event.Application.Event.Dtos.ArrangementPreviewOutput},System.Collections.Generic.IReadOnlyCollection{YD_Event.Core.Entities.ProjectGroup},System.Int32,System.Int32)">
<summary>
花样项目以整个组别为不可拆分单元分配场地,优先将大组分配给当前负载最小的场地。
同一场地内的组别串行,且每个组别的所有出场记录连续。
</summary>
</member>
<member name="M:YD_Event.Application.Event.EventArrangementService.Confirm(System.Int64)">
<summary>确认编排并固化场地、场次及出场记录。</summary>
</member>

View File

@ -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",

View File

@ -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"