已改成“普通排课与实验学时分离”:

普通课表学时 = 总学时 − 实践学时。
自动排课、手工排课、课表发布都会阻止把实践学时重复排入。
全部为实践学时的课程应设为“非排时课程”,再由实验管理安排。
教学任务和排课页面会显示“常规 / 实践学时”。
已有教学任务即使仍保存旧周学时,排课时也会按拆分后的课程学时计算。
无需数据库迁移。
This commit is contained in:
2026-07-29 09:57:49 +08:00 Unverified
parent 356c410788
commit 692b521453
14 changed files with 525 additions and 30 deletions
@@ -145,6 +145,8 @@ public sealed class CoursesController(
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null, CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
x.Credits, x.Credits,
x.TotalHours, x.TotalHours,
x.LectureHours,
x.PracticeHours,
x.Nature, x.Nature,
x.AssessmentMethod x.AssessmentMethod
}) })
@@ -3,6 +3,7 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -96,6 +97,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
x.StartWeek, x.StartWeek,
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
x.SchedulingMode x.SchedulingMode
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -107,6 +110,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
return Ok(tasks.Select(task => return Ok(tasks.Select(task =>
{ {
constraints.TryGetValue(task.Id, out var constraint); 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 return new
{ {
task.Id, task.Id,
@@ -120,7 +132,9 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
task.Capacity, task.Capacity,
task.StartWeek, task.StartWeek,
task.EndWeek, task.EndWeek,
task.WeeklyHours, WeeklyHours = weeklyHours,
task.CourseTotalHours,
task.CoursePracticeHours,
task.SchedulingMode, task.SchedulingMode,
HasCustomConstraint = constraint is not null, HasCustomConstraint = constraint is not null,
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
@@ -6,6 +6,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System; using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -497,6 +498,7 @@ public sealed class SchedulesController(
.Include(x => x.Classes) .Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students) .ThenInclude(x => x!.Students)
.Include(x => x.Course)
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null || if (task is null ||
task.Status != TeachingTaskStatus.Published || task.Status != TeachingTaskStatus.Published ||
@@ -506,6 +508,27 @@ public sealed class SchedulesController(
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。"); return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek) if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
return ValidationProblem("排课周次必须位于教学任务的授课周次内。"); 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() var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Include(x => x.AllowedClassrooms) .Include(x => x.AllowedClassrooms)
@@ -82,6 +82,9 @@ public sealed class TeachingTasksController(
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours, CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
TeacherNames = x.Teachers TeacherNames = x.Teachers
@@ -127,6 +130,10 @@ public sealed class TeachingTasksController(
x.EndWeek, x.EndWeek,
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course!.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
TeacherNames = x.Teachers TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name), .Select(item => item.Teacher!.Name),
@@ -163,6 +170,9 @@ public sealed class TeachingTasksController(
x.WeeklyHours, x.WeeklyHours,
x.SchedulingMode, x.SchedulingMode,
CourseTotalHours = x.Course.TotalHours, CourseTotalHours = x.Course.TotalHours,
CoursePracticeHours = x.Course.PracticeHours,
CourseRegularScheduleHours =
x.Course.TotalHours - x.Course.PracticeHours,
x.GenerationBatchCode, x.GenerationBatchCode,
x.Status, x.Status,
x.Notes, x.Notes,
@@ -423,7 +433,8 @@ public sealed class TeachingTasksController(
course, course,
request.StartWeek, request.StartWeek,
request.EndWeek, request.EndWeek,
request.WeeklyHours); request.WeeklyHours,
TeachingTaskSchedulingMode.Standard);
if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (hoursProblem is not null) return ValidationProblem(hoursProblem);
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
@@ -598,7 +609,8 @@ public sealed class TeachingTasksController(
course, course,
request.StartWeek, request.StartWeek,
request.EndWeek, request.EndWeek,
request.WeeklyHours); request.WeeklyHours,
request.SchedulingMode);
if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (hoursProblem is not null) return ValidationProblem(hoursProblem);
var collegeId = ScopedCollegeId(); var collegeId = ScopedCollegeId();
if (!await db.AcademicTerms.AnyAsync( if (!await db.AcademicTerms.AnyAsync(
@@ -1,5 +1,6 @@
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling; namespace Jiaowu.Api.Infrastructure.Scheduling;
@@ -34,6 +35,7 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
.Include(x => x.Classes) .Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students) .ThenInclude(x => x!.Students)
.Include(x => x.Course)
.OrderByDescending(x => x.Classes.Count + x.Teachers.Count) .OrderByDescending(x => x.Classes.Count + x.Teachers.Count)
.ThenByDescending(x => x.Capacity) .ThenByDescending(x => x.Capacity)
.ThenBy(x => x.TaskNumber) .ThenBy(x => x.TaskNumber)
@@ -72,10 +74,43 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
constraints.TryGetValue(task.Id, out var constraint); 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 var scheduledHours = entries
.Where(x => x.TeachingTaskId == task.Id) .Where(x => x.TeachingTaskId == task.Id)
.Sum(x => x.PeriodCount); .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) if (remainingHours == 0)
{ {
completedTasks++; completedTasks++;
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling; namespace Jiaowu.Api.Infrastructure.Scheduling;
@@ -179,19 +180,68 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
x.AcademicTermId == plan.AcademicTermId && x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published && x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard) 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); .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 var scheduledHours = plan.Entries
.GroupBy(x => x.TeachingTaskId) .GroupBy(x => x.TeachingTaskId)
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount)); .ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
var incomplete = requiredTasks.FirstOrDefault(task => var incomplete = requiredWeeklyHours.FirstOrDefault(item =>
!scheduledHours.TryGetValue(task.Id, out var hours) || scheduledHours.GetValueOrDefault(item.Task.Id) < item.Hours);
hours < task.WeeklyHours);
if (incomplete is not null) if (incomplete is not null)
{ {
throw new SchedulePublishValidationException( throw new SchedulePublishValidationException(
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 " + $"{incomplete.Task.TaskNumber} · {incomplete.Task.Name} 尚未达到每周 " +
$"{incomplete.WeeklyHours} 学时,不能发布。"); $"{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); await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
@@ -7,15 +7,75 @@ public static class TeachingTaskHours
public static int Calculate(int startWeek, int endWeek, int weeklyHours) => public static int Calculate(int startWeek, int endWeek, int weeklyHours) =>
endWeek < startWeek ? 0 : (endWeek - startWeek + 1) * 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( public static string? Validate(
Course course, Course course,
int startWeek, int startWeek,
int endWeek, int endWeek,
int weeklyHours) int weeklyHours,
TeachingTaskSchedulingMode schedulingMode =
TeachingTaskSchedulingMode.Standard)
{ {
var plannedHours = Calculate(startWeek, endWeek, weeklyHours); var plannedHours = Calculate(startWeek, endWeek, weeklyHours);
return plannedHours == course.TotalHours var targetHours = TargetHours(course, schedulingMode);
? null if (plannedHours == targetHours) return null;
: $"课程“{course.Name}”总学时为 {course.TotalHours};当前第 {startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 {plannedHours} 学时。请调整授课周次或周学时。";
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} 学时。请调整授课周次或周学时。";
} }
} }
@@ -136,6 +136,91 @@ public sealed class AutomaticScheduleGeneratorTests
Assert.Empty(await db.ScheduleEntries.ToListAsync()); 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<AppDbContext>()
.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] [Fact]
public async Task Generator_schedules_roomless_course_within_allowed_time() public async Task Generator_schedules_roomless_course_within_allowed_time()
{ {
@@ -12,7 +12,7 @@ public sealed class SchedulePublishJobProcessorTests
[Fact] [Fact]
public async Task Processor_validates_and_publishes_plan_atomically() 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(SchedulePublishJobStatus.Succeeded, result.JobStatus);
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus); Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
@@ -26,17 +26,46 @@ public sealed class SchedulePublishJobProcessorTests
[Fact] [Fact]
public async Task Processor_keeps_draft_when_validation_fails() 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(SchedulePublishJobStatus.Failed, result.JobStatus);
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus); Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
Assert.Null(result.ActiveAcademicTermId); Assert.Null(result.ActiveAcademicTermId);
Assert.Equal("检查未通过", result.CurrentStep); Assert.Equal("检查未通过", result.CurrentStep);
Assert.Contains("尚未达到每周 4 学时", result.ErrorMessage); Assert.Contains("尚未达到每周 2 个普通排课学时", result.ErrorMessage);
Assert.Null(result.PublishedAt); Assert.Null(result.PublishedAt);
} }
private static async Task<PublishResult> 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<PublishResult> RunPublishAsync(
int weeklyHours,
int scheduledHours,
int practiceHours = 0)
{ {
var databasePath = Path.Combine( var databasePath = Path.Combine(
Path.GetTempPath(), Path.GetTempPath(),
@@ -75,7 +104,8 @@ public sealed class SchedulePublishJobProcessorTests
College = college, College = college,
Credits = 1, Credits = 1,
TotalHours = 32, TotalHours = 32,
LectureHours = 32 LectureHours = 32 - practiceHours,
PracticeHours = practiceHours
}; };
var task = new TeachingTask var task = new TeachingTask
{ {
@@ -100,7 +130,7 @@ public sealed class SchedulePublishJobProcessorTests
TeachingTask = task, TeachingTask = task,
DayOfWeek = 1, DayOfWeek = 1,
StartPeriod = 1, StartPeriod = 1,
PeriodCount = 2, PeriodCount = scheduledHours,
StartWeek = 1, StartWeek = 1,
EndWeek = 16, EndWeek = 16,
WeekPattern = WeekPattern.All WeekPattern = WeekPattern.All
@@ -189,6 +189,8 @@ public sealed class ScheduleSettingsControllerTests
}); });
Assert.Contains("测试教师", json); Assert.Contains("测试教师", json);
Assert.Contains(classroom.Id.ToString(), json); Assert.Contains(classroom.Id.ToString(), json);
Assert.Contains("\"WeeklyHours\":3", json);
Assert.Contains("\"CoursePracticeHours\":16", json);
} }
[Fact] [Fact]
@@ -12,6 +12,114 @@ namespace Jiaowu.Api.Tests;
public sealed class SchedulesControllerTests 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<AppDbContext>()
.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<ObjectResult>(result);
var details = Assert.IsType<ValidationProblemDetails>(problem.Value);
Assert.Contains(
"实践学时请在实验管理中安排",
details.Detail);
Assert.Single(await db.ScheduleEntries.ToListAsync());
}
[Fact] [Fact]
public async Task Publish_returns_accepted_before_background_validation_runs() public async Task Publish_returns_accepted_before_background_validation_runs()
{ {
@@ -30,4 +30,57 @@ public sealed class TeachingTaskHoursTests
Assert.Contains("总学时为 32", result); Assert.Contains("总学时为 32", result);
Assert.Contains("共 16 学时", 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)!);
}
} }
+1 -1
View File
@@ -988,7 +988,7 @@ onBeforeUnmount(() => {
</el-form-item> </el-form-item>
<el-alert <el-alert
v-if="selectedTaskConstraint" v-if="selectedTaskConstraint"
:title="`可排第 ${selectedTaskConstraint.startWeek}—${selectedTaskConstraint.endWeek} 周;允许上课日:${entryWeekdays.map((day) => day.label).join('、')}`" :title="`普通课表每周 ${selectedTaskConstraint.weeklyHours} 学时(实践 ${selectedTaskConstraint.coursePracticeHours} 学时另由实验管理安排);可排第 ${selectedTaskConstraint.startWeek}—${selectedTaskConstraint.endWeek} 周;允许上课日:${entryWeekdays.map((day) => day.label).join('、')}`"
type="info" type="info"
:closable="false" :closable="false"
show-icon show-icon
+30 -9
View File
@@ -77,13 +77,22 @@ const availableClasses = computed(() => {
const selectedCourse = computed(() => const selectedCourse = computed(() =>
courses.value.find((item) => item.id === form.courseId), 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(() => const plannedHours = computed(() =>
form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek
? (form.endWeek - form.startWeek + 1) * form.weeklyHours ? (form.endWeek - form.startWeek + 1) * form.weeklyHours
: 0, : 0,
) )
const hoursMatch = computed(() => const hoursMatch = computed(() =>
!selectedCourse.value || plannedHours.value === selectedCourse.value.totalHours, !selectedCourse.value || plannedHours.value === selectedCourseTargetHours.value,
) )
const assignableTeachers = computed(() => const assignableTeachers = computed(() =>
manualEligibleTeachers.value.map((item) => ({ manualEligibleTeachers.value.map((item) => ({
@@ -115,7 +124,7 @@ const generationPlannedHours = computed(() =>
) )
const generationHoursMatch = computed(() => const generationHoursMatch = computed(() =>
!selectedGenerationCourse.value || !selectedGenerationCourse.value ||
generationPlannedHours.value === selectedGenerationCourse.value.totalHours, generationPlannedHours.value === regularScheduleHours(selectedGenerationCourse.value),
) )
const manageableCourses = computed(() => { const manageableCourses = computed(() => {
if (isSuperAdmin.value) return courses.value if (isSuperAdmin.value) return courses.value
@@ -355,7 +364,7 @@ async function save() {
} }
if (!hoursMatch.value) { if (!hoursMatch.value) {
ElMessage.warning( ElMessage.warning(
`课程总学时为 ${selectedCourse.value.totalHours},当前授课安排合计 ${plannedHours.value} 学时,请调整周次或周学时`, `课程普通课表应安排 ${selectedCourseTargetHours.value} 学时,当前安排合计 ${plannedHours.value} 学时;实践学时请在实验管理中安排`,
) )
return return
} }
@@ -542,7 +551,7 @@ async function generatePublicTasks() {
} }
if (!generationHoursMatch.value) { if (!generationHoursMatch.value) {
ElMessage.warning( ElMessage.warning(
`课程总学时为 ${selectedGenerationCourse.value.totalHours},当前授课安排合计 ${generationPlannedHours.value} 学时,请调整周次或周学时`, `课程普通课表应安排 ${regularScheduleHours(selectedGenerationCourse.value)} 学时,当前安排合计 ${generationPlannedHours.value} 学时;实践学时不进入普通课表`,
) )
return return
} }
@@ -683,6 +692,9 @@ onMounted(async () => {
<el-table-column label="行政班" min-width="150"><template #default="{ row }">{{ row.classNames.join('、') || '开放选课' }}</template></el-table-column> <el-table-column label="行政班" min-width="150"><template #default="{ row }">{{ row.classNames.join('、') || '开放选课' }}</template></el-table-column>
<el-table-column label="人数 / 容量" width="105"><template #default="{ row }">{{ row.studentCount }} / {{ row.capacity }}</template></el-table-column> <el-table-column label="人数 / 容量" width="105"><template #default="{ row }">{{ row.studentCount }} / {{ row.capacity }}</template></el-table-column>
<el-table-column label="周次" width="105"><template #default="{ row }">{{ row.startWeek }}{{ row.endWeek }} </template></el-table-column> <el-table-column label="周次" width="105"><template #default="{ row }">{{ row.startWeek }}{{ row.endWeek }} </template></el-table-column>
<el-table-column label="常规 / 实践学时" width="135">
<template #default="{ row }">{{ row.courseRegularScheduleHours }} / {{ row.coursePracticeHours }}</template>
</el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><span class="table-status" :class="{ off: row.status === 'Closed' }">{{ statusLabels[row.status] }}</span></template></el-table-column> <el-table-column label="状态" width="90"><template #default="{ row }"><span class="table-status" :class="{ off: row.status === 'Closed' }">{{ statusLabels[row.status] }}</span></template></el-table-column>
<el-table-column label="操作" width="210" fixed="right"> <el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
@@ -714,7 +726,12 @@ onMounted(async () => {
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" /></el-select></el-form-item> <el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" /></el-select></el-form-item>
<el-form-item label="课程" required> <el-form-item label="课程" required>
<el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()"> <el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()">
<el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}${item.totalHours} 学时)`" :value="item.id" /> <el-option
v-for="item in filteredManageableCourses"
:key="item.id"
:label="`${item.code} · ${item.name}(常规 ${regularScheduleHours(item)} / 实践 ${item.practiceHours} 学时)`"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
</div> </div>
@@ -744,7 +761,10 @@ onMounted(async () => {
<b>{{ hoursMatch ? '学时匹配' : '学时不匹配' }}</b> <b>{{ hoursMatch ? '学时匹配' : '学时不匹配' }}</b>
<span> <span>
{{ form.startWeek }}{{ form.endWeek }} × 每周 {{ form.weeklyHours }} 学时 {{ form.startWeek }}{{ form.endWeek }} × 每周 {{ form.weeklyHours }} 学时
= {{ plannedHours }} 学时课程库总学时 {{ selectedCourse.totalHours }} = {{ plannedHours }} 学时当前授课方式应计 {{ selectedCourseTargetHours }} 学时
<template v-if="form.schedulingMode === 'Standard'">
实践 {{ selectedCourse.practiceHours }} 学时由实验管理单独安排
</template>
</span> </span>
</div> </div>
<el-form-item label="授课方式"> <el-form-item label="授课方式">
@@ -753,7 +773,7 @@ onMounted(async () => {
<el-radio-button value="Flexible">非排时课程</el-radio-button> <el-radio-button value="Flexible">非排时课程</el-radio-button>
</el-radio-group> </el-radio-group>
<small class="field-hint"> <small class="field-hint">
非排时课程不进入自动排课不占用星期节次和场地并在课表中单独列出 正常排课只安排非实践学时全部由实验模块安排的课程请选择非排时课程
</small> </small>
</el-form-item> </el-form-item>
<el-form-item label="授课教师"> <el-form-item label="授课教师">
@@ -881,8 +901,9 @@ onMounted(async () => {
> >
<b>{{ generationHoursMatch ? '学时匹配' : '学时不匹配' }}</b> <b>{{ generationHoursMatch ? '学时匹配' : '学时不匹配' }}</b>
<span> <span>
当前合计 {{ generationPlannedHours }} 学时课程库总学时 当前合计 {{ generationPlannedHours }} 学时普通课表应安排
{{ selectedGenerationCourse.totalHours }} {{ regularScheduleHours(selectedGenerationCourse) }} 学时实践
{{ selectedGenerationCourse.practiceHours }} 学时由实验管理单独安排
</span> </span>
</div> </div>
<div class="generation-preview"> <div class="generation-preview">