检录通知模板调整
This commit is contained in:
parent
f61d86550d
commit
e21aaf3150
@ -408,8 +408,7 @@ public partial class CheckinController : IDynamicApiController
|
||||
|
||||
/// <summary>
|
||||
/// 对报名了所选项目的人员发送检录通知;单人失败不影响整体流程。
|
||||
/// 个人项目:通知运动员本人(从 EventRegistrationPerson 取)。
|
||||
/// 团队项目:通知教练和领队(从 TeamMember 取,因为报名表中不存储教练/领队的 RegistrationItemId)。
|
||||
/// 个人报名:通知运动员本人;单位报名:通知教练和领队,不受项目类型影响。
|
||||
/// 短信内容:您报名的{赛事名称}正在检录,当前检录至{项目名称}第{x}场次,您所在第{y}场次,请提前前往检录区做好准备。
|
||||
/// </summary>
|
||||
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<int> 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);
|
||||
}
|
||||
|
||||
/// <summary>个人项目:取 RegistrationItemId 下的运动员及其场次号。</summary>
|
||||
private async Task<List<(string? Phone, int SessionOrder)>> CollectIndividualRecipientsAsync(
|
||||
/// <summary>个人报名:取 RegistrationItemId 下的运动员及其场次号。</summary>
|
||||
private async Task<List<(string? Phone, List<int> SessionOrders)>> CollectIndividualRecipientsAsync(
|
||||
List<CheckinOrderBookUnitDto> units)
|
||||
{
|
||||
// 构建 personId/userId → 场次号 的映射(优先使用秩序册编排的 SessionIndex,缺失时回退到序号)
|
||||
var userIdToOrder = new Dictionary<long, int>();
|
||||
var personIdToOrder = new Dictionary<long, int>();
|
||||
var userIdToOrders = new Dictionary<long, HashSet<int>>();
|
||||
var personIdToOrders = new Dictionary<long, HashSet<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;
|
||||
if (athlete.UserId > 0)
|
||||
{
|
||||
if (!userIdToOrders.TryGetValue(athlete.UserId, out var userOrders))
|
||||
userIdToOrders[athlete.UserId] = userOrders = new HashSet<int>();
|
||||
userOrders.Add(sessionOrder);
|
||||
}
|
||||
if (!personIdToOrders.TryGetValue(athlete.Id, out var personOrders))
|
||||
personIdToOrders[athlete.Id] = personOrders = new HashSet<int>();
|
||||
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<int> { 1 };
|
||||
|
||||
return (phone, order);
|
||||
return (phone, sessionOrders);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 团队项目:运动员 → TeamMember → 同团队教练/领队 → AppUser。
|
||||
/// 每位教练/领队的场次号取其所带队伍中最早的场次。
|
||||
/// 单位报名:运动员 → TeamMember → 同团队教练/领队 → AppUser,汇总其队伍涉及的场次。
|
||||
/// </summary>
|
||||
private async Task<List<(string? Phone, int SessionOrder)>> CollectTeamCoachLeaderRecipientsAsync(
|
||||
private async Task<List<(string? Phone, List<int> SessionOrders)>> CollectTeamCoachLeaderRecipientsAsync(
|
||||
List<CheckinOrderBookUnitDto> units)
|
||||
{
|
||||
// 构建 athleteUserId → 最早场次号(优先使用 SessionIndex)
|
||||
var athleteToOrder = new Dictionary<long, int>();
|
||||
// 构建 athleteUserId → 场次号集合(优先使用 SessionIndex)
|
||||
var athleteToOrders = new Dictionary<long, HashSet<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;
|
||||
if (!athleteToOrders.TryGetValue(a.UserId, out var orders))
|
||||
athleteToOrders[a.UserId] = orders = new HashSet<int>();
|
||||
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<long, int>();
|
||||
// 构建 teamId → 场次号集合
|
||||
var teamToOrders = new Dictionary<long, HashSet<int>>();
|
||||
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<int>();
|
||||
teamOrders.UnionWith(orders);
|
||||
}
|
||||
}
|
||||
|
||||
@ -574,38 +582,41 @@ public partial class CheckinController : IDynamicApiController
|
||||
return await FallbackCollectCoachLeaderAsync(units);
|
||||
}
|
||||
|
||||
// 教练/领队 UserId → 最早场次号
|
||||
var coachOrderMap = new Dictionary<long, int>();
|
||||
// 教练/领队 UserId → 场次号集合
|
||||
var coachOrderMap = new Dictionary<long, HashSet<int>>();
|
||||
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<int>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>降级路径:从 EventRegistrationPerson 查教练/领队(默认场次号 1)。</summary>
|
||||
private async Task<List<(string? Phone, int SessionOrder)>> FallbackCollectCoachLeaderAsync(
|
||||
/// <summary>降级路径:从 EventRegistrationPerson 查同报名下教练/领队。</summary>
|
||||
private async Task<List<(string? Phone, List<int> SessionOrders)>> FallbackCollectCoachLeaderAsync(
|
||||
List<CheckinOrderBookUnitDto> 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<int>())
|
||||
.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<int>())
|
||||
.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();
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ public class CheckinScheduleService : ITransient
|
||||
{
|
||||
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
|
||||
private readonly ISqlSugarRepository<EventRegistrationPerson> _personRepository;
|
||||
private readonly ISqlSugarRepository<EventRegistration> _registrationRepository;
|
||||
private readonly ISqlSugarRepository<EventSession> _sessionRepository;
|
||||
private readonly ISqlSugarRepository<EventSessionEntry> _entryRepository;
|
||||
private readonly ISqlSugarRepository<EventProject> _eventProjectRepository;
|
||||
@ -18,6 +19,7 @@ public class CheckinScheduleService : ITransient
|
||||
public CheckinScheduleService(
|
||||
ISqlSugarRepository<EventInfo> eventRepository,
|
||||
ISqlSugarRepository<EventRegistrationPerson> personRepository,
|
||||
ISqlSugarRepository<EventRegistration> registrationRepository,
|
||||
ISqlSugarRepository<EventSession> sessionRepository,
|
||||
ISqlSugarRepository<EventSessionEntry> entryRepository,
|
||||
ISqlSugarRepository<EventProject> 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<long, int>()
|
||||
: (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(),
|
||||
|
||||
@ -48,6 +48,8 @@ public class CheckinOrderBookUnitDto
|
||||
public long? GroupId { get; set; }
|
||||
public string GroupName { get; set; } = string.Empty;
|
||||
public long RegistrationItemId { get; set; }
|
||||
/// <summary>报名方式:1-个人报名,2-单位报名;无法关联报名记录时为空。</summary>
|
||||
public int? RegistrationMode { get; set; }
|
||||
public string TeamName { get; set; } = string.Empty;
|
||||
public string UnitName { get; set; } = string.Empty;
|
||||
public List<long> RegistrationPersonIds { get; set; } = new();
|
||||
|
||||
@ -78,10 +78,10 @@ public class AliyunSmsService : ITransient
|
||||
|
||||
/// <summary>
|
||||
/// 发送检录通知短信;未配置检录模板时返回 false 由调用方降级处理。
|
||||
/// 模板示例:您报名的${event}正在检录,当前检录至${current},您所在第${order}场次,请提前前往检录区做好准备。
|
||||
/// 模板示例:您报名的${event}正在检录,当前检录至${current},您所在第${order}场次,请提前前往检录区做好准备。单位报名多个场次时 order 可为“1、3、4、5”。
|
||||
/// current 参数格式:{项目名称}第{x}场次。
|
||||
/// </summary>
|
||||
public async Task<bool> SendCheckinNotice(string phone, string eventName, string currentSession, int sessionOrder)
|
||||
public async Task<bool> 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;
|
||||
|
||||
@ -32,7 +32,7 @@ public class SmsOptions : IConfigurableOptions
|
||||
public string VerificationCodeTemplateCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 检录通知模板Code;模板格式:您报名的${event}正在检录,当前检录至${current},您所在第${order}场次,请提前前往检录区做好准备。
|
||||
/// 检录通知模板Code;order 支持单个场次或“1、3、4、5”场次列表。
|
||||
/// current 参数格式为“{项目名称}第{x}场次”;未配置时不发送检录短信。
|
||||
/// </summary>
|
||||
[ConfigurationKeyName("CheckinNoticeTemplate")]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user