feat: 增加裁判任务分页与下一场查询接口

批量查询并稳定排序裁判任务,返回分页及各页场次范围;
增加按出场 ID 定位任务和查询当前任务之后下一条未评分任务的接口,优化场次摘要计数。
This commit is contained in:
ltx 2026-10-28 14:48:53 +08:00
parent 2e00719d56
commit f12afe8a36
3 changed files with 252 additions and 16 deletions

View File

@ -173,17 +173,13 @@ public class CheckinScheduleService : ITransient
var sessionOrderMap = sessions.ToDictionary(
x => x.Id,
x => GetImportedSessionOrder(x.SessionName, GetSessionOrder(x.SessionNo)));
var orderedSessions = sessions
.OrderBy(x => sessionOrderMap[x.Id])
.ThenBy(x => venueMap.GetValueOrDefault(x.VenueId)?.Sort ?? int.MaxValue)
.ThenBy(x => x.SessionNo)
.ToList();
var sessionMap = sessions.ToDictionary(x => x.Id);
// 与成绩管理列表保持一致:导入记录的 ID 按秩序册预览行顺序生成。
// SessionName 中的“第 N 场”仅用于展示场次,不能用它重新排列选手。
var units = entries
.Where(x => sessionMap.ContainsKey(x.SessionId))
.OrderBy(x => sessionOrderMap[x.SessionId])
.ThenBy(x => x.PlayOrder)
.OrderBy(x => x.Id)
.Select((entry, index) =>
{
var session = sessionMap[entry.SessionId];

View File

@ -1,5 +1,32 @@
namespace YD_Event.Application.Scoring.Dtos;
/// <summary>裁判任务分页结果;场次范围使用秩序册中的实际场次号。</summary>
public class JudgeTaskPageDto
{
public long EventId { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages { get; set; }
public List<JudgeTaskPageRangeDto> PageRanges { get; set; } = new();
public List<JudgeTaskDto> Items { get; set; } = new();
}
public class JudgeTaskPageRangeDto
{
public int Page { get; set; }
public int FirstSessionOrder { get; set; }
public int LastSessionOrder { get; set; }
}
/// <summary>仅含当前裁判可访问的出场信息,不含平台最终成绩。</summary>
public class JudgeTaskDto
{
public int Page { get; set; }
public JudgeSessionDto Session { get; set; } = new();
public JudgeEntryDto Entry { get; set; } = new();
}
/// <summary>
/// 裁判端查询的当前场次赛事状态与操作权限。
/// </summary>

View File

@ -187,12 +187,17 @@ public class JudgeScoringController : IDynamicApiController
.Where(x => sessionIds.Contains(x.SessionId) && x.RefereeId == referee.Id
&& x.IsCurrent && !x.DeleteMark
&& x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted).ToListAsync();
var entriesBySession = entries.GroupBy(x => x.SessionId)
.ToDictionary(x => x.Key, x => x.ToList());
var scoredBySession = mySheets.GroupBy(x => x.SessionId)
.ToDictionary(x => x.Key, x => x.Count());
var assignmentsBySession = assignments.GroupBy(x => x.SessionId)
.ToDictionary(x => x.Key, x => x.First());
var rows = new List<JudgeSessionDto>();
foreach (var session in sessions)
{
var assignment = assignments.FirstOrDefault(x => x.SessionId == session.Id);
if (assignment == null) continue;
if (!assignmentsBySession.TryGetValue(session.Id, out var assignment)) continue;
var roleType = JudgeRoleSchemaService.NormalizeRoleType(
string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType);
var project = eventProjects.TryGetValue(session.EventProjectId, out var ep)
@ -200,6 +205,7 @@ public class JudgeScoringController : IDynamicApiController
var rule = rules.GetValueOrDefault(session.ScoreRuleId);
var slot = JudgeRoleSchemaService.ParseSlots(rule?.JudgeRoleSchemaJson)
.FirstOrDefault(x => string.Equals(x.Code, roleType, StringComparison.OrdinalIgnoreCase));
entriesBySession.TryGetValue(session.Id, out var sessionEntries);
rows.Add(new JudgeSessionDto
{
SessionId = session.Id,
@ -209,9 +215,8 @@ public class JudgeScoringController : IDynamicApiController
RuleName = rule?.RuleName ?? string.Empty,
SessionName = session.SessionName ?? session.SessionNo,
VenueName = venues.GetValueOrDefault(session.VenueId)?.VenueName ?? "-",
GroupName = entries.Where(x => x.SessionId == session.Id).Select(x => x.GroupName)
.Distinct().Count() == 1
? entries.First(x => x.SessionId == session.Id).GroupName
GroupName = sessionEntries?.Select(x => x.GroupName).Distinct().Count() == 1
? sessionEntries[0].GroupName
: null,
StartTime = session.StartTime,
SessionStatus = session.SessionStatus,
@ -219,8 +224,8 @@ public class JudgeScoringController : IDynamicApiController
JudgeRole = assignment.JudgeRole,
JudgeRoleType = roleType,
JudgeRoleName = slot?.Name ?? JudgeRoleSchemaService.RoleName(roleType),
EntryCount = entries.Count(x => x.SessionId == session.Id),
ScoredCount = mySheets.Count(x => x.SessionId == session.Id),
EntryCount = sessionEntries?.Count ?? 0,
ScoredCount = scoredBySession.GetValueOrDefault(session.Id),
ArrangementOrder = GetArrangementSequence(session.SessionNo) is var order
&& order < int.MaxValue
? order
@ -230,6 +235,212 @@ public class JudgeScoringController : IDynamicApiController
return rows;
}
/// <summary>分页读取当前裁判最近待执裁赛事的任务。</summary>
[HttpGet("/api/judge-scoring/tasks")]
[DisplayName("分页获取我的评分任务")]
public async Task<JudgeTaskPageDto> GetTaskPage([FromQuery] int page = 1, [FromQuery] int pageSize = 10)
{
ValidateTaskPageSize(pageSize);
if (page < 1) throw Oops.Bah("页码必须大于0");
var (eventId, tasks) = await LoadJudgeTasksAsync();
var totalPages = (tasks.Count + pageSize - 1) / pageSize;
var actualPage = totalPages == 0 ? 1 : Math.Min(page, totalPages);
for (var index = 0; index < tasks.Count; index++)
tasks[index].Page = index / pageSize + 1;
return new JudgeTaskPageDto
{
EventId = eventId,
Page = actualPage,
PageSize = pageSize,
TotalCount = tasks.Count,
TotalPages = totalPages,
PageRanges = tasks.Chunk(pageSize).Select((chunk, index) => new JudgeTaskPageRangeDto
{
Page = index + 1,
FirstSessionOrder = chunk[0].Entry.SessionOrder,
LastSessionOrder = chunk[^1].Entry.SessionOrder
}).ToList(),
Items = tasks.Skip((actualPage - 1) * pageSize).Take(pageSize).ToList()
};
}
/// <summary>按出场ID定位本人任务,用于修正通知跳转。</summary>
[HttpGet("/api/judge-scoring/tasks/{sessionEntryId:long}")]
[DisplayName("按出场ID获取我的评分任务")]
public async Task<JudgeTaskDto> GetTaskByEntry(long sessionEntryId, [FromQuery] int pageSize = 10)
{
ValidateTaskPageSize(pageSize);
var entry = await _entryRepository.GetFirstAsync(x => x.Id == sessionEntryId && !x.DeleteMark);
if (entry == null) throw Oops.Bah("评分任务不存在或不属于当前裁判");
var session = await _sessionRepository.GetFirstAsync(x => x.Id == entry.SessionId && !x.DeleteMark);
if (session == null) throw Oops.Bah("评分任务不存在或不属于当前裁判");
var (_, tasks) = await LoadJudgeTasksAsync(session.EventId);
var index = tasks.FindIndex(x => x.Entry.SessionEntryId == sessionEntryId);
if (index < 0) throw Oops.Bah("评分任务不存在或不属于当前裁判");
tasks[index].Page = index / pageSize + 1;
return tasks[index];
}
/// <summary>只沿编排顺序向后查找下一条正常待评分任务。</summary>
[HttpGet("/api/judge-scoring/tasks/next")]
[DisplayName("获取下一条待评分任务")]
public async Task<JudgeTaskDto?> GetNextTask([FromQuery] long afterEntryId, [FromQuery] int pageSize = 10)
{
ValidateTaskPageSize(pageSize);
var (_, tasks) = await LoadJudgeTasksAsync();
var index = tasks.FindIndex(x => x.Entry.SessionEntryId == afterEntryId);
if (index < 0)
{
// The completed event may no longer be the selected event. Verify
// ownership before advancing to the first task of the new event.
var referee = await EnsureRefereeAsync();
var previous = await _entryRepository.GetFirstAsync(x => x.Id == afterEntryId && !x.DeleteMark);
if (previous == null) throw Oops.Bah("当前评分任务不存在或不属于当前裁判");
await GetMyAssignmentAsync(previous.SessionId, referee.Id);
}
for (var next = index + 1; next < tasks.Count; next++)
{
if (tasks[next].Entry.ScoreStatus is 2 or 3) continue;
tasks[next].Page = next / pageSize + 1;
return tasks[next];
}
return null;
}
private static void ValidateTaskPageSize(int pageSize)
{
if (pageSize is not (10 or 20 or 50)) throw Oops.Bah("每页数量只支持10、20或50");
}
/// <summary>一次批量读取任务所需数据,避免对每个场次请求一次出场名单。</summary>
private async Task<(long EventId, List<JudgeTaskDto> Tasks)> LoadJudgeTasksAsync(long? eventId = null)
{
var referee = await EnsureRefereeAsync();
var summaries = await GetSessions(eventId);
if (summaries.Count == 0) return (0, new List<JudgeTaskDto>());
var targetEventId = summaries[0].EventId;
var sessionIds = summaries.Select(x => x.SessionId).ToList();
var summaryBySession = summaries.ToDictionary(x => x.SessionId);
var sessionOrder = summaries.Select((value, index) => (value.SessionId, index))
.ToDictionary(x => x.SessionId, x => x.index);
var sessions = (await _sessionRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.Id)).ToListAsync())
.ToDictionary(x => x.Id);
var assignments = (await _assignmentRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.SessionId) && x.RefereeId == referee.Id &&
x.AssignmentStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark)
.ToListAsync()).GroupBy(x => x.SessionId)
.ToDictionary(x => x.Key, x => x.First());
var allEntries = await _entryRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.SessionId) && !x.DeleteMark).ToListAsync();
var entryById = allEntries.ToDictionary(x => x.Id);
var entries = allEntries.Where(x => !x.IsVoided && x.Status == AvailabilityStatus.Enable).ToList();
if (entries.Count == 0) return (targetEventId, new List<JudgeTaskDto>());
var sheets = await _sheetRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.SessionId) && x.RefereeId == referee.Id && !x.DeleteMark)
.ToListAsync();
var submittedByEntry = sheets.Where(x => x.IsCurrent &&
x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted)
.GroupBy(x => x.SessionEntryId).ToDictionary(x => x.Key, x => x.First());
var draftByEntry = sheets.Where(x => x.SheetStatus == SheetStatusDraft)
.GroupBy(x => x.SessionEntryId).ToDictionary(x => x.Key, x => x.First());
var assignmentIds = assignments.Values.Select(x => x.Id).ToList();
var corrections = await _correctionRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.SessionId) && assignmentIds.Contains(x.JudgeAssignmentId) &&
(x.CorrectionStatus == CorrectionStatusApproved || x.CorrectionStatus == CorrectionStatusEditing))
.ToListAsync();
var correctionSheetIds = corrections.Select(x => x.OriginalScoreSheetId).ToHashSet();
var eventProjects = await _eventProjectRepository.AsQueryable()
.Where(x => x.EventId == targetEventId && !x.DeleteMark).ToListAsync();
var projectIds = eventProjects.Select(x => x.ProjectId).Distinct().ToList();
var projects = (await _projectRepository.AsQueryable()
.Where(x => projectIds.Contains(x.Id) && !x.DeleteMark).ToListAsync())
.ToDictionary(x => x.Id);
var eventProjectById = eventProjects.ToDictionary(x => x.Id);
var orderedProjectIds = projects.Values.OrderBy(x => x.SortIndex).ThenBy(x => x.Id)
.Select(x => x.Id).ToList();
var projectOrderById = orderedProjectIds.Select((id, index) => (id, index))
.ToDictionary(x => x.id, x => x.index + 1);
var venueIds = sessions.Values.Select(x => x.VenueId).Distinct().ToList();
var venues = (await _venueRepository.AsQueryable()
.Where(x => venueIds.Contains(x.Id) && !x.DeleteMark).ToListAsync())
.ToDictionary(x => x.Id);
var ruleIds = sessions.Values.Select(x => x.ScoreRuleId).Distinct().ToList();
var rules = (await _ruleRepository.AsQueryable()
.Where(x => ruleIds.Contains(x.Id) && !x.DeleteMark).ToListAsync())
.ToDictionary(x => x.Id);
var factorByRuleId = rules.ToDictionary(x => x.Key,
x => ScoreRuleConfigService.Parse(x.Value.RuleConfigJson).IsFactorTotal);
int DisplayPlayOrder(EventSessionEntry entry)
{
var result = entry.PlayOrder;
var sourceId = entry.RematchFromEntryId;
var visited = new HashSet<long> { entry.Id };
while (sourceId.HasValue && visited.Add(sourceId.Value) &&
entryById.TryGetValue(sourceId.Value, out var source))
{
result = source.PlayOrder;
sourceId = source.RematchFromEntryId;
}
return result;
}
var tasks = new List<JudgeTaskDto>(entries.Count);
foreach (var entry in entries)
{
if (!sessions.TryGetValue(entry.SessionId, out var session) ||
!assignments.TryGetValue(entry.SessionId, out var assignment)) continue;
var summary = summaryBySession[entry.SessionId];
var arrangement = GetArrangementSequence(session.SessionNo);
var eventProject = eventProjectById.GetValueOrDefault(session.EventProjectId);
var project = eventProject == null ? null : projects.GetValueOrDefault(eventProject.ProjectId);
var projectOrder = eventProject == null
? orderedProjectIds.Count + 1
: projectOrderById.GetValueOrDefault(eventProject.ProjectId, orderedProjectIds.Count + 1);
var venue = venues.GetValueOrDefault(session.VenueId);
var venueOrder = venue?.Sort > 0 ? venue.Sort : 1;
var scope = project == null ? ScoreScope.Personal : ScoreSheetCalculationService.ResolveScope(project.ProjectType);
var submitted = submittedByEntry.GetValueOrDefault(entry.Id);
var draft = draftByEntry.GetValueOrDefault(entry.Id);
var inCorrection = submitted != null && correctionSheetIds.Contains(submitted.Id);
var status = inCorrection ? 3 : submitted != null ? 2 : draft != null ? 1 : 0;
var isFactorTotal = factorByRuleId.GetValueOrDefault(session.ScoreRuleId);
tasks.Add(new JudgeTaskDto
{
Session = summary,
Entry = new JudgeEntryDto
{
SessionEntryId = entry.Id,
EntryNo = entry.EntryNo,
PlayOrder = entry.PlayOrder,
SessionOrder = entry.SourceType == 1
? GetImportedSessionOrder(session.SessionName,
BuildEntrySessionOrder(arrangement, DisplayPlayOrder(entry)))
: BuildEntrySessionOrder(arrangement, DisplayPlayOrder(entry)),
ScheduleOrder = entry.SourceType == 1 ? entry.Id :
BuildArrangementScheduleOrder(projectOrder, arrangement, venueOrder, entry.PlayOrder),
ParticipantName = DisplayParticipantName(entry, scope),
GroupName = entry.GroupName,
EntryStatus = entry.EntryStatus,
IsRematch = entry.IsRematch,
ScoreStatus = status,
ScoreStatusName = ScoreStatusName(status),
MyScore = submitted == null ? null : ScoreDisplayService.JudgeText(submitted, isFactorTotal),
IsDisqualified = submitted?.IsDisqualified ?? false,
ScoreSheetId = submitted?.Id ?? draft?.Id ?? 0
}
});
}
return (targetEventId, tasks.OrderBy(x => x.Entry.ScheduleOrder)
.ThenBy(x => sessionOrder[x.Session.SessionId])
.ThenBy(x => x.Entry.PlayOrder)
.ThenBy(x => x.Entry.SessionEntryId).ToList());
}
/// <summary>
/// 查询当前场次的赛事阶段与操作权限,供裁判端进入场次或准备评分时刷新界面状态。
/// </summary>
@ -1302,10 +1513,12 @@ public class JudgeScoringController : IDynamicApiController
if (uploaded < 0m || uploaded > 1m)
throw Oops.Bah("扣分系数必须在0至1之间");
// 明细行保留了递进扣分的未舍入小计,用它校验 0.125 等三位小数系数。
var expected = calculation.Lines
// 明细行保留递进扣分的未舍入小计;累计扣分可超过 1,
// 但上传的扣分系数与最终 M=Max(0, 1-扣分) 使用同一 0..1 口径。
var rawDeduction = calculation.Lines
.Where(x => x.ScoreDimension == ScoreDimensionCode.Deduction)
.Sum(x => x.Subtotal);
var expected = Math.Clamp(rawDeduction, 0m, 1m);
if (Math.Abs(uploaded.Value - expected) > 0.0005m)
throw Oops.Bah($"扣分系数与扣分明细不一致,应为{expected:0.###}");
}