using YD_Event.Application.Auth; using YD_Event.Application.Event.Dtos; using YD_Event.Application.Scoring; namespace YD_Event.Application.Event; /// /// 赛事裁判报名与分配 /// [ApiDescriptionSettings("打分系统")] [Route("api/judgeassignment")] public class JudgeAssignmentService : IDynamicApiController, ITransient { private readonly ISqlSugarRepository _eventRefereeRepository; private readonly ISqlSugarRepository _refereeRepository; private readonly ISqlSugarRepository _sessionRepository; private readonly ISqlSugarRepository _venueRepository; private readonly ISqlSugarRepository _eventProjectRepository; private readonly ISqlSugarRepository _projectRepository; private readonly ISqlSugarRepository _ruleRepository; private readonly ISqlSugarRepository _assignmentRepository; private readonly ISqlSugarRepository _eventRepository; private readonly IIdGenerator _idGenerator; private readonly AuthManager _authManager; public JudgeAssignmentService(ISqlSugarRepository eventRefereeRepository, ISqlSugarRepository refereeRepository, ISqlSugarRepository sessionRepository, ISqlSugarRepository venueRepository, ISqlSugarRepository eventProjectRepository, ISqlSugarRepository projectRepository, ISqlSugarRepository ruleRepository, ISqlSugarRepository assignmentRepository, ISqlSugarRepository 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; } /// /// 获取赛事裁判列表 /// [HttpGet("event-referees/{eventId}"), DisplayName("获取赛事裁判列表")] public async Task> 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(); } /// /// 获取裁判分配看板 /// [HttpGet("board/{eventId}"), DisplayName("获取裁判分配看板")] public async Task 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; } /// /// 获取可分配裁判(过滤同时段已分配的裁判) /// [HttpGet("candidates/{eventId}/{sessionId}"), DisplayName("获取可分配裁判")] public async Task> 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(); } /// /// 保存场次裁判分配。 /// 同一裁判类型可分配多名裁判,人数上下限由计分规则的裁判席位配置决定。 /// /// 场次裁判分配信息。 [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()); } /// /// 提交赛事全部场次的裁判分配。 /// 首次提交时,将本赛事所有裁判分配草稿更新为已提交,并将对应的准备中场次更新为就绪; /// 赛前修改已提交的分配后,可调用本接口重新提交,本次仅更新发生修改的草稿场次,其他已提交场次保持不变。 /// 提交前会校验赛事已生成有效场次、每个有效场次均已分配裁判,且所有场次仍处于准备中或就绪状态。 /// 任一场次进入检录中、进行中或赛后状态后,均不允许再提交或重新提交裁判分配。 /// /// 赛事裁判分配提交信息。 [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); } /// /// 生成裁判报名码,确保唯一性 /// [HttpPost("registration-code/{eventId}"), DisplayName("生成裁判报名码")] public async Task 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; } /// /// 组装赛事裁判输出,裁判档案缺失时用占位值兜底 /// 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() .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 ? "小程序报名" : "后台添加" }; } /// /// 组装看板上的裁判席位输出,裁判类型名称取自计分规则 /// private static JudgeAssignmentItemOutput ToAssignmentItemOutput(EventJudgeAssignment assignment, YD_Event.Core.Entities.Referee? referee, Dictionary 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 }; } /// /// 按裁判类型给提交的分配记录归组,同一类型可以出现多条,表示该类型分配了多名裁判。 /// 前端既可能传裁判类型编码(DP),也可能传历史席位编码(DP1),统一归一到规则里的类型编码。 /// private static Dictionary> GroupByRoleType( List assignments, List slots) { var slotMap = slots.ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase); var grouped = new Dictionary>(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(); grouped[roleType] = list; } list.Add(item); } return grouped; } /// /// 校验每个裁判类型分配的人数是否落在计分规则给出的上下限内 /// private static void EnsureRefereeCount(List slots, Dictionary> 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} 名裁判"); } } /// /// 把归组后的分配记录展开成待落库的裁判席位,并校验裁判资格与重复分配。 /// 席位编码规则:类型限 1 人时直接用类型编码(T),可分配多人时按顺序追加序号(DP1、DP2)。 /// private static List BuildSeats(List slots, Dictionary> grouped, List candidates) { var seats = new List(); 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; } /// /// 解析计分规则的裁判席位配置,与裁判端评分共用一份解析逻辑 /// private static List ParseRoleSections(string? schemaJson) => JudgeRoleSchemaService.ParseSections(schemaJson); /// /// 把席位编码归一为裁判类型编码,兼容 DP1、P2 这类带席位序号的历史编码 /// private static string NormalizeRoleType(string? code) => JudgeRoleSchemaService.NormalizeRoleType(code); /// /// 裁判类型编码对应的执裁资格位,未单独列出的类型都按难度裁判处理 /// private static int RoleFlag(string roleType) => JudgeRoleSchemaService.RoleFlagOf(roleType); /// /// 一条待落库的裁判席位 /// private class JudgeSeat { /// /// 席位所属的裁判类型 /// public JudgeRoleSlotOutput Slot { get; set; } = null!; /// /// 席位编码,如 T、DP1、DP2 /// public string SeatCode { get; set; } = null!; /// /// 该席位的执裁裁判 /// public EventRefereeOutput Candidate { get; set; } = null!; } }