2275 lines
104 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
using System.Text.RegularExpressions;
namespace YD_Event.Application.Workbench;
/// <summary>
/// 打分系统工作台接口(供工作台管理端调用)
/// </summary>
[ApiDescriptionSettings("打分系统")]
[Route("api/workbench")]
[Authorize]
public class WorkbenchController : IDynamicApiController
{
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
private readonly ISqlSugarRepository<EventVenue> _venueRepository;
private readonly ISqlSugarRepository<EventProject> _eventProjectRepository;
private readonly ISqlSugarRepository<EventProjectGroup> _groupRepository;
private readonly ISqlSugarRepository<EventSession> _sessionRepository;
private readonly ISqlSugarRepository<EventSessionEntry> _entryRepository;
private readonly ISqlSugarRepository<EventJudgeAssignment> _assignmentRepository;
private readonly ISqlSugarRepository<EventJudgeScoreSheet> _sheetRepository;
private readonly ISqlSugarRepository<EventJudgeScoreAttachment> _scoreAttachmentRepository;
private readonly ISqlSugarRepository<EventScoreCorrection> _correctionRepository;
private readonly ISqlSugarRepository<EventResult> _resultRepository;
private readonly ISqlSugarRepository<EventResultVersion> _resultVersionRepository;
private readonly ISqlSugarRepository<EventResultScreenCast> _screenCastRepository;
private readonly ISqlSugarRepository<EventJudgeTask> _taskRepository;
private readonly ISqlSugarRepository<EventJudgeMessage> _messageRepository;
private readonly ISqlSugarRepository<Core.Entities.AppUser> _userRepository;
private readonly ISqlSugarRepository<Core.Entities.Referee> _refereeRepository;
private readonly ISqlSugarRepository<Core.Entities.Project> _projectRepository;
private readonly ISqlSugarRepository<Core.Entities.ScoreRule> _scoreRuleRepository;
private readonly ISqlSugarRepository<EventJudgeScoreDetail> _scoreDetailRepository;
private readonly ISqlSugarRepository<SysOperationLog> _operationLogRepository;
private readonly ISqlSugarRepository<SysUser> _sysUserRepository;
private readonly ISqlSugarRepository<MerchantCertification> _merchantCertificationRepository;
private readonly AuthManager _authManager;
private readonly IIdGenerator _idGenerator;
public WorkbenchController(
ISqlSugarRepository<EventInfo> eventRepository,
ISqlSugarRepository<EventVenue> venueRepository,
ISqlSugarRepository<EventProject> eventProjectRepository,
ISqlSugarRepository<EventProjectGroup> groupRepository,
ISqlSugarRepository<EventSession> sessionRepository,
ISqlSugarRepository<EventSessionEntry> entryRepository,
ISqlSugarRepository<EventJudgeAssignment> assignmentRepository,
ISqlSugarRepository<EventJudgeScoreSheet> sheetRepository,
ISqlSugarRepository<EventJudgeScoreAttachment> scoreAttachmentRepository,
ISqlSugarRepository<EventScoreCorrection> correctionRepository,
ISqlSugarRepository<EventResult> resultRepository,
ISqlSugarRepository<EventResultVersion> resultVersionRepository,
ISqlSugarRepository<EventResultScreenCast> screenCastRepository,
ISqlSugarRepository<EventJudgeTask> taskRepository,
ISqlSugarRepository<EventJudgeMessage> messageRepository,
ISqlSugarRepository<Core.Entities.AppUser> userRepository,
ISqlSugarRepository<Core.Entities.Referee> refereeRepository,
ISqlSugarRepository<Core.Entities.Project> projectRepository,
ISqlSugarRepository<Core.Entities.ScoreRule> scoreRuleRepository,
ISqlSugarRepository<EventJudgeScoreDetail> scoreDetailRepository,
ISqlSugarRepository<SysOperationLog> operationLogRepository,
ISqlSugarRepository<SysUser> sysUserRepository,
ISqlSugarRepository<MerchantCertification> merchantCertificationRepository,
AuthManager authManager,
IIdGenerator idGenerator)
{
_eventRepository = eventRepository;
_venueRepository = venueRepository;
_eventProjectRepository = eventProjectRepository;
_groupRepository = groupRepository;
_sessionRepository = sessionRepository;
_entryRepository = entryRepository;
_assignmentRepository = assignmentRepository;
_sheetRepository = sheetRepository;
_scoreAttachmentRepository = scoreAttachmentRepository;
_correctionRepository = correctionRepository;
_resultRepository = resultRepository;
_resultVersionRepository = resultVersionRepository;
_screenCastRepository = screenCastRepository;
_taskRepository = taskRepository;
_messageRepository = messageRepository;
_userRepository = userRepository;
_refereeRepository = refereeRepository;
_projectRepository = projectRepository;
_scoreRuleRepository = scoreRuleRepository;
_scoreDetailRepository = scoreDetailRepository;
_operationLogRepository = operationLogRepository;
_sysUserRepository = sysUserRepository;
_merchantCertificationRepository = merchantCertificationRepository;
_authManager = authManager;
_idGenerator = idGenerator;
}
#region 场次管理
/// <summary>
/// 获取可进入打分工作台的赛事列表
/// </summary>
[HttpGet("events")]
public async Task<PageResult<WorkbenchEventDto>> GetEvents([FromQuery] WorkbenchEventQueryInput input)
{
var mode = input.SourceMode?.Trim().ToLowerInvariant();
var query = _eventRepository.AsQueryable()
.Where(e => !e.DeleteMark)
.WhereIF(!string.IsNullOrWhiteSpace(input.EventName), e => e.EventName.Contains(input.EventName!.Trim()));
// 报名平台入口只展示进行中/已结束的赛事;秩序册入口不限制赛事状态
if (mode == "registration")
query = query.Where(e => e.EventStatus == Core.Enum.EventStatus.Ongoing || e.EventStatus == Core.Enum.EventStatus.Ended);
else if (mode != "orderbook")
throw Oops.Bah("数据来源只能为registration或orderbook");
return await query.OrderByDescending(e => e.MatchStartTime)
.Select(e => new WorkbenchEventDto
{
Id = e.Id,
EventName = e.EventName,
EventStatus = (int)e.EventStatus,
MatchStartTime = e.MatchStartTime,
MatchEndTime = e.MatchEndTime
}).ToPagedListAsync(input);
}
/// <summary>
/// 获取工作台场次列表
/// </summary>
[HttpGet("sessions")]
public async Task<List<WorkbenchSessionDto>> GetSessions([FromQuery] WorkbenchSessionQueryInput input)
{
var query = _sessionRepository.AsQueryable()
.Where(s => !s.DeleteMark && s.EventId == input.EventId);
if (input.VenueId.HasValue)
query = query.Where(s => s.VenueId == input.VenueId.Value);
if (input.EventProjectId.HasValue)
query = query.Where(s => s.EventProjectId == input.EventProjectId.Value);
if (input.SessionStatus.HasValue)
query = query.Where(s => s.SessionStatus == input.SessionStatus.Value);
if (!string.IsNullOrWhiteSpace(input.Keyword))
{
var keyword = input.Keyword.Trim();
query = query.Where(s => s.SessionNo.Contains(keyword) || (s.SessionName != null && s.SessionName.Contains(keyword)));
}
var sessions = await query
.Select(s => new
{
s.Id,
s.SessionNo,
s.SessionName,
s.EventId,
s.VenueId,
s.EventProjectId,
s.EventProjectGroupId,
s.ScoreRuleId,
s.StartTime,
s.EndTime,
s.SessionStatus,
s.CurrentEntryId
})
.ToListAsync();
if (!sessions.Any())
return new List<WorkbenchSessionDto>();
var eventIds = sessions.Select(s => s.EventId).Distinct().ToList();
var venueIds = sessions.Select(s => s.VenueId).Distinct().ToList();
var projectIds = sessions.Select(s => s.EventProjectId).Distinct().ToList();
var groupIds = sessions.Where(s => s.EventProjectGroupId.HasValue).Select(s => s.EventProjectGroupId!.Value).Distinct().ToList();
var sessionIds = sessions.Select(s => s.Id).ToList();
var events = await _eventRepository.AsQueryable()
.Where(e => eventIds.Contains(e.Id))
.Select(e => new { e.Id, e.EventName })
.ToListAsync();
var venues = await _venueRepository.AsQueryable()
.Where(v => venueIds.Contains(v.Id))
.Select(v => new { v.Id, v.VenueName })
.ToListAsync();
var eventProjects = await _eventProjectRepository.AsQueryable()
.Where(ep => projectIds.Contains(ep.Id))
.Select(ep => new { ep.Id, ep.ProjectId })
.ToListAsync();
var projectDict = (await _projectRepository.AsQueryable()
.Where(p => eventProjects.Select(ep => ep.ProjectId).Contains(p.Id))
.Select(p => new { p.Id, p.Name, p.SortIndex })
.ToListAsync())
.ToDictionary(p => p.Id);
var eventProjectDict = eventProjects.ToDictionary(ep => ep.Id);
sessions = sessions
// 场次号末段保存了秩序册预览中的全局编排序号。先按该序号排序,
// 前端再按项目合并时,项目顺序便与它在秩序册中首次出现的顺序一致。
.OrderBy(s => GetArrangementSequence(s.SessionNo))
.ThenBy(s => eventProjectDict.TryGetValue(s.EventProjectId, out var eventProject)
&& projectDict.TryGetValue(eventProject.ProjectId, out var project)
? project.SortIndex
: int.MaxValue)
.ThenBy(s => eventProjectDict.TryGetValue(s.EventProjectId, out var eventProject)
? eventProject.ProjectId
: long.MaxValue)
.ThenBy(s => s.StartTime ?? DateTime.MaxValue)
.ThenBy(s => s.SessionNo)
.ToList();
var groups = await _groupRepository.AsQueryable()
.Where(g => groupIds.Contains(g.Id))
.Select(g => new { g.Id, g.GroupName })
.ToListAsync();
// 批量查出场次绑定的计分规则编码,供前端按规则渲染评分维度
var ruleIds = sessions.Select(s => s.ScoreRuleId).Distinct().ToList();
var ruleDict = (await _scoreRuleRepository.AsQueryable()
.Where(r => !r.DeleteMark && ruleIds.Contains(r.Id))
.Select(r => new { r.Id, r.RuleCode })
.ToListAsync()).ToDictionary(r => r.Id, r => r.RuleCode);
var entryCounts = await _entryRepository.AsQueryable()
.Where(e => !e.DeleteMark && sessionIds.Contains(e.SessionId))
.GroupBy(e => e.SessionId)
.Select(e => new
{
SessionId = e.SessionId,
TotalCount = SqlFunc.AggregateCount(e.Id),
CompletedCount = SqlFunc.AggregateSum(SqlFunc.IIF(e.EntryStatus == 40, 1, 0))
})
.ToListAsync();
var resultCounts = await _resultRepository.AsQueryable()
.InnerJoin<EventSessionEntry>((r, e) => r.SessionEntryId == e.Id)
.Where((r, e) => !r.DeleteMark && !e.DeleteMark && sessionIds.Contains(e.SessionId) &&
// 已确认(20)、已投屏(30)、已更正(40)均计入已完成成绩数
(r.ResultStatus == 20 || r.ResultStatus == 30 || r.ResultStatus == 40))
.GroupBy((r, e) => e.SessionId)
.Select((r, e) => new
{
SessionId = e.SessionId,
ConfirmedCount = SqlFunc.AggregateCount(r.Id)
})
.ToListAsync();
var eventDict = events.ToDictionary(e => e.Id, e => e.EventName);
var venueDict = venues.ToDictionary(v => v.Id, v => v.VenueName);
var eventProjectNameDict = eventProjects.ToDictionary(ep => ep.Id,
ep => projectDict.GetValueOrDefault(ep.ProjectId)?.Name ?? "");
var groupDict = groups.ToDictionary(g => g.Id, g => g.GroupName);
var entryCountDict = entryCounts.ToDictionary(ec => ec.SessionId);
var resultCountDict = resultCounts.ToDictionary(rc => rc.SessionId);
return sessions.Select(s => new WorkbenchSessionDto
{
SessionId = s.Id,
EventId = s.EventId,
VenueId = s.VenueId,
EventProjectId = s.EventProjectId,
EventProjectGroupId = s.EventProjectGroupId,
ScoreRuleCode = ruleDict.GetValueOrDefault(s.ScoreRuleId, string.Empty),
SessionNo = s.SessionNo,
SessionName = s.SessionName,
EventName = eventDict.GetValueOrDefault(s.EventId, ""),
ProjectName = eventProjectNameDict.GetValueOrDefault(s.EventProjectId, ""),
VenueName = venueDict.GetValueOrDefault(s.VenueId, ""),
GroupName = s.EventProjectGroupId.HasValue ? groupDict.GetValueOrDefault(s.EventProjectGroupId.Value) : null,
StartTime = s.StartTime,
EndTime = s.EndTime,
SessionStatus = s.SessionStatus,
SessionStatusName = GetSessionStatusName(s.SessionStatus),
CurrentEntryId = s.CurrentEntryId,
TotalEntries = entryCountDict.TryGetValue(s.Id, out var ec) ? ec.TotalCount : 0,
CompletedEntries = entryCountDict.TryGetValue(s.Id, out var ec2) ? ec2.CompletedCount : 0,
ConfirmedResults = resultCountDict.TryGetValue(s.Id, out var rc) ? rc.ConfirmedCount : 0
}).ToList();
}
/// <summary>
/// 获取场次的出场记录列表
/// </summary>
[HttpGet("sessions/{sessionId}/entries")]
public async Task<List<WorkbenchEntryDetailDto>> GetSessionEntries(long sessionId, [FromQuery] WorkbenchEntryQueryInput input)
{
var session = await _sessionRepository.GetByIdAsync(sessionId)
?? throw Oops.Bah("场次不存在");
var scoreRule = await _scoreRuleRepository.GetByIdAsync(session.ScoreRuleId);
var roleSlots = JudgeRoleSchemaService.ParseSlots(scoreRule?.JudgeRoleSchemaJson);
var venue = await _venueRepository.GetByIdAsync(session.VenueId);
var venueOrder = venue?.Sort > 0 ? venue.Sort : 1;
var sessionArrangementOrder = GetArrangementSequence(session.SessionNo);
var query = _entryRepository.AsQueryable()
.Where(e => !e.DeleteMark && e.SessionId == sessionId);
if (input.EntryStatus.HasValue)
query = query.Where(e => e.EntryStatus == input.EntryStatus.Value);
if (!string.IsNullOrWhiteSpace(input.Keyword))
{
var keyword = input.Keyword.Trim();
query = query.Where(e => e.EntryNo.Contains(keyword) || e.ParticipantName.Contains(keyword) ||
(e.TeamName != null && e.TeamName.Contains(keyword)));
}
var entries = await query
.OrderBy(e => e.PlayOrder)
.Select(e => new
{
e.Id,
e.EntryNo,
e.PlayOrder,
e.SourceType,
e.ParticipantName,
e.TeamName,
e.GroupName,
e.EntryStatus,
e.IsRematch
})
.ToListAsync();
if (!entries.Any())
return new List<WorkbenchEntryDetailDto>();
var entryIds = entries.Select(e => e.Id).ToList();
var results = await _resultRepository.AsQueryable()
.Where(r => !r.DeleteMark && entryIds.Contains(r.SessionEntryId!.Value))
.Select(r => new
{
r.Id,
r.SessionEntryId,
r.DifficultyScore,
r.PerformanceScore,
r.RequiredScore,
r.DeductionScore,
r.CalculationJson,
r.TotalScore,
r.Score,
r.IsDisqualified,
r.DisqualifyReasonCode,
r.ResultStatus,
r.ResultVersion,
r.ReviewUserId,
r.ReviewTime,
r.Rank,
r.MerchantCertificationId
})
.ToListAsync();
// 批量查询成绩关联的参赛单位名称(单位信息存于商户认证表)
var merchantIds = results.Where(r => r.MerchantCertificationId.HasValue)
.Select(r => r.MerchantCertificationId!.Value).Distinct().ToList();
var merchantNames = (await _merchantCertificationRepository.AsQueryable()
.Where(m => merchantIds.Contains(m.Id) && !m.DeleteMark)
.Select(m => new { m.Id, m.MerchantName }).ToListAsync())
.ToDictionary(m => m.Id, m => m.MerchantName);
if (input.ResultStatus.HasValue)
{
var filteredEntryIds = results.Where(r => r.ResultStatus == input.ResultStatus.Value).Select(r => r.SessionEntryId).ToHashSet();
entries = entries.Where(e => filteredEntryIds.Contains(e.Id)).ToList();
entryIds = entries.Select(e => e.Id).ToList();
}
var sheets = await _sheetRepository.AsQueryable()
.Where(s => !s.DeleteMark && entryIds.Contains(s.SessionEntryId) && s.IsCurrent)
.OrderBy(s => s.JudgeRole)
.ToListAsync();
// 表头必须以场次裁判分配为准,不能等评分单生成后才出现裁判列。
var assignments = await _assignmentRepository.AsQueryable()
.Where(a => !a.DeleteMark && a.SessionId == sessionId && a.AssignmentStatus < 9 &&
a.Status == Core.Enum.AvailabilityStatus.Enable)
.OrderBy(a => a.JudgeRole)
.ToListAsync();
// 批量查询当前评分单的动态评分项明细,按评分单分组挂到各裁判评分下
var sheetIds = sheets.Select(s => s.Id).ToList();
var annotationUrls = sheetIds.Count == 0
? new Dictionary<long, string>()
: (await _scoreAttachmentRepository.AsQueryable()
.Where(a => !a.DeleteMark && sheetIds.Contains(a.ScoreSheetId)
&& a.IsCurrent && a.AttachmentType == "score_annotation_png")
.Select(a => new { a.ScoreSheetId, a.FileUrl, a.CreatedTime })
.ToListAsync())
.GroupBy(a => a.ScoreSheetId)
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.CreatedTime).First().FileUrl);
var scoreItems = await _scoreDetailRepository.AsQueryable()
.Where(d => !d.DeleteMark && sheetIds.Contains(d.ScoreSheetId))
.OrderBy(d => d.Sort)
.Select(d => new
{
d.ScoreSheetId,
d.ItemCode,
d.ItemName,
d.SubjectCode,
d.ItemType,
d.ScoreDimension,
d.CountValue,
d.InputValue,
d.Subtotal,
d.IsAutoGenerated,
d.ParentItemCode,
d.Sort
}).ToListAsync();
var refereeIds = assignments.Select(a => a.RefereeId)
.Concat(sheets.Select(s => s.RefereeId)).Distinct().ToList();
var refereeMap = (await _refereeRepository.AsQueryable()
.Where(r => refereeIds.Contains(r.Id))
.Select(r => new { r.Id, r.Name })
.ToListAsync())
.ToDictionary(r => r.Id, r => r.Name);
var pendingCorrections = await _correctionRepository.AsQueryable()
.Where(c => !c.DeleteMark && entryIds.Contains(c.SessionEntryId) && c.CorrectionStatus < 20)
.Select(c => c.OriginalScoreSheetId)
.ToListAsync();
var pendingCorrectionSet = pendingCorrections.ToHashSet();
// 集体项目为每名队员留存同一出场成绩,记分台按出场展示一行。
// 稳定选取一个代表记录,避免同队多名队员导致重复字典键。
var resultDict = results.GroupBy(r => r.SessionEntryId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(r => r.ResultVersion)
.ThenBy(r => r.Id).First());
WorkbenchJudgeScoreDto MapSheet(EventJudgeScoreSheet sheet) => new()
{
ScoreSheetId = sheet.Id,
JudgeRole = sheet.JudgeRole,
JudgeRoleType = sheet.JudgeRoleType,
JudgeRoleName = JudgeRoleSchemaService.RoleNameOf(roleSlots, sheet.JudgeRoleType),
JudgeName = refereeMap.GetValueOrDefault(sheet.RefereeId, ""),
SheetStatus = sheet.SheetStatus,
SheetStatusName = GetSheetStatusName(sheet.SheetStatus),
DifficultyScore = sheet.DifficultyScore,
PerformanceScore = sheet.PerformanceScore,
RequiredScore = sheet.RequiredScore,
DeductionScore = sheet.DeductionScore,
TotalScore = sheet.TotalScore,
IsDisqualified = sheet.IsDisqualified,
SubmittedTime = sheet.SubmittedTime,
HasPendingCorrection = pendingCorrectionSet.Contains(sheet.Id),
AnnotationUrl = annotationUrls.GetValueOrDefault(sheet.Id),
ScoreItems = scoreItems.Where(d => d.ScoreSheetId == sheet.Id).Select(d => new WorkbenchScoreItemDto
{
ItemCode = d.ItemCode,
ItemName = d.ItemName,
SubjectCode = d.SubjectCode,
ItemType = d.ItemType,
ScoreDimension = d.ScoreDimension,
CountValue = d.CountValue,
InputValue = d.InputValue,
Subtotal = d.Subtotal,
IsAutoGenerated = d.IsAutoGenerated,
ParentItemCode = d.ParentItemCode,
Sort = d.Sort
}).ToList()
};
return entries.Select(e =>
{
var result = resultDict.GetValueOrDefault(e.Id);
var entrySheets = sheets.Where(s => s.SessionEntryId == e.Id).ToList();
var assignedScores = assignments.Select(assignment =>
{
var sheet = entrySheets.FirstOrDefault(s => s.RefereeId == assignment.RefereeId &&
s.JudgeRole == assignment.JudgeRole);
return sheet != null ? MapSheet(sheet) : new WorkbenchJudgeScoreDto
{
ScoreSheetId = 0,
JudgeRole = assignment.JudgeRole,
JudgeRoleType = assignment.JudgeRoleType ?? string.Empty,
JudgeRoleName = JudgeRoleSchemaService.RoleNameOf(roleSlots, assignment.JudgeRoleType ?? string.Empty),
JudgeName = refereeMap.GetValueOrDefault(assignment.RefereeId, ""),
SheetStatus = 0,
SheetStatusName = "待提交"
};
}).ToList();
var assignmentKeys = assignments.Select(a => $"{a.RefereeId}:{a.JudgeRole}").ToHashSet();
assignedScores.AddRange(entrySheets
.Where(sheet => !assignmentKeys.Contains($"{sheet.RefereeId}:{sheet.JudgeRole}"))
.Select(MapSheet));
return new WorkbenchEntryDetailDto
{
SessionEntryId = e.Id,
EntryNo = e.EntryNo,
PlayOrder = e.PlayOrder,
SessionOrder = e.SourceType == 1
? GetImportedSessionOrder(session.SessionName, sessionArrangementOrder)
: BuildEntrySessionOrder(sessionArrangementOrder, e.PlayOrder),
// 导入数据按预览行生成雪花 ID;自动编排数据按场次起始序号、出场序号和场地序号还原。
ScheduleOrder = e.SourceType == 1
? e.Id
: BuildEntryScheduleOrder(sessionArrangementOrder, venueOrder, e.PlayOrder),
ParticipantName = e.ParticipantName,
TeamName = e.TeamName,
UnitName = result?.MerchantCertificationId is long merchantId
? merchantNames.GetValueOrDefault(merchantId)
: null,
GroupName = e.GroupName,
EntryStatus = e.EntryStatus,
EntryStatusName = GetEntryStatusName(e.EntryStatus),
IsRematch = e.IsRematch,
Result = result != null ? new WorkbenchEntryResultDto
{
ResultId = result.Id,
DifficultyScore = result.DifficultyScore,
PerformanceScore = result.PerformanceScore,
RequiredScore = result.RequiredScore,
DeductionScore = result.DeductionScore,
DeductionFactor = ScoreDisplayService.DeductionFactor(result.CalculationJson),
CalculationJson = result.CalculationJson,
TotalScore = result.TotalScore,
ScoreText = result.Score,
IsDisqualified = result.IsDisqualified,
DisqualifyReason = result.DisqualifyReasonCode,
ResultStatus = result.ResultStatus,
ResultStatusName = GetResultStatusName(result.ResultStatus),
ResultVersion = result.ResultVersion,
ReviewUserId = result.ReviewUserId,
ReviewTime = result.ReviewTime,
Rank = result.Rank
} : null,
JudgeScores = assignedScores
};
}).ToList();
}
/// <summary>
/// 获取某个出场可发起成绩修正的裁判:
/// 裁判类型取该场次项目的计分规则配置,裁判人数取【裁判报名与分配】为本场次分配的裁判。
/// </summary>
[HttpGet("entries/{sessionEntryId}/correction-judges")]
public async Task<List<CorrectionJudgeOptionDto>> GetCorrectionJudges(long sessionEntryId)
{
var entry = await _entryRepository.GetFirstAsync(e => e.Id == sessionEntryId && !e.DeleteMark)
?? throw Oops.Bah("出场记录不存在");
var session = await _sessionRepository.GetByIdAsync(entry.SessionId)
?? throw Oops.Bah("场次不存在");
// 裁判类型来自项目绑定的计分规则,保证与裁判分配看板同源
var scoreRule = await _scoreRuleRepository.GetByIdAsync(session.ScoreRuleId);
var roleSlots = JudgeRoleSchemaService.ParseSlots(scoreRule?.JudgeRoleSchemaJson);
// 裁判数量来自该场次的裁判分配(排除已停用的分配)
var assignments = await _assignmentRepository.AsQueryable()
.Where(a => !a.DeleteMark && a.SessionId == session.Id && a.AssignmentStatus < 9 &&
a.Status == Core.Enum.AvailabilityStatus.Enable)
.OrderBy(a => a.JudgeRole)
.ToListAsync();
if (assignments.Count == 0) return new List<CorrectionJudgeOptionDto>();
var refereeIds = assignments.Select(a => a.RefereeId).Distinct().ToList();
var refereeNames = (await _refereeRepository.AsQueryable()
.Where(r => refereeIds.Contains(r.Id))
.Select(r => new { r.Id, r.Name }).ToListAsync())
.ToDictionary(r => r.Id, r => r.Name);
var sheets = await _sheetRepository.AsQueryable()
.Where(s => !s.DeleteMark && s.SessionEntryId == entry.Id && s.IsCurrent)
.ToListAsync();
var sheetIds = sheets.Select(s => s.Id).ToList();
var pendingSheetIds = (await _correctionRepository.AsQueryable()
.Where(c => !c.DeleteMark && sheetIds.Contains(c.OriginalScoreSheetId) && c.CorrectionStatus < 20)
.Select(c => c.OriginalScoreSheetId).ToListAsync()).ToHashSet();
// 规则里配置的类型排在前,规则中未配置的历史类型排到最后
var slotOrder = roleSlots.Select((slot, index) => (slot.Code, Order: index))
.ToDictionary(x => x.Code, x => x.Order, StringComparer.OrdinalIgnoreCase);
return assignments
.GroupBy(a => JudgeRoleSchemaService.NormalizeRoleType(a.JudgeRoleType ?? a.JudgeRole),
StringComparer.OrdinalIgnoreCase)
.OrderBy(g => slotOrder.GetValueOrDefault(g.Key, int.MaxValue))
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var roleName = JudgeRoleSchemaService.RoleNameOf(roleSlots, group.Key);
var ordered = group.OrderBy(a => a.JudgeRole, StringComparer.OrdinalIgnoreCase).ToList();
return new CorrectionJudgeOptionDto
{
JudgeRoleType = group.Key,
JudgeRoleName = roleName,
Judges = ordered.Select((assignment, index) =>
{
var judgeName = refereeNames.GetValueOrDefault(assignment.RefereeId, "");
var sheet = sheets.FirstOrDefault(s => s.JudgeAssignmentId == assignment.Id);
// 同一类型多名裁判时按席位顺序编号,单人时不加序号
var seatLabel = ordered.Count > 1 ? $"{roleName}{index + 1}" : roleName;
var hasPending = sheet != null && pendingSheetIds.Contains(sheet.Id);
var submitted = sheet != null && sheet.SheetStatus >= 10 && sheet.SheetStatus < 90;
return new CorrectionJudgeItemDto
{
JudgeAssignmentId = assignment.Id,
JudgeRole = assignment.JudgeRole,
RefereeId = assignment.RefereeId,
JudgeName = judgeName,
DisplayName = string.IsNullOrWhiteSpace(judgeName) ? seatLabel : $"{seatLabel}({judgeName})",
ScoreSheetId = sheet?.Id,
SheetStatus = sheet?.SheetStatus,
SheetStatusName = sheet != null ? GetSheetStatusName(sheet.SheetStatus) : null,
TotalScore = sheet?.TotalScore,
CanCorrect = submitted && !hasPending,
DisabledReason = sheet == null ? "该裁判尚未提交评分"
: hasPending ? "该评分单已有修正流程在处理中"
: !submitted ? $"评分单当前为“{GetSheetStatusName(sheet.SheetStatus)}”,不能修正"
: null
};
}).ToList()
};
}).ToList();
}
#endregion
#region 成绩投屏
/// <summary>
/// 投出成绩到大屏
/// </summary>
[HttpPost("screencast")]
[UnitOfWork]
public async Task<ResultScreenCastOutput> CastResult([FromBody] ResultScreenCastInput input)
{
var currentUserId = _authManager.UserId;
var now = DateTime.Now;
// 两种投屏类型都先校验赛事存在且未删除
if (input.EventId <= 0 || !await _eventRepository.IsAnyAsync(e => e.Id == input.EventId && !e.DeleteMark))
throw Oops.Bah("赛事不存在或已删除");
if (input.CastType == 1)
{
if (!input.SessionId.HasValue || !input.SessionEntryId.HasValue || !input.EventResultId.HasValue)
throw Oops.Bah("投出单个成绩时,必须提供场次ID、出场记录ID和成绩ID");
var entry = await _entryRepository.GetFirstAsync(e => e.Id == input.SessionEntryId.Value && !e.DeleteMark)
?? throw Oops.Bah("出场记录不存在");
var result = await _resultRepository.GetFirstAsync(r => r.Id == input.EventResultId.Value && !r.DeleteMark)
?? throw Oops.Bah("成绩记录不存在");
var previousResultStatus = result.ResultStatus;
var session = await _sessionRepository.GetByIdAsync(entry.SessionId)
?? throw Oops.Bah("场次不存在");
// 投屏前校验归属关系与成绩状态,防止跨赛事/跨场次或过期版本被投出
if (session.DeleteMark || session.Id != input.SessionId.Value || session.EventId != input.EventId ||
(input.VenueId.HasValue && session.VenueId != input.VenueId.Value))
throw Oops.Bah("赛事、场地、场次和出场记录不匹配");
if (result.EventId != input.EventId || result.SessionId != session.Id || result.SessionEntryId != entry.Id)
throw Oops.Bah("成绩记录与当前赛事出场记录不匹配");
if (result.ResultStatus is not (20 or 30 or 40))
throw Oops.Bah("只有已确认的有效成绩可以投屏");
if (result.IsDisqualified)
throw Oops.Bah("DQ成绩不能投屏");
if (input.ResultVersion.HasValue && input.ResultVersion.Value != result.ResultVersion)
throw Oops.Bah("成绩已产生新版本,请刷新后重新投屏");
// 该出场应到裁判的评分单必须全部提交,否则成绩未定稿,不允许投屏
var expectedJudgeCount = await _assignmentRepository.AsQueryable()
.Where(a => !a.DeleteMark && a.SessionId == session.Id && a.Status == Core.Enum.AvailabilityStatus.Enable)
.CountAsync();
var submittedJudgeCount = await _sheetRepository.AsQueryable()
.Where(s => !s.DeleteMark && s.IsCurrent && s.SessionEntryId == entry.Id &&
s.SheetStatus >= 10 && s.SheetStatus < 90)
.Select(s => s.JudgeAssignmentId).Distinct().CountAsync();
if (expectedJudgeCount <= 0 || submittedJudgeCount < expectedJudgeCount)
throw Oops.Bah("相关裁判评分单尚未全部提交,不能投屏");
var unitName = string.Empty;
if (result.MerchantCertificationId.HasValue)
{
var merchant = await _merchantCertificationRepository.GetFirstAsync(m =>
m.Id == result.MerchantCertificationId.Value && !m.DeleteMark);
unitName = merchant?.MerchantName ?? string.Empty;
}
var payload = new
{
EntryNo = entry.EntryNo,
ParticipantName = entry.ParticipantName,
TeamName = entry.TeamName,
UnitName = unitName,
GroupName = entry.GroupName,
DifficultyScore = result.DifficultyScore,
PerformanceScore = result.PerformanceScore,
RequiredScore = result.RequiredScore,
DeductionScore = result.DeductionScore,
DeductionFactor = ScoreDisplayService.DeductionFactor(result.CalculationJson),
CalculationJson = result.CalculationJson,
TotalScore = result.TotalScore,
Score = result.Score,
IsDisqualified = result.IsDisqualified,
Rank = result.Rank
};
// 同赛事同场地此前投出中的记录统一置为“已被新投屏替换(20)”,保证大屏只展示最新一条
var activeScreenCastUpdate = _screenCastRepository.AsUpdateable()
.SetColumns(sc => new EventResultScreenCast { CastStatus = 20 },
appendColumnsByDataFilter: true)
.Where(sc => sc.EventId == input.EventId && sc.CastStatus == 0);
if (input.VenueId.HasValue)
{
var venueId = input.VenueId.Value;
activeScreenCastUpdate = activeScreenCastUpdate.Where(sc => sc.VenueId == venueId);
}
await activeScreenCastUpdate.ExecuteCommandAsync();
var screenCast = new EventResultScreenCast
{
Id = _idGenerator.NextId(),
EventId = input.EventId,
CastType = 1,
VenueId = input.VenueId,
EventProjectId = session.EventProjectId,
EventProjectGroupId = session.EventProjectGroupId,
SessionId = session.Id,
SessionEntryId = entry.Id,
EventResultId = result.Id,
ResultVersion = input.ResultVersion ?? result.ResultVersion,
PayloadJson = System.Text.Json.JsonSerializer.Serialize(payload),
CastStatus = 0,
CastTime = now,
CreatedUserId = currentUserId,
CreatedTime = now
};
await _screenCastRepository.InsertAsync(screenCast);
// 投屏成功后成绩置为“已投屏(30)”,同步当前版本状态并记录审计
result.ResultStatus = 30;
result.UpdatedUserId = currentUserId;
result.UpdatedTime = now;
await _resultRepository.UpdateAsync(result);
await SyncResultVersionStatusAsync(result, 30, currentUserId, now);
await WriteAuditAsync("投出单人成绩", input.EventId, result.Id, previousResultStatus, 30, sessionId: session.Id);
return new ResultScreenCastOutput
{
ScreenCastId = screenCast.Id,
CastType = 1,
CastTime = now,
PayloadJson = screenCast.PayloadJson
};
}
else if (input.CastType == 2)
{
if (!input.EventProjectId.HasValue || !input.EventProjectGroupId.HasValue)
throw Oops.Bah("投出排名时,必须提供项目ID和组别ID");
// 投屏排名前校验项目归属当前赛事、组别归属该项目
var eventProject = await _eventProjectRepository.GetFirstAsync(ep =>
ep.Id == input.EventProjectId.Value && ep.EventId == input.EventId && !ep.DeleteMark)
?? throw Oops.Bah("所选项目不属于当前赛事");
_ = eventProject;
var eventProjectGroup = await _groupRepository.GetFirstAsync(g =>
g.Id == input.EventProjectGroupId.Value &&
g.EventProjectId == input.EventProjectId.Value && !g.DeleteMark)
?? throw Oops.Bah("所选组别不属于当前赛事项目");
var groupName = eventProjectGroup.GroupName;
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 &&
r.EventId == input.EventId &&
s.EventProjectId == input.EventProjectId.Value &&
(s.EventProjectGroupId == input.EventProjectGroupId.Value ||
e.GroupName == groupName) &&
// 只投屏有效成绩(已确认20/已投屏30/已更正40),排除DQ成绩
(r.ResultStatus == 20 || r.ResultStatus == 30 || r.ResultStatus == 40) &&
!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.CalculationJson,
r.TotalScore,
r.Score,
r.IsDisqualified,
r.MerchantCertificationId
})
.ToListAsync();
if (!results.Any())
throw Oops.Bah("当前项目组别没有可投屏的已确认成绩");
var rankingMerchantIds = results
.Where(r => r.MerchantCertificationId.HasValue)
.Select(r => r.MerchantCertificationId!.Value)
.Distinct()
.ToList();
var rankingMerchantNames = (await _merchantCertificationRepository.AsQueryable()
.Where(m => rankingMerchantIds.Contains(m.Id) && !m.DeleteMark)
.Select(m => new { m.Id, m.MerchantName })
.ToListAsync())
.ToDictionary(m => m.Id, m => m.MerchantName);
// 落库名次可能是成绩刚生成时连同“待复核”成绩一起计算的。
// 投屏时必须以上面已经过状态筛选的有效成绩重新排名,避免待复核成绩隐形占位。
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,
r.TeamName,
UnitName = r.MerchantCertificationId.HasValue
? rankingMerchantNames.GetValueOrDefault(r.MerchantCertificationId.Value)
: null,
r.GroupName,
r.TotalScore,
r.Score,
Rank = screenRankByEntry[r.SessionEntryId],
r.IsDisqualified
}).ToList();
// 同赛事同场地此前投出中的记录统一置为“已被新投屏替换(20)”,保证大屏只展示最新一条
var activeScreenCastUpdate = _screenCastRepository.AsUpdateable()
.SetColumns(sc => new EventResultScreenCast { CastStatus = 20 },
appendColumnsByDataFilter: true)
.Where(sc => sc.EventId == input.EventId && sc.CastStatus == 0);
if (input.VenueId.HasValue)
{
var venueId = input.VenueId.Value;
activeScreenCastUpdate = activeScreenCastUpdate.Where(sc => sc.VenueId == venueId);
}
await activeScreenCastUpdate.ExecuteCommandAsync();
var screenCast = new EventResultScreenCast
{
Id = _idGenerator.NextId(),
EventId = input.EventId,
CastType = 2,
VenueId = input.VenueId,
EventProjectId = input.EventProjectId.Value,
EventProjectGroupId = input.EventProjectGroupId.Value,
SessionId = null,
SessionEntryId = null,
EventResultId = null,
ResultVersion = null,
PayloadJson = System.Text.Json.JsonSerializer.Serialize(payload),
CastStatus = 0,
CastTime = now,
CreatedUserId = currentUserId,
CreatedTime = now
};
await _screenCastRepository.InsertAsync(screenCast);
// 组别排名投屏后,组内全部成绩置为“已投屏(30)”
var rankedResultIds = results.Select(r => r.Id).ToList();
await _resultRepository.AsUpdateable()
.SetColumns(r => new EventResult
{
ResultStatus = 30,
UpdatedUserId = currentUserId,
UpdatedTime = now
}, appendColumnsByDataFilter: true)
.Where(r => rankedResultIds.Contains(r.Id))
.ExecuteCommandAsync();
var rankedVersions = await _resultVersionRepository.AsQueryable()
.Where(v => !v.DeleteMark && rankedResultIds.Contains(v.EventResultId))
.ToListAsync();
// 只同步各成绩的当前版本,历史版本保留原状态留痕
var currentRankedVersionIds = rankedVersions
.Where(v => results.Any(r => r.Id == v.EventResultId && r.ResultVersion == v.ResultVersion))
.Select(v => v.Id)
.ToList();
if (currentRankedVersionIds.Count > 0)
await _resultVersionRepository.AsUpdateable()
.SetColumns(v => new EventResultVersion
{
ResultStatus = 30,
UpdatedUserId = currentUserId,
UpdatedTime = now
}, appendColumnsByDataFilter: true)
.Where(v => currentRankedVersionIds.Contains(v.Id))
.ExecuteCommandAsync();
await WriteAuditAsync("投出组别排名", input.EventId, input.EventProjectGroupId.Value, null, 30);
return new ResultScreenCastOutput
{
ScreenCastId = screenCast.Id,
CastType = 2,
CastTime = now,
PayloadJson = screenCast.PayloadJson
};
}
else
{
throw Oops.Bah("无效的投屏类型");
}
}
/// <summary>
/// 撤回投屏内容
/// </summary>
[HttpPost("screencast/withdraw")]
public async Task WithdrawScreenCast([FromBody] WithdrawScreenCastInput input)
{
var screenCast = await _screenCastRepository.GetByIdAsync(input.ScreenCastId)
?? throw Oops.Bah("投屏记录不存在");
if (screenCast.CastStatus != 0)
throw Oops.Bah("该投屏已被撤回或替换,无法再次撤回");
screenCast.CastStatus = 10;
screenCast.UpdatedUserId = _authManager.UserId;
screenCast.UpdatedTime = DateTime.Now;
await _screenCastRepository.UpdateAsync(screenCast);
await WriteAuditAsync("撤回投屏", screenCast.EventId, screenCast.Id, 0, 10, sessionId: screenCast.SessionId);
}
/// <summary>
/// 获取当前投屏内容
/// </summary>
[HttpGet("screencast/current")]
// 大屏展示端无登录态,允许匿名拉取当前投屏内容
[AllowAnonymous]
public async Task<CurrentScreenCastDto?> GetCurrentScreenCast([FromQuery] GetCurrentScreenCastInput input)
{
var query = _screenCastRepository.AsQueryable()
.Where(sc => sc.EventId == input.EventId && sc.CastStatus == 0);
if (input.VenueId.HasValue)
query = query.Where(sc => sc.VenueId == input.VenueId.Value);
var screenCast = (await query
.OrderByDescending(sc => sc.CastTime)
.Take(1)
.ToListAsync())
.FirstOrDefault();
if (screenCast == null)
return null;
// 补全赛事、项目、组别、场地、场次与计分规则信息,供大屏标题区展示
var eventInfo = await _eventRepository.GetByIdAsync(screenCast.EventId);
var venue = screenCast.VenueId.HasValue
? await _venueRepository.GetByIdAsync(screenCast.VenueId.Value)
: null;
var eventProject = screenCast.EventProjectId.HasValue
? await _eventProjectRepository.GetByIdAsync(screenCast.EventProjectId.Value)
: null;
var project = eventProject != null
? await _projectRepository.GetByIdAsync(eventProject.ProjectId)
: null;
var group = screenCast.EventProjectGroupId.HasValue
? await _groupRepository.GetByIdAsync(screenCast.EventProjectGroupId.Value)
: null;
var session = screenCast.SessionId.HasValue
? await _sessionRepository.GetByIdAsync(screenCast.SessionId.Value)
: null;
var rule = session != null
? await _scoreRuleRepository.GetByIdAsync(session.ScoreRuleId)
: null;
return new CurrentScreenCastDto
{
ScreenCastId = screenCast.Id,
CastType = screenCast.CastType,
CastStatus = screenCast.CastStatus,
CastTime = screenCast.CastTime,
EventName = eventInfo?.EventName ?? string.Empty,
ProjectName = project?.Name ?? string.Empty,
ScoreRuleCode = rule?.RuleCode ?? string.Empty,
GroupName = group?.GroupName ?? string.Empty,
VenueName = venue?.VenueName ?? string.Empty,
SessionNo = session?.SessionNo ?? string.Empty,
PayloadJson = screenCast.PayloadJson
};
}
#endregion
#region 成绩修正审批
/// <summary>
/// 获取成绩修正申请列表
/// </summary>
[HttpGet("corrections")]
public async Task<List<ScoreCorrectionListDto>> GetCorrections([FromQuery] ScoreCorrectionQueryInput input)
{
var query = _correctionRepository.AsQueryable()
.Where(c => !c.DeleteMark && c.EventId == input.EventId);
if (input.CorrectionStatus.HasValue)
query = query.Where(c => c.CorrectionStatus == input.CorrectionStatus.Value);
if (!string.IsNullOrWhiteSpace(input.Keyword))
{
var keyword = input.Keyword.Trim();
query = query.Where(c => c.ApplyReason.Contains(keyword));
}
var corrections = await query
.OrderByDescending(c => c.CreatedTime)
.ToListAsync();
if (!corrections.Any())
return new List<ScoreCorrectionListDto>();
var sessionIds = corrections.Select(c => c.SessionId).Distinct().ToList();
var entryIds = corrections.Select(c => c.SessionEntryId).Distinct().ToList();
var applyUserIds = corrections.Where(c => c.ApplyUserId.HasValue)
.Select(c => c.ApplyUserId!.Value).Distinct().ToList();
var applyRefereeIds = corrections.Where(c => c.ApplyRefereeId.HasValue)
.Select(c => c.ApplyRefereeId!.Value).Distinct().ToList();
var approveUserIds = corrections.Where(c => c.ApproveUserId.HasValue).Select(c => c.ApproveUserId!.Value).Distinct().ToList();
var sessions = (await _sessionRepository.AsQueryable()
.Where(s => sessionIds.Contains(s.Id))
.Select(s => new { s.Id, s.SessionNo })
.ToListAsync())
.ToDictionary(s => s.Id, s => s.SessionNo);
var entries = (await _entryRepository.AsQueryable()
.Where(e => entryIds.Contains(e.Id))
.Select(e => new { e.Id, e.EntryNo, e.ParticipantName })
.ToListAsync())
.ToDictionary(e => e.Id);
var eventInfo = await _eventRepository.GetFirstAsync(e => e.Id == input.EventId);
var allUserIds = applyUserIds.Concat(approveUserIds).Distinct().ToList();
var users = (await _sysUserRepository.AsQueryable()
.Where(u => allUserIds.Contains(u.Id))
.Select(u => new { u.Id, u.Name })
.ToListAsync())
.ToDictionary(u => u.Id, u => u.Name);
var applyingReferees = (await _refereeRepository.AsQueryable()
.Where(r => applyRefereeIds.Contains(r.Id))
.Select(r => new { r.Id, r.Name })
.ToListAsync())
.ToDictionary(r => r.Id, r => r.Name);
return corrections.Select(c =>
{
var entry = entries.GetValueOrDefault(c.SessionEntryId);
return new ScoreCorrectionListDto
{
CorrectionId = c.Id,
EventName = eventInfo?.EventName ?? "",
SessionNo = sessions.GetValueOrDefault(c.SessionId, ""),
EntryNo = entry?.EntryNo ?? "",
ParticipantName = entry?.ParticipantName ?? "",
JudgeRole = c.JudgeRole,
ApplyUserName = c.ApplyRefereeId.HasValue
? applyingReferees.GetValueOrDefault(c.ApplyRefereeId.Value, "")
: c.ApplyUserId.HasValue ? users.GetValueOrDefault(c.ApplyUserId.Value, "") : "",
ApplyReason = c.ApplyReason,
CreatedTime = c.CreatedTime,
CorrectionStatus = c.CorrectionStatus,
CorrectionStatusName = GetCorrectionStatusName(c.CorrectionStatus),
ApproveUserName = c.ApproveUserId.HasValue ? users.GetValueOrDefault(c.ApproveUserId.Value, "") : null,
ApproveTime = c.ApproveTime,
CompletedTime = c.CompletedTime
};
}).ToList();
}
/// <summary>
/// 获取成绩修正申请详情
/// </summary>
[HttpGet("corrections/{correctionId}")]
public async Task<ScoreCorrectionDetailDto> GetCorrectionDetail(long correctionId)
{
var correction = await _correctionRepository.GetFirstAsync(c => c.Id == correctionId && !c.DeleteMark)
?? throw Oops.Bah("修正申请不存在");
var session = await _sessionRepository.GetByIdAsync(correction.SessionId)
?? throw Oops.Bah("场次不存在");
var entry = await _entryRepository.GetByIdAsync(correction.SessionEntryId)
?? throw Oops.Bah("出场记录不存在");
var originalSheet = await _sheetRepository.GetByIdAsync(correction.OriginalScoreSheetId)
?? throw Oops.Bah("原评分单不存在");
EventJudgeScoreSheet? newSheet = null;
if (correction.NewScoreSheetId.HasValue)
{
newSheet = await _sheetRepository.GetByIdAsync(correction.NewScoreSheetId.Value);
}
var referee = await _refereeRepository.GetByIdAsync(originalSheet.RefereeId);
var scoreRule = await _scoreRuleRepository.GetByIdAsync(session.ScoreRuleId);
var roleSlots = JudgeRoleSchemaService.ParseSlots(scoreRule?.JudgeRoleSchemaJson);
var eventInfo = await _eventRepository.GetByIdAsync(correction.EventId);
var venue = await _venueRepository.GetByIdAsync(session.VenueId);
var eventProject = await _eventProjectRepository.GetByIdAsync(session.EventProjectId);
var project = eventProject != null ? await _projectRepository.GetByIdAsync(eventProject.ProjectId) : null;
var applyUser = correction.ApplyUserId.HasValue
? await _sysUserRepository.GetByIdAsync(correction.ApplyUserId.Value)
: null;
var applyReferee = correction.ApplyRefereeId.HasValue
? await _refereeRepository.GetByIdAsync(correction.ApplyRefereeId.Value)
: null;
var approveUser = correction.ApproveUserId.HasValue
? await _userRepository.GetByIdAsync(correction.ApproveUserId.Value)
: null;
var result = await _resultRepository.GetFirstAsync(r => r.SessionEntryId == entry.Id && !r.DeleteMark);
return new ScoreCorrectionDetailDto
{
CorrectionId = correction.Id,
Session = new WorkbenchSessionDto
{
SessionId = session.Id,
EventId = session.EventId,
VenueId = session.VenueId,
EventProjectId = session.EventProjectId,
EventProjectGroupId = session.EventProjectGroupId,
SessionNo = session.SessionNo,
SessionName = session.SessionName,
EventName = eventInfo?.EventName ?? "",
VenueName = venue?.VenueName ?? "",
ProjectName = project?.Name ?? "",
StartTime = session.StartTime,
SessionStatus = session.SessionStatus,
SessionStatusName = GetSessionStatusName(session.SessionStatus)
},
Entry = new WorkbenchEntryDetailDto
{
SessionEntryId = entry.Id,
EntryNo = entry.EntryNo,
PlayOrder = entry.PlayOrder,
ParticipantName = entry.ParticipantName,
TeamName = entry.TeamName,
GroupName = entry.GroupName,
EntryStatus = entry.EntryStatus,
EntryStatusName = GetEntryStatusName(entry.EntryStatus),
IsRematch = entry.IsRematch,
Result = result != null ? new WorkbenchEntryResultDto
{
ResultId = result.Id,
DifficultyScore = result.DifficultyScore,
PerformanceScore = result.PerformanceScore,
RequiredScore = result.RequiredScore,
DeductionScore = result.DeductionScore,
DeductionFactor = ScoreDisplayService.DeductionFactor(result.CalculationJson),
CalculationJson = result.CalculationJson,
TotalScore = result.TotalScore,
ScoreText = result.Score,
IsDisqualified = result.IsDisqualified,
ResultStatus = result.ResultStatus,
ResultStatusName = GetResultStatusName(result.ResultStatus),
ResultVersion = result.ResultVersion,
ReviewUserId = result.ReviewUserId,
ReviewTime = result.ReviewTime
} : null
},
OriginalScore = new WorkbenchJudgeScoreDto
{
ScoreSheetId = originalSheet.Id,
JudgeRole = originalSheet.JudgeRole,
JudgeRoleType = originalSheet.JudgeRoleType,
JudgeRoleName = GetJudgeRoleTypeName(originalSheet.JudgeRoleType),
JudgeName = referee?.Name ?? "",
SheetStatus = originalSheet.SheetStatus,
SheetStatusName = GetSheetStatusName(originalSheet.SheetStatus),
DifficultyScore = originalSheet.DifficultyScore,
PerformanceScore = originalSheet.PerformanceScore,
RequiredScore = originalSheet.RequiredScore,
DeductionScore = originalSheet.DeductionScore,
TotalScore = originalSheet.TotalScore,
IsDisqualified = originalSheet.IsDisqualified,
SubmittedTime = originalSheet.SubmittedTime,
HasPendingCorrection = false
},
NewScore = newSheet != null ? new WorkbenchJudgeScoreDto
{
ScoreSheetId = newSheet.Id,
JudgeRole = newSheet.JudgeRole,
JudgeRoleType = newSheet.JudgeRoleType,
JudgeRoleName = JudgeRoleSchemaService.RoleNameOf(roleSlots, newSheet.JudgeRoleType),
JudgeName = referee?.Name ?? "",
SheetStatus = newSheet.SheetStatus,
SheetStatusName = GetSheetStatusName(newSheet.SheetStatus),
DifficultyScore = newSheet.DifficultyScore,
PerformanceScore = newSheet.PerformanceScore,
RequiredScore = newSheet.RequiredScore,
DeductionScore = newSheet.DeductionScore,
TotalScore = newSheet.TotalScore,
IsDisqualified = newSheet.IsDisqualified,
SubmittedTime = newSheet.SubmittedTime,
HasPendingCorrection = false
} : null,
ApplyUserName = applyReferee?.Name ?? applyUser?.Name ?? "",
ApplyReason = correction.ApplyReason,
CreatedTime = correction.CreatedTime,
CorrectionStatus = correction.CorrectionStatus,
ApproveUserName = approveUser?.Name,
ApproveRemark = correction.ApproveRemark,
ApproveTime = correction.ApproveTime,
CompletedTime = correction.CompletedTime
};
}
/// <summary>
/// 审批成绩修正申请
/// </summary>
[HttpPost("corrections/approve")]
[UnitOfWork]
public async Task ApproveCorrection([FromBody] ApproveScoreCorrectionInput input)
{
var currentUserId = _authManager.UserId;
var now = DateTime.Now;
var correction = await _correctionRepository.GetFirstAsync(c => c.Id == input.CorrectionId && !c.DeleteMark)
?? throw Oops.Bah("修正申请不存在");
if (correction.CorrectionStatus != 0)
throw Oops.Bah("该申请已被处理,无法重复审批");
correction.ApproveUserId = currentUserId;
correction.ApproveRemark = input.ApproveRemark;
correction.ApproveTime = now;
correction.CorrectionStatus = input.IsApproved ? 10 : 20;
correction.UpdatedUserId = currentUserId;
correction.UpdatedTime = now;
await _correctionRepository.UpdateAsync(correction);
if (input.IsApproved)
{
// 批准修正需向原裁判下发修正任务,原裁判分配已删除时无法继续
var assignment = await _assignmentRepository.GetByIdAsync(correction.JudgeAssignmentId);
if (assignment == null || assignment.DeleteMark)
throw Oops.Bah("原裁判分配不存在,无法下发修正任务");
var originalSheet = await _sheetRepository.GetByIdAsync(correction.OriginalScoreSheetId)
?? throw Oops.Bah("原评分单不存在,无法下发修正任务");
var originalTask = originalSheet.JudgeTaskId > 0
? await _taskRepository.GetByIdAsync(originalSheet.JudgeTaskId)
: null;
var session = await _sessionRepository.GetByIdAsync(correction.SessionId)
?? throw Oops.Bah("比赛场次不存在,无法下发修正任务");
var receiverUserId = await ResolveJudgeUserIdAsync(assignment, originalSheet);
var task = new EventJudgeTask
{
Id = _idGenerator.NextId(),
EventId = correction.EventId,
SessionId = correction.SessionId,
SessionEntryId = correction.SessionEntryId,
JudgeAssignmentId = correction.JudgeAssignmentId,
EventRefereeId = assignment.EventRefereeId,
RefereeId = assignment.RefereeId,
JudgeUserId = receiverUserId,
JudgeRole = assignment.JudgeRole,
JudgeRoleType = assignment.JudgeRoleType,
ScoreRuleId = originalTask?.ScoreRuleId ?? session.ScoreRuleId,
RuleVersion = originalTask?.RuleVersion ?? originalSheet.RuleVersion,
TaskSnapshotJson = originalTask?.TaskSnapshotJson ?? "{}",
TaskType = 1,
TaskStatus = 10,
TaskVersion = (originalTask?.TaskVersion ?? 0) + 1,
OriginalTaskId = originalTask?.Id,
DispatchedTime = now,
CreatedUserId = currentUserId,
CreatedTime = now
};
await _taskRepository.InsertAsync(task);
correction.CorrectionTaskId = task.Id;
await _correctionRepository.UpdateAsync(correction);
if (receiverUserId.HasValue)
{
var message = new EventJudgeMessage
{
Id = _idGenerator.NextId(),
EventId = correction.EventId,
ReceiverUserId = receiverUserId.Value,
RefereeId = assignment.RefereeId,
MessageType = 1,
Title = "成绩修正任务通知",
Content = $"您有一条成绩修正任务需要处理,申请原因:{correction.ApplyReason}",
RelationType = nameof(EventScoreCorrection),
RelationId = correction.Id,
IsRead = false,
CreatedUserId = currentUserId,
CreatedTime = now
};
await _messageRepository.InsertAsync(message);
}
}
await WriteAuditAsync(input.IsApproved ? "批准成绩修正" : "驳回成绩修正",
correction.EventId, correction.Id, 0, correction.CorrectionStatus,
input.IsApproved ? correction.ApplyReason : input.ApproveRemark, correction.SessionId);
}
/// <summary>
/// 后台发起成绩修正,可一次勾选同一出场下的多名裁判。
/// 发起即代表已批准,被勾选的裁判可直接重新提交评分单。
/// </summary>
[HttpPost("corrections/initiate")]
[UnitOfWork]
public async Task<List<long>> InitiateCorrections([FromBody] WorkbenchCorrectionInitiateInput input)
{
var currentUserId = _authManager.UserId;
var now = DateTime.Now;
var reason = NormalizeReason(input.Reason, "修正原因");
var entry = await _entryRepository.GetFirstAsync(e => e.Id == input.SessionEntryId && !e.DeleteMark)
?? throw Oops.Bah("出场记录不存在");
var session = await _sessionRepository.GetByIdAsync(entry.SessionId)
?? throw Oops.Bah("比赛场次不存在");
var sheetIds = input.ScoreSheetIds.Where(id => id > 0).Distinct().ToList();
if (sheetIds.Count == 0) throw Oops.Bah("请至少选择一名裁判");
// 只允许修正本出场当前生效且已提交的评分单
var sheets = await _sheetRepository.AsQueryable()
.Where(s => !s.DeleteMark && sheetIds.Contains(s.Id) && s.SessionEntryId == entry.Id && s.IsCurrent)
.ToListAsync();
var missing = sheetIds.Except(sheets.Select(s => s.Id)).ToList();
if (missing.Count > 0) throw Oops.Bah("所选裁判的评分单不存在或已不是当前生效版本");
var notSubmitted = sheets.FirstOrDefault(s => s.SheetStatus < 10 || s.SheetStatus >= 90);
if (notSubmitted != null)
throw Oops.Bah($"{notSubmitted.JudgeRole}号位评分单当前为“{GetSheetStatusName(notSubmitted.SheetStatus)}”,不能修正");
var pendingSheetIds = (await _correctionRepository.AsQueryable()
.Where(c => !c.DeleteMark && sheetIds.Contains(c.OriginalScoreSheetId)
&& (c.CorrectionStatus == 0 || c.CorrectionStatus == 10 || c.CorrectionStatus == 30))
.Select(c => c.OriginalScoreSheetId).ToListAsync()).ToHashSet();
var pending = sheets.FirstOrDefault(s => pendingSheetIds.Contains(s.Id));
if (pending != null)
throw Oops.Bah($"{pending.JudgeRole}号位评分单已有修正流程在处理中");
var correctionIds = new List<long>();
foreach (var sheet in sheets.OrderBy(s => s.JudgeRole, StringComparer.OrdinalIgnoreCase))
{
var assignment = await _assignmentRepository.GetByIdAsync(sheet.JudgeAssignmentId);
if (assignment == null || assignment.DeleteMark)
throw Oops.Bah($"{sheet.JudgeRole}号位的裁判分配不存在,无法下发修正任务");
var correction = new EventScoreCorrection
{
Id = _idGenerator.NextId(),
EventId = sheet.EventId,
SessionId = sheet.SessionId,
SessionEntryId = sheet.SessionEntryId,
OriginalScoreSheetId = sheet.Id,
JudgeAssignmentId = sheet.JudgeAssignmentId,
JudgeRole = sheet.JudgeRole,
ApplyUserId = currentUserId,
ApplyReason = reason,
// 后台发起本身即代表已批准,裁判端可直接重新评分
CorrectionStatus = 10,
ApproveUserId = currentUserId,
ApproveRemark = "赛事管理员发起",
ApproveTime = now,
CreatedUserId = currentUserId,
CreatedTime = now
};
await _correctionRepository.InsertAsync(correction);
var originalTask = sheet.JudgeTaskId > 0
? await _taskRepository.GetByIdAsync(sheet.JudgeTaskId)
: null;
var receiverUserId = await ResolveJudgeUserIdAsync(assignment, sheet);
var task = new EventJudgeTask
{
Id = _idGenerator.NextId(),
EventId = correction.EventId,
SessionId = correction.SessionId,
SessionEntryId = correction.SessionEntryId,
JudgeAssignmentId = correction.JudgeAssignmentId,
EventRefereeId = assignment.EventRefereeId,
RefereeId = assignment.RefereeId,
JudgeUserId = receiverUserId,
JudgeRole = assignment.JudgeRole,
JudgeRoleType = assignment.JudgeRoleType,
ScoreRuleId = originalTask?.ScoreRuleId ?? session.ScoreRuleId,
RuleVersion = originalTask?.RuleVersion ?? sheet.RuleVersion,
TaskSnapshotJson = originalTask?.TaskSnapshotJson ?? "{}",
TaskType = 1,
TaskStatus = 10,
TaskVersion = (originalTask?.TaskVersion ?? 0) + 1,
OriginalTaskId = originalTask?.Id,
DispatchedTime = now,
CreatedUserId = currentUserId,
CreatedTime = now
};
await _taskRepository.InsertAsync(task);
correction.CorrectionTaskId = task.Id;
await _correctionRepository.UpdateAsync(correction);
// 裁判未绑定小程序账号时消息无处投递,只保留修正记录,不阻断主流程
if (receiverUserId.HasValue)
{
await _messageRepository.InsertAsync(new EventJudgeMessage
{
Id = _idGenerator.NextId(),
EventId = correction.EventId,
ReceiverUserId = receiverUserId.Value,
RefereeId = assignment.RefereeId,
MessageType = 1,
Title = "成绩修正通知",
Content = $"请对{assignment.JudgeRole}号位的评分重新提交,原因:{reason}",
RelationType = nameof(EventScoreCorrection),
RelationId = correction.Id,
IsRead = false,
CreatedUserId = currentUserId,
CreatedTime = now
});
}
correctionIds.Add(correction.Id);
}
await WriteAuditAsync("发起成绩修正", sheets[0].EventId, entry.Id, null, 10,
$"裁判数量:{sheets.Count},原因:{reason}", entry.SessionId);
return correctionIds;
}
#endregion
#region 成绩管理
/// <summary>
/// 确认成绩
/// </summary>
[HttpPost("results/confirm")]
[UnitOfWork]
public async Task ConfirmResult([FromBody] ConfirmResultInput input)
{
var currentUserId = _authManager.UserId;
var now = DateTime.Now;
var result = await _resultRepository.GetFirstAsync(r => r.Id == input.ResultId && !r.DeleteMark)
?? throw Oops.Bah("成绩记录不存在");
if (result.ResultStatus == 90)
throw Oops.Bah("该成绩已作废");
if (result.ResultStatus != 10)
throw Oops.Bah("只能确认待复核状态的成绩");
// 当前版本由成绩修正产生时确认为“已更正(40)”,常规成绩确认为“已确认(20)”
var currentVersion = await _resultVersionRepository.GetFirstAsync(v =>
!v.DeleteMark && v.EventResultId == result.Id && v.ResultVersion == result.ResultVersion);
var confirmedStatus = currentVersion?.ScoreCorrectionId.HasValue == true ? 40 : 20;
result.ResultStatus = confirmedStatus;
result.ReviewUserId = currentUserId;
result.ReviewTime = now;
result.UpdatedUserId = currentUserId;
result.UpdatedTime = now;
await _resultRepository.UpdateAsync(result);
await SyncResultVersionStatusAsync(result, confirmedStatus, currentUserId, now, currentVersion);
await WriteAuditAsync("确认成绩", result.EventId, result.Id, 10, confirmedStatus, sessionId: result.SessionId);
}
/// <summary>
/// 批量确认成绩
/// </summary>
[HttpPost("results/batch-confirm")]
[UnitOfWork]
public async Task BatchConfirmResults([FromBody] BatchConfirmResultInput input)
{
var currentUserId = _authManager.UserId;
var now = DateTime.Now;
var query = _resultRepository.AsQueryable()
.InnerJoin<EventSessionEntry>((r, e) => r.SessionEntryId == e.Id)
.Where((r, e) => !r.DeleteMark && !e.DeleteMark && e.SessionId == input.SessionId && r.ResultStatus == 10);
// 必须显式提交选中的成绩,过滤非法Id并去重,防止误确认整场次
var requestedIds = input.ResultIds.Where(id => id > 0).Distinct().ToList();
if (!requestedIds.Any())
throw Oops.Bah("请选择至少一条待确认成绩");
query = query.Where((r, e) => requestedIds.Contains(r.Id));
var results = await query.Select((r, e) => r).ToListAsync();
if (!results.Any())
throw Oops.Bah("没有可确认的成绩");
if (results.Count != requestedIds.Count)
throw Oops.Bah("选中成绩中包含非当前场次或非待复核数据,请刷新后重试");
// 批量取出当前版本,逐条同步确认状态(含修正产生的已更正40状态)
var resultIds = results.Select(r => r.Id).ToList();
var resultVersions = await _resultVersionRepository.AsQueryable()
.Where(v => !v.DeleteMark && resultIds.Contains(v.EventResultId))
.ToListAsync();
var currentResultVersions = new List<EventResultVersion>();
foreach (var result in results)
{
var version = resultVersions.FirstOrDefault(v =>
v.EventResultId == result.Id && v.ResultVersion == result.ResultVersion);
result.ResultStatus = version?.ScoreCorrectionId.HasValue == true ? 40 : 20;
result.ReviewUserId = currentUserId;
result.ReviewTime = now;
result.UpdatedUserId = currentUserId;
result.UpdatedTime = now;
if (version != null)
{
version.ResultStatus = result.ResultStatus;
version.UpdatedUserId = currentUserId;
version.UpdatedTime = now;
currentResultVersions.Add(version);
}
}
await _resultRepository.UpdateRangeAsync(results);
if (currentResultVersions.Count > 0)
await _resultVersionRepository.UpdateRangeAsync(currentResultVersions);
await WriteAuditAsync("批量确认成绩", results[0].EventId, input.SessionId, 10,
results.Any(r => r.ResultStatus == 40) ? "20/40" : "20",
$"成绩数量:{results.Count}", input.SessionId);
}
/// <summary>
/// 创建重赛
/// </summary>
[HttpPost("entries/rematch")]
[UnitOfWork]
public async Task<CreateRematchOutput> CreateRematch([FromBody] CreateRematchInput input)
{
var currentUserId = _authManager.UserId;
var now = DateTime.Now;
var reason = NormalizeReason(input.RematchReason, "重赛原因");
var originalEntry = await _entryRepository.GetFirstAsync(e => e.Id == input.OriginalSessionEntryId && !e.DeleteMark)
?? throw Oops.Bah("原出场记录不存在");
var session = await _sessionRepository.GetByIdAsync(originalEntry.SessionId)
?? throw Oops.Bah("场次不存在");
// 已作废的出场不能重复发起重赛,避免生成多条重赛记录
if (originalEntry.IsVoided)
throw Oops.Bah("该出场记录已作废,不能重复创建重赛");
// 出场序号只在同组别内连续,重赛出场追加到本组别末尾
var groupLastPlayOrder = await _entryRepository.AsQueryable()
.Where(e => !e.DeleteMark && e.SessionId == originalEntry.SessionId && e.GroupName == originalEntry.GroupName)
.MaxAsync(e => e.PlayOrder);
var newPlayOrder = groupLastPlayOrder + 1;
// 占用该序号及之后的同组别出场倒序后移一位,避免出场序号冲突
var laterEntries = await _entryRepository.AsQueryable()
.Where(e => !e.DeleteMark && e.SessionId == originalEntry.SessionId &&
e.GroupName == originalEntry.GroupName && e.PlayOrder >= newPlayOrder)
.OrderByDescending(e => e.PlayOrder)
.ToListAsync();
foreach (var laterEntry in laterEntries)
{
laterEntry.PlayOrder++;
laterEntry.UpdatedUserId = currentUserId;
laterEntry.UpdatedTime = now;
await _entryRepository.UpdateAsync(laterEntry);
}
originalEntry.IsVoided = true;
originalEntry.VoidReason = $"重赛原因:{reason}";
originalEntry.UpdatedUserId = currentUserId;
originalEntry.UpdatedTime = now;
await _entryRepository.UpdateAsync(originalEntry);
var result = await _resultRepository.GetFirstAsync(r => r.SessionEntryId == originalEntry.Id && !r.DeleteMark);
if (result != null)
{
result.ResultStatus = 90;
result.UpdatedUserId = currentUserId;
result.UpdatedTime = now;
await _resultRepository.UpdateAsync(result);
await SyncResultVersionStatusAsync(result, 90, currentUserId, now);
}
var newEntry = new EventSessionEntry
{
Id = _idGenerator.NextId(),
SessionId = originalEntry.SessionId,
ParticipationRecordId = originalEntry.ParticipationRecordId,
RegistrationItemId = originalEntry.RegistrationItemId,
SourceType = originalEntry.SourceType,
EntryNo = $"{originalEntry.EntryNo}-R",
PlayOrder = newPlayOrder,
EntryStatus = 0,
IsRematch = true,
RematchFromEntryId = originalEntry.Id,
IsVoided = false,
ParticipantName = originalEntry.ParticipantName,
TeamName = originalEntry.TeamName,
GroupName = originalEntry.GroupName,
Version = 1,
Status = Core.Enum.AvailabilityStatus.Enable,
DeleteMark = false,
CreatedUserId = currentUserId,
CreatedTime = now
};
await _entryRepository.InsertAsync(newEntry);
await WriteAuditAsync("创建重赛", session.EventId, originalEntry.Id, "有效", "已作废并创建重赛", reason, session.Id);
return new CreateRematchOutput
{
NewSessionEntryId = newEntry.Id,
NewEntryNo = newEntry.EntryNo,
NewPlayOrder = newEntry.PlayOrder
};
}
/// <summary>
/// 作废成绩
/// </summary>
[HttpPost("results/void")]
[UnitOfWork]
public async Task VoidResult([FromBody] VoidResultInput input)
{
var currentUserId = _authManager.UserId;
var now = DateTime.Now;
var reason = NormalizeReason(input.VoidReason, "作废原因");
var entry = await _entryRepository.GetFirstAsync(e => e.Id == input.SessionEntryId && !e.DeleteMark)
?? throw Oops.Bah("出场记录不存在");
// 已作废的出场记录直接拦截,避免重复写入作废状态与原因
if (entry.IsVoided)
throw Oops.Bah("该出场记录已作废");
entry.IsVoided = true;
entry.VoidReason = reason;
entry.UpdatedUserId = currentUserId;
entry.UpdatedTime = now;
await _entryRepository.UpdateAsync(entry);
var result = await _resultRepository.GetFirstAsync(r => r.SessionEntryId == entry.Id && !r.DeleteMark);
if (result != null)
{
result.ResultStatus = 90;
result.UpdatedUserId = currentUserId;
result.UpdatedTime = now;
await _resultRepository.UpdateAsync(result);
await SyncResultVersionStatusAsync(result, 90, currentUserId, now);
}
var voidSession = await _sessionRepository.GetByIdAsync(entry.SessionId);
await WriteAuditAsync("作废成绩", voidSession?.EventId ?? 0, entry.Id, "有效", "已作废", reason, entry.SessionId);
}
#endregion
#region 数据导出
/// <summary>
/// 导出成绩册(与成绩管理页面筛选条件一致,默认仅导出已确认、未DQ成绩)
/// </summary>
[DisplayName("导出成绩册")]
[HttpGet("results/export")]
public async Task<IActionResult> ExportResults([FromQuery] ResultExportQueryInput input)
{
var query = _resultRepository.AsQueryable()
.InnerJoin<EventSessionEntry>((r, e) => r.SessionEntryId == e.Id)
.LeftJoin<EventSession>((r, e, s) => e.SessionId == s.Id)
.Where((r, e, s) => !r.DeleteMark && !e.DeleteMark && r.EventId == input.EventId);
if (input.ResultStatus.HasValue)
{
var resultStatus = input.ResultStatus.Value;
query = query.Where((r, e, s) => r.ResultStatus == resultStatus);
}
if (!input.IncludeDq)
query = query.Where((r, e, s) => !r.IsDisqualified);
if (!string.IsNullOrWhiteSpace(input.GroupName))
{
var groupName = input.GroupName.Trim();
query = query.Where((r, e, s) => e.GroupName.Contains(groupName) || r.GroupName.Contains(groupName));
}
if (input.EventProjectId.HasValue)
{
var eventProjectId = input.EventProjectId.Value;
query = query.Where((r, e, s) => s.EventProjectId == eventProjectId);
}
// 支持按单个场次缩小导出范围
if (input.SessionId.HasValue)
{
var sessionId = input.SessionId.Value;
query = query.Where((r, e, s) => e.SessionId == sessionId);
}
// 关键词匹配出场编号、参赛者姓名或队名
if (!string.IsNullOrWhiteSpace(input.Keyword))
{
var keyword = input.Keyword.Trim();
query = query.Where((r, e, s) => e.EntryNo.Contains(keyword) || e.ParticipantName.Contains(keyword) ||
(e.TeamName != null && e.TeamName.Contains(keyword)));
}
var flat = await query
.OrderBy((r, e, s) => r.Rank)
.Select((r, e, s) => new
{
r.Id,
r.Rank,
EntryNo = e.EntryNo,
Participant = e.ParticipantName,
Team = e.TeamName,
GroupName = e.GroupName,
r.ProjectId,
r.DifficultyScore,
r.PerformanceScore,
r.RequiredScore,
r.DeductionScore,
r.CalculationJson,
r.TotalScore,
r.Score,
r.IsDisqualified,
r.ResultStatus,
r.ResultVersion,
r.ReviewTime,
EventProjectId = (long?)s.EventProjectId
})
.ToListAsync();
if (!flat.Any())
throw Oops.Bah("暂无数据可导出");
var eventProjectIds = flat.Where(x => x.EventProjectId != null).Select(x => x.EventProjectId!.Value).Distinct().ToList();
var eventProjects = await _eventProjectRepository.AsQueryable()
.Where(x => eventProjectIds.Contains(x.Id))
.Select(x => new { x.Id, x.ProjectId })
.ToListAsync();
var projectIds = eventProjects.Select(x => x.ProjectId).Distinct().ToList();
var projectNames = (await _projectRepository.AsQueryable()
.Where(x => projectIds.Contains(x.Id))
.Select(x => new { x.Id, x.Name })
.ToListAsync())
.ToDictionary(x => x.Id, x => x.Name);
var eventProjectProject = eventProjects.ToDictionary(x => x.Id, x => x.ProjectId);
var rows = flat.Select(x =>
{
long? projectId = x.EventProjectId != null && eventProjectProject.TryGetValue(x.EventProjectId.Value, out var pid)
? pid
: x.ProjectId > 0 ? (long?)x.ProjectId : null;
var projectName = projectId != null && projectNames.TryGetValue(projectId.Value, out var pn) ? pn : string.Empty;
return new object?[]
{
x.IsDisqualified ? "DQ" : (x.Rank > 0 ? x.Rank.ToString() : string.Empty),
x.EntryNo,
string.IsNullOrWhiteSpace(x.Team) ? x.Participant : x.Team,
projectName,
x.GroupName,
x.DifficultyScore.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
x.PerformanceScore.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
x.RequiredScore.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
x.DeductionScore.ToString("F3", System.Globalization.CultureInfo.InvariantCulture),
ScoreDisplayService.DeductionFactor(x.CalculationJson)?.ToString("F3", System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty,
x.TotalScore.HasValue ? x.TotalScore.Value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) : x.Score,
x.IsDisqualified ? "取消资格" : GetResultStatusName(x.ResultStatus),
x.ResultVersion,
FmtTime(x.ReviewTime)
};
}).ToList();
var headers = new[] { "名次", "出场编号", "姓名/队名", "项目", "组别", "难度分", "表现分", "规定分", "扣分合计", "得分系数M", "总分", "状态", "成绩版本", "确认时间" };
var bytes = await new WorkbenchListExcelExporter("成绩册", $"成绩册(导出时间:{DateTime.Now:yyyy-MM-dd HH:mm})", headers, rows,
new double[] { 8, 16, 16, 16, 12, 10, 10, 10, 10, 12, 10, 10, 10, 20 }).ExportAsync();
return ExcelFile(bytes, $"成绩册_{input.EventId}_{DateTime.Now:yyyyMMddHHmmss}.xlsx");
}
/// <summary>
/// 导出评分单明细(按场次/项目,逐出场逐裁判列出评分项与提交信息)
/// </summary>
[DisplayName("导出评分单明细")]
[HttpGet("score-sheets/export")]
public async Task<IActionResult> ExportScoreSheets([FromQuery] ScoreSheetExportQueryInput input)
{
if (!input.SessionId.HasValue && !input.EventProjectId.HasValue)
throw Oops.Bah("场次ID与赛事项目ID至少传一个");
var query = _sheetRepository.AsQueryable()
.Where(s => !s.DeleteMark && s.EventId == input.EventId && s.IsCurrent);
if (input.SessionId.HasValue)
{
var sessionId = input.SessionId.Value;
query = query.Where(s => s.SessionId == sessionId);
}
if (input.EventProjectId.HasValue)
{
var eventProjectId = input.EventProjectId.Value;
var projectSessionIds = await _sessionRepository.AsQueryable()
.Where(s => !s.DeleteMark && s.EventProjectId == eventProjectId)
.Select(s => s.Id)
.ToListAsync();
query = query.Where(s => projectSessionIds.Contains(s.SessionId));
}
var sheets = await query
.OrderBy(s => s.SessionId)
.OrderBy(s => s.SessionEntryId)
.OrderBy(s => s.JudgeRole)
.ToListAsync();
if (!sheets.Any())
throw Oops.Bah("暂无数据可导出");
var entryIds = sheets.Select(s => s.SessionEntryId).Distinct().ToList();
var sessionIds = sheets.Select(s => s.SessionId).Distinct().ToList();
var refereeIds = sheets.Select(s => s.RefereeId).Distinct().ToList();
var sheetIds = sheets.Select(s => s.Id).ToList();
var entries = (await _entryRepository.AsQueryable()
.Where(e => entryIds.Contains(e.Id))
.Select(e => new { e.Id, e.EntryNo, e.ParticipantName, e.TeamName })
.ToListAsync())
.ToDictionary(e => e.Id);
var sessions = (await _sessionRepository.AsQueryable()
.Where(s => sessionIds.Contains(s.Id))
.Select(s => new { s.Id, s.SessionNo })
.ToListAsync())
.ToDictionary(s => s.Id);
var referees = (await _refereeRepository.AsQueryable()
.Where(r => refereeIds.Contains(r.Id))
.Select(r => new { r.Id, r.Name })
.ToListAsync())
.ToDictionary(r => r.Id);
var details = (await _scoreDetailRepository.AsQueryable()
.Where(d => !d.DeleteMark && sheetIds.Contains(d.ScoreSheetId))
.OrderBy(d => d.Sort)
.ToListAsync())
.GroupBy(d => d.ScoreSheetId)
.ToDictionary(g => g.Key, g => g.ToList());
var scoreRuleIds = sheets.Select(x => x.ScoreRuleId).Distinct().ToList();
var factorRules = (await _scoreRuleRepository.GetListAsync(x => scoreRuleIds.Contains(x.Id)))
.ToDictionary(x => x.Id, x => ScoreRuleConfigService.Parse(x.RuleConfigJson).IsFactorTotal);
var rows = new List<object?[]>();
foreach (var sheet in sheets)
{
entries.TryGetValue(sheet.SessionEntryId, out var entry);
sessions.TryGetValue(sheet.SessionId, out var session);
referees.TryGetValue(sheet.RefereeId, out var referee);
details.TryGetValue(sheet.Id, out var sheetDetails);
var detailText = sheetDetails == null
? string.Empty
: string.Join("\n", sheetDetails.Select(d => $"{d.ItemName}:{FmtDec(d.InputValue ?? d.CountValue ?? d.Subtotal)}"));
rows.Add(new object?[]
{
session?.SessionNo ?? string.Empty,
entry?.EntryNo ?? string.Empty,
string.IsNullOrWhiteSpace(entry?.TeamName) ? entry?.ParticipantName : entry.TeamName,
referee?.Name ?? string.Empty,
GetJudgeRoleTypeName(sheet.JudgeRoleType),
detailText,
Num(sheet.DifficultyScore),
Num(sheet.PerformanceScore),
Num(sheet.DeductionScore),
Num(sheet.RequiredCompletedCount),
Num(sheet.RequiredMissingCount),
ScoreDisplayService.JudgeValue(sheet, factorRules.GetValueOrDefault(sheet.ScoreRuleId))
.ToString(factorRules.GetValueOrDefault(sheet.ScoreRuleId) && sheet.JudgeRoleType == "T" ? "F3" : "F2", System.Globalization.CultureInfo.InvariantCulture),
sheet.IsDisqualified ? "DQ" : string.Empty,
sheet.SheetVersion,
sheet.RuleVersion,
sheet.OriginalSheetId.HasValue ? sheet.OriginalSheetId.Value.ToString() : string.Empty,
FmtTime(sheet.SubmittedTime)
});
}
var headers = new[] { "场次编号", "出场编号", "姓名/队名", "裁判", "裁判角色", "评分项明细", "难度分", "表现分", "扣分合计", "规定完成量", "规定缺失量", "分数/本裁判系数", "DQ", "评分单版本", "规则版本", "原评分单", "提交时间" };
var bytes = await new WorkbenchListExcelExporter("评分单", $"评分单明细(导出时间:{DateTime.Now:yyyy-MM-dd HH:mm})", headers, rows,
new double[] { 20, 16, 16, 12, 14, 42, 10, 10, 10, 12, 12, 18, 8, 10, 10, 18, 20 }).ExportAsync();
return ExcelFile(bytes, $"评分单_{input.EventId}_{DateTime.Now:yyyyMMddHHmmss}.xlsx");
}
/// <summary>
/// 导出操作审计日志(按赛事ID匹配请求参数,支持时间范围、操作人筛选,最多导出10000条)
/// </summary>
[DisplayName("导出操作审计")]
[HttpGet("audit/export")]
public async Task<IActionResult> ExportAudit([FromQuery] AuditExportQueryInput input)
{
var eventText = input.EventId.ToString();
// 日志表没有结构化 EventId,只先用文本缩小候选集,随后解析 JSON 做精确参数匹配。
var query = _operationLogRepository.AsQueryable()
.Where(l => l.Parameter != null && l.Parameter.Contains(eventText));
if (input.StartTime.HasValue)
{
var startTime = input.StartTime.Value;
query = query.Where(l => l.CreatedTime >= startTime);
}
if (input.EndTime.HasValue)
{
var endTime = input.EndTime.Value.Date.AddDays(1);
query = query.Where(l => l.CreatedTime < endTime);
}
if (input.OperatorId.HasValue)
{
var operatorId = input.OperatorId.Value;
query = query.Where(l => l.UserId == operatorId);
}
if (input.SessionId.HasValue)
{
var sessionText = input.SessionId.Value.ToString();
query = query.Where(l => l.Parameter != null && l.Parameter.Contains(sessionText));
}
var candidates = await query.OrderByDescending(l => l.CreatedTime).Take(50000).ToListAsync();
var logs = candidates
.Where(l => HasNumericParameter(l.Parameter, "EventId", input.EventId))
.Where(l => !input.SessionId.HasValue || HasNumericParameter(l.Parameter, "SessionId", input.SessionId.Value))
.Take(10000)
.ToList();
if (!logs.Any())
throw Oops.Bah("暂无数据可导出");
var userIds = logs.Where(l => l.UserId.HasValue).Select(l => l.UserId!.Value).Distinct().ToList();
var userNames = (await _sysUserRepository.AsQueryable()
.Where(u => userIds.Contains(u.Id))
.Select(u => new { u.Id, u.Name })
.ToListAsync())
.ToDictionary(u => u.Id, u => u.Name);
var rows = logs.Select(l => new object?[]
{
FmtTime(l.CreatedTime),
l.UserId.HasValue && userNames.TryGetValue(l.UserId.Value, out var name) ? name : l.UserId?.ToString() ?? string.Empty,
$"{l.ControllerName}/{l.ActionName}",
l.Description ?? string.Empty,
TrimLog(l.Parameter),
l.HttpStatusCode,
l.RemoteIp
}).ToList();
var headers = new[] { "操作时间", "操作人", "操作对象", "动作", "请求参数", "结果码", "IP" };
var bytes = await new WorkbenchListExcelExporter("操作审计", $"操作审计(导出时间:{DateTime.Now:yyyy-MM-dd HH:mm})", headers, rows,
new double[] { 20, 14, 28, 22, 60, 10, 16 }).ExportAsync();
return ExcelFile(bytes, $"操作审计_{input.EventId}_{DateTime.Now:yyyyMMddHHmmss}.xlsx");
}
private static decimal Num(decimal value) => Math.Round(value, 3);
private static string FmtDec(decimal? value) => value.HasValue ? value.Value.ToString("0.##") : string.Empty;
private static string FmtTime(DateTime? time) => time.HasValue ? time.Value.ToString("yyyy-MM-dd HH:mm:ss") : string.Empty;
private static string TrimLog(string? text)
{
if (string.IsNullOrEmpty(text))
return string.Empty;
return text.Length > 2000 ? text[..2000] : text;
}
private static bool HasNumericParameter(string? json, string parameterName, long expectedValue)
{
if (string.IsNullOrWhiteSpace(json))
return false;
try
{
var token = JToken.Parse(json);
if (token is not JContainer container)
return false;
return container.Descendants()
.OfType<JProperty>()
.Any(property => property.Name.Equals(parameterName, StringComparison.OrdinalIgnoreCase) &&
long.TryParse(property.Value.ToString(), out var value) &&
value == expectedValue);
}
catch
{
return false;
}
}
private static IActionResult ExcelFile(byte[] bytes, string fileName) =>
new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = fileName
};
#endregion
#region 辅助方法
/// <summary>
/// 获取场次在秩序册/自动编排中的全局顺序。
/// 秩序册导入及花样赛自动编排生成的场次号末段均保存该顺序;
/// 无全局序号的速度项目回退到项目配置及场地顺序。
/// </summary>
private static int GetArrangementSequence(string? sessionNo)
{
if (string.IsNullOrWhiteSpace(sessionNo)) return int.MaxValue;
var match = Regex.Match(sessionNo, @"^EP\d+-(?:V)?\d+(?:-(?<sequence>\d+))?$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
if (!match.Success) return int.MaxValue;
return match.Groups["sequence"].Success &&
int.TryParse(match.Groups["sequence"].Value, out var sequence)
? sequence
: int.MaxValue;
}
/// <summary>
/// 将自动编排场次中的出场记录还原为秩序册预览的全局顺序。
/// 同一组别、同一场地的多条记录共用一个场次,因此需要在场次起始序号上叠加出场序号。
/// </summary>
private static long BuildEntryScheduleOrder(int sessionArrangementOrder, int venueOrder, int playOrder)
{
var taskSessionOrder = BuildEntrySessionOrder(sessionArrangementOrder, playOrder);
return (long)taskSessionOrder * 1_000_000L + Math.Max(venueOrder, 1);
}
/// <summary>
/// 获取自动编排出场记录的实际场次号。
/// 花样项目的场次号保存的是本场起始序号;速度项目没有起始序号时按出场顺序计算。
/// </summary>
private static int BuildEntrySessionOrder(int sessionArrangementOrder, int playOrder)
{
var safePlayOrder = Math.Max(playOrder, 1);
return sessionArrangementOrder > 0 && sessionArrangementOrder < int.MaxValue
? sessionArrangementOrder + safePlayOrder - 1
: safePlayOrder;
}
/// <summary>秩序册导入以 Excel 场次列作为逻辑场次,无法解析时回退到物理编排序号。</summary>
private static int GetImportedSessionOrder(string? sessionName, int fallback)
{
if (string.IsNullOrWhiteSpace(sessionName)) return fallback;
var match = Regex.Match(sessionName, @"^\s*(?:第\s*)?(?<sequence>\d+)(?:\s*场)?\s*$",
RegexOptions.CultureInvariant);
return match.Success && int.TryParse(match.Groups["sequence"].Value, out var sequence) && sequence > 0
? sequence
: fallback;
}
private static string GetSessionStatusName(int status)
{
return status switch
{
0 => "草稿",
10 => "就绪",
20 => "检录中",
30 => "进行中",
40 => "暂停",
50 => "待确认",
60 => "已结束",
90 => "已取消",
_ => "未知"
};
}
private static string GetEntryStatusName(int status)
{
return status switch
{
0 => "待出场",
10 => "已检录",
20 => "已叫号",
30 => "进行中",
40 => "已完成",
80 => "退赛",
90 => "取消比赛资格",
_ => "未知"
};
}
private static string GetResultStatusName(int status)
{
return status switch
{
0 => "计算中",
10 => "待复核",
20 => "已确认",
30 => "已投屏",
40 => "已更正",
90 => "已作废",
_ => "未知"
};
}
/// <summary>
/// 规范化重赛/作废原因:去除首尾空格并校验长度为2-200个字符
/// </summary>
private static string NormalizeReason(string? value, string fieldName)
{
var reason = value?.Trim() ?? string.Empty;
if (reason.Length < 2 || reason.Length > 200)
throw Oops.Bah($"{fieldName}长度必须为2-200个字符");
return reason;
}
/// <summary>
/// Resolve the App user that currently owns a judge assignment. Older assignments may not
/// have JudgeUserId populated, while the referee profile or submitted sheet still has it.
/// </summary>
private async Task<long?> ResolveJudgeUserIdAsync(EventJudgeAssignment assignment,
EventJudgeScoreSheet? sheet = null)
{
var receiverUserId = sheet?.JudgeUserId ?? assignment.JudgeUserId;
if (receiverUserId.HasValue) return receiverUserId;
var referee = await _refereeRepository.GetByIdAsync(assignment.RefereeId);
return referee?.AppUserId;
}
/// <summary>
/// 同步成绩当前版本的状态,保证 EventResult 与 EventResultVersion 状态一致
/// </summary>
private async Task SyncResultVersionStatusAsync(EventResult result, int status, long userId,
DateTime now, EventResultVersion? version = null)
{
version ??= await _resultVersionRepository.GetFirstAsync(v =>
!v.DeleteMark && v.EventResultId == result.Id && v.ResultVersion == result.ResultVersion);
if (version == null) return;
version.ResultStatus = status;
version.UpdatedUserId = userId;
version.UpdatedTime = now;
await _resultVersionRepository.UpdateAsync(version);
}
/// <summary>
/// 写入工作台业务操作审计日志,记录投屏、确认、修正、重赛、作废等关键动作的状态流转
/// </summary>
private async Task WriteAuditAsync(string action, long eventId, long objectId,
object? beforeStatus, object? afterStatus, string? reason = null, long? sessionId = null)
{
var parameter = System.Text.Json.JsonSerializer.Serialize(new
{
EventId = eventId,
SessionId = sessionId,
ObjectId = objectId,
BeforeStatus = beforeStatus,
AfterStatus = afterStatus,
Reason = reason
});
await _operationLogRepository.InsertAsync(new SysOperationLog
{
Id = _idGenerator.NextId(),
UserId = _authManager.UserId,
LogLevel = Microsoft.Extensions.Logging.LogLevel.Information,
Description = action,
ActionName = action,
ControllerName = nameof(WorkbenchController),
HttpMethod = "BUSINESS",
HttpStatusCode = 200,
Message = action,
RequestUrl = "/workbench/audit",
Parameter = parameter,
Response = null,
TraceId = string.Empty,
ThreadId = Environment.CurrentManagedThreadId,
UserAgent = string.Empty,
RemoteIp = string.Empty,
Location = string.Empty,
OsDescription = null,
Elapsed = 0,
CreatedTime = DateTime.Now
});
}
private static string GetSheetStatusName(int status)
{
return status switch
{
0 => "草稿",
10 => "已提交",
20 => "已确认",
30 => "已被修正",
90 => "已作废",
_ => "未知"
};
}
private static string GetCorrectionStatusName(int status)
{
return status switch
{
0 => "已申请",
10 => "已批准",
20 => "已拒绝",
30 => "裁判修改中",
40 => "已完成",
90 => "已取消",
_ => "未知"
};
}
private static string GetJudgeRoleTypeName(string roleType)
{
return roleType switch
{
"DP" => "难度裁判(DP)",
"DM" => "难度裁判(DM)",
"DR" => "难度裁判(DR)",
"P" => "表现裁判",
"T" => "主裁",
_ => roleType
};
}
#endregion
}