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 GenerateAsync( SchedulePlan plan, CancellationToken cancellationToken) => GenerateAsync(plan, null, true, cancellationToken); public async Task GenerateAsync( SchedulePlan plan, Func? 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(); 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 activePeriods, IReadOnlyList classrooms, IReadOnlyList 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 activePeriods, IReadOnlyList classrooms, IReadOnlyList 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().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 EligibleRooms( TeachingTask task, TeachingTaskScheduleConstraint? constraint, ScheduleEntryKind kind, IReadOnlyList 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 Messages, int TotalTasks = 0, int ProcessedTasks = 0); public sealed record AutomaticScheduleProgress( int TotalTasks, int ProcessedTasks, int CreatedEntries, int CompletedTasks);