变更摘要:考试安排注入课表
问题根因 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 =>
|
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,69 +307,171 @@ 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)));
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
{
|
{
|
||||||
var dayOfWeek = x.ExamDate.DayOfWeek == 0 ? 7 : (int)x.ExamDate.DayOfWeek;
|
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(
|
return new TimetableEntryDto(
|
||||||
x.Id,
|
x.Id,
|
||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
@@ -377,8 +479,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
x.TaskName,
|
x.TaskName,
|
||||||
x.CourseCode,
|
x.CourseCode,
|
||||||
x.CourseName,
|
x.CourseName,
|
||||||
x.TeacherNames.Concat(
|
teacherNames,
|
||||||
new[] { "监考:" + string.Join("、", x.InvigilatorNames) }),
|
|
||||||
x.ClassNames,
|
x.ClassNames,
|
||||||
x.ClassroomName,
|
x.ClassroomName,
|
||||||
x.BuildingName,
|
x.BuildingName,
|
||||||
@@ -390,11 +491,30 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
WeekPattern.All,
|
WeekPattern.All,
|
||||||
x.Notes,
|
x.Notes,
|
||||||
true,
|
true,
|
||||||
x.Name,
|
x.PlanName,
|
||||||
x.ExamDate,
|
x.ExamDate,
|
||||||
x.UpdatedAt);
|
x.UpdatedAt);
|
||||||
}).ToList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user