890 lines
40 KiB
C#
890 lines
40 KiB
C#
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;
|
||
}
|
||
}
|