diff --git a/src/Jiaowu.Api/Controllers/ExperimentsController.cs b/src/Jiaowu.Api/Controllers/ExperimentsController.cs index bc3a19a..6656bfe 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentsController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentsController.cs @@ -3,6 +3,7 @@ using System.Data; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Experiments; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; using Jiaowu.Api.Infrastructure.Timetables; @@ -833,38 +834,38 @@ public sealed class ExperimentsController( term, date); - var scheduleConflicts = await db.ScheduleEntries.AsNoTracking() - .Where(x => - x.SchedulePlan!.Status == SchedulePlanStatus.Published && - x.SchedulePlan.AcademicTermId == term.Id && - x.DayOfWeek == dayOfWeek && - x.StartWeek <= week && - x.EndWeek >= week && - x.StartPeriod < startPeriod + periodCount && - startPeriod < x.StartPeriod + x.PeriodCount && - (x.TeachingTask!.Teachers.Any(item => - teacherIds.Contains(item.TeacherId)) || - x.TeachingTask.Classes.Any(item => - classIds.Contains(item.AdministrativeClassId)))) + var relatedTaskIds = (await CentralizedExperimentConflictQuery + .TaskIdsForTeachers(db, teacherIds) + .ToListAsync(cancellationToken)) + .Concat(await CentralizedExperimentConflictQuery + .TaskIdsForClasses(db, classIds) + .ToListAsync(cancellationToken)) + .Distinct() + .ToArray(); + + var scheduleConflicts = await CentralizedExperimentConflictQuery + .ScheduleEntries( + db, + relatedTaskIds, + term.Id, + dayOfWeek, + week, + startPeriod, + periodCount) .Select(x => new { x.TeachingTaskId, x.WeekPattern }) .ToListAsync(cancellationToken); if (scheduleConflicts.Any(x => FreeClassroomRules.MatchesWeek(x.WeekPattern, week))) return "集中实验与相关教师或行政班的已发布课表冲突。"; - var experimentConflict = await db.ExperimentSessions.AsNoTracking() - .AnyAsync(x => - x.Status == ExperimentSessionStatus.Scheduled && - x.SessionDate == date && - x.StartPeriod < startPeriod + periodCount && - startPeriod < x.StartPeriod + x.PeriodCount && - x.ExperimentProject!.ArrangementMode == - ExperimentArrangementMode.Centralized && - (x.ExperimentProject.TeachingTask!.Teachers.Any(item => - teacherIds.Contains(item.TeacherId)) || - x.ExperimentProject.TeachingTask.Classes.Any(item => - classIds.Contains(item.AdministrativeClassId))), - cancellationToken); + var experimentConflict = await CentralizedExperimentConflictQuery + .ExperimentSessions( + db, + relatedTaskIds, + date, + startPeriod, + periodCount) + .AnyAsync(cancellationToken); return experimentConflict ? "集中实验与相关教师或行政班的其他实验安排冲突。" : null; diff --git a/src/Jiaowu.Api/Infrastructure/Experiments/CentralizedExperimentConflictQuery.cs b/src/Jiaowu.Api/Infrastructure/Experiments/CentralizedExperimentConflictQuery.cs new file mode 100644 index 0000000..acb8256 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Experiments/CentralizedExperimentConflictQuery.cs @@ -0,0 +1,59 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.Experiments; + +internal static class CentralizedExperimentConflictQuery +{ + public static IQueryable TaskIdsForTeachers( + AppDbContext db, + IReadOnlyCollection teacherIds) => + db.TeachingTaskTeachers.AsNoTracking() + .WhereIn(teacherIds, x => x.TeacherId) + .Select(x => x.TeachingTaskId); + + public static IQueryable TaskIdsForClasses( + AppDbContext db, + IReadOnlyCollection classIds) => + db.TeachingTaskClasses.AsNoTracking() + .WhereIn(classIds, x => x.AdministrativeClassId) + .Select(x => x.TeachingTaskId); + + public static IQueryable ScheduleEntries( + AppDbContext db, + IReadOnlyCollection relatedTaskIds, + Guid academicTermId, + int dayOfWeek, + int week, + int startPeriod, + int periodCount) => + db.ScheduleEntries.AsNoTracking() + .Where(x => + x.SchedulePlan!.Status == SchedulePlanStatus.Published && + x.SchedulePlan.AcademicTermId == academicTermId && + x.DayOfWeek == dayOfWeek && + x.StartWeek <= week && + x.EndWeek >= week && + x.StartPeriod < startPeriod + periodCount && + startPeriod < x.StartPeriod + x.PeriodCount) + .WhereIn(relatedTaskIds, x => x.TeachingTaskId); + + public static IQueryable ExperimentSessions( + AppDbContext db, + IReadOnlyCollection relatedTaskIds, + DateOnly date, + int startPeriod, + int periodCount) => + db.ExperimentSessions.AsNoTracking() + .Where(x => + x.Status == ExperimentSessionStatus.Scheduled && + x.SessionDate == date && + x.StartPeriod < startPeriod + periodCount && + startPeriod < x.StartPeriod + x.PeriodCount && + x.ExperimentProject!.ArrangementMode == + ExperimentArrangementMode.Centralized) + .WhereIn( + relatedTaskIds, + x => x.ExperimentProject!.TeachingTaskId); +} diff --git a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs index d05a248..ae68cdb 100644 --- a/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs @@ -138,6 +138,47 @@ public sealed class ExperimentsControllerTests Assert.Empty(fixture.Db.ExperimentBookings); } + [Fact] + public async Task CentralizedSession_RejectsRelatedPublishedTimetableConflict() + { + await using var fixture = await ExperimentFixture.CreateAsync(); + var manager = fixture.Controller(fixture.ManagerScope); + await manager.CreateProject( + fixture.ProjectRequest(ExperimentArrangementMode.Centralized), + CancellationToken.None); + var project = await fixture.Db.ExperimentProjects.SingleAsync(); + fixture.Db.SchedulePlans.Add(new SchedulePlan + { + AcademicTermId = fixture.Term.Id, + Name = "正式课表", + Version = "V1", + Status = SchedulePlanStatus.Published, + Entries = + [ + new ScheduleEntry + { + TeachingTaskId = fixture.Task.Id, + ClassroomId = fixture.SecondClassroom.Id, + DayOfWeek = 1, + StartPeriod = 1, + PeriodCount = 2, + StartWeek = 1, + EndWeek = 18, + WeekPattern = WeekPattern.All + } + ] + }); + await fixture.Db.SaveChangesAsync(); + + var result = await manager.CreateSession( + project.Id, + fixture.SessionRequest(1, 2), + CancellationToken.None); + + Assert.IsType(result); + Assert.Empty(fixture.Db.ExperimentSessions); + } + [Fact] public async Task StudentTimetable_IncludesCentralizedAndBookedSelfScheduledSessions() { diff --git a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs index 6e46eb6..e1cf942 100644 --- a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs +++ b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs @@ -1,5 +1,6 @@ -using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Experiments; using Jiaowu.Api.Infrastructure.Exams; +using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -176,6 +177,64 @@ public sealed class MySqlMigrationTests StringComparison.OrdinalIgnoreCase); } + [Fact] + public void MySql_centralized_experiment_conflict_queries_are_translatable() + { + using var db = new AppDbContext(CreateMySqlOptions()); + var firstId = + Guid.Parse("11111111-1111-1111-1111-111111111111"); + var secondId = + Guid.Parse("22222222-2222-2222-2222-222222222222"); + Guid[] relatedIds = [firstId, secondId]; + + var teacherTaskSql = CentralizedExperimentConflictQuery + .TaskIdsForTeachers(db, relatedIds) + .ToQueryString(); + var classTaskSql = CentralizedExperimentConflictQuery + .TaskIdsForClasses(db, relatedIds) + .ToQueryString(); + var scheduleSql = CentralizedExperimentConflictQuery + .ScheduleEntries( + db, + relatedIds, + firstId, + 1, + 2, + 1, + 2) + .ToQueryString(); + var experimentSql = CentralizedExperimentConflictQuery + .ExperimentSessions( + db, + relatedIds, + new DateOnly(2026, 8, 1), + 1, + 2) + .ToQueryString(); + + Assert.Contains( + "TeachingTaskTeachers", + teacherTaskSql, + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "TeachingTaskClasses", + classTaskSql, + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "ScheduleEntries", + scheduleSql, + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + "ExperimentSessions", + experimentSql, + StringComparison.OrdinalIgnoreCase); + Assert.Contains(" IN (", scheduleSql, StringComparison.OrdinalIgnoreCase); + Assert.Contains( + " IN (", + experimentSql, + StringComparison.OrdinalIgnoreCase); + } + [Fact] public void MySql_index_names_fit_the_server_identifier_limit() {