优化考试排考
This commit is contained in:
@@ -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,10 +796,34 @@ 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)
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
.ThenBy(session => session.StartPeriod)
|
if (plan is null) return NotFound();
|
||||||
|
|
||||||
|
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
|
.Select(session => new
|
||||||
{
|
{
|
||||||
session.Id,
|
session.Id,
|
||||||
@@ -669,21 +844,44 @@ public sealed class ExamsController(
|
|||||||
.OrderBy(item => item.Teacher!.TeacherNumber)
|
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||||
.Select(item => item.Teacher!.Name)
|
.Select(item => item.Teacher!.Name)
|
||||||
})
|
})
|
||||||
})
|
.ToListAsync(cancellationToken);
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
if (rooms.Count == 0 && legacySessions.Count == 0)
|
||||||
if (plan is null) return NotFound();
|
|
||||||
if (!plan.Sessions.Any())
|
|
||||||
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,8 +997,24 @@ 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.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.Id,
|
||||||
x.PlanName,
|
x.PlanName,
|
||||||
x.ExamDate,
|
x.ExamDate,
|
||||||
@@ -781,15 +1027,47 @@ public sealed class ExamsController(
|
|||||||
x.CourseName,
|
x.CourseName,
|
||||||
x.ClassroomName,
|
x.ClassroomName,
|
||||||
x.BuildingName,
|
x.BuildingName,
|
||||||
x.InvigilatorNames,
|
x.InvigilatorNames.ToList(),
|
||||||
SeatNumber = seatNumbers.GetValueOrDefault((x.TeachingTaskId, studentId.Value)),
|
seatNumbers.GetValueOrDefault(
|
||||||
x.IsExam
|
(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,12 +1091,28 @@ 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.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.Id,
|
||||||
x.PlanName,
|
x.PlanName,
|
||||||
x.ExamDate,
|
x.ExamDate,
|
||||||
@@ -831,10 +1125,12 @@ public sealed class ExamsController(
|
|||||||
x.CourseName,
|
x.CourseName,
|
||||||
x.ClassroomName,
|
x.ClassroomName,
|
||||||
x.BuildingName,
|
x.BuildingName,
|
||||||
x.InvigilatorNames,
|
x.InvigilatorNames.ToList(),
|
||||||
StudentCount = rosterCounts.GetValueOrDefault(x.TeachingTaskId),
|
null,
|
||||||
x.IsExam
|
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)
|
||||||
|
ComputeTimesFromSlots(session, timeSlotLookup);
|
||||||
|
|
||||||
|
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,
|
db,
|
||||||
sessions.Select(x => x.TeachingTaskId),
|
sessions.Select(x => x.TeachingTaskId),
|
||||||
cancellationToken))
|
cancellationToken);
|
||||||
|
var sessionByTaskId = sessions
|
||||||
.GroupBy(x => x.TeachingTaskId)
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
.ToDictionary(x => x.Key, x => x.Count());
|
.ToDictionary(x => x.Key, x => x.First());
|
||||||
|
|
||||||
int assignedRooms = 0;
|
var replacedRooms = assignClassrooms
|
||||||
int assignedInvigilators = 0;
|
? plan.Rooms
|
||||||
int unavailableRooms = 0;
|
.Where(room => room.SessionLinks.Any(link =>
|
||||||
int unavailableInvigilators = 0;
|
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 messages = new List<string>();
|
||||||
|
var seatedStudents = 0;
|
||||||
|
var unavailableStudents = 0;
|
||||||
|
|
||||||
// Track occupied time slots to avoid conflicts
|
if (assignClassrooms)
|
||||||
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)
|
foreach (var session in sessions)
|
||||||
{
|
{
|
||||||
// Compute StartsAt/EndsAt from time slots
|
session.ClassroomId = null;
|
||||||
ComputeTimesFromSlots(session, timeSlotLookup);
|
if (session.Invigilators.Count > 0)
|
||||||
var studentCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
|
{
|
||||||
|
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||||
|
session.Invigilators.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Auto-assign classroom ──
|
foreach (var group in sessions
|
||||||
if (assignClassrooms && !session.ClassroomId.HasValue)
|
.GroupBy(GroupKey)
|
||||||
|
.OrderBy(x => x.Key.ExamDate)
|
||||||
|
.ThenBy(x => x.Key.StartPeriod)
|
||||||
|
.ThenBy(x => x.First().TeachingTask!.Course!.Code))
|
||||||
{
|
{
|
||||||
var room = await FindBestClassroomAsync(
|
var groupSessions = group.ToList();
|
||||||
session, studentCount, occupiedRooms, cancellationToken);
|
var candidates = InterleaveCandidates(
|
||||||
if (room is not null)
|
groupSessions,
|
||||||
|
roster,
|
||||||
|
sessionByTaskId);
|
||||||
|
if (candidates.Count == 0)
|
||||||
{
|
{
|
||||||
session.ClassroomId = room.Id;
|
|
||||||
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
|
||||||
assignedRooms++;
|
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)");
|
$"“{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
|
else
|
||||||
{
|
{
|
||||||
unavailableRooms++;
|
targetRooms.AddRange(plan.Rooms.Where(room =>
|
||||||
messages.Add(
|
room.SessionLinks.Any(link =>
|
||||||
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ── Auto-assign invigilators ──
|
seatedStudents = targetRooms.Sum(x => x.Seats.Count);
|
||||||
var currentInvigilatorCount = session.Invigilators.Count;
|
}
|
||||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
|
||||||
if (assignInvigilators && needed > 0)
|
var assignedInvigilators = 0;
|
||||||
|
var unavailableInvigilators = 0;
|
||||||
|
if (assignInvigilators)
|
||||||
{
|
{
|
||||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
if (targetRooms.Count == 0)
|
||||||
.Select(x => x.TeacherId).ToHashSet();
|
return ExamArrangementResult.Fail("尚未生成实际考场,请先分配考场。");
|
||||||
var newlyAssigned = await FindInvigilatorsAsync(
|
|
||||||
session, needed, courseTeacherIds,
|
var occupiedInvigilators = plan.Rooms
|
||||||
occupiedInvigilators, cancellationToken);
|
.Where(x => !replacedRoomIds.Contains(x.Id) &&
|
||||||
foreach (var teacher in newlyAssigned)
|
!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))
|
||||||
{
|
{
|
||||||
session.Invigilators.Add(new ExamSessionInvigilator
|
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");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260727105716_ExamRoomMixing.Designer.cs
Generated
+5139
File diff suppressed because it is too large
Load Diff
+196
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+220
@@ -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,14 +152,7 @@ public sealed class TeachingWorkflowRosterTests
|
|||||||
task);
|
task);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var examPlan = new ExamPlan
|
var examSession = new ExamSession
|
||||||
{
|
|
||||||
AcademicTermId = term.Id,
|
|
||||||
Name = "期末考试",
|
|
||||||
Status = ExamPlanStatus.Published,
|
|
||||||
Sessions =
|
|
||||||
[
|
|
||||||
new ExamSession
|
|
||||||
{
|
{
|
||||||
TeachingTaskId = task.Id,
|
TeachingTaskId = task.Id,
|
||||||
ExamDate = new DateOnly(2027, 1, 8),
|
ExamDate = new DateOnly(2027, 1, 8),
|
||||||
@@ -146,6 +161,48 @@ public sealed class TeachingWorkflowRosterTests
|
|||||||
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
|
||||||
|
};
|
||||||
|
var examPlan = new ExamPlan
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
Name = "期末考试",
|
||||||
|
Status = ExamPlanStatus.Draft,
|
||||||
|
Sessions = [examSession],
|
||||||
|
Rooms =
|
||||||
|
[
|
||||||
|
new ExamRoomAssignment
|
||||||
|
{
|
||||||
|
CourseId = course.Id,
|
||||||
|
ClassroomId = classroom.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,
|
||||||
|
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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user