实验课现在直接进入普通排课流程,不再要求先到实验排课模块逐个安排。

普通“添加排课”新增“理论课 / 实验课”类型。
自动排课会分别补足理论学时和实验学时。
实验课只能安排到实验室、实训室、机房、语音室等场地。
发布课表时分别校验理论、实验学时;任一未排足都不能发布。
普通课表及 Excel 导出会标注“实验课”。
历史排课保持不变,迁移后默认识别为理论课;后续新建或修订版本时再补充实验课。
This commit is contained in:
2026-08-02 16:54:41 +08:00 Unverified
parent 9d525826ce
commit f0f59419d5
22 changed files with 6488 additions and 266 deletions
@@ -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];