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

普通“添加排课”新增“理论课 / 实验课”类型。
自动排课会分别补足理论学时和实验学时。
实验课只能安排到实验室、实训室、机房、语音室等场地。
发布课表时分别校验理论、实验学时;任一未排足都不能发布。
普通课表及 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
@@ -3,7 +3,6 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -110,15 +109,6 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
return Ok(tasks.Select(task =>
{
constraints.TryGetValue(task.Id, out var constraint);
var weeklyHours = task.WeeklyHours;
if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard &&
TeachingTaskHours.TryResolveRegularWeeklyHours(
task.CourseTotalHours,
task.CoursePracticeHours,
task.StartWeek,
task.EndWeek,
out var regularWeeklyHours))
weeklyHours = regularWeeklyHours;
return new
{
task.Id,
@@ -132,7 +122,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
task.Capacity,
task.StartWeek,
task.EndWeek,
WeeklyHours = weeklyHours,
task.WeeklyHours,
task.CourseTotalHours,
task.CoursePracticeHours,
task.SchedulingMode,
@@ -71,6 +71,7 @@ public sealed class SchedulesController(
{
entry.Id,
entry.TeachingTaskId,
entry.Kind,
TaskNumber = entry.TeachingTask!.TaskNumber,
TaskName = entry.TeachingTask.Name,
CourseCode = entry.TeachingTask.Course!.Code,
@@ -167,6 +168,7 @@ public sealed class SchedulesController(
Entries = source.Entries.Select(entry => new ScheduleEntry
{
TeachingTaskId = entry.TeachingTaskId,
Kind = entry.Kind,
ClassroomId = entry.ClassroomId,
DayOfWeek = entry.DayOfWeek,
StartPeriod = entry.StartPeriod,
@@ -419,6 +421,7 @@ public sealed class SchedulesController(
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
if (validation is not null) return validation;
entry.TeachingTaskId = request.TeachingTaskId;
entry.Kind = request.Kind;
entry.ClassroomId = request.ClassroomId;
entry.DayOfWeek = request.DayOfWeek;
entry.StartPeriod = request.StartPeriod;
@@ -508,34 +511,52 @@ public sealed class SchedulesController(
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
task.Course!,
task.StartWeek,
task.EndWeek,
out var requiredWeeklyHours))
var targetHours = TeachingTaskHours.TargetHours(
task.Course!,
request.Kind);
if (targetHours == 0)
return ValidationProblem(
"该课程的普通排课学时不能按授课周次整除,请先调整教学任务周次。");
if (requiredWeeklyHours == 0)
return ValidationProblem(
"该课程全部为实践学时,无需进入普通课表,请在实验管理中安排。");
var existingHours = await db.ScheduleEntries.AsNoTracking()
request.Kind == ScheduleEntryKind.Experiment
? "该课程没有实践学时,不能安排实验课。"
: "该课程没有理论学时,不能安排理论课。");
var existingEntries = await db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlanId == plan.Id &&
x.TeachingTaskId == request.TeachingTaskId &&
x.Kind == request.Kind &&
x.Id != entryId)
.SumAsync(x => x.PeriodCount, cancellationToken);
if (existingHours + request.PeriodCount > requiredWeeklyHours)
.Select(x => new
{
x.StartWeek,
x.EndWeek,
x.WeekPattern,
x.PeriodCount
})
.ToListAsync(cancellationToken);
var existingHours = existingEntries.Sum(x =>
TeachingTaskHours.ScheduledHours(
x.StartWeek,
x.EndWeek,
x.WeekPattern,
x.PeriodCount));
var proposedHours = TeachingTaskHours.ScheduledHours(
request.StartWeek,
request.EndWeek,
request.WeekPattern,
request.PeriodCount);
if (existingHours + proposedHours > targetHours)
return ValidationProblem(
$"该教学任务普通课表每周只需 {requiredWeeklyHours} 学时;" +
$"当前操作后将达到 {existingHours + request.PeriodCount} 学时," +
"实践学时请在实验管理中安排。");
$"该教学任务{(request.Kind == ScheduleEntryKind.Experiment ? "" : "")}课" +
$"共需 {targetHours} 学时;当前操作后将达到 " +
$"{existingHours + proposedHours} 学时。");
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Include(x => x.AllowedClassrooms)
.FirstOrDefaultAsync(
x => x.TeachingTaskId == request.TeachingTaskId,
cancellationToken);
var requiresClassroom = constraint?.RequiresClassroom ?? true;
var requiresClassroom = request.Kind == ScheduleEntryKind.Experiment ||
constraint?.RequiresClassroom != false;
if (requiresClassroom && !request.ClassroomId.HasValue)
return ValidationProblem("该课程需要占用教室,请选择教室。");
if (!requiresClassroom && request.ClassroomId.HasValue)
@@ -559,6 +580,10 @@ public sealed class SchedulesController(
x => x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken);
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
if (request.Kind == ScheduleEntryKind.Experiment &&
!IsExperimentRoom(classroom.RoomType))
return ValidationProblem(
$"实验课必须安排在实验室、实训室或机房;“{classroom.Name}”的场地类型为“{classroom.RoomType}”。");
if (constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId)
return ValidationProblem("所选教室不在该课程指定的校区。");
@@ -612,6 +637,7 @@ public sealed class SchedulesController(
{
SchedulePlanId = planId,
TeachingTaskId = request.TeachingTaskId,
Kind = request.Kind,
ClassroomId = request.ClassroomId,
DayOfWeek = request.DayOfWeek,
StartPeriod = request.StartPeriod,
@@ -629,6 +655,12 @@ public sealed class SchedulesController(
.Select(int.Parse)
.ToHashSet();
private static bool IsExperimentRoom(string roomType) =>
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
@@ -726,7 +758,8 @@ public sealed record ScheduleEntryRequest(
[Range(1, 30)] int StartWeek,
[Range(1, 30)] int EndWeek,
WeekPattern WeekPattern,
[MaxLength(500)] string? Notes);
[MaxLength(500)] string? Notes,
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture);
public sealed record AutomaticScheduleJobResponse(
Guid Id,
@@ -20,6 +20,7 @@ public sealed class ScheduleEntry : EntityBase
public SchedulePlan? SchedulePlan { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public ScheduleEntryKind Kind { get; set; } = ScheduleEntryKind.Lecture;
public Guid? ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public int DayOfWeek { get; set; }
@@ -129,3 +130,9 @@ public enum WeekPattern
Odd = 2,
Even = 3
}
public enum ScheduleEntryKind
{
Lecture = 1,
Experiment = 2
}
@@ -416,6 +416,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<ScheduleEntry>(entity =>
{
entity.Property(x => x.Kind)
.HasDefaultValue(ScheduleEntryKind.Lecture);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
@@ -76,6 +76,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260728_40_experiment_grade_management";
private const string AppUpdateReleasesMigration =
"20260729_41_app_update_releases";
private const string IntegratedExperimentSchedulingMigration =
"20260802_42_integrated_experiment_scheduling";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -577,6 +579,20 @@ public sealed class DevelopmentSqliteMigrator(
AppUpdateReleasesMigration,
AppUpdateReleasesStatements,
cancellationToken);
var scheduleEntryKindExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('ScheduleEntries')
WHERE name = 'Kind'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
IntegratedExperimentSchedulingMigration,
scheduleEntryKindExists
? []
: IntegratedExperimentSchedulingStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -2714,4 +2730,12 @@ public sealed class DevelopmentSqliteMigrator(
ON "ExperimentGradeItemScores" ("ExperimentGradeItemId");
"""
];
private static readonly string[] IntegratedExperimentSchedulingStatements =
[
"""
ALTER TABLE "ScheduleEntries"
ADD COLUMN "Kind" INTEGER NOT NULL DEFAULT 1;
"""
];
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class IntegratedExperimentScheduling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Kind",
table: "ScheduleEntries",
type: "int",
nullable: false,
defaultValue: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Kind",
table: "ScheduleEntries");
}
}
}
@@ -3296,6 +3296,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<int>("EndWeek")
.HasColumnType("int");
b.Property<int>("Kind")
.HasColumnType("int")
.HasDefaultValue(1);
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
@@ -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];
@@ -191,57 +191,30 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
CoursePracticeHours = x.Course.PracticeHours
})
.ToListAsync(cancellationToken);
var invalidHours = requiredTasks.FirstOrDefault(task =>
!TeachingTaskHours.TryResolveRegularWeeklyHours(
task.CourseTotalHours,
task.CoursePracticeHours,
task.StartWeek,
task.EndWeek,
out _));
if (invalidHours is not null)
{
throw new SchedulePublishValidationException(
$"{invalidHours.TaskNumber} · {invalidHours.Name} 的普通排课学时" +
"不能按授课周次整除,请先调整教学任务周次。");
}
var requiredWeeklyHours = requiredTasks
.Select(task =>
{
TeachingTaskHours.TryResolveRegularWeeklyHours(
task.CourseTotalHours,
task.CoursePracticeHours,
task.StartWeek,
task.EndWeek,
out var hours);
return new
{
Task = task,
Hours = hours
};
})
.ToList();
var scheduledHours = plan.Entries
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
var incomplete = requiredWeeklyHours.FirstOrDefault(item =>
scheduledHours.GetValueOrDefault(item.Task.Id) < item.Hours);
if (incomplete is not null)
.GroupBy(x => new { x.TeachingTaskId, x.Kind })
.ToDictionary(
group => (group.Key.TeachingTaskId, group.Key.Kind),
group => group.Sum(TeachingTaskHours.ScheduledHours));
foreach (var task in requiredTasks)
{
throw new SchedulePublishValidationException(
$"{incomplete.Task.TaskNumber} · {incomplete.Task.Name} 尚未达到每周 " +
$"{incomplete.Hours} 个普通排课学时,不能发布。");
}
var excessive = requiredWeeklyHours.FirstOrDefault(item =>
scheduledHours.GetValueOrDefault(item.Task.Id) > item.Hours);
if (excessive is not null)
{
var actualHours = scheduledHours.GetValueOrDefault(excessive.Task.Id);
throw new SchedulePublishValidationException(
$"{excessive.Task.TaskNumber} · {excessive.Task.Name} 已安排每周 " +
$"{actualHours} 学时,普通课表应为 {excessive.Hours} 学时;" +
"请删除已包含的实践学时后再发布。");
var targets = new[]
{
(Kind: ScheduleEntryKind.Lecture,
Hours: Math.Max(0, task.CourseTotalHours - task.CoursePracticeHours),
Label: "理论课"),
(Kind: ScheduleEntryKind.Experiment,
Hours: Math.Max(0, task.CoursePracticeHours),
Label: "实验课")
};
foreach (var target in targets)
{
var actual = scheduledHours.GetValueOrDefault((task.Id, target.Kind));
if (actual == target.Hours) continue;
throw new SchedulePublishValidationException(
$"{task.TaskNumber} · {task.Name} 的{target.Label}应安排 " +
$"{target.Hours} 学时,当前已安排 {actual} 学时,不能发布。");
}
}
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
@@ -275,7 +248,8 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
Fail(entry, "排课周次不在教学任务的授课周次内");
constraints.TryGetValue(entry.TeachingTaskId, out var constraint);
var requiresClassroom = constraint?.RequiresClassroom ?? true;
var requiresClassroom = entry.Kind == ScheduleEntryKind.Experiment ||
constraint?.RequiresClassroom != false;
if (requiresClassroom && entry.ClassroomId is null)
Fail(entry, "该课程需要占用教室");
if (!requiresClassroom && entry.ClassroomId is not null)
@@ -296,6 +270,9 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
{
if (classroom is null || !classroom.IsEnabled)
Fail(entry, "所选教室不存在或已停用");
if (entry.Kind == ScheduleEntryKind.Experiment &&
!IsExperimentRoom(classroom.RoomType))
Fail(entry, $"实验课不能安排在“{classroom.RoomType}”类型的场地");
if (constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId)
Fail(entry, "所选教室不在指定校区");
@@ -328,6 +305,12 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
.Select(int.Parse)
.ToHashSet();
private static bool IsExperimentRoom(string roomType) =>
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
[DoesNotReturn]
private static void Fail(ScheduleEntry entry, string message)
{
@@ -16,10 +16,35 @@ public static class TeachingTaskHours
public static int TargetHours(
Course course,
TeachingTaskSchedulingMode schedulingMode) =>
schedulingMode == TeachingTaskSchedulingMode.Flexible
? course.TotalHours
course.TotalHours;
public static int TargetHours(Course course, ScheduleEntryKind kind) =>
kind == ScheduleEntryKind.Experiment
? Math.Max(0, course.PracticeHours)
: RegularScheduleHours(course);
public static int ScheduledHours(ScheduleEntry entry) =>
ScheduledHours(
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.PeriodCount);
public static int ScheduledHours(
int startWeek,
int endWeek,
WeekPattern weekPattern,
int periodCount)
{
if (endWeek < startWeek || periodCount <= 0) return 0;
var occurrences = Enumerable.Range(startWeek, endWeek - startWeek + 1)
.Count(week =>
weekPattern == WeekPattern.All ||
weekPattern == WeekPattern.Odd && week % 2 == 1 ||
weekPattern == WeekPattern.Even && week % 2 == 0);
return occurrences * periodCount;
}
public static bool TryResolveRegularWeeklyHours(
Course course,
int startWeek,
@@ -62,14 +87,8 @@ public static class TeachingTaskHours
if (schedulingMode == TeachingTaskSchedulingMode.Standard)
{
if (targetHours == 0)
{
return $"课程“{course.Name}”的 {course.TotalHours} 学时均为实践学时," +
"无需进入普通课表;请将授课方式设为“非排时课程”,并在实验管理中安排。";
}
return $"课程“{course.Name}”总学时为 {course.TotalHours},其中实践学时 " +
$"{course.PracticeHours},普通课表应安排 {targetHours} 学时;当前第 " +
$"{course.PracticeHours};理论课和实验课均应进入课表。当前第 " +
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
$"{plannedHours} 学时。请调整授课周次或周学时。";
}
@@ -106,7 +106,14 @@ public sealed class TimetableDataService(AppDbContext db)
false,
null,
null,
x.UpdatedAt))
x.UpdatedAt,
false,
null,
null,
null,
null,
null,
x.Kind))
.ToListAsync(cancellationToken);
}
@@ -751,7 +758,8 @@ public sealed record TimetableEntryDto(
string? ExperimentProjectCode = null,
string? ExperimentProjectName = null,
DateOnly? ExperimentDate = null,
ExperimentArrangementMode? ExperimentArrangementMode = null);
ExperimentArrangementMode? ExperimentArrangementMode = null,
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture);
public sealed record FlexibleCourseDto(
Guid Id,
@@ -173,7 +173,7 @@ public static class TimetableExcelExporter
$"{Location(entry)}\n" +
$"{entry.ExamDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
: $"{entry.CourseName}\n" +
: $"{(entry.Kind == Domain.Academic.ScheduleEntryKind.Experiment ? "" : "")}{entry.CourseName}\n" +
$"{string.Join('、', entry.TeacherNames)}\n" +
$"{Location(entry)}\n" +
$"{entry.StartWeek}-{entry.EndWeek} 周";