优化考试排考
This commit is contained in:
@@ -88,6 +88,10 @@ public sealed class ExamsController(
|
||||
if (plan.Status != ExamPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿考试计划可以删除。");
|
||||
|
||||
var rooms = await db.ExamRooms
|
||||
.Where(x => x.ExamPlanId == id)
|
||||
.ToListAsync(cancellationToken);
|
||||
db.ExamRooms.RemoveRange(rooms);
|
||||
db.ExamPlans.Remove(plan);
|
||||
try
|
||||
{
|
||||
@@ -147,6 +151,39 @@ public sealed class ExamsController(
|
||||
}).FirstOrDefaultAsync(cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
|
||||
var roomEntities = await db.ExamRooms.AsNoTracking()
|
||||
.Include(x => x.Classroom)
|
||||
.ThenInclude(x => x!.Building)
|
||||
.Include(x => x.SessionLinks)
|
||||
.Include(x => x.Seats)
|
||||
.Include(x => x.Invigilators)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.Where(x => x.ExamPlanId == id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var roomsBySession = roomEntities
|
||||
.SelectMany(room => room.SessionLinks.Select(link => new
|
||||
{
|
||||
link.ExamSessionId,
|
||||
Room = new
|
||||
{
|
||||
link.ExamRoomId,
|
||||
room.ClassroomId,
|
||||
ClassroomName = room.Classroom!.Name,
|
||||
BuildingName = room.Classroom.Building!.Name,
|
||||
ClassroomCapacity = room.Classroom.Capacity,
|
||||
SeatCount = room.Seats.Count(seat =>
|
||||
seat.ExamSessionId == link.ExamSessionId),
|
||||
TotalSeatCount = room.Seats.Count,
|
||||
IsMixed = room.SessionLinks.Count > 1,
|
||||
InvigilatorIds = room.Invigilators
|
||||
.Select(invigilator => invigilator.TeacherId)
|
||||
.ToList(),
|
||||
InvigilatorNames = room.Invigilators
|
||||
.Select(invigilator => invigilator.Teacher!.Name)
|
||||
.ToList()
|
||||
}
|
||||
}))
|
||||
.ToLookup(x => x.ExamSessionId, x => x.Room);
|
||||
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||
db,
|
||||
plan.Sessions.Select(x => x.TeachingTaskId),
|
||||
@@ -185,6 +222,7 @@ public sealed class ExamsController(
|
||||
item.Notes,
|
||||
item.InvigilatorIds,
|
||||
item.InvigilatorNames,
|
||||
ExamRooms = roomsBySession[item.Id],
|
||||
StudentCount = rosterCounts.GetValueOrDefault(item.TeachingTaskId)
|
||||
})
|
||||
});
|
||||
@@ -358,6 +396,7 @@ public sealed class ExamsController(
|
||||
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
||||
session.Notes = Normalize(request.Notes);
|
||||
|
||||
await RemoveRoomsForSessionsAsync([id], cancellationToken);
|
||||
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||
session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid =>
|
||||
new ExamSessionInvigilator { TeacherId = tid }).ToList();
|
||||
@@ -377,6 +416,7 @@ public sealed class ExamsController(
|
||||
if (session is null) return NotFound();
|
||||
if (session.ExamPlan!.Status != ExamPlanStatus.Draft)
|
||||
return ConflictProblem("已发布的考试计划不能调整场次。");
|
||||
await RemoveRoomsForSessionsAsync([id], cancellationToken);
|
||||
db.ExamSessions.Remove(session);
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
@@ -408,6 +448,7 @@ public sealed class ExamsController(
|
||||
return ConflictProblem(
|
||||
"所选考试场次包含不存在或不属于当前计划的记录,请刷新后重新选择。");
|
||||
|
||||
await RemoveRoomsForSessionsAsync(sessionIds, cancellationToken);
|
||||
db.ExamSessions.RemoveRange(sessions);
|
||||
try
|
||||
{
|
||||
@@ -485,6 +526,19 @@ public sealed class ExamsController(
|
||||
|
||||
var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value)
|
||||
.ToListAsync(cancellationToken);
|
||||
var occupiedRoomQuery = db.ExamRooms.AsNoTracking()
|
||||
.Where(x => x.StartsAt < endsAt && startsAt < x.EndsAt);
|
||||
if (planId.HasValue)
|
||||
occupiedRoomQuery = occupiedRoomQuery.Where(
|
||||
x => x.ExamPlanId == planId.Value);
|
||||
if (excludeSessionId.HasValue)
|
||||
occupiedRoomQuery = occupiedRoomQuery.Where(
|
||||
x => !x.SessionLinks.Any(link =>
|
||||
link.ExamSessionId == excludeSessionId.Value));
|
||||
occupiedIds.AddRange(await occupiedRoomQuery
|
||||
.Select(x => x.ClassroomId)
|
||||
.ToListAsync(cancellationToken));
|
||||
occupiedIds = occupiedIds.Distinct().ToList();
|
||||
|
||||
if (occupiedIds.Count > 0)
|
||||
query = query.WhereNotIn(occupiedIds, x => x.Id);
|
||||
@@ -528,6 +582,20 @@ public sealed class ExamsController(
|
||||
|
||||
var busyIds = await busyQuery.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var roomBusyQuery = db.ExamRoomInvigilators.AsNoTracking()
|
||||
.Where(x => x.ExamRoom!.StartsAt < endsAt &&
|
||||
startsAt < x.ExamRoom.EndsAt);
|
||||
if (planId.HasValue)
|
||||
roomBusyQuery = roomBusyQuery.Where(
|
||||
x => x.ExamRoom!.ExamPlanId == planId.Value);
|
||||
if (excludeSessionId.HasValue)
|
||||
roomBusyQuery = roomBusyQuery.Where(
|
||||
x => !x.ExamRoom!.SessionLinks.Any(link =>
|
||||
link.ExamSessionId == excludeSessionId.Value));
|
||||
busyIds.AddRange(await roomBusyQuery
|
||||
.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken));
|
||||
busyIds = busyIds.Distinct().ToList();
|
||||
|
||||
return Ok(await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
@@ -570,7 +638,17 @@ public sealed class ExamsController(
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await db.ExamPlans.Include(x => x.Sessions)
|
||||
var plan = await db.ExamPlans
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.Invigilators)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.RoomLinks)
|
||||
.ThenInclude(x => x.ExamRoom)
|
||||
.ThenInclude(x => x!.Seats)
|
||||
.Include(x => x.Sessions)
|
||||
.ThenInclude(x => x.RoomLinks)
|
||||
.ThenInclude(x => x.ExamRoom)
|
||||
.ThenInclude(x => x!.Invigilators)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != ExamPlanStatus.Draft)
|
||||
@@ -578,11 +656,39 @@ public sealed class ExamsController(
|
||||
if (!plan.Sessions.Any())
|
||||
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
||||
|
||||
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||
db,
|
||||
plan.Sessions.Select(x => x.TeachingTaskId),
|
||||
cancellationToken))
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.ToDictionary(x => x.Key, x => x.Count());
|
||||
var unassigned = plan.Sessions.Count(x =>
|
||||
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
|
||||
{
|
||||
if (x.RoomLinks.Count == 0)
|
||||
return !x.ClassroomId.HasValue || x.Invigilators.Count == 0;
|
||||
var assignedSeatCount = x.RoomLinks
|
||||
.SelectMany(link => link.ExamRoom!.Seats)
|
||||
.Count(seat => seat.ExamSessionId == x.Id);
|
||||
return assignedSeatCount !=
|
||||
rosterCounts.GetValueOrDefault(x.TeachingTaskId) ||
|
||||
x.RoomLinks.Any(link =>
|
||||
link.ExamRoom!.Invigilators.Count <
|
||||
link.ExamRoom.RequiredInvigilatorCount);
|
||||
});
|
||||
if (unassigned > 0)
|
||||
return ConflictProblem(
|
||||
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||
$"还有 {unassigned} 个教学班未完成考场座位或监考安排,请先完成自动编排。");
|
||||
|
||||
var invalidMixedRoomCount = await db.ExamRooms.AsNoTracking()
|
||||
.CountAsync(room =>
|
||||
room.ExamPlanId == id &&
|
||||
(room.Seats.Count > room.Classroom!.Capacity ||
|
||||
room.SessionLinks.Any(link =>
|
||||
link.ExamSession!.TeachingTask!.CourseId != room.CourseId)),
|
||||
cancellationToken);
|
||||
if (invalidMixedRoomCount > 0)
|
||||
return ConflictProblem(
|
||||
$"发现 {invalidMixedRoomCount} 个考场容量超限或混入不同课程,请重新编排。");
|
||||
|
||||
plan.Status = ExamPlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
@@ -610,6 +716,51 @@ public sealed class ExamsController(
|
||||
}).FirstOrDefaultAsync(cancellationToken);
|
||||
if (session is null) return NotFound();
|
||||
|
||||
var assignedStudents = await db.ExamSeats.AsNoTracking()
|
||||
.Where(x => x.ExamSessionId == id)
|
||||
.OrderBy(x => x.ExamRoom!.Classroom!.Building!.Name)
|
||||
.ThenBy(x => x.ExamRoom!.Classroom!.Name)
|
||||
.ThenBy(x => x.SeatNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
x.ExamRoomId,
|
||||
ClassroomName = x.ExamRoom!.Classroom!.Name,
|
||||
BuildingName = x.ExamRoom.Classroom.Building!.Name,
|
||||
x.SeatNumber
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
if (assignedStudents.Count > 0)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
session.Id,
|
||||
session.TeachingTaskId,
|
||||
session.TaskNumber,
|
||||
session.CourseName,
|
||||
ClassroomName = string.Join(
|
||||
"、",
|
||||
assignedStudents
|
||||
.Select(x => $"{x.BuildingName} · {x.ClassroomName}")
|
||||
.Distinct()),
|
||||
session.StartsAt,
|
||||
Students = assignedStudents.Select(student => new
|
||||
{
|
||||
student.StudentId,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.ClassName,
|
||||
student.ExamRoomId,
|
||||
student.ClassroomName,
|
||||
student.BuildingName,
|
||||
SeatNumber = student.SeatNumber.ToString("D3")
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
var students = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||
db,
|
||||
[session.TeachingTaskId],
|
||||
@@ -645,45 +796,92 @@ public sealed class ExamsController(
|
||||
.Select(x => new
|
||||
{
|
||||
x.Name,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
Sessions = x.Sessions
|
||||
.OrderBy(session => session.ExamDate)
|
||||
.ThenBy(session => session.StartPeriod)
|
||||
.Select(session => new
|
||||
{
|
||||
session.Id,
|
||||
session.TeachingTaskId,
|
||||
session.ExamDate,
|
||||
session.StartsAt,
|
||||
session.EndsAt,
|
||||
CourseCode = session.TeachingTask!.Course!.Code,
|
||||
CourseName = session.TeachingTask.Course.Name,
|
||||
session.TeachingTask.TaskNumber,
|
||||
BuildingName = session.Classroom != null
|
||||
? session.Classroom.Building!.Name
|
||||
: null,
|
||||
ClassroomName = session.Classroom != null
|
||||
? session.Classroom.Name
|
||||
: null,
|
||||
InvigilatorNames = session.Invigilators
|
||||
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||
.Select(item => item.Teacher!.Name)
|
||||
})
|
||||
TermName = x.AcademicTerm!.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
if (!plan.Sessions.Any())
|
||||
|
||||
var rooms = await db.ExamRooms.AsNoTracking()
|
||||
.Include(x => x.Course)
|
||||
.Include(x => x.Classroom)
|
||||
.ThenInclude(x => x!.Building)
|
||||
.Include(x => x.Invigilators)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.Include(x => x.SessionLinks)
|
||||
.ThenInclude(x => x.ExamSession)
|
||||
.ThenInclude(x => x!.TeachingTask)
|
||||
.Include(x => x.Seats)
|
||||
.ThenInclude(x => x.Student)
|
||||
.ThenInclude(x => x!.AdministrativeClass)
|
||||
.Where(x => x.ExamPlanId == id)
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.ThenBy(x => x.Classroom!.Building!.Name)
|
||||
.ThenBy(x => x.Classroom!.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
var legacySessions = await db.ExamSessions.AsNoTracking()
|
||||
.Where(x => x.ExamPlanId == id)
|
||||
.Where(x => !x.RoomLinks.Any())
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.Select(session => new
|
||||
{
|
||||
session.Id,
|
||||
session.TeachingTaskId,
|
||||
session.ExamDate,
|
||||
session.StartsAt,
|
||||
session.EndsAt,
|
||||
CourseCode = session.TeachingTask!.Course!.Code,
|
||||
CourseName = session.TeachingTask.Course.Name,
|
||||
session.TeachingTask.TaskNumber,
|
||||
BuildingName = session.Classroom != null
|
||||
? session.Classroom.Building!.Name
|
||||
: null,
|
||||
ClassroomName = session.Classroom != null
|
||||
? session.Classroom.Name
|
||||
: null,
|
||||
InvigilatorNames = session.Invigilators
|
||||
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||
.Select(item => item.Teacher!.Name)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
if (rooms.Count == 0 && legacySessions.Count == 0)
|
||||
return ConflictProblem("当前考试计划没有可导出的考试场次。");
|
||||
|
||||
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||
db,
|
||||
plan.Sessions.Select(x => x.TeachingTaskId),
|
||||
legacySessions.Select(x => x.TeachingTaskId),
|
||||
cancellationToken);
|
||||
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
|
||||
var data = new ExamSignInWorkbookData(
|
||||
plan.Name,
|
||||
plan.TermName,
|
||||
plan.Sessions.Select(session => new ExamSignInSessionData(
|
||||
var sheets = rooms.Select(room => new ExamSignInSessionData(
|
||||
room.Id,
|
||||
room.ExamDate,
|
||||
room.StartsAt,
|
||||
room.EndsAt,
|
||||
room.Course!.Code,
|
||||
room.Course.Name,
|
||||
string.Join(
|
||||
"、",
|
||||
room.SessionLinks
|
||||
.Select(link => link.ExamSession!.TeachingTask!.TaskNumber)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)),
|
||||
room.Classroom!.Building!.Name,
|
||||
room.Classroom.Name,
|
||||
room.Invigilators
|
||||
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||
.Select(item => item.Teacher!.Name)
|
||||
.ToList(),
|
||||
room.Seats
|
||||
.OrderBy(seat => seat.SeatNumber)
|
||||
.Select(seat => new ExamSignInStudentData(
|
||||
seat.StudentId,
|
||||
seat.Student!.StudentNumber,
|
||||
seat.Student.Name,
|
||||
seat.Student.AdministrativeClass!.Name,
|
||||
seat.SeatNumber))
|
||||
.ToList()))
|
||||
.Concat(legacySessions.Select(session => new ExamSignInSessionData(
|
||||
session.Id,
|
||||
session.ExamDate,
|
||||
session.StartsAt,
|
||||
@@ -695,13 +893,18 @@ public sealed class ExamsController(
|
||||
session.ClassroomName,
|
||||
session.InvigilatorNames.ToList(),
|
||||
studentsByTask[session.TeachingTaskId]
|
||||
.Select(student => new ExamSignInStudentData(
|
||||
.Select((student, index) => new ExamSignInStudentData(
|
||||
student.StudentId,
|
||||
student.StudentNumber,
|
||||
student.Name,
|
||||
student.ClassName))
|
||||
.ToList()))
|
||||
.ToList());
|
||||
student.ClassName,
|
||||
index + 1))
|
||||
.ToList())))
|
||||
.ToList();
|
||||
var data = new ExamSignInWorkbookData(
|
||||
plan.Name,
|
||||
plan.TermName,
|
||||
sheets);
|
||||
|
||||
var bytes = ExamSignInWorkbookExporter.Create(data);
|
||||
return File(
|
||||
@@ -724,11 +927,37 @@ public sealed class ExamsController(
|
||||
.Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
|
||||
var assignedSchedule = await db.ExamSeats.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == studentId.Value &&
|
||||
x.ExamRoom!.ExamPlan!.Status == ExamPlanStatus.Published)
|
||||
.Select(x => new
|
||||
{
|
||||
x.ExamSessionId,
|
||||
PlanName = x.ExamRoom!.ExamPlan!.Name,
|
||||
x.ExamRoom.ExamDate,
|
||||
x.ExamRoom.StartPeriod,
|
||||
x.ExamRoom.PeriodCount,
|
||||
x.ExamRoom.StartsAt,
|
||||
x.ExamRoom.EndsAt,
|
||||
x.ExamSession!.TeachingTask!.TaskNumber,
|
||||
CourseCode = x.ExamRoom.Course!.Code,
|
||||
CourseName = x.ExamRoom.Course.Name,
|
||||
ClassroomName = x.ExamRoom.Classroom!.Name,
|
||||
BuildingName = x.ExamRoom.Classroom.Building!.Name,
|
||||
InvigilatorNames = x.ExamRoom.Invigilators
|
||||
.Select(i => i.Teacher!.Name),
|
||||
x.SeatNumber
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent(
|
||||
db,
|
||||
studentId.Value);
|
||||
var schedule = await db.ExamSessions.AsNoTracking()
|
||||
var legacySchedule = await db.ExamSessions.AsNoTracking()
|
||||
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
||||
!x.RoomLinks.Any() &&
|
||||
rosterTaskIds.Contains(x.TeachingTaskId))
|
||||
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
|
||||
.Select(x => new
|
||||
@@ -750,9 +979,10 @@ public sealed class ExamsController(
|
||||
IsExam = true
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
if (schedule.Count == 0) return Ok(schedule);
|
||||
|
||||
var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray();
|
||||
var taskIds = legacySchedule
|
||||
.Select(x => x.TeachingTaskId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var seatRows = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||
db,
|
||||
taskIds,
|
||||
@@ -767,29 +997,77 @@ public sealed class ExamsController(
|
||||
}))
|
||||
.ToDictionary(x => (x.TeachingTaskId, x.StudentId), x => x.SeatNumber);
|
||||
|
||||
return Ok(schedule.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.PlanName,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.TaskNumber,
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
x.InvigilatorNames,
|
||||
SeatNumber = seatNumbers.GetValueOrDefault((x.TeachingTaskId, studentId.Value)),
|
||||
x.IsExam
|
||||
}));
|
||||
return Ok(assignedSchedule.Select(x => new ExamScheduleResponse(
|
||||
x.ExamSessionId,
|
||||
x.PlanName,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.TaskNumber,
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
x.InvigilatorNames.ToList(),
|
||||
x.SeatNumber.ToString("D3"),
|
||||
null,
|
||||
true))
|
||||
.Concat(legacySchedule.Select(x => new ExamScheduleResponse(
|
||||
x.Id,
|
||||
x.PlanName,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.TaskNumber,
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
x.InvigilatorNames.ToList(),
|
||||
seatNumbers.GetValueOrDefault(
|
||||
(x.TeachingTaskId, studentId.Value)),
|
||||
null,
|
||||
x.IsExam)))
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod));
|
||||
}
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
{
|
||||
var schedule = await db.ExamSessions.AsNoTracking()
|
||||
var roomSchedule = await db.ExamRooms.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
||||
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
PlanName = x.ExamPlan!.Name,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
TaskNumbers = x.SessionLinks
|
||||
.Select(link =>
|
||||
link.ExamSession!.TeachingTask!.TaskNumber),
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course.Name,
|
||||
ClassroomName = x.Classroom!.Name,
|
||||
BuildingName = x.Classroom.Building!.Name,
|
||||
InvigilatorNames = x.Invigilators
|
||||
.Select(i => i.Teacher!.Name),
|
||||
StudentCount = x.Seats.Count
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var legacySchedule = await db.ExamSessions.AsNoTracking()
|
||||
.Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published &&
|
||||
!x.RoomLinks.Any() &&
|
||||
x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId))
|
||||
.OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod)
|
||||
.Select(x => new
|
||||
@@ -813,28 +1091,46 @@ public sealed class ExamsController(
|
||||
.ToListAsync(cancellationToken);
|
||||
var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||
db,
|
||||
schedule.Select(x => x.TeachingTaskId),
|
||||
legacySchedule.Select(x => x.TeachingTaskId),
|
||||
cancellationToken))
|
||||
.GroupBy(x => x.TeachingTaskId)
|
||||
.ToDictionary(x => x.Key, x => x.Count());
|
||||
return Ok(schedule.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.PlanName,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.TaskNumber,
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
x.InvigilatorNames,
|
||||
StudentCount = rosterCounts.GetValueOrDefault(x.TeachingTaskId),
|
||||
x.IsExam
|
||||
}));
|
||||
return Ok(roomSchedule.Select(x => new ExamScheduleResponse(
|
||||
x.Id,
|
||||
x.PlanName,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
string.Join("、", x.TaskNumbers.OrderBy(value => value)),
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
x.InvigilatorNames.ToList(),
|
||||
null,
|
||||
x.StudentCount,
|
||||
true))
|
||||
.Concat(legacySchedule.Select(x => new ExamScheduleResponse(
|
||||
x.Id,
|
||||
x.PlanName,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.TaskNumber,
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
x.InvigilatorNames.ToList(),
|
||||
null,
|
||||
rosterCounts.GetValueOrDefault(x.TeachingTaskId),
|
||||
x.IsExam)))
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod));
|
||||
}
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
@@ -843,6 +1139,24 @@ public sealed class ExamsController(
|
||||
// Private helpers
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private async Task RemoveRoomsForSessionsAsync(
|
||||
IReadOnlyCollection<Guid> sessionIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ids = sessionIds.Distinct().ToArray();
|
||||
if (ids.Length == 0) return;
|
||||
var roomIds = await db.ExamRoomSessions.AsNoTracking()
|
||||
.WhereIn(ids, x => x.ExamSessionId)
|
||||
.Select(x => x.ExamRoomId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
if (roomIds.Count == 0) return;
|
||||
var rooms = await db.ExamRooms
|
||||
.WhereIn(roomIds, x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
db.ExamRooms.RemoveRange(rooms);
|
||||
}
|
||||
|
||||
private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime(
|
||||
Guid academicTermId,
|
||||
DateOnly examDate,
|
||||
@@ -926,6 +1240,16 @@ public sealed class ExamsController(
|
||||
if (await overlaps.AnyAsync(
|
||||
x => x.ClassroomId == classroomId.Value, cancellationToken))
|
||||
return ConflictProblem("该时段考场已被占用。");
|
||||
if (await db.ExamRooms.AsNoTracking().AnyAsync(
|
||||
room =>
|
||||
room.ClassroomId == classroomId.Value &&
|
||||
room.StartsAt < endsAt &&
|
||||
startsAt < room.EndsAt &&
|
||||
(!currentId.HasValue ||
|
||||
!room.SessionLinks.Any(link =>
|
||||
link.ExamSessionId == currentId.Value)),
|
||||
cancellationToken))
|
||||
return ConflictProblem("该时段考场已被混排考试占用。");
|
||||
}
|
||||
|
||||
if (teacherIds.Length > 0)
|
||||
@@ -935,6 +1259,16 @@ public sealed class ExamsController(
|
||||
.WhereIn(teacherIds, i => i.TeacherId)
|
||||
.AnyAsync(cancellationToken))
|
||||
return ConflictProblem("监考教师在该时段已有考试任务。");
|
||||
if (await db.ExamRoomInvigilators.AsNoTracking()
|
||||
.Where(i =>
|
||||
i.ExamRoom!.StartsAt < endsAt &&
|
||||
startsAt < i.ExamRoom.EndsAt &&
|
||||
(!currentId.HasValue ||
|
||||
!i.ExamRoom.SessionLinks.Any(link =>
|
||||
link.ExamSessionId == currentId.Value)))
|
||||
.WhereIn(teacherIds, i => i.TeacherId)
|
||||
.AnyAsync(cancellationToken))
|
||||
return ConflictProblem("监考教师在该时段已有混排考场任务。");
|
||||
}
|
||||
|
||||
var overlappingTaskIds = await overlaps
|
||||
@@ -1034,3 +1368,21 @@ public sealed record ExamAutoArrangeRequest(
|
||||
IReadOnlyCollection<Guid>? SessionIds = null,
|
||||
bool AssignClassrooms = true,
|
||||
bool AssignInvigilators = true);
|
||||
|
||||
public sealed record ExamScheduleResponse(
|
||||
Guid Id,
|
||||
string PlanName,
|
||||
DateOnly ExamDate,
|
||||
int StartPeriod,
|
||||
int PeriodCount,
|
||||
DateTime StartsAt,
|
||||
DateTime EndsAt,
|
||||
string TaskNumber,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
string? ClassroomName,
|
||||
string? BuildingName,
|
||||
IReadOnlyCollection<string> InvigilatorNames,
|
||||
string? SeatNumber,
|
||||
int? StudentCount,
|
||||
bool IsExam);
|
||||
|
||||
Reference in New Issue
Block a user