优化考试排考

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)
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);
}
@@ -163,11 +163,15 @@ public static class ExamSignInWorkbookExporter
sheet.Row(7).Height = 27;
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;
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, 4).Value = student.Name;
sheet.Cell(row, 5).Value = student.ClassName;
@@ -297,4 +301,5 @@ public sealed record ExamSignInStudentData(
Guid StudentId,
string StudentNumber,
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<ExamSessionInvigilator> ExamSessionInvigilators =>
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<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
@@ -719,6 +724,65 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasOne(x => x.Teacher).WithMany()
.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 =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -64,6 +64,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260727_34_course_adjustment_occurrences";
private const string AcademicPlanningPrerequisitesMigration =
"20260727_35_academic_planning_prerequisites";
private const string ExamRoomMixingMigration =
"20260727_36_exam_room_mixing";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -468,6 +470,19 @@ public sealed class DevelopmentSqliteMigrator(
AcademicPlanningPrerequisitesMigration,
coursePrerequisitesExist ? [] : AcademicPlanningPrerequisiteStatements,
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(
@@ -2132,4 +2147,112 @@ public sealed class DevelopmentSqliteMigrator(
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");
});
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 =>
{
b.Property<Guid>("Id")
@@ -4097,6 +4210,98 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
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 =>
{
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 =>
{
b.Navigation("Rooms");
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 =>
{
b.Navigation("Invigilators");
b.Navigation("RoomLinks");
b.Navigation("SeatAssignments");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b =>