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

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