using Microsoft.Extensions.Logging; using YD_Event.Application.Auth; using YD_Event.Application.Client.Dtos; using YD_Event.Application.Event; using YD_Event.Application.Sms; namespace YD_Event.Application.Client; /// 小程序检录员按秩序册列进行顺序检录。 [ApiDescriptionSettings("微信小程序接口")] [Authorize] public partial class CheckinController : IDynamicApiController { private readonly ISqlSugarRepository _userRepository; private readonly ISqlSugarRepository _eventRepository; private readonly ISqlSugarRepository _taskRepository; private readonly ISqlSugarRepository _recordRepository; private readonly ISqlSugarRepository _personRepository; private readonly ISqlSugarRepository _registrationRepository; private readonly ISqlSugarRepository _projectRepository; private readonly ISqlSugarRepository _teamMemberRepository; private readonly CheckinScheduleService _scheduleService; private readonly AliyunSmsService _smsService; private readonly ILogger _logger; private readonly AuthManager _authManager; public CheckinController( ISqlSugarRepository userRepository, ISqlSugarRepository eventRepository, ISqlSugarRepository taskRepository, ISqlSugarRepository recordRepository, ISqlSugarRepository personRepository, ISqlSugarRepository registrationRepository, ISqlSugarRepository projectRepository, ISqlSugarRepository teamMemberRepository, CheckinScheduleService scheduleService, AliyunSmsService smsService, ILogger logger, AuthManager authManager) { _userRepository = userRepository; _eventRepository = eventRepository; _taskRepository = taskRepository; _recordRepository = recordRepository; _personRepository = personRepository; _registrationRepository = registrationRepository; _projectRepository = projectRepository; _teamMemberRepository = teamMemberRepository; _scheduleService = scheduleService; _smsService = smsService; _logger = logger; _authManager = authManager; } [HttpGet("current")] [DisplayName("获取当前赛事检录任务")] public async Task GetCurrent( [FromQuery] long? eventProjectId = null, [FromQuery] long? groupId = null) { var userId = await EnsureCheckerAsync(); var task = await GetCurrentTaskAsync(userId); return task == null ? null : await BuildContextAsync(task, eventProjectId); } [HttpPost("status")] [UnitOfWork] [DisplayName("更新秩序册列当前检次状态")] public async Task UpdateStatus([FromBody] UpdateCheckinStatusInput input) { var checkerUserId = await EnsureCheckerAsync(); var task = await GetCurrentTaskAsync(checkerUserId); if (task == null) throw Oops.Bah("当前账号尚未由后台绑定赛事检录任务"); await EnsureEventCanCheckinAsync(task.EventId); var options = await _scheduleService.GetOptionsAsync(task.EventId); var unit = CheckinScheduleService.ResolveUnit(options, input.UnitKey); if (task.CheckinNo > 1) { var previousTask = await GetEventTaskAsync(task.EventId, task.CheckinNo - 1); var previousPassed = previousTask != null && await HasCheckedUnitRecordAsync(previousTask.Id, unit); if (!previousPassed) throw Oops.Bah($"该秩序册列尚未通过{ToCheckinName(task.CheckinNo - 1)},不能进行{ToCheckinName(task.CheckinNo)}"); } if (!input.Checked) { var laterTasks = await _taskRepository.AsQueryable() .Where(x => x.EventId == task.EventId && x.CheckinNo > task.CheckinNo && x.TaskStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .ToListAsync(); var laterTaskIds = laterTasks.Select(x => x.Id).ToList(); if (laterTaskIds.Count > 0 && await HasCheckedUnitRecordAsync(laterTaskIds, unit)) throw Oops.Bah("该秩序册列已通过后续检录,不能取消当前检录"); } var record = await GetUnitRecordAsync(task.Id, unit); if (record == null) { record = new CheckinRecord { TaskId = task.Id, Round = task.CheckinNo, SessionKey = unit.UnitKey, SessionOrder = unit.Order, GroupId = unit.GroupId, RegistrationPersonId = unit.RegistrationPersonIds.FirstOrDefault(), CheckerUserId = checkerUserId, Checked = input.Checked, CheckedTime = input.Checked ? DateTime.Now : null, CreatedUserId = checkerUserId, CreatedTime = DateTime.Now }; await _recordRepository.InsertAsync(record); } else { record.SessionKey = unit.UnitKey; record.Round = task.CheckinNo; record.SessionOrder = unit.Order; record.GroupId = unit.GroupId; record.CheckerUserId = checkerUserId; record.Checked = input.Checked; record.CheckedTime = input.Checked ? DateTime.Now : null; record.UpdatedUserId = checkerUserId; record.UpdatedTime = DateTime.Now; await _recordRepository.UpdateAsync(record); } // 保持页面当前项目筛选;UnitKey 已独立校验检录项归属。 return await BuildContextAsync(task, input.EventProjectId); } [HttpPost("start")] [UnitOfWork] [DisplayName("开始检录并通知报名人员")] public async Task Start([FromBody] StartCheckinInput input) { var checkerUserId = await EnsureCheckerAsync(); var task = await GetCurrentTaskAsync(checkerUserId); if (task == null) throw Oops.Bah("当前账号尚未由后台绑定赛事检录任务"); await EnsureEventCanCheckinAsync(task.EventId); var options = await _scheduleService.GetOptionsAsync(task.EventId); var project = options.Projects.FirstOrDefault(x => x.EventProjectId == input.EventProjectId) ?? throw Oops.Bah("所选参赛项目不属于当前赛事秩序册"); var units = options.Units .Where(x => x.EventProjectId == input.EventProjectId) .OrderBy(x => x.Order).ToList(); if (units.Count == 0) throw Oops.Bah("所选项目没有可检录的参赛队伍"); // 记录当前检录进度,供运动员端与投屏读取"当前检录项目"。 task.CurrentEventProjectId = input.EventProjectId; task.CurrentGroupId = null; task.CurrentGroupName = "全部组别"; task.GroupOrder = project.Groups.Count == 0 ? 0 : project.Groups.Min(x => x.Order); task.TotalGroups = project.Groups.Count; task.NextGroupName = null; task.CurrentSessionKey = units[0].UnitKey; task.CurrentSessionOrder = units[0].Order; task.CurrentSessionName = project.ProjectName; task.CurrentSessionVenue = units[0].VenueName; task.UpdatedUserId = checkerUserId; task.UpdatedTime = DateTime.Now; await _taskRepository.UpdateAsync(task); var total = 0; var success = 0; var failed = 0; if (task.CheckinNo == 1) { var projectEntity = await _projectRepository.GetFirstAsync(x => x.Id == project.ProjectId && !x.DeleteMark && x.Status == AvailabilityStatus.Enable); if (projectEntity == null) throw Oops.Bah("参赛项目不存在或已停用"); var noticeName = project.ProjectName; (total, success, failed) = await NotifyUnitsAsync( units, projectEntity.ProjectType, options.EventName, noticeName); } var context = await BuildContextAsync(task, input.EventProjectId); return new StartCheckinOutput { TaskId = task.Id, EventProjectId = project.EventProjectId, ProjectName = project.ProjectName, GroupId = 0, GroupName = "全部组别", UnitCount = units.Count, NoticeTotal = total, SmsSuccessCount = success, SmsFailedCount = failed, Message = task.CheckinNo != 1 ? $"已开始{ToCheckinName(task.CheckinNo)}" : success > 0 ? $"已开始{ToCheckinName(task.CheckinNo)},已通知{success}人,{failed}人通知失败" : $"已开始{ToCheckinName(task.CheckinNo)},共{total}人待通知", Context = context }; } [HttpPost("scan")] [UnitOfWork] [DisplayName("扫码检录")] public async Task Scan([FromBody] ScanCheckinInput input) { var checkerUserId = await EnsureCheckerAsync(); var task = await GetCurrentTaskAsync(checkerUserId); if (task == null) throw Oops.Bah("当前账号尚未由后台绑定赛事检录任务"); await EnsureEventCanCheckinAsync(task.EventId); var code = input.Code?.Trim() ?? string.Empty; if (code.Length == 0) throw Oops.Bah("扫码内容不能为空"); var options = await _scheduleService.GetOptionsAsync(task.EventId); var unit = ResolveScannedUnit(options, code, out var athlete); if (unit == null) return new ScanCheckinOutput { Success = false, Message = "扫码内容与当前赛事秩序册不匹配,请核对号码布" }; await EnsurePreviousPassedAsync(task, unit); await UpsertRecordAsync(task, unit, checkerUserId, true); var context = await BuildContextAsync(task, input.EventProjectId ?? unit.EventProjectId); return new ScanCheckinOutput { Success = true, Message = $"{ToCheckinName(task.CheckinNo)}检录成功", UnitKey = unit.UnitKey, ProjectName = unit.ProjectName, GroupName = unit.GroupName, UnitName = string.IsNullOrWhiteSpace(unit.TeamName) ? unit.UnitName : unit.TeamName, AthleteName = athlete?.Name ?? string.Join(", ", unit.Athletes.Select(x => x.Name)), BibNo = athlete?.BibNo ?? string.Empty, Context = context }; } [HttpPost("on-stage")] [UnitOfWork] [DisplayName("标记参赛队伍上场")] public async Task OnStage([FromBody] CheckinOnStageInput input) { var checkerUserId = await EnsureCheckerAsync(); var task = await GetCurrentTaskAsync(checkerUserId); if (task == null) throw Oops.Bah("当前账号尚未由后台绑定赛事检录任务"); await EnsureEventCanCheckinAsync(task.EventId); var activeTasks = await GetActiveEventTasksAsync(task.EventId); var lastCheckinNo = activeTasks.Count == 0 ? task.CheckinNo : activeTasks.Max(x => x.CheckinNo); if (task.CheckinNo != lastCheckinNo) throw Oops.Bah($"上场由{ToCheckinName(lastCheckinNo)}检录员操作"); var options = await _scheduleService.GetOptionsAsync(task.EventId); var unit = CheckinScheduleService.ResolveUnit(options, input.UnitKey); await EnsurePreviousPassedAsync(task, unit); await UpsertRecordAsync(task, unit, checkerUserId, true); // 上场后把当前列同步到任务,供运动员端展示"当前正在比赛"。 task.CurrentEventProjectId = unit.EventProjectId; task.CurrentGroupId = unit.GroupId; task.CurrentGroupName = unit.GroupName; task.CurrentSessionKey = unit.UnitKey; task.CurrentSessionOrder = unit.Order; task.CurrentSessionName = $"{unit.ProjectName}-{unit.GroupName}"; task.CurrentSessionVenue = unit.VenueName; task.UpdatedUserId = checkerUserId; task.UpdatedTime = DateTime.Now; await _taskRepository.UpdateAsync(task); return await BuildContextAsync(task, input.EventProjectId); } /// 按扫码内容定位秩序册列;支持号码布、报名人员ID和姓名。 private static CheckinOrderBookUnitDto? ResolveScannedUnit( CheckinEventOptionsDto options, string code, out CheckinAthleteDto? athlete) { athlete = null; foreach (var unit in options.Units.OrderBy(x => x.Order)) { var matched = unit.Athletes.FirstOrDefault(x => x.BibNo == code || x.Id.ToString() == code || x.Name == code); if (matched != null) { athlete = matched; return unit; } } // 兼容团队二维码:扫码内容直接是秩序册列标识。 return options.Units.FirstOrDefault(x => x.UnitKey == code); } /// 检录顺序卡控:非一检必须先通过上一检次。 private async Task EnsurePreviousPassedAsync(CheckinTask task, CheckinOrderBookUnitDto unit) { if (task.CheckinNo <= 1) return; var previousTask = await GetEventTaskAsync(task.EventId, task.CheckinNo - 1); var previousPassed = previousTask != null && await HasCheckedUnitRecordAsync(previousTask.Id, unit); if (!previousPassed) throw Oops.Bah($"该秩序册列尚未通过{ToCheckinName(task.CheckinNo - 1)},不能进行{ToCheckinName(task.CheckinNo)}"); } private async Task UpsertRecordAsync( CheckinTask task, CheckinOrderBookUnitDto unit, long checkerUserId, bool isChecked) { var record = await GetUnitRecordAsync(task.Id, unit); if (record == null) { await _recordRepository.InsertAsync(new CheckinRecord { TaskId = task.Id, Round = task.CheckinNo, SessionKey = unit.UnitKey, SessionOrder = unit.Order, GroupId = unit.GroupId, RegistrationPersonId = unit.RegistrationPersonIds.FirstOrDefault(), CheckerUserId = checkerUserId, Checked = isChecked, CheckedTime = isChecked ? DateTime.Now : null, CreatedUserId = checkerUserId, CreatedTime = DateTime.Now }); return; } record.SessionKey = unit.UnitKey; record.Round = task.CheckinNo; record.SessionOrder = unit.Order; record.GroupId = unit.GroupId; record.CheckerUserId = checkerUserId; record.Checked = isChecked; record.CheckedTime = isChecked ? DateTime.Now : null; record.UpdatedUserId = checkerUserId; record.UpdatedTime = DateTime.Now; await _recordRepository.UpdateAsync(record); } /// /// 获取指定检录对象的记录;个人项目同时兼容未包含人员 ID 的历史检录键。 /// private async Task GetUnitRecordAsync(long taskId, CheckinOrderBookUnitDto unit) { if (unit.IsTeamProject) { return await _recordRepository.GetFirstAsync(x => x.TaskId == taskId && x.SessionKey == unit.UnitKey && !x.DeleteMark); } var legacyUnitKey = GetLegacyUnitKey(unit); var registrationPersonId = unit.RegistrationPersonIds.FirstOrDefault(); return await _recordRepository.GetFirstAsync(x => x.TaskId == taskId && !x.DeleteMark && (x.SessionKey == unit.UnitKey || (x.SessionKey == legacyUnitKey && x.RegistrationPersonId == registrationPersonId))); } /// /// 判断单个检录任务中,指定检录对象是否已经通过检录。 /// private async Task HasCheckedUnitRecordAsync(long taskId, CheckinOrderBookUnitDto unit) => await HasCheckedUnitRecordAsync(new List { taskId }, unit); /// /// 判断多个后续检录任务中,指定检录对象是否已经通过检录。 /// private async Task HasCheckedUnitRecordAsync( List taskIds, CheckinOrderBookUnitDto unit) { if (unit.IsTeamProject) { return await _recordRepository.IsAnyAsync(x => taskIds.Contains(x.TaskId) && x.SessionKey == unit.UnitKey && x.Checked && !x.DeleteMark); } var legacyUnitKey = GetLegacyUnitKey(unit); var registrationPersonId = unit.RegistrationPersonIds.FirstOrDefault(); return await _recordRepository.IsAnyAsync(x => taskIds.Contains(x.TaskId) && x.Checked && !x.DeleteMark && (x.SessionKey == unit.UnitKey || (x.SessionKey == legacyUnitKey && x.RegistrationPersonId == registrationPersonId))); } /// /// 获取旧版检录键。旧版仅包含赛事项目 ID 和报名项目 ID。 /// private static string GetLegacyUnitKey(CheckinOrderBookUnitDto unit) => CheckinScheduleService.BuildUnitKey(unit.EventProjectId, unit.RegistrationItemId); /// /// 将检录记录映射到当前检录键。旧版个人项目记录按保存的报名人员 ID 精确归属, /// 防止同一单位账号报名的其他人员被一并判定为已检录。 /// private static string? ResolveRecordUnitKey( IReadOnlyCollection units, CheckinRecord record) { var exactUnit = units.FirstOrDefault(x => x.UnitKey == record.SessionKey); if (exactUnit != null) return exactUnit.UnitKey; return units.FirstOrDefault(x => !x.IsTeamProject && GetLegacyUnitKey(x) == record.SessionKey && x.RegistrationPersonIds.Contains(record.RegistrationPersonId))?.UnitKey; } /// /// 对报名了所选项目的人员发送检录通知;单人失败不影响整体流程。 /// 个人项目:通知运动员本人(从 EventRegistrationPerson 取)。 /// 团队项目:通知教练和领队(从 TeamMember 取,因为报名表中不存储教练/领队的 RegistrationItemId)。 /// 短信内容:您报名的{赛事名称}正在检录,当前检录至{项目名称}第{x}场次,您所在第{y}场次,请提前前往检录区做好准备。 /// private async Task<(int Total, int Success, int Failed)> NotifyUnitsAsync( List units, ProjectTypeEnum projectType, string eventName, string projectName) { var isIndividualProject = projectType is ProjectTypeEnum.PersonalSpeed or ProjectTypeEnum.PersonalFreestyle; var isTeamProject = projectType is ProjectTypeEnum.TeamSpeed or ProjectTypeEnum.TeamFreestyle; if (!isIndividualProject && !isTeamProject) throw Oops.Bah("当前项目类型不支持检录短信通知"); // 收集通知接收人:{ Phone, SessionOrder } List<(string? Phone, int SessionOrder)> recipients; if (isIndividualProject) { recipients = await CollectIndividualRecipientsAsync(units); } else { recipients = await CollectTeamCoachLeaderRecipientsAsync(units); } // 按手机号去重(同一教练带多个队取最早场次) var missingPhoneCount = recipients.Count(x => string.IsNullOrWhiteSpace(x.Phone)); var validRecipients = recipients .Where(x => !string.IsNullOrWhiteSpace(x.Phone)) .GroupBy(x => x.Phone!.Trim()) .Select(g => g.OrderBy(x => x.SessionOrder).First()) .ToList(); // 当前检录进度:获取该项目在秩序册中的起始场次 var firstSessionIndex = units.Select(x => x.SessionIndex).FirstOrDefault(x => x > 0); if (firstSessionIndex <= 0) firstSessionIndex = 1; var currentSession = $"{projectName}第{firstSessionIndex}场次"; var success = 0; var failed = missingPhoneCount; foreach (var recipient in validRecipients) { try { var sent = await _smsService.SendCheckinNotice( recipient.Phone!.Trim(), eventName, currentSession, recipient.SessionOrder); if (sent) success++; else failed++; } catch (Exception ex) { failed++; _logger.LogWarning(ex, "检录通知发送失败:{EventName} {Project} {Phone}", eventName, projectName, recipient.Phone); } } return (validRecipients.Count + missingPhoneCount, success, failed); } /// 个人项目:取 RegistrationItemId 下的运动员及其场次号。 private async Task> CollectIndividualRecipientsAsync( List units) { // 构建 personId/userId → 场次号 的映射(优先使用秩序册编排的 SessionIndex,缺失时回退到序号) var userIdToOrder = new Dictionary(); var personIdToOrder = new Dictionary(); for (var i = 0; i < units.Count; i++) { var sessionOrder = units[i].SessionIndex > 0 ? units[i].SessionIndex : (i + 1); foreach (var athlete in units[i].Athletes) { if (athlete.UserId > 0 && !userIdToOrder.ContainsKey(athlete.UserId)) userIdToOrder[athlete.UserId] = sessionOrder; if (!personIdToOrder.ContainsKey(athlete.Id)) personIdToOrder[athlete.Id] = sessionOrder; } } var registrationItemIds = units.Select(x => x.RegistrationItemId).Distinct().ToList(); if (registrationItemIds.Count == 0) return new(); var persons = await _personRepository.AsQueryable() .Where(x => registrationItemIds.Contains(x.RegistrationItemId) && x.PersonType == PersonType.Athlete && !x.DeleteMark) .ToListAsync(); persons = persons .GroupBy(x => x.UserId > 0 ? $"user:{x.UserId}" : $"person:{x.Id}") .Select(x => x.First()) .ToList(); var userIds = persons.Where(x => x.UserId > 0).Select(x => x.UserId).Distinct().ToList(); var userMap = await LoadUserMapAsync(userIds); return persons.Select(p => { userMap.TryGetValue(p.UserId, out var user); var phone = !string.IsNullOrWhiteSpace(p.Phone) ? p.Phone : user?.Mobile; // 取该人的场次号 var order = 0; if (p.UserId > 0) userIdToOrder.TryGetValue(p.UserId, out order); if (order == 0) personIdToOrder.TryGetValue(p.Id, out order); if (order == 0) order = 1; // 兜底 return (phone, order); }).ToList(); } /// /// 团队项目:运动员 → TeamMember → 同团队教练/领队 → AppUser。 /// 每位教练/领队的场次号取其所带队伍中最早的场次。 /// private async Task> CollectTeamCoachLeaderRecipientsAsync( List units) { // 构建 athleteUserId → 最早场次号(优先使用 SessionIndex) var athleteToOrder = new Dictionary(); for (var i = 0; i < units.Count; i++) { var sessionOrder = units[i].SessionIndex > 0 ? units[i].SessionIndex : (i + 1); foreach (var a in units[i].Athletes.Where(a => a.UserId > 0)) { if (!athleteToOrder.ContainsKey(a.UserId)) athleteToOrder[a.UserId] = sessionOrder; } } var athleteUserIds = athleteToOrder.Keys.ToList(); if (athleteUserIds.Count == 0) return new(); // 步骤1:运动员 → TeamMember(运动员身份) var athleteMembers = await _teamMemberRepository.AsQueryable() .Where(x => athleteUserIds.Contains(x.UserId) && !x.DeleteMark) .ToListAsync(); var teamIds = athleteMembers.Select(x => x.TeamId).Distinct().ToList(); if (teamIds.Count == 0) return new(); // 构建 teamId → 最早场次号(取该队运动员中最早的场次) var teamToOrder = new Dictionary(); foreach (var am in athleteMembers) { if (athleteToOrder.TryGetValue(am.UserId, out var order)) { if (!teamToOrder.ContainsKey(am.TeamId) || teamToOrder[am.TeamId] > order) teamToOrder[am.TeamId] = order; } } // 步骤2:同 TeamId 的教练/领队 var coachLeaders = await _teamMemberRepository.AsQueryable() .Where(x => teamIds.Contains(x.TeamId) && (x.PersonType == PersonType.Coach || x.PersonType == PersonType.Leader) && !x.DeleteMark) .ToListAsync(); if (coachLeaders.Count == 0) { // 降级:从 EventRegistrationPerson 查同 RegistrationId 下的教练/领队 return await FallbackCollectCoachLeaderAsync(units); } // 教练/领队 UserId → 最早场次号 var coachOrderMap = new Dictionary(); foreach (var cl in coachLeaders) { if (teamToOrder.TryGetValue(cl.TeamId, out var order)) { if (!coachOrderMap.ContainsKey(cl.UserId) || coachOrderMap[cl.UserId] > order) coachOrderMap[cl.UserId] = order; } } var userMap = await LoadUserMapAsync(coachOrderMap.Keys.ToList()); return coachOrderMap.Select(kv => { userMap.TryGetValue(kv.Key, out var user); return (user?.Mobile, kv.Value); }).ToList(); } /// 降级路径:从 EventRegistrationPerson 查教练/领队(默认场次号 1)。 private async Task> FallbackCollectCoachLeaderAsync( List units) { var registrationItemIds = units.Select(x => x.RegistrationItemId).Distinct().ToList(); if (registrationItemIds.Count == 0) return new(); var registrationIds = await _personRepository.AsQueryable() .Where(x => registrationItemIds.Contains(x.RegistrationItemId) && !x.DeleteMark) .Select(x => x.RegistrationId) .Distinct() .ToListAsync(); if (registrationIds.Count == 0) return new(); var fallbackPersons = await _personRepository.AsQueryable() .Where(x => registrationIds.Contains(x.RegistrationId) && (x.PersonType == PersonType.Coach || x.PersonType == PersonType.Leader) && !x.DeleteMark) .ToListAsync(); var coachUserIds = fallbackPersons.Where(p => p.UserId > 0).Select(p => p.UserId).Distinct().ToList(); if (coachUserIds.Count == 0) return new(); var userMap = await LoadUserMapAsync(coachUserIds); var defaultOrder = units.Select(x => x.SessionIndex).FirstOrDefault(x => x > 0); if (defaultOrder <= 0) defaultOrder = 1; return coachUserIds.Select(uid => { userMap.TryGetValue(uid, out var user); return (user?.Mobile, defaultOrder); }).ToList(); } /// 批量加载 AppUser 并返回字典。 private async Task> LoadUserMapAsync(List userIds) { if (userIds.Count == 0) return new(); var users = await _userRepository.AsQueryable() .Where(x => userIds.Contains(x.Id) && !x.DeleteMark) .ToListAsync(); return users.ToDictionary(x => x.Id); } private async Task BuildContextAsync(CheckinTask task, long? eventProjectId) { var evt = await _eventRepository.GetFirstAsync(x => x.Id == task.EventId && !x.DeleteMark); var eventLockReason = "赛事未开始,不允许检录"; var eventCanCheckin = evt != null && IsEventCanCheckin(evt, out eventLockReason); var calculatedStatus = evt != null ? EventStatusUpdateService.CalculateEventStatus(evt, DateTime.Now) : EventStatus.NotStarted; var options = await _scheduleService.GetOptionsAsync(task.EventId); var orderedProjects = options.Projects .OrderBy(project => options.Units .Where(unit => unit.EventProjectId == project.EventProjectId) .Select(unit => unit.Order) .DefaultIfEmpty(int.MaxValue) .Min()) .ToList(); var selectedProject = orderedProjects.FirstOrDefault(x => x.EventProjectId == eventProjectId) ?? orderedProjects.FirstOrDefault(); if (selectedProject == null) throw Oops.Bah("当前赛事秩序册没有可检录项目"); var allUnits = options.Units.OrderBy(x => x.Order).ToList(); var units = allUnits .Where(x => x.EventProjectId == selectedProject.EventProjectId) .OrderBy(x => x.Order).ToList(); var allUnitKeys = allUnits.Select(x => x.UnitKey).ToHashSet(); var records = await _recordRepository.AsQueryable() .Where(x => x.TaskId == task.Id && x.SessionKey != null && !x.DeleteMark) .ToListAsync(); var checkedKeys = records.Where(x => x.Checked) .Select(x => ResolveRecordUnitKey(allUnits, x)) .Where(x => x != null) .Select(x => x!) .ToHashSet(); var activeTasks = await GetActiveEventTasksAsync(task.EventId); // 三检通过视为已上场;仅配置两检时,二检通过即视为已上场。 var onStageKeys = await GetOnStageKeysAsync(activeTasks, allUnits); HashSet previousPassedKeys; if (task.CheckinNo == 1) { previousPassedKeys = allUnitKeys; } else { var previousTask = await GetEventTaskAsync(task.EventId, task.CheckinNo - 1); var previousRecords = previousTask == null ? new List() : await _recordRepository.AsQueryable() .Where(x => x.TaskId == previousTask.Id && x.SessionKey != null && x.Checked && !x.DeleteMark) .ToListAsync(); previousPassedKeys = previousRecords .Select(x => ResolveRecordUnitKey(allUnits, x)) .Where(x => x != null) .Select(x => x!) .ToHashSet(); } var allStatusUnits = allUnits.Select(unit => { var previousPassed = eventCanCheckin && previousPassedKeys.Contains(unit.UnitKey); return new CheckinOrderBookUnitStatusDto { UnitKey = unit.UnitKey, Order = unit.Order, SessionIndex = unit.SessionIndex, VenueIndex = unit.VenueIndex, VenueName = unit.VenueName, EventProjectId = unit.EventProjectId, ProjectId = unit.ProjectId, ProjectName = unit.ProjectName, GroupId = unit.GroupId, GroupName = unit.GroupName, RegistrationItemId = unit.RegistrationItemId, TeamName = unit.TeamName, UnitName = unit.UnitName, RegistrationPersonIds = unit.RegistrationPersonIds, Athletes = unit.Athletes, Checked = checkedKeys.Contains(unit.UnitKey), PreviousPassed = previousPassed, CanCheck = eventCanCheckin && previousPassed, LockReason = !eventCanCheckin ? eventLockReason : (previousPassed ? string.Empty : $"待通过{ToCheckinName(task.CheckinNo - 1)}"), OnStage = onStageKeys.Contains(unit.UnitKey) }; }).ToList(); var statusUnits = allStatusUnits .Where(x => x.EventProjectId == selectedProject.EventProjectId) .ToList(); // 为项目、组别选择区计算当前检次概况,全部完成时由小程序显示绿色状态。 foreach (var project in orderedProjects) { var projectUnits = allStatusUnits.Where(x => x.EventProjectId == project.EventProjectId).ToList(); ApplyProgress(projectUnits, out var projectTotal, out var projectChecked, out var projectLocked, out var projectPercent, out var projectCompleted); project.TotalUnits = projectTotal; project.CheckedUnits = projectChecked; project.LockedUnits = projectLocked; project.ProgressPercent = projectPercent; project.IsCompleted = projectCompleted; foreach (var group in project.Groups) { var groupUnits = projectUnits.Where(x => x.GroupId == group.Id).ToList(); ApplyProgress(groupUnits, out var groupTotal, out var groupChecked, out var groupLocked, out var groupPercent, out var groupCompleted); group.TotalUnits = groupTotal; group.CheckedUnits = groupChecked; group.LockedUnits = groupLocked; group.ProgressPercent = groupPercent; group.IsCompleted = groupCompleted; } } return new CheckinContextDto { TaskId = task.Id, EventId = task.EventId, EventName = options.EventName, TotalCheckins = activeTasks.Count, CurrentCheckinNo = task.CheckinNo, CurrentCheckinName = ToCheckinName(task.CheckinNo), TotalRounds = activeTasks.Count, CurrentRound = task.CheckinNo, RoundName = ToCheckinName(task.CheckinNo), SelectedEventProjectId = selectedProject.EventProjectId, SelectedGroupId = 0, CurrentGroupId = null, CurrentGroupName = "全部组别", Projects = orderedProjects, Units = statusUnits, PendingUnits = statusUnits.Where(x => x.CanCheck && !x.Checked).ToList(), // 已检录跟随当前选中的项目,覆盖该项目全部组别。 CheckedUnits = statusUnits.Where(x => x.Checked).ToList(), LockedUnits = statusUnits.Where(x => !x.CanCheck).ToList(), CanCheck = eventCanCheckin, CheckLockReason = eventCanCheckin ? string.Empty : eventLockReason, EventStatus = (int)calculatedStatus, EventStatusText = calculatedStatus.Description(), Rounds = activeTasks.Select(x => new CheckinRoundOptionDto { Round = x.CheckinNo, RoundName = ToCheckinName(x.CheckinNo) }).ToList() }; } private async Task GetCurrentTaskAsync(long userId) => await _taskRepository.AsQueryable() .InnerJoin(_eventRepository.AsQueryable(), (task, evt) => task.EventId == evt.Id) .Where((task, evt) => task.CheckerUserId == userId && task.TaskStatus == 1 && task.Status == AvailabilityStatus.Enable && !task.DeleteMark && evt.EventStatus != EventStatus.Ended && !evt.DeleteMark) .OrderByDescending((task, evt) => task.UpdatedTime) .OrderByDescending((task, evt) => task.CreatedTime) .Select((task, evt) => task) .FirstAsync(); private async Task GetEventTaskAsync(long eventId, int checkinNo) => await _taskRepository.GetFirstAsync(x => x.EventId == eventId && x.CheckinNo == checkinNo && x.TaskStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark); private async Task> GetActiveEventTasksAsync(long eventId) => await _taskRepository.AsQueryable() .Where(x => x.EventId == eventId && x.TaskStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark) .OrderBy(x => x.CheckinNo).ToListAsync(); /// 三检通过即为已上场;仅配置两检时由二检标记上场。 private async Task> GetOnStageKeysAsync( List activeTasks, IReadOnlyCollection units) { if (activeTasks.Count == 0) return new HashSet(); var onStageCheckinNo = ResolveOnStageCheckinNo(activeTasks); var onStageTask = activeTasks.First(x => x.CheckinNo == onStageCheckinNo); var records = await _recordRepository.AsQueryable() .Where(x => x.TaskId == onStageTask.Id && x.SessionKey != null && x.Checked && !x.DeleteMark) .ToListAsync(); return records.Select(x => ResolveRecordUnitKey(units, x)) .Where(x => x != null) .Select(x => x!) .ToHashSet(); } /// 上场检次固定为三检;未配置三检但配置了二检时使用二检。 private static int ResolveOnStageCheckinNo(List activeTasks) { if (activeTasks.Any(x => x.CheckinNo == 3)) return 3; if (activeTasks.Any(x => x.CheckinNo == 2)) return 2; return activeTasks.Count == 0 ? 1 : activeTasks.Min(x => x.CheckinNo); } /// 校验赛事状态是否允许检录操作,不允许时抛出业务异常。 private async Task EnsureEventCanCheckinAsync(long eventId) { var evt = await _eventRepository.GetFirstAsync(x => x.Id == eventId && !x.DeleteMark); if (evt == null) throw Oops.Bah("赛事不存在"); if (!IsEventCanCheckin(evt, out var reason)) throw Oops.Bah(reason); } /// 判断赛事是否允许检录:仅比赛中(Ongoing)允许。 private static bool IsEventCanCheckin(EventInfo evt, out string lockReason) { var now = DateTime.Now; var status = EventStatusUpdateService.CalculateEventStatus(evt, now); if (status == EventStatus.Ongoing || evt.EventStatus == EventStatus.Ongoing) { lockReason = string.Empty; return true; } var isEnded = status == EventStatus.Ended || evt.EventStatus == EventStatus.Ended; lockReason = isEnded ? "赛事已结束,不允许检录" : "赛事未开始,不允许检录"; return false; } private async Task EnsureCheckerAsync() { var userId = _authManager.UserId; var user = await _userRepository.GetFirstAsync(x => x.Id == userId && !x.DeleteMark); if (user == null) throw Oops.Bah("用户不存在"); if (user.MiniProgramRole != MiniProgramRole.Checker) throw Oops.Bah("当前账号不是检录员身份"); return userId; } private static string ToCheckinName(int checkinNo) => checkinNo switch { 1 => "一检", 2 => "二检", 3 => "三检", _ => $"第{checkinNo}检" }; private static void ApplyProgress( List units, out int total, out int checkedCount, out int lockedCount, out int percent, out bool completed) { total = units.Count; checkedCount = units.Count(x => x.Checked); lockedCount = units.Count(x => !x.CanCheck); percent = total == 0 ? 0 : (int)Math.Round(checkedCount * 100d / total); completed = total > 0 && checkedCount == total; } }