using YD_Event.Application.Auth;
using YD_Event.Application.Scoring.Dtos;
namespace YD_Event.Application.Scoring;
///
/// 打分系统裁判端评分接口(独立于微信小程序,供裁判端页面调用)。
/// 规定赛计分(totalMode=linear):创意编排裁判创意分取平均记作 T1,完成质量裁判完成分取平均记作 T2,
/// 主裁每项违例从 100 分中扣 5 分记作 T3,总分 = T1 + T2 - T3。
/// 花样赛计分(totalMode=factor):两人车轮跳花样 R = D × (1 + P) × Q × M,
/// 其中 D = (dA + dB) / 2、P = 表现裁判均值 × 0.05、Q = max(0, 1 - 0.05 × 规定元素缺失类别数)、M = max(0, 1 - 主裁扣分合计);
/// 交互绳花样 R = D × (1 + P) × M,其中 D = dJ × 1.4 + dT、P = 表现裁判均值 × 0.033,不计 Q。
/// 表演赛 R = D × (1 + P) × Q × M,其中 D = (A + B) / 2(A/B 组难度裁判各自取平均)、P = 表现裁判均值 × 0.05、
/// Q = max(0, 1 - 0.05 × 5 类规定元素缺失数)、M = max(0, 1 - (失误 + 违例))。
/// 裁判提交的分值一律在服务端按计分规则重算,客户端上传的合计值不参与落库。
///
[ApiDescriptionSettings("打分系统裁判端")]
[Authorize]
public class JudgeScoringController : IDynamicApiController
{
private readonly ISqlSugarRepository _userRepository;
private readonly ISqlSugarRepository _refereeRepository;
private readonly ISqlSugarRepository _eventRepository;
private readonly ISqlSugarRepository _venueRepository;
private readonly ISqlSugarRepository _eventProjectRepository;
private readonly ISqlSugarRepository _projectRepository;
private readonly ISqlSugarRepository _ruleRepository;
private readonly ISqlSugarRepository _sessionRepository;
private readonly ISqlSugarRepository _entryRepository;
private readonly ISqlSugarRepository _assignmentRepository;
private readonly ISqlSugarRepository _taskRepository;
private readonly ISqlSugarRepository _sheetRepository;
private readonly ISqlSugarRepository _detailRepository;
private readonly ISqlSugarRepository _correctionRepository;
private readonly ISqlSugarRepository _messageRepository;
private readonly ISqlSugarRepository _resultRepository;
private readonly ISqlSugarRepository _personRepository;
private readonly EventScoreCalculationService _calculationService;
private readonly AuthManager _authManager;
private readonly IIdGenerator _idGenerator;
public JudgeScoringController(
ISqlSugarRepository userRepository,
ISqlSugarRepository refereeRepository,
ISqlSugarRepository eventRepository,
ISqlSugarRepository venueRepository,
ISqlSugarRepository eventProjectRepository,
ISqlSugarRepository projectRepository,
ISqlSugarRepository ruleRepository,
ISqlSugarRepository sessionRepository,
ISqlSugarRepository entryRepository,
ISqlSugarRepository assignmentRepository,
ISqlSugarRepository taskRepository,
ISqlSugarRepository sheetRepository,
ISqlSugarRepository detailRepository,
ISqlSugarRepository correctionRepository,
ISqlSugarRepository messageRepository,
ISqlSugarRepository resultRepository,
ISqlSugarRepository personRepository,
EventScoreCalculationService calculationService,
AuthManager authManager,
IIdGenerator idGenerator)
{
_userRepository = userRepository;
_refereeRepository = refereeRepository;
_eventRepository = eventRepository;
_venueRepository = venueRepository;
_eventProjectRepository = eventProjectRepository;
_projectRepository = projectRepository;
_ruleRepository = ruleRepository;
_sessionRepository = sessionRepository;
_entryRepository = entryRepository;
_assignmentRepository = assignmentRepository;
_taskRepository = taskRepository;
_sheetRepository = sheetRepository;
_detailRepository = detailRepository;
_correctionRepository = correctionRepository;
_messageRepository = messageRepository;
_resultRepository = resultRepository;
_personRepository = personRepository;
_calculationService = calculationService;
_authManager = authManager;
_idGenerator = idGenerator;
}
///
/// 获取我的执裁场次
///
[HttpGet("judge-scoring/sessions")]
[DisplayName("获取我的执裁场次")]
public async Task> GetSessions([FromQuery] long? eventId = null)
{
var referee = await EnsureRefereeAsync();
var assignments = await _assignmentRepository.AsQueryable()
.Where(x => x.RefereeId == referee.Id && x.AssignmentStatus == 1)
.WhereIF(eventId.HasValue, x => x.EventId == eventId!.Value)
.ToListAsync();
if (assignments.Count == 0) return new List();
var sessionIds = assignments.Select(x => x.SessionId).Distinct().ToList();
var sessions = (await _sessionRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.Id) && x.SessionStatus != 90).ToListAsync())
.OrderByDescending(x => x.StartTime ?? DateTime.MinValue).ThenBy(x => x.SessionNo).ToList();
if (sessions.Count == 0) return new List();
var eventIds = sessions.Select(x => x.EventId).Distinct().ToList();
var events = (await _eventRepository.AsQueryable().Where(x => eventIds.Contains(x.Id)).ToListAsync())
.ToDictionary(x => x.Id);
var venues = (await _venueRepository.AsQueryable()
.Where(x => sessions.Select(s => s.VenueId).Contains(x.Id)).ToListAsync()).ToDictionary(x => x.Id);
var eventProjects = (await _eventProjectRepository.AsQueryable()
.Where(x => sessions.Select(s => s.EventProjectId).Contains(x.Id)).ToListAsync()).ToDictionary(x => x.Id);
var projects = (await _projectRepository.AsQueryable()
.Where(x => eventProjects.Values.Select(e => e.ProjectId).Contains(x.Id)).ToListAsync()).ToDictionary(x => x.Id);
var rules = (await _ruleRepository.AsQueryable()
.Where(x => sessions.Select(s => s.ScoreRuleId).Contains(x.Id)).ToListAsync()).ToDictionary(x => x.Id);
var entries = await _entryRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.SessionId) && !x.IsVoided).ToListAsync();
var mySheets = await _sheetRepository.AsQueryable()
.Where(x => sessionIds.Contains(x.SessionId) && x.RefereeId == referee.Id
&& x.IsCurrent && x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted).ToListAsync();
var rows = new List();
foreach (var session in sessions)
{
var assignment = assignments.FirstOrDefault(x => x.SessionId == session.Id);
if (assignment == null) continue;
var roleType = JudgeRoleSchemaService.NormalizeRoleType(
string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType);
var project = eventProjects.TryGetValue(session.EventProjectId, out var ep)
&& projects.TryGetValue(ep.ProjectId, out var p) ? p : null;
var rule = rules.GetValueOrDefault(session.ScoreRuleId);
var slot = JudgeRoleSchemaService.ParseSlots(rule?.JudgeRoleSchemaJson)
.FirstOrDefault(x => string.Equals(x.Code, roleType, StringComparison.OrdinalIgnoreCase));
rows.Add(new JudgeSessionDto
{
SessionId = session.Id,
EventId = session.EventId,
EventName = events.GetValueOrDefault(session.EventId)?.EventName ?? string.Empty,
ProjectName = project?.Name ?? string.Empty,
RuleName = rule?.RuleName ?? string.Empty,
SessionName = session.SessionName ?? session.SessionNo,
VenueName = venues.GetValueOrDefault(session.VenueId)?.VenueName ?? "-",
GroupName = entries.Where(x => x.SessionId == session.Id).Select(x => x.GroupName)
.Distinct().Count() == 1
? entries.First(x => x.SessionId == session.Id).GroupName
: null,
StartTime = session.StartTime,
SessionStatus = session.SessionStatus,
SessionStatusName = SessionStatusName(session.SessionStatus),
JudgeRole = assignment.JudgeRole,
JudgeRoleType = roleType,
JudgeRoleName = slot?.Name ?? JudgeRoleSchemaService.RoleName(roleType),
EntryCount = entries.Count(x => x.SessionId == session.Id),
ScoredCount = mySheets.Count(x => x.SessionId == session.Id)
});
}
return rows;
}
///
/// 获取场次出场名单与我的评分状态
///
[HttpGet("judge-scoring/entries/{sessionId}")]
[DisplayName("获取场次出场名单")]
public async Task> GetEntries(long sessionId)
{
var referee = await EnsureRefereeAsync();
var assignment = await GetMyAssignmentAsync(sessionId, referee.Id);
var session = await _sessionRepository.GetFirstAsync(x => x.Id == sessionId);
if (session == null) throw Oops.Bah("比赛场次不存在");
var config = ScoreRuleConfigService.Parse(
(await _ruleRepository.GetFirstAsync(x => x.Id == session.ScoreRuleId))?.RuleConfigJson);
var entries = (await _entryRepository.AsQueryable().Where(x => x.SessionId == sessionId).ToListAsync())
.OrderBy(x => x.PlayOrder).ThenBy(x => x.Id).ToList();
if (entries.Count == 0) return new List();
var entryIds = entries.Select(x => x.Id).ToList();
var sheets = await _sheetRepository.AsQueryable()
.Where(x => entryIds.Contains(x.SessionEntryId) && x.JudgeAssignmentId == assignment.Id).ToListAsync();
var corrections = await _correctionRepository.AsQueryable()
.Where(x => x.SessionId == sessionId && x.JudgeAssignmentId == assignment.Id
&& (x.CorrectionStatus == CorrectionStatusApproved || x.CorrectionStatus == CorrectionStatusEditing))
.ToListAsync();
return entries.Select(entry =>
{
var submitted = sheets.FirstOrDefault(x => x.SessionEntryId == entry.Id && x.IsCurrent
&& x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted);
var draft = sheets.FirstOrDefault(x => x.SessionEntryId == entry.Id && x.SheetStatus == SheetStatusDraft);
var inCorrection = submitted != null && corrections.Any(x => x.OriginalScoreSheetId == submitted.Id);
var status = inCorrection ? 3 : submitted != null ? 2 : draft != null ? 1 : 0;
return new JudgeEntryDto
{
SessionEntryId = entry.Id,
EntryNo = entry.EntryNo,
PlayOrder = entry.PlayOrder,
ParticipantName = string.IsNullOrWhiteSpace(entry.TeamName) ? entry.ParticipantName : entry.TeamName!,
GroupName = entry.GroupName,
EntryStatus = entry.EntryStatus,
IsRematch = entry.IsRematch,
ScoreStatus = status,
ScoreStatusName = ScoreStatusName(status),
MyScore = submitted == null ? null : config.Format(submitted.TotalScore),
IsDisqualified = submitted?.IsDisqualified ?? false,
ScoreSheetId = submitted?.Id ?? draft?.Id ?? 0
};
}).ToList();
}
///
/// 获取评分表:表头、评分项、我已录入的值与合计预览
///
[HttpGet("judge-scoring/sheet/{sessionEntryId}")]
[DisplayName("获取评分表")]
public async Task GetSheet(long sessionEntryId)
{
var referee = await EnsureRefereeAsync();
var (entry, ctx, assignment, roleType) = await LoadSheetContextAsync(sessionEntryId, referee.Id);
var definitions = ScoreSheetCalculationService.ItemsOf(ctx, roleType);
if (definitions.Count == 0) throw Oops.Bah($"计分规则“{ctx.Rule.RuleName}”未配置{JudgeRoleSchemaService.RoleNameOf(ctx.RoleSlots, roleType)}的评分项");
var submitted = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id
&& x.JudgeAssignmentId == assignment.Id && x.IsCurrent
&& x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted);
var draft = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id
&& x.JudgeAssignmentId == assignment.Id && x.SheetStatus == SheetStatusDraft);
var correction = submitted == null ? null : await _correctionRepository.GetFirstAsync(
x => x.OriginalScoreSheetId == submitted.Id
&& (x.CorrectionStatus == CorrectionStatusApproved || x.CorrectionStatus == CorrectionStatusEditing));
var source = draft ?? submitted;
var details = source == null
? new List()
: await _detailRepository.GetListAsync(x => x.ScoreSheetId == source.Id);
var inputs = details.Select(x => new ScoreItemInputValue
{
ItemCode = x.ItemCode, InputValue = x.InputValue, CountValue = x.CountValue
}).ToList();
// 草稿允许留空,因此按非必填口径试算,只汇总已录入的部分。
var preview = ScoreSheetCalculationService.Calculate(definitions, inputs, ctx.Config, false);
var canEdit = submitted == null || correction != null;
var members = await LoadMemberNamesAsync(entry);
var venue = await _venueRepository.GetFirstAsync(x => x.Id == ctx.Session.VenueId);
var eventInfo = await _eventRepository.GetFirstAsync(x => x.Id == ctx.Session.EventId);
var nextVersion = (submitted?.SheetVersion ?? 0) + 1;
return new JudgeScoreSheetOutput
{
SessionEntryId = entry.Id,
SessionId = ctx.Session.Id,
JudgeTaskId = draft?.JudgeTaskId ?? submitted?.JudgeTaskId ?? 0,
ScoreSheetId = source?.Id ?? 0,
SheetStatus = source?.SheetStatus ?? SheetStatusDraft,
CanEdit = canEdit,
LockReason = canEdit ? null : "已提交评分,如需修改请先申请成绩修正",
IsCorrection = correction != null,
// 幂等键随评分单版本递增,弱网重复提交同一版本只会落一张评分单。
IdempotencyKey = $"{assignment.Id}-{entry.Id}-v{nextVersion}",
Header = new JudgeSheetHeaderDto
{
EventName = eventInfo?.EventName ?? string.Empty,
ProjectName = ctx.Project.Name,
RuleName = ctx.Rule.RuleName,
GroupName = entry.GroupName,
SessionVenue = $"{ctx.Session.SessionName ?? ctx.Session.SessionNo}-{venue?.VenueName ?? "-"}",
EntryNo = entry.EntryNo,
ParticipantName = string.IsNullOrWhiteSpace(entry.TeamName) ? entry.ParticipantName : entry.TeamName!,
MemberNames = members,
JudgeRole = assignment.JudgeRole,
JudgeRoleType = roleType,
JudgeRoleName = JudgeRoleSchemaService.RoleNameOf(ctx.RoleSlots, roleType),
RuleType = ctx.Config.RuleType,
TotalMode = ctx.Config.TotalMode,
Formula = EntryResultCalculationService.BuildFormula(ctx.Config),
Scope = ctx.Scope,
BaseScore = ctx.Config.BaseScore,
ScoreDecimals = ctx.Config.ScoreDecimals
},
Items = definitions.Select(definition =>
{
var line = preview.Lines.FirstOrDefault(x => string.Equals(x.ItemCode, definition.ItemCode, StringComparison.OrdinalIgnoreCase));
return new JudgeScoreItemDto
{
ScoreRuleItemId = definition.Item.Id,
ItemCode = definition.ItemCode,
ItemName = definition.Item.ItemName,
ItemType = definition.Item.ItemType,
ScoreDimension = definition.Item.ScoreDimension,
InputMode = definition.InputMode,
MinValue = definition.MinValue,
MaxValue = definition.MaxValue,
StepValue = definition.StepValue,
UnitValue = definition.Item.UnitValue,
DefaultValue = definition.Item.DefaultValue,
UnitLabel = definition.UnitLabel,
Remark = definition.Remark,
IsRequired = definition.Item.IsRequired,
IsDisqualifyReason = definition.IsDisqualifyReason,
IsAutoGenerated = definition.IsAuto,
SubjectCode = definition.SubjectCode,
SubjectName = definition.SubjectName,
DimensionCode = definition.DimensionCode,
DimensionWeight = definition.DimensionWeight,
BaseValue = definition.BaseValue,
AdjustSign = definition.AdjustSign,
IsFailureCount = definition.IsFailureCount,
StepDeductions = definition.StepDeductions,
RequiredTarget = definition.RequiredTarget,
RequiredAsCategory = definition.RequiredAsCategory,
Sort = definition.Item.Sort,
InputValue = line?.InputValue,
CountValue = line?.CountValue,
Subtotal = line?.Subtotal ?? 0m
};
}).ToList(),
Preview = ToPreviewDto(preview, ctx.Config)
};
}
///
/// 保存评分草稿。草稿允许留空,不参与成绩计算。
///
[HttpPost("judge-scoring/save")]
[DisplayName("保存评分草稿")]
[UnitOfWork]
public async Task SaveDraft([FromBody] SaveJudgeScoreInput input)
{
var referee = await EnsureRefereeAsync();
var (entry, ctx, assignment, roleType) = await LoadSheetContextAsync(input.SessionEntryId, referee.Id);
var definitions = ScoreSheetCalculationService.ItemsOf(ctx, roleType);
if (definitions.Count == 0) throw Oops.Bah($"计分规则“{ctx.Rule.RuleName}”未配置{JudgeRoleSchemaService.RoleNameOf(ctx.RoleSlots, roleType)}的评分项");
var submitted = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id
&& x.JudgeAssignmentId == assignment.Id && x.IsCurrent
&& x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted);
if (submitted != null && !await HasOpenCorrectionAsync(submitted.Id))
throw Oops.Bah("已提交评分,如需修改请先申请成绩修正");
var calculation = ScoreSheetCalculationService.Calculate(definitions, ToInputValues(input.Items), ctx.Config, false);
var task = await EnsureTaskAsync(entry, ctx, assignment, roleType, submitted == null ? 0 : TaskTypeCorrection);
var draft = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id
&& x.JudgeAssignmentId == assignment.Id && x.SheetStatus == SheetStatusDraft);
var nextVersion = (submitted?.SheetVersion ?? 0) + 1;
await UpsertSheetAsync(draft, entry, ctx, assignment, roleType, task, calculation,
SheetStatusDraft, nextVersion, $"{assignment.Id}-{entry.Id}-v{nextVersion}-draft", null, null, null);
return ToPreviewDto(calculation, ctx.Config);
}
///
/// 提交评分。提交后自动聚合本出场成绩(T1、T2、T3 与总分)并重排名次。
///
[HttpPost("judge-scoring/submit")]
[DisplayName("提交评分")]
[UnitOfWork]
public async Task Submit([FromBody] SubmitJudgeScoreInput input)
{
var referee = await EnsureRefereeAsync();
var (entry, ctx, assignment, roleType) = await LoadSheetContextAsync(input.SessionEntryId, referee.Id);
if (entry.IsVoided) throw Oops.Bah("该出场成绩已作废,无法提交评分");
if (entry.EntryStatus == EntryStatusWithdrawn) throw Oops.Bah("该运动员已退赛,无需评分");
var definitions = ScoreSheetCalculationService.ItemsOf(ctx, roleType);
if (definitions.Count == 0) throw Oops.Bah($"计分规则“{ctx.Rule.RuleName}”未配置{JudgeRoleSchemaService.RoleNameOf(ctx.RoleSlots, roleType)}的评分项");
// 弱网重试时同一幂等键只落一张评分单,直接回放上次的提交结果。
var idempotencyKey = string.IsNullOrWhiteSpace(input.IdempotencyKey) ? null : input.IdempotencyKey!.Trim();
if (idempotencyKey != null)
{
var replay = await _sheetRepository.GetFirstAsync(x => x.IdempotencyKey == idempotencyKey
&& x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted);
if (replay != null) return await BuildSubmitOutputAsync(entry, ctx, replay, true);
}
var submitted = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id
&& x.JudgeAssignmentId == assignment.Id && x.IsCurrent
&& x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted);
var correction = submitted == null ? null : await _correctionRepository.GetFirstAsync(
x => x.OriginalScoreSheetId == submitted.Id
&& (x.CorrectionStatus == CorrectionStatusApproved || x.CorrectionStatus == CorrectionStatusEditing));
if (submitted != null && correction == null) throw Oops.Bah("已提交评分,如需修改请先申请成绩修正");
// 正式提交才校验必填项与取值范围。
var calculation = ScoreSheetCalculationService.Calculate(definitions, ToInputValues(input.Items), ctx.Config, true);
var task = await EnsureTaskAsync(entry, ctx, assignment, roleType, correction == null ? 0 : TaskTypeCorrection);
var draft = await _sheetRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id
&& x.JudgeAssignmentId == assignment.Id && x.SheetStatus == SheetStatusDraft);
var nextVersion = (submitted?.SheetVersion ?? 0) + 1;
var sheet = await UpsertSheetAsync(draft, entry, ctx, assignment, roleType, task, calculation,
EventScoreCalculationService.SheetStatusSubmitted, nextVersion,
idempotencyKey ?? $"{assignment.Id}-{entry.Id}-v{nextVersion}",
input.DisqualifyRemark, submitted?.Id, correction?.ApplyReason);
var now = DateTime.Now;
if (submitted != null)
{
// 修正后的评分单接替原评分单,原评分单留档但不再参与计分。
submitted.IsCurrent = false;
submitted.SheetStatus = SheetStatusCorrected;
submitted.UpdatedUserId = _authManager.UserId;
submitted.UpdatedTime = now;
await _sheetRepository.UpdateAsync(submitted);
}
if (correction != null)
{
correction.NewScoreSheetId = sheet.Id;
correction.CorrectionStatus = CorrectionStatusCompleted;
correction.CompletedTime = now;
correction.UpdatedUserId = _authManager.UserId;
correction.UpdatedTime = now;
await _correctionRepository.UpdateAsync(correction);
}
task.TaskStatus = TaskStatusSubmitted;
task.SubmittedTime = now;
task.UpdatedUserId = _authManager.UserId;
task.UpdatedTime = now;
await _taskRepository.UpdateAsync(task);
// 所有应到裁判都提交后即写入成绩并重排名次,未齐时只返回提交进度。
await _calculationService.RecalculateEntryAsync(entry.Id,
correction == null ? EventScoreCalculationService.SourceTypeNormal : EventScoreCalculationService.SourceTypeCorrection,
correction?.Id, _authManager.UserId, ctx);
return await BuildSubmitOutputAsync(entry, ctx, sheet, false);
}
///
/// 查询场次成绩:各出场的 T1、T2、T3、总分与名次
///
[HttpGet("judge-scoring/results/{sessionId}")]
[DisplayName("查询场次成绩")]
public async Task> GetResults(long sessionId)
{
var referee = await EnsureRefereeAsync();
await GetMyAssignmentAsync(sessionId, referee.Id);
var ctx = await _calculationService.LoadContextAsync(sessionId);
var outcomes = await _calculationService.EvaluateSessionAsync(sessionId, ctx);
var entryIds = outcomes.Select(x => (long?)x.SessionEntryId).ToList();
var results = entryIds.Count == 0
? new List()
: await _resultRepository.GetListAsync(x => entryIds.Contains(x.SessionEntryId));
return outcomes.Select(outcome =>
{
var result = results.FirstOrDefault(x => x.SessionEntryId == outcome.SessionEntryId);
return new JudgeEntryResultDto
{
SessionEntryId = outcome.SessionEntryId,
EntryNo = outcome.EntryNo,
ParticipantName = outcome.ParticipantName,
GroupName = outcome.GroupName,
Rank = result?.Rank ?? 0,
TotalScore = outcome.IsDisqualified ? "取消资格"
: outcome.IsCompleted ? ctx.Config.Format(outcome.TotalScore) : "-",
Formula = outcome.Aggregate?.Formula ?? EntryResultCalculationService.BuildFormula(ctx.Config),
IsCompleted = outcome.IsCompleted,
IsDisqualified = outcome.IsDisqualified,
Symbols = (outcome.Aggregate?.Symbols ?? new List()).Select(symbol => new JudgeSymbolScoreDto
{
Symbol = symbol.Symbol, Label = symbol.Label, Sign = symbol.Sign,
Value = ctx.Config.Format(symbol.Value),
Judges = symbol.Judges.Select(judge => new JudgeSymbolJudgeDto
{
JudgeRole = judge.JudgeRole,
JudgeName = judge.JudgeName ?? string.Empty,
Value = ctx.Config.Format(judge.Value)
}).ToList()
}).ToList(),
Factors = !ctx.Config.IsFactorTotal || outcome.Aggregate == null
? null
: new JudgeScoreFactorDto
{
Difficulty = ctx.Config.Format(outcome.Aggregate.DifficultyFactor),
Performance = ctx.Config.Format(outcome.Aggregate.PerformanceFactor),
Required = ctx.Config.Format(outcome.Aggregate.RequiredFactor),
Deduction = ctx.Config.Format(outcome.Aggregate.DeductionFactor)
},
Progress = outcome.Roles.Select(ToProgressDto).ToList()
};
}).ToList();
}
///
/// 获取可申请成绩修正的评分单
///
[HttpGet("judge-scoring/corrections/candidates/{sessionId}")]
[DisplayName("获取可修正的评分单")]
public async Task> GetCorrectionCandidates(long sessionId)
{
var referee = await EnsureRefereeAsync();
var assignment = await GetMyAssignmentAsync(sessionId, referee.Id);
var ctx = await _calculationService.LoadContextAsync(sessionId);
var sheets = (await _sheetRepository.GetListAsync(x => x.SessionId == sessionId
&& x.JudgeAssignmentId == assignment.Id && x.IsCurrent
&& x.SheetStatus >= EventScoreCalculationService.SheetStatusSubmitted))
.OrderByDescending(x => x.SubmittedTime).ToList();
if (sheets.Count == 0) return new List();
var sheetIds = sheets.Select(x => x.Id).ToList();
var corrections = await _correctionRepository.GetListAsync(x => sheetIds.Contains(x.OriginalScoreSheetId));
var entryIds = sheets.Select(x => x.SessionEntryId).Distinct().ToList();
var entries = (await _entryRepository.GetListAsync(x => entryIds.Contains(x.Id))).ToDictionary(x => x.Id);
return sheets.Select(sheet =>
{
var entry = entries.GetValueOrDefault(sheet.SessionEntryId);
var correction = corrections.Where(x => x.OriginalScoreSheetId == sheet.Id)
.OrderByDescending(x => x.CreatedTime).FirstOrDefault();
var status = correction?.CorrectionStatus ?? -1;
return new JudgeCorrectionCandidateDto
{
ScoreSheetId = sheet.Id,
SessionEntryId = sheet.SessionEntryId,
EntryNo = entry?.EntryNo ?? string.Empty,
ParticipantName = entry == null ? string.Empty
: string.IsNullOrWhiteSpace(entry.TeamName) ? entry.ParticipantName : entry.TeamName!,
MyScore = ctx.Config.Format(sheet.TotalScore),
SubmittedTime = sheet.SubmittedTime,
CorrectionStatus = status,
CorrectionStatusName = CorrectionStatusName(status),
// 已在流程中的评分单不能重复申请,被拒绝或已完成的可以再次申请。
CanApply = status is -1 or CorrectionStatusRejected or CorrectionStatusCompleted or CorrectionStatusCancelled
};
}).ToList();
}
///
/// 申请成绩修正。规则未要求审批时申请即生效,裁判可直接重新提交评分。
///
[HttpPost("judge-scoring/corrections/apply")]
[DisplayName("申请成绩修正")]
[UnitOfWork]
public async Task ApplyCorrection([FromBody] ApplyJudgeCorrectionInput input)
{
var referee = await EnsureRefereeAsync();
if (string.IsNullOrWhiteSpace(input.ApplyReason)) throw Oops.Bah("请填写申请修正原因");
var sheet = await _sheetRepository.GetFirstAsync(x => x.Id == input.ScoreSheetId && x.RefereeId == referee.Id);
if (sheet == null) throw Oops.Bah("评分单不存在");
if (!sheet.IsCurrent || sheet.SheetStatus < EventScoreCalculationService.SheetStatusSubmitted)
throw Oops.Bah("该评分单尚未提交,可直接修改后提交");
if (sheet.SheetStatus == SheetStatusConfirmed) throw Oops.Bah("成绩已确认,请联系赛事管理员处理");
if (await HasOpenCorrectionAsync(sheet.Id)) throw Oops.Bah("该评分单已有修正申请在处理中");
var ctx = await _calculationService.LoadContextAsync(sheet.SessionId);
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.ApplyReason.Trim(),
CorrectionStatus = ctx.Config.CorrectionNeedApproval ? CorrectionStatusApplied : CorrectionStatusApproved,
CreatedUserId = _authManager.UserId,
CreatedTime = now
};
if (!ctx.Config.CorrectionNeedApproval)
{
correction.ApproveUserId = _authManager.UserId;
correction.ApproveRemark = "规则配置为无需审批,申请后即可修正";
correction.ApproveTime = now;
}
await _correctionRepository.InsertAsync(correction);
return correction.Id;
}
///
/// 获取我的消息
///
[HttpGet("judge-scoring/messages")]
[DisplayName("获取裁判端消息")]
public async Task> GetMessages([FromQuery] bool? onlyUnread = null)
{
var referee = await EnsureRefereeAsync();
var userId = _authManager.UserId;
var messages = (await _messageRepository.AsQueryable()
.Where(x => x.RefereeId == referee.Id || x.ReceiverUserId == userId)
.WhereIF(onlyUnread == true, x => !x.IsRead)
.ToListAsync())
.OrderByDescending(x => x.CreatedTime).ToList();
return messages.Select(x => new JudgeMessageDto
{
Id = x.Id, EventId = x.EventId, MessageType = x.MessageType, Title = x.Title, Content = x.Content,
RelationType = x.RelationType, RelationId = x.RelationId, IsRead = x.IsRead, CreatedTime = x.CreatedTime
}).ToList();
}
///
/// 标记消息已读,不传消息ID表示全部标记已读
///
[HttpPost("judge-scoring/messages/read")]
[DisplayName("标记裁判端消息已读")]
public async Task MarkMessagesRead([FromBody] ReadJudgeMessageInput input)
{
var referee = await EnsureRefereeAsync();
var userId = _authManager.UserId;
var messages = await _messageRepository.AsQueryable()
.Where(x => (x.RefereeId == referee.Id || x.ReceiverUserId == userId) && !x.IsRead)
.WhereIF(input.MessageIds.Count > 0, x => input.MessageIds.Contains(x.Id))
.ToListAsync();
if (messages.Count == 0) return;
var now = DateTime.Now;
messages.ForEach(x => { x.IsRead = true; x.ReadTime = now; x.UpdatedUserId = userId; x.UpdatedTime = now; });
await _messageRepository.UpdateRangeAsync(messages);
}
///
/// 校验当前登录用户的裁判身份并取出裁判档案
///
private async Task EnsureRefereeAsync()
{
var userId = _authManager.UserId;
var user = await _userRepository.GetFirstAsync(x => x.Id == userId);
if (user == null) throw Oops.Bah("用户不存在");
if (user.MiniProgramRole != MiniProgramRole.Referee) throw Oops.Bah("当前账号不是裁判身份");
var referee = await _refereeRepository.GetFirstAsync(x => x.AppUserId == userId);
if (referee == null) throw Oops.Bah("裁判档案不存在,请先完善裁判资料");
return referee;
}
///
/// 取我在该场次的裁判席位,未被分配到该场次时直接拒绝
///
private async Task GetMyAssignmentAsync(long sessionId, long refereeId)
{
var assignment = await _assignmentRepository.GetFirstAsync(x => x.SessionId == sessionId
&& x.RefereeId == refereeId && x.AssignmentStatus == 1);
if (assignment == null) throw Oops.Bah("您未被分配到该场次执裁");
return assignment;
}
///
/// 加载评分表所需的出场、计分上下文与我的裁判席位
///
private async Task<(EventSessionEntry Entry, ScoringContext Context, EventJudgeAssignment Assignment, string RoleType)>
LoadSheetContextAsync(long sessionEntryId, long refereeId)
{
var entry = await _entryRepository.GetFirstAsync(x => x.Id == sessionEntryId);
if (entry == null) throw Oops.Bah("出场记录不存在");
var assignment = await GetMyAssignmentAsync(entry.SessionId, refereeId);
var ctx = await _calculationService.LoadContextAsync(entry.SessionId);
var roleType = JudgeRoleSchemaService.NormalizeRoleType(
string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType);
return (entry, ctx, assignment, roleType);
}
///
/// 取出场对应的运动员姓名,用于评分表表头展示
///
private async Task> LoadMemberNamesAsync(EventSessionEntry entry)
{
if (!entry.RegistrationItemId.HasValue) return new List();
var persons = await _personRepository.GetListAsync(x => x.RegistrationItemId == entry.RegistrationItemId.Value);
var athletes = persons.Where(x => x.PersonType == PersonType.Athlete).ToList();
if (athletes.Count == 0) athletes = persons;
return athletes.OrderBy(x => x.Name).Select(x => x.Name).ToList();
}
///
/// 该评分单是否有正在处理中的修正申请
///
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 EnsureTaskAsync(EventSessionEntry entry, ScoringContext ctx,
EventJudgeAssignment assignment, string roleType, int taskType)
{
var task = await _taskRepository.GetFirstAsync(x => x.SessionEntryId == entry.Id
&& x.JudgeAssignmentId == assignment.Id && x.TaskType == taskType && x.TaskStatus != TaskStatusVoided);
var now = DateTime.Now;
if (task != null)
{
if (task.AcceptedTime == null)
{
task.TaskStatus = TaskStatusAccepted;
task.AcceptedTime = now;
task.UpdatedUserId = _authManager.UserId;
task.UpdatedTime = now;
await _taskRepository.UpdateAsync(task);
}
return task;
}
task = new EventJudgeTask
{
Id = _idGenerator.NextId(),
EventId = ctx.Session.EventId,
SessionId = ctx.Session.Id,
SessionEntryId = entry.Id,
JudgeAssignmentId = assignment.Id,
EventRefereeId = assignment.EventRefereeId,
RefereeId = assignment.RefereeId,
JudgeUserId = assignment.JudgeUserId ?? _authManager.UserId,
JudgeRole = assignment.JudgeRole,
JudgeRoleType = roleType,
ScoreRuleId = ctx.Rule.Id,
RuleVersion = ctx.Rule.RuleVersion,
// 任务快照记录评分时看到的赛事信息,后续赛程调整不影响已产生的评分留痕。
TaskSnapshotJson = JsonConvert.SerializeObject(new
{
projectName = ctx.Project.Name,
ruleName = ctx.Rule.RuleName,
sessionName = ctx.Session.SessionName ?? ctx.Session.SessionNo,
entryNo = entry.EntryNo,
groupName = entry.GroupName,
participantName = string.IsNullOrWhiteSpace(entry.TeamName) ? entry.ParticipantName : entry.TeamName,
judgeRole = assignment.JudgeRole,
judgeRoleType = roleType,
scope = ctx.Scope
}),
TaskType = taskType,
TaskStatus = TaskStatusAccepted,
DispatchedTime = now,
AcceptedTime = now,
CreatedUserId = _authManager.UserId,
CreatedTime = now
};
await _taskRepository.InsertAsync(task);
return task;
}
///
/// 写入(或更新)评分单及其明细。分值一律取服务端计算结果。
///
private async Task UpsertSheetAsync(EventJudgeScoreSheet? sheet, EventSessionEntry entry,
ScoringContext ctx, EventJudgeAssignment assignment, string roleType, EventJudgeTask task,
ScoreSheetCalculationResult calculation, int sheetStatus, int sheetVersion, string idempotencyKey,
string? disqualifyRemark, long? originalSheetId, string? correctionReason)
{
var now = DateTime.Now;
var isNew = sheet == null;
sheet ??= new EventJudgeScoreSheet
{
Id = _idGenerator.NextId(),
EventId = ctx.Session.EventId,
SessionId = ctx.Session.Id,
SessionEntryId = entry.Id,
CreatedUserId = _authManager.UserId,
CreatedTime = now
};
sheet.JudgeTaskId = task.Id;
sheet.JudgeAssignmentId = assignment.Id;
sheet.RefereeId = assignment.RefereeId;
sheet.JudgeUserId = assignment.JudgeUserId ?? _authManager.UserId;
sheet.JudgeRole = assignment.JudgeRole;
sheet.JudgeRoleType = roleType;
sheet.ScoreRuleId = ctx.Rule.Id;
sheet.RuleVersion = ctx.Rule.RuleVersion;
sheet.SheetVersion = sheetVersion;
sheet.SheetStatus = sheetStatus;
// 只有已提交的评分单参与成绩聚合,草稿不置为当前版本。
sheet.IsCurrent = sheetStatus >= EventScoreCalculationService.SheetStatusSubmitted;
sheet.DifficultyScore = calculation.DifficultyScore;
sheet.PerformanceScore = calculation.PerformanceScore;
sheet.RequiredScore = calculation.RequiredScore;
sheet.DeductionScore = calculation.DeductionScore;
sheet.TotalScore = calculation.TotalScore;
// 规定元素完成量、缺失量由服务端按折算系数与目标数算出,供成绩聚合换算 Q 因子。
sheet.RequiredCompletedCount = calculation.RequiredCompletedCount;
sheet.RequiredMissingCount = calculation.RequiredMissingCount;
sheet.IsDisqualified = calculation.IsDisqualified;
sheet.DisqualifyReasonCode = calculation.DisqualifyReasonCode;
sheet.DisqualifyRemark = calculation.IsDisqualified ? disqualifyRemark : null;
sheet.CalculationJson = ScoreSheetCalculationService.BuildCalculationJson(calculation, assignment.JudgeRole, roleType, ctx.Scope);
sheet.IdempotencyKey = idempotencyKey;
sheet.OriginalSheetId = originalSheetId;
sheet.CorrectionReason = correctionReason;
if (sheetStatus >= EventScoreCalculationService.SheetStatusSubmitted) sheet.SubmittedTime = now;
if (isNew) await _sheetRepository.InsertAsync(sheet);
else
{
sheet.UpdatedUserId = _authManager.UserId;
sheet.UpdatedTime = now;
await _sheetRepository.UpdateAsync(sheet);
}
// 明细整单覆盖:重算后的行数可能变化,逐行比对不如整单重写清晰。
await _detailRepository.SoftDeleteAsync(x => x.ScoreSheetId == sheet.Id);
if (calculation.Lines.Count > 0)
{
await _detailRepository.InsertRangeAsync(calculation.Lines.Select(line => new EventJudgeScoreDetail
{
Id = _idGenerator.NextId(),
ScoreSheetId = sheet.Id,
ScoreRuleItemId = line.ScoreRuleItemId,
ItemCode = line.ItemCode,
ItemName = line.ItemName,
ItemType = line.ItemType,
ScoreDimension = line.ScoreDimension,
SubjectCode = line.SubjectCode,
IsAutoGenerated = line.IsAutoGenerated,
ParentItemCode = line.ParentItemCode,
CountValue = line.CountValue,
InputValue = line.InputValue,
UnitValue = line.UnitValue,
Subtotal = line.Subtotal,
Sort = line.Sort,
CreatedUserId = _authManager.UserId,
CreatedTime = now
}).ToList());
}
return sheet;
}
///
/// 组装提交结果:我的得分 + 本出场的聚合进度
///
private async Task BuildSubmitOutputAsync(EventSessionEntry entry, ScoringContext ctx,
EventJudgeScoreSheet sheet, bool isDuplicate)
{
var outcomes = await _calculationService.EvaluateSessionAsync(ctx.Session.Id, ctx);
var outcome = outcomes.FirstOrDefault(x => x.SessionEntryId == entry.Id);
return new SubmitJudgeScoreOutput
{
ScoreSheetId = sheet.Id,
IsDuplicate = isDuplicate,
MyScore = ctx.Config.Format(sheet.TotalScore),
ResultCompleted = outcome?.IsCompleted ?? false,
TotalScore = outcome == null || !outcome.IsCompleted ? null
: outcome.IsDisqualified ? "取消资格" : ctx.Config.Format(outcome.TotalScore),
Progress = (outcome?.Roles ?? new List()).Select(ToProgressDto).ToList()
};
}
///
/// 把录入项转成计算器的入参
///
private static List ToInputValues(List items) =>
items.Select(x => new ScoreItemInputValue
{
ItemCode = x.ItemCode, InputValue = x.InputValue, CountValue = x.CountValue
}).ToList();
///
/// 组装合计预览
///
private static JudgeScorePreviewDto ToPreviewDto(ScoreSheetCalculationResult calculation, ScoreRuleConfig config) =>
new()
{
DifficultyScore = calculation.DifficultyScore,
PerformanceScore = calculation.PerformanceScore,
RequiredScore = calculation.RequiredScore,
DeductionScore = calculation.DeductionScore,
TotalScore = calculation.TotalScore,
TotalScoreText = config.Format(calculation.TotalScore),
IsDisqualified = calculation.IsDisqualified,
DisqualifyReasonName = calculation.DisqualifyReasonName,
RequiredCompletedCount = calculation.RequiredCompletedCount,
RequiredMissingCount = calculation.RequiredMissingCount,
Steps = calculation.Steps.Select(x => new JudgeScoreStepDto
{
Code = x.Code, Label = x.Label, Formula = x.Formula, Value = x.Value
}).ToList()
};
///
/// 组装裁判类型的提交进度
///
private static JudgeRoleProgressDto ToProgressDto(EntryRoleProgress progress) => new()
{
RoleType = progress.RoleType, RoleName = progress.RoleName, Symbol = progress.Symbol,
ExpectedCount = progress.ExpectedCount, SubmittedCount = progress.SubmittedCount,
PendingJudges = progress.PendingJudges
};
///
/// 评分单状态:草稿
///
private const int SheetStatusDraft = 0;
///
/// 评分单状态:已确认
///
private const int SheetStatusConfirmed = 20;
///
/// 评分单状态:已被修正
///
private const int SheetStatusCorrected = 30;
///
/// 评分任务状态:已接收
///
private const int TaskStatusAccepted = 20;
///
/// 评分任务状态:已提交
///
private const int TaskStatusSubmitted = 40;
///
/// 评分任务状态:已作废
///
private const int TaskStatusVoided = 90;
///
/// 评分任务类型:成绩修正
///
private const int TaskTypeCorrection = 1;
///
/// 修正状态:已申请
///
private const int CorrectionStatusApplied = 0;
///
/// 修正状态:已批准
///
private const int CorrectionStatusApproved = 10;
///
/// 修正状态:已拒绝
///
private const int CorrectionStatusRejected = 20;
///
/// 修正状态:裁判修改中
///
private const int CorrectionStatusEditing = 30;
///
/// 修正状态:已完成
///
private const int CorrectionStatusCompleted = 40;
///
/// 修正状态:已取消
///
private const int CorrectionStatusCancelled = 90;
///
/// 出场状态:退赛
///
private const int EntryStatusWithdrawn = 80;
///
/// 场次状态名称
///
private static string SessionStatusName(int status) => status switch
{
10 => "就绪",
20 => "检录中",
30 => "进行中",
40 => "暂停",
50 => "待确认",
60 => "已结束",
90 => "已取消",
_ => "草稿"
};
///
/// 我的评分状态名称
///
private static string ScoreStatusName(int status) => status switch
{
1 => "草稿",
2 => "已提交",
3 => "待修正",
_ => "未评分"
};
///
/// 修正申请状态名称
///
private static string CorrectionStatusName(int status) => status switch
{
CorrectionStatusApplied => "已申请",
CorrectionStatusApproved => "已批准",
CorrectionStatusRejected => "已拒绝",
CorrectionStatusEditing => "裁判修改中",
CorrectionStatusCompleted => "已完成",
CorrectionStatusCancelled => "已取消",
_ => "未申请"
};
}