自动排课:保留手工安排,按教师、行政班、周次、节次、容量和场地约束补排。
课程约束:可限定校区、教学楼、指定教室、允许上课日、最早/最晚节次。 无教室课程:教室可为空,但仍校验教师和班级冲突。 作息维护:按学期维护节次、上下课时间和启用状态。 发布保护:发布前重新检查全部约束,并阻止学时未排满的课表发布。 工作台升级:增加“排课规则与作息”“自动排课”入口,自动生成后仍支持手工微调。 同时兼容 SQLite 和 MySQL,已生成正式迁移。
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user