2026-10-09 14:20:22 +08:00

895 lines
39 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.ScoreRule> _scoreRuleRepository;
private readonly ISqlSugarRepository<EventJudgeAssignment> _judgeAssignmentRepository;
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.ScoreRule> scoreRuleRepository,
ISqlSugarRepository<EventJudgeAssignment> judgeAssignmentRepository,
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;
_scoreRuleRepository = scoreRuleRepository;
_judgeAssignmentRepository = judgeAssignmentRepository;
_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 scoreRuleNames = await _scoreRuleRepository.AsQueryable()
.Where(x => !x.DeleteMark && x.Status == AvailabilityStatus.Enable)
.Select(x => x.RuleName)
.ToListAsync();
scoreRuleNames = scoreRuleNames
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.Ordinal)
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
if (scoreRuleNames.Count == 0)
throw Oops.Bah("当前没有已启用的计分规则,请先配置计分规则后再下载模板");
var bytes = await new OrderBookTemplateExporter(scoreRuleNames).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) ? "overwrite" : 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);
await ValidateScoreRulesAsync(draftEntries, issues);
await ValidateRefereesAsync(draftReferees, issues);
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,
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 + 3; // 模板第 1 行为说明标题,第 2 行为表头
// 整行为空直接跳过
if (IsAllBlank(row.ProjectName, row.ScoreRuleName, row.GroupName, row.ParticipantName,
row.TeamName, row.UnitName, row.SessionName, row.VenueName))
continue;
var project = Trim(row.ProjectName);
var scoreRule = Trim(row.ScoreRuleName);
var group = Trim(row.GroupName);
var participant = Trim(row.ParticipantName);
var session = Trim(row.SessionName);
var venue = Trim(row.VenueName);
if (string.IsNullOrWhiteSpace(project))
issues.Add(Issue(rowNo, "项目名称", "项目名称为必填项"));
if (string.IsNullOrWhiteSpace(scoreRule))
issues.Add(Issue(rowNo, "计分规则", "计分规则为必填项"));
if (string.IsNullOrWhiteSpace(group))
issues.Add(Issue(rowNo, "组别名称", "组别名称为必填项"));
if (string.IsNullOrWhiteSpace(participant))
issues.Add(Issue(rowNo, "姓名", "姓名为必填项"));
if (string.IsNullOrWhiteSpace(session))
issues.Add(Issue(rowNo, "场次", "场次为必填项"));
if (string.IsNullOrWhiteSpace(venue))
issues.Add(Issue(rowNo, "场地", "场地为必填项"));
result.Add(new OrderBookDraftEntry
{
Row = rowNo,
VenueName = venue,
ProjectName = project,
ScoreRuleName = scoreRule,
GroupName = group,
SessionName = session,
ParticipantName = participant,
TeamName = Trim(row.TeamName),
UnitName = Trim(row.UnitName)
});
}
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 + 3;
if (IsAllBlank(row.Name, row.Mobile))
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!
});
}
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 => SessionKey(e.ProjectName, e.GroupName, e.SessionName, e.VenueName))
.Distinct().Count(),
EntryCount = draft.Entries.Count,
RefereeCount = draft.Referees.Select(r => r.Mobile).Distinct().Count()
};
}
private async Task ValidateScoreRulesAsync(
List<OrderBookDraftEntry> entries, List<OrderBookParseIssue> issues)
{
var inconsistentProjects = entries
.Where(x => !string.IsNullOrWhiteSpace(x.ProjectName) && !string.IsNullOrWhiteSpace(x.ScoreRuleName))
.GroupBy(x => x.ProjectName)
.Where(x => x.Select(item => item.ScoreRuleName).Distinct().Count() > 1)
.SelectMany(x => x)
.ToList();
foreach (var entry in inconsistentProjects)
issues.Add(Issue(entry.Row, "计分规则", $"同一项目【{entry.ProjectName}】只能使用一种计分规则"));
var ruleNames = entries.Select(x => x.ScoreRuleName)
.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
if (ruleNames.Count == 0) return;
var existingNames = await _scoreRuleRepository.AsQueryable()
.Where(x => !x.DeleteMark && x.Status == AvailabilityStatus.Enable && ruleNames.Contains(x.RuleName))
.Select(x => x.RuleName).ToListAsync();
foreach (var entry in entries.Where(x => !string.IsNullOrWhiteSpace(x.ScoreRuleName)
&& !existingNames.Contains(x.ScoreRuleName)))
issues.Add(Issue(entry.Row, "计分规则",
$"计分规则【{entry.ScoreRuleName}】不存在或已停用,请从最新版模板的「计分规则参考」工作表选择"));
}
private async Task ValidateRefereesAsync(
List<OrderBookDraftReferee> referees, List<OrderBookParseIssue> issues)
{
var mobiles = referees.Select(x => x.Mobile).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
if (mobiles.Count == 0) return;
var registeredMobiles = await _refereeRepository.AsQueryable()
.Where(x => !x.DeleteMark && x.AuditStatus == AuditStatus.Pass && mobiles.Contains(x.Mobile))
.Select(x => x.Mobile).ToListAsync();
foreach (var referee in referees.Where(x => !registeredMobiles.Contains(x.Mobile)))
issues.Add(Issue(referee.Row, "手机号", $"手机号【{referee.Mobile}】尚未完成裁判注册认证"));
}
#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 scoreRuleContext = await GetScoreRuleContextAsync(draft);
var projectContext = await EnsureProjectsAsync(draft, eventId, scoreRuleContext, 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 _judgeAssignmentRepository.SoftDeleteAsync(x => sessionIds.Contains(x.SessionId));
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, Dictionary<string, Core.Entities.ScoreRule> scoreRuleContext,
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 ruleName = draft.Entries.First(x => x.ProjectName == projectName).ScoreRuleName;
var scoreRule = scoreRuleContext[ruleName];
var project = existProjects.FirstOrDefault(p => p.Name == projectName);
if (project == null)
{
project = new Core.Entities.Project
{
Id = _idGenerator.NextId(),
Name = projectName,
ProjectType = 0,
ScoreRuleId = scoreRule.Id,
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,
ScoreRuleId = scoreRule.Id,
GroupMap = groupMap
};
}
return result;
}
private async Task<Dictionary<string, Core.Entities.ScoreRule>> GetScoreRuleContextAsync(OrderBookDraft draft)
{
var names = draft.Entries.Select(x => x.ScoreRuleName).Distinct().ToList();
var rules = await _scoreRuleRepository.AsQueryable()
.Where(x => !x.DeleteMark && x.Status == AvailabilityStatus.Enable && names.Contains(x.RuleName))
.ToListAsync();
var result = rules.ToDictionary(x => x.RuleName);
var missing = names.FirstOrDefault(x => !result.ContainsKey(x));
if (missing != null) throw Oops.Bah($"计分规则【{missing}】不存在或已停用,请重新上传秩序册");
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.SessionName, 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 &&
s.SessionName == combo.SessionName &&
(groupId == null ? s.EventProjectGroupId == null : s.EventProjectGroupId == groupId));
if (exist != null)
{
result[SessionKey(combo.ProjectName, combo.GroupName, combo.SessionName, 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.ScoreRuleId,
SessionNo = $"EP{pc.EventProjectId}-V{vc.Index:00}-{sessionSort:000}",
SessionName = combo.SessionName,
SessionStatus = 0,
Version = 1,
Status = AvailabilityStatus.Enable,
CreatedUserId = userId,
CreatedTime = now
};
await _sessionRepository.InsertAsync(session);
result[SessionKey(combo.ProjectName, combo.GroupName, combo.SessionName, 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.SessionName, 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 = $"{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 = d.TeamName,
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 && r.AuditStatus == AuditStatus.Pass && 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 referee = existReferees.FirstOrDefault(r => r.Mobile == d.Mobile);
if (referee == null)
throw Oops.Bah($"裁判【{d.Name}】尚未完成注册认证,请重新上传秩序册");
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 session, string venue)
=> $"{project}|{group}|{session}|{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 sealed class OrderBookProjectContext
{
public Core.Entities.Project Project { get; set; } = null!;
public long EventProjectId { get; set; }
public long ScoreRuleId { 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
{
private const int EntryInputEndRow = 1000;
private readonly IReadOnlyList<string> _scoreRuleNames;
public OrderBookTemplateExporter(IReadOnlyList<string> scoreRuleNames)
{
_scoreRuleNames = scoreRuleNames;
}
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);
SetDataRowStyle(3, entryHeaders.Length);
var scoreRuleValidation = Worksheet.DataValidations.AddListValidation($"B3:B{EntryInputEndRow}");
scoreRuleValidation.Formula.ExcelFormula = $"'计分规则参考'!$B$3:$B${_scoreRuleNames.Count + 2}";
scoreRuleValidation.AllowBlank = false;
scoreRuleValidation.ShowInputMessage = true;
scoreRuleValidation.PromptTitle = "请选择计分规则";
scoreRuleValidation.Prompt = "下拉列表来自当前已启用的计分规则。";
scoreRuleValidation.ShowErrorMessage = true;
scoreRuleValidation.ErrorTitle = "计分规则无效";
scoreRuleValidation.Error = "请选择下拉列表中的计分规则。";
SetColumnWidths(new double[] { 18, 22, 14, 22, 18, 22, 22, 16 });
FreezePanes(3, 1);
#endregion
#region 裁判名单
CreateWorksheet("裁判名单");
var refereeHeaders = new[] { "序号", "姓名*", "手机号*" };
SetMergeTitle("秩序册 - 裁判名单(须完成注册认证;无裁判可留空)", 1, 1, 1, refereeHeaders.Length, headerColor, 12);
SetHeaders(refereeHeaders, 2, headerColor);
SetDataRowStyle(3, refereeHeaders.Length);
SetColumnWidths(new double[] { 10, 18, 22 });
FreezePanes(3, 1);
#endregion
#region 计分规则参考
CreateWorksheet("计分规则参考");
var scoreRuleHeaders = new[] { "序号", "计分规则名称" };
SetMergeTitle("当前已启用的计分规则", 1, 1, 1, scoreRuleHeaders.Length, headerColor, 12);
SetHeaders(scoreRuleHeaders, 2, headerColor);
for (var i = 0; i < _scoreRuleNames.Count; i++)
{
Worksheet.Cells[i + 3, 1].Value = i + 1;
Worksheet.Cells[i + 3, 2].Value = _scoreRuleNames[i];
SetDataRowStyle(i + 3, scoreRuleHeaders.Length);
}
SetColumnWidths(new double[] { 8, 36 });
FreezePanes(3, 1);
#endregion
return GetAsByteArrayAsync();
}
}