马梓彭 29419e84eb 三人交互绳花样
难度裁判:跳绳运动员难度(DJ)等级从0(间歇)-5;摇绳运动员难度(DT)等级从1-5 ;两种难度每个等级都应包含 x-、x、x+ (x为等级)
主裁:失误项放到最下面
2026-09-29 22:21:19 +08:00

867 lines
37 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 System.Drawing;
using YD_Event.Application.Auth;
using YD_Event.Application.Common.Excel;
using YD_Event.Application.OrderBook.Dtos;
using YD_Event.Core.Domain.Export;
namespace YD_Event.Application.OrderBook;
/// <summary>
/// 秩序册导入接口(下载模板 / 同步解析 / 确认落库)
/// </summary>
[ApiDescriptionSettings("打分系统", Name = "order-book")]
[Route("api/orderbook")]
[Authorize]
public class OrderBookService : IDynamicApiController, ITransient
{
private const string EntrySheetName = "出场秩序";
private const string RefereeSheetName = "裁判名单";
private const string DefaultGroupName = "公开组";
private static readonly TimeSpan BatchTtl = TimeSpan.FromMinutes(30);
private readonly ISqlSugarRepository<EventInfo> _eventRepository;
private readonly ISqlSugarRepository<Core.Entities.Project> _projectRepository;
private readonly ISqlSugarRepository<EventProject> _eventProjectRepository;
private readonly ISqlSugarRepository<EventProjectGroup> _groupRepository;
private readonly ISqlSugarRepository<EventVenue> _venueRepository;
private readonly ISqlSugarRepository<EventSession> _sessionRepository;
private readonly ISqlSugarRepository<EventSessionEntry> _entryRepository;
private readonly ISqlSugarRepository<Core.Entities.Referee> _refereeRepository;
private readonly ISqlSugarRepository<EventReferee> _eventRefereeRepository;
private readonly ISqlSugarRepository<Core.Entities.AppUser> _appUserRepository;
private readonly IEasyCachingProvider _cache;
private readonly IIdGenerator _idGenerator;
private readonly AuthManager _authManager;
public OrderBookService(
ISqlSugarRepository<EventInfo> eventRepository,
ISqlSugarRepository<Core.Entities.Project> projectRepository,
ISqlSugarRepository<EventProject> eventProjectRepository,
ISqlSugarRepository<EventProjectGroup> groupRepository,
ISqlSugarRepository<EventVenue> venueRepository,
ISqlSugarRepository<EventSession> sessionRepository,
ISqlSugarRepository<EventSessionEntry> entryRepository,
ISqlSugarRepository<Core.Entities.Referee> refereeRepository,
ISqlSugarRepository<EventReferee> eventRefereeRepository,
ISqlSugarRepository<Core.Entities.AppUser> appUserRepository,
IEasyCachingProvider cache,
IIdGenerator idGenerator,
AuthManager authManager)
{
_eventRepository = eventRepository;
_projectRepository = projectRepository;
_eventProjectRepository = eventProjectRepository;
_groupRepository = groupRepository;
_venueRepository = venueRepository;
_sessionRepository = sessionRepository;
_entryRepository = entryRepository;
_refereeRepository = refereeRepository;
_eventRefereeRepository = eventRefereeRepository;
_appUserRepository = appUserRepository;
_cache = cache;
_idGenerator = idGenerator;
_authManager = authManager;
}
#region 下载模板
/// <summary>
/// 下载秩序册导入模板
/// </summary>
/// <param name="projectMode"></param>
/// <returns></returns>
[DisplayName("下载秩序册导入模板")]
[HttpGet("template")]
public async Task<IActionResult> DownloadTemplate([FromQuery] string? projectMode)
{
var bytes = await new OrderBookTemplateExporter().ExportAsync();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = "秩序册导入模板.xlsx"
};
}
#endregion
#region 同步解析
/// <summary>
/// 上传并同步解析秩序册(不产生业务数据,结果缓存待确认)
/// </summary>
/// <param name="eventId"></param>
/// <param name="importMode"></param>
/// <param name="file"></param>
/// <returns></returns>
[DisplayName("解析秩序册")]
[HttpPost("import")]
public async Task<OrderBookImportOutput> Import(long? eventId, string? importMode, IFormFile file)
{
if (file == null || file.Length == 0)
throw Oops.Bah("请上传秩序册文件");
var ext = Path.GetExtension(file.FileName)?.ToLowerInvariant();
if (ext is not ".xlsx" and not ".xls")
throw Oops.Bah("仅支持 .xls/.xlsx 格式的秩序册文件");
if (eventId.HasValue && eventId.Value > 0 &&
!await _eventRepository.IsAnyAsync(e => e.Id == eventId.Value && !e.DeleteMark))
throw Oops.Bah("赛事不存在或已删除");
var mode = string.IsNullOrWhiteSpace(importMode) ? "append" : importMode.Trim().ToLowerInvariant();
if (mode != "append" && mode != "overwrite")
throw Oops.Bah("导入模式只能为 append(追加)或 overwrite(覆盖)");
List<OrderBookEntryRow> entryRows;
List<OrderBookRefereeRow> refereeRows;
try
{
using var stream = file.OpenReadStream();
// NPOI 统一读取,WorkbookFactory 按文件头自动识别 .xls/.xlsx
using var workbook = NpoiExcelReader.CreateWorkbook(stream);
entryRows = NpoiExcelReader.ReadSheet<OrderBookEntryRow>(workbook, EntrySheetName);
refereeRows = NpoiExcelReader.ReadSheet<OrderBookRefereeRow>(workbook, RefereeSheetName);
}
catch (Exception ex)
{
throw Oops.Bah($"秩序册解析失败,请确认包含「{EntrySheetName}」「{RefereeSheetName}」两个工作表:{ex.Message}");
}
var issues = new List<OrderBookParseIssue>();
var draftEntries = ParseEntryRows(entryRows, issues);
var draftReferees = ParseRefereeRows(refereeRows, issues);
// 同场次出场编号重复校验
var duplicateEntryNos = draftEntries
.Where(e => !string.IsNullOrWhiteSpace(e.EntryNo))
.GroupBy(e => $"{e.VenueName}|{e.ProjectName}|{e.GroupName}|{e.EntryNo}")
.Where(g => g.Count() > 1)
.SelectMany(g => g.Skip(1))
.ToList();
foreach (var dup in duplicateEntryNos)
{
issues.Add(new OrderBookParseIssue
{
Row = dup.Row,
Column = "出场编号",
Message = $"同一场次内出场编号「{dup.EntryNo}」重复",
Level = "error"
});
}
var errorCount = issues.Count(i => i.Level == "error");
var warningCount = issues.Count(i => i.Level == "warning");
var draft = new OrderBookDraft
{
BatchId = _idGenerator.NextId(),
CreatedUserId = _authManager.UserId,
HasErrors = errorCount > 0,
EventId = eventId,
ImportMode = mode,
CreatedAt = DateTime.Now,
Entries = draftEntries.Where(e => duplicateEntryNos.All(d => d.Row != e.Row)).ToList(),
Referees = draftReferees
};
var preview = BuildPreview(draft);
// 重传覆盖:同一赛事存在旧的未确认批次时移除,始终以最新一次上传为准
if (eventId.HasValue)
{
var currentKey = CurrentKey(eventId.Value, _authManager.UserId);
var oldBatch = await _cache.GetAsync<long>(currentKey);
if (oldBatch.HasValue)
await _cache.RemoveAsync(BatchKey(oldBatch.Value));
await _cache.SetAsync(currentKey, draft.BatchId, BatchTtl);
}
await _cache.SetAsync(BatchKey(draft.BatchId), draft, BatchTtl);
return new OrderBookImportOutput
{
BatchId = draft.BatchId,
Status = errorCount > 0 ? 20 : 10,
TotalRows = entryRows.Count + refereeRows.Count,
ValidRows = draft.Entries.Count + draft.Referees.Count,
ErrorCount = errorCount,
WarningCount = warningCount,
Preview = preview,
Errors = issues
};
}
private List<OrderBookDraftEntry> ParseEntryRows(List<OrderBookEntryRow> rows, List<OrderBookParseIssue> issues)
{
var result = new List<OrderBookDraftEntry>();
for (var i = 0; i < rows.Count; i++)
{
var row = rows[i];
var rowNo = i + 2; // 表头占第 1 行
// 整行为空直接跳过
if (IsAllBlank(row.VenueName, row.ProjectName, row.GroupName, row.EntryNo,
row.ParticipantName, row.UnitName, row.Mobile, row.IdCard, row.Remark))
continue;
var venue = Trim(row.VenueName);
var project = Trim(row.ProjectName);
var participant = Trim(row.ParticipantName);
if (string.IsNullOrWhiteSpace(venue))
issues.Add(Issue(rowNo, "场地名称", "场地名称为必填项"));
if (string.IsNullOrWhiteSpace(project))
issues.Add(Issue(rowNo, "项目名称", "项目名称为必填项"));
if (string.IsNullOrWhiteSpace(participant))
issues.Add(Issue(rowNo, "姓名/队名", "姓名/队名为必填项"));
var mobile = Trim(row.Mobile);
if (!string.IsNullOrWhiteSpace(mobile) && !Regex.IsMatch(mobile, @"^1[3-9]\d{9}$"))
issues.Add(Issue(rowNo, "联系电话", $"联系电话「{mobile}」格式不正确", "warning"));
result.Add(new OrderBookDraftEntry
{
Row = rowNo,
VenueName = venue,
ProjectName = project,
GroupName = string.IsNullOrWhiteSpace(Trim(row.GroupName)) ? DefaultGroupName : Trim(row.GroupName)!,
EntryNo = Trim(row.EntryNo),
ParticipantName = participant,
UnitName = Trim(row.UnitName),
Mobile = mobile
});
}
return result;
}
private List<OrderBookDraftReferee> ParseRefereeRows(List<OrderBookRefereeRow> rows, List<OrderBookParseIssue> issues)
{
var result = new List<OrderBookDraftReferee>();
var seenMobiles = new HashSet<string>();
for (var i = 0; i < rows.Count; i++)
{
var row = rows[i];
var rowNo = i + 2;
if (IsAllBlank(row.Name, row.Mobile, row.RoleText, row.LevelText, row.VenueName, row.ProjectName))
continue;
var name = Trim(row.Name);
var mobile = Trim(row.Mobile);
if (string.IsNullOrWhiteSpace(name))
issues.Add(Issue(rowNo, "姓名", "裁判姓名为必填项"));
if (string.IsNullOrWhiteSpace(mobile))
issues.Add(Issue(rowNo, "手机号", "裁判手机号为必填项"));
else if (!Regex.IsMatch(mobile, @"^1[3-9]\d{9}$"))
issues.Add(Issue(rowNo, "手机号", $"裁判手机号「{mobile}」格式不正确"));
else if (!seenMobiles.Add(mobile))
issues.Add(Issue(rowNo, "手机号", $"裁判手机号「{mobile}」重复,已自动合并", "warning"));
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(mobile))
continue;
// 同手机号只保留第一条
if (seenMobiles.Contains(mobile) && result.Any(r => r.Mobile == mobile))
continue;
result.Add(new OrderBookDraftReferee
{
Row = rowNo,
Name = name!,
Mobile = mobile!,
RoleText = Trim(row.RoleText),
LevelText = Trim(row.LevelText),
VenueName = Trim(row.VenueName),
ProjectName = Trim(row.ProjectName)
});
}
return result;
}
private static OrderBookPreview BuildPreview(OrderBookDraft draft)
{
return new OrderBookPreview
{
VenueCount = draft.Entries.Select(e => e.VenueName).Where(v => !string.IsNullOrWhiteSpace(v)).Distinct().Count(),
SessionCount = draft.Entries
.Select(e => $"{e.ProjectName}|{e.GroupName}|{e.VenueName}")
.Distinct().Count(),
EntryCount = draft.Entries.Count,
RefereeCount = draft.Referees.Select(r => r.Mobile).Distinct().Count()
};
}
#endregion
#region 确认落库
/// <summary>
/// 确认导入:按解析结果生成场地、场次、出场及裁判名单(幂等,同一批次只生成一次)
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
[UnitOfWork]
[DisplayName("确认秩序册导入")]
[HttpPost("confirm")]
public async Task<OrderBookConfirmOutput> Confirm([FromBody] OrderBookConfirmInput input)
{
var cached = await _cache.GetAsync<OrderBookDraft>(BatchKey(input.BatchId));
if (!cached.HasValue || cached.Value == null)
throw Oops.Bah("导入批次不存在或已过期,请重新上传秩序册");
var draft = cached.Value;
var currentUserId = _authManager.UserId;
if (draft.CreatedUserId != currentUserId)
throw Oops.Bah("无权确认其他用户创建的导入批次");
if (draft.HasErrors)
throw Oops.Bah("导入文件仍存在校验错误,请修正后重新上传");
var confirmedCache = await _cache.GetAsync<bool>(ConfirmedKey(input.BatchId));
if (confirmedCache.HasValue && confirmedCache.Value)
throw Oops.Bah("该批次已确认,请勿重复提交");
if (!draft.Entries.Any())
throw Oops.Bah("秩序册没有可导入的出场数据");
if (!draft.EventId.HasValue || draft.EventId.Value <= 0)
throw Oops.Bah("请先创建赛事并配置项目评分规则,再导入秩序册");
var eventId = draft.EventId.Value;
var eventInfo = await _eventRepository.GetFirstAsync(e => e.Id == eventId && !e.DeleteMark)
?? throw Oops.Bah("赛事不存在或已删除");
var now = DateTime.Now;
// 覆盖模式:先软删该赛事既有编排(场地/场次/出场),与编排确认逻辑保持一致
if (draft.ImportMode == "overwrite")
await SoftDeleteArrangementAsync(eventId);
// 1. 准备项目 / 赛事项目 / 组别映射,并校验评分规则
var projectContext = await EnsureProjectsAsync(draft, eventId, currentUserId, now);
// 2. 准备场地
var venueContext = await EnsureVenuesAsync(draft, eventId, currentUserId, now);
// 3. 生成场次
var sessionContext = await EnsureSessionsAsync(draft, eventId, projectContext, venueContext, currentUserId, now);
// 4. 生成出场
var entryCount = await CreateEntriesAsync(draft, projectContext, venueContext, sessionContext, currentUserId, now);
// 5. 裁判名单 + 可选打分账号
var refereeCount = await EnsureRefereesAsync(draft, eventId, input, currentUserId, now);
// 幂等标记 + 清理批次缓存
await _cache.SetAsync(ConfirmedKey(input.BatchId), true, BatchTtl);
await _cache.RemoveAsync(BatchKey(input.BatchId));
if (await _cache.GetAsync<long>(CurrentKey(eventId, currentUserId)) is { HasValue: true } current && current.Value == input.BatchId)
await _cache.RemoveAsync(CurrentKey(eventId, currentUserId));
return new OrderBookConfirmOutput
{
OrderBookVersion = 1,
VenueCount = venueContext.Count,
SessionCount = sessionContext.Count,
EntryCount = entryCount,
RefereeCount = refereeCount
};
}
private async Task SoftDeleteArrangementAsync(long eventId)
{
var sessionIds = await _sessionRepository.AsQueryable()
.Where(s => !s.DeleteMark && s.EventId == eventId)
.Select(s => s.Id).ToListAsync();
if (sessionIds.Any())
{
await _entryRepository.SoftDeleteAsync(e => sessionIds.Contains(e.SessionId));
await _sessionRepository.SoftDeleteAsync(s => sessionIds.Contains(s.Id));
}
await _venueRepository.SoftDeleteAsync(v => v.EventId == eventId);
}
// 项目(含赛事项目、组别)建档,返回 (项目名,组别名) -> 上下文 的映射
private async Task<Dictionary<string, OrderBookProjectContext>> EnsureProjectsAsync(
OrderBookDraft draft, long eventId, long userId, DateTime now)
{
var projectNames = draft.Entries.Select(e => e.ProjectName).Where(n => !string.IsNullOrWhiteSpace(n)).Distinct().ToList();
var result = new Dictionary<string, OrderBookProjectContext>();
var existProjects = await _projectRepository.AsQueryable()
.Where(p => !p.DeleteMark && projectNames.Contains(p.Name)).ToListAsync();
var existEventProjects = await _eventProjectRepository.AsQueryable()
.Where(ep => !ep.DeleteMark && ep.EventId == eventId).ToListAsync();
var existGroups = await _groupRepository.AsQueryable()
.Where(g => !g.DeleteMark && g.EventId == eventId).ToListAsync();
var maxSort = existProjects.Count == 0 ? 0 : existProjects.Max(p => p.SortIndex);
foreach (var projectName in projectNames)
{
var project = existProjects.FirstOrDefault(p => p.Name == projectName);
if (project == null)
{
project = new Core.Entities.Project
{
Id = _idGenerator.NextId(),
Name = projectName,
ProjectType = 0,
SortIndex = ++maxSort,
Status = AvailabilityStatus.Enable,
CreatedUserId = userId,
CreatedTime = now
};
await _projectRepository.InsertAsync(project);
existProjects.Add(project);
}
var eventProject = existEventProjects.FirstOrDefault(ep => ep.ProjectId == project.Id);
if (eventProject == null)
{
eventProject = new EventProject
{
Id = _idGenerator.NextId(),
EventId = eventId,
ProjectId = project.Id,
GenderType = Gender.Unknown,
CreatedUserId = userId,
CreatedTime = now
};
await _eventProjectRepository.InsertAsync(eventProject);
existEventProjects.Add(eventProject);
}
var groupNames = draft.Entries.Where(e => e.ProjectName == projectName).Select(e => e.GroupName).Distinct().ToList();
var groupMap = new Dictionary<string, long?>();
foreach (var groupName in groupNames)
{
if (groupName == DefaultGroupName)
{
groupMap[groupName] = null;
continue;
}
var group = existGroups.FirstOrDefault(g => g.EventProjectId == eventProject.Id && g.GroupName == groupName);
if (group == null)
{
group = new EventProjectGroup
{
Id = _idGenerator.NextId(),
EventId = eventId,
EventProjectId = eventProject.Id,
ProjectId = project.Id,
ProjectGroupId = 0,
GroupName = groupName,
BirthDateStart = new DateTime(1900, 1, 1),
BirthDateEnd = now.AddYears(1),
Gender = Gender.Unknown,
CreatedUserId = userId,
CreatedTime = now
};
await _groupRepository.InsertAsync(group);
existGroups.Add(group);
}
groupMap[groupName] = group.Id;
}
result[projectName] = new OrderBookProjectContext
{
Project = project,
EventProjectId = eventProject.Id,
GroupMap = groupMap
};
}
// 评分规则必须先配置,否则无法生成场次(与赛事编排确认一致)
var missingRule = result.Values.FirstOrDefault(p => p.Project.ScoreRuleId == null || p.Project.ScoreRuleId <= 0);
if (missingRule != null)
throw Oops.Bah($"项目【{missingRule.Project.Name}】尚未配置评分规则,请先在评分规则中配置后再导入");
return result;
}
// 场地建档(追加模式按名称复用),返回场地名 -> 场地上下文 的映射
private async Task<Dictionary<string, OrderBookVenueContext>> EnsureVenuesAsync(
OrderBookDraft draft, long eventId, long userId, DateTime now)
{
var venueNames = draft.Entries.Select(e => e.VenueName).Where(n => !string.IsNullOrWhiteSpace(n)).Distinct().ToList();
var existVenues = await _venueRepository.AsQueryable()
.Where(v => !v.DeleteMark && v.EventId == eventId).OrderBy(v => v.Sort).ToListAsync();
var result = new Dictionary<string, OrderBookVenueContext>();
foreach (var name in venueNames)
{
var venue = existVenues.FirstOrDefault(v => v.VenueName == name);
int venueIndex;
if (venue == null)
{
venueIndex = existVenues.Count + 1;
venue = new EventVenue
{
Id = _idGenerator.NextId(),
EventId = eventId,
VenueCode = $"V{venueIndex:00}",
VenueName = name,
Sort = venueIndex,
Status = AvailabilityStatus.Enable,
CreatedUserId = userId,
CreatedTime = now
};
await _venueRepository.InsertAsync(venue);
existVenues.Add(venue);
}
else
{
venueIndex = venue.Sort <= 0 ? existVenues.IndexOf(venue) + 1 : venue.Sort;
}
result[name] = new OrderBookVenueContext { Venue = venue, Index = venueIndex };
}
return result;
}
// 按 项目×组别×场地 生成场次,返回组合键 -> 场次 的映射
private async Task<Dictionary<string, EventSession>> EnsureSessionsAsync(
OrderBookDraft draft, long eventId,
Dictionary<string, OrderBookProjectContext> projectContext,
Dictionary<string, OrderBookVenueContext> venueContext,
long userId, DateTime now)
{
var combos = draft.Entries
.Select(e => (e.ProjectName, e.GroupName, e.VenueName))
.Distinct()
.ToList();
var result = new Dictionary<string, EventSession>();
var sessionSort = 0;
foreach (var combo in combos)
{
var pc = projectContext[combo.ProjectName];
var vc = venueContext[combo.VenueName];
var groupId = pc.GroupMap.GetValueOrDefault(combo.GroupName);
var exist = await _sessionRepository.GetFirstAsync(s =>
!s.DeleteMark && s.EventId == eventId &&
s.EventProjectId == pc.EventProjectId && s.VenueId == vc.Venue.Id &&
(groupId == null ? s.EventProjectGroupId == null : s.EventProjectGroupId == groupId));
if (exist != null)
{
result[SessionKey(combo.ProjectName, combo.GroupName, combo.VenueName)] = exist;
continue;
}
sessionSort++;
var session = new EventSession
{
Id = _idGenerator.NextId(),
EventId = eventId,
EventProjectId = pc.EventProjectId,
EventProjectGroupId = groupId,
VenueId = vc.Venue.Id,
ScoreRuleId = pc.Project.ScoreRuleId!.Value,
SessionNo = $"EP{pc.EventProjectId}-V{vc.Index:00}-{sessionSort:00}",
SessionName = $"{combo.ProjectName}-{combo.GroupName}-{combo.VenueName}",
SessionStatus = 0,
Version = 1,
Status = AvailabilityStatus.Enable,
CreatedUserId = userId,
CreatedTime = now
};
await _sessionRepository.InsertAsync(session);
result[SessionKey(combo.ProjectName, combo.GroupName, combo.VenueName)] = session;
}
return result;
}
// 生成出场记录,返回数量
private async Task<int> CreateEntriesAsync(
OrderBookDraft draft,
Dictionary<string, OrderBookProjectContext> projectContext,
Dictionary<string, OrderBookVenueContext> venueContext,
Dictionary<string, EventSession> sessionContext,
long userId, DateTime now)
{
// 统计每个场次已有的最大出场顺序,追加模式在此基础上递增
var sessionIds = sessionContext.Values.Select(s => s.Id).ToList();
var existOrders = await _entryRepository.AsQueryable()
.Where(e => !e.DeleteMark && sessionIds.Contains(e.SessionId))
.GroupBy(e => e.SessionId)
.Select(e => new { SessionId = e.SessionId, MaxOrder = SqlFunc.AggregateMax(e.PlayOrder) })
.ToListAsync();
var orderCounter = existOrders.ToDictionary(x => x.SessionId, x => x.MaxOrder);
var usedEntryNos = (await _entryRepository.AsQueryable()
.Where(e => !e.DeleteMark && sessionIds.Contains(e.SessionId))
.Select(e => new { e.SessionId, e.EntryNo }).ToListAsync())
.GroupBy(x => x.SessionId)
.ToDictionary(g => g.Key, g => g.Select(x => x.EntryNo).ToHashSet());
var toInsert = new List<EventSessionEntry>();
foreach (var d in draft.Entries)
{
var session = sessionContext[SessionKey(d.ProjectName, d.GroupName, d.VenueName)];
var pc = projectContext[d.ProjectName];
var vc = venueContext[d.VenueName];
orderCounter.TryGetValue(session.Id, out var order);
order++;
orderCounter[session.Id] = order;
var entryNo = !string.IsNullOrWhiteSpace(d.EntryNo) ? d.EntryNo! : $"{pc.Project.SortIndex:000}{vc.Index:00}{order:000}";
if (!usedEntryNos.TryGetValue(session.Id, out var noSet))
{
noSet = new HashSet<string>();
usedEntryNos[session.Id] = noSet;
}
// 自动编号撞号时追加顺序位,保证唯一
while (!noSet.Add(entryNo))
entryNo = $"{entryNo}-{order}";
toInsert.Add(new EventSessionEntry
{
Id = _idGenerator.NextId(),
SessionId = session.Id,
ParticipationRecordId = null,
RegistrationItemId = null,
SourceType = 1, // 秩序册导入
EntryNo = entryNo,
PlayOrder = order,
EntryStatus = 0,
IsRematch = false,
IsVoided = false,
ParticipantName = d.ParticipantName,
TeamName = null,
GroupName = d.GroupName,
Version = 1,
Status = AvailabilityStatus.Enable,
CreatedUserId = userId,
CreatedTime = now
});
}
if (toInsert.Any())
await _entryRepository.InsertRangeAsync(toInsert);
return toInsert.Count;
}
// 裁判档案 / 赛事裁判关系 / 可选打分账号,返回纳入的裁判数量
private async Task<int> EnsureRefereesAsync(
OrderBookDraft draft, long eventId, OrderBookConfirmInput input, long userId, DateTime now)
{
if (!draft.Referees.Any())
return 0;
string? password = null;
if (input.GenerateJudgeAccount)
{
password = input.DefaultPassword?.Trim();
if (string.IsNullOrWhiteSpace(password))
throw Oops.Bah("生成裁判账号时必须设置初始密码");
if (password.Length is < 6 or > 32)
throw Oops.Bah("裁判初始密码长度必须为6-32位");
}
var mobiles = draft.Referees.Select(r => r.Mobile).Distinct().ToList();
var existReferees = await _refereeRepository.AsQueryable().Where(r => !r.DeleteMark && mobiles.Contains(r.Mobile)).ToListAsync();
var existEventReferees = await _eventRefereeRepository.AsQueryable()
.Where(er => !er.DeleteMark && er.EventId == eventId).ToListAsync();
var existAppUsers = await _appUserRepository.AsQueryable().Where(u => !u.DeleteMark && mobiles.Contains(u.Mobile)).ToListAsync();
var newEventReferees = new List<EventReferee>();
var count = 0;
foreach (var d in draft.Referees)
{
var importedRoles = ParseRefereeRoles(d.RoleText);
var referee = existReferees.FirstOrDefault(r => r.Mobile == d.Mobile);
if (referee == null)
{
referee = new Core.Entities.Referee
{
Id = _idGenerator.NextId(),
Name = d.Name,
Mobile = d.Mobile,
AppUserId = null,
ProvinceCode = string.Empty,
ProvinceName = string.Empty,
CityCode = string.Empty,
CityName = string.Empty,
DistrictCode = string.Empty,
DistrictName = string.Empty,
Level = ParseRefereeLevel(d.LevelText),
CertificateUrl = string.Empty,
Roles = importedRoles,
AuditStatus = AuditStatus.Pass,
AuditUserId = userId,
AuditTime = now,
Status = AvailabilityStatus.Enable,
CreatedUserId = userId,
CreatedTime = now
};
await _refereeRepository.InsertAsync(referee);
existReferees.Add(referee);
}
else if (importedRoles != RefereeRole.None && !referee.Roles.HasFlag(importedRoles))
{
referee.Roles |= importedRoles;
referee.UpdatedUserId = userId;
referee.UpdatedTime = now;
await _refereeRepository.UpdateAsync(referee);
}
if (input.GenerateJudgeAccount)
{
var appUser = existAppUsers.FirstOrDefault(u => u.Mobile == d.Mobile);
if (appUser == null)
{
var appUserId = _idGenerator.NextId();
appUser = new Core.Entities.AppUser
{
Id = appUserId,
PersonType = PersonType.User,
MiniProgramRole = MiniProgramRole.Referee,
Name = d.Name,
Mobile = d.Mobile,
Gender = Gender.Unknown,
Password = MD5Encryption.Encrypt($"{_idGenerator.Encode(appUserId)}{password!}"),
AuditStatus = AuditStatus.Pass,
AuditUserId = userId,
AuditTime = now,
Status = AvailabilityStatus.Enable,
CreatedUserId = userId,
CreatedTime = now
};
await _appUserRepository.InsertAsync(appUser);
existAppUsers.Add(appUser);
referee.AppUserId = appUser.Id;
referee.UpdatedUserId = userId;
referee.UpdatedTime = now;
await _refereeRepository.UpdateAsync(referee);
}
else if (referee.AppUserId == null)
{
referee.AppUserId = appUser.Id;
referee.UpdatedUserId = userId;
referee.UpdatedTime = now;
await _refereeRepository.UpdateAsync(referee);
}
}
if (existEventReferees.All(er => er.RefereeId != referee.Id))
{
newEventReferees.Add(new EventReferee
{
Id = _idGenerator.NextId(),
EventId = eventId,
RefereeId = referee.Id,
RefereeApplicationId = null,
SourceType = 3, // 秩序册导入
Status = AvailabilityStatus.Enable,
CreatedUserId = userId,
CreatedTime = now
});
}
count++;
}
if (newEventReferees.Any())
await _eventRefereeRepository.InsertRangeAsync(newEventReferees);
return count;
}
#endregion
#region 辅助
private static string BatchKey(long batchId) => $"orderbook:batch:{batchId}";
private static string CurrentKey(long eventId, long userId) => $"orderbook:current:{eventId}:{userId}";
private static string ConfirmedKey(long batchId) => $"orderbook:confirmed:{batchId}";
private static string SessionKey(string project, string group, string venue) => $"{project}|{group}|{venue}";
private static OrderBookParseIssue Issue(int row, string column, string message, string level = "error")
=> new() { Row = row, Column = column, Message = message, Level = level };
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static bool IsAllBlank(params string?[] values) => values.All(string.IsNullOrWhiteSpace);
private static RefereeLevel ParseRefereeLevel(string? text)
{
return text switch
{
"国际级" => RefereeLevel.International,
"国家级" => RefereeLevel.National,
"一级" => RefereeLevel.LevelOne,
"二级" => RefereeLevel.LevelTwo,
"三级" => RefereeLevel.LevelThree,
_ => RefereeLevel.LevelThree
};
}
private static RefereeRole ParseRefereeRoles(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return RefereeRole.None;
var roles = RefereeRole.None;
if (text.Contains("主裁", StringComparison.OrdinalIgnoreCase)) roles |= RefereeRole.Chief;
if (text.Contains("表现", StringComparison.OrdinalIgnoreCase)) roles |= RefereeRole.Performance;
if (text.Contains("难度", StringComparison.OrdinalIgnoreCase)) roles |= RefereeRole.Difficulty;
if (text.Contains("创意", StringComparison.OrdinalIgnoreCase)) roles |= RefereeRole.Choreography;
if (text.Contains("完成质量", StringComparison.OrdinalIgnoreCase)) roles |= RefereeRole.CompletionQuality;
return roles;
}
private sealed class OrderBookProjectContext
{
public Core.Entities.Project Project { get; set; } = null!;
public long EventProjectId { get; set; }
public Dictionary<string, long?> GroupMap { get; set; } = new();
}
private sealed class OrderBookVenueContext
{
public EventVenue Venue { get; set; } = null!;
public int Index { get; set; }
}
#endregion
}
/// <summary>
/// 秩序册导入模板导出器:出场秩序 + 裁判名单 两个工作表
/// </summary>
internal sealed class OrderBookTemplateExporter : ExcelExportBase
{
public override Task<byte[]> ExportAsync()
{
var headerColor = Color.FromArgb(217, 225, 242);
#region 出场秩序
CreateWorksheet("出场秩序");
var entryHeaders = new[] { "序号", "场地名称", "项目名称", "组别名称", "出场编号", "姓名/队名", "单位", "联系电话", "证件号", "备注" };
SetMergeTitle("秩序册 - 出场秩序(带*为必填,组别留空默认归入公开组,出场编号留空自动生成)", 1, 1, 1, entryHeaders.Length, headerColor, 12);
SetHeaders(entryHeaders, 2, headerColor);
var sample = new[] { "1", "1号场地", "男子单人", "公开组", "", "张三", "某某单位", "13800000000", "", "" };
for (var c = 0; c < sample.Length; c++)
Worksheet.Cells[3, c + 1].Value = sample[c];
SetDataRowStyle(3, entryHeaders.Length);
SetColumnWidths(new double[] { 6, 14, 16, 12, 12, 14, 20, 14, 20, 16 });
FreezePanes(3, 1);
#endregion
#region 裁判名单
CreateWorksheet("裁判名单");
var refereeHeaders = new[] { "序号", "姓名", "手机号", "裁判角色", "裁判等级", "负责场地", "负责项目" };
SetMergeTitle("秩序册 - 裁判名单(姓名、手机号必填,无可不填本工作表)", 1, 1, 1, refereeHeaders.Length, headerColor, 12);
SetHeaders(refereeHeaders, 2, headerColor);
var refereeSample = new[] { "1", "李四", "13900000000", "主裁", "一级", "1号场地", "男子单人" };
for (var c = 0; c < refereeSample.Length; c++)
Worksheet.Cells[3, c + 1].Value = refereeSample[c];
SetDataRowStyle(3, refereeHeaders.Length);
SetColumnWidths(new double[] { 6, 12, 14, 14, 12, 14, 16 });
FreezePanes(3, 1);
#endregion
return GetAsByteArrayAsync();
}
}