优化考试排考

This commit is contained in:
biss committed 2026-07-27 19:31:19 +08:00
1 parent 64decdab7e
commit c3e8a42641
14 files changed
+7013 -272

No files matched your search

+431 -79
View File
@@ -88,6 +88,10 @@ public sealed class ExamsController(
if (plan.Status != ExamPlanStatus.Draft)
return ConflictProblem("只有草稿考试计划可以删除。");
var rooms = await db.ExamRooms
.Where(x => x.ExamPlanId == id)
.ToListAsync(cancellationToken);
db.ExamRooms.RemoveRange(rooms);
db.ExamPlans.Remove(plan);
try
{
@@ -147,6 +151,39 @@ public sealed class ExamsController(
}).FirstOrDefaultAsync(cancellationToken);
if (plan is null) return NotFound();
var roomEntities = await db.ExamRooms.AsNoTracking()
.Include(x => x.Classroom)
.ThenInclude(x => x!.Building)
.Include(x => x.SessionLinks)
.Include(x => x.Seats)
.Include(x => x.Invigilators)
.ThenInclude(x => x.Teacher)
.Where(x => x.ExamPlanId == id)
.ToListAsync(cancellationToken);
var roomsBySession = roomEntities
.SelectMany(room => room.SessionLinks.Select(link => new
{
link.ExamSessionId,
Room = new
{
link.ExamRoomId,
room.ClassroomId,
ClassroomName = room.Classroom!.Name,
BuildingName = room.Classroom.Building!.Name,
ClassroomCapacity = room.Classroom.Capacity,
SeatCount = room.Seats.Count(seat =>
seat.ExamSessionId == link.ExamSessionId),
TotalSeatCount = room.Seats.Count,
IsMixed = room.SessionLinks.Count > 1,
InvigilatorIds = room.Invigilators
.Select(invigilator => invigilator.TeacherId)
.ToList(),
InvigilatorNames = room.Invigilators
.Select(invigilator => invigilator.Teacher!.Name)
.ToList()
}
}))
.ToLookup(x => x.ExamSessionId, x => x.Room);
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
plan.Sessions.Select(x => x.TeachingTaskId),
@@ -185,6 +222,7 @@ public sealed class ExamsController(
item.Notes,
item.InvigilatorIds,
item.InvigilatorNames,
ExamRooms = roomsBySession[item.Id],
StudentCount = rosterCounts.GetValueOrDefault(item.TeachingTaskId)
})
});
@@ -358,6 +396,7 @@ public sealed class ExamsController(
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
session.Notes = Normalize(request.Notes);
await RemoveRoomsForSessionsAsync([id], cancellationToken);
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid =>
new ExamSessionInvigilator { TeacherId = tid }).ToList();
@@ -377,6 +416,7 @@ public sealed class ExamsController(
if (session is null) return NotFound();
if (session.ExamPlan!.Status != ExamPlanStatus.Draft)
return ConflictProblem("已发布的考试计划不能调整场次。");
await RemoveRoomsForSessionsAsync([id], cancellationToken);
db.ExamSessions.Remove(session);
return await SaveAsync(id, false, cancellationToken);
}
@@ -408,6 +448,7 @@ public sealed class ExamsController(
return ConflictProblem(
"所选考试场次包含不存在或不属于当前计划的记录,请刷新后重新选择。");
await RemoveRoomsForSessionsAsync(sessionIds, cancellationToken);
db.ExamSessions.RemoveRange(sessions);
try
{
@@ -485,6 +526,19 @@ public sealed class ExamsController(
var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value)
.ToListAsync(cancellationToken);
var occupiedRoomQuery = db.ExamRooms.AsNoTracking()
.Where(x => x.StartsAt < endsAt && startsAt < x.EndsAt);
if (planId.HasValue)
occupiedRoomQuery = occupiedRoomQuery.Where(
x => x.ExamPlanId == planId.Value);
if (excludeSessionId.HasValue)
occupiedRoomQuery = occupiedRoomQuery.Where(
x => !x.SessionLinks.Any(link =>
link.ExamSessionId == excludeSessionId.Value));
occupiedIds.AddRange(await occupiedRoomQuery
.Select(x => x.ClassroomId)
.ToListAsync(cancellationToken));
occupiedIds = occupiedIds.Distinct().ToList();
if (occupiedIds.Count > 0)
query = query.WhereNotIn(occupiedIds, x => x.Id);
@@ -528,6 +582,20 @@ public sealed class ExamsController(
var busyIds = await busyQuery.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
var roomBusyQuery = db.ExamRoomInvigilators.AsNoTracking()
.Where(x => x.ExamRoom!.StartsAt < endsAt &&
startsAt < x.ExamRoom.EndsAt);
if (planId.HasValue)
roomBusyQuery = roomBusyQuery.Where(
x => x.ExamRoom!.ExamPlanId == planId.Value);
if (excludeSessionId.HasValue)
roomBusyQuery = roomBusyQuery.Where(
x => !x.ExamRoom!.SessionLinks.Any(link =>
link.ExamSessionId == excludeSessionId.Value));
busyIds.AddRange(await roomBusyQuery
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken));
busyIds = busyIds.Distinct().ToList();
return Ok(await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active)
@@ -570,7 +638,17 @@ public sealed class ExamsController(
[Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
var plan = await db.ExamPlans.Include(x => x.Sessions)
var plan = await db.ExamPlans
.Include(x => x.Sessions)
.ThenInclude(x => x.Invigilators)
.Include(x => x.Sessions)
.ThenInclude(x => x.RoomLinks)
.ThenInclude(x => x.ExamRoom)
.ThenInclude(x => x!.Seats)
.Include(x => x.Sessions)
.ThenInclude(x => x.RoomLinks)
.ThenInclude(x => x.ExamRoom)
.ThenInclude(x => x!.Invigilators)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != ExamPlanStatus.Draft)
@@ -578,11 +656,39 @@ public sealed class ExamsController(
if (!plan.Sessions.Any())
return ConflictProblem("至少安排一个考试场次后才能发布。");
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
plan.Sessions.Select(x => x.TeachingTaskId),
cancellationToken))
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.Count());
var unassigned = plan.Sessions.Count(x =>
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
{
if (x.RoomLinks.Count == 0)
return !x.ClassroomId.HasValue || x.Invigilators.Count == 0;
var assignedSeatCount = x.RoomLinks
.SelectMany(link => link.ExamRoom!.Seats)
.Count(seat => seat.ExamSessionId == x.Id);
return assignedSeatCount !=
rosterCounts.GetValueOrDefault(x.TeachingTaskId) ||
x.RoomLinks.Any(link =>
link.ExamRoom!.Invigilators.Count <
link.ExamRoom.RequiredInvigilatorCount);
});
if (unassigned > 0)
return ConflictProblem(
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
$"还有 {unassigned} 个教学班未完成考场座位或监考安排,请先完成自动编排。");
var invalidMixedRoomCount = await db.ExamRooms.AsNoTracking()
.CountAsync(room =>
room.ExamPlanId == id &&
(room.Seats.Count > room.Classroom!.Capacity ||
room.SessionLinks.Any(link =>
link.ExamSession!.TeachingTask!.CourseId != room.CourseId)),
cancellationToken);
if (invalidMixedRoomCount > 0)
return ConflictProblem(
$"发现 {invalidMixedRoomCount} 个考场容量超限或混入不同课程,请重新编排。");
plan.Status = ExamPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
@@ -610,6 +716,51 @@ public sealed class ExamsController(
}).FirstOrDefaultAsync(cancellationToken);
if (session is null) return NotFound();
var assignedStudents = await db.ExamSeats.AsNoTracking()
.Where(x => x.ExamSessionId == id)
.OrderBy(x => x.ExamRoom!.Classroom!.Building!.Name)
.ThenBy(x => x.ExamRoom!.Classroom!.Name)
.ThenBy(x => x.SeatNumber)
.Select(x => new
{
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
x.ExamRoomId,
ClassroomName = x.ExamRoom!.Classroom!.Name,
BuildingName = x.ExamRoom.Classroom.Building!.Name,
x.SeatNumber
})
.ToListAsync(cancellationToken);
if (assignedStudents.Count > 0)
{
return Ok(new
{
session.Id,
session.TeachingTaskId,
session.TaskNumber,
session.CourseName,
ClassroomName = string.Join(
"、",
assignedStudents
.Select(x => $"{x.BuildingName} · {x.ClassroomName}")
.Distinct()),
session.StartsAt,
Students = assignedStudents.Select(student => new
{
student.StudentId,
student.StudentNumber,
student.Name,
student.ClassName,
student.ExamRoomId,
student.ClassroomName,
student.BuildingName,
SeatNumber = student.SeatNumber.ToString("D3")
})
});
}
var students = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
[session.TeachingTaskId],
@@ -645,45 +796,92 @@ public sealed class ExamsController(
.Select(x => new
{
x.Name,
TermName = x.AcademicTerm!.Name,
Sessions = x.Sessions
.OrderBy(session => session.ExamDate)
.ThenBy(session => session.StartPeriod)
.Select(session => new
{
session.Id,
session.TeachingTaskId,
session.ExamDate,
session.StartsAt,
session.EndsAt,
CourseCode = session.TeachingTask!.Course!.Code,
CourseName = session.TeachingTask.Course.Name,
session.TeachingTask.TaskNumber,
BuildingName = session.Classroom != null
? session.Classroom.Building!.Name
: null,
ClassroomName = session.Classroom != null
? session.Classroom.Name
: null,
InvigilatorNames = session.Invigilators
.OrderBy(item => item.Teacher!.TeacherNumber)
.Select(item => item.Teacher!.Name)
})
TermName = x.AcademicTerm!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (plan is null) return NotFound();
if (!plan.Sessions.Any())
var rooms = await db.ExamRooms.AsNoTracking()
.Include(x => x.Course)
.Include(x => x.Classroom)
.ThenInclude(x => x!.Building)
.Include(x => x.Invigilators)
.ThenInclude(x => x.Teacher)
.Include(x => x.SessionLinks)
.ThenInclude(x => x.ExamSession)
.ThenInclude(x => x!.TeachingTask)
.Include(x => x.Seats)
.ThenInclude(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.Where(x => x.ExamPlanId == id)
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.Classroom!.Building!.Name)
.ThenBy(x => x.Classroom!.Name)
.ToListAsync(cancellationToken);
var legacySessions = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlanId == id)
.Where(x => !x.RoomLinks.Any())
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.Select(session => new
{
session.Id,
session.TeachingTaskId,
session.ExamDate,
session.StartsAt,
session.EndsAt,
CourseCode = session.TeachingTask!.Course!.Code,
CourseName = session.TeachingTask.Course.Name,
session.TeachingTask.TaskNumber,
BuildingName = session.Classroom != null
? session.Classroom.Building!.Name
: null,
ClassroomName = session.Classroom != null
? session.Classroom.Name
: null,
InvigilatorNames = session.Invigilators
.OrderBy(item => item.Teacher!.TeacherNumber)
.Select(item => item.Teacher!.Name)
})
.ToListAsync(cancellationToken);
if (rooms.Count == 0 && legacySessions.Count == 0)
return ConflictProblem("当前考试计划没有可导出的考试场次。");
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
plan.Sessions.Select(x => x.TeachingTaskId),
legacySessions.Select(x => x.TeachingTaskId),
cancellationToken);
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
var data = new ExamSignInWorkbookData(
plan.Name,
plan.TermName,
plan.Sessions.Select(session => new ExamSignInSessionData(
var sheets = rooms.Select(room => new ExamSignInSessionData(
room.Id,
room.ExamDate,
room.StartsAt,
room.EndsAt,
room.Course!.Code,
room.Course.Name,
string.Join(
"、",
room.SessionLinks
.Select(link => link.ExamSession!.TeachingTask!.TaskNumber)
.Distinct()
.OrderBy(x => x)),
room.Classroom!.Building!.Name,
room.Classroom.Name,
room.Invigilators
.OrderBy(item => item.Teacher!.TeacherNumber)
.Select(item => item.Teacher!.Name)
.ToList(),
room.Seats
.OrderBy(seat => seat.SeatNumber)
.Select(seat => new ExamSignInStudentData(
seat.StudentId,
seat.Student!.StudentNumber,
seat.Student.Name,
seat.Student.AdministrativeClass!.Name,
seat.SeatNumber))
.ToList()))
.Concat(legacySessions.Select(session => new ExamSignInSessionData(
session.Id,
session.ExamDate,
session.StartsAt,
@@ -695,13 +893,18 @@ public sealed class ExamsController(
session.ClassroomName,
session.InvigilatorNames.ToList(),
studentsByTask[session.TeachingTaskId]
.Select(student => new ExamSignInStudentData(
.Select((student, index) => new ExamSignInStudentData(
student.StudentId,
student.StudentNumber,
student.Name,
student.ClassName))
.ToList()))
.ToList());
student.ClassName,
index + 1))
.ToList())))
.ToList();
var data = new ExamSignInWorkbookData(
plan.Name,
plan.TermName,
sheets);
var bytes = ExamSignInWorkbookExporter.Create(data);
return File(
@@ -724,11 +927,37 @@ public sealed class ExamsController(
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。");
var assignedSchedule = await db.ExamSeats.AsNoTracking()
.Where(x =>
x.StudentId == studentId.Value &&
x.ExamRoom!.ExamPlan!.Status == ExamPlanStatus.Published)
.Select(x => new
{
x.ExamSessionId,
PlanName = x.ExamRoom!.ExamPlan!.Name,
x.ExamRoom.ExamDate,
x.ExamRoom.StartPeriod,
x.ExamRoom.PeriodCount,
x.ExamRoom.StartsAt,
x.ExamRoom.EndsAt,
x.ExamSession!.TeachingTask!.TaskNumber,
CourseCode = x.ExamRoom.Course!.Code,
CourseName = x.ExamRoom.Course.Name,
ClassroomName = x.ExamRoom.Classroom!.Name,
BuildingName = x.ExamRoom.Classroom.Building!.Name,
InvigilatorNames = x.ExamRoom.Invigilators
.Select(i => i.Teacher!.Name),
x.SeatNumber
})
.ToListAsync(cancellationToken);
var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent(
db,
studentId.Value);
var schedule = await db.ExamSessions.AsNoTracking()
var legacySchedule = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
!x.RoomLinks.Any() &&
rosterTaskIds.Contains(x.TeachingTaskId))
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
.Select(x => new
@@ -750,9 +979,10 @@ public sealed class ExamsController(
IsExam = true
})
.ToListAsync(cancellationToken);
if (schedule.Count == 0) return Ok(schedule);
var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray();
var taskIds = legacySchedule
.Select(x => x.TeachingTaskId)
.Distinct()
.ToArray();
var seatRows = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
taskIds,
@@ -767,29 +997,77 @@ public sealed class ExamsController(
}))
.ToDictionary(x => (x.TeachingTaskId, x.StudentId), x => x.SeatNumber);
return Ok(schedule.Select(x => new
{
x.Id,
x.PlanName,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TaskNumber,
x.CourseCode,
x.CourseName,
x.ClassroomName,
x.BuildingName,
x.InvigilatorNames,
SeatNumber = seatNumbers.GetValueOrDefault((x.TeachingTaskId, studentId.Value)),
x.IsExam
}));
return Ok(assignedSchedule.Select(x => new ExamScheduleResponse(
x.ExamSessionId,
x.PlanName,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TaskNumber,
x.CourseCode,
x.CourseName,
x.ClassroomName,
x.BuildingName,
x.InvigilatorNames.ToList(),
x.SeatNumber.ToString("D3"),
null,
true))
.Concat(legacySchedule.Select(x => new ExamScheduleResponse(
x.Id,
x.PlanName,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TaskNumber,
x.CourseCode,
x.CourseName,
x.ClassroomName,
x.BuildingName,
x.InvigilatorNames.ToList(),
seatNumbers.GetValueOrDefault(
(x.TeachingTaskId, studentId.Value)),
null,
x.IsExam)))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod));
}
if (scope.IsInRole(SystemRoles.Teacher))
{
var schedule = await db.ExamSessions.AsNoTracking()
var roomSchedule = await db.ExamRooms.AsNoTracking()
.Where(x =>
x.ExamPlan!.Status == ExamPlanStatus.Published &&
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.Select(x => new
{
x.Id,
PlanName = x.ExamPlan!.Name,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
TaskNumbers = x.SessionLinks
.Select(link =>
link.ExamSession!.TeachingTask!.TaskNumber),
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
ClassroomName = x.Classroom!.Name,
BuildingName = x.Classroom.Building!.Name,
InvigilatorNames = x.Invigilators
.Select(i => i.Teacher!.Name),
StudentCount = x.Seats.Count
})
.ToListAsync(cancellationToken);
var legacySchedule = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
!x.RoomLinks.Any() &&
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
.Select(x => new
@@ -813,28 +1091,46 @@ public sealed class ExamsController(
.ToListAsync(cancellationToken);
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
schedule.Select(x => x.TeachingTaskId),
legacySchedule.Select(x => x.TeachingTaskId),
cancellationToken))
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.Count());
return Ok(schedule.Select(x => new
{
x.Id,
x.PlanName,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TaskNumber,
x.CourseCode,
x.CourseName,
x.ClassroomName,
x.BuildingName,
x.InvigilatorNames,
StudentCount = rosterCounts.GetValueOrDefault(x.TeachingTaskId),
x.IsExam
}));
return Ok(roomSchedule.Select(x => new ExamScheduleResponse(
x.Id,
x.PlanName,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
string.Join("、", x.TaskNumbers.OrderBy(value => value)),
x.CourseCode,
x.CourseName,
x.ClassroomName,
x.BuildingName,
x.InvigilatorNames.ToList(),
null,
x.StudentCount,
true))
.Concat(legacySchedule.Select(x => new ExamScheduleResponse(
x.Id,
x.PlanName,
x.ExamDate,
x.StartPeriod,
x.PeriodCount,
x.StartsAt,
x.EndsAt,
x.TaskNumber,
x.CourseCode,
x.CourseName,
x.ClassroomName,
x.BuildingName,
x.InvigilatorNames.ToList(),
null,
rosterCounts.GetValueOrDefault(x.TeachingTaskId),
x.IsExam)))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod));
}
return Ok(Array.Empty<object>());
}
@@ -843,6 +1139,24 @@ public sealed class ExamsController(
// Private helpers
// ═══════════════════════════════════════════
private async Task RemoveRoomsForSessionsAsync(
IReadOnlyCollection<Guid> sessionIds,
CancellationToken cancellationToken)
{
var ids = sessionIds.Distinct().ToArray();
if (ids.Length == 0) return;
var roomIds = await db.ExamRoomSessions.AsNoTracking()
.WhereIn(ids, x => x.ExamSessionId)
.Select(x => x.ExamRoomId)
.Distinct()
.ToListAsync(cancellationToken);
if (roomIds.Count == 0) return;
var rooms = await db.ExamRooms
.WhereIn(roomIds, x => x.Id)
.ToListAsync(cancellationToken);
db.ExamRooms.RemoveRange(rooms);
}
private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime(
Guid academicTermId,
DateOnly examDate,
@@ -926,6 +1240,16 @@ public sealed class ExamsController(
if (await overlaps.AnyAsync(
x => x.ClassroomId == classroomId.Value, cancellationToken))
return ConflictProblem("该时段考场已被占用。");
if (await db.ExamRooms.AsNoTracking().AnyAsync(
room =>
room.ClassroomId == classroomId.Value &&
room.StartsAt < endsAt &&
startsAt < room.EndsAt &&
(!currentId.HasValue ||
!room.SessionLinks.Any(link =>
link.ExamSessionId == currentId.Value)),
cancellationToken))
return ConflictProblem("该时段考场已被混排考试占用。");
}
if (teacherIds.Length > 0)
@@ -935,6 +1259,16 @@ public sealed class ExamsController(
.WhereIn(teacherIds, i => i.TeacherId)
.AnyAsync(cancellationToken))
return ConflictProblem("监考教师在该时段已有考试任务。");
if (await db.ExamRoomInvigilators.AsNoTracking()
.Where(i =>
i.ExamRoom!.StartsAt < endsAt &&
startsAt < i.ExamRoom.EndsAt &&
(!currentId.HasValue ||
!i.ExamRoom.SessionLinks.Any(link =>
link.ExamSessionId == currentId.Value)))
.WhereIn(teacherIds, i => i.TeacherId)
.AnyAsync(cancellationToken))
return ConflictProblem("监考教师在该时段已有混排考场任务。");
}
var overlappingTaskIds = await overlaps
@@ -1034,3 +1368,21 @@ public sealed record ExamAutoArrangeRequest(
IReadOnlyCollection<Guid>? SessionIds = null,
bool AssignClassrooms = true,
bool AssignInvigilators = true);
public sealed record ExamScheduleResponse(
Guid Id,
string PlanName,
DateOnly ExamDate,
int StartPeriod,
int PeriodCount,
DateTime StartsAt,
DateTime EndsAt,
string TaskNumber,
string CourseCode,
string CourseName,
string? ClassroomName,
string? BuildingName,
IReadOnlyCollection<string> InvigilatorNames,
string? SeatNumber,
int? StudentCount,
bool IsExam);
@@ -11,6 +11,7 @@ public sealed class ExamPlan : EntityBase
public string? Notes { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<ExamSession> Sessions { get; set; } = [];
public ICollection<ExamRoomAssignment> Rooms { get; set; } = [];
}
public sealed class ExamSession : EntityBase
@@ -31,6 +32,8 @@ public sealed class ExamSession : EntityBase
public int RequiredInvigilatorCount { get; set; } = 2;
public string? Notes { get; set; }
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
public ICollection<ExamRoomSession> RoomLinks { get; set; } = [];
public ICollection<ExamSeatAssignment> SeatAssignments { get; set; } = [];
}
public sealed class ExamSessionInvigilator
@@ -41,6 +44,52 @@ public sealed class ExamSessionInvigilator
public Teacher? Teacher { get; set; }
}
public sealed class ExamRoomAssignment : EntityBase
{
public Guid ExamPlanId { get; set; }
public ExamPlan? ExamPlan { get; set; }
public Guid CourseId { get; set; }
public Course? Course { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public DateOnly ExamDate { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public DateTime StartsAt { get; set; }
public DateTime EndsAt { get; set; }
public int RequiredInvigilatorCount { get; set; } = 2;
public ICollection<ExamRoomSession> SessionLinks { get; set; } = [];
public ICollection<ExamSeatAssignment> Seats { get; set; } = [];
public ICollection<ExamRoomInvigilator> Invigilators { get; set; } = [];
}
public sealed class ExamRoomSession
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid ExamSessionId { get; set; }
public ExamSession? ExamSession { get; set; }
}
public sealed class ExamSeatAssignment
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid ExamSessionId { get; set; }
public ExamSession? ExamSession { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public int SeatNumber { get; set; }
}
public sealed class ExamRoomInvigilator
{
public Guid ExamRoomId { get; set; }
public ExamRoomAssignment? ExamRoom { get; set; }
public Guid TeacherId { get; set; }
public Teacher? Teacher { get; set; }
}
public enum ExamPlanStatus
{
Draft = 1,
@@ -7,8 +7,27 @@ namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamArrangementService(AppDbContext db)
{
private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt);
private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt);
private sealed record RoomGroupKey(
Guid CourseId,
DateOnly ExamDate,
int StartPeriod,
int PeriodCount,
Guid? RequiredBuildingId);
private sealed record RoomOccupancy(
Guid ClassroomId,
DateTime StartsAt,
DateTime EndsAt);
private sealed record InvigilatorOccupancy(
Guid TeacherId,
DateTime StartsAt,
DateTime EndsAt);
private sealed record CandidateSeat(
Guid ExamSessionId,
Guid StudentId,
string StudentNumber);
public async Task<ExamArrangementResult> ArrangeAsync(
Guid planId,
@@ -30,6 +49,12 @@ public sealed class ExamArrangementService(AppDbContext db)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Include(x => x.Rooms)
.ThenInclude(x => x.SessionLinks)
.Include(x => x.Rooms)
.ThenInclude(x => x.Seats)
.Include(x => x.Rooms)
.ThenInclude(x => x.Invigilators)
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null)
@@ -46,206 +71,524 @@ public sealed class ExamArrangementService(AppDbContext db)
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。");
var sessions = plan.Sessions
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenByDescending(x => x.RequiredInvigilatorCount)
.ToList();
if (sessions.Count == 0)
return ExamArrangementResult.Fail("没有可处理的考试场次。");
var termId = plan.AcademicTermId;
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == termId && x.IsEnabled)
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.ToListAsync(cancellationToken);
if (timeSlots.Count == 0)
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
sessions.Select(x => x.TeachingTaskId),
cancellationToken))
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.Count());
int assignedRooms = 0;
int assignedInvigilators = 0;
int unavailableRooms = 0;
int unavailableInvigilators = 0;
var messages = new List<string>();
// Track occupied time slots to avoid conflicts
var occupiedRooms = sessions
.Where(x => x.ClassroomId.HasValue)
.Select(x => new RoomOccupancy(x.ClassroomId!.Value, x.StartsAt, x.EndsAt))
.ToList();
var occupiedInvigilators = sessions
.SelectMany(x => x.Invigilators.Select(i =>
new InvigilatorOccupancy(i.TeacherId, x.StartsAt, x.EndsAt)))
.ToList();
foreach (var session in sessions)
{
// Compute StartsAt/EndsAt from time slots
foreach (var session in plan.Sessions)
ComputeTimesFromSlots(session, timeSlotLookup);
var studentCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
// ── Auto-assign classroom ──
if (assignClassrooms && !session.ClassroomId.HasValue)
var explicitlySelected = plan.Sessions
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
.ToList();
if (explicitlySelected.Count == 0)
return ExamArrangementResult.Fail("没有可处理的考试场次。");
var selectedKeys = explicitlySelected
.Select(GroupKey)
.ToHashSet();
var sessions = plan.Sessions
.Where(x => selectedKeys.Contains(GroupKey(x)))
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ToList();
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db,
sessions.Select(x => x.TeachingTaskId),
cancellationToken);
var sessionByTaskId = sessions
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.First());
var replacedRooms = assignClassrooms
? plan.Rooms
.Where(room => room.SessionLinks.Any(link =>
sessionIds.Contains(link.ExamSessionId)))
.ToList()
: [];
var replacedRoomIds = replacedRooms.Select(x => x.Id).ToHashSet();
if (replacedRooms.Count > 0)
db.ExamRooms.RemoveRange(replacedRooms);
var occupiedRooms = await LoadOccupiedRoomsAsync(
plan,
sessionIds,
replacedRoomIds,
cancellationToken);
var rooms = await db.Classrooms.AsNoTracking()
.Include(x => x.Building)
.Where(x => x.IsEnabled)
.OrderBy(x => x.Building!.Name)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken);
var targetRooms = new List<ExamRoomAssignment>();
var messages = new List<string>();
var seatedStudents = 0;
var unavailableStudents = 0;
if (assignClassrooms)
{
foreach (var session in sessions)
{
var room = await FindBestClassroomAsync(
session, studentCount, occupiedRooms, cancellationToken);
if (room is not null)
session.ClassroomId = null;
if (session.Invigilators.Count > 0)
{
session.ClassroomId = room.Id;
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
assignedRooms++;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)");
}
else
{
unavailableRooms++;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
session.Invigilators.Clear();
}
}
// ── Auto-assign invigilators ──
var currentInvigilatorCount = session.Invigilators.Count;
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
if (assignInvigilators && needed > 0)
foreach (var group in sessions
.GroupBy(GroupKey)
.OrderBy(x => x.Key.ExamDate)
.ThenBy(x => x.Key.StartPeriod)
.ThenBy(x => x.First().TeachingTask!.Course!.Code))
{
var courseTeacherIds = session.TeachingTask!.Teachers
.Select(x => x.TeacherId).ToHashSet();
var newlyAssigned = await FindInvigilatorsAsync(
session, needed, courseTeacherIds,
occupiedInvigilators, cancellationToken);
foreach (var teacher in newlyAssigned)
var groupSessions = group.ToList();
var candidates = InterleaveCandidates(
groupSessions,
roster,
sessionByTaskId);
if (candidates.Count == 0)
{
session.Invigilators.Add(new ExamSessionInvigilator
messages.Add(
$"“{groupSessions[0].TeachingTask!.Course!.Name}”没有有效考生。");
continue;
}
var availableRooms = rooms
.Where(room =>
(!group.Key.RequiredBuildingId.HasValue ||
room.BuildingId == group.Key.RequiredBuildingId.Value) &&
occupiedRooms.All(occupied =>
occupied.ClassroomId != room.Id ||
!ExamConflictRules.TimeOverlaps(
occupied.StartsAt,
occupied.EndsAt,
groupSessions[0].StartsAt,
groupSessions[0].EndsAt)))
.ToList();
var selectedRooms = SelectRooms(
availableRooms,
candidates.Count);
if (selectedRooms.Sum(x => x.Capacity) < candidates.Count)
{
unavailableStudents += candidates.Count;
messages.Add(
$"“{groupSessions[0].TeachingTask!.Course!.Name}”缺少足够考场容量," +
$"需 {candidates.Count} 座、可用 {selectedRooms.Sum(x => x.Capacity)} 座。");
continue;
}
var offset = 0;
foreach (var classroom in selectedRooms)
{
var roomCandidates = candidates
.Skip(offset)
.Take(classroom.Capacity)
.ToList();
if (roomCandidates.Count == 0) break;
offset += roomCandidates.Count;
var room = new ExamRoomAssignment
{
ExamPlanId = plan.Id,
CourseId = group.Key.CourseId,
ClassroomId = classroom.Id,
ExamDate = group.Key.ExamDate,
StartPeriod = group.Key.StartPeriod,
PeriodCount = group.Key.PeriodCount,
StartsAt = groupSessions[0].StartsAt,
EndsAt = groupSessions[0].EndsAt,
RequiredInvigilatorCount =
groupSessions.Max(x => x.RequiredInvigilatorCount),
SessionLinks = roomCandidates
.Select(x => x.ExamSessionId)
.Distinct()
.Select(sessionId => new ExamRoomSession
{
ExamSessionId = sessionId
})
.ToList(),
Seats = roomCandidates
.Select((candidate, index) =>
new ExamSeatAssignment
{
ExamSessionId = candidate.ExamSessionId,
StudentId = candidate.StudentId,
SeatNumber = index + 1
})
.ToList()
};
db.ExamRooms.Add(room);
targetRooms.Add(room);
occupiedRooms.Add(new RoomOccupancy(
classroom.Id,
room.StartsAt,
room.EndsAt));
seatedStudents += roomCandidates.Count;
}
messages.Add(
$"“{groupSessions[0].TeachingTask!.Course!.Name}”" +
$"{groupSessions.Count}个教学班混排至{selectedRooms.Count}个考场。");
}
}
else
{
targetRooms.AddRange(plan.Rooms.Where(room =>
room.SessionLinks.Any(link =>
sessionIds.Contains(link.ExamSessionId))));
var linkedSessionIds = targetRooms
.SelectMany(x => x.SessionLinks)
.Select(x => x.ExamSessionId)
.ToHashSet();
foreach (var session in sessions.Where(x =>
!linkedSessionIds.Contains(x.Id) &&
x.ClassroomId.HasValue))
{
var legacyRoom = CreateLegacyRoom(
plan,
session,
roster.Where(x =>
x.TeachingTaskId == session.TeachingTaskId));
db.ExamRooms.Add(legacyRoom);
targetRooms.Add(legacyRoom);
session.ClassroomId = null;
if (session.Invigilators.Count > 0)
{
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
session.Invigilators.Clear();
}
}
seatedStudents = targetRooms.Sum(x => x.Seats.Count);
}
var assignedInvigilators = 0;
var unavailableInvigilators = 0;
if (assignInvigilators)
{
if (targetRooms.Count == 0)
return ExamArrangementResult.Fail("尚未生成实际考场,请先分配考场。");
var occupiedInvigilators = plan.Rooms
.Where(x => !replacedRoomIds.Contains(x.Id) &&
!targetRooms.Any(target => target.Id == x.Id))
.SelectMany(room => room.Invigilators.Select(item =>
new InvigilatorOccupancy(
item.TeacherId,
room.StartsAt,
room.EndsAt)))
.Concat(plan.Sessions
.Where(x => !sessionIds.Contains(x.Id))
.SelectMany(session => session.Invigilators.Select(item =>
new InvigilatorOccupancy(
item.TeacherId,
session.StartsAt,
session.EndsAt))))
.ToList();
foreach (var room in targetRooms
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.ClassroomId))
{
var needed = room.RequiredInvigilatorCount -
room.Invigilators.Count;
if (needed <= 0) continue;
var linkedSessionIds = room.SessionLinks
.Select(x => x.ExamSessionId)
.ToHashSet();
var excludedTeacherIds = sessions
.Where(x => linkedSessionIds.Contains(x.Id))
.SelectMany(x => x.TeachingTask!.Teachers)
.Select(x => x.TeacherId)
.ToHashSet();
var teachers = await FindInvigilatorsAsync(
room,
needed,
excludedTeacherIds,
occupiedInvigilators,
cancellationToken);
foreach (var teacher in teachers)
{
room.Invigilators.Add(new ExamRoomInvigilator
{
ExamSessionId = session.Id,
TeacherId = teacher.Id
});
occupiedInvigilators.Add(new InvigilatorOccupancy(
teacher.Id, session.StartsAt, session.EndsAt));
teacher.Id,
room.StartsAt,
room.EndsAt));
assignedInvigilators++;
}
if (newlyAssigned.Count < needed)
{
unavailableInvigilators += needed - newlyAssigned.Count;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
}
if (teachers.Count < needed)
unavailableInvigilators += needed - teachers.Count;
}
}
await db.SaveChangesAsync(cancellationToken);
var expandedCount = sessions.Count - explicitlySelected.Count;
var detail = messages.Count > 0
? $" 详情:{string.Join(";", messages.Take(10))}"
: "";
return new ExamArrangementResult(
true,
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") +
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
$"{sessions.Count}个教学班场次处理完成,生成{targetRooms.Count}个实际考场," +
$"安排{seatedStudents}名考生、{assignedInvigilators}名监考教师。" +
(expandedCount > 0
? $" 为保持混排完整性,自动包含同组{expandedCount}个场次。"
: "") +
(unavailableStudents > 0
? $" {unavailableStudents}名考生尚未安排考场。"
: "") +
(unavailableInvigilators > 0
? $" 仍缺{unavailableInvigilators}名监考教师。"
: "") +
detail);
}
private async Task<List<RoomOccupancy>> LoadOccupiedRoomsAsync(
ExamPlan plan,
IReadOnlySet<Guid> targetSessionIds,
IReadOnlySet<Guid> replacedRoomIds,
CancellationToken cancellationToken)
{
var occupied = plan.Rooms
.Where(x => !replacedRoomIds.Contains(x.Id))
.Select(x => new RoomOccupancy(
x.ClassroomId,
x.StartsAt,
x.EndsAt))
.ToList();
occupied.AddRange(plan.Sessions
.Where(x =>
!targetSessionIds.Contains(x.Id) &&
x.ClassroomId.HasValue)
.Select(x => new RoomOccupancy(
x.ClassroomId!.Value,
x.StartsAt,
x.EndsAt)));
var externalRooms = await db.ExamRooms.AsNoTracking()
.Where(x =>
x.ExamPlanId != plan.Id &&
x.ExamPlan!.Status != ExamPlanStatus.Archived)
.Select(x => new RoomOccupancy(
x.ClassroomId,
x.StartsAt,
x.EndsAt))
.ToListAsync(cancellationToken);
occupied.AddRange(externalRooms);
var externalLegacyRooms = await db.ExamSessions.AsNoTracking()
.Where(x =>
x.ExamPlanId != plan.Id &&
x.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ClassroomId != null)
.Select(x => new RoomOccupancy(
x.ClassroomId!.Value,
x.StartsAt,
x.EndsAt))
.ToListAsync(cancellationToken);
occupied.AddRange(externalLegacyRooms);
return occupied;
}
private async Task<List<Teacher>> FindInvigilatorsAsync(
ExamRoomAssignment room,
int needed,
HashSet<Guid> excludedTeacherIds,
List<InvigilatorOccupancy> occupied,
CancellationToken cancellationToken)
{
var busyTeacherIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt,
x.EndsAt,
room.StartsAt,
room.EndsAt))
.Select(x => x.TeacherId)
.ToHashSet();
var databaseBusyIds = await db.ExamRoomInvigilators.AsNoTracking()
.Where(x =>
x.ExamRoomId != room.Id &&
x.ExamRoom!.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ExamRoom.StartsAt < room.EndsAt &&
room.StartsAt < x.ExamRoom.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var teacherId in databaseBusyIds)
busyTeacherIds.Add(teacherId);
var legacyBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
.Where(x =>
x.ExamSession!.ExamPlan!.Status != ExamPlanStatus.Archived &&
x.ExamSession.StartsAt < room.EndsAt &&
room.StartsAt < x.ExamSession.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var teacherId in legacyBusyIds)
busyTeacherIds.Add(teacherId);
foreach (var teacherId in excludedTeacherIds)
busyTeacherIds.Add(teacherId);
var candidates = await InvigilatorCandidateQuery
.Create(db, busyTeacherIds)
.ToListAsync(cancellationToken);
return candidates
.OrderBy(_ => Random.Shared.Next())
.Take(needed)
.ToList();
}
private static List<Classroom> SelectRooms(
IReadOnlyCollection<Classroom> availableRooms,
int candidateCount)
{
var remainingRooms = availableRooms.ToList();
var selected = new List<Classroom>();
var remainingSeats = candidateCount;
while (remainingSeats > 0 && remainingRooms.Count > 0)
{
var room = remainingRooms
.Where(x => x.Capacity >= remainingSeats)
.OrderBy(x => x.Capacity)
.ThenBy(x => x.Name)
.FirstOrDefault()
?? remainingRooms
.OrderByDescending(x => x.Capacity)
.ThenBy(x => x.Name)
.First();
selected.Add(room);
remainingRooms.Remove(room);
remainingSeats -= room.Capacity;
}
return selected;
}
private static List<CandidateSeat> InterleaveCandidates(
IReadOnlyCollection<ExamSession> sessions,
IReadOnlyCollection<TeachingTaskRosterEntry> roster,
IReadOnlyDictionary<Guid, ExamSession> sessionByTaskId)
{
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
var queues = roster
.Where(x =>
sessionByTaskId.TryGetValue(
x.TeachingTaskId,
out var session) &&
sessionIds.Contains(session.Id))
.GroupBy(x => x.TeachingTaskId)
.OrderBy(x => sessionByTaskId[x.Key].TeachingTask!.TaskNumber)
.Select(group => new Queue<CandidateSeat>(
group.OrderBy(x => x.StudentNumber)
.Select(x => new CandidateSeat(
sessionByTaskId[x.TeachingTaskId].Id,
x.StudentId,
x.StudentNumber))))
.ToList();
var result = new List<CandidateSeat>();
var assignedStudentIds = new HashSet<Guid>();
while (queues.Any(x => x.Count > 0))
{
foreach (var queue in queues)
{
while (queue.Count > 0)
{
var candidate = queue.Dequeue();
if (!assignedStudentIds.Add(candidate.StudentId))
continue;
result.Add(candidate);
break;
}
}
}
return result;
}
private static ExamRoomAssignment CreateLegacyRoom(
ExamPlan plan,
ExamSession session,
IEnumerable<TeachingTaskRosterEntry> roster)
{
var students = roster
.OrderBy(x => x.StudentNumber)
.ToList();
return new ExamRoomAssignment
{
ExamPlanId = plan.Id,
CourseId = session.TeachingTask!.CourseId,
ClassroomId = session.ClassroomId!.Value,
ExamDate = session.ExamDate,
StartPeriod = session.StartPeriod,
PeriodCount = session.PeriodCount,
StartsAt = session.StartsAt,
EndsAt = session.EndsAt,
RequiredInvigilatorCount = session.RequiredInvigilatorCount,
SessionLinks =
[
new ExamRoomSession
{
ExamSessionId = session.Id
}
],
Seats = students.Select((student, index) =>
new ExamSeatAssignment
{
ExamSessionId = session.Id,
StudentId = student.StudentId,
SeatNumber = index + 1
}).ToList(),
Invigilators = session.Invigilators.Select(x =>
new ExamRoomInvigilator
{
TeacherId = x.TeacherId
}).ToList()
};
}
private static RoomGroupKey GroupKey(ExamSession session) => new(
session.TeachingTask!.CourseId,
session.ExamDate,
session.StartPeriod,
session.PeriodCount,
session.RequiredBuildingId);
private static void ComputeTimesFromSlots(
ExamSession session,
Dictionary<int, ScheduleTimeSlot> timeSlotLookup)
IReadOnlyDictionary<int, ScheduleTimeSlot> timeSlotLookup)
{
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
var endSlot = timeSlotLookup.GetValueOrDefault(
session.StartPeriod + session.PeriodCount - 1);
if (startSlot is null || endSlot is null) return;
var examDate = session.ExamDate;
session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc);
session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc);
}
private async Task<Classroom?> FindBestClassroomAsync(
ExamSession session,
int studentCount,
List<RoomOccupancy> occupied,
CancellationToken cancellationToken)
{
var query = db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled && x.Capacity >= studentCount);
if (session.RequiredBuildingId.HasValue)
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
// Exclude classrooms already occupied in-memory
var occupiedRoomIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.ClassroomId)
.ToHashSet();
if (occupiedRoomIds.Count > 0)
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
// Exclude classrooms occupied by DB sessions not yet tracked in memory
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlanId == session.ExamPlanId &&
x.Id != session.Id &&
x.ClassroomId != null &&
x.StartsAt < session.EndsAt &&
session.StartsAt < x.EndsAt)
.Select(x => x.ClassroomId!.Value)
.ToListAsync(cancellationToken);
if (dbOccupiedRooms.Count > 0)
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
return await query
.OrderBy(x => x.Capacity)
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<List<Teacher>> FindInvigilatorsAsync(
ExamSession session,
int needed,
HashSet<Guid> excludeTeacherIds,
List<InvigilatorOccupancy> occupied,
CancellationToken cancellationToken)
{
var busyTeacherIds = occupied
.Where(x => ExamConflictRules.TimeOverlaps(
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
.Select(x => x.TeacherId)
.ToHashSet();
var dbBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
.Where(x => x.ExamSession!.ExamPlanId == session.ExamPlanId &&
x.ExamSessionId != session.Id &&
x.ExamSession!.StartsAt < session.EndsAt &&
session.StartsAt < x.ExamSession.EndsAt)
.Select(x => x.TeacherId)
.ToListAsync(cancellationToken);
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
var candidates = await InvigilatorCandidateQuery
.Create(db, busyTeacherIds)
.ToListAsync(cancellationToken);
return candidates
.OrderBy(_ => Random.Shared.Next())
.Take(needed)
.ToList();
session.StartsAt = session.ExamDate.ToDateTime(
startSlot.StartsAt,
DateTimeKind.Utc);
session.EndsAt = session.ExamDate.ToDateTime(
endSlot.EndsAt,
DateTimeKind.Utc);
}
}
public sealed record ExamArrangementResult(bool Success, string Message)
{
public static ExamArrangementResult Fail(string message) => new(false, message);
public static ExamArrangementResult Fail(string message) =>
new(false, message);
}
Loaded 3 of 14 files, more files were not shown because too many files have changed in this diff. Show more