Files
Academic-Affairs-System/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs
T
biss f0f59419d5 实验课现在直接进入普通排课流程,不再要求先到实验排课模块逐个安排。
普通“添加排课”新增“理论课 / 实验课”类型。
自动排课会分别补足理论学时和实验学时。
实验课只能安排到实验室、实训室、机房、语音室等场地。
发布课表时分别校验理论、实验学时;任一未排足都不能发布。
普通课表及 Excel 导出会标注“实验课”。
历史排课保持不变,迁移后默认识别为理论课;后续新建或修订版本时再补充实验课。
2026-08-02 16:54:41 +08:00

329 lines
13 KiB
C#

using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public sealed class AutomaticScheduleGenerator(AppDbContext db)
{
public Task<AutomaticScheduleResult> GenerateAsync(
SchedulePlan plan,
CancellationToken cancellationToken) =>
GenerateAsync(plan, null, true, cancellationToken);
public async Task<AutomaticScheduleResult> GenerateAsync(
SchedulePlan plan,
Func<AutomaticScheduleProgress, CancellationToken, Task>? reportProgress,
bool saveChanges,
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 &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Include(x => x.Teachers)
.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)
.ToListAsync(cancellationToken);
var taskIds = tasks.Select(task => task.Id).ToArray();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.WhereIn(taskIds, x => 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 processedTasks = 0;
var messages = new List<string>();
if (reportProgress is not null)
{
await reportProgress(
new(tasks.Count, 0, 0, 0),
cancellationToken);
}
foreach (var task in tasks)
{
cancellationToken.ThrowIfCancellationRequested();
constraints.TryGetValue(task.Id, out var constraint);
var taskCompleted = true;
foreach (var kind in new[]
{
ScheduleEntryKind.Lecture,
ScheduleEntryKind.Experiment
})
{
var targetHours = TeachingTaskHours.TargetHours(task.Course!, kind);
var scheduledHours = entries
.Where(x =>
x.TeachingTaskId == task.Id &&
x.Kind == kind)
.Sum(TeachingTaskHours.ScheduledHours);
var label = kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
if (scheduledHours > targetHours)
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," +
$"超过课程规定的 {targetHours} 学时,请先删除多余课次。");
taskCompleted = false;
continue;
}
var remainingHours = targetHours - scheduledHours;
while (remainingHours > 0)
{
var candidate = FindBestCandidateForHours(
plan.Id,
task,
constraint,
kind,
remainingHours,
activePeriods,
classrooms,
entries,
cancellationToken);
if (candidate is null) break;
db.ScheduleEntries.Add(candidate);
entries.Add(candidate);
created++;
remainingHours -= TeachingTaskHours.ScheduledHours(candidate);
}
if (remainingHours > 0)
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 个{label}学时无法安排," +
(kind == ScheduleEntryKind.Experiment
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
: "请检查教师/班级冲突或场地与时间约束。"));
taskCompleted = false;
}
}
if (taskCompleted) completedTasks++;
processedTasks++;
if (reportProgress is not null)
{
await reportProgress(
new(tasks.Count, processedTasks, created, completedTasks),
cancellationToken);
}
}
if (created > 0 && saveChanges)
await db.SaveChangesAsync(cancellationToken);
return new(
created,
completedTasks,
messages,
tasks.Count,
processedTasks);
}
private static ScheduleEntry? FindBestCandidateForHours(
Guid planId,
TeachingTask task,
TeachingTaskScheduleConstraint? constraint,
ScheduleEntryKind kind,
int remainingHours,
HashSet<int> activePeriods,
IReadOnlyList<Classroom> classrooms,
IReadOnlyList<ScheduleEntry> entries,
CancellationToken cancellationToken)
{
var weekCount = task.EndWeek - task.StartWeek + 1;
foreach (var periodCount in remainingHours >= 2
? new[] { 2, 1 }
: new[] { 1 })
{
var maxOccurrences = Math.Min(
weekCount,
remainingHours / periodCount);
for (var occurrences = maxOccurrences; occurrences >= 1; occurrences--)
{
var candidate = FindBestCandidate(
planId,
task,
constraint,
kind,
periodCount,
occurrences,
activePeriods,
classrooms,
entries,
cancellationToken);
if (candidate is not null) return candidate;
}
}
return null;
}
private static ScheduleEntry? FindBestCandidate(
Guid planId,
TeachingTask task,
TeachingTaskScheduleConstraint? constraint,
ScheduleEntryKind kind,
int periodCount,
int occurrenceCount,
HashSet<int> activePeriods,
IReadOnlyList<Classroom> classrooms,
IReadOnlyList<ScheduleEntry> entries,
CancellationToken cancellationToken)
{
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max();
var rooms = EligibleRooms(task, constraint, kind, classrooms);
if ((constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
return null;
var candidates = new List<(ScheduleEntry Entry, int Score)>();
for (var startWeek = task.StartWeek;
startWeek + occurrenceCount - 1 <= task.EndWeek;
startWeek++)
{
foreach (var day in allowedDays)
{
cancellationToken.ThrowIfCancellationRequested();
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
{
cancellationToken.ThrowIfCancellationRequested();
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
continue;
var roomOptions = kind != ScheduleEntryKind.Experiment &&
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,
Kind = kind,
ClassroomId = room?.Id,
DayOfWeek = day,
StartPeriod = start,
PeriodCount = periodCount,
StartWeek = startWeek,
EndWeek = startWeek + occurrenceCount - 1,
WeekPattern = WeekPattern.All,
Notes = kind == ScheduleEntryKind.Experiment
? "自动排课 · 实验课"
: "自动排课 · 理论课"
};
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 + startWeek;
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,
ScheduleEntryKind kind,
IReadOnlyList<Classroom> classrooms)
{
if (kind != ScheduleEntryKind.Experiment &&
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 is not Guid requiredCampusId ||
room.Building!.CampusId == requiredCampusId) &&
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
room.BuildingId == requiredBuildingId) &&
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
(kind != ScheduleEntryKind.Experiment || IsExperimentRoom(room.RoomType)))
.ToList();
}
private static bool IsExperimentRoom(string roomType) =>
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
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,
int TotalTasks = 0,
int ProcessedTasks = 0);
public sealed record AutomaticScheduleProgress(
int TotalTasks,
int ProcessedTasks,
int CreatedEntries,
int CompletedTasks);