优化考试排考

This commit is contained in:
2026-07-27 19:31:19 +08:00 Unverified
parent 64decdab7e
commit c3e8a42641
14 changed files with 7013 additions and 272 deletions
+431 -79
View File
@@ -88,6 +88,10 @@ public sealed class ExamsController(
if (plan.Status != ExamPlanStatus.Draft) if (plan.Status != ExamPlanStatus.Draft)
return ConflictProblem("只有草稿考试计划可以删除。"); return ConflictProblem("只有草稿考试计划可以删除。");
var rooms = await db.ExamRooms
.Where(x => x.ExamPlanId == id)
.ToListAsync(cancellationToken);
db.ExamRooms.RemoveRange(rooms);
db.ExamPlans.Remove(plan); db.ExamPlans.Remove(plan);
try try
{ {
@@ -147,6 +151,39 @@ public sealed class ExamsController(
}).FirstOrDefaultAsync(cancellationToken); }).FirstOrDefaultAsync(cancellationToken);
if (plan is null) return NotFound(); 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( var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
db, db,
plan.Sessions.Select(x => x.TeachingTaskId), plan.Sessions.Select(x => x.TeachingTaskId),
@@ -185,6 +222,7 @@ public sealed class ExamsController(
item.Notes, item.Notes,
item.InvigilatorIds, item.InvigilatorIds,
item.InvigilatorNames, item.InvigilatorNames,
ExamRooms = roomsBySession[item.Id],
StudentCount = rosterCounts.GetValueOrDefault(item.TeachingTaskId) StudentCount = rosterCounts.GetValueOrDefault(item.TeachingTaskId)
}) })
}); });
@@ -358,6 +396,7 @@ public sealed class ExamsController(
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount; session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
session.Notes = Normalize(request.Notes); session.Notes = Normalize(request.Notes);
await RemoveRoomsForSessionsAsync([id], cancellationToken);
db.ExamSessionInvigilators.RemoveRange(session.Invigilators); db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid => session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid =>
new ExamSessionInvigilator { TeacherId = tid }).ToList(); new ExamSessionInvigilator { TeacherId = tid }).ToList();
@@ -377,6 +416,7 @@ public sealed class ExamsController(
if (session is null) return NotFound(); if (session is null) return NotFound();
if (session.ExamPlan!.Status != ExamPlanStatus.Draft) if (session.ExamPlan!.Status != ExamPlanStatus.Draft)
return ConflictProblem("已发布的考试计划不能调整场次。"); return ConflictProblem("已发布的考试计划不能调整场次。");
await RemoveRoomsForSessionsAsync([id], cancellationToken);
db.ExamSessions.Remove(session); db.ExamSessions.Remove(session);
return await SaveAsync(id, false, cancellationToken); return await SaveAsync(id, false, cancellationToken);
} }
@@ -408,6 +448,7 @@ public sealed class ExamsController(
return ConflictProblem( return ConflictProblem(
"所选考试场次包含不存在或不属于当前计划的记录,请刷新后重新选择。"); "所选考试场次包含不存在或不属于当前计划的记录,请刷新后重新选择。");
await RemoveRoomsForSessionsAsync(sessionIds, cancellationToken);
db.ExamSessions.RemoveRange(sessions); db.ExamSessions.RemoveRange(sessions);
try try
{ {
@@ -485,6 +526,19 @@ public sealed class ExamsController(
var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value) var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value)
.ToListAsync(cancellationToken); .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) if (occupiedIds.Count > 0)
query = query.WhereNotIn(occupiedIds, x => x.Id); query = query.WhereNotIn(occupiedIds, x => x.Id);
@@ -528,6 +582,20 @@ public sealed class ExamsController(
var busyIds = await busyQuery.Select(x => x.TeacherId) var busyIds = await busyQuery.Select(x => x.TeacherId)
.ToListAsync(cancellationToken); .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() return Ok(await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active) .Where(x => x.Status == TeacherStatus.Active)
@@ -570,7 +638,17 @@ public sealed class ExamsController(
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken) 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); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound(); if (plan is null) return NotFound();
if (plan.Status != ExamPlanStatus.Draft) if (plan.Status != ExamPlanStatus.Draft)
@@ -578,11 +656,39 @@ public sealed class ExamsController(
if (!plan.Sessions.Any()) if (!plan.Sessions.Any())
return ConflictProblem("至少安排一个考试场次后才能发布。"); 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 => 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) if (unassigned > 0)
return ConflictProblem( 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.Status = ExamPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow; plan.PublishedAt = DateTime.UtcNow;
@@ -610,6 +716,51 @@ public sealed class ExamsController(
}).FirstOrDefaultAsync(cancellationToken); }).FirstOrDefaultAsync(cancellationToken);
if (session is null) return NotFound(); 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( var students = await TeachingTaskRosterQuery.LoadForTasksAsync(
db, db,
[session.TeachingTaskId], [session.TeachingTaskId],
@@ -645,45 +796,92 @@ public sealed class ExamsController(
.Select(x => new .Select(x => new
{ {
x.Name, x.Name,
TermName = x.AcademicTerm!.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)
})
}) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (plan is null) return NotFound(); 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("当前考试计划没有可导出的考试场次。"); return ConflictProblem("当前考试计划没有可导出的考试场次。");
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync( var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db, db,
plan.Sessions.Select(x => x.TeachingTaskId), legacySessions.Select(x => x.TeachingTaskId),
cancellationToken); cancellationToken);
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId); var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
var data = new ExamSignInWorkbookData( var sheets = rooms.Select(room => new ExamSignInSessionData(
plan.Name, room.Id,
plan.TermName, room.ExamDate,
plan.Sessions.Select(session => new ExamSignInSessionData( 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.Id,
session.ExamDate, session.ExamDate,
session.StartsAt, session.StartsAt,
@@ -695,13 +893,18 @@ public sealed class ExamsController(
session.ClassroomName, session.ClassroomName,
session.InvigilatorNames.ToList(), session.InvigilatorNames.ToList(),
studentsByTask[session.TeachingTaskId] studentsByTask[session.TeachingTaskId]
.Select(student => new ExamSignInStudentData( .Select((student, index) => new ExamSignInStudentData(
student.StudentId, student.StudentId,
student.StudentNumber, student.StudentNumber,
student.Name, student.Name,
student.ClassName)) student.ClassName,
.ToList())) index + 1))
.ToList()); .ToList())))
.ToList();
var data = new ExamSignInWorkbookData(
plan.Name,
plan.TermName,
sheets);
var bytes = ExamSignInWorkbookExporter.Create(data); var bytes = ExamSignInWorkbookExporter.Create(data);
return File( return File(
@@ -724,11 +927,37 @@ public sealed class ExamsController(
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken); .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue) if (!studentId.HasValue)
return ConflictProblem("当前账号未关联学生档案。"); 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( var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent(
db, db,
studentId.Value); studentId.Value);
var schedule = await db.ExamSessions.AsNoTracking() var legacySchedule = await db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
!x.RoomLinks.Any() &&
rosterTaskIds.Contains(x.TeachingTaskId)) rosterTaskIds.Contains(x.TeachingTaskId))
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
.Select(x => new .Select(x => new
@@ -750,9 +979,10 @@ public sealed class ExamsController(
IsExam = true IsExam = true
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (schedule.Count == 0) return Ok(schedule); var taskIds = legacySchedule
.Select(x => x.TeachingTaskId)
var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray(); .Distinct()
.ToArray();
var seatRows = await TeachingTaskRosterQuery.LoadForTasksAsync( var seatRows = await TeachingTaskRosterQuery.LoadForTasksAsync(
db, db,
taskIds, taskIds,
@@ -767,29 +997,77 @@ public sealed class ExamsController(
})) }))
.ToDictionary(x => (x.TeachingTaskId, x.StudentId), x => x.SeatNumber); .ToDictionary(x => (x.TeachingTaskId, x.StudentId), x => x.SeatNumber);
return Ok(schedule.Select(x => new return Ok(assignedSchedule.Select(x => new ExamScheduleResponse(
{ x.ExamSessionId,
x.Id, x.PlanName,
x.PlanName, x.ExamDate,
x.ExamDate, x.StartPeriod,
x.StartPeriod, x.PeriodCount,
x.PeriodCount, x.StartsAt,
x.StartsAt, x.EndsAt,
x.EndsAt, x.TaskNumber,
x.TaskNumber, x.CourseCode,
x.CourseCode, x.CourseName,
x.CourseName, x.ClassroomName,
x.ClassroomName, x.BuildingName,
x.BuildingName, x.InvigilatorNames.ToList(),
x.InvigilatorNames, x.SeatNumber.ToString("D3"),
SeatNumber = seatNumbers.GetValueOrDefault((x.TeachingTaskId, studentId.Value)), null,
x.IsExam 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)) 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 && .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
!x.RoomLinks.Any() &&
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId)) x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
.Select(x => new .Select(x => new
@@ -813,28 +1091,46 @@ public sealed class ExamsController(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
db, db,
schedule.Select(x => x.TeachingTaskId), legacySchedule.Select(x => x.TeachingTaskId),
cancellationToken)) cancellationToken))
.GroupBy(x => x.TeachingTaskId) .GroupBy(x => x.TeachingTaskId)
.ToDictionary(x => x.Key, x => x.Count()); .ToDictionary(x => x.Key, x => x.Count());
return Ok(schedule.Select(x => new return Ok(roomSchedule.Select(x => new ExamScheduleResponse(
{ x.Id,
x.Id, x.PlanName,
x.PlanName, x.ExamDate,
x.ExamDate, x.StartPeriod,
x.StartPeriod, x.PeriodCount,
x.PeriodCount, x.StartsAt,
x.StartsAt, x.EndsAt,
x.EndsAt, string.Join("、", x.TaskNumbers.OrderBy(value => value)),
x.TaskNumber, x.CourseCode,
x.CourseCode, x.CourseName,
x.CourseName, x.ClassroomName,
x.ClassroomName, x.BuildingName,
x.BuildingName, x.InvigilatorNames.ToList(),
x.InvigilatorNames, null,
StudentCount = rosterCounts.GetValueOrDefault(x.TeachingTaskId), x.StudentCount,
x.IsExam 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>()); return Ok(Array.Empty<object>());
} }
@@ -843,6 +1139,24 @@ public sealed class ExamsController(
// Private helpers // 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( private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime(
Guid academicTermId, Guid academicTermId,
DateOnly examDate, DateOnly examDate,
@@ -926,6 +1240,16 @@ public sealed class ExamsController(
if (await overlaps.AnyAsync( if (await overlaps.AnyAsync(
x => x.ClassroomId == classroomId.Value, cancellationToken)) x => x.ClassroomId == classroomId.Value, cancellationToken))
return ConflictProblem("该时段考场已被占用。"); 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) if (teacherIds.Length > 0)
@@ -935,6 +1259,16 @@ public sealed class ExamsController(
.WhereIn(teacherIds, i => i.TeacherId) .WhereIn(teacherIds, i => i.TeacherId)
.AnyAsync(cancellationToken)) .AnyAsync(cancellationToken))
return ConflictProblem("监考教师在该时段已有考试任务。"); 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 var overlappingTaskIds = await overlaps
@@ -1034,3 +1368,21 @@ public sealed record ExamAutoArrangeRequest(
IReadOnlyCollection<Guid>? SessionIds = null, IReadOnlyCollection<Guid>? SessionIds = null,
bool AssignClassrooms = true, bool AssignClassrooms = true,
bool AssignInvigilators = 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 string? Notes { get; set; }
public DateTime? PublishedAt { get; set; } public DateTime? PublishedAt { get; set; }
public ICollection<ExamSession> Sessions { get; set; } = []; public ICollection<ExamSession> Sessions { get; set; } = [];
public ICollection<ExamRoomAssignment> Rooms { get; set; } = [];
} }
public sealed class ExamSession : EntityBase public sealed class ExamSession : EntityBase
@@ -31,6 +32,8 @@ public sealed class ExamSession : EntityBase
public int RequiredInvigilatorCount { get; set; } = 2; public int RequiredInvigilatorCount { get; set; } = 2;
public string? Notes { get; set; } public string? Notes { get; set; }
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = []; public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
public ICollection<ExamRoomSession> RoomLinks { get; set; } = [];
public ICollection<ExamSeatAssignment> SeatAssignments { get; set; } = [];
} }
public sealed class ExamSessionInvigilator public sealed class ExamSessionInvigilator
@@ -41,6 +44,52 @@ public sealed class ExamSessionInvigilator
public Teacher? Teacher { get; set; } 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 public enum ExamPlanStatus
{ {
Draft = 1, Draft = 1,
@@ -7,8 +7,27 @@ namespace Jiaowu.Api.Infrastructure.Exams;
public sealed class ExamArrangementService(AppDbContext db) public sealed class ExamArrangementService(AppDbContext db)
{ {
private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt); private sealed record RoomGroupKey(
private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt); 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( public async Task<ExamArrangementResult> ArrangeAsync(
Guid planId, Guid planId,
@@ -30,6 +49,12 @@ public sealed class ExamArrangementService(AppDbContext db)
.Include(x => x.Sessions) .Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask) .ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Course) .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); .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null) if (plan is null)
@@ -46,206 +71,524 @@ public sealed class ExamArrangementService(AppDbContext db)
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count) plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。"); 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() 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) .OrderBy(x => x.PeriodNumber)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (timeSlots.Count == 0) if (timeSlots.Count == 0)
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。"); return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber); var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( foreach (var session in plan.Sessions)
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
ComputeTimesFromSlots(session, timeSlotLookup); ComputeTimesFromSlots(session, timeSlotLookup);
var studentCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
// ── Auto-assign classroom ── var explicitlySelected = plan.Sessions
if (assignClassrooms && !session.ClassroomId.HasValue) .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.ClassroomId = null;
session, studentCount, occupiedRooms, cancellationToken); if (session.Invigilators.Count > 0)
if (room is not null)
{ {
session.ClassroomId = room.Id; db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt)); session.Invigilators.Clear();
assignedRooms++;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)");
}
else
{
unavailableRooms++;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
} }
} }
// ── Auto-assign invigilators ──
var currentInvigilatorCount = session.Invigilators.Count; foreach (var group in sessions
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount; .GroupBy(GroupKey)
if (assignInvigilators && needed > 0) .OrderBy(x => x.Key.ExamDate)
.ThenBy(x => x.Key.StartPeriod)
.ThenBy(x => x.First().TeachingTask!.Course!.Code))
{ {
var courseTeacherIds = session.TeachingTask!.Teachers var groupSessions = group.ToList();
.Select(x => x.TeacherId).ToHashSet(); var candidates = InterleaveCandidates(
var newlyAssigned = await FindInvigilatorsAsync( groupSessions,
session, needed, courseTeacherIds, roster,
occupiedInvigilators, cancellationToken); sessionByTaskId);
foreach (var teacher in newlyAssigned) 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 TeacherId = teacher.Id
}); });
occupiedInvigilators.Add(new InvigilatorOccupancy( occupiedInvigilators.Add(new InvigilatorOccupancy(
teacher.Id, session.StartsAt, session.EndsAt)); teacher.Id,
room.StartsAt,
room.EndsAt));
assignedInvigilators++; assignedInvigilators++;
} }
if (newlyAssigned.Count < needed) if (teachers.Count < needed)
{ unavailableInvigilators += needed - teachers.Count;
unavailableInvigilators += needed - newlyAssigned.Count;
messages.Add(
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
}
} }
} }
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
var expandedCount = sessions.Count - explicitlySelected.Count;
var detail = messages.Count > 0
? $" 详情:{string.Join("", messages.Take(10))}"
: "";
return new ExamArrangementResult( return new ExamArrangementResult(
true, true,
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" + $"{sessions.Count}个教学班场次处理完成,生成{targetRooms.Count}个实际考场," +
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") + $"安排{seatedStudents}名考生、{assignedInvigilators}名监考教师。" +
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") + (expandedCount > 0
(messages.Count > 0 ? $" 详情:{string.Join("", messages.Take(10))}" : "")); ? $" 为保持混排完整性,自动包含同组{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( private static void ComputeTimesFromSlots(
ExamSession session, ExamSession session,
Dictionary<int, ScheduleTimeSlot> timeSlotLookup) IReadOnlyDictionary<int, ScheduleTimeSlot> timeSlotLookup)
{ {
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod); var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
var endSlot = timeSlotLookup.GetValueOrDefault( var endSlot = timeSlotLookup.GetValueOrDefault(
session.StartPeriod + session.PeriodCount - 1); session.StartPeriod + session.PeriodCount - 1);
if (startSlot is null || endSlot is null) return; if (startSlot is null || endSlot is null) return;
var examDate = session.ExamDate; session.StartsAt = session.ExamDate.ToDateTime(
session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc); startSlot.StartsAt,
session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc); DateTimeKind.Utc);
} session.EndsAt = session.ExamDate.ToDateTime(
endSlot.EndsAt,
private async Task<Classroom?> FindBestClassroomAsync( DateTimeKind.Utc);
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();
} }
} }
public sealed record ExamArrangementResult(bool Success, string Message) 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);
} }
@@ -163,11 +163,15 @@ public static class ExamSignInWorkbookExporter
sheet.Row(7).Height = 27; sheet.Row(7).Height = 27;
var row = 8; var row = 8;
foreach (var student in session.Students.OrderBy(x => x.StudentNumber)) foreach (var student in session.Students
.OrderBy(x => x.SeatNumber)
.ThenBy(x => x.StudentNumber))
{ {
var index = row - 7; var index = row - 7;
sheet.Cell(row, 1).Value = index; sheet.Cell(row, 1).Value = index;
sheet.Cell(row, 2).Value = index.ToString("D3"); sheet.Cell(row, 2).Value =
(student.SeatNumber > 0 ? student.SeatNumber : index)
.ToString("D3");
sheet.Cell(row, 3).Value = student.StudentNumber; sheet.Cell(row, 3).Value = student.StudentNumber;
sheet.Cell(row, 4).Value = student.Name; sheet.Cell(row, 4).Value = student.Name;
sheet.Cell(row, 5).Value = student.ClassName; sheet.Cell(row, 5).Value = student.ClassName;
@@ -297,4 +301,5 @@ public sealed record ExamSignInStudentData(
Guid StudentId, Guid StudentId,
string StudentNumber, string StudentNumber,
string Name, string Name,
string ClassName); string ClassName,
int SeatNumber = 0);
@@ -61,6 +61,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<ExamSession> ExamSessions => Set<ExamSession>(); public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators => public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>(); Set<ExamSessionInvigilator>();
public DbSet<ExamRoomAssignment> ExamRooms => Set<ExamRoomAssignment>();
public DbSet<ExamRoomSession> ExamRoomSessions => Set<ExamRoomSession>();
public DbSet<ExamSeatAssignment> ExamSeats => Set<ExamSeatAssignment>();
public DbSet<ExamRoomInvigilator> ExamRoomInvigilators =>
Set<ExamRoomInvigilator>();
public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>(); public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>();
public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>(); public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators => public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
@@ -719,6 +724,65 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.Teacher).WithMany() entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<ExamRoomAssignment>(entity =>
{
entity.ToTable("ExamRooms");
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
.HasDatabaseName("IX_ExamRooms_Plan_Time");
entity.HasIndex(x => new
{
x.ExamPlanId,
x.ClassroomId,
x.StartsAt
})
.HasDatabaseName("IX_ExamRooms_Plan_Room_Time");
entity.HasIndex(x => x.CourseId)
.HasDatabaseName("IX_ExamRooms_CourseId");
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Rooms)
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Course).WithMany()
.HasForeignKey(x => x.CourseId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany()
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamRoomSession>(entity =>
{
entity.ToTable("ExamRoomSessions");
entity.HasKey(x => new { x.ExamRoomId, x.ExamSessionId });
entity.HasIndex(x => x.ExamSessionId)
.HasDatabaseName("IX_ExamRoomSessions_SessionId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.SessionLinks)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExamSession).WithMany(x => x.RoomLinks)
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamSeatAssignment>(entity =>
{
entity.ToTable("ExamSeats");
entity.HasKey(x => new { x.ExamRoomId, x.StudentId });
entity.HasIndex(x => new { x.ExamSessionId, x.StudentId })
.IsUnique()
.HasDatabaseName("UX_ExamSeats_Session_Student");
entity.HasIndex(x => x.StudentId)
.HasDatabaseName("IX_ExamSeats_StudentId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Seats)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExamSession).WithMany(x => x.SeatAssignments)
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamRoomInvigilator>(entity =>
{
entity.ToTable("ExamRoomInvigilators");
entity.HasKey(x => new { x.ExamRoomId, x.TeacherId });
entity.HasIndex(x => x.TeacherId)
.HasDatabaseName("IX_ExamRoomInvigilators_TeacherId");
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Invigilators)
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Teacher).WithMany()
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<MakeupExamPlan>(entity => builder.Entity<MakeupExamPlan>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -64,6 +64,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260727_34_course_adjustment_occurrences"; "20260727_34_course_adjustment_occurrences";
private const string AcademicPlanningPrerequisitesMigration = private const string AcademicPlanningPrerequisitesMigration =
"20260727_35_academic_planning_prerequisites"; "20260727_35_academic_planning_prerequisites";
private const string ExamRoomMixingMigration =
"20260727_36_exam_room_mixing";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -468,6 +470,19 @@ public sealed class DevelopmentSqliteMigrator(
AcademicPlanningPrerequisitesMigration, AcademicPlanningPrerequisitesMigration,
coursePrerequisitesExist ? [] : AcademicPlanningPrerequisiteStatements, coursePrerequisitesExist ? [] : AcademicPlanningPrerequisiteStatements,
cancellationToken); cancellationToken);
var examRoomsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'ExamRooms'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExamRoomMixingMigration,
examRoomsExist ? [] : ExamRoomMixingStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -2132,4 +2147,112 @@ public sealed class DevelopmentSqliteMigrator(
ON "CoursePrerequisites" ("PrerequisiteCourseId"); ON "CoursePrerequisites" ("PrerequisiteCourseId");
""" """
]; ];
private static readonly string[] ExamRoomMixingStatements =
[
"""
CREATE TABLE "ExamRooms" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamRooms" PRIMARY KEY,
"ExamPlanId" TEXT NOT NULL,
"CourseId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL,
"ExamDate" TEXT NOT NULL,
"StartPeriod" INTEGER NOT NULL,
"PeriodCount" INTEGER NOT NULL,
"StartsAt" TEXT NOT NULL,
"EndsAt" TEXT NOT NULL,
"RequiredInvigilatorCount" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExamRooms_ExamPlans_ExamPlanId"
FOREIGN KEY ("ExamPlanId") REFERENCES "ExamPlans" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExamRooms_Courses_CourseId"
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ExamRooms_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE INDEX "IX_ExamRooms_Plan_Time"
ON "ExamRooms" ("ExamPlanId", "StartsAt");
""",
"""
CREATE INDEX "IX_ExamRooms_Plan_Room_Time"
ON "ExamRooms" ("ExamPlanId", "ClassroomId", "StartsAt");
""",
"""
CREATE INDEX "IX_ExamRooms_CourseId"
ON "ExamRooms" ("CourseId");
""",
"""
CREATE INDEX "IX_ExamRooms_ClassroomId"
ON "ExamRooms" ("ClassroomId");
""",
"""
CREATE TABLE "ExamRoomSessions" (
"ExamRoomId" TEXT NOT NULL,
"ExamSessionId" TEXT NOT NULL,
CONSTRAINT "PK_ExamRoomSessions"
PRIMARY KEY ("ExamRoomId", "ExamSessionId"),
CONSTRAINT "FK_ExamRoomSessions_ExamRooms_ExamRoomId"
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExamRoomSessions_ExamSessions_ExamSessionId"
FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE INDEX "IX_ExamRoomSessions_SessionId"
ON "ExamRoomSessions" ("ExamSessionId");
""",
"""
CREATE TABLE "ExamSeats" (
"ExamRoomId" TEXT NOT NULL,
"ExamSessionId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"SeatNumber" INTEGER NOT NULL,
CONSTRAINT "PK_ExamSeats"
PRIMARY KEY ("ExamRoomId", "StudentId"),
CONSTRAINT "FK_ExamSeats_ExamRooms_ExamRoomId"
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExamSeats_ExamSessions_ExamSessionId"
FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ExamSeats_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX "UX_ExamSeats_Session_Student"
ON "ExamSeats" ("ExamSessionId", "StudentId");
""",
"""
CREATE INDEX "IX_ExamSeats_StudentId"
ON "ExamSeats" ("StudentId");
""",
"""
CREATE TABLE "ExamRoomInvigilators" (
"ExamRoomId" TEXT NOT NULL,
"TeacherId" TEXT NOT NULL,
CONSTRAINT "PK_ExamRoomInvigilators"
PRIMARY KEY ("ExamRoomId", "TeacherId"),
CONSTRAINT "FK_ExamRoomInvigilators_ExamRooms_ExamRoomId"
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExamRoomInvigilators_Teachers_TeacherId"
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE INDEX "IX_ExamRoomInvigilators_TeacherId"
ON "ExamRoomInvigilators" ("TeacherId");
"""
];
} }
@@ -0,0 +1,196 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamRoomMixing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExamRooms",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExamPlanId = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
ExamDate = table.Column<DateTime>(type: "date", nullable: false),
StartPeriod = table.Column<int>(type: "int", nullable: false),
PeriodCount = table.Column<int>(type: "int", nullable: false),
StartsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
EndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
RequiredInvigilatorCount = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamRooms", x => x.Id);
table.ForeignKey(
name: "FK_ExamRooms_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExamRooms_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExamRooms_ExamPlans_ExamPlanId",
column: x => x.ExamPlanId,
principalTable: "ExamPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExamRoomInvigilators",
columns: table => new
{
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
TeacherId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamRoomInvigilators", x => new { x.ExamRoomId, x.TeacherId });
table.ForeignKey(
name: "FK_ExamRoomInvigilators_ExamRooms_ExamRoomId",
column: x => x.ExamRoomId,
principalTable: "ExamRooms",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExamRoomInvigilators_Teachers_TeacherId",
column: x => x.TeacherId,
principalTable: "Teachers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExamRoomSessions",
columns: table => new
{
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
ExamSessionId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamRoomSessions", x => new { x.ExamRoomId, x.ExamSessionId });
table.ForeignKey(
name: "FK_ExamRoomSessions_ExamRooms_ExamRoomId",
column: x => x.ExamRoomId,
principalTable: "ExamRooms",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExamRoomSessions_ExamSessions_ExamSessionId",
column: x => x.ExamSessionId,
principalTable: "ExamSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExamSeats",
columns: table => new
{
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
ExamSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
SeatNumber = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExamSeats", x => new { x.ExamRoomId, x.StudentId });
table.ForeignKey(
name: "FK_ExamSeats_ExamRooms_ExamRoomId",
column: x => x.ExamRoomId,
principalTable: "ExamRooms",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExamSeats_ExamSessions_ExamSessionId",
column: x => x.ExamSessionId,
principalTable: "ExamSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExamSeats_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExamRoomInvigilators_TeacherId",
table: "ExamRoomInvigilators",
column: "TeacherId");
migrationBuilder.CreateIndex(
name: "IX_ExamRooms_ClassroomId",
table: "ExamRooms",
column: "ClassroomId");
migrationBuilder.CreateIndex(
name: "IX_ExamRooms_CourseId",
table: "ExamRooms",
column: "CourseId");
migrationBuilder.CreateIndex(
name: "IX_ExamRooms_Plan_Room_Time",
table: "ExamRooms",
columns: new[] { "ExamPlanId", "ClassroomId", "StartsAt" });
migrationBuilder.CreateIndex(
name: "IX_ExamRooms_Plan_Time",
table: "ExamRooms",
columns: new[] { "ExamPlanId", "StartsAt" });
migrationBuilder.CreateIndex(
name: "IX_ExamRoomSessions_SessionId",
table: "ExamRoomSessions",
column: "ExamSessionId");
migrationBuilder.CreateIndex(
name: "IX_ExamSeats_StudentId",
table: "ExamSeats",
column: "StudentId");
migrationBuilder.CreateIndex(
name: "UX_ExamSeats_Session_Student",
table: "ExamSeats",
columns: new[] { "ExamSessionId", "StudentId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExamRoomInvigilators");
migrationBuilder.DropTable(
name: "ExamRoomSessions");
migrationBuilder.DropTable(
name: "ExamSeats");
migrationBuilder.DropTable(
name: "ExamRooms");
}
}
}
@@ -1558,6 +1558,119 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("ExamPlans"); b.ToTable("ExamPlans");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.Property<Guid>("CourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("EndsAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("ExamDate")
.HasColumnType("date");
b.Property<Guid>("ExamPlanId")
.HasColumnType("char(36)");
b.Property<int>("PeriodCount")
.HasColumnType("int");
b.Property<int>("RequiredInvigilatorCount")
.HasColumnType("int");
b.Property<int>("StartPeriod")
.HasColumnType("int");
b.Property<DateTime>("StartsAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("ClassroomId");
b.HasIndex("CourseId")
.HasDatabaseName("IX_ExamRooms_CourseId");
b.HasIndex("ExamPlanId", "StartsAt")
.HasDatabaseName("IX_ExamRooms_Plan_Time");
b.HasIndex("ExamPlanId", "ClassroomId", "StartsAt")
.HasDatabaseName("IX_ExamRooms_Plan_Room_Time");
b.ToTable("ExamRooms", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomInvigilator", b =>
{
b.Property<Guid>("ExamRoomId")
.HasColumnType("char(36)");
b.Property<Guid>("TeacherId")
.HasColumnType("char(36)");
b.HasKey("ExamRoomId", "TeacherId");
b.HasIndex("TeacherId")
.HasDatabaseName("IX_ExamRoomInvigilators_TeacherId");
b.ToTable("ExamRoomInvigilators", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomSession", b =>
{
b.Property<Guid>("ExamRoomId")
.HasColumnType("char(36)");
b.Property<Guid>("ExamSessionId")
.HasColumnType("char(36)");
b.HasKey("ExamRoomId", "ExamSessionId");
b.HasIndex("ExamSessionId")
.HasDatabaseName("IX_ExamRoomSessions_SessionId");
b.ToTable("ExamRoomSessions", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSeatAssignment", b =>
{
b.Property<Guid>("ExamRoomId")
.HasColumnType("char(36)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<Guid>("ExamSessionId")
.HasColumnType("char(36)");
b.Property<int>("SeatNumber")
.HasColumnType("int");
b.HasKey("ExamRoomId", "StudentId");
b.HasIndex("StudentId")
.HasDatabaseName("IX_ExamSeats_StudentId");
b.HasIndex("ExamSessionId", "StudentId")
.IsUnique()
.HasDatabaseName("UX_ExamSeats_Session_Student");
b.ToTable("ExamSeats", (string)null);
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -4097,6 +4210,98 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("AcademicTerm"); b.Navigation("AcademicTerm");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
.WithMany()
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan")
.WithMany("Rooms")
.HasForeignKey("ExamPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Classroom");
b.Navigation("Course");
b.Navigation("ExamPlan");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomInvigilator", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", "ExamRoom")
.WithMany("Invigilators")
.HasForeignKey("ExamRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher")
.WithMany()
.HasForeignKey("TeacherId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ExamRoom");
b.Navigation("Teacher");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomSession", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", "ExamRoom")
.WithMany("SessionLinks")
.HasForeignKey("ExamRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession")
.WithMany("RoomLinks")
.HasForeignKey("ExamSessionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ExamRoom");
b.Navigation("ExamSession");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSeatAssignment", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", "ExamRoom")
.WithMany("Seats")
.HasForeignKey("ExamRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession")
.WithMany("SeatAssignments")
.HasForeignKey("ExamSessionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ExamRoom");
b.Navigation("ExamSession");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
@@ -4832,12 +5037,27 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b =>
{ {
b.Navigation("Rooms");
b.Navigation("Sessions"); b.Navigation("Sessions");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", b =>
{
b.Navigation("Invigilators");
b.Navigation("Seats");
b.Navigation("SessionLinks");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b =>
{ {
b.Navigation("Invigilators"); b.Navigation("Invigilators");
b.Navigation("RoomLinks");
b.Navigation("SeatAssignments");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>
@@ -21,6 +21,14 @@ public sealed class AutomaticScheduleGeneratorTests
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
await db.Database.ExecuteSqlRawAsync( await db.Database.ExecuteSqlRawAsync(
"DROP TABLE \"BackgroundJobOutboxMessages\""); "DROP TABLE \"BackgroundJobOutboxMessages\"");
await db.Database.ExecuteSqlRawAsync(
"DROP TABLE \"ExamRoomInvigilators\"");
await db.Database.ExecuteSqlRawAsync(
"DROP TABLE \"ExamSeats\"");
await db.Database.ExecuteSqlRawAsync(
"DROP TABLE \"ExamRoomSessions\"");
await db.Database.ExecuteSqlRawAsync(
"DROP TABLE \"ExamRooms\"");
var migrator = new DevelopmentSqliteMigrator( var migrator = new DevelopmentSqliteMigrator(
db, db,
@@ -30,6 +38,10 @@ public sealed class AutomaticScheduleGeneratorTests
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0); Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0); Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0); Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0);
Assert.True(await db.ExamRooms.CountAsync() == 0);
Assert.True(await db.ExamRoomSessions.CountAsync() == 0);
Assert.True(await db.ExamSeats.CountAsync() == 0);
Assert.True(await db.ExamRoomInvigilators.CountAsync() == 0);
Assert.True(await db.Database Assert.True(await db.Database
.SqlQueryRaw<int>( .SqlQueryRaw<int>(
""" """
@@ -9,7 +9,7 @@ namespace Jiaowu.Api.Tests;
public sealed class ExamArrangementServiceTests public sealed class ExamArrangementServiceTests
{ {
[Fact] [Fact]
public async Task Normal_arrangement_only_updates_selected_session() public async Task Normal_arrangement_creates_room_seats_for_selected_session()
{ {
await using var connection = new SqliteConnection("Data Source=:memory:"); await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync(); await connection.OpenAsync();
@@ -40,23 +40,76 @@ public sealed class ExamArrangementServiceTests
assignInvigilators: true, assignInvigilators: true,
CancellationToken.None); CancellationToken.None);
var persistedSelected = await db.ExamSessions var room = await db.ExamRooms
.Include(x => x.SessionLinks)
.Include(x => x.Seats)
.Include(x => x.Invigilators) .Include(x => x.Invigilators)
.SingleAsync(x => x.Id == selected.Id); .SingleAsync();
var persistedUntouched = await db.ExamSessions
.Include(x => x.Invigilators)
.SingleAsync(x => x.Id == untouched.Id);
Assert.True(result.Success); Assert.True(result.Success);
Assert.NotNull(persistedSelected.ClassroomId); Assert.Equal(seed.LargeClassroom.Id, room.ClassroomId);
Assert.Equal(seed.LargeClassroom.Id, persistedSelected.ClassroomId); Assert.Equal(selected.Id, Assert.Single(room.SessionLinks).ExamSessionId);
Assert.Null(persistedUntouched.ClassroomId); Assert.Equal(3, room.Seats.Count);
Assert.Single(persistedSelected.Invigilators); Assert.Single(room.Invigilators);
Assert.Empty(persistedUntouched.Invigilators); Assert.False(await db.ExamRoomSessions.AnyAsync(
Assert.Contains("1个场次处理完成", result.Message); x => x.ExamSessionId == untouched.Id));
Assert.Contains("1个教学班场次处理完成", result.Message);
Assert.Contains("1名监考教师", result.Message); Assert.Contains("1名监考教师", result.Message);
Assert.Contains("程序设计", result.Message); Assert.Contains("程序设计", result.Message);
} }
[Fact]
public async Task Same_course_sessions_are_interleaved_and_split_across_rooms()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var seed = await SeedBaseDataAsync(db, "MIXED");
seed.LargeClassroom.Capacity = 4;
seed.SmallClassroom.Capacity = 4;
var plan = new ExamPlan
{
AcademicTermId = seed.Term.Id,
Name = "期末考试"
};
var first = NewExamSession(plan.Id, seed.FirstTask.Id);
var second = NewExamSession(plan.Id, seed.SameCourseTask.Id);
plan.Sessions.Add(first);
plan.Sessions.Add(second);
db.ExamPlans.Add(plan);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var result = await new ExamArrangementService(db).ArrangeAsync(
plan.Id,
[first.Id],
assignClassrooms: true,
assignInvigilators: false,
CancellationToken.None);
var rooms = await db.ExamRooms
.Include(x => x.SessionLinks)
.Include(x => x.Seats)
.OrderByDescending(x => x.Seats.Count)
.ToListAsync();
Assert.True(result.Success);
Assert.Equal(2, rooms.Count);
Assert.Equal([4, 2], rooms.Select(x => x.Seats.Count).ToArray());
Assert.All(rooms, room =>
{
Assert.Equal(2, room.SessionLinks.Count);
Assert.Equal(2, room.Seats.Select(x => x.ExamSessionId).Distinct().Count());
Assert.True(room.Seats.Count <= 4);
});
Assert.Equal(6, await db.ExamSeats.CountAsync());
Assert.Contains("2个教学班混排至2个考场", result.Message);
Assert.Contains("自动包含同组1个场次", result.Message);
}
[Fact] [Fact]
public async Task Makeup_arrangement_only_updates_selected_session() public async Task Makeup_arrangement_only_updates_selected_session()
{ {
@@ -181,6 +234,13 @@ public sealed class ExamArrangementServiceTests
MajorId = major.Id, MajorId = major.Id,
Grade = 2026 Grade = 2026
}; };
var secondAdministrativeClass = new AdministrativeClass
{
Code = $"CLASS-2-{suffix}",
Name = "计科 2026-2 班",
MajorId = major.Id,
Grade = 2026
};
var firstCourse = new Course var firstCourse = new Course
{ {
Code = $"COURSE-1-{suffix}", Code = $"COURSE-1-{suffix}",
@@ -216,6 +276,21 @@ public sealed class ExamArrangementServiceTests
CourseId = secondCourse.Id, CourseId = secondCourse.Id,
Status = TeachingTaskStatus.Published Status = TeachingTaskStatus.Published
}; };
var sameCourseTask = new TeachingTask
{
TaskNumber = $"TASK-3-{suffix}",
Name = "程序设计第二教学班",
AcademicTermId = term.Id,
CourseId = firstCourse.Id,
Status = TeachingTaskStatus.Published,
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = secondAdministrativeClass.Id
}
]
};
var invigilator = new Teacher var invigilator = new Teacher
{ {
TeacherNumber = $"INVIGILATOR-{suffix}", TeacherNumber = $"INVIGILATOR-{suffix}",
@@ -233,6 +308,7 @@ public sealed class ExamArrangementServiceTests
smallClassroom, smallClassroom,
major, major,
administrativeClass, administrativeClass,
secondAdministrativeClass,
new Student new Student
{ {
StudentNumber = $"STUDENT-1-{suffix}", StudentNumber = $"STUDENT-1-{suffix}",
@@ -249,10 +325,43 @@ public sealed class ExamArrangementServiceTests
EnrollmentYear = 2026, EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1) EnrollmentDate = new DateOnly(2026, 9, 1)
}, },
new Student
{
StudentNumber = $"STUDENT-3-{suffix}",
Name = "学生丙",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
new Student
{
StudentNumber = $"STUDENT-4-{suffix}",
Name = "学生丁",
AdministrativeClassId = secondAdministrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
new Student
{
StudentNumber = $"STUDENT-5-{suffix}",
Name = "学生戊",
AdministrativeClassId = secondAdministrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
new Student
{
StudentNumber = $"STUDENT-6-{suffix}",
Name = "学生己",
AdministrativeClassId = secondAdministrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
},
firstCourse, firstCourse,
secondCourse, secondCourse,
firstTask, firstTask,
secondTask, secondTask,
sameCourseTask,
invigilator, invigilator,
new ScheduleTimeSlot new ScheduleTimeSlot
{ {
@@ -271,12 +380,20 @@ public sealed class ExamArrangementServiceTests
EndsAt = new TimeOnly(9, 50) EndsAt = new TimeOnly(9, 50)
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return new SeedData(term, firstTask, secondTask, classroom); return new SeedData(
term,
firstTask,
secondTask,
sameCourseTask,
classroom,
smallClassroom);
} }
private sealed record SeedData( private sealed record SeedData(
AcademicTerm Term, AcademicTerm Term,
TeachingTask FirstTask, TeachingTask FirstTask,
TeachingTask SecondTask, TeachingTask SecondTask,
Classroom LargeClassroom); TeachingTask SameCourseTask,
Classroom LargeClassroom,
Classroom SmallClassroom);
} }
@@ -11,7 +11,7 @@ namespace Jiaowu.Api.Tests;
public sealed class MySqlMigrationTests public sealed class MySqlMigrationTests
{ {
private const string LatestMigration = private const string LatestMigration =
"20260726190000_PersonalCalendarSubscription"; "20260727105716_ExamRoomMixing";
[Fact] [Fact]
public void Production_migration_is_discoverable_and_generates_mysql_sql() public void Production_migration_is_discoverable_and_generates_mysql_sql()
@@ -54,6 +54,10 @@ public sealed class MySqlMigrationTests
Assert.Contains( Assert.Contains(
"ADD `CalendarSubscriptionCreatedAt` datetime(6) NULL", "ADD `CalendarSubscriptionCreatedAt` datetime(6) NULL",
script); script);
Assert.Contains("CREATE TABLE `ExamRooms`", script);
Assert.Contains("CREATE TABLE `ExamRoomSessions`", script);
Assert.Contains("CREATE TABLE `ExamSeats`", script);
Assert.Contains("CREATE TABLE `ExamRoomInvigilators`", script);
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script); Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
Assert.Contains("DEFAULT 1", script); Assert.Contains("DEFAULT 1", script);
Assert.DoesNotContain("0001-01-01", script); Assert.DoesNotContain("0001-01-01", script);
@@ -41,7 +41,26 @@ public sealed class TeachingWorkflowRosterTests
NormalizedUserName = "202601001", NormalizedUserName = "202601001",
DisplayName = "周同学" DisplayName = "周同学"
}; };
var college = new College { Code = "CS", Name = "计算机学院" }; var campus = new Campus { Code = "MAIN", Name = "主校区" };
var college = new College
{
Code = "CS",
Name = "计算机学院",
CampusId = campus.Id
};
var building = new Building
{
Code = "B01",
Name = "第一教学楼",
CampusId = campus.Id
};
var classroom = new Classroom
{
Code = "B01-101",
Name = "101",
BuildingId = building.Id,
Capacity = 60
};
var major = new Major var major = new Major
{ {
Code = "080901", Code = "080901",
@@ -120,7 +139,10 @@ public sealed class TeachingWorkflowRosterTests
db.AddRange( db.AddRange(
teacherUser, teacherUser,
studentUser, studentUser,
campus,
college, college,
building,
classroom,
major, major,
administrativeClass, administrativeClass,
student, student,
@@ -130,22 +152,57 @@ public sealed class TeachingWorkflowRosterTests
task); task);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var examSession = new ExamSession
{
TeachingTaskId = task.Id,
ExamDate = new DateOnly(2027, 1, 8),
StartPeriod = 1,
PeriodCount = 2,
StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc),
RequiredInvigilatorCount = 1
};
var examPlan = new ExamPlan var examPlan = new ExamPlan
{ {
AcademicTermId = term.Id, AcademicTermId = term.Id,
Name = "期末考试", Name = "期末考试",
Status = ExamPlanStatus.Published, Status = ExamPlanStatus.Draft,
Sessions = Sessions = [examSession],
Rooms =
[ [
new ExamSession new ExamRoomAssignment
{ {
TeachingTaskId = task.Id, CourseId = course.Id,
ClassroomId = classroom.Id,
ExamDate = new DateOnly(2027, 1, 8), ExamDate = new DateOnly(2027, 1, 8),
StartPeriod = 1, StartPeriod = 1,
PeriodCount = 2, PeriodCount = 2,
StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc), StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc), EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc),
RequiredInvigilatorCount = 1 RequiredInvigilatorCount = 1,
SessionLinks =
[
new ExamRoomSession
{
ExamSessionId = examSession.Id
}
],
Seats =
[
new ExamSeatAssignment
{
ExamSessionId = examSession.Id,
StudentId = student.Id,
SeatNumber = 1
}
],
Invigilators =
[
new ExamRoomInvigilator
{
TeacherId = teacher.Id
}
]
} }
] ]
}; };
@@ -195,10 +252,14 @@ public sealed class TeachingWorkflowRosterTests
managerScope, managerScope,
new ExamArrangementService(db), new ExamArrangementService(db),
NoOpAppCache.Instance); NoOpAppCache.Instance);
var publishResult = await exams.Publish(examPlan.Id, CancellationToken.None);
Assert.IsType<NoContentResult>(publishResult);
var planResult = Assert.IsType<OkObjectResult>( var planResult = Assert.IsType<OkObjectResult>(
await exams.GetPlan(examPlan.Id, CancellationToken.None)); await exams.GetPlan(examPlan.Id, CancellationToken.None));
var planSessions = ReadEnumerableProperty(planResult.Value!, "Sessions"); var planSessions = ReadEnumerableProperty(planResult.Value!, "Sessions");
Assert.Equal(1, ReadIntProperty(planSessions.Single(), "StudentCount")); Assert.Equal(1, ReadIntProperty(planSessions.Single(), "StudentCount"));
Assert.Single(ReadEnumerableProperty(planSessions.Single(), "ExamRooms"));
var examSessionId = examPlan.Sessions.Single().Id; var examSessionId = examPlan.Sessions.Single().Id;
var rosterResult = Assert.IsType<OkObjectResult>( var rosterResult = Assert.IsType<OkObjectResult>(
@@ -212,6 +273,13 @@ public sealed class TeachingWorkflowRosterTests
NoOpAppCache.Instance); NoOpAppCache.Instance);
Assert.Single(ReadItems( Assert.Single(ReadItems(
await studentExams.GetMySchedule(CancellationToken.None))); await studentExams.GetMySchedule(CancellationToken.None)));
var teacherExams = new ExamsController(
db,
scope,
new ExamArrangementService(db),
NoOpAppCache.Instance);
Assert.Single(ReadItems(
await teacherExams.GetMySchedule(CancellationToken.None)));
var exportResult = Assert.IsType<FileContentResult>( var exportResult = Assert.IsType<FileContentResult>(
await exams.ExportSignInSheets(examPlan.Id, CancellationToken.None)); await exams.ExportSignInSheets(examPlan.Id, CancellationToken.None));
+58 -9
View File
@@ -68,17 +68,39 @@ const filteredTasks = computed(() => {
const filteredSessions = computed(() => { const filteredSessions = computed(() => {
const keyword = sessionFilter.keyword.trim().toLowerCase() const keyword = sessionFilter.keyword.trim().toLowerCase()
return (selected.value?.sessions ?? []).filter((session: any) => { return (selected.value?.sessions ?? []).filter((session: any) => {
if (sessionFilter.allocation === 'room' && session.classroomId) return false if (sessionFilter.allocation === 'room' && hasRoomAssignment(session)) return false
if (sessionFilter.allocation === 'invigilator' && if (sessionFilter.allocation === 'invigilator' &&
session.invigilatorIds.length >= session.requiredInvigilatorCount) return false hasCompleteInvigilators(session)) return false
if (sessionFilter.allocation === 'complete' && if (sessionFilter.allocation === 'complete' &&
(!session.classroomId || session.invigilatorIds.length < session.requiredInvigilatorCount)) return false (!hasRoomAssignment(session) || !hasCompleteInvigilators(session))) return false
if (!keyword) return true if (!keyword) return true
return [session.taskNumber, session.courseCode, session.courseName, session.taskName] return [session.taskNumber, session.courseCode, session.courseName, session.taskName]
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword)) .some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
}) })
}) })
function sessionRooms(session: any) {
return session.examRooms ?? []
}
function hasRoomAssignment(session: any) {
return sessionRooms(session).length > 0 || Boolean(session.classroomId)
}
function hasCompleteInvigilators(session: any) {
const roomAssignments = sessionRooms(session)
if (roomAssignments.length > 0) {
return roomAssignments.every((room: any) =>
room.invigilatorIds.length >= session.requiredInvigilatorCount)
}
return session.invigilatorIds.length >= session.requiredInvigilatorCount
}
function sessionInvigilatorNames(session: any) {
const roomAssignments = sessionRooms(session)
const names = roomAssignments.length > 0
? roomAssignments.flatMap((room: any) => room.invigilatorNames)
: session.invigilatorNames
return Array.from(new Set(names))
}
function timeText(startsAt: string) { function timeText(startsAt: string) {
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
} }
@@ -144,7 +166,18 @@ async function savePlan() {
await load() await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) } } catch (error) { ElMessage.error(apiErrorMessage(error)) }
} }
function openSession(existing?: any) { async function openSession(existing?: any) {
if (existing && sessionRooms(existing).length > 0) {
try {
await ElMessageBox.confirm(
'修改该教学班的课程或考试时间会清除相关混排考场和座位,需要重新执行考场编排。',
'修改混排场次',
{ type: 'warning', confirmButtonText: '继续修改' },
)
} catch {
return
}
}
editingSession.value = existing ?? null editingSession.value = existing ?? null
selectedTaskIds.value = existing ? [existing.teachingTaskId] : [] selectedTaskIds.value = existing ? [existing.teachingTaskId] : []
Object.assign(taskFilter, { keyword: '', collegeId: '', courseNature: '' }) Object.assign(taskFilter, { keyword: '', collegeId: '', courseNature: '' })
@@ -263,8 +296,11 @@ async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
: '当前计划全部场次' : '当前计划全部场次'
const action = mode === 'rooms' ? '分配考场' const action = mode === 'rooms' ? '分配考场'
: mode === 'invigilators' ? '分配监考教师' : '分配考场和监考教师' : mode === 'invigilators' ? '分配监考教师' : '分配考场和监考教师'
const arrangementNotice = mode === 'invigilators'
? '已有考场和座位不会改变。'
: '系统会按同一课程、同一时间生成完整混排组,并重建相关考场和座位。'
await ElMessageBox.confirm( await ElMessageBox.confirm(
`系统将为${target}${action},已有安排不会被覆盖`, `系统将为${target}${action}${arrangementNotice}`,
`一键${action}`, { type: 'info', confirmButtonText: '开始分配' }) `一键${action}`, { type: 'info', confirmButtonText: '开始分配' })
arrangeLoading.value = true arrangeLoading.value = true
const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`, { const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`, {
@@ -373,7 +409,7 @@ onMounted(async () => {
<div> <div>
<span class="section-kicker">EXAMINATION OFFICE</span> <span class="section-kicker">EXAMINATION OFFICE</span>
<h2>{{ isManager ? '考试安排与考场' : isTeacher ? '我的监考' : '我的考试' }}</h2> <h2>{{ isManager ? '考试安排与考场' : isTeacher ? '我的监考' : '我的考试' }}</h2>
<p>{{ isManager ? '基于课表节次安排考试,自动匹配考场与监考教师。' : '查看学校已经正式发布的考试日程。' }}</p> <p>{{ isManager ? '基于课表节次安排考试,同课程教学班混排考场并自动匹配监考教师。' : '查看学校已经正式发布的考试日程。' }}</p>
</div> </div>
<el-button v-if="isManager" type="primary" :icon="Plus" @click="openPlan">新建考试计划</el-button> <el-button v-if="isManager" type="primary" :icon="Plus" @click="openPlan">新建考试计划</el-button>
<el-button v-else :icon="Refresh" @click="load">刷新日程</el-button> <el-button v-else :icon="Refresh" @click="load">刷新日程</el-button>
@@ -438,7 +474,7 @@ onMounted(async () => {
</template> </template>
</div> </div>
<div class="exam-timeline"> <div class="exam-timeline">
<article v-for="session in filteredSessions" :key="session.id" :class="{ unassigned: !session.classroomId }"> <article v-for="session in filteredSessions" :key="session.id" :class="{ unassigned: !hasRoomAssignment(session) }">
<time> <time>
<el-checkbox <el-checkbox
v-if="selected.status === 'Draft'" v-if="selected.status === 'Draft'"
@@ -452,7 +488,14 @@ onMounted(async () => {
<span>{{ session.courseCode }} · {{ session.taskNumber }}</span> <span>{{ session.courseCode }} · {{ session.taskNumber }}</span>
<h4>{{ session.courseName }}</h4> <h4>{{ session.courseName }}</h4>
<p> <p>
<template v-if="session.classroomId">{{ session.buildingName }} · {{ session.classroomName }} · {{ session.classroomCapacity }}</template> <template v-if="sessionRooms(session).length">
<span v-for="room in sessionRooms(session)" :key="room.examRoomId" class="exam-room-chip">
{{ room.buildingName }} · {{ room.classroomName }}
· 本班{{ room.seatCount }}/全场{{ room.totalSeatCount }}
<el-tag v-if="room.isMixed" size="small" type="success">混排</el-tag>
</span>
</template>
<template v-else-if="session.classroomId">{{ session.buildingName }} · {{ session.classroomName }} · {{ session.classroomCapacity }}</template>
<template v-else><el-tag size="small" type="warning">待分配考场</el-tag></template> <template v-else><el-tag size="small" type="warning">待分配考场</el-tag></template>
· {{ session.studentCount }} · {{ session.studentCount }}
<template v-if="session.requiredBuildingName"> · {{ session.requiredBuildingName }}</template> <template v-if="session.requiredBuildingName"> · {{ session.requiredBuildingName }}</template>
@@ -461,7 +504,7 @@ onMounted(async () => {
</div> </div>
<div class="exam-staff"> <div class="exam-staff">
<span>监考</span> <span>监考</span>
<b>{{ session.invigilatorNames.length ? session.invigilatorNames.join('、') : '待分配' }}</b> <b>{{ sessionInvigilatorNames(session).length ? sessionInvigilatorNames(session).join('、') : '待分配' }}</b>
<small>{{ timeText(session.startsAt) }}{{ timeText(session.endsAt) }}</small> <small>{{ timeText(session.startsAt) }}{{ timeText(session.endsAt) }}</small>
</div> </div>
<div class="exam-row-actions"> <div class="exam-row-actions">
@@ -607,6 +650,8 @@ onMounted(async () => {
<!-- Roster Drawer --> <!-- Roster Drawer -->
<el-drawer v-model="rosterDrawer" title="考生名单" size="560px"> <el-drawer v-model="rosterDrawer" title="考生名单" size="560px">
<el-table v-if="roster" :data="roster.students"> <el-table v-if="roster" :data="roster.students">
<el-table-column prop="buildingName" label="教学楼" width="110" />
<el-table-column prop="classroomName" label="考场" width="90" />
<el-table-column prop="seatNumber" label="座位号" width="80" /> <el-table-column prop="seatNumber" label="座位号" width="80" />
<el-table-column prop="studentNumber" label="学号" width="130" /> <el-table-column prop="studentNumber" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="90" /> <el-table-column prop="name" label="姓名" width="90" />
@@ -655,6 +700,10 @@ onMounted(async () => {
.exam-timeline article.unassigned { .exam-timeline article.unassigned {
border-left-color: #e6a23c; border-left-color: #e6a23c;
} }
.exam-room-chip {
display: block;
margin: 3px 0;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.exam-actions { .exam-actions {
justify-content: flex-start; justify-content: flex-start;