变更摘要:考试安排注入课表
问题根因 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:
@@ -367,7 +367,10 @@ public sealed class TimetablesController(
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == x.Id &&
|
||||
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);
|
||||
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
||||
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||
@@ -394,7 +397,12 @@ public sealed class TimetablesController(
|
||||
task.AcademicTermId == defaultTermId.Value &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
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);
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
|
||||
@@ -58,6 +58,17 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
|
||||
.ToListAsync(cancellationToken);
|
||||
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()
|
||||
.Where(session =>
|
||||
session.ClassroomId.HasValue &&
|
||||
|
||||
@@ -307,94 +307,214 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
IReadOnlyList<TimetableSlotDto> slots,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (resourceType == TimetableResourceType.Classroom) return [];
|
||||
|
||||
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 &&
|
||||
x.ExamPlan.Status == ExamPlanStatus.Published &&
|
||||
x.ClassroomId != null);
|
||||
|
||||
if (resourceType == TimetableResourceType.Teacher)
|
||||
{
|
||||
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));
|
||||
}
|
||||
legacyQuery = ApplyLegacyResourceFilter(
|
||||
legacyQuery, resourceType, resourceId, studentId);
|
||||
|
||||
var sessions = await source
|
||||
var legacySessions = await legacyQuery
|
||||
.OrderBy(x => x.ExamDate)
|
||||
.ThenBy(x => x.StartPeriod)
|
||||
.Select(x => new
|
||||
{
|
||||
.Select(x => new ExamSessionProjection(
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
TaskName = x.TeachingTask.Name,
|
||||
CourseCode = x.TeachingTask.Course!.Code,
|
||||
CourseName = x.TeachingTask.Course.Name,
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
x.TeachingTask.Name,
|
||||
x.TeachingTask.Course!.Code,
|
||||
x.TeachingTask.Course.Name,
|
||||
x.TeachingTask.Teachers
|
||||
.OrderByDescending(t => t.IsPrimary)
|
||||
.Select(t => t.Teacher!.Name),
|
||||
ClassNames = x.TeachingTask.Classes
|
||||
.Select(c => c.AdministrativeClass!.Name),
|
||||
ClassroomName = x.Classroom!.Name,
|
||||
BuildingName = x.Classroom.Building!.Name,
|
||||
CampusName = x.Classroom.Building.Campus!.Name,
|
||||
.Select(t => t.Teacher!.Name).ToList(),
|
||||
x.TeachingTask.Classes
|
||||
.Select(c => c.AdministrativeClass!.Name).ToList(),
|
||||
x.Classroom!.Name,
|
||||
x.Classroom.Building!.Name,
|
||||
x.Classroom.Building.Campus!.Name,
|
||||
x.ExamDate,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
x.ExamPlan!.Name,
|
||||
InvigilatorNames = x.Invigilators
|
||||
.Select(i => i.Teacher!.Name),
|
||||
x.Invigilators.Select(i => i.Teacher!.Name).ToList(),
|
||||
x.Notes,
|
||||
x.UpdatedAt
|
||||
})
|
||||
x.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return sessions.Select(x =>
|
||||
{
|
||||
var dayOfWeek = x.ExamDate.DayOfWeek == 0 ? 7 : (int)x.ExamDate.DayOfWeek;
|
||||
return new TimetableEntryDto(
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.TaskNumber,
|
||||
x.TaskName,
|
||||
x.CourseCode,
|
||||
x.CourseName,
|
||||
x.TeacherNames.Concat(
|
||||
new[] { "监考:" + string.Join("、", x.InvigilatorNames) }),
|
||||
x.ClassNames,
|
||||
x.ClassroomName,
|
||||
x.BuildingName,
|
||||
x.CampusName,
|
||||
dayOfWeek,
|
||||
x.StartPeriod,
|
||||
x.PeriodCount,
|
||||
1, 1,
|
||||
WeekPattern.All,
|
||||
x.Notes,
|
||||
true,
|
||||
x.Name,
|
||||
x.ExamDate,
|
||||
x.UpdatedAt);
|
||||
}).ToList();
|
||||
result.AddRange(legacySessions.Select(x =>
|
||||
MapToEntryDto(x, slotLookup)));
|
||||
|
||||
// ── Mixed-room sessions (ExamRoomAssignment) ──
|
||||
var mixedQuery = db.ExamRoomSessions.AsNoTracking()
|
||||
.Where(link =>
|
||||
link.ExamRoom!.ExamPlan!.AcademicTermId == academicTermId &&
|
||||
link.ExamRoom.ExamPlan.Status == ExamPlanStatus.Published);
|
||||
|
||||
mixedQuery = ApplyMixedResourceFilter(
|
||||
mixedQuery, resourceType, resourceId, studentId);
|
||||
|
||||
var mixedEntries = await mixedQuery
|
||||
.OrderBy(link => link.ExamRoom!.ExamDate)
|
||||
.ThenBy(link => link.ExamRoom!.StartPeriod)
|
||||
.ThenBy(link => link.ExamSession!.TeachingTask!.Course!.Code)
|
||||
.Select(link => new ExamSessionProjection(
|
||||
link.ExamRoomId,
|
||||
link.ExamSession!.TeachingTaskId,
|
||||
link.ExamSession.TeachingTask!.TaskNumber,
|
||||
link.ExamSession.TeachingTask.Name,
|
||||
link.ExamSession.TeachingTask.Course!.Code,
|
||||
link.ExamSession.TeachingTask.Course.Name,
|
||||
link.ExamSession.TeachingTask.Teachers
|
||||
.OrderByDescending(t => t.IsPrimary)
|
||||
.Select(t => t.Teacher!.Name).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
|
||||
|
||||
Reference in New Issue
Block a user