328 lines
13 KiB
C#
328 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 ||
|
|
TeachingVenueNatureRules.SupportsExperiment(room.TeachingVenueNature)) &&
|
|
(kind != ScheduleEntryKind.Experiment || constraint is null ||
|
|
constraint.AllowedExperimentVenueNatures == 0 ||
|
|
(room.TeachingVenueNature & constraint.AllowedExperimentVenueNatures) != 0))
|
|
.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,
|
|
int TotalTasks = 0,
|
|
int ProcessedTasks = 0);
|
|
|
|
public sealed record AutomaticScheduleProgress(
|
|
int TotalTasks,
|
|
int ProcessedTasks,
|
|
int CreatedEntries,
|
|
int CompletedTasks);
|