难度裁判:跳绳运动员难度(DJ)等级从0(间歇)-5;摇绳运动员难度(DT)等级从1-5 ;两种难度每个等级都应包含 x-、x、x+ (x为等级) 主裁:失误项放到最下面
867 lines
37 KiB
C#
867 lines
37 KiB
C#
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();
|
||
}
|
||
}
|