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

课程约束:可限定校区、教学楼、指定教室、允许上课日、最早/最晚节次。
无教室课程:教室可为空,但仍校验教师和班级冲突。
作息维护:按学期维护节次、上下课时间和启用状态。
发布保护:发布前重新检查全部约束,并阻止学时未排满的课表发布。
工作台升级:增加“排课规则与作息”“自动排课”入口,自动生成后仍支持手工微调。
同时兼容 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
@@ -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);