From a369b4fe0218d012726ea03748dedb298fefd7e1 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 31 Jul 2026 07:56:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=BE=E8=A1=A8=E6=9F=A5=E8=AF=A2=E4=B8=AD?= =?UTF-8?q?=E6=9C=80=E6=98=8E=E6=98=BE=E7=9A=84=E6=80=A7=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E3=80=82=20=E5=B0=86=E6=B7=B7=E5=90=88=E8=80=83?= =?UTF-8?q?=E5=9C=BA=E6=9F=A5=E8=AF=A2=E4=BB=8E=E2=80=9C=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=95=B4=E4=B8=AA=E5=AD=A6=E6=9C=9F=E7=9A=84=E8=80=83=E5=9C=BA?= =?UTF-8?q?=E3=80=81=E5=BA=A7=E4=BD=8D=E3=80=81=E6=95=99=E5=B8=88=E3=80=81?= =?UTF-8?q?=E7=8F=AD=E7=BA=A7=E5=90=8E=E5=9C=A8=E5=86=85=E5=AD=98=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E2=80=9D=EF=BC=8C=E6=94=B9=E4=B8=BA=E5=9C=A8=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E4=B8=AD=E6=8C=89=E7=8F=AD=E7=BA=A7=E3=80=81?= =?UTF-8?q?=E6=95=99=E5=B8=88=E6=88=96=E6=95=99=E5=AE=A4=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E3=80=82=20=E6=94=B9=E4=B8=BA=E7=AA=84?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E6=8A=95=E5=BD=B1=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=A4=A7=E9=87=8F=20EF=20=E5=AE=9E=E4=BD=93?= =?UTF-8?q?=E5=92=8C=E5=85=B3=E7=B3=BB=E5=AF=B9=E8=B1=A1=E3=80=82=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=9F=A5=E8=AF=A2=E6=A0=87=E7=AD=BE=EF=BC=8C?= =?UTF-8?q?=E4=BE=BF=E4=BA=8E=E5=90=8E=E7=BB=AD=E5=9C=A8=20MySQL=20?= =?UTF-8?q?=E6=85=A2=E6=9F=A5=E8=AF=A2=E5=92=8C=E9=93=BE=E8=B7=AF=E8=BF=BD?= =?UTF-8?q?=E8=B8=AA=E4=B8=AD=E5=AE=9A=E4=BD=8D=E3=80=82=20=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E7=8E=B0=E6=9C=89=20HybridCache/=E5=8F=AF=E9=80=89=20?= =?UTF-8?q?Redis=20=E7=BC=93=E5=AD=98=E4=BD=93=E7=B3=BB=EF=BC=8C=E6=97=A0?= =?UTF-8?q?=E9=9C=80=E6=96=B0=E5=A2=9E=E4=BE=9D=E8=B5=96=E6=88=96=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E8=BF=81=E7=A7=BB=E3=80=82=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=95=99=E5=AE=A4=E3=80=81=E6=97=A0=E5=85=B3=E6=95=99?= =?UTF-8?q?=E5=AE=A4=E3=80=81=E7=9B=91=E8=80=83=E6=95=99=E5=B8=88=E4=B8=89?= =?UTF-8?q?=E7=A7=8D=E5=9B=9E=E5=BD=92=E5=9C=BA=E6=99=AF=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timetables/TimetableDataService.cs | 136 ++++++++---------- .../ExamArrangementServiceTests.cs | 43 ++++++ 2 files changed, 103 insertions(+), 76 deletions(-) diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs index 1f98267..7ff5312 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs @@ -356,72 +356,49 @@ public sealed class TimetableDataService(AppDbContext db) MapToEntryDto(x, slotLookup))); // ── Mixed-room sessions (ExamRoomAssignment) ── - // Load all mixed rooms for this term into memory, then filter - var allMixedRooms = await db.ExamRooms.AsNoTracking() - .Where(room => - room.ExamPlan!.AcademicTermId == academicTermId && - room.ExamPlan.Status == ExamPlanStatus.Published) - .Include(room => room.ExamPlan) - .Include(room => room.Classroom) - .ThenInclude(c => c!.Building) - .ThenInclude(b => b!.Campus) - .Include(room => room.Invigilators) - .ThenInclude(i => i.Teacher) - .Include(room => room.SessionLinks) - .ThenInclude(link => link.ExamSession) - .ThenInclude(s => s!.TeachingTask) - .ThenInclude(t => t!.Course) - .Include(room => room.SessionLinks) - .ThenInclude(link => link.ExamSession) - .ThenInclude(s => s!.TeachingTask) - .ThenInclude(t => t!.Teachers) - .ThenInclude(tt => tt.Teacher) - .Include(room => room.SessionLinks) - .ThenInclude(link => link.ExamSession) - .ThenInclude(s => s!.TeachingTask) - .ThenInclude(t => t!.Classes) - .ThenInclude(tc => tc.AdministrativeClass) - .Include(room => room.Seats) + // Start from room/session links so the resource predicate stays in SQL. + // Loading every room, seat and roster for the term made a single + // timetable request scale with the entire exam plan. + 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 mixedSessions = await mixedQuery + .TagWith("Timetable.LoadMixedExamEntries") .AsSplitQuery() + .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(), + null, + link.ExamRoom.UpdatedAt)) .ToListAsync(cancellationToken); - foreach (var room in allMixedRooms) - { - foreach (var link in room.SessionLinks) - { - var session = link.ExamSession; - if (session == null || session.TeachingTask == null) continue; - - // Apply resource filter in memory - if (!MatchesMixedResource( - room, session, resourceType, resourceId, studentId)) - continue; - - result.Add(MapToEntryDto(new ExamSessionProjection( - room.Id, - session.TeachingTaskId, - session.TeachingTask.TaskNumber, - session.TeachingTask.Name, - session.TeachingTask.Course!.Code, - session.TeachingTask.Course.Name, - session.TeachingTask.Teachers - .OrderByDescending(t => t.IsPrimary) - .Select(t => t.Teacher!.Name).ToList(), - session.TeachingTask.Classes - .Select(c => c.AdministrativeClass!.Name).ToList(), - room.Classroom!.Name, - room.Classroom.Building!.Name, - room.Classroom.Building.Campus!.Name, - room.ExamDate, - room.StartPeriod, - room.PeriodCount, - room.ExamPlan!.Name, - room.Invigilators - .Select(i => i.Teacher!.Name).ToList(), - null, - room.UpdatedAt), slotLookup)); - } - } + result.AddRange(mixedSessions.Select(x => + MapToEntryDto(x, slotLookup))); return result; } @@ -535,9 +512,8 @@ public sealed class TimetableDataService(AppDbContext db) return date.AddDays(1 - dayOfWeek); } - private static bool MatchesMixedResource( - ExamRoomAssignment room, - ExamSession session, + private static IQueryable ApplyMixedResourceFilter( + IQueryable source, TimetableResourceType resourceType, Guid resourceId, Guid? studentId) @@ -545,20 +521,28 @@ public sealed class TimetableDataService(AppDbContext db) switch (resourceType) { case TimetableResourceType.Classroom: - return room.ClassroomId == resourceId; + return source.Where(link => + link.ExamRoom!.ClassroomId == resourceId); case TimetableResourceType.Teacher: - return room.Invigilators.Any(i => i.TeacherId == resourceId) || - session.TeachingTask!.Teachers.Any( - t => t.TeacherId == resourceId); + 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 session.TeachingTask!.Classes.Any(c => - c.AdministrativeClassId == resourceId) || - room.Seats.Any(s => s.StudentId == studentId.Value); - return session.TeachingTask!.Classes.Any(c => - c.AdministrativeClassId == resourceId); + { + 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 true; + return source; } } diff --git a/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs b/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs index 33c5df0..27f4544 100644 --- a/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs +++ b/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs @@ -1,6 +1,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -55,6 +56,48 @@ public sealed class ExamArrangementServiceTests Assert.Contains("1个教学班场次处理完成", result.Message); Assert.Contains("1名监考教师", result.Message); Assert.Contains("程序设计", result.Message); + + plan = await db.ExamPlans.SingleAsync(x => x.Id == plan.Id); + plan.Status = ExamPlanStatus.Published; + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + + var timetableService = new TimetableDataService(db); + var classroomTimetable = await timetableService.BuildAsync( + TimetableResourceType.Classroom, + seed.LargeClassroom.Id, + seed.Term.Id, + null, + false, + null, + null, + CancellationToken.None); + var unrelatedClassroomTimetable = await timetableService.BuildAsync( + TimetableResourceType.Classroom, + seed.SmallClassroom.Id, + seed.Term.Id, + null, + false, + null, + null, + CancellationToken.None); + var invigilatorTimetable = await timetableService.BuildAsync( + TimetableResourceType.Teacher, + room.Invigilators.Single().TeacherId, + seed.Term.Id, + null, + false, + null, + null, + CancellationToken.None); + + Assert.Equal( + "程序设计", + Assert.Single(classroomTimetable!.ExamEntries).CourseName); + Assert.Empty(unrelatedClassroomTimetable!.ExamEntries); + Assert.Contains( + "监考:监考教师", + Assert.Single(invigilatorTimetable!.ExamEntries).TeacherNames); } [Fact]