实验课现在直接进入普通排课流程,不再要求先到实验排课模块逐个安排。
普通“添加排课”新增“理论课 / 实验课”类型。 自动排课会分别补足理论学时和实验学时。 实验课只能安排到实验室、实训室、机房、语音室等场地。 发布课表时分别校验理论、实验学时;任一未排足都不能发布。 普通课表及 Excel 导出会标注“实验课”。 历史排课保持不变,迁移后默认识别为理论课;后续新建或修订版本时再补充实验课。
This commit is contained in:
@@ -74,97 +74,62 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
constraints.TryGetValue(task.Id, out var constraint);
|
||||
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||
task.Course!,
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
out var requiredWeeklyHours))
|
||||
var taskCompleted = true;
|
||||
foreach (var kind in new[]
|
||||
{
|
||||
ScheduleEntryKind.Lecture,
|
||||
ScheduleEntryKind.Experiment
|
||||
})
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 的普通排课学时不能按授课周次整除,请调整教学任务周次。");
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
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)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," +
|
||||
$"超过课程规定的 {targetHours} 学时,请先删除多余课次。");
|
||||
taskCompleted = false;
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var scheduledHours = entries
|
||||
.Where(x => x.TeachingTaskId == task.Id)
|
||||
.Sum(x => x.PeriodCount);
|
||||
if (scheduledHours > requiredWeeklyHours)
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 已安排每周 {scheduledHours} 学时," +
|
||||
$"普通课表只需 {requiredWeeklyHours} 学时;请删除已包含的实践学时。");
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
var remainingHours = targetHours - scheduledHours;
|
||||
while (remainingHours > 0)
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var remainingHours = requiredWeeklyHours - 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(
|
||||
var candidate = FindBestCandidateForHours(
|
||||
plan.Id,
|
||||
task,
|
||||
constraint,
|
||||
1,
|
||||
kind,
|
||||
remainingHours,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries,
|
||||
cancellationToken);
|
||||
if (candidate is null) break;
|
||||
|
||||
db.ScheduleEntries.Add(candidate);
|
||||
entries.Add(candidate);
|
||||
created++;
|
||||
remainingHours -= TeachingTaskHours.ScheduledHours(candidate);
|
||||
}
|
||||
if (candidate is null) break;
|
||||
|
||||
db.ScheduleEntries.Add(candidate);
|
||||
entries.Add(candidate);
|
||||
created++;
|
||||
remainingHours -= candidate.PeriodCount;
|
||||
if (remainingHours > 0)
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 个{label}学时无法安排," +
|
||||
(kind == ScheduleEntryKind.Experiment
|
||||
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
|
||||
: "请检查教师/班级冲突或场地与时间约束。"));
|
||||
taskCompleted = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (remainingHours == 0)
|
||||
{
|
||||
completedTasks++;
|
||||
}
|
||||
else
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
|
||||
}
|
||||
if (taskCompleted) completedTasks++;
|
||||
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
@@ -185,11 +150,51 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
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,
|
||||
@@ -198,50 +203,60 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
|
||||
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
|
||||
var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max();
|
||||
var rooms = EligibleRooms(task, constraint, classrooms);
|
||||
var rooms = EligibleRooms(task, constraint, kind, 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 startWeek = task.StartWeek;
|
||||
startWeek + occurrenceCount - 1 <= task.EndWeek;
|
||||
startWeek++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
||||
foreach (var day in allowedDays)
|
||||
{
|
||||
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)
|
||||
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
|
||||
{
|
||||
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))
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
||||
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));
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,9 +271,11 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
private static IReadOnlyList<Classroom> EligibleRooms(
|
||||
TeachingTask task,
|
||||
TeachingTaskScheduleConstraint? constraint,
|
||||
ScheduleEntryKind kind,
|
||||
IReadOnlyList<Classroom> classrooms)
|
||||
{
|
||||
if (constraint?.RequiresClassroom == false) return [];
|
||||
if (kind != ScheduleEntryKind.Experiment &&
|
||||
constraint?.RequiresClassroom == false) return [];
|
||||
var allowedRoomIds = constraint?.AllowedClassrooms
|
||||
.Select(x => x.ClassroomId)
|
||||
.ToHashSet() ?? [];
|
||||
@@ -273,10 +290,17 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
room.Building!.CampusId == requiredCampusId) &&
|
||||
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
|
||||
room.BuildingId == requiredBuildingId) &&
|
||||
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)))
|
||||
(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];
|
||||
|
||||
Reference in New Issue
Block a user