个人项目按人检录,团体项目按团队检录

This commit is contained in:
马梓彭 2026-09-21 14:55:44 +08:00
parent 8a9905cdb2
commit 321be399ee
6 changed files with 131 additions and 30 deletions

View File

@ -95,7 +95,6 @@ public partial class CheckinController
private async Task<CheckinProgressContext> BuildProgressContextAsync(
CheckinEventOptionsDto options, List<CheckinTask> activeTasks)
{
var validUnitKeys = options.Units.Select(x => x.UnitKey).ToHashSet();
var taskIds = activeTasks.Select(x => x.Id).ToList();
var records = taskIds.Count == 0
? new List<CheckinRecord>()
@ -118,20 +117,22 @@ public partial class CheckinController
var taskCheckinNoMap = activeTasks.ToDictionary(x => x.Id, x => x.CheckinNo);
var passedByKey = new Dictionary<string, Dictionary<int, DateTime?>>();
var checkerNameByKey = new Dictionary<string, Dictionary<int, string>>();
foreach (var record in records.Where(x => validUnitKeys.Contains(x.SessionKey!)))
foreach (var record in records)
{
var unitKey = ResolveRecordUnitKey(options.Units, record);
if (unitKey == null) continue;
if (!taskCheckinNoMap.TryGetValue(record.TaskId, out var checkinNo)) continue;
if (!passedByKey.TryGetValue(record.SessionKey!, out var rounds))
if (!passedByKey.TryGetValue(unitKey, out var rounds))
{
rounds = new Dictionary<int, DateTime?>();
passedByKey[record.SessionKey!] = rounds;
passedByKey[unitKey] = rounds;
}
rounds[checkinNo] = record.CheckedTime;
if (!checkerNameByKey.TryGetValue(record.SessionKey!, out var checkerNames))
if (!checkerNameByKey.TryGetValue(unitKey, out var checkerNames))
{
checkerNames = new Dictionary<int, string>();
checkerNameByKey[record.SessionKey!] = checkerNames;
checkerNameByKey[unitKey] = checkerNames;
}
checkerNames[checkinNo] = checkerNameMap.GetValueOrDefault(record.CheckerUserId) ?? string.Empty;
}

View File

@ -79,8 +79,8 @@ public partial class CheckinController : IDynamicApiController
if (task.CheckinNo > 1)
{
var previousTask = await GetEventTaskAsync(task.EventId, task.CheckinNo - 1);
var previousPassed = previousTask != null && await _recordRepository.IsAnyAsync(x =>
x.TaskId == previousTask.Id && x.SessionKey == unit.UnitKey && x.Checked && !x.DeleteMark);
var previousPassed = previousTask != null &&
await HasCheckedUnitRecordAsync(previousTask.Id, unit);
if (!previousPassed)
throw Oops.Bah($"该秩序册列尚未通过{ToCheckinName(task.CheckinNo - 1)},不能进行{ToCheckinName(task.CheckinNo)}");
}
@ -92,13 +92,11 @@ public partial class CheckinController : IDynamicApiController
x.TaskStatus == 1 && x.Status == AvailabilityStatus.Enable && !x.DeleteMark)
.ToListAsync();
var laterTaskIds = laterTasks.Select(x => x.Id).ToList();
if (laterTaskIds.Count > 0 && await _recordRepository.IsAnyAsync(x =>
laterTaskIds.Contains(x.TaskId) && x.SessionKey == unit.UnitKey && x.Checked && !x.DeleteMark))
if (laterTaskIds.Count > 0 && await HasCheckedUnitRecordAsync(laterTaskIds, unit))
throw Oops.Bah("该秩序册列已通过后续检录,不能取消当前检录");
}
var record = await _recordRepository.GetFirstAsync(x =>
x.TaskId == task.Id && x.SessionKey == unit.UnitKey && !x.DeleteMark);
var record = await GetUnitRecordAsync(task.Id, unit);
if (record == null)
{
record = new CheckinRecord
@ -119,6 +117,7 @@ public partial class CheckinController : IDynamicApiController
}
else
{
record.SessionKey = unit.UnitKey;
record.Round = task.CheckinNo;
record.SessionOrder = unit.Order;
record.GroupId = unit.GroupId;
@ -220,7 +219,7 @@ public partial class CheckinController : IDynamicApiController
if (unit == null)
return new ScanCheckinOutput { Success = false, Message = "扫码内容与当前赛事秩序册不匹配,请核对号码布" };
await EnsurePreviousPassedAsync(task, unit.UnitKey);
await EnsurePreviousPassedAsync(task, unit);
await UpsertRecordAsync(task, unit, checkerUserId, true);
var context = await BuildContextAsync(task, input.EventProjectId ?? unit.EventProjectId);
@ -255,7 +254,7 @@ public partial class CheckinController : IDynamicApiController
var options = await _scheduleService.GetOptionsAsync(task.EventId);
var unit = CheckinScheduleService.ResolveUnit(options, input.UnitKey);
await EnsurePreviousPassedAsync(task, unit.UnitKey);
await EnsurePreviousPassedAsync(task, unit);
await UpsertRecordAsync(task, unit, checkerUserId, true);
// 上场后把当前列同步到任务,供运动员端展示"当前正在比赛"。
@ -294,12 +293,12 @@ public partial class CheckinController : IDynamicApiController
}
/// <summary>检录顺序卡控:非一检必须先通过上一检次。</summary>
private async Task EnsurePreviousPassedAsync(CheckinTask task, string unitKey)
private async Task EnsurePreviousPassedAsync(CheckinTask task, CheckinOrderBookUnitDto unit)
{
if (task.CheckinNo <= 1) return;
var previousTask = await GetEventTaskAsync(task.EventId, task.CheckinNo - 1);
var previousPassed = previousTask != null && await _recordRepository.IsAnyAsync(x =>
x.TaskId == previousTask.Id && x.SessionKey == unitKey && x.Checked && !x.DeleteMark);
var previousPassed = previousTask != null &&
await HasCheckedUnitRecordAsync(previousTask.Id, unit);
if (!previousPassed)
throw Oops.Bah($"该秩序册列尚未通过{ToCheckinName(task.CheckinNo - 1)},不能进行{ToCheckinName(task.CheckinNo)}");
}
@ -307,8 +306,7 @@ public partial class CheckinController : IDynamicApiController
private async Task UpsertRecordAsync(
CheckinTask task, CheckinOrderBookUnitDto unit, long checkerUserId, bool isChecked)
{
var record = await _recordRepository.GetFirstAsync(x =>
x.TaskId == task.Id && x.SessionKey == unit.UnitKey && !x.DeleteMark);
var record = await GetUnitRecordAsync(task.Id, unit);
if (record == null)
{
await _recordRepository.InsertAsync(new CheckinRecord
@ -328,6 +326,7 @@ public partial class CheckinController : IDynamicApiController
return;
}
record.SessionKey = unit.UnitKey;
record.Round = task.CheckinNo;
record.SessionOrder = unit.Order;
record.GroupId = unit.GroupId;
@ -339,6 +338,74 @@ public partial class CheckinController : IDynamicApiController
await _recordRepository.UpdateAsync(record);
}
/// <summary>
/// 获取指定检录对象的记录;个人项目同时兼容未包含人员 ID 的历史检录键。
/// </summary>
private async Task<CheckinRecord?> GetUnitRecordAsync(long taskId, CheckinOrderBookUnitDto unit)
{
if (unit.IsTeamProject)
{
return await _recordRepository.GetFirstAsync(x =>
x.TaskId == taskId && x.SessionKey == unit.UnitKey && !x.DeleteMark);
}
var legacyUnitKey = GetLegacyUnitKey(unit);
var registrationPersonId = unit.RegistrationPersonIds.FirstOrDefault();
return await _recordRepository.GetFirstAsync(x =>
x.TaskId == taskId && !x.DeleteMark &&
(x.SessionKey == unit.UnitKey ||
(x.SessionKey == legacyUnitKey && x.RegistrationPersonId == registrationPersonId)));
}
/// <summary>
/// 判断单个检录任务中,指定检录对象是否已经通过检录。
/// </summary>
private async Task<bool> HasCheckedUnitRecordAsync(long taskId, CheckinOrderBookUnitDto unit) =>
await HasCheckedUnitRecordAsync(new List<long> { taskId }, unit);
/// <summary>
/// 判断多个后续检录任务中,指定检录对象是否已经通过检录。
/// </summary>
private async Task<bool> HasCheckedUnitRecordAsync(
List<long> taskIds, CheckinOrderBookUnitDto unit)
{
if (unit.IsTeamProject)
{
return await _recordRepository.IsAnyAsync(x =>
taskIds.Contains(x.TaskId) && x.SessionKey == unit.UnitKey &&
x.Checked && !x.DeleteMark);
}
var legacyUnitKey = GetLegacyUnitKey(unit);
var registrationPersonId = unit.RegistrationPersonIds.FirstOrDefault();
return await _recordRepository.IsAnyAsync(x =>
taskIds.Contains(x.TaskId) && x.Checked && !x.DeleteMark &&
(x.SessionKey == unit.UnitKey ||
(x.SessionKey == legacyUnitKey && x.RegistrationPersonId == registrationPersonId)));
}
/// <summary>
/// 获取旧版检录键。旧版仅包含赛事项目 ID 和报名项目 ID。
/// </summary>
private static string GetLegacyUnitKey(CheckinOrderBookUnitDto unit) =>
CheckinScheduleService.BuildUnitKey(unit.EventProjectId, unit.RegistrationItemId);
/// <summary>
/// 将检录记录映射到当前检录键。旧版个人项目记录按保存的报名人员 ID 精确归属,
/// 防止同一单位账号报名的其他人员被一并判定为已检录。
/// </summary>
private static string? ResolveRecordUnitKey(
IReadOnlyCollection<CheckinOrderBookUnitDto> units, CheckinRecord record)
{
var exactUnit = units.FirstOrDefault(x => x.UnitKey == record.SessionKey);
if (exactUnit != null) return exactUnit.UnitKey;
return units.FirstOrDefault(x =>
!x.IsTeamProject &&
GetLegacyUnitKey(x) == record.SessionKey &&
x.RegistrationPersonIds.Contains(record.RegistrationPersonId))?.UnitKey;
}
/// <summary>
/// 对报名了所选项目的人员发送检录通知;单人失败不影响整体流程。
/// 个人项目:通知运动员本人(从 EventRegistrationPerson 取)。
@ -602,12 +669,15 @@ public partial class CheckinController : IDynamicApiController
var records = await _recordRepository.AsQueryable()
.Where(x => x.TaskId == task.Id && x.SessionKey != null && !x.DeleteMark)
.ToListAsync();
var checkedKeys = records.Where(x => x.Checked && allUnitKeys.Contains(x.SessionKey!))
.Select(x => x.SessionKey!).ToHashSet();
var checkedKeys = records.Where(x => x.Checked)
.Select(x => ResolveRecordUnitKey(allUnits, x))
.Where(x => x != null)
.Select(x => x!)
.ToHashSet();
var activeTasks = await GetActiveEventTasksAsync(task.EventId);
// 三检通过视为已上场;仅配置两检时,二检通过即视为已上场。
var onStageKeys = await GetOnStageKeysAsync(activeTasks, allUnitKeys);
var onStageKeys = await GetOnStageKeysAsync(activeTasks, allUnits);
HashSet<string> previousPassedKeys;
if (task.CheckinNo == 1)
@ -622,8 +692,11 @@ public partial class CheckinController : IDynamicApiController
: await _recordRepository.AsQueryable()
.Where(x => x.TaskId == previousTask.Id && x.SessionKey != null && x.Checked && !x.DeleteMark)
.ToListAsync();
previousPassedKeys = previousRecords.Where(x => allUnitKeys.Contains(x.SessionKey!))
.Select(x => x.SessionKey!).ToHashSet();
previousPassedKeys = previousRecords
.Select(x => ResolveRecordUnitKey(allUnits, x))
.Where(x => x != null)
.Select(x => x!)
.ToHashSet();
}
var allStatusUnits = allUnits.Select(unit =>
@ -737,7 +810,7 @@ public partial class CheckinController : IDynamicApiController
/// <summary>三检通过即为已上场;仅配置两检时由二检标记上场。</summary>
private async Task<HashSet<string>> GetOnStageKeysAsync(
List<CheckinTask> activeTasks, HashSet<string> validUnitKeys)
List<CheckinTask> activeTasks, IReadOnlyCollection<CheckinOrderBookUnitDto> units)
{
if (activeTasks.Count == 0) return new HashSet<string>();
var onStageCheckinNo = ResolveOnStageCheckinNo(activeTasks);
@ -745,8 +818,10 @@ public partial class CheckinController : IDynamicApiController
var records = await _recordRepository.AsQueryable()
.Where(x => x.TaskId == onStageTask.Id && x.SessionKey != null && x.Checked && !x.DeleteMark)
.ToListAsync();
return records.Where(x => validUnitKeys.Contains(x.SessionKey!))
.Select(x => x.SessionKey!).ToHashSet();
return records.Select(x => ResolveRecordUnitKey(units, x))
.Where(x => x != null)
.Select(x => x!)
.ToHashSet();
}
/// <summary>上场检次固定为三检;未配置三检但配置了二检时使用二检。</summary>

View File

@ -38,9 +38,13 @@ public class CheckinScheduleService : ITransient
.ToListAsync();
var personMap = persons.ToDictionary(x => x.Id);
// 团队项目沿用报名项目作为检录单位;个人项目追加报名人员 ID,
// 避免单位账号在同一报名项目下填报多人时共用同一个检录键。
var units = rows.Select((row, index) => new CheckinOrderBookUnitDto
{
UnitKey = BuildUnitKey(row.EventProjectId, row.RegistrationItemId),
UnitKey = BuildUnitKey(row.EventProjectId, row.RegistrationItemId,
row.IsTeamProject ? null : row.RegistrationPersonIds.SingleOrDefault()),
IsTeamProject = row.IsTeamProject,
Order = index + 1,
SessionIndex = row.SessionIndex,
VenueIndex = row.VenueIndex,
@ -108,8 +112,17 @@ public class CheckinScheduleService : ITransient
return unit ?? throw Oops.Bah("所选检录对象不属于当前赛事秩序册");
}
public static string BuildUnitKey(long eventProjectId, long registrationItemId) =>
$"EP{eventProjectId}-RI{registrationItemId}";
/// <summary>
/// 生成检录对象唯一键。团队项目不传人员 ID,个人项目必须传入人员 ID。
/// </summary>
public static string BuildUnitKey(
long eventProjectId, long registrationItemId, long? registrationPersonId = null)
{
var key = $"EP{eventProjectId}-RI{registrationItemId}";
return registrationPersonId.HasValue && registrationPersonId.Value > 0
? $"{key}-RP{registrationPersonId.Value}"
: key;
}
private static string FormatBibNo(long personId)
{

View File

@ -33,6 +33,11 @@ public class CheckinProjectOptionDto
public class CheckinOrderBookUnitDto
{
public string UnitKey { get; set; } = string.Empty;
/// <summary>
/// 是否为团队项目,用于区分整队检录和个人逐人检录。
/// </summary>
public bool IsTeamProject { get; set; }
public int Order { get; set; }
public int SessionIndex { get; set; }
public int VenueIndex { get; set; }

View File

@ -126,6 +126,11 @@ public class ProjectArrangementSetting
/// </summary>
public class ArrangementPreviewOutput
{
/// <summary>
/// 是否为团队项目。团队项目按报名项目整队检录,个人项目按报名人员逐人检录。
/// </summary>
public bool IsTeamProject { get; set; }
/// <summary>
/// 赛事项目ID,关联 EventProject.Id。
/// </summary>

View File

@ -476,6 +476,7 @@ namespace YD_Event.Application.Event;
projectEntries.Add(new ArrangementPreviewOutput
{
IsTeamProject = true,
EventProjectId = eventProj.Id,
ProjectId = proj.Id,
EventProjectGroupId = teamGroup.Select(x => x.EventProjectGroupId).Distinct().Count() == 1
@ -503,6 +504,7 @@ namespace YD_Event.Application.Event;
projectEntries.Add(new ArrangementPreviewOutput
{
IsTeamProject = false,
EventProjectId = eventProj.Id,
ProjectId = proj.Id,
EventProjectGroupId = person.EventProjectGroupId,