From d67a07f23ef724ba44d24f23713f615b1e93df3b Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 24 Jul 2026 21:55:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E8=A6=81=E6=94=B9=E5=8A=A8=EF=BC=9A?= =?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E6=8E=92=E8=AF=BE=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E7=AB=8B=E5=8D=B3=E8=BF=94=E5=9B=9E=20202=20+=20jobId=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=9C=8D=E5=8A=A1=E7=8B=AC=E7=AB=8B=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=EF=BC=8C=E4=B8=8D=E5=8F=97=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E6=88=96=E5=89=8D=E7=AB=AF=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E5=BD=B1=E5=93=8D=20=E8=BF=9B=E5=BA=A6=E3=80=81=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E3=80=81=E5=A4=B1=E8=B4=A5=E7=8A=B6=E6=80=81=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E5=88=B0=E6=95=B0=E6=8D=AE=E5=BA=93=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=87=8D=E5=90=AF=E5=90=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=8E=92=E9=98=9F=E4=B8=AD=E6=88=96=E4=B8=AD?= =?UTF-8?q?=E6=96=AD=E7=9A=84=E4=BB=BB=E5=8A=A1=20=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E7=A6=81=E6=AD=A2=E9=87=8D=E5=A4=8D=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E5=90=8E=E5=8F=B0=E4=BB=BB=E5=8A=A1=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=9C=9F=E9=97=B4=E7=A6=81=E6=AD=A2=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E3=80=81=E5=8F=91=E5=B8=83=E6=88=96=E5=88=A0=E9=99=A4=E8=AF=A5?= =?UTF-8?q?=E8=AF=BE=E8=A1=A8=20=E6=8E=92=E8=AF=BE=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=92=8C=E4=BB=BB=E5=8A=A1=E6=88=90=E5=8A=9F=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=9C=A8=E5=90=8C=E4=B8=80=E4=BA=8B=E5=8A=A1=E4=B8=AD=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=20=E5=89=8D=E7=AB=AF=E6=AF=8F=E7=A7=92=E8=BD=AE?= =?UTF-8?q?=E8=AF=A2=E8=BF=9B=E5=BA=A6=EF=BC=8C=E6=98=BE=E7=A4=BA=E5=B7=B2?= =?UTF-8?q?=E5=A4=84=E7=90=86=E6=95=99=E5=AD=A6=E7=8F=AD=E5=92=8C=E5=B7=B2?= =?UTF-8?q?=E8=A7=84=E5=88=92=E5=AE=89=E6=8E=92=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/SchedulesController.cs | 141 +- .../Domain/Academic/ScheduleEntities.cs | 26 + .../Persistence/AppDbContext.cs | 19 + .../Persistence/DevelopmentSqliteMigrator.cs | 59 + ...24134114_AutomaticScheduleJobs.Designer.cs | 2873 +++++++++++++++++ .../20260724134114_AutomaticScheduleJobs.cs | 81 + .../MySql/AppDbContextModelSnapshot.cs | 79 + .../Scheduling/AutomaticScheduleGenerator.cs | 62 +- .../Scheduling/AutomaticScheduleJobs.cs | 251 ++ src/Jiaowu.Api/Program.cs | 3 + .../AutomaticScheduleGeneratorTests.cs | 155 +- .../AutomaticScheduleJobProcessorTests.cs | 139 + web/src/style.css | 8 + web/src/views/SchedulesView.vue | 147 +- 14 files changed, 4011 insertions(+), 32 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724134114_AutomaticScheduleJobs.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724134114_AutomaticScheduleJobs.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs create mode 100644 tests/Jiaowu.Api.Tests/AutomaticScheduleJobProcessorTests.cs diff --git a/src/Jiaowu.Api/Controllers/SchedulesController.cs b/src/Jiaowu.Api/Controllers/SchedulesController.cs index 9921ad3..d413044 100644 --- a/src/Jiaowu.Api/Controllers/SchedulesController.cs +++ b/src/Jiaowu.Api/Controllers/SchedulesController.cs @@ -1,4 +1,6 @@ using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using System.Text.Json; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Persistence; @@ -14,7 +16,7 @@ namespace Jiaowu.Api.Controllers; [Route("api/schedules")] public sealed class SchedulesController( AppDbContext db, - AutomaticScheduleGenerator scheduleGenerator) : ControllerBase + AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase { private const string ManagementRoles = SystemRoles.SuperAdmin + "," + @@ -128,6 +130,8 @@ public sealed class SchedulesController( { var plan = await db.SchedulePlans.FindAsync([id], cancellationToken); if (plan is null) return NotFound(); + if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) + return AutomaticScheduleRunningProblem(); if (plan.Status != SchedulePlanStatus.Draft) return ConflictProblem("已发布或已归档的排课版本不可直接修改。"); if (!await db.AcademicTerms.AnyAsync( @@ -147,6 +151,8 @@ public sealed class SchedulesController( CloneSchedulePlanRequest request, CancellationToken cancellationToken) { + if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) + return AutomaticScheduleRunningProblem(); var source = await db.SchedulePlans.AsNoTracking() .Include(x => x.Entries) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); @@ -179,6 +185,8 @@ public sealed class SchedulesController( { var plan = await db.SchedulePlans.FindAsync([id], cancellationToken); if (plan is null) return NotFound(); + if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) + return AutomaticScheduleRunningProblem(); if (plan.Status != SchedulePlanStatus.Draft) return ConflictProblem("仅草稿排课版本可以删除。"); db.SchedulePlans.Remove(plan); @@ -188,6 +196,8 @@ public sealed class SchedulesController( [HttpPost("plans/{id:guid}/publish")] public async Task PublishPlan(Guid id, CancellationToken cancellationToken) { + if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) + return AutomaticScheduleRunningProblem(); var plan = await db.SchedulePlans .Include(x => x.Entries) .ThenInclude(x => x.TeachingTask) @@ -261,6 +271,8 @@ public sealed class SchedulesController( ScheduleEntryRequest request, CancellationToken cancellationToken) { + if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) + return AutomaticScheduleRunningProblem(); var plan = await DraftPlanAsync(planId, cancellationToken); if (plan is null) return NotFound(); var validation = await ValidateEntryAsync(plan, null, request, cancellationToken); @@ -271,14 +283,84 @@ public sealed class SchedulesController( } [HttpPost("plans/{planId:guid}/auto-schedule")] - public async Task AutoSchedule( + public async Task> 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); + + var existing = await db.AutomaticScheduleJobs.AsNoTracking() + .FirstOrDefaultAsync( + x => x.ActiveSchedulePlanId == planId, + cancellationToken); + if (existing is not null) + { + return AcceptedAtAction( + nameof(GetAutomaticScheduleJob), + new { jobId = existing.Id }, + ToResponse(existing)); + } + + var requestedByUserId = Guid.TryParse( + User.FindFirstValue(ClaimTypes.NameIdentifier), + out var userId) + ? userId + : (Guid?)null; + var job = new AutomaticScheduleJob + { + SchedulePlanId = planId, + ActiveSchedulePlanId = planId, + RequestedByUserId = requestedByUserId + }; + db.AutomaticScheduleJobs.Add(job); + try + { + await db.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + db.Entry(job).State = EntityState.Detached; + existing = await db.AutomaticScheduleJobs.AsNoTracking() + .FirstOrDefaultAsync( + x => x.ActiveSchedulePlanId == planId, + cancellationToken); + if (existing is null) throw; + return AcceptedAtAction( + nameof(GetAutomaticScheduleJob), + new { jobId = existing.Id }, + ToResponse(existing)); + } + + automaticScheduleJobQueue.Enqueue(job.Id); + return AcceptedAtAction( + nameof(GetAutomaticScheduleJob), + new { jobId = job.Id }, + ToResponse(job)); + } + + [HttpGet("auto-schedule-jobs/{jobId:guid}")] + public async Task> + GetAutomaticScheduleJob( + Guid jobId, + CancellationToken cancellationToken) + { + var job = await db.AutomaticScheduleJobs.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken); + return job is null ? NotFound() : Ok(ToResponse(job)); + } + + [HttpGet("plans/{planId:guid}/auto-schedule-job")] + public async Task> + GetActiveAutomaticScheduleJob( + Guid planId, + CancellationToken cancellationToken) + { + var job = await db.AutomaticScheduleJobs.AsNoTracking() + .Where(x => x.ActiveSchedulePlanId == planId) + .OrderByDescending(x => x.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + return Ok(job is null ? null : ToResponse(job)); } [HttpPut("plans/{planId:guid}/entries/{entryId:guid}")] @@ -288,6 +370,8 @@ public sealed class SchedulesController( ScheduleEntryRequest request, CancellationToken cancellationToken) { + if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) + return AutomaticScheduleRunningProblem(); var plan = await DraftPlanAsync(planId, cancellationToken); if (plan is null) return NotFound(); var entry = await db.ScheduleEntries @@ -315,6 +399,8 @@ public sealed class SchedulesController( Guid entryId, CancellationToken cancellationToken) { + if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) + return AutomaticScheduleRunningProblem(); if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound(); var entry = await db.ScheduleEntries .FirstOrDefaultAsync( @@ -332,6 +418,13 @@ public sealed class SchedulesController( x => x.Id == id && x.Status == SchedulePlanStatus.Draft, cancellationToken); + private Task HasActiveAutomaticScheduleJobAsync( + Guid planId, + CancellationToken cancellationToken) => + db.AutomaticScheduleJobs.AsNoTracking().AnyAsync( + x => x.ActiveSchedulePlanId == planId, + cancellationToken); + private async Task ValidateEntryAsync( SchedulePlan plan, Guid? entryId, @@ -486,6 +579,32 @@ public sealed class SchedulesController( Status = StatusCodes.Status409Conflict }); + private ActionResult AutomaticScheduleRunningProblem() => + ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。"); + + private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job) + { + IReadOnlyList messages = []; + if (!string.IsNullOrWhiteSpace(job.MessagesJson)) + { + messages = JsonSerializer.Deserialize(job.MessagesJson) ?? []; + } + + return new( + job.Id, + job.SchedulePlanId, + job.Status, + job.TotalTasks, + job.ProcessedTasks, + job.CreatedEntries, + job.CompletedTasks, + messages, + job.ErrorMessage, + job.CreatedAt, + job.StartedAt, + job.CompletedAt); + } + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } @@ -510,3 +629,17 @@ public sealed record ScheduleEntryRequest( [Range(1, 30)] int EndWeek, WeekPattern WeekPattern, [MaxLength(500)] string? Notes); + +public sealed record AutomaticScheduleJobResponse( + Guid Id, + Guid SchedulePlanId, + AutomaticScheduleJobStatus Status, + int TotalTasks, + int ProcessedTasks, + int CreatedEntries, + int CompletedTasks, + IReadOnlyList Messages, + string? ErrorMessage, + DateTime CreatedAt, + DateTime? StartedAt, + DateTime? CompletedAt); diff --git a/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs b/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs index 2bce59e..ea493f0 100644 --- a/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs @@ -65,6 +65,24 @@ public sealed class TeachingTaskAllowedClassroom public Classroom? Classroom { get; set; } } +public sealed class AutomaticScheduleJob : EntityBase +{ + public Guid SchedulePlanId { get; set; } + public SchedulePlan? SchedulePlan { get; set; } + public Guid? ActiveSchedulePlanId { get; set; } + public Guid? RequestedByUserId { get; set; } + public AutomaticScheduleJobStatus Status { get; set; } = + AutomaticScheduleJobStatus.Queued; + public int TotalTasks { get; set; } + public int ProcessedTasks { get; set; } + public int CreatedEntries { get; set; } + public int CompletedTasks { get; set; } + public string? MessagesJson { get; set; } + public string? ErrorMessage { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } +} + public enum SchedulePlanStatus { Draft = 1, @@ -72,6 +90,14 @@ public enum SchedulePlanStatus Archived = 3 } +public enum AutomaticScheduleJobStatus +{ + Queued = 1, + Running = 2, + Succeeded = 3, + Failed = 4 +} + public enum WeekPattern { All = 1, diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 3b0a639..793e258 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -36,6 +36,8 @@ public sealed class AppDbContext(DbContextOptions options) Set(); public DbSet TeachingTaskAllowedClassrooms => Set(); + public DbSet AutomaticScheduleJobs => + Set(); public DbSet CourseSelectionRounds => Set(); public DbSet CourseSelectionOfferings => @@ -381,6 +383,23 @@ public sealed class AppDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.Property(x => x.ErrorMessage).HasMaxLength(2000); + entity.HasIndex(x => x.ActiveSchedulePlanId).IsUnique(); + entity.HasIndex(x => new { x.SchedulePlanId, x.CreatedAt }); + entity.HasIndex(x => new { x.Status, x.CreatedAt }); + entity.HasIndex(x => x.RequestedByUserId); + entity.HasOne(x => x.SchedulePlan) + .WithMany() + .HasForeignKey(x => x.SchedulePlanId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.RequestedByUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + builder.Entity(entity => { entity.Property(x => x.Name).HasMaxLength(120); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 71a7dc9..11689ed 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -23,6 +23,8 @@ public sealed class DevelopmentSqliteMigrator( "20260724_14_scheduling_optimization"; private const string TeacherCourseApplicationsMigration = "20260724_15_teacher_course_applications"; + private const string AutomaticScheduleJobsMigration = + "20260724_16_automatic_schedule_jobs"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -135,6 +137,18 @@ public sealed class DevelopmentSqliteMigrator( TeacherCourseApplicationsMigration, teacherCourseApplicationsExist ? [] : TeacherCourseApplicationStatements, cancellationToken); + var automaticScheduleJobsExist = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM sqlite_master + WHERE type = 'table' AND name = 'AutomaticScheduleJobs' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + AutomaticScheduleJobsMigration, + automaticScheduleJobsExist ? [] : AutomaticScheduleJobStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -1022,4 +1036,49 @@ public sealed class DevelopmentSqliteMigrator( ON "TeacherCourseApplications" ("ReviewedByUserId"); """ ]; + + private static readonly string[] AutomaticScheduleJobStatements = + [ + """ + CREATE TABLE "AutomaticScheduleJobs" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_AutomaticScheduleJobs" PRIMARY KEY, + "SchedulePlanId" TEXT NOT NULL, + "ActiveSchedulePlanId" TEXT NULL, + "RequestedByUserId" TEXT NULL, + "Status" INTEGER NOT NULL, + "TotalTasks" INTEGER NOT NULL, + "ProcessedTasks" INTEGER NOT NULL, + "CreatedEntries" INTEGER NOT NULL, + "CompletedTasks" INTEGER NOT NULL, + "MessagesJson" TEXT NULL, + "ErrorMessage" TEXT NULL, + "StartedAt" TEXT NULL, + "CompletedAt" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_AutomaticScheduleJobs_SchedulePlans" + FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_AutomaticScheduleJobs_RequestedBy" + FOREIGN KEY ("RequestedByUserId") REFERENCES "AspNetUsers" ("Id") + ON DELETE SET NULL + ); + """, + """ + CREATE UNIQUE INDEX "IX_AutomaticScheduleJobs_ActiveSchedulePlanId" + ON "AutomaticScheduleJobs" ("ActiveSchedulePlanId"); + """, + """ + CREATE INDEX "IX_AutomaticScheduleJobs_SchedulePlanId_CreatedAt" + ON "AutomaticScheduleJobs" ("SchedulePlanId", "CreatedAt"); + """, + """ + CREATE INDEX "IX_AutomaticScheduleJobs_Status_CreatedAt" + ON "AutomaticScheduleJobs" ("Status", "CreatedAt"); + """, + """ + CREATE INDEX "IX_AutomaticScheduleJobs_RequestedByUserId" + ON "AutomaticScheduleJobs" ("RequestedByUserId"); + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724134114_AutomaticScheduleJobs.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724134114_AutomaticScheduleJobs.Designer.cs new file mode 100644 index 0000000..2c069b8 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724134114_AutomaticScheduleJobs.Designer.cs @@ -0,0 +1,2873 @@ +// +using System; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260724134114_AutomaticScheduleJobs")] + partial class AutomaticScheduleJobs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicTerm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicYear") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsCurrent") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Season") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsCurrent"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AcademicTerms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CounselorName") + .HasColumnType("longtext"); + + b.Property("CounselorUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CounselorUserId"); + + b.HasIndex("MajorId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AdministrativeClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ActiveSchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedTasks") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedEntries") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MessagesJson") + .HasColumnType("longtext"); + + b.Property("ProcessedTasks") + .HasColumnType("int"); + + b.Property("RequestedByUserId") + .HasColumnType("char(36)"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalTasks") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveSchedulePlanId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("AutomaticScheduleJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Buildings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Campuses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BuildingId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Equipment") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RoomType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ShortName") + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Colleges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AssessmentMethod") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CourseCategoryId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Credits") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EnglishName") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LectureHours") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Nature") + .HasColumnType("int"); + + b.Property("PracticeHours") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalHours") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CourseCategoryId"); + + b.HasIndex("CollegeId", "Nature"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("CourseCategories"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseSelectionOfferingId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrolledAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawnAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseSelectionOfferingId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.ToTable("CourseEnrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseSelectionRoundId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsOpenToAll") + .HasColumnType("tinyint(1)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("CourseSelectionRoundId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("CourseSelectionOfferings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("MaxCredits") + .HasPrecision(6, 1) + .HasColumnType("decimal(6,1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawalEndsAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("CourseSelectionRounds"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumModuleId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RecommendedSemester") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("CurriculumModuleId", "CourseId") + .IsUnique(); + + b.ToTable("CurriculumCourses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId", "Code") + .IsUnique(); + + b.ToTable("CurriculumModules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EffectiveGrade") + .HasColumnType("int"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "EffectiveGrade"); + + b.HasIndex("MajorId", "EffectiveGrade", "Version") + .IsUnique(); + + b.ToTable("CurriculumPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("MinimumGradePoint") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationYear", "Status"); + + b.ToTable("DegreeAwardBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AverageGradePoint") + .HasPrecision(4, 2) + .HasColumnType("decimal(4,2)"); + + b.Property("CalculatedConclusion") + .HasColumnType("int"); + + b.Property("Conclusion") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeAwardBatchId") + .HasColumnType("char(36)"); + + b.Property("ExceptionReason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("GraduationAuditResultId") + .HasColumnType("char(36)"); + + b.Property("IsOverridden") + .HasColumnType("tinyint(1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationAuditResultId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Conclusion", "IsOverridden"); + + b.HasIndex("DegreeAwardBatchId", "StudentId") + .IsUnique(); + + b.ToTable("DegreeAwardResults"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("ExamPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("ExamPlanId", "StartsAt"); + + b.ToTable("ExamSessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.Property("ExamSessionId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("ExamSessionId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ExamSessionInvigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamStatus") + .HasColumnType("int"); + + b.Property("FinalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("GradePoint") + .HasPrecision(3, 1) + .HasColumnType("decimal(3,1)"); + + b.Property("GradeSheetId") + .HasColumnType("char(36)"); + + b.Property("MidtermScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("Notes") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RegularScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("TotalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GradeSheetId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "TotalScore"); + + b.ToTable("GradeRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FinalWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("MidtermWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("RegularWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("GradeSheets"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("GraduationYear", "EnrollmentYear"); + + b.ToTable("GraduationAuditBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedConclusion") + .HasColumnType("int"); + + b.Property("Conclusion") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("EarnedCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("FailedCourseCount") + .HasColumnType("int"); + + b.Property("GraduationAuditBatchId") + .HasColumnType("char(36)"); + + b.Property("IsOverridden") + .HasColumnType("tinyint(1)"); + + b.Property("MissingCourseNames") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("PassedRequiredCourseCount") + .HasColumnType("int"); + + b.Property("RequiredCourseCount") + .HasColumnType("int"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("StudentStatusSnapshot") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Conclusion", "IsOverridden"); + + b.HasIndex("GraduationAuditBatchId", "StudentId") + .IsUnique(); + + b.ToTable("GraduationAuditResults"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationYear", "Status"); + + b.ToTable("GraduationClearanceBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationClearanceBatchId") + .HasColumnType("char(36)"); + + b.Property("IsRequired") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ResponsibleRole") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("ResponsibleUnit") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationClearanceBatchId", "Code") + .IsUnique(); + + b.ToTable("GraduationClearanceItems"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedByUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationClearanceItemId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationClearanceItemId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.ToTable("GraduationClearanceRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SchoolingYears") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Majors"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeekPattern") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("SchedulePlanId", "DayOfWeek", "StartPeriod"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.HasIndex("AcademicTermId", "Version") + .IsUnique(); + + b.ToTable("SchedulePlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("time"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("PeriodNumber") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("time"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EnrollmentDate") + .HasColumnType("date"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentYear"); + + b.HasIndex("StudentNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("AdministrativeClassId", "Status"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OriginalStatus") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("State") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetStatus") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId", "State"); + + b.ToTable("StudentStatusChanges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("IsExternal") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeacherNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Title") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("TeacherNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("CollegeId", "Status"); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Statement") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("TeacherId"); + + b.HasIndex("Status", "AcademicTermId"); + + b.HasIndex("AcademicTermId", "TeacherId", "CourseId") + .IsUnique(); + + b.ToTable("TeacherCourseApplications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("GenerationBatchCode") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeeklyHours") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("TaskNumber") + .IsUnique(); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("TeachingTasks"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.Property("TeachingTaskScheduleConstraintId") + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("TeachingTaskAllowedClassrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskId", "AdministrativeClassId"); + + b.HasIndex("AdministrativeClassId"); + + b.ToTable("TeachingTaskClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AllowedDayOfWeeks") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EarliestPeriod") + .HasColumnType("int"); + + b.Property("LatestPeriod") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredCampusId") + .HasColumnType("char(36)"); + + b.Property("RequiresClassroom") + .HasColumnType("tinyint(1)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("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("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.HasKey("TeachingTaskId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("TeachingTaskTeachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("DataScope") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("StaffNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("StaffNumber"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "CounselorUser") + .WithMany() + .HasForeignKey("CounselorUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CounselorUser"); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("RequestedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany() + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchedulePlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "Building") + .WithMany() + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CourseCategory", "CourseCategory") + .WithMany() + .HasForeignKey("CourseCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("College"); + + b.Navigation("CourseCategory"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", "CourseSelectionOffering") + .WithMany("Enrollments") + .HasForeignKey("CourseSelectionOfferingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionOffering"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound") + .WithMany("Offerings") + .HasForeignKey("CourseSelectionRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionRound"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumModule", "CurriculumModule") + .WithMany("Courses") + .HasForeignKey("CurriculumModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("CurriculumModule"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany("Modules") + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", "DegreeAwardBatch") + .WithMany("Results") + .HasForeignKey("DegreeAwardBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditResult", "GraduationAuditResult") + .WithMany() + .HasForeignKey("GraduationAuditResultId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DegreeAwardBatch"); + + b.Navigation("GraduationAuditResult"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan") + .WithMany("Sessions") + .HasForeignKey("ExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("ExamPlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession") + .WithMany("Invigilators") + .HasForeignKey("ExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamSession"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") + .WithMany("Records") + .HasForeignKey("GradeSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GradeSheet"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany() + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", "GraduationAuditBatch") + .WithMany("Results") + .HasForeignKey("GraduationAuditBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + + b.Navigation("GraduationAuditBatch"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", "GraduationClearanceBatch") + .WithMany("Items") + .HasForeignKey("GraduationClearanceBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GraduationClearanceBatch"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", "GraduationClearanceItem") + .WithMany("Records") + .HasForeignKey("GraduationClearanceItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GraduationClearanceItem"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany("Entries") + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SchedulePlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + 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") + .WithMany("Students") + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AdministrativeClass"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + 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") + .WithMany() + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Classes") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdministrativeClass"); + + 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") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Teachers") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Teacher"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Navigation("Offerings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Navigation("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Navigation("Invigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Navigation("Classes"); + + b.Navigation("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Navigation("AllowedClassrooms"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724134114_AutomaticScheduleJobs.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724134114_AutomaticScheduleJobs.cs new file mode 100644 index 0000000..76e709e --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724134114_AutomaticScheduleJobs.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class AutomaticScheduleJobs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AutomaticScheduleJobs", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + SchedulePlanId = table.Column(type: "char(36)", nullable: false), + ActiveSchedulePlanId = table.Column(type: "char(36)", nullable: true), + RequestedByUserId = table.Column(type: "char(36)", nullable: true), + Status = table.Column(type: "int", nullable: false), + TotalTasks = table.Column(type: "int", nullable: false), + ProcessedTasks = table.Column(type: "int", nullable: false), + CreatedEntries = table.Column(type: "int", nullable: false), + CompletedTasks = table.Column(type: "int", nullable: false), + MessagesJson = table.Column(type: "longtext", nullable: true), + ErrorMessage = table.Column(type: "varchar(2000)", maxLength: 2000, nullable: true), + StartedAt = table.Column(type: "datetime(6)", nullable: true), + CompletedAt = table.Column(type: "datetime(6)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AutomaticScheduleJobs", x => x.Id); + table.ForeignKey( + name: "FK_AutomaticScheduleJobs_AspNetUsers_RequestedByUserId", + column: x => x.RequestedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_AutomaticScheduleJobs_SchedulePlans_SchedulePlanId", + column: x => x.SchedulePlanId, + principalTable: "SchedulePlans", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_AutomaticScheduleJobs_ActiveSchedulePlanId", + table: "AutomaticScheduleJobs", + column: "ActiveSchedulePlanId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AutomaticScheduleJobs_RequestedByUserId", + table: "AutomaticScheduleJobs", + column: "RequestedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AutomaticScheduleJobs_SchedulePlanId_CreatedAt", + table: "AutomaticScheduleJobs", + columns: new[] { "SchedulePlanId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_AutomaticScheduleJobs_Status_CreatedAt", + table: "AutomaticScheduleJobs", + columns: new[] { "Status", "CreatedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AutomaticScheduleJobs"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index e35fe20..bfd6f55 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -129,6 +129,69 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("AdministrativeClasses"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ActiveSchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedTasks") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedEntries") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MessagesJson") + .HasColumnType("longtext"); + + b.Property("ProcessedTasks") + .HasColumnType("int"); + + b.Property("RequestedByUserId") + .HasColumnType("char(36)"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalTasks") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveSchedulePlanId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("AutomaticScheduleJobs"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => { b.Property("Id") @@ -2113,6 +2176,22 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Major"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("RequestedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany() + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchedulePlan"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => { b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs index 65a7d8d..2830d4e 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs @@ -6,8 +6,15 @@ namespace Jiaowu.Api.Infrastructure.Scheduling; public sealed class AutomaticScheduleGenerator(AppDbContext db) { + public Task GenerateAsync( + SchedulePlan plan, + CancellationToken cancellationToken) => + GenerateAsync(plan, null, true, cancellationToken); + public async Task GenerateAsync( SchedulePlan plan, + Func? reportProgress, + bool saveChanges, CancellationToken cancellationToken) { var timeSlots = await db.ScheduleTimeSlots.AsNoTracking() @@ -50,9 +57,18 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) var created = 0; var completedTasks = 0; + var processedTasks = 0; var messages = new List(); + if (reportProgress is not null) + { + await reportProgress( + new(tasks.Count, 0, 0, 0), + cancellationToken); + } + foreach (var task in tasks) { + cancellationToken.ThrowIfCancellationRequested(); constraints.TryGetValue(task.Id, out var constraint); var scheduledHours = entries .Where(x => x.TeachingTaskId == task.Id) @@ -61,6 +77,13 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) if (remainingHours == 0) { completedTasks++; + processedTasks++; + if (reportProgress is not null) + { + await reportProgress( + new(tasks.Count, processedTasks, created, completedTasks), + cancellationToken); + } continue; } @@ -74,7 +97,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) desiredBlock, activePeriods, classrooms, - entries); + entries, + cancellationToken); if (candidate is null && desiredBlock > 1) { candidate = FindBestCandidate( @@ -84,7 +108,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) 1, activePeriods, classrooms, - entries); + entries, + cancellationToken); } if (candidate is null) break; @@ -103,11 +128,24 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) messages.Add( $"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。"); } + + processedTasks++; + if (reportProgress is not null) + { + await reportProgress( + new(tasks.Count, processedTasks, created, completedTasks), + cancellationToken); + } } - if (created > 0) + if (created > 0 && saveChanges) await db.SaveChangesAsync(cancellationToken); - return new(created, completedTasks, messages); + return new( + created, + completedTasks, + messages, + tasks.Count, + processedTasks); } private static ScheduleEntry? FindBestCandidate( @@ -117,7 +155,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) int periodCount, HashSet activePeriods, IReadOnlyList classrooms, - IReadOnlyList entries) + IReadOnlyList entries, + CancellationToken cancellationToken) { var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks); var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min(); @@ -129,8 +168,10 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) var candidates = new List<(ScheduleEntry Entry, int Score)>(); foreach (var day in allowedDays) { + cancellationToken.ThrowIfCancellationRequested(); for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++) { + cancellationToken.ThrowIfCancellationRequested(); if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period))) continue; @@ -145,7 +186,6 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) TeachingTaskId = task.Id, TeachingTask = task, ClassroomId = room?.Id, - Classroom = room, DayOfWeek = day, StartPeriod = start, PeriodCount = periodCount, @@ -216,4 +256,12 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) public sealed record AutomaticScheduleResult( int CreatedEntries, int CompletedTasks, - IReadOnlyList Messages); + IReadOnlyList Messages, + int TotalTasks = 0, + int ProcessedTasks = 0); + +public sealed record AutomaticScheduleProgress( + int TotalTasks, + int ProcessedTasks, + int CreatedEntries, + int CompletedTasks); diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs new file mode 100644 index 0000000..f7c6a3a --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs @@ -0,0 +1,251 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Threading.Channels; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.Scheduling; + +public sealed class AutomaticScheduleJobQueue +{ + private readonly Channel _channel = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + + public void Enqueue(Guid jobId) + { + if (!_channel.Writer.TryWrite(jobId)) + throw new InvalidOperationException("自动排课任务队列当前不可用。"); + } + + public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken) => + _channel.Reader.ReadAllAsync(cancellationToken); +} + +public sealed class AutomaticScheduleJobWorker( + IServiceScopeFactory scopeFactory, + AutomaticScheduleJobQueue queue, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await RecoverInterruptedJobsAsync(stoppingToken); + + try + { + await foreach (var jobId in queue.ReadAllAsync(stoppingToken)) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var processor = scope.ServiceProvider + .GetRequiredService(); + await processor.ProcessAsync(jobId, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logger.LogError( + exception, + "Unexpected failure while dispatching automatic schedule job {JobId}.", + jobId); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + logger.LogInformation("Automatic schedule job worker is stopping."); + } + } + + private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var jobs = await db.AutomaticScheduleJobs + .Where(x => + x.Status == AutomaticScheduleJobStatus.Queued || + x.Status == AutomaticScheduleJobStatus.Running) + .OrderBy(x => x.CreatedAt) + .ToListAsync(cancellationToken); + + foreach (var job in jobs) + { + job.Status = AutomaticScheduleJobStatus.Queued; + job.ActiveSchedulePlanId = job.SchedulePlanId; + job.StartedAt = null; + job.CompletedAt = null; + job.ErrorMessage = null; + } + + if (jobs.Count > 0) + await db.SaveChangesAsync(cancellationToken); + foreach (var job in jobs) + queue.Enqueue(job.Id); + + if (jobs.Count > 0) + { + logger.LogInformation( + "Recovered {JobCount} queued or interrupted automatic schedule jobs.", + jobs.Count); + } + } +} + +public sealed class AutomaticScheduleJobProcessor( + AppDbContext db, + AutomaticScheduleGenerator generator, + IServiceScopeFactory scopeFactory, + ILogger logger) +{ + public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken) + { + try + { + var job = await db.AutomaticScheduleJobs + .FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken); + if (job is null || + job.Status is AutomaticScheduleJobStatus.Succeeded + or AutomaticScheduleJobStatus.Failed) + { + return; + } + + var plan = await db.SchedulePlans.FirstOrDefaultAsync( + x => x.Id == job.SchedulePlanId, + stoppingToken); + if (plan is null || plan.Status != SchedulePlanStatus.Draft) + throw new InvalidOperationException("排课草稿不存在或已不允许修改。"); + + job.Status = AutomaticScheduleJobStatus.Running; + job.StartedAt = DateTime.UtcNow; + job.CompletedAt = null; + job.ErrorMessage = null; + job.TotalTasks = await db.TeachingTasks.CountAsync( + x => + x.AcademicTermId == plan.AcademicTermId && + x.Status == TeachingTaskStatus.Published, + stoppingToken); + job.ProcessedTasks = 0; + job.CreatedEntries = 0; + job.CompletedTasks = 0; + job.MessagesJson = null; + await db.SaveChangesAsync(stoppingToken); + + var progressClock = Stopwatch.StartNew(); + var lastPersistedTaskCount = 0; + + async Task ReportProgress( + AutomaticScheduleProgress progress, + CancellationToken cancellationToken) + { + var isFinal = progress.ProcessedTasks >= progress.TotalTasks; + var hasBatch = progress.ProcessedTasks - lastPersistedTaskCount >= 5; + if (!isFinal && !hasBatch && progressClock.ElapsedMilliseconds < 500) + return; + + await PersistProgressAsync(jobId, progress, cancellationToken); + lastPersistedTaskCount = progress.ProcessedTasks; + progressClock.Restart(); + } + + var result = await generator.GenerateAsync( + plan, + ReportProgress, + false, + stoppingToken); + job.Status = AutomaticScheduleJobStatus.Succeeded; + job.ActiveSchedulePlanId = null; + job.TotalTasks = result.TotalTasks; + job.ProcessedTasks = result.ProcessedTasks; + job.CreatedEntries = result.CreatedEntries; + job.CompletedTasks = result.CompletedTasks; + job.MessagesJson = JsonSerializer.Serialize(result.Messages); + job.CompletedAt = DateTime.UtcNow; + await using var transaction = + await db.Database.BeginTransactionAsync(stoppingToken); + await db.SaveChangesAsync(stoppingToken); + await transaction.CommitAsync(stoppingToken); + + logger.LogInformation( + "Automatic schedule job {JobId} completed with {CreatedEntries} entries.", + job.Id, + result.CreatedEntries); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + logger.LogInformation( + "Automatic schedule job {JobId} was interrupted by application shutdown.", + jobId); + throw; + } + catch (Exception exception) + { + logger.LogError( + exception, + "Automatic schedule job {JobId} failed.", + jobId); + await MarkFailedAsync(jobId, exception); + } + } + + private async Task PersistProgressAsync( + Guid jobId, + AutomaticScheduleProgress progress, + CancellationToken cancellationToken) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var progressDb = scope.ServiceProvider.GetRequiredService(); + var updatedAt = DateTime.UtcNow; + await progressDb.AutomaticScheduleJobs + .Where(x => + x.Id == jobId && + x.Status == AutomaticScheduleJobStatus.Running) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(x => x.TotalTasks, progress.TotalTasks) + .SetProperty(x => x.ProcessedTasks, progress.ProcessedTasks) + .SetProperty(x => x.CreatedEntries, progress.CreatedEntries) + .SetProperty(x => x.CompletedTasks, progress.CompletedTasks) + .SetProperty(x => x.UpdatedAt, updatedAt), + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Could not persist progress for automatic schedule job {JobId}.", + jobId); + } + } + + private async Task MarkFailedAsync(Guid jobId, Exception exception) + { + db.ChangeTracker.Clear(); + var job = await db.AutomaticScheduleJobs.FirstOrDefaultAsync( + x => x.Id == jobId, + CancellationToken.None); + if (job is null) + return; + + var message = exception.GetBaseException().Message; + job.Status = AutomaticScheduleJobStatus.Failed; + job.ActiveSchedulePlanId = null; + job.ErrorMessage = message.Length <= 2000 ? message : message[..2000]; + job.CompletedAt = DateTime.UtcNow; + await db.SaveChangesAsync(CancellationToken.None); + } +} diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 7529ad3..79dfa75 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -89,6 +89,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) diff --git a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs index 73d172b..98d299c 100644 --- a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs +++ b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs @@ -26,6 +26,7 @@ public sealed class AutomaticScheduleGeneratorTests await migrator.MigrateAsync(); Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0); + Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0); Assert.True(await db.Database .SqlQueryRaw( """ @@ -112,15 +113,167 @@ public sealed class AutomaticScheduleGeneratorTests }); await db.SaveChangesAsync(); + var progressUpdates = new List(); var result = await new AutomaticScheduleGenerator(db) - .GenerateAsync(plan, CancellationToken.None); + .GenerateAsync( + plan, + (progress, _) => + { + progressUpdates.Add(progress); + return Task.CompletedTask; + }, + true, + CancellationToken.None); var entry = await db.ScheduleEntries.SingleAsync(); Assert.Equal(1, result.CreatedEntries); Assert.Empty(result.Messages); + Assert.Collection( + progressUpdates, + progress => + { + Assert.Equal(1, progress.TotalTasks); + Assert.Equal(0, progress.ProcessedTasks); + }, + progress => + { + Assert.Equal(1, progress.TotalTasks); + Assert.Equal(1, progress.ProcessedTasks); + Assert.Equal(1, progress.CreatedEntries); + }); Assert.Equal(3, entry.DayOfWeek); Assert.Equal(1, entry.StartPeriod); Assert.Equal(2, entry.PeriodCount); Assert.Null(entry.ClassroomId); } + + [Fact] + public async Task Generator_does_not_attach_duplicate_buildings_from_untracked_classrooms() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var term = new AcademicTerm + { + Code = "2026-B", + Name = "2026 秋季", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 15) + }; + var campus = new Campus { Code = "MAIN", Name = "主校区" }; + var building = new Building + { + Code = "A", + Name = "教学楼 A", + Campus = campus + }; + var smallRoom = new Classroom + { + Code = "A101", + Name = "A101", + Building = building, + Capacity = 20 + }; + var largeRoom = new Classroom + { + Code = "A102", + Name = "A102", + Building = building, + Capacity = 30 + }; + var college = new College { Code = "CS", Name = "计算机学院" }; + var course = new Course + { + Code = "CS-01", + Name = "程序设计", + College = college, + Credits = 2, + TotalHours = 32, + LectureHours = 32 + }; + var smallTask = new TeachingTask + { + TaskNumber = "TASK-SMALL", + Name = "小班教学任务", + AcademicTerm = term, + Course = course, + Capacity = 20, + StartWeek = 1, + EndWeek = 16, + WeeklyHours = 2, + Status = TeachingTaskStatus.Published + }; + var largeTask = new TeachingTask + { + TaskNumber = "TASK-LARGE", + Name = "大班教学任务", + AcademicTerm = term, + Course = course, + Capacity = 30, + StartWeek = 1, + EndWeek = 16, + WeeklyHours = 2, + Status = TeachingTaskStatus.Published + }; + var plan = new SchedulePlan + { + AcademicTerm = term, + Name = "同楼多教室排课", + Version = "V1" + }; + db.AddRange( + term, + campus, + building, + smallRoom, + largeRoom, + college, + course, + smallTask, + largeTask, + plan); + db.ScheduleTimeSlots.AddRange( + new ScheduleTimeSlot + { + AcademicTerm = term, + PeriodNumber = 1, + Name = "第 1 节", + StartsAt = new TimeOnly(8, 0), + EndsAt = new TimeOnly(8, 45) + }, + new ScheduleTimeSlot + { + AcademicTerm = term, + PeriodNumber = 2, + Name = "第 2 节", + StartsAt = new TimeOnly(8, 55), + EndsAt = new TimeOnly(9, 40) + }); + await db.SaveChangesAsync(); + + db.ChangeTracker.Clear(); + plan = await db.SchedulePlans.SingleAsync(); + + var result = await new AutomaticScheduleGenerator(db) + .GenerateAsync(plan, CancellationToken.None); + var entries = await db.ScheduleEntries + .OrderBy(x => x.TeachingTaskId) + .ToListAsync(); + + Assert.Equal(2, result.CreatedEntries); + Assert.Empty(result.Messages); + Assert.Equal(2, entries.Count); + Assert.True( + new HashSet { smallRoom.Id, largeRoom.Id } + .SetEquals(entries.Select(x => x.ClassroomId!.Value))); + Assert.Equal(1, await db.Buildings.CountAsync()); + Assert.Equal(2, await db.Classrooms.CountAsync()); + } } diff --git a/tests/Jiaowu.Api.Tests/AutomaticScheduleJobProcessorTests.cs b/tests/Jiaowu.Api.Tests/AutomaticScheduleJobProcessorTests.cs new file mode 100644 index 0000000..1516631 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/AutomaticScheduleJobProcessorTests.cs @@ -0,0 +1,139 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Scheduling; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Jiaowu.Api.Tests; + +public sealed class AutomaticScheduleJobProcessorTests +{ + [Fact] + public async Task Processor_persists_progress_result_and_schedule_entries() + { + var databasePath = Path.Combine( + Path.GetTempPath(), + $"jiaowu-auto-schedule-{Guid.NewGuid():N}.sqlite"); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext( + options => options.UseSqlite( + $"Data Source={databasePath};Pooling=False")); + services.AddScoped(); + services.AddScoped(); + var provider = services.BuildServiceProvider(); + + try + { + Guid jobId; + await using (var seedScope = provider.CreateAsyncScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + var term = new AcademicTerm + { + Code = "2026-JOB", + Name = "后台排课学期", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 15) + }; + var college = new College { Code = "JOB", Name = "后台任务学院" }; + var course = new Course + { + Code = "JOB-01", + Name = "后台任务课程", + College = college, + Credits = 1, + TotalHours = 32, + LectureHours = 32 + }; + var task = new TeachingTask + { + TaskNumber = "TASK-JOB", + Name = "后台任务教学班", + AcademicTerm = term, + Course = course, + Capacity = 100, + StartWeek = 1, + EndWeek = 16, + WeeklyHours = 2, + Status = TeachingTaskStatus.Published + }; + var plan = new SchedulePlan + { + AcademicTerm = term, + Name = "后台排课草稿", + Version = "V1" + }; + var job = new AutomaticScheduleJob + { + SchedulePlan = plan, + ActiveSchedulePlanId = plan.Id + }; + jobId = job.Id; + db.AddRange(term, college, course, task, plan, job); + db.ScheduleTimeSlots.AddRange( + new ScheduleTimeSlot + { + AcademicTerm = term, + PeriodNumber = 1, + Name = "第 1 节", + StartsAt = new TimeOnly(8, 0), + EndsAt = new TimeOnly(8, 45) + }, + new ScheduleTimeSlot + { + AcademicTerm = term, + PeriodNumber = 2, + Name = "第 2 节", + StartsAt = new TimeOnly(8, 55), + EndsAt = new TimeOnly(9, 40) + }); + db.TeachingTaskScheduleConstraints.Add( + new TeachingTaskScheduleConstraint + { + TeachingTask = task, + RequiresClassroom = false, + AllowedDayOfWeeks = "2", + EarliestPeriod = 1, + LatestPeriod = 2 + }); + await db.SaveChangesAsync(); + } + + await using (var workerScope = provider.CreateAsyncScope()) + { + var processor = workerScope.ServiceProvider + .GetRequiredService(); + await processor.ProcessAsync(jobId, CancellationToken.None); + } + + await using (var assertScope = provider.CreateAsyncScope()) + { + var db = assertScope.ServiceProvider.GetRequiredService(); + var job = await db.AutomaticScheduleJobs.SingleAsync(); + var entry = await db.ScheduleEntries.SingleAsync(); + + Assert.Equal(AutomaticScheduleJobStatus.Succeeded, job.Status); + Assert.Null(job.ActiveSchedulePlanId); + Assert.Equal(1, job.TotalTasks); + Assert.Equal(1, job.ProcessedTasks); + Assert.Equal(1, job.CreatedEntries); + Assert.Equal(1, job.CompletedTasks); + Assert.Equal("[]", job.MessagesJson); + Assert.NotNull(job.StartedAt); + Assert.NotNull(job.CompletedAt); + Assert.Null(job.ErrorMessage); + Assert.Equal(2, entry.DayOfWeek); + Assert.Equal(2, entry.PeriodCount); + } + } + finally + { + await provider.DisposeAsync(); + File.Delete(databasePath); + } + } +} diff --git a/web/src/style.css b/web/src/style.css index 888d693..f3212f9 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -323,6 +323,13 @@ button { cursor: pointer; } .schedule-sheet-head > div:first-child > span { color: var(--teal); font-size: 10px; letter-spacing: .07em; } .schedule-sheet-head h3 { margin: 6px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 21px; } .schedule-sheet-head p { margin: 0; color: var(--muted); font-size: 10px; } +.auto-schedule-progress { padding: 13px 18px; display: grid; grid-template-columns: minmax(260px, 1fr) minmax(220px, 38%); align-items: center; gap: 20px; border-bottom: 1px solid #c9d9dc; background: #f1f7f7; } +.auto-schedule-progress > div { display: grid; gap: 3px; } +.auto-schedule-progress b { color: #24484d; font-size: 12px; font-weight: 650; } +.auto-schedule-progress span { color: var(--muted); font-size: 10px; } +.auto-schedule-progress.is-succeeded { border-bottom-color: #bedac8; background: #f1f8f3; } +.auto-schedule-progress.is-failed { border-bottom-color: #e6c5c5; background: #fff5f4; } +.auto-schedule-progress.is-failed b { color: #9d3434; } .schedule-search { padding: 12px 16px; display: flex; align-items: center; gap: 10px; background: #fafbfc; border-bottom: 1px solid var(--line); } .schedule-search .el-input { width: 320px; } .schedule-search > span { margin-left: auto; color: var(--muted); font-size: 10px; } @@ -1012,6 +1019,7 @@ button { cursor: pointer; } .schedule-version-strip { margin-top: 10px; } .schedule-sheet-head { display: block; } .schedule-sheet-head .plan-actions { margin-top: 15px; } + .auto-schedule-progress { grid-template-columns: 1fr; gap: 10px; } .schedule-search { flex-wrap: wrap; } .schedule-search .el-input { width: 100%; } .schedule-search > span { width: 100%; margin-left: 0; } diff --git a/web/src/views/SchedulesView.vue b/web/src/views/SchedulesView.vue index 24f1f5f..3e4c82f 100644 --- a/web/src/views/SchedulesView.vue +++ b/web/src/views/SchedulesView.vue @@ -1,5 +1,5 @@