YD_Event.Admin/YD_Event.Application/Event/JudgeAssignmentService.cs
2026-09-08 16:27:27 +08:00

460 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using YD_Event.Application.Auth;
using YD_Event.Application.Event.Dtos;
using YD_Event.Application.Scoring;
namespace YD_Event.Application.Event;
/// <summary>
/// 赛事裁判报名与分配
/// </summary>
[ApiDescriptionSettings("打分系统")]
[Route("api/judgeassignment")]
public class JudgeAssignmentService : IDynamicApiController, ITransient
{
private readonly ISqlSugarRepository<EventReferee> _eventRefereeRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Referee> _refereeRepository;
private readonly ISqlSugarRepository<EventSession> _sessionRepository;
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<EventJudgeAssignment> _assignmentRepository;
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
private readonly IIdGenerator _idGenerator;
private readonly AuthManager _authManager;
public JudgeAssignmentService(ISqlSugarRepository<EventReferee> eventRefereeRepository,
ISqlSugarRepository<YD_Event.Core.Entities.Referee> refereeRepository,
ISqlSugarRepository<EventSession> sessionRepository, ISqlSugarRepository<EventVenue> venueRepository,
ISqlSugarRepository<EventProject> eventProjectRepository, ISqlSugarRepository<YD_Event.Core.Entities.Project> projectRepository,
ISqlSugarRepository<YD_Event.Core.Entities.ScoreRule> ruleRepository, ISqlSugarRepository<EventJudgeAssignment> assignmentRepository,
ISqlSugarRepository<EventInfo> eventRepository, IIdGenerator idGenerator, AuthManager authManager)
{
_eventRefereeRepository = eventRefereeRepository;
_refereeRepository = refereeRepository;
_sessionRepository = sessionRepository; _venueRepository = venueRepository; _eventProjectRepository = eventProjectRepository;
_projectRepository = projectRepository; _ruleRepository = ruleRepository; _assignmentRepository = assignmentRepository;
_eventRepository = eventRepository;
_idGenerator = idGenerator; _authManager = authManager;
}
/// <summary>
/// 获取赛事裁判列表
/// </summary>
[HttpGet("event-referees/{eventId}"), DisplayName("获取赛事裁判列表")]
public async Task<List<EventRefereeOutput>> GetEventReferees(long eventId)
{
var relations = await _eventRefereeRepository.AsQueryable()
// 赛事裁判目前没有“禁用”业务,是否有效统一由软删除标记控制。
// 同时兼容早期迁移中被错误写成 Status=1 的存量记录。
.Where(x => x.EventId == eventId)
.OrderByDescending(x => x.CreatedTime).ToListAsync();
var referees = await _refereeRepository.AsQueryable()
.Where(x => relations.Select(r => r.RefereeId).Contains(x.Id)).ToListAsync();
return relations.Select(relation => ToEventRefereeOutput(relation,
referees.FirstOrDefault(x => x.Id == relation.RefereeId))).ToList();
}
/// <summary>
/// 获取裁判分配看板
/// </summary>
[HttpGet("board/{eventId}"), DisplayName("获取裁判分配看板")]
public async Task<JudgeAssignmentBoardOutput> GetBoard(long eventId)
{
var sessions = await _sessionRepository.AsQueryable().Where(x => x.EventId == eventId && x.SessionStatus != 90).ToListAsync();
if (sessions.Count == 0) return new JudgeAssignmentBoardOutput();
var venueMap = (await _venueRepository.AsQueryable().Where(x => x.EventId == eventId).ToListAsync()).ToDictionary(x => x.Id);
var eventProjects = await _eventProjectRepository.AsQueryable().Where(x => x.EventId == eventId).ToListAsync();
var projects = await _projectRepository.AsQueryable().Where(x => eventProjects.Select(e => e.ProjectId).Contains(x.Id)).ToListAsync();
var rules = await _ruleRepository.AsQueryable().Where(x => projects.Where(p => p.ScoreRuleId.HasValue).Select(p => p.ScoreRuleId!.Value).Contains(x.Id)).ToListAsync();
var assignments = await _assignmentRepository.AsQueryable().Where(x => x.EventId == eventId && x.AssignmentStatus < 9).ToListAsync();
var referees = await _refereeRepository.AsQueryable().Where(x => assignments.Select(a => a.RefereeId).Contains(x.Id)).ToListAsync();
var result = new JudgeAssignmentBoardOutput { ArrangementConfirmed = true, Submitted = assignments.Count > 0 && assignments.All(x => x.AssignmentStatus == 1) };
foreach (var ep in eventProjects)
{
var project = projects.FirstOrDefault(x => x.Id == ep.ProjectId);
if (project == null) continue;
var rule = rules.FirstOrDefault(x => x.Id == project.ScoreRuleId);
var sections = ParseRoleSections(rule?.JudgeRoleSchemaJson);
var slots = sections.SelectMany(x => x.Slots).ToList();
// 裁判类型编码 → 类型定义(回显类型名称)/ 在计分规则中的次序(保证席位展示顺序与规则一致)。
var slotMap = slots.ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase);
var slotOrder = slots.Select((slot, index) => (slot.Code, Order: index))
.ToDictionary(x => x.Code, x => x.Order, StringComparer.OrdinalIgnoreCase);
var group = new JudgeAssignmentProjectOutput
{
EventProjectId = ep.Id, ProjectName = project.Name,
RuleName = rule?.RuleName ?? "未配置计分规则", JudgeRoleSections = sections
};
foreach (var session in sessions.Where(x => x.EventProjectId == ep.Id).OrderBy(x => venueMap.GetValueOrDefault(x.VenueId)?.Sort))
{
var items = assignments.Where(x => x.SessionId == session.Id)
.Select(x => ToAssignmentItemOutput(x, referees.FirstOrDefault(r => r.Id == x.RefereeId), slotMap))
// 先按规则中的裁判类型排序(规则里没有的历史类型排到最后),同类型内再按席位编码排列。
.OrderBy(x => slotOrder.GetValueOrDefault(x.JudgeRoleType, int.MaxValue))
.ThenBy(x => x.JudgeRole, StringComparer.OrdinalIgnoreCase).ToList();
group.Sessions.Add(new JudgeAssignmentSessionOutput
{
SessionId = session.Id, SessionName = session.SessionName ?? session.SessionNo,
VenueName = venueMap.GetValueOrDefault(session.VenueId)?.VenueName ?? "-",
Assigned = items.Count > 0, Assignments = items
});
}
if (group.Sessions.Count > 0) result.Projects.Add(group);
}
return result;
}
/// <summary>
/// 获取可分配裁判(过滤同时段已分配的裁判)
/// </summary>
[HttpGet("candidates/{eventId}/{sessionId}"), DisplayName("获取可分配裁判")]
public async Task<List<EventRefereeOutput>> GetCandidates(long eventId, long sessionId)
{
var session = await _sessionRepository.GetFirstAsync(x => x.Id == sessionId && x.EventId == eventId);
if (session == null) throw Oops.Bah("场次不存在");
var allReferees = await GetEventReferees(eventId);
// 如果场次没有设置时间,则不进行冲突检测
if (!session.StartTime.HasValue || !session.EndTime.HasValue)
{
return allReferees;
}
// 查找同时段已分配的裁判ID(场次时间重叠且已分配)
var conflictSessions = await _sessionRepository.AsQueryable()
.Where(x => x.EventId == eventId && x.Id != sessionId && x.SessionStatus != 90)
.Where(x => x.StartTime.HasValue && x.EndTime.HasValue)
.Where(x => (x.StartTime.Value <= session.EndTime.Value && x.EndTime.Value >= session.StartTime.Value))
.ToListAsync();
if (conflictSessions.Count == 0)
{
return allReferees;
}
var conflictSessionIds = conflictSessions.Select(x => x.Id).ToList();
var conflictAssignments = await _assignmentRepository.AsQueryable()
.Where(x => conflictSessionIds.Contains(x.SessionId) && x.AssignmentStatus < 9)
.ToListAsync();
var conflictRefereeIds = conflictAssignments.Select(x => x.RefereeId).Distinct().ToList();
// 过滤已分配裁判
return allReferees.Where(x => !conflictRefereeIds.Contains(x.RefereeId)).ToList();
}
/// <summary>
/// 保存场次裁判分配。
/// 同一裁判类型可分配多名裁判,人数上下限由计分规则的裁判席位配置决定。
/// </summary>
/// <param name="input">场次裁判分配信息。</param>
[HttpPost("save"), DisplayName("保存裁判分配"), UnitOfWork]
public async Task Save([FromBody] SaveJudgeAssignmentsInput input)
{
var session = await _sessionRepository.GetFirstAsync(x => x.Id == input.SessionId && x.EventId == input.EventId);
if (session == null || session.SessionStatus == 90)
{
throw Oops.Bah("场次不存在或已取消");
}
var eventSessions = await _sessionRepository.AsQueryable()
.Where(x => x.EventId == input.EventId && x.SessionStatus != 90)
.ToListAsync();
var lockedSession = eventSessions.FirstOrDefault(
x => !CompetitionStagePolicy.CanModifyAssignment(x.SessionStatus));
if (lockedSession != null)
{
throw Oops.Bah(CompetitionStagePolicy.AssignmentLockReason(lockedSession.SessionStatus));
}
if (input.Assignments.Any(x => string.IsNullOrWhiteSpace(x.JudgeRole) || (x.EventRefereeId <= 0 && x.RefereeId <= 0)))
{
throw Oops.Bah("存在未选择裁判的分配记录");
}
var rule = await _ruleRepository.GetByIdAsync(session.ScoreRuleId);
var slots = ParseRoleSections(rule?.JudgeRoleSchemaJson).SelectMany(x => x.Slots).ToList();
if (slots.Count == 0)
{
throw Oops.Bah("当前项目的计分规则未配置裁判类型");
}
var grouped = GroupByRoleType(input.Assignments, slots);
EnsureRefereeCount(slots, grouped);
var candidates = await GetEventReferees(input.EventId);
var seats = BuildSeats(slots, grouped, candidates);
// 赛前允许反复修改已提交的分配。保存新版本后先回到草稿态,重新提交后裁判端才可见。
await _assignmentRepository.SoftDeleteAsync(x => x.SessionId == input.SessionId && x.AssignmentStatus < 9);
var now = DateTime.Now;
var refereeIds = seats.Select(x => x.Candidate.RefereeId).Distinct().ToList();
var refereeUsers = (await _refereeRepository.AsQueryable()
.Where(x => refereeIds.Contains(x.Id)).ToListAsync())
.ToDictionary(x => x.Id, x => x.AppUserId);
await _assignmentRepository.InsertRangeAsync(seats.Select(x => new EventJudgeAssignment
{
Id = _idGenerator.NextId(), EventId = input.EventId, SessionId = input.SessionId,
EventRefereeId = x.Candidate.EventRefereeId, RefereeId = x.Candidate.RefereeId,
JudgeUserId = refereeUsers.GetValueOrDefault(x.Candidate.RefereeId),
JudgeRole = x.SeatCode, JudgeRoleType = x.Slot.Code, AssignmentStatus = 0,
Status = AvailabilityStatus.Enable, CreatedUserId = _authManager.UserId, CreatedTime = now
}).ToList());
}
/// <summary>
/// 提交赛事全部场次的裁判分配。
/// 首次提交时,将本赛事所有裁判分配草稿更新为已提交,并将对应的准备中场次更新为就绪;
/// 赛前修改已提交的分配后,可调用本接口重新提交,本次仅更新发生修改的草稿场次,其他已提交场次保持不变。
/// 提交前会校验赛事已生成有效场次、每个有效场次均已分配裁判,且所有场次仍处于准备中或就绪状态。
/// 任一场次进入检录中、进行中或赛后状态后,均不允许再提交或重新提交裁判分配。
/// </summary>
/// <param name="input">赛事裁判分配提交信息。</param>
[HttpPost("submit"), DisplayName("提交裁判分配"), UnitOfWork]
public async Task Submit([FromBody] SubmitJudgeAssignmentsInput input)
{
var sessions = await _sessionRepository.AsQueryable()
.Where(x => x.EventId == input.EventId && x.SessionStatus != 90)
.ToListAsync();
if (sessions.Count == 0)
{
throw Oops.Bah("请先确认赛事编排");
}
var lockedSession = sessions.FirstOrDefault(
x => !CompetitionStagePolicy.CanModifyAssignment(x.SessionStatus));
if (lockedSession != null)
{
throw Oops.Bah(CompetitionStagePolicy.AssignmentLockReason(lockedSession.SessionStatus));
}
// 重提时只有修改过的场次是草稿,其余场次仍是已提交;完整性校验必须同时考虑两者。
var assignments = await _assignmentRepository.AsQueryable()
.Where(x => x.EventId == input.EventId
&& (x.AssignmentStatus == 0 || x.AssignmentStatus == 1))
.ToListAsync();
if (sessions.Any(sessionItem => !assignments.Any(assignment => assignment.SessionId == sessionItem.Id)))
{
throw Oops.Bah("仍有场次未分配裁判");
}
var drafts = assignments.Where(x => x.AssignmentStatus == 0).ToList();
if (drafts.Count == 0)
{
return;
}
var now = DateTime.Now;
drafts.ForEach(assignment =>
{
assignment.AssignmentStatus = 1;
assignment.SubmittedTime = now;
assignment.UpdatedUserId = _authManager.UserId;
assignment.UpdatedTime = now;
});
await _assignmentRepository.UpdateRangeAsync(drafts);
var changedSessionIds = drafts.Select(x => x.SessionId).Distinct().ToHashSet();
var changedSessions = sessions.Where(x => changedSessionIds.Contains(x.Id)).ToList();
changedSessions.ForEach(sessionItem =>
{
if (sessionItem.SessionStatus == 0)
{
sessionItem.SessionStatus = 10;
}
sessionItem.Version++;
sessionItem.UpdatedUserId = _authManager.UserId;
sessionItem.UpdatedTime = now;
});
await _sessionRepository.UpdateRangeAsync(changedSessions);
}
/// <summary>
/// 生成裁判报名码,确保唯一性
/// </summary>
[HttpPost("registration-code/{eventId}"), DisplayName("生成裁判报名码")]
public async Task<string> GenerateRegistrationCode(long eventId)
{
var entity = await _eventRepository.GetByIdAsync(eventId);
if (entity == null) throw Oops.Bah("赛事不存在");
if (string.IsNullOrWhiteSpace(entity.RefereeRegistrationCode))
{
// 生成唯一报名码,最多重试10次
for (int i = 0; i < 10; i++)
{
var code = $"RJ{eventId % 1000000:D6}{Random.Shared.Next(1000, 9999)}";
// 校验全局唯一性
if (!await _eventRepository.IsAnyAsync(x => x.RefereeRegistrationCode == code))
{
entity.RefereeRegistrationCode = code;
entity.UpdatedUserId = _authManager.UserId;
entity.UpdatedTime = DateTime.Now;
await _eventRepository.UpdateAsync(entity);
return code;
}
}
throw Oops.Bah("报名码生成失败,请重试");
}
return entity.RefereeRegistrationCode;
}
/// <summary>
/// 组装赛事裁判输出,裁判档案缺失时用占位值兜底
/// </summary>
private static EventRefereeOutput ToEventRefereeOutput(EventReferee? relation,
YD_Event.Core.Entities.Referee? referee)
{
var roles = referee?.Roles ?? RefereeRole.None;
return new EventRefereeOutput
{
EventRefereeId = relation?.Id ?? 0,
ApplicationId = relation?.RefereeApplicationId ?? 0,
RefereeId = referee?.Id ?? relation?.RefereeId ?? 0,
Name = referee?.Name ?? "-",
RegionName = referee == null ? "-" : $"{referee.ProvinceName}/{referee.CityName}/{referee.DistrictName}",
LevelName = referee?.Level.Description() ?? "-",
CertificateUrl = referee?.CertificateUrl ?? string.Empty,
Mobile = referee?.Mobile ?? string.Empty,
Roles = (int)roles,
RoleNames = Enum.GetValues<RefereeRole>()
.Where(role => role != RefereeRole.None && roles.HasFlag(role))
.Select(role => role.Description()).ToList(),
CreatedTime = relation?.CreatedTime ?? referee?.CreatedTime ?? DateTime.MinValue,
ApplicationStatus = 2,
ApplicationStatusName = "已通过",
SourceType = relation?.SourceType ?? 1,
SourceTypeName = relation?.SourceType == 2 ? "小程序报名" : "后台添加"
};
}
/// <summary>
/// 组装看板上的裁判席位输出,裁判类型名称取自计分规则
/// </summary>
private static JudgeAssignmentItemOutput ToAssignmentItemOutput(EventJudgeAssignment assignment,
YD_Event.Core.Entities.Referee? referee, Dictionary<string, JudgeRoleSlotOutput> slotMap)
{
// 裁判类型是后加的字段,存量分配记录只有席位编码(DP1),需要回退解析出类型(DP)。
var roleType = string.IsNullOrWhiteSpace(assignment.JudgeRoleType)
? NormalizeRoleType(assignment.JudgeRole)
: assignment.JudgeRoleType!;
return new JudgeAssignmentItemOutput
{
EventRefereeId = assignment.EventRefereeId,
JudgeRole = assignment.JudgeRole,
JudgeRoleType = roleType,
JudgeRoleName = slotMap.GetValueOrDefault(roleType)?.Name ?? roleType,
RefereeId = assignment.RefereeId,
RefereeName = referee?.Name ?? "-",
Mobile = referee?.Mobile ?? string.Empty
};
}
/// <summary>
/// 按裁判类型给提交的分配记录归组,同一类型可以出现多条,表示该类型分配了多名裁判。
/// 前端既可能传裁判类型编码(DP),也可能传历史席位编码(DP1),统一归一到规则里的类型编码。
/// </summary>
private static Dictionary<string, List<SaveJudgeAssignmentItem>> GroupByRoleType(
List<SaveJudgeAssignmentItem> assignments, List<JudgeRoleSlotOutput> slots)
{
var slotMap = slots.ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase);
var grouped = new Dictionary<string, List<SaveJudgeAssignmentItem>>(StringComparer.OrdinalIgnoreCase);
foreach (var item in assignments)
{
var roleType = slotMap.TryGetValue(item.JudgeRole, out var matched) ? matched.Code : NormalizeRoleType(item.JudgeRole);
if (!slotMap.ContainsKey(roleType)) throw Oops.Bah($"裁判类型{item.JudgeRole}不属于当前项目的计分规则");
if (!grouped.TryGetValue(roleType, out var list))
{
list = new List<SaveJudgeAssignmentItem>();
grouped[roleType] = list;
}
list.Add(item);
}
return grouped;
}
/// <summary>
/// 校验每个裁判类型分配的人数是否落在计分规则给出的上下限内
/// </summary>
private static void EnsureRefereeCount(List<JudgeRoleSlotOutput> slots,
Dictionary<string, List<SaveJudgeAssignmentItem>> grouped)
{
foreach (var slot in slots)
{
var count = grouped.TryGetValue(slot.Code, out var list) ? list.Count : 0;
if (count < slot.MinCount) throw Oops.Bah($"{slot.Name}至少需要分配 {slot.MinCount} 名裁判");
if (slot.MaxCount.HasValue && count > slot.MaxCount.Value) throw Oops.Bah($"{slot.Name}最多只能分配 {slot.MaxCount.Value} 名裁判");
}
}
/// <summary>
/// 把归组后的分配记录展开成待落库的裁判席位,并校验裁判资格与重复分配。
/// 席位编码规则:类型限 1 人时直接用类型编码(T),可分配多人时按顺序追加序号(DP1、DP2)。
/// </summary>
private static List<JudgeSeat> BuildSeats(List<JudgeRoleSlotOutput> slots,
Dictionary<string, List<SaveJudgeAssignmentItem>> grouped, List<EventRefereeOutput> candidates)
{
var seats = new List<JudgeSeat>();
foreach (var slot in slots)
{
if (!grouped.TryGetValue(slot.Code, out var list)) continue;
var seatNo = 0;
foreach (var item in list)
{
var candidate = item.EventRefereeId > 0
? candidates.FirstOrDefault(x => x.EventRefereeId == item.EventRefereeId)
: candidates.FirstOrDefault(x => x.RefereeId == item.RefereeId);
if (candidate == null) throw Oops.Bah("存在尚未关联到当前赛事的裁判");
if (slot.RoleFlag != 0 && (candidate.Roles & slot.RoleFlag) == 0) throw Oops.Bah($"裁判{candidate.Name}不具备{slot.Name}资格");
seatNo++;
seats.Add(new JudgeSeat
{
Slot = slot,
SeatCode = slot.MaxCount == 1 ? slot.Code : $"{slot.Code}{seatNo}",
Candidate = candidate
});
}
}
var repeated = seats.GroupBy(x => x.Candidate.EventRefereeId).FirstOrDefault(x => x.Count() > 1);
if (repeated != null) throw Oops.Bah($"裁判{repeated.First().Candidate.Name}在同一场次只能担任一个裁判席位");
return seats;
}
/// <summary>
/// 解析计分规则的裁判席位配置,与裁判端评分共用一份解析逻辑
/// </summary>
private static List<JudgeRoleSectionOutput> ParseRoleSections(string? schemaJson) => JudgeRoleSchemaService.ParseSections(schemaJson);
/// <summary>
/// 把席位编码归一为裁判类型编码,兼容 DP1、P2 这类带席位序号的历史编码
/// </summary>
private static string NormalizeRoleType(string? code) => JudgeRoleSchemaService.NormalizeRoleType(code);
/// <summary>
/// 裁判类型编码对应的执裁资格位,未单独列出的类型都按难度裁判处理
/// </summary>
private static int RoleFlag(string roleType) => JudgeRoleSchemaService.RoleFlagOf(roleType);
/// <summary>
/// 一条待落库的裁判席位
/// </summary>
private class JudgeSeat
{
/// <summary>
/// 席位所属的裁判类型
/// </summary>
public JudgeRoleSlotOutput Slot { get; set; } = null!;
/// <summary>
/// 席位编码,如 T、DP1、DP2
/// </summary>
public string SeatCode { get; set; } = null!;
/// <summary>
/// 该席位的执裁裁判
/// </summary>
public EventRefereeOutput Candidate { get; set; } = null!;
}
}