From 7e14b22296134872693d7daf28a621f504383a59 Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 28 Jul 2026 08:27:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=98=E6=9B=B4=E6=91=98=E8=A6=81=EF=BC=9A?= =?UTF-8?q?=E8=80=83=E8=AF=95=E5=AE=89=E6=8E=92=E6=B3=A8=E5=85=A5=E8=AF=BE?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题根因 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 关联判断 --- .../Controllers/TimetablesController.cs | 12 +- ...ClassroomReservationAvailabilityService.cs | 11 + .../Timetables/TimetableDataService.cs | 254 +++++++++++++----- 3 files changed, 208 insertions(+), 69 deletions(-) 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