2026-09-21 15:07:54 +08:00

890 lines
40 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 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;
/// <summary>小程序检录员按秩序册列进行顺序检录。</summary>
[ApiDescriptionSettings("微信小程序接口")]
[Authorize]
public partial class CheckinController : IDynamicApiController
{
private readonly ISqlSugarRepository<YD_Event.Core.Entities.AppUser> _userRepository;
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
private readonly ISqlSugarRepository<CheckinTask> _taskRepository;
private readonly ISqlSugarRepository<CheckinRecord> _recordRepository;
private readonly ISqlSugarRepository<EventRegistrationPerson> _personRepository;
private readonly ISqlSugarRepository<EventRegistration> _registrationRepository;
private readonly ISqlSugarRepository<YD_Event.Core.Entities.Project> _projectRepository;
private readonly ISqlSugarRepository<TeamMember> _teamMemberRepository;
private readonly CheckinScheduleService _scheduleService;
private readonly AliyunSmsService _smsService;
private readonly ILogger<CheckinController> _logger;
private readonly AuthManager _authManager;
public CheckinController(
ISqlSugarRepository<YD_Event.Core.Entities.AppUser> userRepository,
ISqlSugarRepository<EventInfo> eventRepository,
ISqlSugarRepository<CheckinTask> taskRepository,
ISqlSugarRepository<CheckinRecord> recordRepository,
ISqlSugarRepository<EventRegistrationPerson> personRepository,
ISqlSugarRepository<EventRegistration> registrationRepository,
ISqlSugarRepository<YD_Event.Core.Entities.Project> projectRepository,
ISqlSugarRepository<TeamMember> teamMemberRepository,
CheckinScheduleService scheduleService,
AliyunSmsService smsService,
ILogger<CheckinController> 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<CheckinContextDto?> 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<CheckinContextDto> 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<StartCheckinOutput> 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<ScanCheckinOutput> 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<CheckinContextDto> 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);
}
/// <summary>按扫码内容定位秩序册列;支持号码布、报名人员ID和姓名。</summary>
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);
}
/// <summary>检录顺序卡控:非一检必须先通过上一检次。</summary>
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);
}
/// <summary>
/// 获取指定检录对象的记录;个人项目同时兼容未包含人员 ID 的历史检录键。
/// </summary>
private async Task<CheckinRecord?> 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)));
}
/// <summary>
/// 判断单个检录任务中,指定检录对象是否已经通过检录。
/// </summary>
private async Task<bool> HasCheckedUnitRecordAsync(long taskId, CheckinOrderBookUnitDto unit) =>
await HasCheckedUnitRecordAsync(new List<long> { taskId }, unit);
/// <summary>
/// 判断多个后续检录任务中,指定检录对象是否已经通过检录。
/// </summary>
private async Task<bool> HasCheckedUnitRecordAsync(
List<long> 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)));
}
/// <summary>
/// 获取旧版检录键。旧版仅包含赛事项目 ID 和报名项目 ID。
/// </summary>
private static string GetLegacyUnitKey(CheckinOrderBookUnitDto unit) =>
CheckinScheduleService.BuildUnitKey(unit.EventProjectId, unit.RegistrationItemId);
/// <summary>
/// 将检录记录映射到当前检录键。旧版个人项目记录按保存的报名人员 ID 精确归属,
/// 防止同一单位账号报名的其他人员被一并判定为已检录。
/// </summary>
private static string? ResolveRecordUnitKey(
IReadOnlyCollection<CheckinOrderBookUnitDto> 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;
}
/// <summary>
/// 对报名了所选项目的人员发送检录通知;单人失败不影响整体流程。
/// 个人项目:通知运动员本人(从 EventRegistrationPerson 取)。
/// 团队项目:通知教练和领队(从 TeamMember 取,因为报名表中不存储教练/领队的 RegistrationItemId)。
/// 短信内容:您报名的{赛事名称}正在检录,当前检录至{项目名称}第{x}场次,您所在第{y}场次,请提前前往检录区做好准备。
/// </summary>
private async Task<(int Total, int Success, int Failed)> NotifyUnitsAsync(
List<CheckinOrderBookUnitDto> 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);
}
/// <summary>个人项目:取 RegistrationItemId 下的运动员及其场次号。</summary>
private async Task<List<(string? Phone, int SessionOrder)>> CollectIndividualRecipientsAsync(
List<CheckinOrderBookUnitDto> units)
{
// 构建 personId/userId → 场次号 的映射(优先使用秩序册编排的 SessionIndex,缺失时回退到序号)
var userIdToOrder = new Dictionary<long, int>();
var personIdToOrder = new Dictionary<long, int>();
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();
}
/// <summary>
/// 团队项目:运动员 → TeamMember → 同团队教练/领队 → AppUser。
/// 每位教练/领队的场次号取其所带队伍中最早的场次。
/// </summary>
private async Task<List<(string? Phone, int SessionOrder)>> CollectTeamCoachLeaderRecipientsAsync(
List<CheckinOrderBookUnitDto> units)
{
// 构建 athleteUserId → 最早场次号(优先使用 SessionIndex)
var athleteToOrder = new Dictionary<long, int>();
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<long, int>();
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<long, int>();
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();
}
/// <summary>降级路径:从 EventRegistrationPerson 查教练/领队(默认场次号 1)。</summary>
private async Task<List<(string? Phone, int SessionOrder)>> FallbackCollectCoachLeaderAsync(
List<CheckinOrderBookUnitDto> 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();
}
/// <summary>批量加载 AppUser 并返回字典。</summary>
private async Task<Dictionary<long, YD_Event.Core.Entities.AppUser>> LoadUserMapAsync(List<long> 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<CheckinContextDto> 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<string> previousPassedKeys;
if (task.CheckinNo == 1)
{
previousPassedKeys = allUnitKeys;
}
else
{
var previousTask = await GetEventTaskAsync(task.EventId, task.CheckinNo - 1);
var previousRecords = previousTask == null
? new List<CheckinRecord>()
: 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<CheckinTask?> 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<CheckinTask?> 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<List<CheckinTask>> 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();
/// <summary>三检通过即为已上场;仅配置两检时由二检标记上场。</summary>
private async Task<HashSet<string>> GetOnStageKeysAsync(
List<CheckinTask> activeTasks, IReadOnlyCollection<CheckinOrderBookUnitDto> units)
{
if (activeTasks.Count == 0) return new HashSet<string>();
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();
}
/// <summary>上场检次固定为三检;未配置三检但配置了二检时使用二检。</summary>
private static int ResolveOnStageCheckinNo(List<CheckinTask> 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);
}
/// <summary>校验赛事状态是否允许检录操作,不允许时抛出业务异常。</summary>
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);
}
/// <summary>判断赛事是否允许检录:仅比赛中(Ongoing)允许。</summary>
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<long> 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<CheckinOrderBookUnitStatusDto> 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;
}
}