diff --git a/src/Jiaowu.Api/Controllers/SchedulesController.cs b/src/Jiaowu.Api/Controllers/SchedulesController.cs index 0e9a22d..687f8da 100644 --- a/src/Jiaowu.Api/Controllers/SchedulesController.cs +++ b/src/Jiaowu.Api/Controllers/SchedulesController.cs @@ -16,7 +16,8 @@ namespace Jiaowu.Api.Controllers; [Route("api/schedules")] public sealed class SchedulesController( AppDbContext db, - AutomaticScheduleJobQueue automaticScheduleJobQueue) : ControllerBase + AutomaticScheduleJobQueue automaticScheduleJobQueue, + SchedulePublishJobQueue schedulePublishJobQueue) : ControllerBase { private const string ManagementRoles = SystemRoles.SuperAdmin + "," + @@ -130,8 +131,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 (await HasActiveScheduleJobAsync(id, cancellationToken)) + return ScheduleJobRunningProblem(); if (plan.Status != SchedulePlanStatus.Draft) return ConflictProblem("已发布或已归档的排课版本不可直接修改。"); if (!await db.AcademicTerms.AnyAsync( @@ -151,8 +152,8 @@ public sealed class SchedulesController( CloneSchedulePlanRequest request, CancellationToken cancellationToken) { - if (await HasActiveAutomaticScheduleJobAsync(id, cancellationToken)) - return AutomaticScheduleRunningProblem(); + if (await HasActiveScheduleJobAsync(id, cancellationToken)) + return ScheduleJobRunningProblem(); var source = await db.SchedulePlans.AsNoTracking() .Include(x => x.Entries) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); @@ -185,8 +186,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 (await HasActiveScheduleJobAsync(id, cancellationToken)) + return ScheduleJobRunningProblem(); if (plan.Status != SchedulePlanStatus.Draft) return ConflictProblem("仅草稿排课版本可以删除。"); db.SchedulePlans.Remove(plan); @@ -194,76 +195,79 @@ public sealed class SchedulesController( } [HttpPost("plans/{id:guid}/publish")] - public async Task PublishPlan(Guid id, CancellationToken cancellationToken) + 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) - .ThenInclude(x => x!.Teachers) - .Include(x => x.Entries) - .ThenInclude(x => x.TeachingTask) - .ThenInclude(x => x!.Classes) + var plan = await db.SchedulePlans.AsNoTracking() + .Select(x => new + { + x.Id, + x.AcademicTermId, + x.Status, + HasEntries = x.Entries.Any() + }) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (plan is null) return NotFound(); if (plan.Status != SchedulePlanStatus.Draft) return ConflictProblem("只有草稿排课版本可以发布。"); - if (plan.Entries.Count == 0) + if (!plan.HasEntries) return ConflictProblem("排课版本中至少需要一条课表安排。"); - foreach (var entry in plan.Entries) - { - var validation = await ValidateEntryAsync( - plan, - entry.Id, - new ScheduleEntryRequest( - entry.TeachingTaskId, - entry.ClassroomId, - entry.DayOfWeek, - entry.StartPeriod, - entry.PeriodCount, - entry.StartWeek, - entry.EndWeek, - entry.WeekPattern, - entry.Notes), + var existing = await db.SchedulePublishJobs.AsNoTracking() + .FirstOrDefaultAsync( + x => x.ActiveAcademicTermId == plan.AcademicTermId, cancellationToken); - if (validation is not null) return validation; + if (existing is not null) + { + if (existing.SchedulePlanId != id) + return ConflictProblem( + "同一学期已有课表正在后台检查并发布,请等待任务完成。"); + return AcceptedAtAction( + nameof(GetSchedulePublishJob), + new { jobId = existing.Id }, + ToResponse(existing)); } - var requiredTasks = await db.TeachingTasks.AsNoTracking() - .Where(x => - x.AcademicTermId == plan.AcademicTermId && - x.Status == TeachingTaskStatus.Published && - x.SchedulingMode == TeachingTaskSchedulingMode.Standard) - .Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours }) - .ToListAsync(cancellationToken); - var scheduledHours = plan.Entries - .GroupBy(x => x.TeachingTaskId) - .ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount)); - var incomplete = requiredTasks.FirstOrDefault(task => - !scheduledHours.TryGetValue(task.Id, out var hours) || - hours < task.WeeklyHours); - if (incomplete is not null) - return ConflictProblem( - $"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 {incomplete.WeeklyHours} 学时,不能发布。"); + var requestedByUserId = CurrentUserId(); + var job = new SchedulePublishJob + { + SchedulePlanId = id, + AcademicTermId = plan.AcademicTermId, + ActiveAcademicTermId = plan.AcademicTermId, + RequestedByUserId = requestedByUserId, + CurrentStep = "等待后台检查" + }; + db.SchedulePublishJobs.Add(job); + try + { + await db.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + db.Entry(job).State = EntityState.Detached; + existing = await db.SchedulePublishJobs.AsNoTracking() + .FirstOrDefaultAsync( + x => x.ActiveAcademicTermId == plan.AcademicTermId, + cancellationToken); + if (existing is null) + throw; + if (existing.SchedulePlanId != id) + return ConflictProblem( + "同一学期已有课表正在后台检查并发布,请等待任务完成。"); + return AcceptedAtAction( + nameof(GetSchedulePublishJob), + new { jobId = existing.Id }, + ToResponse(existing)); + } - var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList()); - if (conflict is not null) return ConflictProblem(conflict); - - await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); - var previous = await db.SchedulePlans - .Where(x => - x.Id != plan.Id && - x.AcademicTermId == plan.AcademicTermId && - x.Status == SchedulePlanStatus.Published) - .ToListAsync(cancellationToken); - foreach (var oldPlan in previous) oldPlan.Status = SchedulePlanStatus.Archived; - plan.Status = SchedulePlanStatus.Published; - plan.PublishedAt = DateTime.UtcNow; - await db.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - return NoContent(); + schedulePublishJobQueue.Enqueue(job.Id); + return AcceptedAtAction( + nameof(GetSchedulePublishJob), + new { jobId = job.Id }, + ToResponse(job)); } [HttpPost("plans/{planId:guid}/entries")] @@ -272,8 +276,8 @@ public sealed class SchedulesController( ScheduleEntryRequest request, CancellationToken cancellationToken) { - if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) - return AutomaticScheduleRunningProblem(); + if (await HasActiveScheduleJobAsync(planId, cancellationToken)) + return ScheduleJobRunningProblem(); var plan = await DraftPlanAsync(planId, cancellationToken); if (plan is null) return NotFound(); var validation = await ValidateEntryAsync(plan, null, request, cancellationToken); @@ -290,6 +294,8 @@ public sealed class SchedulesController( { var plan = await DraftPlanAsync(planId, cancellationToken); if (plan is null) return NotFound(); + if (await HasActiveSchedulePublishJobAsync(planId, cancellationToken)) + return SchedulePublishRunningProblem(); var existing = await db.AutomaticScheduleJobs.AsNoTracking() .FirstOrDefaultAsync( @@ -364,6 +370,30 @@ public sealed class SchedulesController( return Ok(job is null ? null : ToResponse(job)); } + [HttpGet("publish-jobs/{jobId:guid}")] + public async Task> + GetSchedulePublishJob( + Guid jobId, + CancellationToken cancellationToken) + { + var job = await db.SchedulePublishJobs.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken); + return job is null ? NotFound() : Ok(ToResponse(job)); + } + + [HttpGet("plans/{planId:guid}/publish-job")] + public async Task> + GetLatestSchedulePublishJob( + Guid planId, + CancellationToken cancellationToken) + { + var job = await db.SchedulePublishJobs.AsNoTracking() + .Where(x => x.SchedulePlanId == planId) + .OrderByDescending(x => x.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + return Ok(job is null ? null : ToResponse(job)); + } + [HttpPut("plans/{planId:guid}/entries/{entryId:guid}")] public async Task UpdateEntry( Guid planId, @@ -371,8 +401,8 @@ public sealed class SchedulesController( ScheduleEntryRequest request, CancellationToken cancellationToken) { - if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) - return AutomaticScheduleRunningProblem(); + if (await HasActiveScheduleJobAsync(planId, cancellationToken)) + return ScheduleJobRunningProblem(); var plan = await DraftPlanAsync(planId, cancellationToken); if (plan is null) return NotFound(); var entry = await db.ScheduleEntries @@ -400,8 +430,8 @@ public sealed class SchedulesController( Guid entryId, CancellationToken cancellationToken) { - if (await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken)) - return AutomaticScheduleRunningProblem(); + if (await HasActiveScheduleJobAsync(planId, cancellationToken)) + return ScheduleJobRunningProblem(); if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound(); var entry = await db.ScheduleEntries .FirstOrDefaultAsync( @@ -426,6 +456,19 @@ public sealed class SchedulesController( x => x.ActiveSchedulePlanId == planId, cancellationToken); + private Task HasActiveSchedulePublishJobAsync( + Guid planId, + CancellationToken cancellationToken) => + db.SchedulePublishJobs.AsNoTracking().AnyAsync( + x => x.SchedulePlanId == planId && x.ActiveAcademicTermId != null, + cancellationToken); + + private async Task HasActiveScheduleJobAsync( + Guid planId, + CancellationToken cancellationToken) => + await HasActiveAutomaticScheduleJobAsync(planId, cancellationToken) || + await HasActiveSchedulePublishJobAsync(planId, cancellationToken); + private async Task ValidateEntryAsync( SchedulePlan plan, Guid? entryId, @@ -585,6 +628,12 @@ public sealed class SchedulesController( private ActionResult AutomaticScheduleRunningProblem() => ConflictProblem("自动排课正在后台运行,请等待任务完成后再修改该排课版本。"); + private ActionResult SchedulePublishRunningProblem() => + ConflictProblem("课表正在后台检查并发布,请等待任务完成后再修改。"); + + private ActionResult ScheduleJobRunningProblem() => + ConflictProblem("后台任务正在处理该排课版本,请等待任务完成后再修改。"); + private static AutomaticScheduleJobResponse ToResponse(AutomaticScheduleJob job) { IReadOnlyList messages = []; @@ -608,6 +657,24 @@ public sealed class SchedulesController( job.CompletedAt); } + private static SchedulePublishJobResponse ToResponse(SchedulePublishJob job) => + new( + job.Id, + job.SchedulePlanId, + job.Status, + job.TotalSteps, + job.CompletedSteps, + job.CurrentStep, + job.ErrorMessage, + job.CreatedAt, + job.StartedAt, + job.CompletedAt); + + private Guid? CurrentUserId() => + Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId) + ? userId + : null; + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } @@ -646,3 +713,15 @@ public sealed record AutomaticScheduleJobResponse( DateTime CreatedAt, DateTime? StartedAt, DateTime? CompletedAt); + +public sealed record SchedulePublishJobResponse( + Guid Id, + Guid SchedulePlanId, + SchedulePublishJobStatus Status, + int TotalSteps, + int CompletedSteps, + string? CurrentStep, + 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 ea493f0..75d7794 100644 --- a/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs @@ -83,6 +83,23 @@ public sealed class AutomaticScheduleJob : EntityBase public DateTime? CompletedAt { get; set; } } +public sealed class SchedulePublishJob : EntityBase +{ + public Guid SchedulePlanId { get; set; } + public SchedulePlan? SchedulePlan { get; set; } + public Guid AcademicTermId { get; set; } + public Guid? ActiveAcademicTermId { get; set; } + public Guid? RequestedByUserId { get; set; } + public SchedulePublishJobStatus Status { get; set; } = + SchedulePublishJobStatus.Queued; + public int TotalSteps { get; set; } = 5; + public int CompletedSteps { get; set; } + public string? CurrentStep { get; set; } + public string? ErrorMessage { get; set; } + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } +} + public enum SchedulePlanStatus { Draft = 1, @@ -98,6 +115,14 @@ public enum AutomaticScheduleJobStatus Failed = 4 } +public enum SchedulePublishJobStatus +{ + 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 793e258..ae5cbd3 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -38,6 +38,8 @@ public sealed class AppDbContext(DbContextOptions options) Set(); public DbSet AutomaticScheduleJobs => Set(); + public DbSet SchedulePublishJobs => + Set(); public DbSet CourseSelectionRounds => Set(); public DbSet CourseSelectionOfferings => @@ -400,6 +402,24 @@ public sealed class AppDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.SetNull); }); + builder.Entity(entity => + { + entity.Property(x => x.CurrentStep).HasMaxLength(200); + entity.Property(x => x.ErrorMessage).HasMaxLength(2000); + entity.HasIndex(x => x.ActiveAcademicTermId).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 39f4baf..e793aab 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -27,6 +27,8 @@ public sealed class DevelopmentSqliteMigrator( "20260724_16_automatic_schedule_jobs"; private const string TeachingTaskSchedulingModesMigration = "20260725_17_teaching_task_scheduling_modes"; + private const string SchedulePublishJobsMigration = + "20260725_18_schedule_publish_jobs"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -163,6 +165,18 @@ public sealed class DevelopmentSqliteMigrator( TeachingTaskSchedulingModesMigration, teachingTaskSchedulingModeExists ? [] : TeachingTaskSchedulingModeStatements, cancellationToken); + var schedulePublishJobsExist = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM sqlite_master + WHERE type = 'table' AND name = 'SchedulePublishJobs' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + SchedulePublishJobsMigration, + schedulePublishJobsExist ? [] : SchedulePublishJobStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -1104,4 +1118,48 @@ public sealed class DevelopmentSqliteMigrator( ADD COLUMN "SchedulingMode" INTEGER NOT NULL DEFAULT 1; """ ]; + + private static readonly string[] SchedulePublishJobStatements = + [ + """ + CREATE TABLE "SchedulePublishJobs" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_SchedulePublishJobs" PRIMARY KEY, + "SchedulePlanId" TEXT NOT NULL, + "AcademicTermId" TEXT NOT NULL, + "ActiveAcademicTermId" TEXT NULL, + "RequestedByUserId" TEXT NULL, + "Status" INTEGER NOT NULL, + "TotalSteps" INTEGER NOT NULL, + "CompletedSteps" INTEGER NOT NULL, + "CurrentStep" TEXT NULL, + "ErrorMessage" TEXT NULL, + "StartedAt" TEXT NULL, + "CompletedAt" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_SchedulePublishJobs_SchedulePlans" + FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_SchedulePublishJobs_RequestedBy" + FOREIGN KEY ("RequestedByUserId") REFERENCES "AspNetUsers" ("Id") + ON DELETE SET NULL + ); + """, + """ + CREATE UNIQUE INDEX "IX_SchedulePublishJobs_ActiveAcademicTermId" + ON "SchedulePublishJobs" ("ActiveAcademicTermId"); + """, + """ + CREATE INDEX "IX_SchedulePublishJobs_SchedulePlanId_CreatedAt" + ON "SchedulePublishJobs" ("SchedulePlanId", "CreatedAt"); + """, + """ + CREATE INDEX "IX_SchedulePublishJobs_Status_CreatedAt" + ON "SchedulePublishJobs" ("Status", "CreatedAt"); + """, + """ + CREATE INDEX "IX_SchedulePublishJobs_RequestedByUserId" + ON "SchedulePublishJobs" ("RequestedByUserId"); + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725040310_SchedulePublishJobs.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725040310_SchedulePublishJobs.Designer.cs new file mode 100644 index 0000000..4c3d42d --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725040310_SchedulePublishJobs.Designer.cs @@ -0,0 +1,2953 @@ +// +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("20260725040310_SchedulePublishJobs")] + partial class SchedulePublishJobs + { + /// + 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.SchedulePublishJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("ActiveAcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedSteps") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurrentStep") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + 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("TotalSteps") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAcademicTermId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("SchedulePublishJobs"); + }); + + 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("SchedulingMode") + .HasColumnType("int"); + + 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.SchedulePublishJob", 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.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/20260725040310_SchedulePublishJobs.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725040310_SchedulePublishJobs.cs new file mode 100644 index 0000000..13d12ca --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725040310_SchedulePublishJobs.cs @@ -0,0 +1,80 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class SchedulePublishJobs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SchedulePublishJobs", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + SchedulePlanId = table.Column(type: "char(36)", nullable: false), + AcademicTermId = table.Column(type: "char(36)", nullable: false), + ActiveAcademicTermId = table.Column(type: "char(36)", nullable: true), + RequestedByUserId = table.Column(type: "char(36)", nullable: true), + Status = table.Column(type: "int", nullable: false), + TotalSteps = table.Column(type: "int", nullable: false), + CompletedSteps = table.Column(type: "int", nullable: false), + CurrentStep = table.Column(type: "varchar(200)", maxLength: 200, 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_SchedulePublishJobs", x => x.Id); + table.ForeignKey( + name: "FK_SchedulePublishJobs_AspNetUsers_RequestedByUserId", + column: x => x.RequestedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_SchedulePublishJobs_SchedulePlans_SchedulePlanId", + column: x => x.SchedulePlanId, + principalTable: "SchedulePlans", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_SchedulePublishJobs_ActiveAcademicTermId", + table: "SchedulePublishJobs", + column: "ActiveAcademicTermId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SchedulePublishJobs_RequestedByUserId", + table: "SchedulePublishJobs", + column: "RequestedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_SchedulePublishJobs_SchedulePlanId_CreatedAt", + table: "SchedulePublishJobs", + columns: new[] { "SchedulePlanId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_SchedulePublishJobs_Status_CreatedAt", + table: "SchedulePublishJobs", + columns: new[] { "Status", "CreatedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SchedulePublishJobs"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index 68639af..ff80c8c 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -1448,6 +1448,67 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("SchedulePlans"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("ActiveAcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedSteps") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurrentStep") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + 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("TotalSteps") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAcademicTermId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("SchedulePublishJobs"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => { b.Property("Id") @@ -2553,6 +2614,22 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("AcademicTerm"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", 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.ScheduleTimeSlot", b => { b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs new file mode 100644 index 0000000..00e0538 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs @@ -0,0 +1,371 @@ +using System.Threading.Channels; +using System.Diagnostics.CodeAnalysis; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.Scheduling; + +public sealed class SchedulePublishJobQueue +{ + 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 SchedulePublishJobWorker( + IServiceScopeFactory scopeFactory, + SchedulePublishJobQueue 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 schedule publish job {JobId}.", + jobId); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + logger.LogInformation("Schedule publish 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.SchedulePublishJobs + .Where(x => + x.Status == SchedulePublishJobStatus.Queued || + x.Status == SchedulePublishJobStatus.Running) + .OrderBy(x => x.CreatedAt) + .ToListAsync(cancellationToken); + + foreach (var job in jobs) + { + job.Status = SchedulePublishJobStatus.Queued; + job.ActiveAcademicTermId = job.AcademicTermId; + job.CompletedSteps = 0; + job.CurrentStep = "等待后台检查"; + 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 schedule publish jobs.", + jobs.Count); + } + } +} + +public sealed class SchedulePublishJobProcessor( + AppDbContext db, + SchedulePlanPublisher publisher, + ILogger logger) +{ + public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken) + { + try + { + var job = await db.SchedulePublishJobs + .FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken); + if (job is null || + job.Status is SchedulePublishJobStatus.Succeeded + or SchedulePublishJobStatus.Failed) + { + return; + } + + job.Status = SchedulePublishJobStatus.Running; + job.StartedAt = DateTime.UtcNow; + job.CompletedAt = null; + job.CompletedSteps = 0; + job.CurrentStep = "读取排课版本"; + job.ErrorMessage = null; + await db.SaveChangesAsync(stoppingToken); + + async Task ReportProgress( + int completedSteps, + string currentStep, + CancellationToken cancellationToken) + { + job.CompletedSteps = completedSteps; + job.CurrentStep = currentStep; + await db.SaveChangesAsync(cancellationToken); + } + + var plan = await publisher.ValidateAsync( + job.SchedulePlanId, + ReportProgress, + stoppingToken); + + await using var transaction = + await db.Database.BeginTransactionAsync(stoppingToken); + if (plan.Status != SchedulePlanStatus.Draft) + throw new SchedulePublishValidationException( + "排课草稿状态已发生变化,请刷新后重试。"); + + var previous = await db.SchedulePlans + .Where(x => + x.Id != plan.Id && + x.AcademicTermId == plan.AcademicTermId && + x.Status == SchedulePlanStatus.Published) + .ToListAsync(stoppingToken); + foreach (var oldPlan in previous) + oldPlan.Status = SchedulePlanStatus.Archived; + + plan.Status = SchedulePlanStatus.Published; + plan.PublishedAt = DateTime.UtcNow; + job.Status = SchedulePublishJobStatus.Succeeded; + job.ActiveAcademicTermId = null; + job.CompletedSteps = job.TotalSteps; + job.CurrentStep = "课表已发布"; + job.CompletedAt = DateTime.UtcNow; + await db.SaveChangesAsync(stoppingToken); + await transaction.CommitAsync(stoppingToken); + + logger.LogInformation( + "Schedule publish job {JobId} published plan {SchedulePlanId}.", + job.Id, + plan.Id); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + logger.LogInformation( + "Schedule publish job {JobId} was interrupted by application shutdown.", + jobId); + throw; + } + catch (Exception exception) + { + logger.LogError(exception, "Schedule publish job {JobId} failed.", jobId); + await MarkFailedAsync(jobId, exception); + } + } + + private async Task MarkFailedAsync(Guid jobId, Exception exception) + { + db.ChangeTracker.Clear(); + var job = await db.SchedulePublishJobs.FirstOrDefaultAsync( + x => x.Id == jobId, + CancellationToken.None); + if (job is null) + return; + + var message = exception.GetBaseException().Message; + job.Status = SchedulePublishJobStatus.Failed; + job.ActiveAcademicTermId = null; + job.CurrentStep = "检查未通过"; + job.ErrorMessage = message.Length <= 2000 ? message : message[..2000]; + job.CompletedAt = DateTime.UtcNow; + await db.SaveChangesAsync(CancellationToken.None); + } +} + +public sealed class SchedulePlanPublisher(AppDbContext db) +{ + public async Task ValidateAsync( + Guid planId, + Func reportProgress, + CancellationToken cancellationToken) + { + var plan = await db.SchedulePlans + .AsSplitQuery() + .Include(x => x.Entries) + .ThenInclude(x => x.Classroom) + .ThenInclude(x => x!.Building) + .Include(x => x.Entries) + .ThenInclude(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .Include(x => x.Entries) + .ThenInclude(x => x.TeachingTask) + .ThenInclude(x => x!.Classes) + .ThenInclude(x => x.AdministrativeClass) + .ThenInclude(x => x!.Students) + .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken) + ?? throw new SchedulePublishValidationException("排课草稿不存在。"); + + if (plan.Status != SchedulePlanStatus.Draft) + throw new SchedulePublishValidationException("只有草稿排课版本可以发布。"); + if (plan.Entries.Count == 0) + throw new SchedulePublishValidationException( + "排课版本中至少需要一条课表安排。"); + + await reportProgress(1, "校验课程、节次与教室", cancellationToken); + var activePeriods = (await db.ScheduleTimeSlots.AsNoTracking() + .Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled) + .Select(x => x.PeriodNumber) + .ToListAsync(cancellationToken)) + .ToHashSet(); + if (activePeriods.Count == 0) + throw new SchedulePublishValidationException( + "请先维护该学期的上课时间表。"); + + var taskIds = plan.Entries.Select(x => x.TeachingTaskId).Distinct().ToList(); + var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() + .Where(x => taskIds.Contains(x.TeachingTaskId)) + .Include(x => x.AllowedClassrooms) + .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); + + foreach (var entry in plan.Entries) + { + ValidateEntry(plan, entry, activePeriods, constraints); + } + + await reportProgress(2, "校验教学任务完整性", cancellationToken); + var requiredTasks = await db.TeachingTasks.AsNoTracking() + .Where(x => + x.AcademicTermId == plan.AcademicTermId && + x.Status == TeachingTaskStatus.Published && + x.SchedulingMode == TeachingTaskSchedulingMode.Standard) + .Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours }) + .ToListAsync(cancellationToken); + var scheduledHours = plan.Entries + .GroupBy(x => x.TeachingTaskId) + .ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount)); + var incomplete = requiredTasks.FirstOrDefault(task => + !scheduledHours.TryGetValue(task.Id, out var hours) || + hours < task.WeeklyHours); + if (incomplete is not null) + { + throw new SchedulePublishValidationException( + $"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 " + + $"{incomplete.WeeklyHours} 学时,不能发布。"); + } + + await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken); + var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList()); + if (conflict is not null) + throw new SchedulePublishValidationException(conflict); + + await reportProgress(4, "写入正式课表", cancellationToken); + return plan; + } + + private static void ValidateEntry( + SchedulePlan plan, + ScheduleEntry entry, + HashSet activePeriods, + IReadOnlyDictionary constraints) + { + var task = entry.TeachingTask; + if (entry.StartWeek > entry.EndWeek) + Fail(entry, "开始周不能晚于结束周"); + if (Enumerable.Range(entry.StartPeriod, entry.PeriodCount) + .Any(period => !activePeriods.Contains(period))) + Fail(entry, "所选节次包含未启用或不存在的上课时间"); + if (task is null || + task.Status != TeachingTaskStatus.Published || + task.AcademicTermId != plan.AcademicTermId) + Fail(entry, "只能安排同一学期内已发布的教学任务"); + if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) + Fail(entry, "非排时课程不应进入正常课表"); + if (entry.StartWeek < task.StartWeek || entry.EndWeek > task.EndWeek) + Fail(entry, "排课周次不在教学任务的授课周次内"); + + constraints.TryGetValue(entry.TeachingTaskId, out var constraint); + var requiresClassroom = constraint?.RequiresClassroom ?? true; + if (requiresClassroom && entry.ClassroomId is null) + Fail(entry, "该课程需要占用教室"); + if (!requiresClassroom && entry.ClassroomId is not null) + Fail(entry, "该课程已设置为不占用教室"); + + var allowedDays = ParseDays(constraint?.AllowedDayOfWeeks); + if (allowedDays.Count > 0 && !allowedDays.Contains(entry.DayOfWeek)) + Fail(entry, "上课日不在教学任务允许范围内"); + if (constraint?.EarliestPeriod is int earliest && + entry.StartPeriod < earliest) + Fail(entry, $"最早只能从第 {earliest} 节开始"); + if (constraint?.LatestPeriod is int latest && + entry.StartPeriod + entry.PeriodCount - 1 > latest) + Fail(entry, $"最晚必须在第 {latest} 节结束"); + + var classroom = entry.Classroom; + if (entry.ClassroomId.HasValue) + { + if (classroom is null || !classroom.IsEnabled) + Fail(entry, "所选教室不存在或已停用"); + if (constraint?.RequiredCampusId is Guid campusId && + classroom.Building!.CampusId != campusId) + Fail(entry, "所选教室不在指定校区"); + if (constraint?.RequiredBuildingId is Guid buildingId && + classroom.BuildingId != buildingId) + Fail(entry, "所选教室不在指定教学楼"); + var allowedClassroomIds = constraint?.AllowedClassrooms + .Select(x => x.ClassroomId) + .ToHashSet() ?? []; + if (allowedClassroomIds.Count > 0 && + !allowedClassroomIds.Contains(classroom.Id)) + Fail(entry, "所选教室不在指定教室范围内"); + } + + var studentCount = task.Classes.Sum(x => + x.AdministrativeClass!.Students.Count(student => + student.Status == StudentStatus.Active)); + var requiredCapacity = Math.Max(task.Capacity, studentCount); + if (classroom is not null && requiredCapacity > classroom.Capacity) + { + Fail(entry, + $"教室容量不足:需要 {requiredCapacity} 人,教室仅容纳 {classroom.Capacity} 人"); + } + } + + private static HashSet ParseDays(string? value) => + string.IsNullOrWhiteSpace(value) + ? [] + : value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse) + .ToHashSet(); + + [DoesNotReturn] + private static void Fail(ScheduleEntry entry, string message) + { + var name = entry.TeachingTask?.Name ?? entry.TeachingTaskId.ToString(); + throw new SchedulePublishValidationException($"“{name}”:{message}。"); + } +} + +public sealed class SchedulePublishValidationException(string message) + : InvalidOperationException(message); diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 68ee16b..330c783 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -96,6 +96,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) diff --git a/tests/Jiaowu.Api.Tests/SchedulePublishJobProcessorTests.cs b/tests/Jiaowu.Api.Tests/SchedulePublishJobProcessorTests.cs new file mode 100644 index 0000000..9ad26f4 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/SchedulePublishJobProcessorTests.cs @@ -0,0 +1,176 @@ +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 SchedulePublishJobProcessorTests +{ + [Fact] + public async Task Processor_validates_and_publishes_plan_atomically() + { + var result = await RunPublishAsync(weeklyHours: 2); + + Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus); + Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus); + Assert.Null(result.ActiveAcademicTermId); + Assert.Equal(5, result.CompletedSteps); + Assert.Equal("课表已发布", result.CurrentStep); + Assert.Null(result.ErrorMessage); + Assert.NotNull(result.PublishedAt); + } + + [Fact] + public async Task Processor_keeps_draft_when_validation_fails() + { + var result = await RunPublishAsync(weeklyHours: 4); + + Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus); + Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus); + Assert.Null(result.ActiveAcademicTermId); + Assert.Equal("检查未通过", result.CurrentStep); + Assert.Contains("尚未达到每周 4 学时", result.ErrorMessage); + Assert.Null(result.PublishedAt); + } + + private static async Task RunPublishAsync(int weeklyHours) + { + var databasePath = Path.Combine( + Path.GetTempPath(), + $"jiaowu-publish-{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-PUBLISH-{weeklyHours}", + Name = "后台发布学期", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 15) + }; + var college = new College { Code = "PUB", Name = "发布测试学院" }; + var course = new Course + { + Code = "PUB-01", + Name = "发布测试课程", + College = college, + Credits = 1, + TotalHours = 32, + LectureHours = 32 + }; + var task = new TeachingTask + { + TaskNumber = $"TASK-PUB-{weeklyHours}", + Name = "发布测试教学班", + AcademicTerm = term, + Course = course, + Capacity = 60, + StartWeek = 1, + EndWeek = 16, + WeeklyHours = weeklyHours, + Status = TeachingTaskStatus.Published + }; + var plan = new SchedulePlan + { + AcademicTerm = term, + Name = "待发布课表", + Version = "V1" + }; + plan.Entries.Add(new ScheduleEntry + { + TeachingTask = task, + DayOfWeek = 1, + StartPeriod = 1, + PeriodCount = 2, + StartWeek = 1, + EndWeek = 16, + WeekPattern = WeekPattern.All + }); + var job = new SchedulePublishJob + { + SchedulePlan = plan, + AcademicTermId = term.Id, + ActiveAcademicTermId = term.Id, + CurrentStep = "等待后台检查" + }; + 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 + }); + 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 assertDb = assertScope.ServiceProvider.GetRequiredService(); + var jobResult = await assertDb.SchedulePublishJobs.SingleAsync(); + var planResult = await assertDb.SchedulePlans.SingleAsync(); + return new( + jobResult.Status, + planResult.Status, + jobResult.ActiveAcademicTermId, + jobResult.CompletedSteps, + jobResult.CurrentStep, + jobResult.ErrorMessage, + planResult.PublishedAt); + } + finally + { + await provider.DisposeAsync(); + File.Delete(databasePath); + } + } + + private sealed record PublishResult( + SchedulePublishJobStatus JobStatus, + SchedulePlanStatus PlanStatus, + Guid? ActiveAcademicTermId, + int CompletedSteps, + string? CurrentStep, + string? ErrorMessage, + DateTime? PublishedAt); +} diff --git a/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs b/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs index 0de4c22..a4ff62e 100644 --- a/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs @@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -10,6 +11,87 @@ namespace Jiaowu.Api.Tests; public sealed class SchedulesControllerTests { + [Fact] + public async Task Publish_returns_accepted_before_background_validation_runs() + { + 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-P", + Name = "2026 发布测试", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 7), + EndDate = new DateOnly(2027, 1, 17) + }; + var college = new College { Code = "P", Name = "发布学院" }; + var course = new Course + { + Code = "P101", + Name = "发布课程", + College = college, + Credits = 1, + TotalHours = 16, + LectureHours = 16 + }; + var task = new TeachingTask + { + TaskNumber = "TASK-P", + Name = "发布教学班", + AcademicTerm = term, + Course = course, + Capacity = 30, + WeeklyHours = 1, + Status = TeachingTaskStatus.Published + }; + var plan = new SchedulePlan + { + AcademicTerm = term, + Name = "发布草稿", + Version = "V1" + }; + plan.Entries.Add(new ScheduleEntry + { + TeachingTask = task, + DayOfWeek = 1, + StartPeriod = 1, + PeriodCount = 1, + StartWeek = 1, + EndWeek = 16, + WeekPattern = WeekPattern.All + }); + db.AddRange(term, college, course, task, plan); + await db.SaveChangesAsync(); + + var controller = new SchedulesController( + db, + new AutomaticScheduleJobQueue(), + new SchedulePublishJobQueue()) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + var result = await controller.PublishPlan(plan.Id, CancellationToken.None); + + var accepted = Assert.IsType(result.Result); + var response = Assert.IsType(accepted.Value); + Assert.Equal(SchedulePublishJobStatus.Queued, response.Status); + Assert.Equal(plan.Id, response.SchedulePlanId); + Assert.Equal( + SchedulePlanStatus.Draft, + (await db.SchedulePlans.SingleAsync()).Status); + Assert.Equal(1, await db.SchedulePublishJobs.CountAsync()); + } + [Fact] public async Task Latest_auto_schedule_job_remains_available_after_completion() { @@ -52,7 +134,8 @@ public sealed class SchedulesControllerTests var controller = new SchedulesController( db, - new AutomaticScheduleJobQueue()); + new AutomaticScheduleJobQueue(), + new SchedulePublishJobQueue()); var result = await controller.GetLatestAutomaticScheduleJob( plan.Id, CancellationToken.None); diff --git a/web/src/views/SchedulesView.vue b/web/src/views/SchedulesView.vue index 727bb19..5938ca0 100644 --- a/web/src/views/SchedulesView.vue +++ b/web/src/views/SchedulesView.vue @@ -15,6 +15,7 @@ const constraints = ref([]) const loading = ref(false) const detailLoading = ref(false) const autoJob = ref(null) +const publishJob = ref(null) const planDialog = ref(false) const cloneDialog = ref(false) const entryDialog = ref(false) @@ -41,6 +42,7 @@ const constraintFilters = reactive({ constraintState: undefined as string | undefined, }) let autoPollTimer: ReturnType | undefined +let publishPollTimer: ReturnType | undefined const weekdays = [ { value: 1, label: '星期一' }, @@ -71,6 +73,10 @@ const isDraft = computed(() => selected.value?.status === 'Draft') const autoLoading = computed(() => autoJob.value?.status === 'Queued' || autoJob.value?.status === 'Running', ) +const publishLoading = computed(() => + publishJob.value?.status === 'Queued' || publishJob.value?.status === 'Running', +) +const scheduleJobLoading = computed(() => autoLoading.value || publishLoading.value) const autoProgress = computed(() => { if (!autoJob.value?.totalTasks) return 0 return Math.min( @@ -94,6 +100,27 @@ const autoStatusText = computed(() => { } return autoJob.value.errorMessage || '后台排课失败,请稍后重试' }) +const publishProgress = computed(() => { + if (!publishJob.value?.totalSteps) return 0 + return Math.min( + 100, + Math.round(publishJob.value.completedSteps / publishJob.value.totalSteps * 100), + ) +}) +const publishProgressStatus = computed(() => { + if (publishJob.value?.status === 'Succeeded') return 'success' + if (publishJob.value?.status === 'Failed') return 'exception' + return undefined +}) +const publishStatusText = computed(() => { + if (!publishJob.value) return '' + if (publishJob.value.status === 'Queued') return '发布任务已进入队列,等待后台检查' + if (publishJob.value.status === 'Running') { + return publishJob.value.currentStep || '正在检查并发布课表' + } + if (publishJob.value.status === 'Succeeded') return '检查已通过,课表发布成功' + return publishJob.value.errorMessage || '课表检查未通过' +}) const selectedTaskConstraint = computed(() => constraints.value.find((item) => item.id === entryForm.teachingTaskId), ) @@ -402,6 +429,50 @@ async function autoSchedule() { } } +function clearPublishPoll() { + if (publishPollTimer) clearTimeout(publishPollTimer) + publishPollTimer = undefined +} + +function schedulePublishPoll(jobId: string, planId: string) { + clearPublishPoll() + publishPollTimer = setTimeout(() => pollPublishJob(jobId, planId), 1000) +} + +async function pollPublishJob(jobId: string, planId: string) { + try { + const { data } = await http.get(`/schedules/publish-jobs/${jobId}`) + if (selected.value?.id !== planId) return + publishJob.value = data + if (data.status === 'Queued' || data.status === 'Running') { + schedulePublishPoll(jobId, planId) + return + } + clearPublishPoll() + if (data.status === 'Succeeded') { + ElMessage.success('课表检查通过,已发布') + await loadPlans() + } else { + ElMessage.error(data.errorMessage || '课表检查未通过,请调整后重试') + } + } catch { + if (selected.value?.id === planId) { + schedulePublishPoll(jobId, planId) + } + } +} + +async function resumePublish(planId: string) { + clearPublishPoll() + publishJob.value = null + const { data } = await http.get(`/schedules/plans/${planId}/publish-job`) + if (selected.value?.id !== planId || !data) return + publishJob.value = data + if (data.status === 'Queued' || data.status === 'Running') { + schedulePublishPoll(data.id, planId) + } +} + function clearAutoSchedulePoll() { if (autoPollTimer) clearTimeout(autoPollTimer) autoPollTimer = undefined @@ -467,6 +538,8 @@ async function loadPlans(keepSelection = true) { selected.value = null clearAutoSchedulePoll() autoJob.value = null + clearPublishPoll() + publishJob.value = null } } catch (error) { ElMessage.error(apiErrorMessage(error)) @@ -480,10 +553,11 @@ async function loadDetail(id: string, resumeJob = true) { try { selected.value = (await http.get(`/schedules/plans/${id}`)).data if (resumeJob && selected.value.status === 'Draft') { - await resumeAutoSchedule(id) + await Promise.all([resumeAutoSchedule(id), resumePublish(id)]) } else if (resumeJob) { clearAutoSchedulePoll() autoJob.value = null + await resumePublish(id) } } catch (error) { ElMessage.error(apiErrorMessage(error)) @@ -556,9 +630,11 @@ async function publishPlan() { '发布课表', { type: 'warning', confirmButtonText: '检查并发布', cancelButtonText: '取消' }, ) - await http.post(`/schedules/plans/${selected.value.id}/publish`) - ElMessage.success('课表已发布') - await loadPlans() + const planId = selected.value.id + const { data } = await http.post(`/schedules/plans/${planId}/publish`) + publishJob.value = data + ElMessage.success('检查并发布任务已提交,可在当前页面查看进度') + schedulePublishPoll(data.id, planId) } catch (error: any) { if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) } @@ -677,7 +753,10 @@ onMounted(async () => { await Promise.all([loadPlans(false), loadSchedulingSettings()]) }) -onBeforeUnmount(clearAutoSchedulePoll) +onBeforeUnmount(() => { + clearAutoSchedulePoll() + clearPublishPoll() +})