1218 lines
63 KiB
C#
1218 lines
63 KiB
C#
using YD_Event.Application.Auth;
|
||
using YD_Event.Application.Common;
|
||
using YD_Event.Application.Scoring.Dtos;
|
||
using System.Text.RegularExpressions;
|
||
|
||
namespace YD_Event.Application.Scoring;
|
||
|
||
/// <summary>
|
||
/// 打分系统裁判端评分接口(独立于微信小程序,供裁判端页面调用)。
|
||
/// 规定赛计分(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 - (失误 + 违例))。
|
||
/// 裁判提交的分值一律在服务端按计分规则重算,客户端上传的合计值不参与落库。
|
||
/// </summary>
|
||
[ApiDescriptionSettings("打分系统")]
|
||
[Route("api/judgescoring")]
|
||
[Authorize]
|
||
public class JudgeScoringController : IDynamicApiController
|
||
{
|
||
private readonly ISqlSugarRepository<YD_Event.Core.Entities.AppUser> _userRepository;
|
||
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Referee> _refereeRepository;
|
||
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
|
||
private readonly ISqlSugarRepository<EventVenue> _venueRepository;
|
||
private readonly ISqlSugarRepository<EventProject> _eventProjectRepository;
|
||
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Project> _projectRepository;
|
||
private readonly ISqlSugarRepository<YD_Event.Core.Entities.ScoreRule> _ruleRepository;
|
||
private readonly ISqlSugarRepository<EventSession> _sessionRepository;
|
||
private readonly ISqlSugarRepository<EventSessionEntry> _entryRepository;
|
||
private readonly ISqlSugarRepository<EventJudgeAssignment> _assignmentRepository;
|
||
private readonly ISqlSugarRepository<EventJudgeTask> _taskRepository;
|
||
private readonly ISqlSugarRepository<EventJudgeScoreSheet> _sheetRepository;
|
||
private readonly ISqlSugarRepository<EventJudgeScoreDetail> _detailRepository;
|
||
private readonly ISqlSugarRepository<EventScoreCorrection> _correctionRepository;
|
||
private readonly ISqlSugarRepository<EventJudgeMessage> _messageRepository;
|
||
private readonly ISqlSugarRepository<EventJudgeScoreAttachment> _attachmentRepository;
|
||
private readonly ISqlSugarRepository<EventResult> _resultRepository;
|
||
private readonly ISqlSugarRepository<EventRegistrationPerson> _personRepository;
|
||
private readonly EventScoreCalculationService _calculationService;
|
||
private readonly AuthManager _authManager;
|
||
private readonly IIdGenerator _idGenerator;
|
||
private readonly FileService _fileService;
|
||
|
||
public JudgeScoringController(
|
||
ISqlSugarRepository<YD_Event.Core.Entities.AppUser> userRepository,
|
||
ISqlSugarRepository<YD_Event.Core.Entities.Referee> refereeRepository,
|
||
ISqlSugarRepository<EventInfo> eventRepository,
|
||
ISqlSugarRepository<EventVenue> venueRepository,
|
||
ISqlSugarRepository<EventProject> eventProjectRepository,
|
||
ISqlSugarRepository<YD_Event.Core.Entities.Project> projectRepository,
|
||
ISqlSugarRepository<YD_Event.Core.Entities.ScoreRule> ruleRepository,
|
||
ISqlSugarRepository<EventSession> sessionRepository,
|
||
ISqlSugarRepository<EventSessionEntry> entryRepository,
|
||
ISqlSugarRepository<EventJudgeAssignment> assignmentRepository,
|
||
ISqlSugarRepository<EventJudgeTask> taskRepository,
|
||
ISqlSugarRepository<EventJudgeScoreSheet> sheetRepository,
|
||
ISqlSugarRepository<EventJudgeScoreDetail> detailRepository,
|
||
ISqlSugarRepository<EventScoreCorrection> correctionRepository,
|
||
ISqlSugarRepository<EventJudgeMessage> messageRepository,
|
||
ISqlSugarRepository<EventJudgeScoreAttachment> attachmentRepository,
|
||
ISqlSugarRepository<EventResult> resultRepository,
|
||
ISqlSugarRepository<EventRegistrationPerson> personRepository,
|
||
EventScoreCalculationService calculationService,
|
||
AuthManager authManager,
|
||
IIdGenerator idGenerator,
|
||
FileService fileService)
|
||
{
|
||
_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;
|
||
_attachmentRepository = attachmentRepository;
|
||
_resultRepository = resultRepository;
|
||
_personRepository = personRepository;
|
||
_calculationService = calculationService;
|
||
_authManager = authManager;
|
||
_idGenerator = idGenerator;
|
||
_fileService = fileService;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取我的执裁场次
|
||
/// </summary>
|
||
[HttpGet("sessions")]
|
||
[DisplayName("获取我的执裁场次")]
|
||
public async Task<List<JudgeSessionDto>> 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<JudgeSessionDto>();
|
||
|
||
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<JudgeSessionDto>();
|
||
|
||
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<JudgeSessionDto>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询当前场次的赛事阶段与操作权限,供裁判端进入场次或准备评分时刷新界面状态。
|
||
/// </summary>
|
||
/// <param name="sessionId">场次ID。</param>
|
||
/// <returns>当前场次的赛事状态和操作权限。</returns>
|
||
[HttpGet("status/{sessionId}")]
|
||
[DisplayName("查询当前赛事状态")]
|
||
public async Task<JudgeEventStatusDto> GetCurrentEventStatus(long sessionId)
|
||
{
|
||
var referee = await EnsureRefereeAsync();
|
||
await GetMyAssignmentAsync(sessionId, referee.Id);
|
||
var session = await _sessionRepository.GetFirstAsync(x => x.Id == sessionId);
|
||
if (session == null) throw Oops.Bah("比赛场次不存在");
|
||
var eventInfo = await GetEventAsync(session.EventId);
|
||
|
||
var canScore = CompetitionStagePolicy.CanScore(session.SessionStatus, eventInfo.EventStatus);
|
||
return new JudgeEventStatusDto
|
||
{
|
||
EventId = session.EventId,
|
||
SessionId = session.Id,
|
||
SessionStatus = session.SessionStatus,
|
||
SessionStatusName = SessionStatusName(session.SessionStatus),
|
||
Phase = CompetitionStagePolicy.PhaseOf(session.SessionStatus, eventInfo.EventStatus),
|
||
PhaseName = CompetitionStagePolicy.PhaseName(session.SessionStatus, eventInfo.EventStatus),
|
||
CanScore = canScore,
|
||
CanModifyAssignment = CompetitionStagePolicy.CanModifyAssignment(session.SessionStatus, eventInfo.EventStatus),
|
||
Message = canScore ? null : CompetitionStagePolicy.ScoreLockReason(session.SessionStatus, eventInfo.EventStatus)
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取场次出场名单与我的评分状态
|
||
/// </summary>
|
||
[HttpGet("entries/{sessionId}")]
|
||
[DisplayName("获取场次出场名单")]
|
||
public async Task<List<JudgeEntryDto>> 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<JudgeEntryDto>();
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取评分表:表头、评分项、我已录入的值与合计预览
|
||
/// </summary>
|
||
[HttpGet("sheet/{sessionEntryId}")]
|
||
[DisplayName("获取评分表")]
|
||
public async Task<JudgeScoreSheetOutput> GetSheet(long sessionEntryId)
|
||
{
|
||
var referee = await EnsureRefereeAsync();
|
||
var (entry, ctx, eventInfo, 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<EventJudgeScoreDetail>()
|
||
: 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 stageAllowsScoring = CompetitionStagePolicy.CanScore(ctx.Session.SessionStatus, eventInfo.EventStatus);
|
||
var scoreSheetAllowsEditing = submitted == null || correction != null;
|
||
var canEdit = stageAllowsScoring && scoreSheetAllowsEditing;
|
||
var members = await LoadMemberNamesAsync(entry);
|
||
var venue = await _venueRepository.GetFirstAsync(x => x.Id == ctx.Session.VenueId);
|
||
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,
|
||
SheetVersion = source?.SheetVersion ?? nextVersion,
|
||
SheetStatus = source?.SheetStatus ?? SheetStatusDraft,
|
||
CanEdit = canEdit,
|
||
LockReason = canEdit ? null : !stageAllowsScoring
|
||
? CompetitionStagePolicy.ScoreLockReason(ctx.Session.SessionStatus, eventInfo.EventStatus)
|
||
: "已提交评分,如需修改请先申请成绩修正",
|
||
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)
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存评分草稿。草稿允许留空,不参与成绩计算。
|
||
/// </summary>
|
||
[HttpPost("save")]
|
||
[DisplayName("保存评分草稿")]
|
||
[UnitOfWork]
|
||
public async Task<JudgeScorePreviewDto> SaveDraft([FromBody] SaveJudgeScoreInput input)
|
||
{
|
||
var referee = await EnsureRefereeAsync();
|
||
var (entry, ctx, eventInfo, assignment, roleType) = await LoadSheetContextAsync(input.SessionEntryId, referee.Id);
|
||
EnsureCanScore(ctx.Session.SessionStatus, eventInfo.EventStatus);
|
||
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);
|
||
if (submitted != null)
|
||
{
|
||
var correction = await _correctionRepository.GetFirstAsync(x => x.OriginalScoreSheetId == submitted.Id
|
||
&& x.CorrectionStatus == CorrectionStatusApproved);
|
||
if (correction != null)
|
||
{
|
||
correction.CorrectionTaskId = task.Id;
|
||
correction.CorrectionStatus = CorrectionStatusEditing;
|
||
correction.UpdatedUserId = _authManager.UserId;
|
||
correction.UpdatedTime = DateTime.Now;
|
||
await _correctionRepository.UpdateAsync(correction);
|
||
}
|
||
}
|
||
return ToPreviewDto(calculation, ctx.Config);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 提交评分。提交后自动聚合本出场成绩(T1、T2、T3 与总分)并重排名次。
|
||
/// </summary>
|
||
[HttpPost("submit")]
|
||
[DisplayName("提交评分")]
|
||
[UnitOfWork]
|
||
public async Task<SubmitJudgeScoreOutput> Submit([FromBody] SubmitJudgeScoreInput input)
|
||
{
|
||
var referee = await EnsureRefereeAsync();
|
||
var (entry, ctx, eventInfo, assignment, roleType) = await LoadSheetContextAsync(input.SessionEntryId, referee.Id);
|
||
EnsureCanScore(ctx.Session.SessionStatus, eventInfo.EventStatus);
|
||
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 now = DateTime.Now;
|
||
if (submitted != null)
|
||
{
|
||
// 当前版本唯一索引要求先让原评分单退出当前态;UnitOfWork 会在后续失败时整体回滚。
|
||
submitted.IsCurrent = false;
|
||
submitted.SheetStatus = SheetStatusCorrected;
|
||
submitted.UpdatedUserId = _authManager.UserId;
|
||
submitted.UpdatedTime = now;
|
||
await _sheetRepository.UpdateAsync(submitted);
|
||
}
|
||
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);
|
||
|
||
if (correction != null)
|
||
{
|
||
correction.CorrectionTaskId = task.Id;
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查询场次成绩:各出场的 T1、T2、T3、总分与名次
|
||
/// </summary>
|
||
[HttpGet("results/{sessionId}")]
|
||
[DisplayName("查询场次成绩")]
|
||
public async Task<List<JudgeEntryResultDto>> GetResults(long sessionId)
|
||
{
|
||
var referee = await EnsureRefereeAsync();
|
||
var assignment = await GetMyAssignmentAsync(sessionId, referee.Id);
|
||
var roleType = JudgeRoleSchemaService.NormalizeRoleType(
|
||
string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType);
|
||
if (!string.Equals(roleType, JudgeRoleSchemaService.RoleChief, StringComparison.OrdinalIgnoreCase))
|
||
throw Oops.Bah("仅主裁判可查看场次最终成绩");
|
||
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<EventResult>()
|
||
: await _resultRepository.GetListAsync(x => entryIds.Contains(x.SessionEntryId));
|
||
var scoreSheetIds = outcomes.SelectMany(x => x.Aggregate?.Symbols ?? new List<EntrySymbolScore>())
|
||
.SelectMany(x => x.Judges).Select(x => x.ScoreSheetId).Distinct().ToList();
|
||
var annotationUrls = scoreSheetIds.Count == 0
|
||
? new Dictionary<long, string>()
|
||
: (await _attachmentRepository.GetListAsync(x => scoreSheetIds.Contains(x.ScoreSheetId)
|
||
&& x.IsCurrent && x.AttachmentType == AnnotationAttachmentType))
|
||
.GroupBy(x => x.ScoreSheetId).ToDictionary(x => x.Key, x => x.OrderByDescending(y => y.CreatedTime).First().FileUrl);
|
||
|
||
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<EntrySymbolScore>()).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
|
||
{
|
||
ScoreSheetId = judge.ScoreSheetId,
|
||
JudgeRole = judge.JudgeRole,
|
||
JudgeName = judge.JudgeName ?? string.Empty,
|
||
Value = ctx.Config.Format(judge.Value),
|
||
AnnotationUrl = annotationUrls.GetValueOrDefault(judge.ScoreSheetId)
|
||
}).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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取可申请成绩修正的评分单
|
||
/// </summary>
|
||
[HttpGet("corrections/candidates/{sessionId}")]
|
||
[DisplayName("获取可修正的评分单")]
|
||
public async Task<List<JudgeCorrectionCandidateDto>> 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<JudgeCorrectionCandidateDto>();
|
||
|
||
var sheetIds = sheets.Select(x => x.Id).ToList();
|
||
var corrections = await _correctionRepository.GetListAsync(x => sheetIds.Contains(x.OriginalScoreSheetId)
|
||
|| (x.NewScoreSheetId.HasValue && sheetIds.Contains(x.NewScoreSheetId.Value)));
|
||
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);
|
||
// 修正完成后当前评分单已经变成 NewScoreSheetId,仍需返回最近一次流程状态,
|
||
// 否则 App 会把刚完成的修正误显示成“可首次申请”。
|
||
var correction = corrections.Where(x => x.OriginalScoreSheetId == sheet.Id || x.NewScoreSheetId == sheet.Id)
|
||
.OrderByDescending(x => x.CreatedTime).FirstOrDefault();
|
||
var status = correction?.CorrectionStatus ?? -1;
|
||
return new JudgeCorrectionCandidateDto
|
||
{
|
||
CorrectionId = correction?.Id,
|
||
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),
|
||
ApplyReason = correction?.ApplyReason,
|
||
ApproveRemark = correction?.ApproveRemark,
|
||
// 已在流程中的评分单不能重复申请,被拒绝或已完成的可以再次申请。
|
||
CanApply = status is -1 or CorrectionStatusRejected or CorrectionStatusCompleted or CorrectionStatusCancelled
|
||
};
|
||
}).ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 申请成绩修正。规则未要求审批时申请即生效,裁判可直接重新提交评分。
|
||
/// </summary>
|
||
[HttpPost("corrections/apply")]
|
||
[DisplayName("申请成绩修正")]
|
||
[UnitOfWork]
|
||
public async Task<long> 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("评分单不存在");
|
||
var session = await _sessionRepository.GetFirstAsync(x => x.Id == sheet.SessionId);
|
||
if (session == null) throw Oops.Bah("比赛场次不存在");
|
||
var eventInfo = await GetEventAsync(session.EventId);
|
||
EnsureCanScore(session.SessionStatus, eventInfo.EventStatus);
|
||
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,
|
||
ApplyRefereeId = referee.Id,
|
||
ApplyReason = input.ApplyReason.Trim(),
|
||
CorrectionStatus = ctx.Config.CorrectionNeedApproval ? CorrectionStatusApplied : CorrectionStatusApproved,
|
||
CreatedUserId = _authManager.UserId,
|
||
CreatedTime = now
|
||
};
|
||
if (!ctx.Config.CorrectionNeedApproval)
|
||
{
|
||
// 规则自动放行不属于人工审批,不伪造审批人和审批时间。
|
||
correction.ApproveRemark = "规则配置为无需审批,申请后即可修正";
|
||
}
|
||
await _correctionRepository.InsertAsync(correction);
|
||
return correction.Id;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取我的消息
|
||
/// </summary>
|
||
[HttpGet("messages")]
|
||
[DisplayName("获取裁判端消息")]
|
||
public async Task<List<JudgeMessageDto>> 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();
|
||
var correctionIds = messages
|
||
.Where(x => string.Equals(x.RelationType, nameof(EventScoreCorrection), StringComparison.OrdinalIgnoreCase)
|
||
&& x.RelationId.HasValue)
|
||
.Select(x => x.RelationId!.Value).Distinct().ToList();
|
||
var corrections = correctionIds.Count == 0
|
||
? new Dictionary<long, EventScoreCorrection>()
|
||
: (await _correctionRepository.GetListAsync(x => correctionIds.Contains(x.Id))).ToDictionary(x => x.Id);
|
||
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,
|
||
SessionId = x.RelationId.HasValue ? corrections.GetValueOrDefault(x.RelationId.Value)?.SessionId : null,
|
||
SessionEntryId = x.RelationId.HasValue ? corrections.GetValueOrDefault(x.RelationId.Value)?.SessionEntryId : null,
|
||
ScoreSheetId = x.RelationId.HasValue ? corrections.GetValueOrDefault(x.RelationId.Value)?.OriginalScoreSheetId : null,
|
||
IsRead = x.IsRead, CreatedTime = x.CreatedTime
|
||
}).ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 标记消息已读,不传消息ID表示全部标记已读
|
||
/// </summary>
|
||
[HttpPost("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);
|
||
}
|
||
|
||
/// <summary>上传当前裁判评分单的记录画板 PNG。</summary>
|
||
[HttpPost("judge-scoring/annotations/upload")]
|
||
[DisplayName("上传裁判记录画板")]
|
||
[UnitOfWork]
|
||
public async Task<JudgeScoreAttachmentDto> UploadAnnotation([FromForm] UploadJudgeScoreAnnotationInput input)
|
||
{
|
||
var referee = await EnsureRefereeAsync();
|
||
var sheet = await _sheetRepository.GetFirstAsync(x => x.Id == input.ScoreSheetId && x.RefereeId == referee.Id);
|
||
if (sheet == null) throw Oops.Bah("评分单不存在或不属于当前裁判");
|
||
if (sheet.SheetStatus < EventScoreCalculationService.SheetStatusSubmitted)
|
||
throw Oops.Bah("请先提交成绩,再上传记录画板");
|
||
var key = input.IdempotencyKey?.Trim() ?? string.Empty;
|
||
if (key.Length is < 8 or > 64 || !Regex.IsMatch(key, "^[A-Za-z0-9_-]+$"))
|
||
throw Oops.Bah("画板上传幂等键无效");
|
||
var replay = await _attachmentRepository.GetFirstAsync(x => x.ScoreSheetId == sheet.Id && x.IdempotencyKey == key);
|
||
if (replay != null) return ToAttachmentDto(replay);
|
||
|
||
var file = input.File;
|
||
if (file == null || file.Length == 0) throw Oops.Bah("请选择画板 PNG 文件");
|
||
if (file.Length > MaxAnnotationBytes) throw Oops.Bah("画板图片不能超过5MB");
|
||
if (!string.Equals(Path.GetExtension(file.FileName), ".png", StringComparison.OrdinalIgnoreCase)
|
||
|| !string.Equals(file.ContentType, "image/png", StringComparison.OrdinalIgnoreCase))
|
||
throw Oops.Bah("画板仅支持PNG格式");
|
||
await using var stream = file.OpenReadStream();
|
||
var signature = new byte[8];
|
||
if (await stream.ReadAsync(signature.AsMemory(0, signature.Length)) != signature.Length
|
||
|| !signature.SequenceEqual(PngSignature))
|
||
throw Oops.Bah("上传文件不是有效的PNG图片");
|
||
stream.Position = 0;
|
||
// 文件头可以伪造;在上传 OSS 前完整解码,拒绝截断或损坏的 PNG。
|
||
// 限制解码后的像素数,避免小体积压缩文件造成过量内存分配。
|
||
// 解码器会释放其输入;保留请求流供后续 OSS 上传使用。
|
||
using (var decoderStream = new SkiaSharp.SKManagedStream(stream, false))
|
||
using (var codec = SkiaSharp.SKCodec.Create(decoderStream))
|
||
{
|
||
if (codec == null || codec.EncodedFormat != SkiaSharp.SKEncodedImageFormat.Png
|
||
|| codec.Info.Width <= 0 || codec.Info.Height <= 0
|
||
|| (long)codec.Info.Width * codec.Info.Height > 16_777_216)
|
||
throw Oops.Bah("画板PNG图片损坏或像素尺寸过大");
|
||
using var bitmap = new SkiaSharp.SKBitmap(codec.Info);
|
||
if (codec.GetPixels(bitmap.Info, bitmap.GetPixels()) != SkiaSharp.SKCodecResult.Success)
|
||
throw Oops.Bah("画板PNG图片不完整或无法解码");
|
||
}
|
||
stream.Position = 0;
|
||
var objectName = $"judge-score-annotations/{sheet.EventId}/{sheet.Id}/{key}.png";
|
||
var url = await _fileService.UploadFileStreamAsync(objectName, stream);
|
||
|
||
var now = DateTime.Now;
|
||
var current = await _attachmentRepository.GetListAsync(x => x.ScoreSheetId == sheet.Id
|
||
&& x.AttachmentType == AnnotationAttachmentType && x.IsCurrent);
|
||
foreach (var old in current)
|
||
{
|
||
old.IsCurrent = false;
|
||
old.UpdatedUserId = _authManager.UserId;
|
||
old.UpdatedTime = now;
|
||
}
|
||
if (current.Count > 0) await _attachmentRepository.UpdateRangeAsync(current);
|
||
var attachment = new EventJudgeScoreAttachment
|
||
{
|
||
Id = _idGenerator.NextId(), EventId = sheet.EventId, SessionId = sheet.SessionId,
|
||
SessionEntryId = sheet.SessionEntryId, ScoreSheetId = sheet.Id,
|
||
JudgeAssignmentId = sheet.JudgeAssignmentId, RefereeId = referee.Id,
|
||
AttachmentType = AnnotationAttachmentType, FileName = Path.GetFileName(file.FileName),
|
||
FileUrl = url, FileSize = file.Length, ContentType = "image/png", IdempotencyKey = key,
|
||
IsCurrent = true, CreatedUserId = _authManager.UserId, CreatedTime = now
|
||
};
|
||
await _attachmentRepository.InsertAsync(attachment);
|
||
return ToAttachmentDto(attachment);
|
||
}
|
||
|
||
/// <summary>查询评分单当前记录画板。</summary>
|
||
[HttpGet("judge-scoring/annotations/{scoreSheetId}")]
|
||
[DisplayName("查询裁判记录画板")]
|
||
public async Task<JudgeScoreAttachmentDto?> GetAnnotation(long scoreSheetId)
|
||
{
|
||
var referee = await EnsureRefereeAsync();
|
||
var sheet = await _sheetRepository.GetFirstAsync(x => x.Id == scoreSheetId);
|
||
if (sheet == null) throw Oops.Bah("评分单不存在");
|
||
if (sheet.RefereeId != referee.Id)
|
||
{
|
||
var assignment = await GetMyAssignmentAsync(sheet.SessionId, referee.Id);
|
||
var roleType = JudgeRoleSchemaService.NormalizeRoleType(
|
||
string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType);
|
||
if (!string.Equals(roleType, JudgeRoleSchemaService.RoleChief, StringComparison.OrdinalIgnoreCase))
|
||
throw Oops.Bah("无权查看该评分单画板");
|
||
}
|
||
var attachment = await _attachmentRepository.GetFirstAsync(x => x.ScoreSheetId == scoreSheetId
|
||
&& x.AttachmentType == AnnotationAttachmentType && x.IsCurrent);
|
||
return attachment == null ? null : ToAttachmentDto(attachment);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校验当前登录用户的裁判身份并取出裁判档案
|
||
/// </summary>
|
||
private async Task<YD_Event.Core.Entities.Referee> 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("裁判档案不存在,请先完善裁判资料");
|
||
if (!await _assignmentRepository.IsAnyAsync(x => x.RefereeId == referee.Id && x.AssignmentStatus == 1))
|
||
throw Oops.Bah("裁判分配尚未提交,暂不能进入裁判端");
|
||
return referee;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 校验当前场次状态是否允许评分。
|
||
/// </summary>
|
||
/// <param name="sessionStatus">场次状态。</param>
|
||
/// <param name="eventStatus">赛事状态。</param>
|
||
private static void EnsureCanScore(int sessionStatus, EventStatus eventStatus)
|
||
{
|
||
if (!CompetitionStagePolicy.CanScore(sessionStatus, eventStatus))
|
||
throw Oops.Bah(CompetitionStagePolicy.ScoreLockReason(sessionStatus, eventStatus));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取场次所属赛事,确保评分权限校验与后台展示使用同一赛事状态。
|
||
/// </summary>
|
||
private async Task<EventInfo> GetEventAsync(long eventId)
|
||
{
|
||
var eventInfo = await _eventRepository.GetFirstAsync(x => x.Id == eventId);
|
||
if (eventInfo == null) throw Oops.Bah("赛事不存在");
|
||
return eventInfo;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 取我在该场次的裁判席位,未被分配到该场次时直接拒绝
|
||
/// </summary>
|
||
private async Task<EventJudgeAssignment> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 加载评分表所需的出场、计分上下文与我的裁判席位
|
||
/// </summary>
|
||
private async Task<(EventSessionEntry Entry, ScoringContext Context, EventInfo Event,
|
||
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 eventInfo = await GetEventAsync(ctx.Session.EventId);
|
||
var roleType = JudgeRoleSchemaService.NormalizeRoleType(
|
||
string.IsNullOrWhiteSpace(assignment.JudgeRoleType) ? assignment.JudgeRole : assignment.JudgeRoleType);
|
||
return (entry, ctx, eventInfo, assignment, roleType);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 取出场对应的运动员姓名,用于评分表表头展示
|
||
/// </summary>
|
||
private async Task<List<string>> LoadMemberNamesAsync(EventSessionEntry entry)
|
||
{
|
||
if (!entry.RegistrationItemId.HasValue) return new List<string>();
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 该评分单是否有正在处理中的修正申请
|
||
/// </summary>
|
||
private async Task<bool> HasOpenCorrectionAsync(long scoreSheetId) =>
|
||
await _correctionRepository.IsAnyAsync(x => x.OriginalScoreSheetId == scoreSheetId
|
||
&& (x.CorrectionStatus == CorrectionStatusApplied
|
||
|| x.CorrectionStatus == CorrectionStatusApproved
|
||
|| x.CorrectionStatus == CorrectionStatusEditing));
|
||
|
||
private static JudgeScoreAttachmentDto ToAttachmentDto(EventJudgeScoreAttachment attachment) => new()
|
||
{
|
||
Id = attachment.Id,
|
||
ScoreSheetId = attachment.ScoreSheetId,
|
||
AttachmentType = attachment.AttachmentType,
|
||
FileName = attachment.FileName,
|
||
FileUrl = attachment.FileUrl,
|
||
FileSize = attachment.FileSize,
|
||
UploadedTime = attachment.CreatedTime
|
||
};
|
||
|
||
/// <summary>
|
||
/// 取(或按需创建)本裁判在该出场上的评分任务
|
||
/// </summary>
|
||
private async Task<EventJudgeTask> 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
|
||
&& (taskType != TaskTypeCorrection || x.TaskStatus != TaskStatusSubmitted));
|
||
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;
|
||
}
|
||
|
||
var taskHistory = await _taskRepository.GetListAsync(x => x.SessionEntryId == entry.Id
|
||
&& x.JudgeAssignmentId == assignment.Id && !x.DeleteMark);
|
||
var previousTask = taskHistory.OrderByDescending(x => x.TaskVersion).FirstOrDefault();
|
||
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,
|
||
TaskVersion = (previousTask?.TaskVersion ?? 0) + 1,
|
||
OriginalTaskId = taskType == TaskTypeCorrection ? previousTask?.Id : null,
|
||
TaskStatus = TaskStatusAccepted,
|
||
DispatchedTime = now,
|
||
AcceptedTime = now,
|
||
CreatedUserId = _authManager.UserId,
|
||
CreatedTime = now
|
||
};
|
||
await _taskRepository.InsertAsync(task);
|
||
return task;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 写入(或更新)评分单及其明细。分值一律取服务端计算结果。
|
||
/// </summary>
|
||
private async Task<EventJudgeScoreSheet> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 组装提交结果:我的得分 + 本出场的聚合进度
|
||
/// </summary>
|
||
private async Task<SubmitJudgeScoreOutput> 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 = !string.Equals(JudgeRoleSchemaService.NormalizeRoleType(sheet.JudgeRoleType),
|
||
JudgeRoleSchemaService.RoleChief, StringComparison.OrdinalIgnoreCase)
|
||
|| outcome == null || !outcome.IsCompleted ? null
|
||
: outcome.IsDisqualified ? "取消资格" : ctx.Config.Format(outcome.TotalScore),
|
||
Progress = (outcome?.Roles ?? new List<EntryRoleProgress>()).Select(ToProgressDto).ToList()
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把录入项转成计算器的入参
|
||
/// </summary>
|
||
private static List<ScoreItemInputValue> ToInputValues(List<JudgeScoreItemInput> items) =>
|
||
items.Select(x => new ScoreItemInputValue
|
||
{
|
||
ItemCode = x.ItemCode, InputValue = x.InputValue, CountValue = x.CountValue
|
||
}).ToList();
|
||
|
||
/// <summary>
|
||
/// 组装合计预览
|
||
/// </summary>
|
||
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()
|
||
};
|
||
|
||
/// <summary>
|
||
/// 组装裁判类型的提交进度
|
||
/// </summary>
|
||
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
|
||
};
|
||
|
||
/// <summary>
|
||
/// 评分单状态:草稿
|
||
/// </summary>
|
||
private const int SheetStatusDraft = 0;
|
||
|
||
/// <summary>
|
||
/// 评分单状态:已确认
|
||
/// </summary>
|
||
private const int SheetStatusConfirmed = 20;
|
||
|
||
/// <summary>
|
||
/// 评分单状态:已被修正
|
||
/// </summary>
|
||
private const int SheetStatusCorrected = 30;
|
||
|
||
/// <summary>
|
||
/// 评分任务状态:已接收
|
||
/// </summary>
|
||
private const int TaskStatusAccepted = 20;
|
||
|
||
/// <summary>
|
||
/// 评分任务状态:已提交
|
||
/// </summary>
|
||
private const int TaskStatusSubmitted = 40;
|
||
|
||
/// <summary>
|
||
/// 评分任务状态:已作废
|
||
/// </summary>
|
||
private const int TaskStatusVoided = 90;
|
||
|
||
/// <summary>
|
||
/// 评分任务类型:成绩修正
|
||
/// </summary>
|
||
private const int TaskTypeCorrection = 1;
|
||
|
||
/// <summary>
|
||
/// 修正状态:已申请
|
||
/// </summary>
|
||
private const int CorrectionStatusApplied = 0;
|
||
|
||
/// <summary>
|
||
/// 修正状态:已批准
|
||
/// </summary>
|
||
private const int CorrectionStatusApproved = 10;
|
||
|
||
/// <summary>
|
||
/// 修正状态:已拒绝
|
||
/// </summary>
|
||
private const int CorrectionStatusRejected = 20;
|
||
|
||
/// <summary>
|
||
/// 修正状态:裁判修改中
|
||
/// </summary>
|
||
private const int CorrectionStatusEditing = 30;
|
||
|
||
/// <summary>
|
||
/// 修正状态:已完成
|
||
/// </summary>
|
||
private const int CorrectionStatusCompleted = 40;
|
||
|
||
/// <summary>
|
||
/// 修正状态:已取消
|
||
/// </summary>
|
||
private const int CorrectionStatusCancelled = 90;
|
||
|
||
private const string AnnotationAttachmentType = "score_annotation_png";
|
||
private const long MaxAnnotationBytes = 5L * 1024 * 1024;
|
||
private static readonly byte[] PngSignature = [137, 80, 78, 71, 13, 10, 26, 10];
|
||
|
||
/// <summary>
|
||
/// 出场状态:退赛
|
||
/// </summary>
|
||
private const int EntryStatusWithdrawn = 80;
|
||
|
||
/// <summary>
|
||
/// 场次状态名称
|
||
/// </summary>
|
||
private static string SessionStatusName(int status) => status switch
|
||
{
|
||
10 => "就绪",
|
||
20 => "检录中",
|
||
30 => "进行中",
|
||
40 => "暂停",
|
||
50 => "待确认",
|
||
60 => "已结束",
|
||
90 => "已取消",
|
||
_ => "草稿"
|
||
};
|
||
|
||
/// <summary>
|
||
/// 我的评分状态名称
|
||
/// </summary>
|
||
private static string ScoreStatusName(int status) => status switch
|
||
{
|
||
1 => "草稿",
|
||
2 => "已提交",
|
||
3 => "待修正",
|
||
_ => "未评分"
|
||
};
|
||
|
||
/// <summary>
|
||
/// 修正申请状态名称
|
||
/// </summary>
|
||
private static string CorrectionStatusName(int status) => status switch
|
||
{
|
||
CorrectionStatusApplied => "已申请",
|
||
CorrectionStatusApproved => "已批准",
|
||
CorrectionStatusRejected => "已拒绝",
|
||
CorrectionStatusEditing => "裁判修改中",
|
||
CorrectionStatusCompleted => "已完成",
|
||
CorrectionStatusCancelled => "已取消",
|
||
_ => "未申请"
|
||
};
|
||
}
|