diff --git a/YD_Event.Application/Client/CheckinController.cs b/YD_Event.Application/Client/CheckinController.cs index aae8068..1d9d579 100644 --- a/YD_Event.Application/Client/CheckinController.cs +++ b/YD_Event.Application/Client/CheckinController.cs @@ -408,8 +408,7 @@ public partial class CheckinController : IDynamicApiController /// /// 对报名了所选项目的人员发送检录通知;单人失败不影响整体流程。 - /// 个人项目:通知运动员本人(从 EventRegistrationPerson 取)。 - /// 团队项目:通知教练和领队(从 TeamMember 取,因为报名表中不存储教练/领队的 RegistrationItemId)。 + /// 个人报名:通知运动员本人;单位报名:通知教练和领队,不受项目类型影响。 /// 短信内容:您报名的{赛事名称}正在检录,当前检录至{项目名称}第{x}场次,您所在第{y}场次,请提前前往检录区做好准备。 /// private async Task<(int Total, int Success, int Failed)> NotifyUnitsAsync( @@ -423,24 +422,24 @@ public partial class CheckinController : IDynamicApiController if (!isIndividualProject && !isTeamProject) throw Oops.Bah("当前项目类型不支持检录短信通知"); - // 收集通知接收人:{ Phone, SessionOrder } - List<(string? Phone, int SessionOrder)> recipients; + // 已知报名方式优先按个人/单位报名区分;缺少报名方式时按项目类型兜底。 + var individualUnits = units.Where(x => x.RegistrationMode == 1 || + (x.RegistrationMode == null && isIndividualProject)).ToList(); + var unitUnits = units.Where(x => x.RegistrationMode == 2 || + (x.RegistrationMode == null && isTeamProject)).ToList(); + var recipients = new List<(string? Phone, List SessionOrders)>(); + if (individualUnits.Count > 0) + recipients.AddRange(await CollectIndividualRecipientsAsync(individualUnits)); + if (unitUnits.Count > 0) + recipients.AddRange(await CollectTeamCoachLeaderRecipientsAsync(unitUnits)); - 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()) + .Select(g => (Phone: g.Key, SessionOrders: g.SelectMany(x => x.SessionOrders) + .Where(order => order > 0).Distinct().OrderBy(order => order).ToList())) .ToList(); // 当前检录进度:获取该项目在秩序册中的起始场次 @@ -455,7 +454,8 @@ public partial class CheckinController : IDynamicApiController try { var sent = await _smsService.SendCheckinNotice( - recipient.Phone!.Trim(), eventName, currentSession, recipient.SessionOrder); + recipient.Phone, eventName, currentSession, + recipient.SessionOrders.Count == 0 ? "1" : string.Join("、", recipient.SessionOrders)); if (sent) success++; else failed++; } @@ -470,22 +470,27 @@ public partial class CheckinController : IDynamicApiController return (validRecipients.Count + missingPhoneCount, success, failed); } - /// 个人项目:取 RegistrationItemId 下的运动员及其场次号。 - private async Task> CollectIndividualRecipientsAsync( + /// 个人报名:取 RegistrationItemId 下的运动员及其场次号。 + private async Task SessionOrders)>> CollectIndividualRecipientsAsync( List units) { // 构建 personId/userId → 场次号 的映射(优先使用秩序册编排的 SessionIndex,缺失时回退到序号) - var userIdToOrder = new Dictionary(); - var personIdToOrder = new Dictionary(); + var userIdToOrders = new Dictionary>(); + var personIdToOrders = 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; + if (athlete.UserId > 0) + { + if (!userIdToOrders.TryGetValue(athlete.UserId, out var userOrders)) + userIdToOrders[athlete.UserId] = userOrders = new HashSet(); + userOrders.Add(sessionOrder); + } + if (!personIdToOrders.TryGetValue(athlete.Id, out var personOrders)) + personIdToOrders[athlete.Id] = personOrders = new HashSet(); + personOrders.Add(sessionOrder); } } @@ -510,54 +515,57 @@ public partial class CheckinController : IDynamicApiController 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; // 兜底 + var orders = p.UserId > 0 && userIdToOrders.TryGetValue(p.UserId, out var userOrders) + ? userOrders + : personIdToOrders.GetValueOrDefault(p.Id); + var sessionOrders = orders?.OrderBy(order => order).ToList() ?? new List { 1 }; - return (phone, order); + return (phone, sessionOrders); }).ToList(); } /// - /// 团队项目:运动员 → TeamMember → 同团队教练/领队 → AppUser。 - /// 每位教练/领队的场次号取其所带队伍中最早的场次。 + /// 单位报名:运动员 → TeamMember → 同团队教练/领队 → AppUser,汇总其队伍涉及的场次。 /// - private async Task> CollectTeamCoachLeaderRecipientsAsync( + private async Task SessionOrders)>> CollectTeamCoachLeaderRecipientsAsync( List units) { - // 构建 athleteUserId → 最早场次号(优先使用 SessionIndex) - var athleteToOrder = new Dictionary(); + // 构建 athleteUserId → 场次号集合(优先使用 SessionIndex) + var athleteToOrders = 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; + if (!athleteToOrders.TryGetValue(a.UserId, out var orders)) + athleteToOrders[a.UserId] = orders = new HashSet(); + orders.Add(sessionOrder); } } - var athleteUserIds = athleteToOrder.Keys.ToList(); - if (athleteUserIds.Count == 0) return new(); + var athleteUserIds = athleteToOrders.Keys.ToList(); + if (athleteUserIds.Count == 0) + return await FallbackCollectCoachLeaderAsync(units); // 步骤1:运动员 → TeamMember(运动员身份) var athleteMembers = await _teamMemberRepository.AsQueryable() - .Where(x => athleteUserIds.Contains(x.UserId) && !x.DeleteMark) + .Where(x => athleteUserIds.Contains(x.UserId) + && x.PersonType == PersonType.Athlete && !x.DeleteMark) .ToListAsync(); var teamIds = athleteMembers.Select(x => x.TeamId).Distinct().ToList(); - if (teamIds.Count == 0) return new(); + if (teamIds.Count == 0) + return await FallbackCollectCoachLeaderAsync(units); - // 构建 teamId → 最早场次号(取该队运动员中最早的场次) - var teamToOrder = new Dictionary(); + // 构建 teamId → 场次号集合 + var teamToOrders = new Dictionary>(); foreach (var am in athleteMembers) { - if (athleteToOrder.TryGetValue(am.UserId, out var order)) + if (athleteToOrders.TryGetValue(am.UserId, out var orders)) { - if (!teamToOrder.ContainsKey(am.TeamId) || teamToOrder[am.TeamId] > order) - teamToOrder[am.TeamId] = order; + if (!teamToOrders.TryGetValue(am.TeamId, out var teamOrders)) + teamToOrders[am.TeamId] = teamOrders = new HashSet(); + teamOrders.UnionWith(orders); } } @@ -574,38 +582,41 @@ public partial class CheckinController : IDynamicApiController return await FallbackCollectCoachLeaderAsync(units); } - // 教练/领队 UserId → 最早场次号 - var coachOrderMap = new Dictionary(); + // 教练/领队 UserId → 场次号集合 + var coachOrderMap = new Dictionary>(); foreach (var cl in coachLeaders) { - if (teamToOrder.TryGetValue(cl.TeamId, out var order)) + if (teamToOrders.TryGetValue(cl.TeamId, out var orders)) { - if (!coachOrderMap.ContainsKey(cl.UserId) || coachOrderMap[cl.UserId] > order) - coachOrderMap[cl.UserId] = order; + if (!coachOrderMap.TryGetValue(cl.UserId, out var coachOrders)) + coachOrderMap[cl.UserId] = coachOrders = new HashSet(); + coachOrders.UnionWith(orders); } } + if (coachOrderMap.Count == 0) + return await FallbackCollectCoachLeaderAsync(units); + var userMap = await LoadUserMapAsync(coachOrderMap.Keys.ToList()); return coachOrderMap.Select(kv => { userMap.TryGetValue(kv.Key, out var user); - return (user?.Mobile, kv.Value); + return (user?.Mobile, kv.Value.OrderBy(order => order).ToList()); }).ToList(); } - /// 降级路径:从 EventRegistrationPerson 查教练/领队(默认场次号 1)。 - private async Task> FallbackCollectCoachLeaderAsync( + /// 降级路径:从 EventRegistrationPerson 查同报名下教练/领队。 + private async Task SessionOrders)>> FallbackCollectCoachLeaderAsync( List units) { var registrationItemIds = units.Select(x => x.RegistrationItemId).Distinct().ToList(); if (registrationItemIds.Count == 0) return new(); - var registrationIds = await _personRepository.AsQueryable() + var athletePersons = await _personRepository.AsQueryable() .Where(x => registrationItemIds.Contains(x.RegistrationItemId) && !x.DeleteMark) - .Select(x => x.RegistrationId) - .Distinct() .ToListAsync(); + var registrationIds = athletePersons.Select(x => x.RegistrationId).Distinct().ToList(); if (registrationIds.Count == 0) return new(); @@ -615,18 +626,25 @@ public partial class CheckinController : IDynamicApiController && !x.DeleteMark) .ToListAsync(); - var coachUserIds = fallbackPersons.Where(p => p.UserId > 0).Select(p => p.UserId).Distinct().ToList(); + var itemOrders = units.GroupBy(x => x.RegistrationItemId) + .ToDictionary(g => g.Key, g => g.Select(x => x.SessionIndex > 0 ? x.SessionIndex : x.Order) + .Where(order => order > 0).Distinct().ToList()); + var registrationOrders = athletePersons.GroupBy(x => x.RegistrationId) + .ToDictionary(g => g.Key, g => g.SelectMany(person => itemOrders.GetValueOrDefault(person.RegistrationItemId) ?? new List()) + .Distinct().OrderBy(order => order).ToList()); + var coachOrders = fallbackPersons.Where(p => p.UserId > 0) + .GroupBy(p => p.UserId) + .ToDictionary(g => g.Key, g => g.SelectMany(p => registrationOrders.GetValueOrDefault(p.RegistrationId) ?? new List()) + .Distinct().OrderBy(order => order).ToList()); + var coachUserIds = coachOrders.Keys.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); + return (user?.Mobile, coachOrders[uid]); }).ToList(); } diff --git a/YD_Event.Application/Client/CheckinScheduleService.cs b/YD_Event.Application/Client/CheckinScheduleService.cs index 1f2254e..1de81da 100644 --- a/YD_Event.Application/Client/CheckinScheduleService.cs +++ b/YD_Event.Application/Client/CheckinScheduleService.cs @@ -8,6 +8,7 @@ public class CheckinScheduleService : ITransient { private readonly ISqlSugarRepository _eventRepository; private readonly ISqlSugarRepository _personRepository; + private readonly ISqlSugarRepository _registrationRepository; private readonly ISqlSugarRepository _sessionRepository; private readonly ISqlSugarRepository _entryRepository; private readonly ISqlSugarRepository _eventProjectRepository; @@ -18,6 +19,7 @@ public class CheckinScheduleService : ITransient public CheckinScheduleService( ISqlSugarRepository eventRepository, ISqlSugarRepository personRepository, + ISqlSugarRepository registrationRepository, ISqlSugarRepository sessionRepository, ISqlSugarRepository entryRepository, ISqlSugarRepository eventProjectRepository, @@ -27,6 +29,7 @@ public class CheckinScheduleService : ITransient { _eventRepository = eventRepository; _personRepository = personRepository; + _registrationRepository = registrationRepository; _sessionRepository = sessionRepository; _entryRepository = entryRepository; _eventProjectRepository = eventProjectRepository; @@ -57,6 +60,13 @@ public class CheckinScheduleService : ITransient .Where(x => personIds.Contains(x.Id) && !x.DeleteMark && x.PersonType == PersonType.Athlete) .ToListAsync(); var personMap = persons.ToDictionary(x => x.Id); + var registrationIds = persons.Select(x => x.RegistrationId).Distinct().ToList(); + var registrationModes = registrationIds.Count == 0 + ? new Dictionary() + : (await _registrationRepository.AsQueryable() + .Where(x => registrationIds.Contains(x.Id) && !x.DeleteMark) + .ToListAsync()) + .ToDictionary(x => x.Id, x => x.RegistrationMode); // 团队项目沿用报名项目作为检录单位;个人项目追加报名人员 ID, // 避免单位账号在同一报名项目下填报多人时共用同一个检录键。 @@ -75,6 +85,10 @@ public class CheckinScheduleService : ITransient GroupId = row.EventProjectGroupId, GroupName = row.GroupName, RegistrationItemId = row.RegistrationItemId, + RegistrationMode = row.RegistrationPersonIds + .Where(personMap.ContainsKey) + .Select(personId => registrationModes.GetValueOrDefault(personMap[personId].RegistrationId)) + .FirstOrDefault(mode => mode > 0) is var mode && mode > 0 ? mode : null, TeamName = row.TeamName ?? string.Empty, UnitName = row.UnitName ?? string.Empty, RegistrationPersonIds = row.RegistrationPersonIds.Distinct().ToList(), diff --git a/YD_Event.Application/Client/Dtos/CheckinAdminDtos.cs b/YD_Event.Application/Client/Dtos/CheckinAdminDtos.cs index 818e735..0246dce 100644 --- a/YD_Event.Application/Client/Dtos/CheckinAdminDtos.cs +++ b/YD_Event.Application/Client/Dtos/CheckinAdminDtos.cs @@ -48,6 +48,8 @@ public class CheckinOrderBookUnitDto public long? GroupId { get; set; } public string GroupName { get; set; } = string.Empty; public long RegistrationItemId { get; set; } + /// 报名方式:1-个人报名,2-单位报名;无法关联报名记录时为空。 + public int? RegistrationMode { get; set; } public string TeamName { get; set; } = string.Empty; public string UnitName { get; set; } = string.Empty; public List RegistrationPersonIds { get; set; } = new(); diff --git a/YD_Event.Application/Sms/AliyunSmsService.cs b/YD_Event.Application/Sms/AliyunSmsService.cs index 8999ac6..7999f18 100644 --- a/YD_Event.Application/Sms/AliyunSmsService.cs +++ b/YD_Event.Application/Sms/AliyunSmsService.cs @@ -78,10 +78,10 @@ public class AliyunSmsService : ITransient /// /// 发送检录通知短信;未配置检录模板时返回 false 由调用方降级处理。 - /// 模板示例:您报名的${event}正在检录,当前检录至${current},您所在第${order}场次,请提前前往检录区做好准备。 + /// 模板示例:您报名的${event}正在检录,当前检录至${current},您所在第${order}场次,请提前前往检录区做好准备。单位报名多个场次时 order 可为“1、3、4、5”。 /// current 参数格式:{项目名称}第{x}场次。 /// - public async Task SendCheckinNotice(string phone, string eventName, string currentSession, int sessionOrder) + public async Task SendCheckinNotice(string phone, string eventName, string currentSession, string sessionOrders) { if (string.IsNullOrWhiteSpace(_options.CheckinNoticeTemplateCode)) { @@ -93,7 +93,7 @@ public class AliyunSmsService : ITransient { @event = eventName, current = currentSession, - order = sessionOrder.ToString() + order = sessionOrders }); await SendSms(phone, _options.CheckinNoticeTemplateCode, param); return true; diff --git a/YD_Event.Core/Options/SmsOptions.cs b/YD_Event.Core/Options/SmsOptions.cs index 7c04dbb..7a66e22 100644 --- a/YD_Event.Core/Options/SmsOptions.cs +++ b/YD_Event.Core/Options/SmsOptions.cs @@ -32,7 +32,7 @@ public class SmsOptions : IConfigurableOptions public string VerificationCodeTemplateCode { get; set; } /// - /// 检录通知模板Code;模板格式:您报名的${event}正在检录,当前检录至${current},您所在第${order}场次,请提前前往检录区做好准备。 + /// 检录通知模板Code;order 支持单个场次或“1、3、4、5”场次列表。 /// current 参数格式为“{项目名称}第{x}场次”;未配置时不发送检录短信。 /// [ConfigurationKeyName("CheckinNoticeTemplate")]