变更摘要:考试安排注入课表

问题根因

  TimetableDataService.LoadExamEntriesAsync 只查询旧的 ExamSession(legacy 直接分配教室),完全忽略自动编排生成的
  ExamRoomAssignment(混排考场),导致:

  ┌──────────┬──────────────────────────┬───────────────────────────────────┐
  │ 课表类型 │           之前           │               之后                │
  ├──────────┼──────────────────────────┼───────────────────────────────────┤
  │ 教室课表 │ return [] 硬编码空数组   │ 查询 legacy + mixed room 两类数据 │
  ├──────────┼──────────────────────────┼───────────────────────────────────┤
  │ 教师课表 │ 只显示监考               │ 监考 + 自己授课的考试             │
  ├──────────┼──────────────────────────┼───────────────────────────────────┤
  │ 学生课表 │ 只通过 CourseEnrollments │ 同时查 ExamSeats(座位分配)      │
  ├──────────┼──────────────────────────┼───────────────────────────────────┤
  │ 空闲教室 │ 只排除 legacy 考试占用   │ 同时排除混排考场占用              │
  └──────────┴──────────────────────────┴───────────────────────────────────┘

  修改的文件

  1. TimetableDataService.cs — 重写 LoadExamEntriesAsync

  - 拆分为两个数据源:legacy ExamSessions + mixed ExamRoomAssignment(通过 ExamRoomSession 关联)
  - 新增 ApplyLegacyResourceFilter / ApplyMixedResourceFilter
  两个过滤方法,按资源类型(Classroom/Teacher/Student/Class)分别过滤
  - 新增 MapToEntryDto 统一映射到 TimetableEntryDto
  - 新增 ExamSessionProjection record 统一两个数据源的投影

  各资源类型的数据来源:

  ┌───────────┬────────────────────────────────┬──────────────────────────────────────┐
  │ 资源类型  │          Legacy 过滤           │           Mixed-room 过滤            │
  ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
  │ Classroom │ ExamSessions.ClassroomId == id │ ExamRoom.ClassroomId == id           │
  ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
  │ Teacher   │ 监考 + 授课教师                │ 监考 + 授课教师                      │
  ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
  │ Student   │ CourseEnrollments + 行政班     │ ExamSeats.StudentId                  │
  ├───────────┼────────────────────────────────┼──────────────────────────────────────┤
  │ Class     │ TeachingTask.Classes           │ SessionLinks → ExamSession → Classes │
  └───────────┴────────────────────────────────┴──────────────────────────────────────┘

  2. ClassroomReservationAvailabilityService.cs — 补充混排考场占用

  在 GetOccupiedClassroomIdsAsync 新增 ExamRoomAssignment 查询,空闲教室查询现在会正确排除混排考场占用的教室。

  3. TimetablesController.cs — 课表选项含考试学期

  - 学期级的 HasPublishedTimetable 增加 ExamPlans.Status == Published 判断
  - 班级级的 HasPublishedTimetable 增加 ExamSessions 关联判断
This commit is contained in:
2026-07-28 08:27:22 +08:00 Unverified
parent 3ec0f117d3
commit 7e14b22296
3 changed files with 208 additions and 69 deletions
@@ -367,7 +367,10 @@ public sealed class TimetablesController(
db.TeachingTasks.Any(task => db.TeachingTasks.Any(task =>
task.AcademicTermId == x.Id && task.AcademicTermId == x.Id &&
task.Status == TeachingTaskStatus.Published && task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible))) task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) ||
db.ExamPlans.Any(plan =>
plan.AcademicTermId == x.Id &&
plan.Status == ExamPlanStatus.Published)))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id; ?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
@@ -394,7 +397,12 @@ public sealed class TimetablesController(
task.AcademicTermId == defaultTermId.Value && task.AcademicTermId == defaultTermId.Value &&
task.Status == TeachingTaskStatus.Published && task.Status == TeachingTaskStatus.Published &&
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible && task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
task.Classes.Any(item => item.AdministrativeClassId == x.Id))))) task.Classes.Any(item => item.AdministrativeClassId == x.Id)) ||
db.ExamSessions.Any(session =>
session.ExamPlan!.AcademicTermId == defaultTermId.Value &&
session.ExamPlan.Status == ExamPlanStatus.Published &&
session.TeachingTask!.Classes.Any(item =>
item.AdministrativeClassId == x.Id)))))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var colleges = await db.Colleges.AsNoTracking() var colleges = await db.Colleges.AsNoTracking()
.Where(x => x.IsEnabled) .Where(x => x.IsEnabled)
@@ -58,6 +58,17 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
occupiedIds.UnionWith(examRoomIds); occupiedIds.UnionWith(examRoomIds);
var mixedExamRoomIds = await db.ExamRooms.AsNoTracking()
.Where(room =>
room.ExamPlan!.AcademicTermId == term.Id &&
room.ExamPlan.Status == ExamPlanStatus.Published &&
room.ExamDate == reservationDate &&
room.StartPeriod < startPeriod + periodCount &&
startPeriod < room.StartPeriod + room.PeriodCount)
.Select(room => room.ClassroomId)
.ToListAsync(cancellationToken);
occupiedIds.UnionWith(mixedExamRoomIds);
var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking() var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking()
.Where(session => .Where(session =>
session.ClassroomId.HasValue && session.ClassroomId.HasValue &&
@@ -307,94 +307,214 @@ public sealed class TimetableDataService(AppDbContext db)
IReadOnlyList<TimetableSlotDto> slots, IReadOnlyList<TimetableSlotDto> slots,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (resourceType == TimetableResourceType.Classroom) return [];
var slotLookup = slots.ToDictionary(x => x.PeriodNumber); var slotLookup = slots.ToDictionary(x => x.PeriodNumber);
var result = new List<TimetableEntryDto>();
IQueryable<ExamSession> source = db.ExamSessions.AsNoTracking() // ── Legacy sessions (ExamSessions with direct ClassroomId) ──
var legacyQuery = db.ExamSessions.AsNoTracking()
.Where(x => x.ExamPlan!.AcademicTermId == academicTermId && .Where(x => x.ExamPlan!.AcademicTermId == academicTermId &&
x.ExamPlan.Status == ExamPlanStatus.Published && x.ExamPlan.Status == ExamPlanStatus.Published &&
x.ClassroomId != null); x.ClassroomId != null);
if (resourceType == TimetableResourceType.Teacher) legacyQuery = ApplyLegacyResourceFilter(
{ legacyQuery, resourceType, resourceId, studentId);
source = source.Where(x =>
x.Invigilators.Any(i => i.TeacherId == resourceId));
}
else if (studentId.HasValue)
{
source = source.Where(x =>
db.CourseEnrollments.Any(e =>
e.StudentId == studentId.Value &&
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId));
}
else
{
// Class timetable: exams for the class's teaching tasks
source = source.Where(x =>
x.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId));
}
var sessions = await source var legacySessions = await legacyQuery
.OrderBy(x => x.ExamDate) .OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod) .ThenBy(x => x.StartPeriod)
.Select(x => new .Select(x => new ExamSessionProjection(
{
x.Id, x.Id,
x.TeachingTaskId, x.TeachingTaskId,
x.TeachingTask!.TaskNumber, x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name, x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code, x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name, x.TeachingTask.Course.Name,
TeacherNames = x.TeachingTask.Teachers x.TeachingTask.Teachers
.OrderByDescending(t => t.IsPrimary) .OrderByDescending(t => t.IsPrimary)
.Select(t => t.Teacher!.Name), .Select(t => t.Teacher!.Name).ToList(),
ClassNames = x.TeachingTask.Classes x.TeachingTask.Classes
.Select(c => c.AdministrativeClass!.Name), .Select(c => c.AdministrativeClass!.Name).ToList(),
ClassroomName = x.Classroom!.Name, x.Classroom!.Name,
BuildingName = x.Classroom.Building!.Name, x.Classroom.Building!.Name,
CampusName = x.Classroom.Building.Campus!.Name, x.Classroom.Building.Campus!.Name,
x.ExamDate, x.ExamDate,
x.StartPeriod, x.StartPeriod,
x.PeriodCount, x.PeriodCount,
x.ExamPlan!.Name, x.ExamPlan!.Name,
InvigilatorNames = x.Invigilators x.Invigilators.Select(i => i.Teacher!.Name).ToList(),
.Select(i => i.Teacher!.Name),
x.Notes, x.Notes,
x.UpdatedAt x.UpdatedAt))
})
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return sessions.Select(x => result.AddRange(legacySessions.Select(x =>
{ MapToEntryDto(x, slotLookup)));
var dayOfWeek = x.ExamDate.DayOfWeek == 0 ? 7 : (int)x.ExamDate.DayOfWeek;
return new TimetableEntryDto( // ── Mixed-room sessions (ExamRoomAssignment) ──
x.Id, var mixedQuery = db.ExamRoomSessions.AsNoTracking()
x.TeachingTaskId, .Where(link =>
x.TaskNumber, link.ExamRoom!.ExamPlan!.AcademicTermId == academicTermId &&
x.TaskName, link.ExamRoom.ExamPlan.Status == ExamPlanStatus.Published);
x.CourseCode,
x.CourseName, mixedQuery = ApplyMixedResourceFilter(
x.TeacherNames.Concat( mixedQuery, resourceType, resourceId, studentId);
new[] { "监考:" + string.Join("、", x.InvigilatorNames) }),
x.ClassNames, var mixedEntries = await mixedQuery
x.ClassroomName, .OrderBy(link => link.ExamRoom!.ExamDate)
x.BuildingName, .ThenBy(link => link.ExamRoom!.StartPeriod)
x.CampusName, .ThenBy(link => link.ExamSession!.TeachingTask!.Course!.Code)
dayOfWeek, .Select(link => new ExamSessionProjection(
x.StartPeriod, link.ExamRoomId,
x.PeriodCount, link.ExamSession!.TeachingTaskId,
1, 1, link.ExamSession.TeachingTask!.TaskNumber,
WeekPattern.All, link.ExamSession.TeachingTask.Name,
x.Notes, link.ExamSession.TeachingTask.Course!.Code,
true, link.ExamSession.TeachingTask.Course.Name,
x.Name, link.ExamSession.TeachingTask.Teachers
x.ExamDate, .OrderByDescending(t => t.IsPrimary)
x.UpdatedAt); .Select(t => t.Teacher!.Name).ToList(),
}).ToList(); link.ExamSession.TeachingTask.Classes
.Select(c => c.AdministrativeClass!.Name).ToList(),
link.ExamRoom!.Classroom!.Name,
link.ExamRoom.Classroom.Building!.Name,
link.ExamRoom.Classroom.Building.Campus!.Name,
link.ExamRoom.ExamDate,
link.ExamRoom.StartPeriod,
link.ExamRoom.PeriodCount,
link.ExamRoom.ExamPlan!.Name,
link.ExamRoom.Invigilators
.Select(i => i.Teacher!.Name).ToList(),
link.ExamRoom.ExamPlan.Notes,
link.ExamRoom.UpdatedAt))
.ToListAsync(cancellationToken);
result.AddRange(mixedEntries.Select(x =>
MapToEntryDto(x, slotLookup)));
return result;
} }
private IQueryable<ExamSession> ApplyLegacyResourceFilter(
IQueryable<ExamSession> source,
TimetableResourceType resourceType,
Guid resourceId,
Guid? studentId)
{
switch (resourceType)
{
case TimetableResourceType.Classroom:
return source.Where(x => x.ClassroomId == resourceId);
case TimetableResourceType.Teacher:
return source.Where(x =>
x.Invigilators.Any(i => i.TeacherId == resourceId) ||
x.TeachingTask!.Teachers.Any(t => t.TeacherId == resourceId));
case TimetableResourceType.Class:
if (studentId.HasValue)
{
return source.Where(x =>
x.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId) ||
db.CourseEnrollments.Any(e =>
e.StudentId == studentId.Value &&
e.Status == CourseEnrollmentStatus.Enrolled &&
e.CourseSelectionOffering!.TeachingTaskId ==
x.TeachingTaskId));
}
return source.Where(x =>
x.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId));
default:
return source;
}
}
private static IQueryable<ExamRoomSession> ApplyMixedResourceFilter(
IQueryable<ExamRoomSession> source,
TimetableResourceType resourceType,
Guid resourceId,
Guid? studentId)
{
switch (resourceType)
{
case TimetableResourceType.Classroom:
return source.Where(link =>
link.ExamRoom!.ClassroomId == resourceId);
case TimetableResourceType.Teacher:
return source.Where(link =>
link.ExamRoom!.Invigilators.Any(i =>
i.TeacherId == resourceId) ||
link.ExamSession!.TeachingTask!.Teachers.Any(t =>
t.TeacherId == resourceId));
case TimetableResourceType.Class:
if (studentId.HasValue)
{
return source.Where(link =>
link.ExamSession!.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId) ||
link.ExamRoom!.Seats.Any(s =>
s.StudentId == studentId.Value));
}
return source.Where(link =>
link.ExamSession!.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId));
default:
return source;
}
}
private static TimetableEntryDto MapToEntryDto(
ExamSessionProjection x,
IReadOnlyDictionary<int, TimetableSlotDto> slotLookup)
{
var dayOfWeek = x.ExamDate.DayOfWeek == 0
? 7
: (int)x.ExamDate.DayOfWeek;
var teacherNames = x.InvigilatorNames.Count > 0
? x.TeacherNames.Concat(
new[] { "监考:" + string.Join("、", x.InvigilatorNames) })
: x.TeacherNames;
return new TimetableEntryDto(
x.Id,
x.TeachingTaskId,
x.TaskNumber,
x.TaskName,
x.CourseCode,
x.CourseName,
teacherNames,
x.ClassNames,
x.ClassroomName,
x.BuildingName,
x.CampusName,
dayOfWeek,
x.StartPeriod,
x.PeriodCount,
1, 1,
WeekPattern.All,
x.Notes,
true,
x.PlanName,
x.ExamDate,
x.UpdatedAt);
}
private sealed record ExamSessionProjection(
Guid Id,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
string CourseCode,
string CourseName,
List<string> TeacherNames,
List<string> ClassNames,
string ClassroomName,
string BuildingName,
string CampusName,
DateOnly ExamDate,
int StartPeriod,
int PeriodCount,
string PlanName,
List<string> InvigilatorNames,
string? Notes,
DateTime UpdatedAt);
} }
public enum TimetableResourceType public enum TimetableResourceType