diff --git a/src/Jiaowu.Api/Controllers/TimetablesController.cs b/src/Jiaowu.Api/Controllers/TimetablesController.cs index e62e457..cad8ef4 100644 --- a/src/Jiaowu.Api/Controllers/TimetablesController.cs +++ b/src/Jiaowu.Api/Controllers/TimetablesController.cs @@ -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) diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/ClassroomReservationAvailabilityService.cs b/src/Jiaowu.Api/Infrastructure/Timetables/ClassroomReservationAvailabilityService.cs index d30b012..8ac097f 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/ClassroomReservationAvailabilityService.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/ClassroomReservationAvailabilityService.cs @@ -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 && diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs index ee50cdb..9d96012 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs @@ -307,94 +307,214 @@ public sealed class TimetableDataService(AppDbContext db) IReadOnlyList slots, CancellationToken cancellationToken) { - if (resourceType == TimetableResourceType.Classroom) return []; - var slotLookup = slots.ToDictionary(x => x.PeriodNumber); + var result = new List(); - IQueryable 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 ApplyLegacyResourceFilter( + IQueryable 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 ApplyMixedResourceFilter( + IQueryable 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 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 TeacherNames, + List ClassNames, + string ClassroomName, + string BuildingName, + string CampusName, + DateOnly ExamDate, + int StartPeriod, + int PeriodCount, + string PlanName, + List InvigilatorNames, + string? Notes, + DateTime UpdatedAt); } public enum TimetableResourceType