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

课程约束:可限定校区、教学楼、指定教室、允许上课日、最早/最晚节次。
无教室课程:教室可为空,但仍校验教师和班级冲突。
作息维护:按学期维护节次、上下课时间和启用状态。
发布保护:发布前重新检查全部约束,并阻止学时未排满的课表发布。
工作台升级:增加“排课规则与作息”“自动排课”入口,自动生成后仍支持手工微调。
同时兼容 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,
@@ -20,7 +20,7 @@ public sealed class ScheduleEntry : EntityBase
public SchedulePlan? SchedulePlan { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid ClassroomId { get; set; }
public Guid? ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public int DayOfWeek { get; set; }
public int StartPeriod { get; set; }
@@ -31,6 +31,40 @@ public sealed class ScheduleEntry : EntityBase
public string? Notes { get; set; }
}
public sealed class ScheduleTimeSlot : EntityBase
{
public Guid AcademicTermId { get; set; }
public AcademicTerm? AcademicTerm { get; set; }
public int PeriodNumber { get; set; }
public required string Name { get; set; }
public TimeOnly StartsAt { get; set; }
public TimeOnly EndsAt { get; set; }
public bool IsEnabled { get; set; } = true;
}
public sealed class TeachingTaskScheduleConstraint : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public bool RequiresClassroom { get; set; } = true;
public Guid? RequiredCampusId { get; set; }
public Campus? RequiredCampus { get; set; }
public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; }
public string? AllowedDayOfWeeks { get; set; }
public int? EarliestPeriod { get; set; }
public int? LatestPeriod { get; set; }
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
}
public sealed class TeachingTaskAllowedClassroom
{
public Guid TeachingTaskScheduleConstraintId { get; set; }
public TeachingTaskScheduleConstraint? TeachingTaskScheduleConstraint { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
}
public enum SchedulePlanStatus
{
Draft = 1,
@@ -29,6 +29,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>();
public DbSet<TeachingTaskScheduleConstraint> TeachingTaskScheduleConstraints =>
Set<TeachingTaskScheduleConstraint>();
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
Set<TeachingTaskAllowedClassroom>();
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>();
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
@@ -293,6 +298,51 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom)
.WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<ScheduleTimeSlot>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(40);
entity.HasIndex(x => new { x.AcademicTermId, x.PeriodNumber }).IsUnique();
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<TeachingTaskScheduleConstraint>(entity =>
{
entity.Property(x => x.AllowedDayOfWeeks).HasMaxLength(20);
entity.HasIndex(x => x.TeachingTaskId).IsUnique();
entity.HasOne(x => x.TeachingTask)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.RequiredCampus)
.WithMany()
.HasForeignKey(x => x.RequiredCampusId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.RequiredBuilding)
.WithMany()
.HasForeignKey(x => x.RequiredBuildingId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskAllowedClassroom>(entity =>
{
entity.HasKey(x => new
{
x.TeachingTaskScheduleConstraintId,
x.ClassroomId
});
entity.HasOne(x => x.TeachingTaskScheduleConstraint)
.WithMany(x => x.AllowedClassrooms)
.HasForeignKey(x => x.TeachingTaskScheduleConstraintId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Classroom)
.WithMany()
.HasForeignKey(x => x.ClassroomId)
@@ -19,6 +19,8 @@ public sealed class DevelopmentSqliteMigrator(
private const string DegreeAwardsMigration = "20260724_11_degree_awards";
private const string GraduationClearanceMigration = "20260724_12_graduation_clearance";
private const string CourseCategoriesMigration = "20260724_13_course_categories";
private const string SchedulingOptimizationMigration =
"20260724_14_scheduling_optimization";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -107,6 +109,18 @@ public sealed class DevelopmentSqliteMigrator(
"ALTER TABLE", StringComparison.OrdinalIgnoreCase))
: CourseCategoriesStatements,
cancellationToken);
var schedulingOptimizationExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'ScheduleTimeSlots'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
SchedulingOptimizationMigration,
schedulingOptimizationExists ? [] : SchedulingOptimizationStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -814,4 +828,129 @@ public sealed class DevelopmentSqliteMigrator(
ON "Courses" ("CourseCategoryId");
"""
];
private static readonly string[] SchedulingOptimizationStatements =
[
"""
CREATE TABLE "ScheduleEntries_New" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ScheduleEntries" PRIMARY KEY,
"SchedulePlanId" TEXT NOT NULL,
"TeachingTaskId" TEXT NOT NULL,
"ClassroomId" TEXT NULL,
"DayOfWeek" INTEGER NOT NULL,
"StartPeriod" INTEGER NOT NULL,
"PeriodCount" INTEGER NOT NULL,
"StartWeek" INTEGER NOT NULL,
"EndWeek" INTEGER NOT NULL,
"WeekPattern" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ScheduleEntries_SchedulePlans_SchedulePlanId"
FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ScheduleEntries_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ScheduleEntries_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
ON DELETE SET NULL
);
""",
"""
INSERT INTO "ScheduleEntries_New"
SELECT "Id", "SchedulePlanId", "TeachingTaskId", "ClassroomId",
"DayOfWeek", "StartPeriod", "PeriodCount", "StartWeek", "EndWeek",
"WeekPattern", "Notes", "CreatedAt", "UpdatedAt"
FROM "ScheduleEntries";
""",
"""DROP TABLE "ScheduleEntries";""",
"""ALTER TABLE "ScheduleEntries_New" RENAME TO "ScheduleEntries";""",
"""
CREATE INDEX "IX_ScheduleEntries_SchedulePlanId_DayOfWeek_StartPeriod"
ON "ScheduleEntries" ("SchedulePlanId", "DayOfWeek", "StartPeriod");
""",
"""
CREATE INDEX "IX_ScheduleEntries_TeachingTaskId"
ON "ScheduleEntries" ("TeachingTaskId");
""",
"""
CREATE INDEX "IX_ScheduleEntries_ClassroomId"
ON "ScheduleEntries" ("ClassroomId");
""",
"""
CREATE TABLE "ScheduleTimeSlots" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ScheduleTimeSlots" PRIMARY KEY,
"AcademicTermId" TEXT NOT NULL,
"PeriodNumber" INTEGER NOT NULL,
"Name" TEXT NOT NULL,
"StartsAt" TEXT NOT NULL,
"EndsAt" TEXT NOT NULL,
"IsEnabled" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ScheduleTimeSlots_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id")
ON DELETE CASCADE
);
""",
"""
CREATE UNIQUE INDEX "IX_ScheduleTimeSlots_AcademicTermId_PeriodNumber"
ON "ScheduleTimeSlots" ("AcademicTermId", "PeriodNumber");
""",
"""
CREATE TABLE "TeachingTaskScheduleConstraints" (
"Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTaskScheduleConstraints" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"RequiresClassroom" INTEGER NOT NULL,
"RequiredCampusId" TEXT NULL,
"RequiredBuildingId" TEXT NULL,
"AllowedDayOfWeeks" TEXT NULL,
"EarliestPeriod" INTEGER NULL,
"LatestPeriod" INTEGER NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_TeachingTaskScheduleConstraints_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_TeachingTaskScheduleConstraints_Campuses_RequiredCampusId"
FOREIGN KEY ("RequiredCampusId") REFERENCES "Campuses" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_TeachingTaskScheduleConstraints_Buildings_RequiredBuildingId"
FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX "IX_TeachingTaskScheduleConstraints_TeachingTaskId"
ON "TeachingTaskScheduleConstraints" ("TeachingTaskId");
""",
"""
CREATE INDEX "IX_TeachingTaskScheduleConstraints_RequiredCampusId"
ON "TeachingTaskScheduleConstraints" ("RequiredCampusId");
""",
"""
CREATE INDEX "IX_TeachingTaskScheduleConstraints_RequiredBuildingId"
ON "TeachingTaskScheduleConstraints" ("RequiredBuildingId");
""",
"""
CREATE TABLE "TeachingTaskAllowedClassrooms" (
"TeachingTaskScheduleConstraintId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL,
CONSTRAINT "PK_TeachingTaskAllowedClassrooms"
PRIMARY KEY ("TeachingTaskScheduleConstraintId", "ClassroomId"),
CONSTRAINT "FK_TeachingTaskAllowedClassrooms_Constraints"
FOREIGN KEY ("TeachingTaskScheduleConstraintId")
REFERENCES "TeachingTaskScheduleConstraints" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_TeachingTaskAllowedClassrooms_Classrooms_ClassroomId"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE INDEX "IX_TeachingTaskAllowedClassrooms_ClassroomId"
ON "TeachingTaskAllowedClassrooms" ("ClassroomId");
"""
];
}
@@ -0,0 +1,187 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class SchedulingOptimization : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ScheduleEntries_Classrooms_ClassroomId",
table: "ScheduleEntries");
migrationBuilder.AlterColumn<Guid>(
name: "ClassroomId",
table: "ScheduleEntries",
type: "char(36)",
nullable: true,
oldClrType: typeof(Guid),
oldType: "char(36)");
migrationBuilder.CreateTable(
name: "ScheduleTimeSlots",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
PeriodNumber = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
StartsAt = table.Column<TimeOnly>(type: "time", nullable: false),
EndsAt = table.Column<TimeOnly>(type: "time", nullable: false),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ScheduleTimeSlots", x => x.Id);
table.ForeignKey(
name: "FK_ScheduleTimeSlots_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "TeachingTaskScheduleConstraints",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
RequiresClassroom = table.Column<bool>(type: "tinyint(1)", nullable: false),
RequiredCampusId = table.Column<Guid>(type: "char(36)", nullable: true),
RequiredBuildingId = table.Column<Guid>(type: "char(36)", nullable: true),
AllowedDayOfWeeks = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true),
EarliestPeriod = table.Column<int>(type: "int", nullable: true),
LatestPeriod = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeachingTaskScheduleConstraints", x => x.Id);
table.ForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Buildings_RequiredBuildingId",
column: x => x.RequiredBuildingId,
principalTable: "Buildings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Campuses_RequiredCampusId",
column: x => x.RequiredCampusId,
principalTable: "Campuses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskScheduleConstraints_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "TeachingTaskAllowedClassrooms",
columns: table => new
{
TeachingTaskScheduleConstraintId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeachingTaskAllowedClassrooms", x => new { x.TeachingTaskScheduleConstraintId, x.ClassroomId });
table.ForeignKey(
name: "FK_TeachingTaskAllowedClassrooms_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskAllowedClassrooms_TeachingTaskScheduleConstraint~",
column: x => x.TeachingTaskScheduleConstraintId,
principalTable: "TeachingTaskScheduleConstraints",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ScheduleTimeSlots_AcademicTermId_PeriodNumber",
table: "ScheduleTimeSlots",
columns: new[] { "AcademicTermId", "PeriodNumber" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskAllowedClassrooms_ClassroomId",
table: "TeachingTaskAllowedClassrooms",
column: "ClassroomId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskScheduleConstraints_RequiredBuildingId",
table: "TeachingTaskScheduleConstraints",
column: "RequiredBuildingId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskScheduleConstraints_RequiredCampusId",
table: "TeachingTaskScheduleConstraints",
column: "RequiredCampusId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskScheduleConstraints_TeachingTaskId",
table: "TeachingTaskScheduleConstraints",
column: "TeachingTaskId",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_ScheduleEntries_Classrooms_ClassroomId",
table: "ScheduleEntries",
column: "ClassroomId",
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ScheduleEntries_Classrooms_ClassroomId",
table: "ScheduleEntries");
migrationBuilder.DropTable(
name: "ScheduleTimeSlots");
migrationBuilder.DropTable(
name: "TeachingTaskAllowedClassrooms");
migrationBuilder.DropTable(
name: "TeachingTaskScheduleConstraints");
migrationBuilder.AlterColumn<Guid>(
name: "ClassroomId",
table: "ScheduleEntries",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "FK_ScheduleEntries_Classrooms_ClassroomId",
table: "ScheduleEntries",
column: "ClassroomId",
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}
@@ -1292,7 +1292,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("ClassroomId")
b.Property<Guid?>("ClassroomId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
@@ -1385,6 +1385,46 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("SchedulePlans");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<TimeOnly>("EndsAt")
.HasColumnType("time");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<int>("PeriodNumber")
.HasColumnType("int");
b.Property<TimeOnly>("StartsAt")
.HasColumnType("time");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("AcademicTermId", "PeriodNumber")
.IsUnique();
b.ToTable("ScheduleTimeSlots");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
{
b.Property<Guid>("Id")
@@ -1636,6 +1676,21 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("TeachingTasks");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b =>
{
b.Property<Guid>("TeachingTaskScheduleConstraintId")
.HasColumnType("char(36)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId");
b.HasIndex("ClassroomId");
b.ToTable("TeachingTaskAllowedClassrooms");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
{
b.Property<Guid>("TeachingTaskId")
@@ -1651,6 +1706,52 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("TeachingTaskClasses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("AllowedDayOfWeeks")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("EarliestPeriod")
.HasColumnType("int");
b.Property<int?>("LatestPeriod")
.HasColumnType("int");
b.Property<Guid?>("RequiredBuildingId")
.HasColumnType("char(36)");
b.Property<Guid?>("RequiredCampusId")
.HasColumnType("char(36)");
b.Property<bool>("RequiresClassroom")
.HasColumnType("tinyint(1)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("RequiredBuildingId");
b.HasIndex("RequiredCampusId");
b.HasIndex("TeachingTaskId")
.IsUnique();
b.ToTable("TeachingTaskScheduleConstraints");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b =>
{
b.Property<Guid>("TeachingTaskId")
@@ -2277,8 +2378,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan")
.WithMany("Entries")
@@ -2310,6 +2410,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("AcademicTerm");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AcademicTerm");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
@@ -2372,6 +2483,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Course");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", "TeachingTaskScheduleConstraint")
.WithMany("AllowedClassrooms")
.HasForeignKey("TeachingTaskScheduleConstraintId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Classroom");
b.Navigation("TeachingTaskScheduleConstraint");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
@@ -2391,6 +2521,31 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding")
.WithMany()
.HasForeignKey("RequiredBuildingId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "RequiredCampus")
.WithMany()
.HasForeignKey("RequiredCampusId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RequiredBuilding");
b.Navigation("RequiredCampus");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher")
@@ -2532,6 +2687,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Teachers");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.Navigation("AllowedClassrooms");
});
#pragma warning restore 612, 618
}
}
@@ -0,0 +1,219 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public sealed class AutomaticScheduleGenerator(AppDbContext db)
{
public async Task<AutomaticScheduleResult> GenerateAsync(
SchedulePlan plan,
CancellationToken cancellationToken)
{
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.ToListAsync(cancellationToken);
if (timeSlots.Count == 0)
return new(0, 0, ["请先维护该学期的上课时间表。"]);
var activePeriods = timeSlots.Select(x => x.PeriodNumber).ToHashSet();
var tasks = await db.TeachingTasks
.Where(x =>
x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.Include(x => x.Teachers)
.Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students)
.OrderByDescending(x => x.Classes.Count + x.Teachers.Count)
.ThenByDescending(x => x.Capacity)
.ThenBy(x => x.TaskNumber)
.ToListAsync(cancellationToken);
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Where(x => tasks.Select(task => task.Id).Contains(x.TeachingTaskId))
.Include(x => x.AllowedClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.Include(x => x.Building)
.OrderBy(x => x.Capacity)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken);
var entries = await db.ScheduleEntries
.Where(x => x.SchedulePlanId == plan.Id)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.ToListAsync(cancellationToken);
var created = 0;
var completedTasks = 0;
var messages = new List<string>();
foreach (var task in tasks)
{
constraints.TryGetValue(task.Id, out var constraint);
var scheduledHours = entries
.Where(x => x.TeachingTaskId == task.Id)
.Sum(x => x.PeriodCount);
var remainingHours = Math.Max(0, task.WeeklyHours - scheduledHours);
if (remainingHours == 0)
{
completedTasks++;
continue;
}
while (remainingHours > 0)
{
var desiredBlock = remainingHours >= 2 ? 2 : 1;
var candidate = FindBestCandidate(
plan.Id,
task,
constraint,
desiredBlock,
activePeriods,
classrooms,
entries);
if (candidate is null && desiredBlock > 1)
{
candidate = FindBestCandidate(
plan.Id,
task,
constraint,
1,
activePeriods,
classrooms,
entries);
}
if (candidate is null) break;
db.ScheduleEntries.Add(candidate);
entries.Add(candidate);
created++;
remainingHours -= candidate.PeriodCount;
}
if (remainingHours == 0)
{
completedTasks++;
}
else
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
}
}
if (created > 0)
await db.SaveChangesAsync(cancellationToken);
return new(created, completedTasks, messages);
}
private static ScheduleEntry? FindBestCandidate(
Guid planId,
TeachingTask task,
TeachingTaskScheduleConstraint? constraint,
int periodCount,
HashSet<int> activePeriods,
IReadOnlyList<Classroom> classrooms,
IReadOnlyList<ScheduleEntry> entries)
{
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max();
var rooms = EligibleRooms(task, constraint, 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 start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
{
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)
{
var proposed = new ScheduleEntry
{
SchedulePlanId = planId,
TeachingTaskId = task.Id,
TeachingTask = task,
ClassroomId = room?.Id,
Classroom = room,
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))
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));
}
}
}
return candidates
.OrderBy(x => x.Score)
.ThenBy(x => x.Entry.DayOfWeek)
.ThenBy(x => x.Entry.StartPeriod)
.Select(x => x.Entry)
.FirstOrDefault();
}
private static IReadOnlyList<Classroom> EligibleRooms(
TeachingTask task,
TeachingTaskScheduleConstraint? constraint,
IReadOnlyList<Classroom> classrooms)
{
if (constraint?.RequiresClassroom == false) return [];
var allowedRoomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
var minimumCapacity = Math.Max(
task.Capacity,
task.Classes.Sum(x =>
x.AdministrativeClass?.Students.Count(student =>
student.Status == StudentStatus.Active) ?? 0));
return classrooms.Where(room =>
room.Capacity >= minimumCapacity &&
(!constraint?.RequiredCampusId.HasValue ?? true ||
room.Building!.CampusId == constraint.RequiredCampusId) &&
(!constraint?.RequiredBuildingId.HasValue ?? true ||
room.BuildingId == constraint.RequiredBuildingId) &&
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)))
.ToList();
}
private static int[] ParseAllowedDays(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return [1, 2, 3, 4, 5];
var days = value.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(item => int.TryParse(item, out var day) ? day : 0)
.Where(day => day is >= 1 and <= 7)
.Distinct()
.Order()
.ToArray();
return days.Length == 0 ? [1, 2, 3, 4, 5] : days;
}
}
public sealed record AutomaticScheduleResult(
int CreatedEntries,
int CompletedTasks,
IReadOnlyList<string> Messages);
@@ -29,7 +29,10 @@ public static class ScheduleConflictDetector
public static string? ConflictReason(ScheduleEntry first, ScheduleEntry second)
{
if (first.ClassroomId == second.ClassroomId) return "教室";
if (first.ClassroomId.HasValue &&
second.ClassroomId.HasValue &&
first.ClassroomId == second.ClassroomId)
return "教室";
var firstTeachers = first.TeachingTask!.Teachers.Select(x => x.TeacherId).ToHashSet();
if (second.TeachingTask!.Teachers.Any(x => firstTeachers.Contains(x.TeacherId)))
return "教师";
+2
View File
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Middleware;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@@ -86,6 +87,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)