自动排课:保留手工安排,按教师、行政班、周次、节次、容量和场地约束补排。

课程约束:可限定校区、教学楼、指定教室、允许上课日、最早/最晚节次。
无教室课程:教室可为空,但仍校验教师和班级冲突。
作息维护:按学期维护节次、上下课时间和启用状态。
发布保护:发布前重新检查全部约束,并阻止学时未排满的课表发布。
工作台升级:增加“排课规则与作息”“自动排课”入口,自动生成后仍支持手工微调。
同时兼容 SQLite 和 MySQL,已生成正式迁移。
This commit is contained in:
2026-07-24 19:19:18 +08:00 Unverified
parent d7cf3f9e76
commit 6538b6080a
19 changed files with 4344 additions and 37 deletions
@@ -0,0 +1,126 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace Jiaowu.Api.Tests;
public sealed class AutomaticScheduleGeneratorTests
{
[Fact]
public async Task Development_migration_chain_accepts_current_sqlite_schema()
{
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 migrator = new DevelopmentSqliteMigrator(
db,
NullLogger<DevelopmentSqliteMigrator>.Instance);
await migrator.MigrateAsync();
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
Assert.True(await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('ScheduleEntries')
WHERE name = 'ClassroomId' AND "notnull" = 0
""")
.AnyAsync(value => value > 0));
}
[Fact]
public async Task Generator_schedules_roomless_course_within_allowed_time()
{
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-A",
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 = "CS", Name = "计算机学院" };
var course = new Course
{
Code = "MOOC-01",
Name = "在线专题",
College = college,
Credits = 1,
TotalHours = 32,
LectureHours = 32
};
var task = new TeachingTask
{
TaskNumber = "TASK-MOOC",
Name = "在线专题教学班",
AcademicTerm = term,
Course = course,
Capacity = 200,
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,
AllowedDayOfWeeks = "3",
EarliestPeriod = 1,
LatestPeriod = 2
});
await db.SaveChangesAsync();
var result = await new AutomaticScheduleGenerator(db)
.GenerateAsync(plan, CancellationToken.None);
var entry = await db.ScheduleEntries.SingleAsync();
Assert.Equal(1, result.CreatedEntries);
Assert.Empty(result.Messages);
Assert.Equal(3, entry.DayOfWeek);
Assert.Equal(1, entry.StartPeriod);
Assert.Equal(2, entry.PeriodCount);
Assert.Null(entry.ClassroomId);
}
}
@@ -63,8 +63,26 @@ public sealed class ScheduleConflictTests
Assert.Equal("教师", ScheduleConflictDetector.ConflictReason(first, second));
}
[Fact]
public void Two_roomless_courses_do_not_create_a_classroom_conflict()
{
var first = Entry(null, WeekPattern.All, new TeachingTask
{
TaskNumber = "ONLINE-1",
Name = "线上课程一"
});
var second = Entry(null, WeekPattern.All, new TeachingTask
{
TaskNumber = "ONLINE-2",
Name = "线上课程二"
});
Assert.True(ScheduleConflictDetector.TimeOverlaps(first, second));
Assert.Null(ScheduleConflictDetector.ConflictReason(first, second));
}
private static ScheduleEntry Entry(
Guid classroomId,
Guid? classroomId,
WeekPattern weekPattern,
TeachingTask task) =>
new()