2026-08-30 21:46:05 +08:00
|
|
|
|
using System.Drawing;
|
|
|
|
|
|
using YD_Event.Application.Auth;
|
2026-09-04 17:15:03 +08:00
|
|
|
|
using YD_Event.Application.Common.Excel;
|
2026-08-30 21:46:05 +08:00
|
|
|
|
using YD_Event.Application.OrderBook.Dtos;
|
|
|
|
|
|
using YD_Event.Core.Domain.Export;
|
|
|
|
|
|
|
|
|
|
|
|
namespace YD_Event.Application.OrderBook;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 秩序册导入接口(下载模板 / 同步解析 / 确认落库)
|
|
|
|
|
|
/// </summary>
|
2026-09-06 22:33:52 +08:00
|
|
|
|
[ApiDescriptionSettings("打分系统", Name = "order-book")]
|
2026-09-08 16:27:27 +08:00
|
|
|
|
[Route("api/orderbook")]
|
2026-08-30 21:46:05 +08:00
|
|
|
|
[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;
|
2026-10-10 09:04:52 +08:00
|
|
|
|
private readonly ISqlSugarRepository<ProjectGroup> _projectGroupRepository;
|
2026-08-30 21:46:05 +08:00
|
|
|
|
private readonly ISqlSugarRepository<EventVenue> _venueRepository;
|
|
|
|
|
|
private readonly ISqlSugarRepository<EventSession> _sessionRepository;
|
|
|
|
|
|
private readonly ISqlSugarRepository<EventSessionEntry> _entryRepository;
|
2026-10-08 10:12:54 +08:00
|
|
|
|
private readonly ISqlSugarRepository<Core.Entities.ScoreRule> _scoreRuleRepository;
|
|
|
|
|
|
private readonly ISqlSugarRepository<EventJudgeAssignment> _judgeAssignmentRepository;
|
2026-08-30 21:46:05 +08:00
|
|
|
|
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,
|
2026-10-10 09:04:52 +08:00
|
|
|
|
ISqlSugarRepository<ProjectGroup> projectGroupRepository,
|
2026-08-30 21:46:05 +08:00
|
|
|
|
ISqlSugarRepository<EventVenue> venueRepository,
|
|
|
|
|
|
ISqlSugarRepository<EventSession> sessionRepository,
|
|
|
|
|
|
ISqlSugarRepository<EventSessionEntry> entryRepository,
|
2026-10-08 10:12:54 +08:00
|
|
|
|
ISqlSugarRepository<Core.Entities.ScoreRule> scoreRuleRepository,
|
|
|
|
|
|
ISqlSugarRepository<EventJudgeAssignment> judgeAssignmentRepository,
|
2026-08-30 21:46:05 +08:00
|
|
|
|
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;
|
2026-10-10 09:04:52 +08:00
|
|
|
|
_projectGroupRepository = projectGroupRepository;
|
2026-08-30 21:46:05 +08:00
|
|
|
|
_venueRepository = venueRepository;
|
|
|
|
|
|
_sessionRepository = sessionRepository;
|
|
|
|
|
|
_entryRepository = entryRepository;
|
2026-10-08 10:12:54 +08:00
|
|
|
|
_scoreRuleRepository = scoreRuleRepository;
|
|
|
|
|
|
_judgeAssignmentRepository = judgeAssignmentRepository;
|
2026-08-30 21:46:05 +08:00
|
|
|
|
_refereeRepository = refereeRepository;
|
|
|
|
|
|
_eventRefereeRepository = eventRefereeRepository;
|
|
|
|
|
|
_appUserRepository = appUserRepository;
|
|
|
|
|
|
_cache = cache;
|
|
|
|
|
|
_idGenerator = idGenerator;
|
|
|
|
|
|
_authManager = authManager;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#region 下载模板
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 下载秩序册导入模板
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="projectMode"></param>
|
|
|
|
|
|
/// <returns></returns>
|
|
|
|
|
|
[DisplayName("下载秩序册导入模板")]
|
|
|
|
|
|
[HttpGet("template")]
|
2026-09-29 22:21:19 +08:00
|
|
|
|
public async Task<IActionResult> DownloadTemplate([FromQuery] string? projectMode)
|
2026-08-30 21:46:05 +08:00
|
|
|
|
{
|
2026-10-09 14:20:22 +08:00
|
|
|
|
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();
|
2026-08-30 21:46:05 +08:00
|
|
|
|
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();
|
2026-09-04 17:15:03 +08:00
|
|
|
|
if (ext is not ".xlsx" and not ".xls")
|
|
|
|
|
|
throw Oops.Bah("仅支持 .xls/.xlsx 格式的秩序册文件");
|
2026-08-30 21:46:05 +08:00
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
if (!eventId.HasValue || eventId.Value <= 0)
|
|
|
|
|
|
throw Oops.Bah("请先选择已配置项目和组别的赛事,再上传秩序册");
|
|
|
|
|
|
if (!await _eventRepository.IsAnyAsync(e => e.Id == eventId.Value && !e.DeleteMark))
|
2026-08-30 21:46:05 +08:00
|
|
|
|
throw Oops.Bah("赛事不存在或已删除");
|
|
|
|
|
|
|
2026-10-08 10:12:54 +08:00
|
|
|
|
var mode = string.IsNullOrWhiteSpace(importMode) ? "overwrite" : importMode.Trim().ToLowerInvariant();
|
2026-08-30 21:46:05 +08:00
|
|
|
|
if (mode != "append" && mode != "overwrite")
|
|
|
|
|
|
throw Oops.Bah("导入模式只能为 append(追加)或 overwrite(覆盖)");
|
|
|
|
|
|
|
|
|
|
|
|
List<OrderBookEntryRow> entryRows;
|
|
|
|
|
|
List<OrderBookRefereeRow> refereeRows;
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
using var stream = file.OpenReadStream();
|
2026-09-04 17:15:03 +08:00
|
|
|
|
// NPOI 统一读取,WorkbookFactory 按文件头自动识别 .xls/.xlsx
|
|
|
|
|
|
using var workbook = NpoiExcelReader.CreateWorkbook(stream);
|
|
|
|
|
|
entryRows = NpoiExcelReader.ReadSheet<OrderBookEntryRow>(workbook, EntrySheetName);
|
|
|
|
|
|
refereeRows = NpoiExcelReader.ReadSheet<OrderBookRefereeRow>(workbook, RefereeSheetName);
|
2026-08-30 21:46:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-10-10 09:04:52 +08:00
|
|
|
|
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);
|
2026-10-08 10:12:54 +08:00
|
|
|
|
await ValidateRefereesAsync(draftReferees, issues);
|
2026-08-30 21:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-10-08 10:12:54 +08:00
|
|
|
|
Entries = draftEntries,
|
2026-08-30 21:46:05 +08:00
|
|
|
|
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,
|
2026-10-10 09:04:52 +08:00
|
|
|
|
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)),
|
2026-08-30 21:46:05 +08:00
|
|
|
|
ErrorCount = errorCount,
|
|
|
|
|
|
WarningCount = warningCount,
|
|
|
|
|
|
Preview = preview,
|
|
|
|
|
|
Errors = issues
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
private static List<OrderBookDraftEntry> ParseEntryRows(List<OrderBookEntryRow> rows, List<OrderBookParseIssue> issues)
|
2026-08-30 21:46:05 +08:00
|
|
|
|
{
|
|
|
|
|
|
var result = new List<OrderBookDraftEntry>();
|
|
|
|
|
|
for (var i = 0; i < rows.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
var row = rows[i];
|
2026-10-10 09:04:52 +08:00
|
|
|
|
var rowNo = row.ExcelRowNumber > 0 ? row.ExcelRowNumber : i + 3;
|
2026-08-30 21:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
// 整行为空直接跳过
|
2026-10-08 10:12:54 +08:00
|
|
|
|
if (IsAllBlank(row.ProjectName, row.ScoreRuleName, row.GroupName, row.ParticipantName,
|
|
|
|
|
|
row.TeamName, row.UnitName, row.SessionName, row.VenueName))
|
2026-08-30 21:46:05 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
|
|
var project = Trim(row.ProjectName);
|
2026-10-08 10:12:54 +08:00
|
|
|
|
var scoreRule = Trim(row.ScoreRuleName);
|
|
|
|
|
|
var group = Trim(row.GroupName);
|
2026-08-30 21:46:05 +08:00
|
|
|
|
var participant = Trim(row.ParticipantName);
|
2026-10-08 10:12:54 +08:00
|
|
|
|
var session = Trim(row.SessionName);
|
|
|
|
|
|
var venue = Trim(row.VenueName);
|
2026-08-30 21:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(project))
|
|
|
|
|
|
issues.Add(Issue(rowNo, "项目名称", "项目名称为必填项"));
|
2026-10-08 10:12:54 +08:00
|
|
|
|
if (string.IsNullOrWhiteSpace(scoreRule))
|
|
|
|
|
|
issues.Add(Issue(rowNo, "计分规则", "计分规则为必填项"));
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(group))
|
|
|
|
|
|
issues.Add(Issue(rowNo, "组别名称", "组别名称为必填项"));
|
2026-08-30 21:46:05 +08:00
|
|
|
|
if (string.IsNullOrWhiteSpace(participant))
|
2026-10-08 10:12:54 +08:00
|
|
|
|
issues.Add(Issue(rowNo, "姓名", "姓名为必填项"));
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(session))
|
|
|
|
|
|
issues.Add(Issue(rowNo, "场次", "场次为必填项"));
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(venue))
|
|
|
|
|
|
issues.Add(Issue(rowNo, "场地", "场地为必填项"));
|
2026-08-30 21:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
result.Add(new OrderBookDraftEntry
|
|
|
|
|
|
{
|
|
|
|
|
|
Row = rowNo,
|
|
|
|
|
|
VenueName = venue,
|
|
|
|
|
|
ProjectName = project,
|
2026-10-08 10:12:54 +08:00
|
|
|
|
ScoreRuleName = scoreRule,
|
|
|
|
|
|
GroupName = group,
|
|
|
|
|
|
SessionName = session,
|
2026-08-30 21:46:05 +08:00
|
|
|
|
ParticipantName = participant,
|
2026-10-08 10:12:54 +08:00
|
|
|
|
TeamName = Trim(row.TeamName),
|
|
|
|
|
|
UnitName = Trim(row.UnitName)
|
2026-08-30 21:46:05 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
private static List<OrderBookDraftReferee> ParseRefereeRows(List<OrderBookRefereeRow> rows, List<OrderBookParseIssue> issues)
|
2026-08-30 21:46:05 +08:00
|
|
|
|
{
|
|
|
|
|
|
var result = new List<OrderBookDraftReferee>();
|
|
|
|
|
|
var seenMobiles = new HashSet<string>();
|
|
|
|
|
|
for (var i = 0; i < rows.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
var row = rows[i];
|
2026-10-10 09:04:52 +08:00
|
|
|
|
var rowNo = row.ExcelRowNumber > 0 ? row.ExcelRowNumber : i + 3;
|
2026-08-30 21:46:05 +08:00
|
|
|
|
|
2026-10-08 10:12:54 +08:00
|
|
|
|
if (IsAllBlank(row.Name, row.Mobile))
|
2026-08-30 21:46:05 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
|
|
var name = Trim(row.Name);
|
|
|
|
|
|
var mobile = Trim(row.Mobile);
|
|
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(name))
|
2026-10-10 09:04:52 +08:00
|
|
|
|
issues.Add(RefereeIssue(rowNo, "姓名", "裁判姓名为必填项"));
|
2026-08-30 21:46:05 +08:00
|
|
|
|
if (string.IsNullOrWhiteSpace(mobile))
|
2026-10-10 09:04:52 +08:00
|
|
|
|
issues.Add(RefereeIssue(rowNo, "手机号", "裁判手机号为必填项"));
|
2026-08-30 21:46:05 +08:00
|
|
|
|
else if (!Regex.IsMatch(mobile, @"^1[3-9]\d{9}$"))
|
2026-10-10 09:04:52 +08:00
|
|
|
|
issues.Add(RefereeIssue(rowNo, "手机号", $"裁判手机号「{mobile}」格式不正确"));
|
2026-08-30 21:46:05 +08:00
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(mobile) ||
|
|
|
|
|
|
!Regex.IsMatch(mobile, @"^1[3-9]\d{9}$"))
|
2026-08-30 21:46:05 +08:00
|
|
|
|
continue;
|
2026-10-10 09:04:52 +08:00
|
|
|
|
if (!seenMobiles.Add(mobile))
|
|
|
|
|
|
{
|
|
|
|
|
|
issues.Add(RefereeIssue(rowNo, "手机号", $"裁判手机号「{mobile}」重复,已自动合并", "warning"));
|
2026-08-30 21:46:05 +08:00
|
|
|
|
continue;
|
2026-10-10 09:04:52 +08:00
|
|
|
|
}
|
2026-08-30 21:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
result.Add(new OrderBookDraftReferee
|
|
|
|
|
|
{
|
|
|
|
|
|
Row = rowNo,
|
|
|
|
|
|
Name = name!,
|
2026-10-08 10:12:54 +08:00
|
|
|
|
Mobile = mobile!
|
2026-08-30 21:46:05 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-10-10 09:04:52 +08:00
|
|
|
|
.Select(e => (e.ProjectName, e.ScoreRuleName, e.GroupName, e.SessionName, e.VenueName))
|
2026-08-30 21:46:05 +08:00
|
|
|
|
.Distinct().Count(),
|
|
|
|
|
|
EntryCount = draft.Entries.Count,
|
|
|
|
|
|
RefereeCount = draft.Referees.Select(r => r.Mobile).Distinct().Count()
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
private async Task<OrderBookProjectCatalog> LoadProjectCatalogAsync(
|
|
|
|
|
|
List<OrderBookDraftEntry> entries, long eventId)
|
2026-10-08 10:12:54 +08:00
|
|
|
|
{
|
2026-10-10 09:04:52 +08:00
|
|
|
|
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
|
|
|
|
|
|
};
|
2026-10-08 10:12:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)))
|
2026-10-10 09:04:52 +08:00
|
|
|
|
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}】已有场次的规则与本次导入不一致,请先核查历史编排"));
|
|
|
|
|
|
}
|
2026-10-08 10:12:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 21:46:05 +08:00
|
|
|
|
#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)
|
2026-10-08 10:12:54 +08:00
|
|
|
|
throw Oops.Bah("请先创建赛事,再导入秩序册");
|
2026-08-30 21:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
var eventId = draft.EventId.Value;
|
|
|
|
|
|
var eventInfo = await _eventRepository.GetFirstAsync(e => e.Id == eventId && !e.DeleteMark)
|
|
|
|
|
|
?? throw Oops.Bah("赛事不存在或已删除");
|
|
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
// 旧版本缓存没有真实关联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);
|
2026-08-30 21:46:05 +08:00
|
|
|
|
var now = DateTime.Now;
|
|
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
// 所有配置复核通过后才覆盖旧编排;后续写入由工作单元统一回滚。
|
2026-08-30 21:46:05 +08:00
|
|
|
|
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())
|
|
|
|
|
|
{
|
2026-10-08 10:12:54 +08:00
|
|
|
|
// 场次被覆盖后,原裁判席位不再对应新场次,必须重新分配。
|
|
|
|
|
|
await _judgeAssignmentRepository.SoftDeleteAsync(x => sessionIds.Contains(x.SessionId));
|
2026-08-30 21:46:05 +08:00
|
|
|
|
await _entryRepository.SoftDeleteAsync(e => sessionIds.Contains(e.SessionId));
|
|
|
|
|
|
await _sessionRepository.SoftDeleteAsync(s => sessionIds.Contains(s.Id));
|
|
|
|
|
|
}
|
|
|
|
|
|
await _venueRepository.SoftDeleteAsync(v => v.EventId == eventId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
// 仅使用解析并复核通过的真实ID,不再通过导入补建项目或组别。
|
|
|
|
|
|
private static Dictionary<long, OrderBookProjectContext> BuildProjectContext(
|
|
|
|
|
|
OrderBookDraft draft, OrderBookProjectCatalog catalog)
|
2026-08-30 21:46:05 +08:00
|
|
|
|
{
|
2026-10-10 09:04:52 +08:00
|
|
|
|
var projects = catalog.Projects.ToDictionary(x => x.Id);
|
|
|
|
|
|
return draft.Entries.GroupBy(x => x.EventProjectId).ToDictionary(x => x.Key, x =>
|
2026-08-30 21:46:05 +08:00
|
|
|
|
{
|
2026-10-10 09:04:52 +08:00
|
|
|
|
var entry = x.First();
|
|
|
|
|
|
return new OrderBookProjectContext
|
2026-08-30 21:46:05 +08:00
|
|
|
|
{
|
2026-10-10 09:04:52 +08:00
|
|
|
|
Project = projects[entry.ProjectId], EventProjectId = entry.EventProjectId,
|
|
|
|
|
|
ScoreRuleId = entry.ScoreRuleId
|
2026-08-30 21:46:05 +08:00
|
|
|
|
};
|
2026-10-10 09:04:52 +08:00
|
|
|
|
});
|
2026-08-30 21:46:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 场地建档(追加模式按名称复用),返回场地名 -> 场地上下文 的映射
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 按 项目×组别×场地 生成场次,返回组合键 -> 场次 的映射
|
2026-10-10 09:04:52 +08:00
|
|
|
|
private async Task<Dictionary<OrderBookSessionKey, EventSession>> EnsureSessionsAsync(
|
2026-08-30 21:46:05 +08:00
|
|
|
|
OrderBookDraft draft, long eventId,
|
2026-10-10 09:04:52 +08:00
|
|
|
|
Dictionary<long, OrderBookProjectContext> projectContext,
|
2026-08-30 21:46:05 +08:00
|
|
|
|
Dictionary<string, OrderBookVenueContext> venueContext,
|
|
|
|
|
|
long userId, DateTime now)
|
|
|
|
|
|
{
|
|
|
|
|
|
var combos = draft.Entries
|
2026-10-10 09:04:52 +08:00
|
|
|
|
.Select(OrderBookSessionKey.From)
|
2026-08-30 21:46:05 +08:00
|
|
|
|
.Distinct()
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
var result = new Dictionary<OrderBookSessionKey, EventSession>();
|
2026-08-30 21:46:05 +08:00
|
|
|
|
var sessionSort = 0;
|
|
|
|
|
|
foreach (var combo in combos)
|
|
|
|
|
|
{
|
2026-10-10 09:04:52 +08:00
|
|
|
|
var pc = projectContext[combo.EventProjectId];
|
2026-08-30 21:46:05 +08:00
|
|
|
|
var vc = venueContext[combo.VenueName];
|
2026-10-10 09:04:52 +08:00
|
|
|
|
var groupId = combo.EventProjectGroupId;
|
2026-08-30 21:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
var exist = await _sessionRepository.GetFirstAsync(s =>
|
|
|
|
|
|
!s.DeleteMark && s.EventId == eventId &&
|
|
|
|
|
|
s.EventProjectId == pc.EventProjectId && s.VenueId == vc.Venue.Id &&
|
2026-10-08 10:12:54 +08:00
|
|
|
|
s.SessionName == combo.SessionName &&
|
2026-10-10 09:04:52 +08:00
|
|
|
|
s.EventProjectGroupId == groupId);
|
2026-08-30 21:46:05 +08:00
|
|
|
|
if (exist != null)
|
|
|
|
|
|
{
|
2026-10-10 09:04:52 +08:00
|
|
|
|
if (exist.ScoreRuleId != pc.ScoreRuleId)
|
|
|
|
|
|
throw Oops.Bah($"场次【{combo.SessionName}】的规则与导入项目不一致,请先核查历史编排");
|
|
|
|
|
|
result[combo] = exist;
|
2026-08-30 21:46:05 +08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sessionSort++;
|
|
|
|
|
|
var session = new EventSession
|
|
|
|
|
|
{
|
|
|
|
|
|
Id = _idGenerator.NextId(),
|
|
|
|
|
|
EventId = eventId,
|
|
|
|
|
|
EventProjectId = pc.EventProjectId,
|
|
|
|
|
|
EventProjectGroupId = groupId,
|
|
|
|
|
|
VenueId = vc.Venue.Id,
|
2026-10-08 10:12:54 +08:00
|
|
|
|
ScoreRuleId = pc.ScoreRuleId,
|
|
|
|
|
|
SessionNo = $"EP{pc.EventProjectId}-V{vc.Index:00}-{sessionSort:000}",
|
|
|
|
|
|
SessionName = combo.SessionName,
|
2026-08-30 21:46:05 +08:00
|
|
|
|
SessionStatus = 0,
|
|
|
|
|
|
Version = 1,
|
|
|
|
|
|
Status = AvailabilityStatus.Enable,
|
|
|
|
|
|
CreatedUserId = userId,
|
|
|
|
|
|
CreatedTime = now
|
|
|
|
|
|
};
|
|
|
|
|
|
await _sessionRepository.InsertAsync(session);
|
2026-10-10 09:04:52 +08:00
|
|
|
|
result[combo] = session;
|
2026-08-30 21:46:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 生成出场记录,返回数量
|
|
|
|
|
|
private async Task<int> CreateEntriesAsync(
|
|
|
|
|
|
OrderBookDraft draft,
|
2026-10-10 09:04:52 +08:00
|
|
|
|
Dictionary<long, OrderBookProjectContext> projectContext,
|
2026-08-30 21:46:05 +08:00
|
|
|
|
Dictionary<string, OrderBookVenueContext> venueContext,
|
2026-10-10 09:04:52 +08:00
|
|
|
|
Dictionary<OrderBookSessionKey, EventSession> sessionContext,
|
2026-08-30 21:46:05 +08:00
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-10-10 09:04:52 +08:00
|
|
|
|
var session = sessionContext[OrderBookSessionKey.From(d)];
|
|
|
|
|
|
var pc = projectContext[d.EventProjectId];
|
2026-08-30 21:46:05 +08:00
|
|
|
|
var vc = venueContext[d.VenueName];
|
|
|
|
|
|
|
|
|
|
|
|
orderCounter.TryGetValue(session.Id, out var order);
|
|
|
|
|
|
order++;
|
|
|
|
|
|
orderCounter[session.Id] = order;
|
|
|
|
|
|
|
2026-10-08 10:12:54 +08:00
|
|
|
|
var entryNo = $"{pc.Project.SortIndex:000}{vc.Index:00}{order:000}";
|
2026-08-30 21:46:05 +08:00
|
|
|
|
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,
|
2026-10-08 10:12:54 +08:00
|
|
|
|
TeamName = d.TeamName,
|
2026-08-30 21:46:05 +08:00
|
|
|
|
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();
|
2026-10-08 10:12:54 +08:00
|
|
|
|
var existReferees = await _refereeRepository.AsQueryable()
|
|
|
|
|
|
.Where(r => !r.DeleteMark && r.AuditStatus == AuditStatus.Pass && mobiles.Contains(r.Mobile))
|
|
|
|
|
|
.ToListAsync();
|
2026-08-30 21:46:05 +08:00
|
|
|
|
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)
|
2026-10-08 10:12:54 +08:00
|
|
|
|
throw Oops.Bah($"裁判【{d.Name}】尚未完成注册认证,请重新上传秩序册");
|
2026-08-30 21:46:05 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 21:46:05 +08:00
|
|
|
|
#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 };
|
|
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
private static OrderBookParseIssue RefereeIssue(int row, string column, string message, string level = "error")
|
|
|
|
|
|
=> new() { Sheet = RefereeSheetName, Row = row, Column = column, Message = message, Level = level };
|
|
|
|
|
|
|
2026-08-30 21:46:05 +08:00
|
|
|
|
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
|
|
|
|
|
|
|
|
|
|
private static bool IsAllBlank(params string?[] values) => values.All(string.IsNullOrWhiteSpace);
|
|
|
|
|
|
|
2026-10-10 09:04:52 +08:00
|
|
|
|
/// <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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 21:46:05 +08:00
|
|
|
|
private sealed class OrderBookProjectContext
|
|
|
|
|
|
{
|
|
|
|
|
|
public Core.Entities.Project Project { get; set; } = null!;
|
|
|
|
|
|
public long EventProjectId { get; set; }
|
2026-10-08 10:12:54 +08:00
|
|
|
|
public long ScoreRuleId { get; set; }
|
2026-08-30 21:46:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private sealed class OrderBookVenueContext
|
|
|
|
|
|
{
|
|
|
|
|
|
public EventVenue Venue { get; set; } = null!;
|
|
|
|
|
|
public int Index { get; set; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
2026-10-09 14:20:22 +08:00
|
|
|
|
/// 秩序册导入模板导出器:出场秩序 + 裁判名单 + 计分规则参考
|
2026-08-30 21:46:05 +08:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal sealed class OrderBookTemplateExporter : ExcelExportBase
|
|
|
|
|
|
{
|
2026-10-09 14:20:22 +08:00
|
|
|
|
private const int EntryInputEndRow = 1000;
|
|
|
|
|
|
private readonly IReadOnlyList<string> _scoreRuleNames;
|
|
|
|
|
|
|
|
|
|
|
|
public OrderBookTemplateExporter(IReadOnlyList<string> scoreRuleNames)
|
|
|
|
|
|
{
|
|
|
|
|
|
_scoreRuleNames = scoreRuleNames;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 21:46:05 +08:00
|
|
|
|
public override Task<byte[]> ExportAsync()
|
|
|
|
|
|
{
|
|
|
|
|
|
var headerColor = Color.FromArgb(217, 225, 242);
|
|
|
|
|
|
|
|
|
|
|
|
#region 出场秩序
|
|
|
|
|
|
CreateWorksheet("出场秩序");
|
2026-10-08 10:12:54 +08:00
|
|
|
|
var entryHeaders = new[] { "项目名称*", "计分规则*", "组别名称*", "姓名*", "队伍名称", "单位", "场次*", "场地*" };
|
2026-10-09 14:20:22 +08:00
|
|
|
|
SetMergeTitle("秩序册 - 出场秩序(带*为必填;计分规则请从下拉列表选择)", 1, 1, 1, entryHeaders.Length, headerColor, 12);
|
2026-08-30 21:46:05 +08:00
|
|
|
|
SetHeaders(entryHeaders, 2, headerColor);
|
|
|
|
|
|
SetDataRowStyle(3, entryHeaders.Length);
|
2026-10-09 14:20:22 +08:00
|
|
|
|
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 = "请选择下拉列表中的计分规则。";
|
2026-10-08 10:12:54 +08:00
|
|
|
|
SetColumnWidths(new double[] { 18, 22, 14, 22, 18, 22, 22, 16 });
|
2026-08-30 21:46:05 +08:00
|
|
|
|
FreezePanes(3, 1);
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region 裁判名单
|
|
|
|
|
|
CreateWorksheet("裁判名单");
|
2026-10-08 10:12:54 +08:00
|
|
|
|
var refereeHeaders = new[] { "序号", "姓名*", "手机号*" };
|
2026-10-09 14:20:22 +08:00
|
|
|
|
SetMergeTitle("秩序册 - 裁判名单(须完成注册认证;无裁判可留空)", 1, 1, 1, refereeHeaders.Length, headerColor, 12);
|
2026-08-30 21:46:05 +08:00
|
|
|
|
SetHeaders(refereeHeaders, 2, headerColor);
|
|
|
|
|
|
SetDataRowStyle(3, refereeHeaders.Length);
|
2026-10-09 14:20:22 +08:00
|
|
|
|
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 });
|
2026-08-30 21:46:05 +08:00
|
|
|
|
FreezePanes(3, 1);
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
return GetAsByteArrayAsync();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|