自动排课:保留手工安排,按教师、行政班、周次、节次、容量和场地约束补排。

课程约束:可限定校区、教学楼、指定教室、允许上课日、最早/最晚节次。
无教室课程:教室可为空,但仍校验教师和班级冲突。
作息维护:按学期维护节次、上下课时间和启用状态。
发布保护:发布前重新检查全部约束,并阻止学时未排满的课表发布。
工作台升级:增加“排课规则与作息”“自动排课”入口,自动生成后仍支持手工微调。
同时兼容 SQLite 和 MySQL,已生成正式迁移。
This commit is contained in:
2026-07-24 19:19:18 +08:00 Unverified
parent d7cf3f9e76
commit 6538b6080a
19 changed files with 4344 additions and 37 deletions
@@ -349,6 +349,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
.Select(x => new
{
x.Id, x.Code, x.Name, x.BuildingId,
CampusId = x.Building!.CampusId,
BuildingName = x.Building!.Name,
CampusName = x.Building.Campus!.Name,
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder
@@ -376,7 +376,7 @@ public sealed class CourseSelectionsController(
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.Classroom!.Name))
entry.Classroom == null ? "不占用教室" : entry.Classroom.Name))
.ToList()))
.ToListAsync(cancellationToken);
@@ -0,0 +1,220 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
[Route("api/schedules")]
public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
{
[HttpGet("time-slots")]
public async Task<ActionResult> GetTimeSlots(
Guid academicTermId,
CancellationToken cancellationToken) =>
Ok(await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == academicTermId)
.OrderBy(x => x.PeriodNumber)
.Select(x => new
{
x.Id,
x.PeriodNumber,
x.Name,
StartsAt = x.StartsAt.ToString("HH:mm"),
EndsAt = x.EndsAt.ToString("HH:mm"),
x.IsEnabled
})
.ToListAsync(cancellationToken));
[HttpPut("time-slots/{academicTermId:guid}")]
public async Task<ActionResult> ReplaceTimeSlots(
Guid academicTermId,
IReadOnlyList<ScheduleTimeSlotRequest> requests,
CancellationToken cancellationToken)
{
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == academicTermId && x.IsEnabled,
cancellationToken))
return ValidationProblem("学期不存在或已停用。");
if (requests.Count == 0)
return ValidationProblem("上课时间表至少需要一个节次。");
if (requests.Select(x => x.PeriodNumber).Distinct().Count() != requests.Count)
return ValidationProblem("节次编号不能重复。");
if (requests.Any(x => x.StartsAt >= x.EndsAt))
return ValidationProblem("每个节次的上课时间必须早于下课时间。");
var ordered = requests.OrderBy(x => x.StartsAt).ToList();
if (ordered.Zip(ordered.Skip(1)).Any(pair => pair.First.EndsAt > pair.Second.StartsAt))
return ValidationProblem("上课时间段不能相互重叠。");
var existing = await db.ScheduleTimeSlots
.Where(x => x.AcademicTermId == academicTermId)
.ToListAsync(cancellationToken);
db.ScheduleTimeSlots.RemoveRange(existing);
db.ScheduleTimeSlots.AddRange(requests.Select(request => new ScheduleTimeSlot
{
AcademicTermId = academicTermId,
PeriodNumber = request.PeriodNumber,
Name = request.Name.Trim(),
StartsAt = request.StartsAt,
EndsAt = request.EndsAt,
IsEnabled = request.IsEnabled
}));
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("constraints")]
public async Task<ActionResult> GetConstraints(
Guid academicTermId,
CancellationToken cancellationToken)
{
var tasks = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.AcademicTermId == academicTermId &&
x.Status == TeachingTaskStatus.Published)
.OrderBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
CourseName = x.Course!.Name,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.Capacity,
x.WeeklyHours
})
.ToListAsync(cancellationToken);
var taskIds = tasks.Select(x => x.Id).ToList();
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => taskIds.Contains(x.TeachingTaskId))
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
return Ok(tasks.Select(task =>
{
constraints.TryGetValue(task.Id, out var constraint);
return new
{
task.Id,
task.TaskNumber,
task.Name,
task.CourseName,
task.TeacherNames,
task.Capacity,
task.WeeklyHours,
RequiresClassroom = constraint?.RequiresClassroom ?? true,
constraint?.RequiredCampusId,
constraint?.RequiredBuildingId,
AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks),
constraint?.EarliestPeriod,
constraint?.LatestPeriod,
AllowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId) ?? []
};
}));
}
[HttpPut("constraints/{teachingTaskId:guid}")]
public async Task<ActionResult> SaveConstraint(
Guid teachingTaskId,
TeachingTaskScheduleConstraintRequest request,
CancellationToken cancellationToken)
{
if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7))
return ValidationProblem("允许上课日必须位于星期一至星期日。");
if (request.EarliestPeriod.HasValue &&
request.LatestPeriod.HasValue &&
request.EarliestPeriod > request.LatestPeriod)
return ValidationProblem("最早节次不能晚于最晚节次。");
var task = await db.TeachingTasks
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null) return NotFound();
Building? building = null;
if (request.RequiredBuildingId.HasValue)
{
building = await db.Buildings.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == request.RequiredBuildingId && x.IsEnabled,
cancellationToken);
if (building is null) return ValidationProblem("指定教学楼不存在或已停用。");
if (request.RequiredCampusId.HasValue &&
building.CampusId != request.RequiredCampusId)
return ValidationProblem("指定教学楼不属于所选校区。");
}
if (request.RequiredCampusId.HasValue &&
!await db.Campuses.AnyAsync(
x => x.Id == request.RequiredCampusId && x.IsEnabled,
cancellationToken))
return ValidationProblem("指定校区不存在或已停用。");
var allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => request.AllowedClassroomIds.Contains(x.Id) && x.IsEnabled)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count())
return ValidationProblem("部分指定教室不存在或已停用。");
if (building is not null && allowedRooms.Any(x => x.BuildingId != building.Id))
return ValidationProblem("指定教室必须位于所选教学楼。");
if (request.RequiredCampusId.HasValue &&
allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId))
return ValidationProblem("指定教室必须位于所选校区。");
var constraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (constraint is null)
{
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = teachingTaskId };
db.TeachingTaskScheduleConstraints.Add(constraint);
}
constraint.RequiresClassroom = request.RequiresClassroom;
constraint.RequiredCampusId = request.RequiresClassroom
? request.RequiredCampusId
: null;
constraint.RequiredBuildingId = request.RequiresClassroom
? request.RequiredBuildingId
: null;
constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0
? null
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
constraint.EarliestPeriod = request.EarliestPeriod;
constraint.LatestPeriod = request.LatestPeriod;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
constraint.AllowedClassrooms = request.RequiresClassroom
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
: [];
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private static int[] ParseDays(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(int.Parse)
.ToArray();
}
public sealed record ScheduleTimeSlotRequest(
[Range(1, 30)] int PeriodNumber,
[Required, MaxLength(40)] string Name,
TimeOnly StartsAt,
TimeOnly EndsAt,
bool IsEnabled);
public sealed record TeachingTaskScheduleConstraintRequest(
bool RequiresClassroom,
Guid? RequiredCampusId,
Guid? RequiredBuildingId,
IReadOnlyList<Guid> AllowedClassroomIds,
IReadOnlyList<int> AllowedDayOfWeeks,
[Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod);
+119 -13
View File
@@ -12,7 +12,9 @@ namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ManagementRoles)]
[Route("api/schedules")]
public sealed class SchedulesController(AppDbContext db) : ControllerBase
public sealed class SchedulesController(
AppDbContext db,
AutomaticScheduleGenerator scheduleGenerator) : ControllerBase
{
private const string ManagementRoles =
SystemRoles.SuperAdmin + "," +
@@ -76,9 +78,15 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
ClassNames = entry.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
entry.ClassroomId,
ClassroomName = entry.Classroom!.Name,
BuildingName = entry.Classroom.Building!.Name,
CampusName = entry.Classroom.Building.Campus!.Name,
ClassroomName = entry.Classroom == null
? null
: entry.Classroom.Name,
BuildingName = entry.Classroom == null
? null
: entry.Classroom.Building!.Name,
CampusName = entry.Classroom == null
? null
: entry.Classroom.Building!.Campus!.Name,
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
@@ -194,6 +202,41 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
if (plan.Entries.Count == 0)
return ConflictProblem("排课版本中至少需要一条课表安排。");
foreach (var entry in plan.Entries)
{
var validation = await ValidateEntryAsync(
plan,
entry.Id,
new ScheduleEntryRequest(
entry.TeachingTaskId,
entry.ClassroomId,
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.Notes),
cancellationToken);
if (validation is not null) return validation;
}
var requiredTasks = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours })
.ToListAsync(cancellationToken);
var scheduledHours = plan.Entries
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
var incomplete = requiredTasks.FirstOrDefault(task =>
!scheduledHours.TryGetValue(task.Id, out var hours) ||
hours < task.WeeklyHours);
if (incomplete is not null)
return ConflictProblem(
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 {incomplete.WeeklyHours} 学时,不能发布。");
var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList());
if (conflict is not null) return ConflictProblem(conflict);
@@ -227,6 +270,17 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
return await SaveAsync(entry.Id, true, cancellationToken);
}
[HttpPost("plans/{planId:guid}/auto-schedule")]
public async Task<ActionResult> AutoSchedule(
Guid planId,
CancellationToken cancellationToken)
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var result = await scheduleGenerator.GenerateAsync(plan, cancellationToken);
return Ok(result);
}
[HttpPut("plans/{planId:guid}/entries/{entryId:guid}")]
public async Task<ActionResult> UpdateEntry(
Guid planId,
@@ -286,8 +340,15 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
{
if (request.StartWeek > request.EndWeek)
return ValidationProblem("开始周不能晚于结束周。");
if (request.StartPeriod + request.PeriodCount - 1 > 12)
return ValidationProblem("结束节次不能超过第 12 节。");
var activePeriods = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
.Select(x => x.PeriodNumber)
.ToListAsync(cancellationToken);
if (activePeriods.Count == 0)
return ValidationProblem("请先维护该学期的上课时间表。");
if (Enumerable.Range(request.StartPeriod, request.PeriodCount)
.Any(period => !activePeriods.Contains(period)))
return ValidationProblem("所选节次包含未启用或不存在的上课时间。");
var task = await db.TeachingTasks.AsNoTracking()
.Include(x => x.Teachers)
@@ -302,17 +363,55 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
var classroom = await db.Classrooms.AsNoTracking()
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Include(x => x.AllowedClassrooms)
.FirstOrDefaultAsync(
x => x.Id == request.ClassroomId && x.IsEnabled,
x => x.TeachingTaskId == request.TeachingTaskId,
cancellationToken);
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
var requiresClassroom = constraint?.RequiresClassroom ?? true;
if (requiresClassroom && !request.ClassroomId.HasValue)
return ValidationProblem("该课程需要占用教室,请选择教室。");
if (!requiresClassroom && request.ClassroomId.HasValue)
return ValidationProblem("该课程已设置为不占用教室,请清空教室。");
var allowedDays = ParseDays(constraint?.AllowedDayOfWeeks);
if (allowedDays.Count > 0 && !allowedDays.Contains(request.DayOfWeek))
return ValidationProblem("所选星期不在该教学任务允许的上课日内。");
if (constraint?.EarliestPeriod is int earliest &&
request.StartPeriod < earliest)
return ValidationProblem($"该教学任务最早只能从第 {earliest} 节开始。");
if (constraint?.LatestPeriod is int latest &&
request.StartPeriod + request.PeriodCount - 1 > latest)
return ValidationProblem($"该教学任务最晚必须在第 {latest} 节结束。");
Classroom? classroom = null;
if (request.ClassroomId.HasValue)
{
classroom = await db.Classrooms.AsNoTracking()
.Include(x => x.Building)
.FirstOrDefaultAsync(
x => x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken);
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
if (constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId)
return ValidationProblem("所选教室不在该课程指定的校区。");
if (constraint?.RequiredBuildingId is Guid buildingId &&
classroom.BuildingId != buildingId)
return ValidationProblem("所选教室不在该课程指定的教学楼。");
var allowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (allowedClassroomIds.Count > 0 &&
!allowedClassroomIds.Contains(classroom.Id))
return ValidationProblem("所选教室不在该课程指定的教室范围内。");
}
var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student =>
student.Status == StudentStatus.Active));
if (studentCount > classroom.Capacity)
var requiredCapacity = Math.Max(task.Capacity, studentCount);
if (classroom is not null && requiredCapacity > classroom.Capacity)
return ConflictProblem(
$"教室容量不足:教学班有 {studentCount} 名学生,教室仅容纳 {classroom.Capacity} 人。");
$"教室容量不足:教学任务容量为 {requiredCapacity} 人,教室仅容纳 {classroom.Capacity} 人。");
var candidates = await db.ScheduleEntries.AsNoTracking()
.Where(x =>
@@ -356,6 +455,13 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase
Notes = Normalize(request.Notes)
};
private static HashSet<int> ParseDays(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(int.Parse)
.ToHashSet();
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
@@ -396,9 +502,9 @@ public sealed record CloneSchedulePlanRequest(
public sealed record ScheduleEntryRequest(
Guid TeachingTaskId,
Guid ClassroomId,
Guid? ClassroomId,
[Range(1, 7)] int DayOfWeek,
[Range(1, 12)] int StartPeriod,
[Range(1, 30)] int StartPeriod,
[Range(1, 6)] int PeriodCount,
[Range(1, 30)] int StartWeek,
[Range(1, 30)] int EndWeek,