270 lines
10 KiB
C#
270 lines
10 KiB
C#
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 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)
|
|
.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 scheduledHours = entries
|
|
.Where(x => x.TeachingTaskId == task.Id)
|
|
.Sum(x => x.PeriodCount);
|
|
var remainingHours = Math.Max(0, task.WeeklyHours - scheduledHours);
|
|
if (remainingHours == 0)
|
|
{
|
|
completedTasks++;
|
|
processedTasks++;
|
|
if (reportProgress is not null)
|
|
{
|
|
await reportProgress(
|
|
new(tasks.Count, processedTasks, created, completedTasks),
|
|
cancellationToken);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
while (remainingHours > 0)
|
|
{
|
|
var desiredBlock = remainingHours >= 2 ? 2 : 1;
|
|
var candidate = FindBestCandidate(
|
|
plan.Id,
|
|
task,
|
|
constraint,
|
|
desiredBlock,
|
|
activePeriods,
|
|
classrooms,
|
|
entries,
|
|
cancellationToken);
|
|
if (candidate is null && desiredBlock > 1)
|
|
{
|
|
candidate = FindBestCandidate(
|
|
plan.Id,
|
|
task,
|
|
constraint,
|
|
1,
|
|
activePeriods,
|
|
classrooms,
|
|
entries,
|
|
cancellationToken);
|
|
}
|
|
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} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
|
|
}
|
|
|
|
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? FindBestCandidate(
|
|
Guid planId,
|
|
TeachingTask task,
|
|
TeachingTaskScheduleConstraint? constraint,
|
|
int periodCount,
|
|
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, classrooms);
|
|
if ((constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
|
|
return null;
|
|
|
|
var candidates = new List<(ScheduleEntry Entry, int Score)>();
|
|
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 = 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,
|
|
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 is not Guid requiredCampusId ||
|
|
room.Building!.CampusId == requiredCampusId) &&
|
|
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
|
room.BuildingId == 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,
|
|
int TotalTasks = 0,
|
|
int ProcessedTasks = 0);
|
|
|
|
public sealed record AutomaticScheduleProgress(
|
|
int TotalTasks,
|
|
int ProcessedTasks,
|
|
int CreatedEntries,
|
|
int CompletedTasks);
|