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

课程约束:可限定校区、教学楼、指定教室、允许上课日、最早/最晚节次。
无教室课程:教室可为空,但仍校验教师和班级冲突。
作息维护:按学期维护节次、上下课时间和启用状态。
发布保护:发布前重新检查全部约束,并阻止学时未排满的课表发布。
工作台升级:增加“排课规则与作息”“自动排课”入口,自动生成后仍支持手工微调。
同时兼容 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,219 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public sealed class AutomaticScheduleGenerator(AppDbContext db)
{
public async Task<AutomaticScheduleResult> GenerateAsync(
SchedulePlan plan,
CancellationToken cancellationToken)
{
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.ToListAsync(cancellationToken);
if (timeSlots.Count == 0)
return new(0, 0, ["请先维护该学期的上课时间表。"]);
var activePeriods = timeSlots.Select(x => x.PeriodNumber).ToHashSet();
var tasks = await db.TeachingTasks
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.Include(x => x.Teachers)
.Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students)
.OrderByDescending(x => x.Classes.Count + x.Teachers.Count)
.ThenByDescending(x => x.Capacity)
.ThenBy(x => x.TaskNumber)
.ToListAsync(cancellationToken);
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => tasks.Select(task => task.Id).Contains(x.TeachingTaskId))
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.Include(x => x.Building)
.OrderBy(x => x.Capacity)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken);
var entries = await db.ScheduleEntries
.Where(x => x.SchedulePlanId == plan.Id)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.ToListAsync(cancellationToken);
var created = 0;
var completedTasks = 0;
var messages = new List<string>();
foreach (var task in tasks)
{
constraints.TryGetValue(task.Id, out var constraint);
var scheduledHours = entries
.Where(x => x.TeachingTaskId == task.Id)
.Sum(x => x.PeriodCount);
var remainingHours = Math.Max(0, task.WeeklyHours - scheduledHours);
if (remainingHours == 0)
{
completedTasks++;
continue;
}
while (remainingHours > 0)
{
var desiredBlock = remainingHours >= 2 ? 2 : 1;
var candidate = FindBestCandidate(
plan.Id,
task,
constraint,
desiredBlock,
activePeriods,
classrooms,
entries);
if (candidate is null && desiredBlock > 1)
{
candidate = FindBestCandidate(
plan.Id,
task,
constraint,
1,
activePeriods,
classrooms,
entries);
}
if (candidate is null) break;
db.ScheduleEntries.Add(candidate);
entries.Add(candidate);
created++;
remainingHours -= candidate.PeriodCount;
}
if (remainingHours == 0)
{
completedTasks++;
}
else
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
}
}
if (created > 0)
await db.SaveChangesAsync(cancellationToken);
return new(created, completedTasks, messages);
}
private static ScheduleEntry? FindBestCandidate(
Guid planId,
TeachingTask task,
TeachingTaskScheduleConstraint? constraint,
int periodCount,
HashSet<int> activePeriods,
IReadOnlyList<Classroom> classrooms,
IReadOnlyList<ScheduleEntry> entries)
{
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max();
var rooms = EligibleRooms(task, constraint, classrooms);
if ((constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
return null;
var candidates = new List<(ScheduleEntry Entry, int Score)>();
foreach (var day in allowedDays)
{
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
{
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
continue;
var roomOptions = constraint?.RequiresClassroom == false
? new Classroom?[] { null }
: rooms.Cast<Classroom?>().ToArray();
foreach (var room in roomOptions)
{
var proposed = new ScheduleEntry
{
SchedulePlanId = planId,
TeachingTaskId = task.Id,
TeachingTask = task,
ClassroomId = room?.Id,
Classroom = room,
DayOfWeek = day,
StartPeriod = start,
PeriodCount = periodCount,
StartWeek = task.StartWeek,
EndWeek = task.EndWeek,
WeekPattern = WeekPattern.All,
Notes = "自动排课"
};
if (entries.Any(existing =>
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
ScheduleConflictDetector.ConflictReason(existing, proposed) is not null))
continue;
var sameTaskDay = entries.Count(x =>
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
var dayLoad = entries.Count(x => x.DayOfWeek == day);
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
var score = sameTaskDay * 1000 + dayLoad * 10 + start + roomWaste / 10;
candidates.Add((proposed, score));
}
}
}
return candidates
.OrderBy(x => x.Score)
.ThenBy(x => x.Entry.DayOfWeek)
.ThenBy(x => x.Entry.StartPeriod)
.Select(x => x.Entry)
.FirstOrDefault();
}
private static IReadOnlyList<Classroom> EligibleRooms(
TeachingTask task,
TeachingTaskScheduleConstraint? constraint,
IReadOnlyList<Classroom> classrooms)
{
if (constraint?.RequiresClassroom == false) return [];
var allowedRoomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
var minimumCapacity = Math.Max(
task.Capacity,
task.Classes.Sum(x =>
x.AdministrativeClass?.Students.Count(student =>
student.Status == StudentStatus.Active) ?? 0));
return classrooms.Where(room =>
room.Capacity >= minimumCapacity &&
(!constraint?.RequiredCampusId.HasValue ?? true ||
room.Building!.CampusId == constraint.RequiredCampusId) &&
(!constraint?.RequiredBuildingId.HasValue ?? true ||
room.BuildingId == constraint.RequiredBuildingId) &&
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)))
.ToList();
}
private static int[] ParseAllowedDays(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return [1, 2, 3, 4, 5];
var days = value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(item => int.TryParse(item, out var day) ? day : 0)
.Where(day => day is >= 1 and <= 7)
.Distinct()
.Order()
.ToArray();
return days.Length == 0 ? [1, 2, 3, 4, 5] : days;
}
}
public sealed record AutomaticScheduleResult(
int CreatedEntries,
int CompletedTasks,
IReadOnlyList<string> Messages);
@@ -29,7 +29,10 @@ public static class ScheduleConflictDetector
public static string? ConflictReason(ScheduleEntry first, ScheduleEntry second)
{
if (first.ClassroomId == second.ClassroomId) return "教室";
if (first.ClassroomId.HasValue &&
second.ClassroomId.HasValue &&
first.ClassroomId == second.ClassroomId)
return "教室";
var firstTeachers = first.TeachingTask!.Teachers.Select(x => x.TeacherId).ToHashSet();
if (second.TeachingTask!.Teachers.Any(x => firstTeachers.Contains(x.TeacherId)))
return "教师";