2026-10-10 09:04:52 +08:00

939 lines
43 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 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<ProjectGroup> _projectGroupRepository;
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<ProjectGroup> projectGroupRepository,
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;
_projectGroupRepository = projectGroupRepository;
_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)
throw Oops.Bah("请先选择已配置项目和组别的赛事,再上传秩序册");
if (!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);
if (draftEntries.Count == 0)
issues.Add(Issue(0, "出场秩序", "秩序册没有可导入的出场数据"));
var catalog = await LoadProjectCatalogAsync(draftEntries, eventId.Value);
issues.AddRange(ValidateProjects(draftEntries, catalog));
if (mode == "append") await ValidateAppendRulesAsync(draftEntries, eventId.Value, 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(e => !issues.Any(i => i.Level == "error" && i.Row == e.Row && i.Sheet == EntrySheetName))
+ draft.Referees.Count(r => !issues.Any(i => i.Level == "error" && i.Row == r.Row && i.Sheet == RefereeSheetName)),
ErrorCount = errorCount,
WarningCount = warningCount,
Preview = preview,
Errors = issues
};
}
private static 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 = row.ExcelRowNumber > 0 ? row.ExcelRowNumber : i + 3;
// 整行为空直接跳过
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 static 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 = row.ExcelRowNumber > 0 ? row.ExcelRowNumber : 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(RefereeIssue(rowNo, "姓名", "裁判姓名为必填项"));
if (string.IsNullOrWhiteSpace(mobile))
issues.Add(RefereeIssue(rowNo, "手机号", "裁判手机号为必填项"));
else if (!Regex.IsMatch(mobile, @"^1[3-9]\d{9}$"))
issues.Add(RefereeIssue(rowNo, "手机号", $"裁判手机号「{mobile}」格式不正确"));
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(mobile) ||
!Regex.IsMatch(mobile, @"^1[3-9]\d{9}$"))
continue;
if (!seenMobiles.Add(mobile))
{
issues.Add(RefereeIssue(rowNo, "手机号", $"裁判手机号「{mobile}」重复,已自动合并", "warning"));
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 => (e.ProjectName, e.ScoreRuleName, e.GroupName, e.SessionName, e.VenueName))
.Distinct().Count(),
EntryCount = draft.Entries.Count,
RefereeCount = draft.Referees.Select(r => r.Mobile).Distinct().Count()
};
}
private async Task<OrderBookProjectCatalog> LoadProjectCatalogAsync(
List<OrderBookDraftEntry> entries, long eventId)
{
var projectNames = entries.Select(x => x.ProjectName).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
var ruleNames = entries.Select(x => x.ScoreRuleName).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
var projects = await _projectRepository.AsQueryable()
.Where(x => !x.DeleteMark && projectNames.Contains(x.Name.Trim())).ToListAsync();
var rules = await _scoreRuleRepository.AsQueryable()
.Where(x => !x.DeleteMark && ruleNames.Contains(x.RuleName.Trim())).ToListAsync();
var projectIds = projects.Select(x => x.Id).ToList();
var eventProjects = await _eventProjectRepository.AsQueryable()
.Where(x => !x.DeleteMark && x.EventId == eventId && projectIds.Contains(x.ProjectId)).ToListAsync();
var projectGroups = await _projectGroupRepository.AsQueryable()
.Where(x => !x.DeleteMark && projectIds.Contains(x.ProjectId)).ToListAsync();
var eventGroups = await _groupRepository.AsQueryable()
.Where(x => !x.DeleteMark && x.EventId == eventId && projectIds.Contains(x.ProjectId)).ToListAsync();
return new OrderBookProjectCatalog
{
EventId = eventId, Projects = projects, Rules = rules, EventProjects = eventProjects,
ProjectGroups = projectGroups, EventGroups = eventGroups
};
}
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(RefereeIssue(referee.Row, "手机号", $"手机号【{referee.Mobile}】尚未完成裁判注册认证"));
}
private async Task ValidateAppendRulesAsync(List<OrderBookDraftEntry> entries, long eventId,
List<OrderBookParseIssue> issues)
{
var projectIds = entries.Where(x => x.EventProjectId > 0).Select(x => x.EventProjectId).Distinct().ToList();
var sessions = await _sessionRepository.AsQueryable()
.Where(x => !x.DeleteMark && x.EventId == eventId && x.SessionStatus != 90 && projectIds.Contains(x.EventProjectId))
.ToListAsync();
var rulesByProject = sessions.ToLookup(x => x.EventProjectId, x => x.ScoreRuleId);
foreach (var entry in entries.Where(x => x.EventProjectId > 0 && x.ScoreRuleId > 0))
{
if (rulesByProject[entry.EventProjectId].Any(x => x != entry.ScoreRuleId))
issues.Add(Issue(entry.Row, "计分规则", $"项目【{entry.ProjectName}】已有场次的规则与本次导入不一致,请先核查历史编排"));
}
}
#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("赛事不存在或已删除");
// 旧版本缓存没有真实关联ID,必须重新解析;不能在确认时重新选另一个同名项目。
var catalog = await LoadProjectCatalogAsync(draft.Entries, eventId);
var issues = ValidateProjects(draft.Entries, catalog, verifyResolvedIds: true);
if (draft.ImportMode == "append") await ValidateAppendRulesAsync(draft.Entries, eventId, issues);
await ValidateRefereesAsync(draft.Referees, issues);
if (issues.Any(x => x.Level == "error"))
throw Oops.Bah(string.Join(";", issues.Where(x => x.Level == "error")
.Take(10).Select(x => x.Row > 0 ? $"{x.Sheet}第{x.Row}行:{x.Message}" : x.Message)) + "。请重新上传并解析");
var projectContext = BuildProjectContext(draft, catalog);
var now = DateTime.Now;
// 所有配置复核通过后才覆盖旧编排;后续写入由工作单元统一回滚。
if (draft.ImportMode == "overwrite")
await SoftDeleteArrangementAsync(eventId);
// 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);
}
// 仅使用解析并复核通过的真实ID,不再通过导入补建项目或组别。
private static Dictionary<long, OrderBookProjectContext> BuildProjectContext(
OrderBookDraft draft, OrderBookProjectCatalog catalog)
{
var projects = catalog.Projects.ToDictionary(x => x.Id);
return draft.Entries.GroupBy(x => x.EventProjectId).ToDictionary(x => x.Key, x =>
{
var entry = x.First();
return new OrderBookProjectContext
{
Project = projects[entry.ProjectId], EventProjectId = entry.EventProjectId,
ScoreRuleId = entry.ScoreRuleId
};
});
}
// 场地建档(追加模式按名称复用),返回场地名 -> 场地上下文 的映射
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<OrderBookSessionKey, EventSession>> EnsureSessionsAsync(
OrderBookDraft draft, long eventId,
Dictionary<long, OrderBookProjectContext> projectContext,
Dictionary<string, OrderBookVenueContext> venueContext,
long userId, DateTime now)
{
var combos = draft.Entries
.Select(OrderBookSessionKey.From)
.Distinct()
.ToList();
var result = new Dictionary<OrderBookSessionKey, EventSession>();
var sessionSort = 0;
foreach (var combo in combos)
{
var pc = projectContext[combo.EventProjectId];
var vc = venueContext[combo.VenueName];
var groupId = combo.EventProjectGroupId;
var exist = await _sessionRepository.GetFirstAsync(s =>
!s.DeleteMark && s.EventId == eventId &&
s.EventProjectId == pc.EventProjectId && s.VenueId == vc.Venue.Id &&
s.SessionName == combo.SessionName &&
s.EventProjectGroupId == groupId);
if (exist != null)
{
if (exist.ScoreRuleId != pc.ScoreRuleId)
throw Oops.Bah($"场次【{combo.SessionName}】的规则与导入项目不一致,请先核查历史编排");
result[combo] = 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[combo] = session;
}
return result;
}
// 生成出场记录,返回数量
private async Task<int> CreateEntriesAsync(
OrderBookDraft draft,
Dictionary<long, OrderBookProjectContext> projectContext,
Dictionary<string, OrderBookVenueContext> venueContext,
Dictionary<OrderBookSessionKey, 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[OrderBookSessionKey.From(d)];
var pc = projectContext[d.EventProjectId];
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
private static List<OrderBookParseIssue> ValidateProjects(
IReadOnlyList<OrderBookDraftEntry> entries, OrderBookProjectCatalog catalog,
bool verifyResolvedIds = false)
{
static string Normalize(string? value) => value?.Trim() ?? string.Empty;
var issues = new List<OrderBookParseIssue>();
var projectsByName = catalog.Projects.Where(x => !x.DeleteMark)
.ToLookup(x => Normalize(x.Name), StringComparer.Ordinal);
var rulesByName = catalog.Rules.Where(x => !x.DeleteMark && x.Status == AvailabilityStatus.Enable)
.ToLookup(x => Normalize(x.RuleName), StringComparer.Ordinal);
var eventProjectsByProject = catalog.EventProjects
.Where(x => !x.DeleteMark && x.EventId == catalog.EventId).ToLookup(x => x.ProjectId);
var baseGroups = catalog.ProjectGroups.Where(x => !x.DeleteMark).ToDictionary(x => x.Id);
var eventGroupsByProject = catalog.EventGroups
.Where(x => !x.DeleteMark && x.EventId == catalog.EventId).ToLookup(x => x.EventProjectId);
foreach (var entry in entries)
{
var projectName = Normalize(entry.ProjectName);
var ruleName = Normalize(entry.ScoreRuleName);
var groupName = Normalize(entry.GroupName);
// 必填错误由行解析返回,避免重复提示。
if (projectName.Length == 0 || ruleName.Length == 0 || groupName.Length == 0) continue;
void AddError(string column, string message) => issues.Add(new OrderBookParseIssue
{
Row = entry.Row, Column = column, Message = message, Level = "error"
});
var rules = rulesByName[ruleName].ToList();
if (rules.Count != 1)
{
AddError("计分规则", rules.Count == 0
? $"计分规则【{ruleName}】不存在或已停用,请先维护或从最新版模板选择"
: $"计分规则【{ruleName}】存在多条同名配置,无法唯一匹配,请先整理平台配置");
continue;
}
var rule = rules[0];
var namedProjects = projectsByName[projectName].Where(x => x.Status == AvailabilityStatus.Enable).ToList();
var matchedProjects = namedProjects.Where(x => x.ScoreRuleId == rule.Id).ToList();
var candidates = matchedProjects.SelectMany(project => eventProjectsByProject[project.Id]
.Select(eventProject => (Project: project, EventProject: eventProject))).ToList();
if (candidates.Count != 1)
{
var message = candidates.Count > 1
? $"当前赛事的项目【{projectName}】+计分规则【{ruleName}】匹配到多条记录,请先整理重复的项目配置"
: namedProjects.Count == 0
? $"项目【{projectName}】不存在或已停用,请先维护平台项目并加入当前赛事"
: matchedProjects.Count == 0
? $"项目【{projectName}】没有配置计分规则【{ruleName}】,请统一平台配置与秩序册"
: $"项目【{projectName}】+计分规则【{ruleName}】尚未加入当前赛事,请先在赛事中配置";
AddError("项目名称", message);
continue;
}
var (project, eventProject) = candidates[0];
var projectEventGroups = eventGroupsByProject[eventProject.Id].ToList();
var groups = projectEventGroups.Where(x => Normalize(x.GroupName) == groupName).ToList();
if (groups.Count != 1)
{
AddError("组别名称", groups.Count == 0
? $"项目【{projectName}】+计分规则【{ruleName}】未配置赛事组别【{groupName}】,请先维护并加入当前赛事"
: $"项目【{projectName}】+计分规则【{ruleName}】存在重复赛事组别【{groupName}】,无法唯一匹配");
continue;
}
var group = groups[0];
if (group.ProjectGroupId <= 0 || group.ProjectId != project.Id ||
!baseGroups.TryGetValue(group.ProjectGroupId, out var baseGroup) ||
baseGroup.ProjectId != project.Id || Normalize(baseGroup.GroupName) != groupName ||
projectEventGroups.Count(x => x.ProjectGroupId == group.ProjectGroupId) != 1)
{
AddError("组别名称", $"项目【{projectName}】+计分规则【{ruleName}】的组别【{groupName}】关联无效或重复,请先修复平台及赛事组别配置");
continue;
}
if (verifyResolvedIds && (entry.ProjectId != project.Id || entry.EventProjectId != eventProject.Id ||
entry.ScoreRuleId != rule.Id || entry.ProjectGroupId != baseGroup.Id || entry.EventProjectGroupId != group.Id))
{
AddError("项目名称", $"项目【{projectName}】的项目、组别或规则配置已变化,请重新上传并解析秩序册");
continue;
}
entry.ProjectId = project.Id;
entry.EventProjectId = eventProject.Id;
entry.ScoreRuleId = rule.Id;
entry.ProjectGroupId = baseGroup.Id;
entry.EventProjectGroupId = group.Id;
}
return issues;
}
#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 OrderBookParseIssue Issue(int row, string column, string message, string level = "error")
=> new() { Row = row, Column = column, Message = message, Level = level };
private static OrderBookParseIssue RefereeIssue(int row, string column, string message, string level = "error")
=> new() { Sheet = RefereeSheetName, 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);
/// <summary>导入匹配只读取已维护的配置,不创建或修复业务数据。</summary>
private sealed class OrderBookProjectCatalog
{
public long EventId { get; init; }
public List<Core.Entities.Project> Projects { get; init; } = new();
public List<Core.Entities.ScoreRule> Rules { get; init; } = new();
public List<EventProject> EventProjects { get; init; } = new();
public List<ProjectGroup> ProjectGroups { get; init; } = new();
public List<EventProjectGroup> EventGroups { get; init; } = new();
}
/// <summary>用真实赛事项目、组别ID区分同名项目;结构化键避免名称含分隔符时碰撞。</summary>
private readonly record struct OrderBookSessionKey(long EventProjectId, long EventProjectGroupId, string SessionName, string VenueName)
{
public static OrderBookSessionKey From(OrderBookDraftEntry entry) =>
new(entry.EventProjectId, entry.EventProjectGroupId, entry.SessionName, entry.VenueName);
}
private sealed class OrderBookProjectContext
{
public Core.Entities.Project Project { get; set; } = null!;
public long EventProjectId { get; set; }
public long ScoreRuleId { get; set; }
}
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();
}
}