diff --git a/src/Jiaowu.Api/Controllers/CoursesController.cs b/src/Jiaowu.Api/Controllers/CoursesController.cs index c0642ef..2d26b07 100644 --- a/src/Jiaowu.Api/Controllers/CoursesController.cs +++ b/src/Jiaowu.Api/Controllers/CoursesController.cs @@ -145,6 +145,8 @@ public sealed class CoursesController( CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null, x.Credits, x.TotalHours, + x.LectureHours, + x.PracticeHours, x.Nature, x.AssessmentMethod }) diff --git a/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs index 10126fd..51c4912 100644 --- a/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs +++ b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs @@ -3,6 +3,7 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -96,6 +97,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache) x.StartWeek, x.EndWeek, x.WeeklyHours, + CourseTotalHours = x.Course.TotalHours, + CoursePracticeHours = x.Course.PracticeHours, x.SchedulingMode }) .ToListAsync(cancellationToken); @@ -107,6 +110,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache) return Ok(tasks.Select(task => { constraints.TryGetValue(task.Id, out var constraint); + var weeklyHours = task.WeeklyHours; + if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard && + TeachingTaskHours.TryResolveRegularWeeklyHours( + task.CourseTotalHours, + task.CoursePracticeHours, + task.StartWeek, + task.EndWeek, + out var regularWeeklyHours)) + weeklyHours = regularWeeklyHours; return new { task.Id, @@ -120,7 +132,9 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache) task.Capacity, task.StartWeek, task.EndWeek, - task.WeeklyHours, + WeeklyHours = weeklyHours, + task.CourseTotalHours, + task.CoursePracticeHours, task.SchedulingMode, HasCustomConstraint = constraint is not null, RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible diff --git a/src/Jiaowu.Api/Controllers/SchedulesController.cs b/src/Jiaowu.Api/Controllers/SchedulesController.cs index 86cf10e..3e89228 100644 --- a/src/Jiaowu.Api/Controllers/SchedulesController.cs +++ b/src/Jiaowu.Api/Controllers/SchedulesController.cs @@ -6,6 +6,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; +using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -497,6 +498,7 @@ public sealed class SchedulesController( .Include(x => x.Classes) .ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x!.Students) + .Include(x => x.Course) .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); if (task is null || task.Status != TeachingTaskStatus.Published || @@ -506,6 +508,27 @@ public sealed class SchedulesController( return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。"); if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek) return ValidationProblem("排课周次必须位于教学任务的授课周次内。"); + if (!TeachingTaskHours.TryResolveRegularWeeklyHours( + task.Course!, + task.StartWeek, + task.EndWeek, + out var requiredWeeklyHours)) + return ValidationProblem( + "该课程的普通排课学时不能按授课周次整除,请先调整教学任务周次。"); + if (requiredWeeklyHours == 0) + return ValidationProblem( + "该课程全部为实践学时,无需进入普通课表,请在实验管理中安排。"); + var existingHours = await db.ScheduleEntries.AsNoTracking() + .Where(x => + x.SchedulePlanId == plan.Id && + x.TeachingTaskId == request.TeachingTaskId && + x.Id != entryId) + .SumAsync(x => x.PeriodCount, cancellationToken); + if (existingHours + request.PeriodCount > requiredWeeklyHours) + return ValidationProblem( + $"该教学任务普通课表每周只需 {requiredWeeklyHours} 学时;" + + $"当前操作后将达到 {existingHours + request.PeriodCount} 学时," + + "实践学时请在实验管理中安排。"); var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking() .Include(x => x.AllowedClassrooms) diff --git a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs index f74b6c7..1e413d4 100644 --- a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs +++ b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs @@ -82,6 +82,9 @@ public sealed class TeachingTasksController( x.WeeklyHours, x.SchedulingMode, CourseTotalHours = x.Course.TotalHours, + CoursePracticeHours = x.Course.PracticeHours, + CourseRegularScheduleHours = + x.Course.TotalHours - x.Course.PracticeHours, x.GenerationBatchCode, x.Status, TeacherNames = x.Teachers @@ -127,6 +130,10 @@ public sealed class TeachingTasksController( x.EndWeek, x.WeeklyHours, x.SchedulingMode, + CourseTotalHours = x.Course!.TotalHours, + CoursePracticeHours = x.Course.PracticeHours, + CourseRegularScheduleHours = + x.Course.TotalHours - x.Course.PracticeHours, TeacherNames = x.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), @@ -163,6 +170,9 @@ public sealed class TeachingTasksController( x.WeeklyHours, x.SchedulingMode, CourseTotalHours = x.Course.TotalHours, + CoursePracticeHours = x.Course.PracticeHours, + CourseRegularScheduleHours = + x.Course.TotalHours - x.Course.PracticeHours, x.GenerationBatchCode, x.Status, x.Notes, @@ -423,7 +433,8 @@ public sealed class TeachingTasksController( course, request.StartWeek, request.EndWeek, - request.WeeklyHours); + request.WeeklyHours, + TeachingTaskSchedulingMode.Standard); if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); @@ -598,7 +609,8 @@ public sealed class TeachingTasksController( course, request.StartWeek, request.EndWeek, - request.WeeklyHours); + request.WeeklyHours, + request.SchedulingMode); if (hoursProblem is not null) return ValidationProblem(hoursProblem); var collegeId = ScopedCollegeId(); if (!await db.AcademicTerms.AnyAsync( diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs index 0a48a23..d6a67fe 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs @@ -1,5 +1,6 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Scheduling; @@ -34,6 +35,7 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) .Include(x => x.Classes) .ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x!.Students) + .Include(x => x.Course) .OrderByDescending(x => x.Classes.Count + x.Teachers.Count) .ThenByDescending(x => x.Capacity) .ThenBy(x => x.TaskNumber) @@ -72,10 +74,43 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) { cancellationToken.ThrowIfCancellationRequested(); constraints.TryGetValue(task.Id, out var constraint); + if (!TeachingTaskHours.TryResolveRegularWeeklyHours( + task.Course!, + task.StartWeek, + task.EndWeek, + out var requiredWeeklyHours)) + { + messages.Add( + $"{task.TaskNumber} · {task.Name} 的普通排课学时不能按授课周次整除,请调整教学任务周次。"); + processedTasks++; + if (reportProgress is not null) + { + await reportProgress( + new(tasks.Count, processedTasks, created, completedTasks), + cancellationToken); + } + continue; + } + var scheduledHours = entries .Where(x => x.TeachingTaskId == task.Id) .Sum(x => x.PeriodCount); - var remainingHours = Math.Max(0, task.WeeklyHours - scheduledHours); + if (scheduledHours > requiredWeeklyHours) + { + messages.Add( + $"{task.TaskNumber} · {task.Name} 已安排每周 {scheduledHours} 学时," + + $"普通课表只需 {requiredWeeklyHours} 学时;请删除已包含的实践学时。"); + processedTasks++; + if (reportProgress is not null) + { + await reportProgress( + new(tasks.Count, processedTasks, created, completedTasks), + cancellationToken); + } + continue; + } + + var remainingHours = requiredWeeklyHours - scheduledHours; if (remainingHours == 0) { completedTasks++; diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs index c3b5a71..0d8ed15 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Scheduling; @@ -179,19 +180,68 @@ public sealed class SchedulePlanPublisher(AppDbContext db) x.AcademicTermId == plan.AcademicTermId && x.Status == TeachingTaskStatus.Published && x.SchedulingMode == TeachingTaskSchedulingMode.Standard) - .Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours }) + .Select(x => new + { + x.Id, + x.TaskNumber, + x.Name, + x.StartWeek, + x.EndWeek, + CourseTotalHours = x.Course!.TotalHours, + CoursePracticeHours = x.Course.PracticeHours + }) .ToListAsync(cancellationToken); + var invalidHours = requiredTasks.FirstOrDefault(task => + !TeachingTaskHours.TryResolveRegularWeeklyHours( + task.CourseTotalHours, + task.CoursePracticeHours, + task.StartWeek, + task.EndWeek, + out _)); + if (invalidHours is not null) + { + throw new SchedulePublishValidationException( + $"{invalidHours.TaskNumber} · {invalidHours.Name} 的普通排课学时" + + "不能按授课周次整除,请先调整教学任务周次。"); + } + + var requiredWeeklyHours = requiredTasks + .Select(task => + { + TeachingTaskHours.TryResolveRegularWeeklyHours( + task.CourseTotalHours, + task.CoursePracticeHours, + task.StartWeek, + task.EndWeek, + out var hours); + return new + { + Task = task, + Hours = hours + }; + }) + .ToList(); var scheduledHours = plan.Entries .GroupBy(x => x.TeachingTaskId) .ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount)); - var incomplete = requiredTasks.FirstOrDefault(task => - !scheduledHours.TryGetValue(task.Id, out var hours) || - hours < task.WeeklyHours); + var incomplete = requiredWeeklyHours.FirstOrDefault(item => + scheduledHours.GetValueOrDefault(item.Task.Id) < item.Hours); if (incomplete is not null) { throw new SchedulePublishValidationException( - $"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 " + - $"{incomplete.WeeklyHours} 学时,不能发布。"); + $"{incomplete.Task.TaskNumber} · {incomplete.Task.Name} 尚未达到每周 " + + $"{incomplete.Hours} 个普通排课学时,不能发布。"); + } + + var excessive = requiredWeeklyHours.FirstOrDefault(item => + scheduledHours.GetValueOrDefault(item.Task.Id) > item.Hours); + if (excessive is not null) + { + var actualHours = scheduledHours.GetValueOrDefault(excessive.Task.Id); + throw new SchedulePublishValidationException( + $"{excessive.Task.TaskNumber} · {excessive.Task.Name} 已安排每周 " + + $"{actualHours} 学时,普通课表应为 {excessive.Hours} 学时;" + + "请删除已包含的实践学时后再发布。"); } await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken); diff --git a/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskHours.cs b/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskHours.cs index 4b1a596..8a77cd4 100644 --- a/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskHours.cs +++ b/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskHours.cs @@ -7,15 +7,75 @@ public static class TeachingTaskHours public static int Calculate(int startWeek, int endWeek, int weeklyHours) => endWeek < startWeek ? 0 : (endWeek - startWeek + 1) * weeklyHours; + public static int RegularScheduleHours(Course course) => + RegularScheduleHours(course.TotalHours, course.PracticeHours); + + public static int RegularScheduleHours(int totalHours, int practiceHours) => + Math.Max(0, totalHours - practiceHours); + + public static int TargetHours( + Course course, + TeachingTaskSchedulingMode schedulingMode) => + schedulingMode == TeachingTaskSchedulingMode.Flexible + ? course.TotalHours + : RegularScheduleHours(course); + + public static bool TryResolveRegularWeeklyHours( + Course course, + int startWeek, + int endWeek, + out int weeklyHours) => + TryResolveRegularWeeklyHours( + course.TotalHours, + course.PracticeHours, + startWeek, + endWeek, + out weeklyHours); + + public static bool TryResolveRegularWeeklyHours( + int totalHours, + int practiceHours, + int startWeek, + int endWeek, + out int weeklyHours) + { + weeklyHours = 0; + var weekCount = endWeek - startWeek + 1; + var regularHours = RegularScheduleHours(totalHours, practiceHours); + if (weekCount <= 0 || regularHours % weekCount != 0) return false; + + weeklyHours = regularHours / weekCount; + return true; + } + public static string? Validate( Course course, int startWeek, int endWeek, - int weeklyHours) + int weeklyHours, + TeachingTaskSchedulingMode schedulingMode = + TeachingTaskSchedulingMode.Standard) { var plannedHours = Calculate(startWeek, endWeek, weeklyHours); - return plannedHours == course.TotalHours - ? null - : $"课程“{course.Name}”总学时为 {course.TotalHours};当前第 {startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 {plannedHours} 学时。请调整授课周次或周学时。"; + var targetHours = TargetHours(course, schedulingMode); + if (plannedHours == targetHours) return null; + + if (schedulingMode == TeachingTaskSchedulingMode.Standard) + { + if (targetHours == 0) + { + return $"课程“{course.Name}”的 {course.TotalHours} 学时均为实践学时," + + "无需进入普通课表;请将授课方式设为“非排时课程”,并在实验管理中安排。"; + } + + return $"课程“{course.Name}”总学时为 {course.TotalHours},其中实践学时 " + + $"{course.PracticeHours},普通课表应安排 {targetHours} 学时;当前第 " + + $"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " + + $"{plannedHours} 学时。请调整授课周次或周学时。"; + } + + return $"课程“{course.Name}”总学时为 {course.TotalHours};当前第 " + + $"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " + + $"{plannedHours} 学时。请调整授课周次或周学时。"; } } diff --git a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs index 0a7115d..70d7591 100644 --- a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs +++ b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs @@ -136,6 +136,91 @@ public sealed class AutomaticScheduleGeneratorTests Assert.Empty(await db.ScheduleEntries.ToListAsync()); } + [Fact] + public async Task Generator_excludes_practice_hours_from_regular_schedule() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var term = new AcademicTerm + { + Code = "2026-LAB", + Name = "2026 实验学时测试", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 15) + }; + var college = new College { Code = "LAB", Name = "实验学院" }; + var course = new Course + { + Code = "LAB-01", + Name = "实验混合课程", + College = college, + Credits = 2, + TotalHours = 32, + LectureHours = 16, + PracticeHours = 16 + }; + var task = new TeachingTask + { + TaskNumber = "TASK-LAB", + Name = "实验混合教学班", + AcademicTerm = term, + Course = course, + Capacity = 30, + StartWeek = 1, + EndWeek = 16, + WeeklyHours = 2, + Status = TeachingTaskStatus.Published + }; + var plan = new SchedulePlan + { + AcademicTerm = term, + Name = "实验学时拆分测试", + Version = "V1" + }; + db.AddRange(term, college, course, task, plan); + db.ScheduleTimeSlots.AddRange( + new ScheduleTimeSlot + { + AcademicTerm = term, + PeriodNumber = 1, + Name = "第 1 节", + StartsAt = new TimeOnly(8, 0), + EndsAt = new TimeOnly(8, 45) + }, + new ScheduleTimeSlot + { + AcademicTerm = term, + PeriodNumber = 2, + Name = "第 2 节", + StartsAt = new TimeOnly(8, 55), + EndsAt = new TimeOnly(9, 40) + }); + db.TeachingTaskScheduleConstraints.Add( + new TeachingTaskScheduleConstraint + { + TeachingTask = task, + RequiresClassroom = false + }); + await db.SaveChangesAsync(); + + var result = await new AutomaticScheduleGenerator(db) + .GenerateAsync(plan, CancellationToken.None); + + Assert.Equal(1, result.CreatedEntries); + Assert.Equal(1, result.CompletedTasks); + Assert.Equal( + 1, + (await db.ScheduleEntries.SingleAsync()).PeriodCount); + } + [Fact] public async Task Generator_schedules_roomless_course_within_allowed_time() { diff --git a/tests/Jiaowu.Api.Tests/SchedulePublishJobProcessorTests.cs b/tests/Jiaowu.Api.Tests/SchedulePublishJobProcessorTests.cs index 0f7d8a5..923f29f 100644 --- a/tests/Jiaowu.Api.Tests/SchedulePublishJobProcessorTests.cs +++ b/tests/Jiaowu.Api.Tests/SchedulePublishJobProcessorTests.cs @@ -12,7 +12,7 @@ public sealed class SchedulePublishJobProcessorTests [Fact] public async Task Processor_validates_and_publishes_plan_atomically() { - var result = await RunPublishAsync(weeklyHours: 2); + var result = await RunPublishAsync(weeklyHours: 2, scheduledHours: 2); Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus); Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus); @@ -26,17 +26,46 @@ public sealed class SchedulePublishJobProcessorTests [Fact] public async Task Processor_keeps_draft_when_validation_fails() { - var result = await RunPublishAsync(weeklyHours: 4); + var result = await RunPublishAsync(weeklyHours: 2, scheduledHours: 1); Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus); Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus); Assert.Null(result.ActiveAcademicTermId); Assert.Equal("检查未通过", result.CurrentStep); - Assert.Contains("尚未达到每周 4 学时", result.ErrorMessage); + Assert.Contains("尚未达到每周 2 个普通排课学时", result.ErrorMessage); Assert.Null(result.PublishedAt); } - private static async Task RunPublishAsync(int weeklyHours) + [Fact] + public async Task Processor_excludes_practice_hours_for_existing_tasks() + { + var result = await RunPublishAsync( + weeklyHours: 2, + scheduledHours: 1, + practiceHours: 16); + + Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus); + Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus); + } + + [Fact] + public async Task Processor_rejects_practice_hours_already_added_to_draft() + { + var result = await RunPublishAsync( + weeklyHours: 2, + scheduledHours: 2, + practiceHours: 16); + + Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus); + Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus); + Assert.Contains("普通课表应为 1 学时", result.ErrorMessage); + Assert.Contains("删除已包含的实践学时", result.ErrorMessage); + } + + private static async Task RunPublishAsync( + int weeklyHours, + int scheduledHours, + int practiceHours = 0) { var databasePath = Path.Combine( Path.GetTempPath(), @@ -75,7 +104,8 @@ public sealed class SchedulePublishJobProcessorTests College = college, Credits = 1, TotalHours = 32, - LectureHours = 32 + LectureHours = 32 - practiceHours, + PracticeHours = practiceHours }; var task = new TeachingTask { @@ -100,7 +130,7 @@ public sealed class SchedulePublishJobProcessorTests TeachingTask = task, DayOfWeek = 1, StartPeriod = 1, - PeriodCount = 2, + PeriodCount = scheduledHours, StartWeek = 1, EndWeek = 16, WeekPattern = WeekPattern.All diff --git a/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs b/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs index 3ec2cf5..f665c4a 100644 --- a/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs @@ -189,6 +189,8 @@ public sealed class ScheduleSettingsControllerTests }); Assert.Contains("测试教师", json); Assert.Contains(classroom.Id.ToString(), json); + Assert.Contains("\"WeeklyHours\":3", json); + Assert.Contains("\"CoursePracticeHours\":16", json); } [Fact] diff --git a/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs b/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs index 24eada7..a564906 100644 --- a/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs @@ -12,6 +12,114 @@ namespace Jiaowu.Api.Tests; public sealed class SchedulesControllerTests { + [Fact] + public async Task Manual_entry_rejects_hours_reserved_for_experiments() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var term = new AcademicTerm + { + Code = "2026-LAB", + Name = "2026 实验学时测试", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 7), + EndDate = new DateOnly(2027, 1, 17) + }; + var college = new College { Code = "LAB", Name = "实验学院" }; + var course = new Course + { + Code = "LAB-01", + Name = "实验混合课程", + College = college, + Credits = 2, + TotalHours = 32, + LectureHours = 16, + PracticeHours = 16 + }; + var task = new TeachingTask + { + TaskNumber = "TASK-LAB", + Name = "实验混合教学班", + AcademicTerm = term, + Course = course, + Capacity = 30, + StartWeek = 1, + EndWeek = 16, + WeeklyHours = 2, + Status = TeachingTaskStatus.Published + }; + var plan = new SchedulePlan + { + AcademicTerm = term, + Name = "排课草稿", + Version = "V1" + }; + plan.Entries.Add(new ScheduleEntry + { + TeachingTask = task, + DayOfWeek = 1, + StartPeriod = 1, + PeriodCount = 1, + StartWeek = 1, + EndWeek = 16, + WeekPattern = WeekPattern.All + }); + db.AddRange(term, college, course, task, plan); + db.ScheduleTimeSlots.AddRange( + new ScheduleTimeSlot + { + AcademicTerm = term, + PeriodNumber = 1, + Name = "第 1 节", + StartsAt = new TimeOnly(8, 0), + EndsAt = new TimeOnly(8, 45) + }, + new ScheduleTimeSlot + { + AcademicTerm = term, + PeriodNumber = 2, + Name = "第 2 节", + StartsAt = new TimeOnly(8, 55), + EndsAt = new TimeOnly(9, 40) + }); + db.TeachingTaskScheduleConstraints.Add( + new TeachingTaskScheduleConstraint + { + TeachingTask = task, + RequiresClassroom = false + }); + await db.SaveChangesAsync(); + + var controller = new SchedulesController(db); + var result = await controller.CreateEntry( + plan.Id, + new ScheduleEntryRequest( + task.Id, + null, + 2, + 2, + 1, + 1, + 16, + WeekPattern.All, + null), + CancellationToken.None); + + var problem = Assert.IsType(result); + var details = Assert.IsType(problem.Value); + Assert.Contains( + "实践学时请在实验管理中安排", + details.Detail); + Assert.Single(await db.ScheduleEntries.ToListAsync()); + } + [Fact] public async Task Publish_returns_accepted_before_background_validation_runs() { diff --git a/tests/Jiaowu.Api.Tests/TeachingTaskHoursTests.cs b/tests/Jiaowu.Api.Tests/TeachingTaskHoursTests.cs index 14c9501..67c3dc1 100644 --- a/tests/Jiaowu.Api.Tests/TeachingTaskHoursTests.cs +++ b/tests/Jiaowu.Api.Tests/TeachingTaskHoursTests.cs @@ -30,4 +30,57 @@ public sealed class TeachingTaskHoursTests Assert.Contains("总学时为 32", result); Assert.Contains("共 16 学时", result); } + + [Fact] + public void Standard_schedule_excludes_practice_hours() + { + var course = new Course + { + Code = "TEST-LAB", + Name = "实验混合课程", + CollegeId = Guid.NewGuid(), + Credits = 4, + TotalHours = 64, + LectureHours = 48, + PracticeHours = 16 + }; + + Assert.Equal(48, TeachingTaskHours.RegularScheduleHours(course)); + Assert.True(TeachingTaskHours.TryResolveRegularWeeklyHours( + course, + 1, + 16, + out var weeklyHours)); + Assert.Equal(3, weeklyHours); + Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 3)); + + var result = TeachingTaskHours.Validate(course, 1, 16, 4); + Assert.NotNull(result); + Assert.Contains("普通课表应安排 48 学时", result); + Assert.Contains("实践学时 16", result); + } + + [Fact] + public void Flexible_schedule_keeps_total_workload_hours() + { + var course = new Course + { + Code = "TEST-PRACTICE", + Name = "实践课程", + CollegeId = Guid.NewGuid(), + Credits = 1, + TotalHours = 16, + PracticeHours = 16 + }; + + Assert.Null(TeachingTaskHours.Validate( + course, + 1, + 16, + 1, + TeachingTaskSchedulingMode.Flexible)); + Assert.Contains( + "无需进入普通课表", + TeachingTaskHours.Validate(course, 1, 16, 1)!); + } } diff --git a/web/src/views/SchedulesView.vue b/web/src/views/SchedulesView.vue index 827b6f5..0170c47 100644 --- a/web/src/views/SchedulesView.vue +++ b/web/src/views/SchedulesView.vue @@ -988,7 +988,7 @@ onBeforeUnmount(() => { { const selectedCourse = computed(() => courses.value.find((item) => item.id === form.courseId), ) +const regularScheduleHours = (course: any) => + Math.max(0, Number(course?.totalHours ?? 0) - Number(course?.practiceHours ?? 0)) +const targetCourseHours = (course: any, schedulingMode: string) => + schedulingMode === 'Flexible' + ? Number(course?.totalHours ?? 0) + : regularScheduleHours(course) +const selectedCourseTargetHours = computed(() => + targetCourseHours(selectedCourse.value, form.schedulingMode), +) const plannedHours = computed(() => form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek ? (form.endWeek - form.startWeek + 1) * form.weeklyHours : 0, ) const hoursMatch = computed(() => - !selectedCourse.value || plannedHours.value === selectedCourse.value.totalHours, + !selectedCourse.value || plannedHours.value === selectedCourseTargetHours.value, ) const assignableTeachers = computed(() => manualEligibleTeachers.value.map((item) => ({ @@ -115,7 +124,7 @@ const generationPlannedHours = computed(() => ) const generationHoursMatch = computed(() => !selectedGenerationCourse.value || - generationPlannedHours.value === selectedGenerationCourse.value.totalHours, + generationPlannedHours.value === regularScheduleHours(selectedGenerationCourse.value), ) const manageableCourses = computed(() => { if (isSuperAdmin.value) return courses.value @@ -355,7 +364,7 @@ async function save() { } if (!hoursMatch.value) { ElMessage.warning( - `课程总学时为 ${selectedCourse.value.totalHours},当前授课安排合计 ${plannedHours.value} 学时,请调整周次或周学时。`, + `该课程普通课表应安排 ${selectedCourseTargetHours.value} 学时,当前安排合计 ${plannedHours.value} 学时;实践学时请在实验管理中安排。`, ) return } @@ -542,7 +551,7 @@ async function generatePublicTasks() { } if (!generationHoursMatch.value) { ElMessage.warning( - `课程总学时为 ${selectedGenerationCourse.value.totalHours},当前授课安排合计 ${generationPlannedHours.value} 学时,请调整周次或周学时。`, + `该课程普通课表应安排 ${regularScheduleHours(selectedGenerationCourse.value)} 学时,当前安排合计 ${generationPlannedHours.value} 学时;实践学时不进入普通课表。`, ) return } @@ -683,6 +692,9 @@ onMounted(async () => { + + +