using YD_Event.Application.Auth; using YD_Event.Application.Scoring.Dtos; namespace YD_Event.Application.Scoring; /// /// 裁判评分与成绩计算(后台记分台) /// [ApiDescriptionSettings("打分系统")] [Route("api/judgescore")] 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 _attachmentRepository; 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 attachmentRepository, 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; _attachmentRepository = attachmentRepository; _refereeRepository = refereeRepository; _resultRepository = resultRepository; _calculationService = calculationService; _idGenerator = idGenerator; _authManager = authManager; } /// /// 获取场次记分台。按当前已提交的评分单实时算分,不写库,可随时刷新查看进度。 /// [HttpGet("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("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("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("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("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("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("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 sheetIds = outcomes.SelectMany(x => x.Aggregate?.Symbols ?? new List()) .SelectMany(x => x.Judges).Select(x => x.ScoreSheetId).Distinct().ToList(); var annotationUrls = sheetIds.Count == 0 ? new Dictionary() : (await _attachmentRepository.GetListAsync(x => sheetIds.Contains(x.ScoreSheetId) && x.IsCurrent && x.AttachmentType == "score_annotation_png")) .GroupBy(x => x.ScoreSheetId).ToDictionary(x => x.Key, x => x.OrderByDescending(y => y.CreatedTime).First().FileUrl); // 一个出场(队伍)的多名运动员共享同一名次,取任意一条即可。 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, AnnotationUrl = annotationUrls.GetValueOrDefault(judge.ScoreSheetId) }).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() }; }