using YD_Event.Application.Auth;
using YD_Event.Application.Scoring.Dtos;
namespace YD_Event.Application.Scoring;
///
/// 裁判评分与成绩计算(后台记分台)
///
[ApiDescriptionSettings("打分系统")]
public class JudgeScoreService : IDynamicApiController, ITransient
{
///
/// 修正状态:0-已申请
///
private const int CorrectionStatusApplied = 0;
///
/// 修正状态:10-已批准
///
private const int CorrectionStatusApproved = 10;
///
/// 修正状态:20-已拒绝
///
private const int CorrectionStatusRejected = 20;
///
/// 修正状态:30-裁判修改中
///
private const int CorrectionStatusEditing = 30;
///
/// 修正状态:40-已完成
///
private const int CorrectionStatusCompleted = 40;
///
/// 修正状态:90-已取消
///
private const int CorrectionStatusCancelled = 90;
///
/// 评分单状态:20-已确认
///
private const int SheetStatusConfirmed = 20;
///
/// 场次状态:60-已结束
///
private const int SessionStatusFinished = 60;
///
/// 消息类型:1-成绩修正提醒
///
private const int MessageTypeCorrection = 1;
private readonly ISqlSugarRepository _sessionRepository;
private readonly ISqlSugarRepository _entryRepository;
private readonly ISqlSugarRepository _venueRepository;
private readonly ISqlSugarRepository _ruleRepository;
private readonly ISqlSugarRepository _assignmentRepository;
private readonly ISqlSugarRepository _sheetRepository;
private readonly ISqlSugarRepository _correctionRepository;
private readonly ISqlSugarRepository _messageRepository;
private readonly ISqlSugarRepository _refereeRepository;
private readonly ISqlSugarRepository _resultRepository;
private readonly EventScoreCalculationService _calculationService;
private readonly IIdGenerator _idGenerator;
private readonly AuthManager _authManager;
public JudgeScoreService(ISqlSugarRepository sessionRepository,
ISqlSugarRepository entryRepository, ISqlSugarRepository venueRepository,
ISqlSugarRepository ruleRepository,
ISqlSugarRepository assignmentRepository,
ISqlSugarRepository sheetRepository,
ISqlSugarRepository correctionRepository,
ISqlSugarRepository messageRepository,
ISqlSugarRepository refereeRepository,
ISqlSugarRepository resultRepository,
EventScoreCalculationService calculationService, IIdGenerator idGenerator, AuthManager authManager)
{
_sessionRepository = sessionRepository; _entryRepository = entryRepository; _venueRepository = venueRepository;
_ruleRepository = ruleRepository; _assignmentRepository = assignmentRepository; _sheetRepository = sheetRepository;
_correctionRepository = correctionRepository; _messageRepository = messageRepository;
_refereeRepository = refereeRepository; _resultRepository = resultRepository;
_calculationService = calculationService; _idGenerator = idGenerator; _authManager = authManager;
}
///
/// 获取场次记分台。按当前已提交的评分单实时算分,不写库,可随时刷新查看进度。
///
[HttpGet("judge-scores/board/{sessionId}")]
[DisplayName("获取场次记分台")]
public async Task GetBoard(long sessionId)
{
var ctx = await _calculationService.LoadContextAsync(sessionId);
var outcomes = await _calculationService.EvaluateSessionAsync(sessionId, ctx);
return await BuildBoardAsync(ctx, outcomes);
}
///
/// 重算整场成绩。所有应到裁判都已提交的出场写入成绩并按规则重排名次,未评满的出场只返回进度。
///
[HttpPost("judge-scores/recalculate/{sessionId}")]
[DisplayName("重算场次成绩")]
[UnitOfWork]
public async Task Recalculate(long sessionId)
{
var ctx = await _calculationService.LoadContextAsync(sessionId);
var outcomes = await _calculationService.RecalculateSessionAsync(sessionId,
EventScoreCalculationService.SourceTypeNormal, _authManager.UserId, ctx);
return await BuildBoardAsync(ctx, outcomes);
}
///
/// 成绩排名查询。按项目、组别筛选,名次为 0 的(未评满、退赛、取消资格)排在最后。
///
[HttpGet("judge-scores/ranking")]
[DisplayName("成绩排名查询")]
public async Task> GetRanking([FromQuery] JudgeRankingQueryInput input)
{
if (input.EventId <= 0) throw Oops.Bah("请指定赛事");
var results = await _resultRepository.AsQueryable()
.Where(x => x.EventId == input.EventId && x.Status == AvailabilityStatus.Enable)
.WhereIF(input.ProjectId.HasValue, x => x.ProjectId == input.ProjectId!.Value)
.WhereIF(!string.IsNullOrWhiteSpace(input.GroupName), x => x.GroupName == input.GroupName)
.WhereIF(input.ResultStatus.HasValue, x => x.ResultStatus == input.ResultStatus!.Value)
.ToListAsync();
return results
.OrderBy(x => x.ProjectId).ThenBy(x => x.GroupName)
// 名次 0 表示尚未参与排名,统一沉到组内末尾,避免占据第一名的位置。
.ThenBy(x => x.Rank == 0 ? int.MaxValue : x.Rank)
.ThenByDescending(x => x.TotalScore ?? 0m).ThenBy(x => x.ParticipantName)
.Select(x => new JudgeRankingRowOutput
{
ResultId = x.Id,
SessionEntryId = x.SessionEntryId,
ProjectId = x.ProjectId,
ProjectTypeName = x.ProjectTypeName ?? string.Empty,
GroupName = x.GroupName ?? string.Empty,
ParticipantName = x.ParticipantName ?? string.Empty,
Rank = x.Rank,
TotalScore = x.Score ?? string.Empty,
DifficultyScore = x.DifficultyScore,
PerformanceScore = x.PerformanceScore,
RequiredScore = x.RequiredScore,
DeductionScore = x.DeductionScore,
IsDisqualified = x.IsDisqualified,
ResultStatus = x.ResultStatus,
ResultStatusName = ResultStatusName(x.ResultStatus),
ResultVersion = x.ResultVersion,
ReviewUserId = x.ReviewUserId,
ReviewTime = x.ReviewTime
}).ToList();
}
///
/// 确认场次成绩。仅在全场都已评满时确认,确认后裁判端不能再自行申请修正。
///
[HttpPost("judge-scores/confirm/{sessionId}")]
[DisplayName("确认场次成绩")]
[UnitOfWork]
public async Task ConfirmSession(long sessionId)
{
var session = await _sessionRepository.GetFirstAsync(x => x.Id == sessionId);
if (session == null) throw Oops.Bah("比赛场次不存在");
var ctx = await _calculationService.LoadContextAsync(session);
// 先只算不写:确认被拒时不留下任何写库痕迹,也不会把已确认的成绩打回待复核。
var preview = await _calculationService.EvaluateSessionAsync(sessionId, ctx);
var output = new ConfirmSessionScoreOutput
{
PendingEntries = preview.Where(x => !x.IsSkipped && !x.IsCompleted).Select(x => x.EntryNo).ToList()
};
// 有出场没评满就不确认,否则先确认的成绩会挡住后到的评分。
if (output.PendingEntries.Count > 0) return output;
var outcomes = await _calculationService.RecalculateSessionAsync(sessionId,
EventScoreCalculationService.SourceTypeNormal, _authManager.UserId, ctx);
var entryIds = outcomes.Select(x => (long?)x.SessionEntryId).ToList();
var results = await _resultRepository.GetListAsync(x => entryIds.Contains(x.SessionEntryId)
&& x.ResultStatus == EventScoreCalculationService.ResultStatusPendingReview);
var now = DateTime.Now;
foreach (var result in results)
{
result.ResultStatus = EventScoreCalculationService.ResultStatusConfirmed;
result.ReviewUserId = _authManager.UserId;
result.ReviewTime = now;
result.UpdatedUserId = _authManager.UserId;
result.UpdatedTime = now;
}
if (results.Count > 0) await _resultRepository.UpdateRangeAsync(results);
output.ConfirmedCount = results.Count;
// 评分单一并置为已确认,裁判端据此禁用修正入口。
var plainIds = outcomes.Select(x => x.SessionEntryId).ToList();
var sheets = await _sheetRepository.GetListAsync(x => plainIds.Contains(x.SessionEntryId)
&& x.IsCurrent && x.SheetStatus == EventScoreCalculationService.SheetStatusSubmitted);
foreach (var sheet in sheets)
{
sheet.SheetStatus = SheetStatusConfirmed;
sheet.UpdatedUserId = _authManager.UserId;
sheet.UpdatedTime = now;
}
if (sheets.Count > 0) await _sheetRepository.UpdateRangeAsync(sheets);
if (session.SessionStatus != SessionStatusFinished)
{
session.SessionStatus = SessionStatusFinished;
session.Version++;
session.EndTime ??= now;
session.UpdatedUserId = _authManager.UserId;
session.UpdatedTime = now;
await _sessionRepository.UpdateAsync(session);
}
output.SessionFinished = true;
return output;
}
///
/// 获取成绩修正申请列表
///
[HttpGet("judge-scores/corrections/{eventId}")]
[DisplayName("获取成绩修正申请列表")]
public async Task> GetCorrections(long eventId, [FromQuery] int? correctionStatus = null)
{
var corrections = (await _correctionRepository.AsQueryable()
.Where(x => x.EventId == eventId)
.WhereIF(correctionStatus.HasValue, x => x.CorrectionStatus == correctionStatus!.Value)
.ToListAsync())
.OrderByDescending(x => x.CreatedTime).ToList();
if (corrections.Count == 0) return new List();
var sessionIds = corrections.Select(x => x.SessionId).Distinct().ToList();
var sessions = await _sessionRepository.GetListAsync(x => sessionIds.Contains(x.Id));
var ruleIds = sessions.Select(x => x.ScoreRuleId).Distinct().ToList();
var rules = await _ruleRepository.GetListAsync(x => ruleIds.Contains(x.Id));
// 每个场次的小数位取自各自的计分规则,避免跨规则显示口径不一致。
var configs = sessions.GroupBy(x => x.Id).ToDictionary(x => x.Key,
x => ScoreRuleConfigService.Parse(rules.FirstOrDefault(r => r.Id == x.First().ScoreRuleId)?.RuleConfigJson));
var entryIds = corrections.Select(x => x.SessionEntryId).Distinct().ToList();
var entries = await _entryRepository.GetListAsync(x => entryIds.Contains(x.Id));
var sheetIds = corrections.Select(x => x.OriginalScoreSheetId)
.Concat(corrections.Where(x => x.NewScoreSheetId.HasValue).Select(x => x.NewScoreSheetId!.Value))
.Distinct().ToList();
var sheets = await _sheetRepository.GetListAsync(x => sheetIds.Contains(x.Id));
var assignmentIds = corrections.Select(x => x.JudgeAssignmentId).Distinct().ToList();
var assignments = await _assignmentRepository.GetListAsync(x => assignmentIds.Contains(x.Id));
var refereeIds = assignments.Select(x => x.RefereeId).Distinct().ToList();
var referees = await _refereeRepository.GetListAsync(x => refereeIds.Contains(x.Id));
return corrections.Select(correction =>
{
var session = sessions.FirstOrDefault(x => x.Id == correction.SessionId);
var entry = entries.FirstOrDefault(x => x.Id == correction.SessionEntryId);
var original = sheets.FirstOrDefault(x => x.Id == correction.OriginalScoreSheetId);
var updated = correction.NewScoreSheetId.HasValue
? sheets.FirstOrDefault(x => x.Id == correction.NewScoreSheetId!.Value)
: null;
var assignment = assignments.FirstOrDefault(x => x.Id == correction.JudgeAssignmentId);
var referee = assignment == null ? null : referees.FirstOrDefault(x => x.Id == assignment.RefereeId);
var config = configs.TryGetValue(correction.SessionId, out var found) ? found : ScoreRuleConfigService.Parse(null);
return new JudgeCorrectionOutput
{
Id = correction.Id,
SessionId = correction.SessionId,
SessionName = session == null ? string.Empty : session.SessionName ?? session.SessionNo,
SessionEntryId = correction.SessionEntryId,
EntryNo = entry?.EntryNo ?? string.Empty,
ParticipantName = entry == null
? string.Empty
: string.IsNullOrWhiteSpace(entry.TeamName) ? entry.ParticipantName : entry.TeamName!,
OriginalScoreSheetId = correction.OriginalScoreSheetId,
NewScoreSheetId = correction.NewScoreSheetId,
JudgeRole = correction.JudgeRole,
RefereeName = referee?.Name ?? string.Empty,
OriginalScore = original == null ? string.Empty : config.Format(original.TotalScore),
NewScore = updated == null ? null : config.Format(updated.TotalScore),
ApplyReason = correction.ApplyReason,
ApproveRemark = correction.ApproveRemark,
CorrectionStatus = correction.CorrectionStatus,
CorrectionStatusName = CorrectionStatusName(correction.CorrectionStatus),
CreatedTime = correction.CreatedTime,
ApproveTime = correction.ApproveTime,
CompletedTime = correction.CompletedTime
};
}).ToList();
}
///
/// 后台发起成绩修正。发起后该裁判可重新提交这张评分单,原评分在新评分提交时才失效。
///
[HttpPost("judge-scores/corrections/initiate")]
[DisplayName("发起成绩修正")]
[UnitOfWork]
public async Task InitiateCorrection([FromBody] InitiateJudgeCorrectionInput input)
{
if (string.IsNullOrWhiteSpace(input.Reason)) throw Oops.Bah("请填写修正原因");
var sheet = await _sheetRepository.GetFirstAsync(x => x.Id == input.ScoreSheetId);
if (sheet == null) throw Oops.Bah("评分单不存在");
if (!sheet.IsCurrent || sheet.SheetStatus < EventScoreCalculationService.SheetStatusSubmitted)
throw Oops.Bah("该评分单不是当前生效的已提交评分,无需修正");
if (await HasOpenCorrectionAsync(sheet.Id)) throw Oops.Bah("该评分单已有修正流程在处理中");
var now = DateTime.Now;
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 = _authManager.UserId,
ApplyReason = input.Reason.Trim(),
// 后台发起本身即代表已批准,裁判端可直接重新评分。
CorrectionStatus = CorrectionStatusApproved,
ApproveUserId = _authManager.UserId,
ApproveRemark = "赛事管理员发起",
ApproveTime = now,
CreatedUserId = _authManager.UserId,
CreatedTime = now
};
await _correctionRepository.InsertAsync(correction);
await NotifyRefereeAsync(correction, sheet, "成绩修正通知",
$"请对{sheet.JudgeRole}号位的评分重新提交,原因:{correction.ApplyReason}");
return correction.Id;
}
///
/// 审批裁判提交的成绩修正申请
///
[HttpPost("judge-scores/corrections/approve")]
[DisplayName("审批成绩修正申请")]
[UnitOfWork]
public async Task ApproveCorrection([FromBody] ApproveJudgeCorrectionInput input)
{
var correction = await _correctionRepository.GetFirstAsync(x => x.Id == input.CorrectionId);
if (correction == null) throw Oops.Bah("修正申请不存在");
if (correction.CorrectionStatus != CorrectionStatusApplied)
throw Oops.Bah($"该申请当前为“{CorrectionStatusName(correction.CorrectionStatus)}”,不能重复审批");
var sheet = await _sheetRepository.GetFirstAsync(x => x.Id == correction.OriginalScoreSheetId);
if (sheet == null) throw Oops.Bah("被修正的评分单不存在");
var now = DateTime.Now;
correction.CorrectionStatus = input.Approved ? CorrectionStatusApproved : CorrectionStatusRejected;
correction.ApproveUserId = _authManager.UserId;
correction.ApproveRemark = input.ApproveRemark;
correction.ApproveTime = now;
if (!input.Approved) correction.CompletedTime = now;
correction.UpdatedUserId = _authManager.UserId;
correction.UpdatedTime = now;
await _correctionRepository.UpdateAsync(correction);
await NotifyRefereeAsync(correction, sheet,
input.Approved ? "成绩修正申请已通过" : "成绩修正申请被驳回",
input.Approved
? $"{sheet.JudgeRole}号位的修正申请已通过,请重新提交评分。"
: $"{sheet.JudgeRole}号位的修正申请未通过。{input.ApproveRemark}");
}
///
/// 组装记分台数据,名次与成绩状态取已落库的成绩
///
private async Task BuildBoardAsync(ScoringContext ctx, List outcomes)
{
var venue = await _venueRepository.GetFirstAsync(x => x.Id == ctx.Session.VenueId);
var output = new JudgeScoreBoardOutput
{
SessionId = ctx.Session.Id,
EventId = ctx.Session.EventId,
SessionName = ctx.Session.SessionName ?? ctx.Session.SessionNo,
VenueName = venue?.VenueName ?? string.Empty,
ProjectName = ctx.Project.Name,
RuleName = ctx.Rule.RuleName,
Formula = EntryResultCalculationService.BuildFormula(ctx.Config),
SessionStatus = ctx.Session.SessionStatus,
EntryCount = outcomes.Count,
CompletedCount = outcomes.Count(x => x.IsCompleted)
};
if (outcomes.Count == 0) return output;
var entryIds = outcomes.Select(x => (long?)x.SessionEntryId).ToList();
var results = await _resultRepository.GetListAsync(x => entryIds.Contains(x.SessionEntryId)
&& x.ResultStatus != EventScoreCalculationService.ResultStatusVoided);
// 一个出场(队伍)的多名运动员共享同一名次,取任意一条即可。
var resultByEntry = results.Where(x => x.SessionEntryId.HasValue)
.GroupBy(x => x.SessionEntryId!.Value).ToDictionary(x => x.Key, x => x.First());
foreach (var outcome in outcomes)
{
resultByEntry.TryGetValue(outcome.SessionEntryId, out var result);
var entry = new JudgeScoreBoardEntryOutput
{
SessionEntryId = outcome.SessionEntryId,
EntryNo = outcome.EntryNo,
ParticipantName = outcome.ParticipantName,
GroupName = outcome.GroupName,
IsCompleted = outcome.IsCompleted,
IsSkipped = outcome.IsSkipped,
SkipReason = outcome.SkipReason,
IsDisqualified = outcome.IsDisqualified,
TotalScore = outcome.IsDisqualified
? "取消资格"
: outcome.IsCompleted ? ctx.Config.Format(outcome.TotalScore) : string.Empty,
Rank = result?.Rank ?? 0,
ResultStatus = result?.ResultStatus ?? 0,
ResultStatusName = ResultStatusName(result?.ResultStatus ?? 0),
Progress = outcome.Roles.Select(role => new JudgeBoardRoleProgressOutput
{
RoleType = role.RoleType, RoleName = role.RoleName, Symbol = role.Symbol,
ExpectedCount = role.ExpectedCount, SubmittedCount = role.SubmittedCount,
PendingJudges = role.PendingJudges
}).ToList()
};
if (outcome.Aggregate != null)
{
entry.Symbols = outcome.Aggregate.Symbols.Select(symbol => new JudgeSymbolScoreOutput
{
Symbol = symbol.Symbol, Label = symbol.Label, RoleType = symbol.RoleType,
Aggregate = symbol.Aggregate, Sign = symbol.Sign, Value = ctx.Config.Format(symbol.Value),
Judges = symbol.Judges.Select(judge => new JudgeBoardSheetScoreOutput
{
ScoreSheetId = judge.ScoreSheetId, RefereeId = judge.RefereeId,
RefereeName = judge.JudgeName ?? string.Empty, JudgeRole = judge.JudgeRole,
JudgeRoleType = judge.JudgeRoleType, Value = ctx.Config.Format(judge.Value),
IsDisqualified = judge.IsDisqualified
}).ToList()
}).ToList();
}
output.Entries.Add(entry);
}
// 出场状态不在计算结果里,单独回填,前端据此区分退赛与未上场。
var plainIds = outcomes.Select(x => x.SessionEntryId).ToList();
var entries = await _entryRepository.GetListAsync(x => plainIds.Contains(x.Id));
foreach (var row in output.Entries)
{
var source = entries.FirstOrDefault(x => x.Id == row.SessionEntryId);
if (source == null) continue;
row.EntryStatus = source.EntryStatus;
row.PlayOrder = source.PlayOrder;
}
return output;
}
///
/// 该评分单是否已有未结束的修正流程
///
private async Task HasOpenCorrectionAsync(long scoreSheetId) =>
await _correctionRepository.IsAnyAsync(x => x.OriginalScoreSheetId == scoreSheetId
&& (x.CorrectionStatus == CorrectionStatusApplied
|| x.CorrectionStatus == CorrectionStatusApproved
|| x.CorrectionStatus == CorrectionStatusEditing));
///
/// 给被修正的裁判发一条站内消息,裁判端在消息列表里看到修正提醒
///
private async Task NotifyRefereeAsync(EventScoreCorrection correction, EventJudgeScoreSheet sheet,
string title, string content)
{
var receiverUserId = sheet.JudgeUserId;
if (!receiverUserId.HasValue)
{
var assignment = await _assignmentRepository.GetFirstAsync(x => x.Id == correction.JudgeAssignmentId);
receiverUserId = assignment?.JudgeUserId;
}
if (!receiverUserId.HasValue)
{
var referee = await _refereeRepository.GetFirstAsync(x => x.Id == sheet.RefereeId);
receiverUserId = referee?.AppUserId;
}
// 裁判还没绑定小程序账号时消息无处投递,此时只留修正记录,不阻断主流程。
if (!receiverUserId.HasValue) return;
await _messageRepository.InsertAsync(new EventJudgeMessage
{
Id = _idGenerator.NextId(),
EventId = correction.EventId,
ReceiverUserId = receiverUserId.Value,
RefereeId = sheet.RefereeId,
MessageType = MessageTypeCorrection,
Title = title,
Content = content.Length > 512 ? content[..512] : content,
RelationType = nameof(EventScoreCorrection),
RelationId = correction.Id,
IsRead = false,
CreatedUserId = _authManager.UserId,
CreatedTime = DateTime.Now
});
}
///
/// 成绩状态名称
///
private static string ResultStatusName(int status) => status switch
{
0 => "未生成",
EventScoreCalculationService.ResultStatusPendingReview => "待复核",
EventScoreCalculationService.ResultStatusConfirmed => "已确认",
EventScoreCalculationService.ResultStatusVoided => "已作废",
_ => status.ToString()
};
///
/// 修正状态名称
///
private static string CorrectionStatusName(int status) => status switch
{
CorrectionStatusApplied => "已申请",
CorrectionStatusApproved => "已批准",
CorrectionStatusRejected => "已拒绝",
CorrectionStatusEditing => "裁判修改中",
CorrectionStatusCompleted => "已完成",
CorrectionStatusCancelled => "已取消",
_ => status.ToString()
};
}