From fe508054d0d83ad7f1fdc9e574c95b1a6c9ae934 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 08:18:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=99=E5=AD=A6=E4=BB=BB=E5=8A=A1=E6=8C=89?= =?UTF-8?q?=20=E6=8E=88=E8=AF=BE=E5=91=A8=E6=95=B0=20=C3=97=20=E5=91=A8?= =?UTF-8?q?=E5=AD=A6=E6=97=B6=20=3D=20=E8=AF=BE=E7=A8=8B=E6=80=BB=E5=AD=A6?= =?UTF-8?q?=E6=97=B6=20=E5=8F=8C=E7=AB=AF=E6=A0=A1=E9=AA=8C=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=8C=B9=E9=85=8D=E4=BC=9A=E6=98=BE=E7=A4=BA=E5=85=B7?= =?UTF-8?q?=E4=BD=93=E5=B7=AE=E9=A2=9D=E5=B9=B6=E9=98=BB=E6=AD=A2=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=EF=BC=9B=E5=85=AC=E5=85=B1=E8=AF=BE=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=90=8C=E6=A0=B7=E6=A0=A1=E9=AA=8C=E3=80=82?= =?UTF-8?q?=20=E6=8E=92=E8=AF=BE=E7=BA=A6=E6=9D=9F=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=AF=BE=E7=A8=8B=E3=80=81=E6=95=99=E5=B8=88=E3=80=81=E5=AD=A6?= =?UTF-8?q?=E9=99=A2=E3=80=81=E6=8E=88=E8=AF=BE=E6=96=B9=E5=BC=8F=E3=80=81?= =?UTF-8?q?=E5=9C=BA=E5=9C=B0=E8=A6=81=E6=B1=82=E3=80=81=E7=BA=A6=E6=9D=9F?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AD=9B=E9=80=89=EF=BC=8C=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=89=B9=E9=87=8F=E4=BF=AE=E6=94=B9=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E7=BB=93=E6=9E=9C=E3=80=82=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E2=80=9C=E9=9D=9E=E6=8E=92=E6=97=B6=E8=AF=BE=E7=A8=8B?= =?UTF-8?q?=E2=80=9D=E6=8E=88=E8=AF=BE=E6=96=B9=E5=BC=8F=EF=BC=9A=E4=B8=8D?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E8=87=AA=E5=8A=A8=E6=8E=92=E8=AF=BE=20?= =?UTF-8?q?=E4=B8=8D=E5=8D=A0=E6=98=9F=E6=9C=9F=E3=80=81=E8=8A=82=E6=AC=A1?= =?UTF-8?q?=E5=92=8C=E6=95=99=E5=AE=A4=20=E4=B8=8D=E9=98=BB=E5=A1=9E?= =?UTF-8?q?=E8=AF=BE=E8=A1=A8=E5=8F=91=E5=B8=83=20=E5=85=81=E8=AE=B8?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E9=80=89=E8=AF=BE=20=E5=9C=A8=E7=8F=AD?= =?UTF-8?q?=E7=BA=A7=E8=AF=BE=E8=A1=A8=E5=92=8C=E5=AD=A6=E7=94=9F=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E8=AF=BE=E8=A1=A8=E4=B8=AD=E5=8D=95=E7=8B=AC=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CourseSelectionsController.cs | 23 +- .../Controllers/CoursesController.cs | 9 +- .../Controllers/ScheduleSettingsController.cs | 199 +- .../Controllers/SchedulesController.cs | 17 +- .../Controllers/TeachingTasksController.cs | 65 +- .../Controllers/TimetablesController.cs | 96 +- .../Domain/Academic/TeachingTaskEntities.cs | 8 + .../Persistence/DevelopmentSqliteMigrator.cs | 35 +- ...51_TeachingTaskSchedulingModes.Designer.cs | 2876 +++++++++++++++++ ...60725001051_TeachingTaskSchedulingModes.cs | 29 + .../MySql/AppDbContextModelSnapshot.cs | 3 + .../Scheduling/AutomaticScheduleGenerator.cs | 7 +- .../Teaching/TeachingTaskHours.cs | 21 + .../AutomaticScheduleGeneratorTests.cs | 80 +- .../TeachingTaskHoursTests.cs | 33 + web/src/style.css | 9 + web/src/views/CourseSelectionView.vue | 7 +- web/src/views/SchedulesView.vue | 280 +- web/src/views/TeachingTasksView.vue | 77 +- web/src/views/TimetableView.vue | 47 +- 20 files changed, 3785 insertions(+), 136 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725001051_TeachingTaskSchedulingModes.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725001051_TeachingTaskSchedulingModes.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskHours.cs create mode 100644 tests/Jiaowu.Api.Tests/TeachingTaskHoursTests.cs diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index a2ce8a7..7c5d59d 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -631,11 +631,12 @@ public sealed class CourseSelectionsController( x.Enrollments.Count(item => item.Status == CourseEnrollmentStatus.Enrolled), x.IsOpenToAll, - x.Enrollments - .Where(item => item.StudentId == student.Id) - .Select(item => (CourseEnrollmentStatus?)item.Status) - .FirstOrDefault(), - db.ScheduleEntries + x.Enrollments + .Where(item => item.StudentId == student.Id) + .Select(item => (CourseEnrollmentStatus?)item.Status) + .FirstOrDefault(), + x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible, + db.ScheduleEntries .Where(entry => entry.TeachingTaskId == x.TeachingTaskId && entry.SchedulePlan!.AcademicTermId == round.AcademicTermId && @@ -803,8 +804,9 @@ public sealed class CourseSelectionsController( round.AcademicTermId, [task.Id], cancellationToken); - if (candidateEntries.Count == 0) - return ConflictProblem("该教学班尚未发布课表,暂时不能选课。"); + if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard && + candidateEntries.Count == 0) + return ConflictProblem("该教学班尚未发布课表,暂时不能选课。"); var selectedTaskIds = await db.CourseEnrollments .Where(x => x.StudentId == student.Id && @@ -1008,9 +1010,10 @@ public sealed record StudentOfferingDto( IEnumerable TeacherNames, int Capacity, int EnrolledCount, - bool IsOpenToAll, - CourseEnrollmentStatus? EnrollmentStatus, - IEnumerable Schedules); + bool IsOpenToAll, + CourseEnrollmentStatus? EnrollmentStatus, + bool IsFlexible, + IEnumerable Schedules); public sealed record StudentScheduleDto( int DayOfWeek, diff --git a/src/Jiaowu.Api/Controllers/CoursesController.cs b/src/Jiaowu.Api/Controllers/CoursesController.cs index 4ee00ab..b201fa1 100644 --- a/src/Jiaowu.Api/Controllers/CoursesController.cs +++ b/src/Jiaowu.Api/Controllers/CoursesController.cs @@ -128,10 +128,11 @@ public sealed class CoursesController( x.Name, x.CollegeId, CollegeName = x.College!.Name, - x.CourseCategoryId, - CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null, - x.Credits, - x.Nature, + x.CourseCategoryId, + CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null, + x.Credits, + x.TotalHours, + x.Nature, x.AssessmentMethod }) .ToListAsync(cancellationToken)); diff --git a/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs index 3cb8f1b..97d8561 100644 --- a/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs +++ b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs @@ -82,13 +82,17 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase { x.Id, x.TaskNumber, - x.Name, - CourseName = x.Course!.Name, - TeacherNames = x.Teachers + x.Name, + CourseCode = x.Course!.Code, + CourseName = x.Course!.Name, + CollegeId = x.Course.CollegeId, + CollegeName = x.Course.College!.Name, + TeacherNames = x.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), - x.Capacity, - x.WeeklyHours + x.Capacity, + x.WeeklyHours, + x.SchedulingMode }) .ToListAsync(cancellationToken); var taskIds = tasks.Select(x => x.Id).ToList(); @@ -103,12 +107,19 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase { task.Id, task.TaskNumber, - task.Name, - task.CourseName, - task.TeacherNames, - task.Capacity, - task.WeeklyHours, - RequiresClassroom = constraint?.RequiresClassroom ?? true, + task.Name, + task.CourseCode, + task.CourseName, + task.CollegeId, + task.CollegeName, + task.TeacherNames, + task.Capacity, + task.WeeklyHours, + task.SchedulingMode, + HasCustomConstraint = constraint is not null, + RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible + ? false + : constraint?.RequiresClassroom ?? true, constraint?.RequiredCampusId, constraint?.RequiredBuildingId, AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks), @@ -128,15 +139,34 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase { if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7)) return ValidationProblem("允许上课日必须位于星期一至星期日。"); - if (request.EarliestPeriod.HasValue && - request.LatestPeriod.HasValue && - request.EarliestPeriod > request.LatestPeriod) - return ValidationProblem("最早节次不能晚于最晚节次。"); - var task = await db.TeachingTasks - .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); - if (task is null) return NotFound(); + if (request.EarliestPeriod.HasValue && + request.LatestPeriod.HasValue && + request.EarliestPeriod > request.LatestPeriod) + return ValidationProblem("最早节次不能晚于最晚节次。"); + if (!Enum.IsDefined(request.SchedulingMode)) + return ValidationProblem("授课方式无效。"); + var task = await db.TeachingTasks + .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); + if (task is null) return NotFound(); + if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible && + await HasScheduleEntriesAsync([teachingTaskId], cancellationToken)) + return ConflictProblem("该教学任务已有正常排课记录,请先删除排课记录后再改为非排时课程。"); - Building? building = null; + task.SchedulingMode = request.SchedulingMode; + if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible) + { + var flexibleConstraint = await db.TeachingTaskScheduleConstraints + .Include(x => x.AllowedClassrooms) + .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); + if (flexibleConstraint is not null) + { + ClearConstraint(flexibleConstraint); + } + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + Building? building = null; if (request.RequiredBuildingId.HasValue) { building = await db.Buildings.AsNoTracking() @@ -191,10 +221,128 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase ? request.AllowedClassroomIds.Distinct().Select(classroomId => new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList() : []; + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpPut("constraints/batch")] + public async Task SaveConstraintsBatch( + TeachingTaskScheduleConstraintBatchRequest request, + CancellationToken cancellationToken) + { + var taskIds = request.TeachingTaskIds.Distinct().ToArray(); + if (taskIds.Length == 0) + return ValidationProblem("请至少选择一个教学任务。"); + if (request.AllowedDayOfWeeks?.Any(day => day is < 1 or > 7) == true) + return ValidationProblem("允许上课日必须位于星期一至星期日。"); + if (request.EarliestPeriod.HasValue && + request.LatestPeriod.HasValue && + request.EarliestPeriod > request.LatestPeriod) + return ValidationProblem("最早节次不能晚于最晚节次。"); + if (request.SchedulingMode.HasValue && + !Enum.IsDefined(request.SchedulingMode.Value)) + return ValidationProblem("授课方式无效。"); + if (!request.SchedulingMode.HasValue && + !request.RequiresClassroom.HasValue && + request.AllowedDayOfWeeks is null && + !request.UpdatePeriodRange && + !request.EarliestPeriod.HasValue && + !request.LatestPeriod.HasValue) + return ValidationProblem("请至少选择一项需要批量修改的设置。"); + + var tasks = await db.TeachingTasks + .Where(x => + taskIds.Contains(x.Id) && + x.AcademicTermId == request.AcademicTermId && + x.Status == TeachingTaskStatus.Published) + .ToListAsync(cancellationToken); + if (tasks.Count != taskIds.Length) + return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。"); + if (request.SchedulingMode == TeachingTaskSchedulingMode.Flexible && + await HasScheduleEntriesAsync(taskIds, cancellationToken)) + return ConflictProblem("所选教学任务中存在已有正常排课记录的课程,请先删除这些排课记录。"); + + var constraints = await db.TeachingTaskScheduleConstraints + .Where(x => taskIds.Contains(x.TeachingTaskId)) + .Include(x => x.AllowedClassrooms) + .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); + foreach (var task in tasks) + { + if (request.SchedulingMode.HasValue) + task.SchedulingMode = request.SchedulingMode.Value; + if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) + { + if (constraints.TryGetValue(task.Id, out var flexibleConstraint)) + ClearConstraint(flexibleConstraint); + continue; + } + + if (!constraints.TryGetValue(task.Id, out var constraint)) + { + var changesConstraint = + request.RequiresClassroom.HasValue || + request.AllowedDayOfWeeks is not null || + request.UpdatePeriodRange; + if (!changesConstraint) continue; + constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id }; + constraints[task.Id] = constraint; + db.TeachingTaskScheduleConstraints.Add(constraint); + } + + if (request.RequiresClassroom.HasValue) + { + constraint.RequiresClassroom = request.RequiresClassroom.Value; + if (!request.RequiresClassroom.Value) + { + constraint.RequiredCampusId = null; + constraint.RequiredBuildingId = null; + db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); + constraint.AllowedClassrooms = []; + } + } + if (request.AllowedDayOfWeeks is not null) + { + constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0 + ? null + : string.Join(',', request.AllowedDayOfWeeks.Distinct().Order()); + } + if (request.UpdatePeriodRange) + { + constraint.EarliestPeriod = request.EarliestPeriod; + constraint.LatestPeriod = request.LatestPeriod; + } + } + await db.SaveChangesAsync(cancellationToken); - return NoContent(); + return Ok(new { AffectedCount = tasks.Count }); } + private Task HasScheduleEntriesAsync( + IReadOnlyCollection taskIds, + CancellationToken cancellationToken) => + db.ScheduleEntries.AsNoTracking() + .AnyAsync(x => taskIds.Contains(x.TeachingTaskId), cancellationToken); + + private void ClearConstraint(TeachingTaskScheduleConstraint constraint) + { + constraint.RequiresClassroom = false; + constraint.RequiredCampusId = null; + constraint.RequiredBuildingId = null; + constraint.AllowedDayOfWeeks = null; + constraint.EarliestPeriod = null; + constraint.LatestPeriod = null; + db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); + constraint.AllowedClassrooms = []; + } + + private ActionResult ConflictProblem(string detail) => + Conflict(new ProblemDetails + { + Title = "操作冲突", + Detail = detail, + Status = StatusCodes.Status409Conflict + }); + private static int[] ParseDays(string? value) => string.IsNullOrWhiteSpace(value) ? [] @@ -211,6 +359,7 @@ public sealed record ScheduleTimeSlotRequest( bool IsEnabled); public sealed record TeachingTaskScheduleConstraintRequest( + TeachingTaskSchedulingMode SchedulingMode, bool RequiresClassroom, Guid? RequiredCampusId, Guid? RequiredBuildingId, @@ -218,3 +367,13 @@ public sealed record TeachingTaskScheduleConstraintRequest( IReadOnlyList AllowedDayOfWeeks, [Range(1, 30)] int? EarliestPeriod, [Range(1, 30)] int? LatestPeriod); + +public sealed record TeachingTaskScheduleConstraintBatchRequest( + Guid AcademicTermId, + [MinLength(1), MaxLength(500)] IReadOnlyCollection TeachingTaskIds, + TeachingTaskSchedulingMode? SchedulingMode, + bool? RequiresClassroom, + IReadOnlyList? AllowedDayOfWeeks, + bool UpdatePeriodRange, + [Range(1, 30)] int? EarliestPeriod, + [Range(1, 30)] int? LatestPeriod); diff --git a/src/Jiaowu.Api/Controllers/SchedulesController.cs b/src/Jiaowu.Api/Controllers/SchedulesController.cs index d413044..9fb9b5e 100644 --- a/src/Jiaowu.Api/Controllers/SchedulesController.cs +++ b/src/Jiaowu.Api/Controllers/SchedulesController.cs @@ -232,9 +232,10 @@ public sealed class SchedulesController( } var requiredTasks = await db.TeachingTasks.AsNoTracking() - .Where(x => - x.AcademicTermId == plan.AcademicTermId && - x.Status == TeachingTaskStatus.Published) + .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 @@ -449,10 +450,12 @@ public sealed class SchedulesController( .ThenInclude(x => x.AdministrativeClass) .ThenInclude(x => x!.Students) .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); - if (task is null || - task.Status != TeachingTaskStatus.Published || - task.AcademicTermId != plan.AcademicTermId) - return ValidationProblem("只能安排同一学期内已发布的教学任务。"); + if (task is null || + task.Status != TeachingTaskStatus.Published || + task.AcademicTermId != plan.AcademicTermId) + return ValidationProblem("只能安排同一学期内已发布的教学任务。"); + if (task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) + return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。"); if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek) return ValidationProblem("排课周次必须位于教学任务的授课周次内。"); diff --git a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs index ecfb301..07f869e 100644 --- a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs +++ b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs @@ -76,9 +76,11 @@ public sealed class TeachingTasksController( CollegeName = x.Course.College!.Name, x.Capacity, x.StartWeek, - x.EndWeek, - x.WeeklyHours, - x.GenerationBatchCode, + x.EndWeek, + x.WeeklyHours, + x.SchedulingMode, + CourseTotalHours = x.Course.TotalHours, + x.GenerationBatchCode, x.Status, TeacherNames = x.Teachers .OrderByDescending(item => item.IsPrimary) @@ -114,9 +116,11 @@ public sealed class TeachingTasksController( CollegeName = x.Course.College!.Name, x.Capacity, x.StartWeek, - x.EndWeek, - x.WeeklyHours, - x.GenerationBatchCode, + x.EndWeek, + x.WeeklyHours, + x.SchedulingMode, + CourseTotalHours = x.Course.TotalHours, + x.GenerationBatchCode, x.Status, x.Notes, x.PublishedAt, @@ -170,9 +174,10 @@ public sealed class TeachingTasksController( CourseId = request.CourseId, Capacity = request.Capacity, StartWeek = request.StartWeek, - EndWeek = request.EndWeek, - WeeklyHours = request.WeeklyHours, - Notes = Normalize(request.Notes) + EndWeek = request.EndWeek, + WeeklyHours = request.WeeklyHours, + SchedulingMode = request.SchedulingMode, + Notes = Normalize(request.Notes) }; SetAssignments(task, request); db.TeachingTasks.Add(task); @@ -203,9 +208,10 @@ public sealed class TeachingTasksController( task.CourseId = request.CourseId; task.Capacity = request.Capacity; task.StartWeek = request.StartWeek; - task.EndWeek = request.EndWeek; - task.WeeklyHours = request.WeeklyHours; - task.Notes = Normalize(request.Notes); + task.EndWeek = request.EndWeek; + task.WeeklyHours = request.WeeklyHours; + task.SchedulingMode = request.SchedulingMode; + task.Notes = Normalize(request.Notes); db.TeachingTaskTeachers.RemoveRange(task.Teachers); db.TeachingTaskClasses.RemoveRange(task.Classes); task.Teachers = []; @@ -342,8 +348,14 @@ public sealed class TeachingTasksController( .FirstOrDefaultAsync( x => x.Id == request.CourseId && x.IsEnabled, cancellationToken); - if (course is null) return ValidationProblem("所选课程不存在或已停用。"); - if (!CanManage(course)) return Forbid(); + if (course is null) return ValidationProblem("所选课程不存在或已停用。"); + if (!CanManage(course)) return Forbid(); + var hoursProblem = TeachingTaskHours.Validate( + course, + request.StartWeek, + request.EndWeek, + request.WeeklyHours); + if (hoursProblem is not null) return ValidationProblem(hoursProblem); if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); var scopedCollegeId = ScopedCollegeId(); @@ -501,9 +513,17 @@ public sealed class TeachingTasksController( return ValidationProblem("开始周不能晚于结束周。"); var course = await db.Courses.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken); - if (course is null) return ValidationProblem("所选课程不存在或已停用。"); - if (!CanManage(course)) return Forbid(); - var collegeId = ScopedCollegeId(); + if (course is null) return ValidationProblem("所选课程不存在或已停用。"); + if (!CanManage(course)) return Forbid(); + if (!Enum.IsDefined(request.SchedulingMode)) + return ValidationProblem("授课方式无效。"); + var hoursProblem = TeachingTaskHours.Validate( + course, + request.StartWeek, + request.EndWeek, + request.WeeklyHours); + if (hoursProblem is not null) return ValidationProblem(hoursProblem); + var collegeId = ScopedCollegeId(); if (!await db.AcademicTerms.AnyAsync( x => x.Id == request.AcademicTermId && x.IsEnabled, cancellationToken)) @@ -695,11 +715,12 @@ public sealed record TeachingTaskRequest( [Range(1, 10000)] int Capacity, [Range(1, 30)] int StartWeek, [Range(1, 30)] int EndWeek, - [Range(1, 40)] int WeeklyHours, - IReadOnlyCollection TeacherIds, - Guid? PrimaryTeacherId, - IReadOnlyCollection ClassIds, - [MaxLength(500)] string? Notes); + [Range(1, 40)] int WeeklyHours, + IReadOnlyCollection TeacherIds, + Guid? PrimaryTeacherId, + IReadOnlyCollection ClassIds, + [MaxLength(500)] string? Notes, + TeachingTaskSchedulingMode SchedulingMode = TeachingTaskSchedulingMode.Standard); public sealed record PublicCourseTaskGenerationRequest( Guid AcademicTermId, diff --git a/src/Jiaowu.Api/Controllers/TimetablesController.cs b/src/Jiaowu.Api/Controllers/TimetablesController.cs index a308312..0974955 100644 --- a/src/Jiaowu.Api/Controllers/TimetablesController.cs +++ b/src/Jiaowu.Api/Controllers/TimetablesController.cs @@ -28,9 +28,13 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase x.StartDate, x.EndDate, x.IsCurrent, - HasPublishedTimetable = db.SchedulePlans.Any(plan => - plan.AcademicTermId == x.Id && - plan.Status == SchedulePlanStatus.Published) + HasPublishedTimetable = db.SchedulePlans.Any(plan => + plan.AcademicTermId == x.Id && + plan.Status == SchedulePlanStatus.Published) || + db.TeachingTasks.Any(task => + task.AcademicTermId == x.Id && + task.Status == TeachingTaskStatus.Published && + task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) }) .ToListAsync(cancellationToken); var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id @@ -47,12 +51,17 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase x.Grade, MajorName = x.Major!.Name, CollegeName = x.Major.College!.Name, - HasPublishedTimetable = defaultTermId.HasValue && - db.ScheduleEntries.Any(entry => - entry.SchedulePlan!.AcademicTermId == defaultTermId.Value && - entry.SchedulePlan.Status == SchedulePlanStatus.Published && - entry.TeachingTask!.Classes.Any(item => - item.AdministrativeClassId == x.Id)) + HasPublishedTimetable = defaultTermId.HasValue && + (db.ScheduleEntries.Any(entry => + entry.SchedulePlan!.AcademicTermId == defaultTermId.Value && + entry.SchedulePlan.Status == SchedulePlanStatus.Published && + entry.TeachingTask!.Classes.Any(item => + item.AdministrativeClassId == x.Id)) || + db.TeachingTasks.Any(task => + task.AcademicTermId == defaultTermId.Value && + task.Status == TeachingTaskStatus.Published && + task.SchedulingMode == TeachingTaskSchedulingMode.Flexible && + task.Classes.Any(item => item.AdministrativeClassId == x.Id))) }) .ToListAsync(cancellationToken); return Ok(new { Terms = terms, Classes = classes }); @@ -148,21 +157,67 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase .Select(x => new { x.Id, x.Name, x.Version, x.PublishedAt }) .FirstOrDefaultAsync(cancellationToken); - var slots = await db.ScheduleTimeSlots.AsNoTracking() + var slots = await db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.AcademicTermId == term.Id && x.IsEnabled) .OrderBy(x => x.PeriodNumber) - .Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt }) + .Select(x => new { x.PeriodNumber, x.Name, x.StartsAt, x.EndsAt }) + .ToListAsync(cancellationToken); + + var flexibleTasks = db.TeachingTasks.AsNoTracking() + .Where(x => + x.AcademicTermId == term.Id && + x.Status == TeachingTaskStatus.Published && + x.SchedulingMode == TeachingTaskSchedulingMode.Flexible && + x.Classes.Any(item => item.AdministrativeClassId == classId)); + if (studentId.HasValue) + { + var selectedFlexibleTaskIds = db.CourseEnrollments.AsNoTracking() + .Where(x => + x.StudentId == studentId.Value && + x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == term.Id) + .Select(x => x.CourseSelectionOffering!.TeachingTaskId); + flexibleTasks = db.TeachingTasks.AsNoTracking() + .Where(x => + x.AcademicTermId == term.Id && + x.Status == TeachingTaskStatus.Published && + x.SchedulingMode == TeachingTaskSchedulingMode.Flexible && + (x.Classes.Any(item => item.AdministrativeClassId == classId) || + selectedFlexibleTaskIds.Contains(x.Id))); + } + var flexibleCourses = await flexibleTasks + .OrderBy(x => x.Course!.Code) + .ThenBy(x => x.TaskNumber) + .Select(x => new + { + x.Id, + x.TaskNumber, + x.Name, + CourseCode = x.Course!.Code, + CourseName = x.Course.Name, + x.Course.Credits, + x.Course.TotalHours, + x.StartWeek, + x.EndWeek, + x.WeeklyHours, + TeacherNames = x.Teachers + .OrderByDescending(item => item.IsPrimary) + .Select(item => item.Teacher!.Name), + ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name), + x.Notes + }) .ToListAsync(cancellationToken); - if (plan is null) - return Ok(new + if (plan is null) + return Ok(new { Term = term, Class = administrativeClass, Student = student, - Plan = (object?)null, - Slots = slots, - Entries = Array.Empty() + Plan = (object?)null, + Slots = slots, + Entries = Array.Empty(), + FlexibleCourses = flexibleCourses }); var entries = db.ScheduleEntries.AsNoTracking() @@ -223,9 +278,10 @@ public sealed class TimetablesController(AppDbContext db) : ControllerBase Term = term, Class = administrativeClass, Student = student, - Plan = plan, - Slots = slots, - Entries = result - }); + Plan = plan, + Slots = slots, + Entries = result, + FlexibleCourses = flexibleCourses + }); } } diff --git a/src/Jiaowu.Api/Domain/Academic/TeachingTaskEntities.cs b/src/Jiaowu.Api/Domain/Academic/TeachingTaskEntities.cs index 707d952..15eab71 100644 --- a/src/Jiaowu.Api/Domain/Academic/TeachingTaskEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/TeachingTaskEntities.cs @@ -14,6 +14,8 @@ public sealed class TeachingTask : EntityBase public int StartWeek { get; set; } = 1; public int EndWeek { get; set; } = 16; public int WeeklyHours { get; set; } = 2; + public TeachingTaskSchedulingMode SchedulingMode { get; set; } = + TeachingTaskSchedulingMode.Standard; public TeachingTaskStatus Status { get; set; } = TeachingTaskStatus.Draft; public string? GenerationBatchCode { get; set; } public string? Notes { get; set; } @@ -63,6 +65,12 @@ public enum TeachingTaskStatus Closed = 3 } +public enum TeachingTaskSchedulingMode +{ + Standard = 1, + Flexible = 2 +} + public enum TeacherCourseApplicationStatus { Pending = 1, diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 11689ed..39f4baf 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -23,8 +23,10 @@ public sealed class DevelopmentSqliteMigrator( "20260724_14_scheduling_optimization"; private const string TeacherCourseApplicationsMigration = "20260724_15_teacher_course_applications"; - private const string AutomaticScheduleJobsMigration = - "20260724_16_automatic_schedule_jobs"; + private const string AutomaticScheduleJobsMigration = + "20260724_16_automatic_schedule_jobs"; + private const string TeachingTaskSchedulingModesMigration = + "20260725_17_teaching_task_scheduling_modes"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -145,9 +147,21 @@ public sealed class DevelopmentSqliteMigrator( WHERE type = 'table' AND name = 'AutomaticScheduleJobs' """) .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + AutomaticScheduleJobsMigration, + automaticScheduleJobsExist ? [] : AutomaticScheduleJobStatements, + cancellationToken); + var teachingTaskSchedulingModeExists = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM pragma_table_info('TeachingTasks') + WHERE name = 'SchedulingMode' + """) + .AnyAsync(value => value > 0, cancellationToken); await ApplyMigrationAsync( - AutomaticScheduleJobsMigration, - automaticScheduleJobsExist ? [] : AutomaticScheduleJobStatements, + TeachingTaskSchedulingModesMigration, + teachingTaskSchedulingModeExists ? [] : TeachingTaskSchedulingModeStatements, cancellationToken); } @@ -384,8 +398,9 @@ public sealed class DevelopmentSqliteMigrator( "Capacity" INTEGER NOT NULL, "StartWeek" INTEGER NOT NULL, "EndWeek" INTEGER NOT NULL, - "WeeklyHours" INTEGER NOT NULL, - "Status" INTEGER NOT NULL, + "WeeklyHours" INTEGER NOT NULL, + "SchedulingMode" INTEGER NOT NULL DEFAULT 1, + "Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, @@ -1081,4 +1096,12 @@ public sealed class DevelopmentSqliteMigrator( ON "AutomaticScheduleJobs" ("RequestedByUserId"); """ ]; + + private static readonly string[] TeachingTaskSchedulingModeStatements = + [ + """ + ALTER TABLE "TeachingTasks" + ADD COLUMN "SchedulingMode" INTEGER NOT NULL DEFAULT 1; + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725001051_TeachingTaskSchedulingModes.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725001051_TeachingTaskSchedulingModes.Designer.cs new file mode 100644 index 0000000..2b7832e --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725001051_TeachingTaskSchedulingModes.Designer.cs @@ -0,0 +1,2876 @@ +// +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("20260725001051_TeachingTaskSchedulingModes")] + partial class TeachingTaskSchedulingModes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicTerm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicYear") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsCurrent") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Season") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsCurrent"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AcademicTerms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CounselorName") + .HasColumnType("longtext"); + + b.Property("CounselorUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CounselorUserId"); + + b.HasIndex("MajorId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AdministrativeClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ActiveSchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedTasks") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedEntries") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MessagesJson") + .HasColumnType("longtext"); + + b.Property("ProcessedTasks") + .HasColumnType("int"); + + b.Property("RequestedByUserId") + .HasColumnType("char(36)"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalTasks") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveSchedulePlanId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("AutomaticScheduleJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Buildings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Campuses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BuildingId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Equipment") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RoomType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ShortName") + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Colleges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AssessmentMethod") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CourseCategoryId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Credits") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EnglishName") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LectureHours") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Nature") + .HasColumnType("int"); + + b.Property("PracticeHours") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalHours") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CourseCategoryId"); + + b.HasIndex("CollegeId", "Nature"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("CourseCategories"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseSelectionOfferingId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrolledAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawnAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseSelectionOfferingId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.ToTable("CourseEnrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseSelectionRoundId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsOpenToAll") + .HasColumnType("tinyint(1)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("CourseSelectionRoundId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("CourseSelectionOfferings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("MaxCredits") + .HasPrecision(6, 1) + .HasColumnType("decimal(6,1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawalEndsAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("CourseSelectionRounds"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumModuleId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RecommendedSemester") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("CurriculumModuleId", "CourseId") + .IsUnique(); + + b.ToTable("CurriculumCourses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId", "Code") + .IsUnique(); + + b.ToTable("CurriculumModules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EffectiveGrade") + .HasColumnType("int"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "EffectiveGrade"); + + b.HasIndex("MajorId", "EffectiveGrade", "Version") + .IsUnique(); + + b.ToTable("CurriculumPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("MinimumGradePoint") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationYear", "Status"); + + b.ToTable("DegreeAwardBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AverageGradePoint") + .HasPrecision(4, 2) + .HasColumnType("decimal(4,2)"); + + b.Property("CalculatedConclusion") + .HasColumnType("int"); + + b.Property("Conclusion") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeAwardBatchId") + .HasColumnType("char(36)"); + + b.Property("ExceptionReason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("GraduationAuditResultId") + .HasColumnType("char(36)"); + + b.Property("IsOverridden") + .HasColumnType("tinyint(1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationAuditResultId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Conclusion", "IsOverridden"); + + b.HasIndex("DegreeAwardBatchId", "StudentId") + .IsUnique(); + + b.ToTable("DegreeAwardResults"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("ExamPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("ExamPlanId", "StartsAt"); + + b.ToTable("ExamSessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.Property("ExamSessionId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("ExamSessionId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ExamSessionInvigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamStatus") + .HasColumnType("int"); + + b.Property("FinalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("GradePoint") + .HasPrecision(3, 1) + .HasColumnType("decimal(3,1)"); + + b.Property("GradeSheetId") + .HasColumnType("char(36)"); + + b.Property("MidtermScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("Notes") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RegularScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("TotalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GradeSheetId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "TotalScore"); + + b.ToTable("GradeRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FinalWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("MidtermWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("RegularWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("GradeSheets"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("GraduationYear", "EnrollmentYear"); + + b.ToTable("GraduationAuditBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedConclusion") + .HasColumnType("int"); + + b.Property("Conclusion") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("EarnedCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("FailedCourseCount") + .HasColumnType("int"); + + b.Property("GraduationAuditBatchId") + .HasColumnType("char(36)"); + + b.Property("IsOverridden") + .HasColumnType("tinyint(1)"); + + b.Property("MissingCourseNames") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("PassedRequiredCourseCount") + .HasColumnType("int"); + + b.Property("RequiredCourseCount") + .HasColumnType("int"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("StudentStatusSnapshot") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Conclusion", "IsOverridden"); + + b.HasIndex("GraduationAuditBatchId", "StudentId") + .IsUnique(); + + b.ToTable("GraduationAuditResults"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationYear", "Status"); + + b.ToTable("GraduationClearanceBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationClearanceBatchId") + .HasColumnType("char(36)"); + + b.Property("IsRequired") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ResponsibleRole") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("ResponsibleUnit") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationClearanceBatchId", "Code") + .IsUnique(); + + b.ToTable("GraduationClearanceItems"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedByUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationClearanceItemId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationClearanceItemId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.ToTable("GraduationClearanceRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SchoolingYears") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Majors"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeekPattern") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("SchedulePlanId", "DayOfWeek", "StartPeriod"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.HasIndex("AcademicTermId", "Version") + .IsUnique(); + + b.ToTable("SchedulePlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("time"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("PeriodNumber") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "PeriodNumber") + .IsUnique(); + + b.ToTable("ScheduleTimeSlots"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EnrollmentDate") + .HasColumnType("date"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentYear"); + + b.HasIndex("StudentNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("AdministrativeClassId", "Status"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OriginalStatus") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("State") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetStatus") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId", "State"); + + b.ToTable("StudentStatusChanges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("IsExternal") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeacherNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Title") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("TeacherNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("CollegeId", "Status"); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Statement") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("TeacherId"); + + b.HasIndex("Status", "AcademicTermId"); + + b.HasIndex("AcademicTermId", "TeacherId", "CourseId") + .IsUnique(); + + b.ToTable("TeacherCourseApplications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("GenerationBatchCode") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("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.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/20260725001051_TeachingTaskSchedulingModes.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725001051_TeachingTaskSchedulingModes.cs new file mode 100644 index 0000000..2153f69 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725001051_TeachingTaskSchedulingModes.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class TeachingTaskSchedulingModes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SchedulingMode", + table: "TeachingTasks", + type: "int", + nullable: false, + defaultValue: 1); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SchedulingMode", + table: "TeachingTasks"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index bfd6f55..68639af 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -1771,6 +1771,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Property("PublishedAt") .HasColumnType("datetime(6)"); + b.Property("SchedulingMode") + .HasColumnType("int"); + b.Property("StartWeek") .HasColumnType("int"); diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs index 2830d4e..d064412 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs @@ -26,9 +26,10 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db) var activePeriods = timeSlots.Select(x => x.PeriodNumber).ToHashSet(); var tasks = await db.TeachingTasks - .Where(x => - x.AcademicTermId == plan.AcademicTermId && - x.Status == TeachingTaskStatus.Published) + .Where(x => + x.AcademicTermId == plan.AcademicTermId && + x.Status == TeachingTaskStatus.Published && + x.SchedulingMode == TeachingTaskSchedulingMode.Standard) .Include(x => x.Teachers) .Include(x => x.Classes) .ThenInclude(x => x.AdministrativeClass) diff --git a/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskHours.cs b/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskHours.cs new file mode 100644 index 0000000..4b1a596 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Teaching/TeachingTaskHours.cs @@ -0,0 +1,21 @@ +using Jiaowu.Api.Domain.Academic; + +namespace Jiaowu.Api.Infrastructure.Teaching; + +public static class TeachingTaskHours +{ + public static int Calculate(int startWeek, int endWeek, int weeklyHours) => + endWeek < startWeek ? 0 : (endWeek - startWeek + 1) * weeklyHours; + + public static string? Validate( + Course course, + int startWeek, + int endWeek, + int weeklyHours) + { + var plannedHours = Calculate(startWeek, endWeek, weeklyHours); + return plannedHours == course.TotalHours + ? null + : $"课程“{course.Name}”总学时为 {course.TotalHours};当前第 {startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 {plannedHours} 学时。请调整授课周次或周学时。"; + } +} diff --git a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs index 98d299c..c634370 100644 --- a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs +++ b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs @@ -27,16 +27,92 @@ public sealed class AutomaticScheduleGeneratorTests Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0); Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0); - Assert.True(await db.Database - .SqlQueryRaw( + Assert.True(await db.Database + .SqlQueryRaw( """ SELECT COUNT(*) AS "Value" FROM pragma_table_info('ScheduleEntries') WHERE name = 'ClassroomId' AND "notnull" = 0 """) + .AnyAsync(value => value > 0)); + Assert.True(await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM pragma_table_info('TeachingTasks') + WHERE name = 'SchedulingMode' + """) .AnyAsync(value => value > 0)); } + [Fact] + public async Task Generator_skips_flexible_courses() + { + 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-F", + Name = "2026 秋季", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 15) + }; + var college = new College { Code = "PUBLIC", Name = "公共教学部" }; + var course = new Course + { + Code = "FLEX-01", + Name = "社会实践", + College = college, + Credits = 1, + TotalHours = 16, + PracticeHours = 16 + }; + var task = new TeachingTask + { + TaskNumber = "TASK-FLEX", + Name = "社会实践教学班", + AcademicTerm = term, + Course = course, + Capacity = 100, + StartWeek = 1, + EndWeek = 16, + WeeklyHours = 1, + SchedulingMode = TeachingTaskSchedulingMode.Flexible, + Status = TeachingTaskStatus.Published + }; + var plan = new SchedulePlan + { + AcademicTerm = term, + Name = "非排时课程测试", + Version = "V1" + }; + db.AddRange(term, college, course, task, plan); + db.ScheduleTimeSlots.Add(new ScheduleTimeSlot + { + AcademicTerm = term, + PeriodNumber = 1, + Name = "第 1 节", + StartsAt = new TimeOnly(8, 0), + EndsAt = new TimeOnly(8, 45) + }); + await db.SaveChangesAsync(); + + var result = await new AutomaticScheduleGenerator(db) + .GenerateAsync(plan, CancellationToken.None); + + Assert.Equal(0, result.TotalTasks); + Assert.Equal(0, result.CreatedEntries); + Assert.Empty(await db.ScheduleEntries.ToListAsync()); + } + [Fact] public async Task Generator_schedules_roomless_course_within_allowed_time() { diff --git a/tests/Jiaowu.Api.Tests/TeachingTaskHoursTests.cs b/tests/Jiaowu.Api.Tests/TeachingTaskHoursTests.cs new file mode 100644 index 0000000..14c9501 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/TeachingTaskHoursTests.cs @@ -0,0 +1,33 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Teaching; + +namespace Jiaowu.Api.Tests; + +public sealed class TeachingTaskHoursTests +{ + private static readonly Course Course = new() + { + Code = "TEST-01", + Name = "测试课程", + CollegeId = Guid.NewGuid(), + Credits = 2, + TotalHours = 32, + LectureHours = 32 + }; + + [Fact] + public void Validate_accepts_matching_week_range_and_weekly_hours() + { + Assert.Null(TeachingTaskHours.Validate(Course, 1, 16, 2)); + } + + [Fact] + public void Validate_reports_course_total_and_planned_hours_when_mismatched() + { + var result = TeachingTaskHours.Validate(Course, 1, 8, 2); + + Assert.NotNull(result); + Assert.Contains("总学时为 32", result); + Assert.Contains("共 16 学时", result); + } +} diff --git a/web/src/style.css b/web/src/style.css index bc12601..97dfb09 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -358,6 +358,10 @@ button { cursor: pointer; } .settings-lead > div:last-child { display: flex; flex-shrink: 0; gap: 8px; } .settings-table .el-input-number { width: 76px; } .constraint-list { display: grid; gap: 8px; } +.constraint-filter-grid { display: grid; grid-template-columns: 1.4fr repeat(4, minmax(120px, 1fr)); gap: 8px; margin-bottom: 8px; } +.constraint-result-summary { margin-bottom: 10px; color: var(--muted); font-size: 10px; text-align: right; } +.constraint-batch-form { margin-top: 16px; display: grid; gap: 10px; } +.constraint-batch-form > .el-checkbox { padding: 8px 10px; background: #f7f9fb; border-left: 3px solid #ccd8e1; } .constraint-list article { min-width: 0; padding: 13px 15px; display: grid; grid-template-columns: minmax(230px, 1fr) minmax(170px, auto) auto; align-items: center; gap: 14px; border: 1px solid var(--line); background: #fff; } .constraint-list article > div:first-child { min-width: 0; display: grid; gap: 4px; } .constraint-list article span { color: var(--teal); font: 700 9px/1.2 Consolas, monospace; } @@ -375,6 +379,10 @@ button { cursor: pointer; } .generation-preview b { color: var(--indigo); font: 700 25px/1 Consolas, monospace; } .generation-preview span, .generation-preview small { font-size: 10px; } .field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; } +.hours-validation { margin: -5px 0 18px; padding: 10px 13px; display: flex; align-items: center; gap: 12px; border-left: 3px solid #27846f; background: #f1f8f6; color: #46645e; font-size: 11px; } +.hours-validation b { flex-shrink: 0; color: #196b59; } +.hours-validation.invalid { border-left-color: #c75252; background: #fff3f2; color: #835353; } +.hours-validation.invalid b { color: #a83f3f; } .option-filter-panel { margin: -3px 0 18px; padding: 11px 13px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; border: 1px solid #dce4eb; border-left: 3px solid var(--teal); background: #f8fafb; } .option-filter-title { color: #3f5367; font-size: 10px; font-weight: 650; white-space: nowrap; } .option-filter-panel > small { color: var(--muted); font-size: 9px; white-space: nowrap; } @@ -1028,6 +1036,7 @@ button { cursor: pointer; } .settings-lead { align-items: stretch; flex-direction: column; } .settings-lead > div:last-child { flex-wrap: wrap; } .constraint-list article { grid-template-columns: 1fr; } + .constraint-filter-grid { grid-template-columns: 1fr; } .constraint-badges { justify-content: flex-start; } .eligibility-flow { grid-template-columns: 1fr; } .eligibility-flow i { display: none; } diff --git a/web/src/views/CourseSelectionView.vue b/web/src/views/CourseSelectionView.vue index d89388b..8896574 100644 --- a/web/src/views/CourseSelectionView.vue +++ b/web/src/views/CourseSelectionView.vue @@ -584,7 +584,10 @@ onMounted(async () => { {{ formatSchedule(schedule) }} - 课表尚未发布 + + 非排时课程 · 不占正常时间与场地 + + 课表尚未发布
@@ -601,7 +604,7 @@ onMounted(async () => { {{ offering.enrollmentStatus === 'Withdrawn' ? '重新选择' : '选择课程' }}
diff --git a/web/src/views/SchedulesView.vue b/web/src/views/SchedulesView.vue index 3e4c82f..0086961 100644 --- a/web/src/views/SchedulesView.vue +++ b/web/src/views/SchedulesView.vue @@ -21,6 +21,8 @@ const entryDialog = ref(false) const settingsDrawer = ref(false) const settingsTab = ref('time') const constraintDialog = ref(false) +const constraintBatchDialog = ref(false) +const constraintBatchSaving = ref(false) const editingPlanId = ref('') const editingEntryId = ref('') const keyword = ref('') @@ -29,6 +31,14 @@ const planForm = reactive>({}) const cloneForm = reactive>({}) const entryForm = reactive>({}) const constraintForm = reactive>({}) +const constraintBatchForm = reactive>({}) +const constraintFilters = reactive({ + keyword: '', + collegeId: undefined as string | undefined, + schedulingMode: undefined as string | undefined, + classroomMode: undefined as string | undefined, + constraintState: undefined as string | undefined, +}) let autoPollTimer: ReturnType | undefined const weekdays = [ @@ -86,6 +96,39 @@ const autoStatusText = computed(() => { const selectedTaskConstraint = computed(() => constraints.value.find((item) => item.id === entryForm.teachingTaskId), ) +const constraintColleges = computed(() => { + const result = new Map() + constraints.value.forEach((item) => result.set(item.collegeId, item.collegeName)) + return [...result].map(([id, name]) => ({ id, name })).sort((a, b) => + a.name.localeCompare(b.name, 'zh-CN'), + ) +}) +const filteredConstraints = computed(() => { + const text = constraintFilters.keyword.trim().toLowerCase() + return constraints.value.filter((item) => { + const matchesKeyword = !text || [ + item.taskNumber, + item.name, + item.courseCode, + item.courseName, + ...item.teacherNames, + ].some((value) => String(value).toLowerCase().includes(text)) + const matchesCollege = !constraintFilters.collegeId || + item.collegeId === constraintFilters.collegeId + const matchesMode = !constraintFilters.schedulingMode || + item.schedulingMode === constraintFilters.schedulingMode + const matchesClassroom = !constraintFilters.classroomMode || + (constraintFilters.classroomMode === 'required' + ? item.requiresClassroom + : !item.requiresClassroom) + const matchesState = !constraintFilters.constraintState || + (constraintFilters.constraintState === 'custom' + ? item.hasCustomConstraint + : !item.hasCustomConstraint) + return matchesKeyword && matchesCollege && matchesMode && + matchesClassroom && matchesState + }) +}) const filteredBuildings = computed(() => constraintForm.requiredCampusId ? buildings.value.filter((item) => item.campusId === constraintForm.requiredCampusId) @@ -133,7 +176,9 @@ async function loadSchedulingSettings() { ]) timeSlots.value = timeRes.data constraints.value = constraintRes.data - tasks.value = constraintRes.data + tasks.value = constraintRes.data.filter( + (item: any) => item.schedulingMode === 'Standard', + ) } catch (error) { ElMessage.error(apiErrorMessage(error)) } @@ -183,6 +228,7 @@ function openConstraint(item: any) { Object.assign(constraintForm, { teachingTaskId: item.id, title: `${item.taskNumber} · ${item.name}`, + schedulingMode: item.schedulingMode, requiresClassroom: item.requiresClassroom, requiredCampusId: item.requiredCampusId, requiredBuildingId: item.requiredBuildingId, @@ -199,6 +245,7 @@ function openConstraint(item: any) { async function saveConstraint() { try { const payload = { + schedulingMode: constraintForm.schedulingMode, requiresClassroom: constraintForm.requiresClassroom, requiredCampusId: constraintForm.requiredCampusId || null, requiredBuildingId: constraintForm.requiredBuildingId || null, @@ -216,6 +263,84 @@ async function saveConstraint() { } } +function resetConstraintFilters() { + Object.assign(constraintFilters, { + keyword: '', + collegeId: undefined, + schedulingMode: undefined, + classroomMode: undefined, + constraintState: undefined, + }) +} + +function openConstraintBatch() { + if (!filteredConstraints.value.length) { + ElMessage.warning('当前筛选结果中没有可修改的教学任务。') + return + } + Object.keys(constraintBatchForm).forEach((key) => delete constraintBatchForm[key]) + Object.assign(constraintBatchForm, { + updateSchedulingMode: false, + schedulingMode: 'Standard', + updateRequiresClassroom: false, + requiresClassroom: true, + updateDays: false, + allowedDayOfWeeks: [1, 2, 3, 4, 5], + updatePeriodRange: false, + earliestPeriod: undefined, + latestPeriod: undefined, + }) + constraintBatchDialog.value = true +} + +async function saveConstraintBatch() { + if (!constraintBatchForm.updateSchedulingMode && + !constraintBatchForm.updateRequiresClassroom && + !constraintBatchForm.updateDays && + !constraintBatchForm.updatePeriodRange) { + ElMessage.warning('请至少勾选一项需要批量修改的设置。') + return + } + const targets = [...filteredConstraints.value] + try { + await ElMessageBox.confirm( + `将修改当前筛选到的 ${targets.length} 个教学任务,确定继续吗?`, + '批量修改排课约束', + { type: 'warning', confirmButtonText: '修改当前结果', cancelButtonText: '取消' }, + ) + constraintBatchSaving.value = true + const flexible = constraintBatchForm.updateSchedulingMode && + constraintBatchForm.schedulingMode === 'Flexible' + const { data } = await http.put('/schedules/constraints/batch', { + academicTermId: termId.value, + teachingTaskIds: targets.map((item) => item.id), + schedulingMode: constraintBatchForm.updateSchedulingMode + ? constraintBatchForm.schedulingMode + : null, + requiresClassroom: !flexible && constraintBatchForm.updateRequiresClassroom + ? constraintBatchForm.requiresClassroom + : null, + allowedDayOfWeeks: !flexible && constraintBatchForm.updateDays + ? constraintBatchForm.allowedDayOfWeeks + : null, + updatePeriodRange: !flexible && constraintBatchForm.updatePeriodRange, + earliestPeriod: !flexible && constraintBatchForm.updatePeriodRange + ? constraintBatchForm.earliestPeriod || null + : null, + latestPeriod: !flexible && constraintBatchForm.updatePeriodRange + ? constraintBatchForm.latestPeriod || null + : null, + }) + constraintBatchDialog.value = false + ElMessage.success(`已批量更新 ${data.affectedCount} 个教学任务`) + await loadSchedulingSettings() + } catch (error: any) { + if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) + } finally { + constraintBatchSaving.value = false + } +} + async function autoSchedule() { try { await ElMessageBox.confirm( @@ -699,40 +824,84 @@ onBeforeUnmount(clearAutoSchedulePoll) - -
-
- 课程排课约束 - 教师来自已发布教学任务;这里限定可用时间、校区、教学楼、指定教室以及是否占用教室。 -
-
-
-
-
- {{ item.taskNumber }} · 每周 {{ item.weeklyHours }} 学时 - {{ item.name }} - {{ item.teacherNames.join('、') || '未分配教师' }} · {{ item.capacity }} 人 -
-
- - {{ item.requiresClassroom ? '占用教室' : '不占教室' }} - + +
+
+ 课程排课约束 + 筛选后可批量修改当前结果;非排时课程不占用正常时间和场地。 +
+
+ 重置筛选 + 批量修改当前结果({{ filteredConstraints.length }}) +
+
+
+ + + + + + + + + + + + + + + + +
+
+ 共 {{ constraints.length }} 个教学任务,当前显示 {{ filteredConstraints.length }} 个 +
+
+
+
+ {{ item.taskNumber }} · 每周 {{ item.weeklyHours }} 学时 + {{ item.name }} · {{ item.courseName }} + {{ item.collegeName }} · {{ item.teacherNames.join('、') || '未分配教师' }} · {{ item.capacity }} 人 +
+
+ 非排时课程 + + {{ item.requiresClassroom ? '占用教室' : '不占教室' }} + 限定教学楼 指定 {{ item.allowedClassroomIds.length }} 间教室
- 设置约束 -
-
+ 设置约束 +
+ +
-
{{ constraintForm.title }}
- - +
{{ constraintForm.title }}
+ + + + 正常排课 + 非排时课程 + + 非排时课程不进入自动排课,并在班级和个人课表中单独显示。 + + + +
+ + +
+ 将作用于当前筛选到的 {{ filteredConstraints.length }} 个教学任务 +
+ + + 修改授课方式 + + + 正常排课 + 非排时课程 + + + + +
diff --git a/web/src/views/TeachingTasksView.vue b/web/src/views/TeachingTasksView.vue index b4e1aa8..a52be93 100644 --- a/web/src/views/TeachingTasksView.vue +++ b/web/src/views/TeachingTasksView.vue @@ -77,6 +77,14 @@ const availableClasses = computed(() => { const selectedCourse = computed(() => courses.value.find((item) => item.id === form.courseId), ) +const plannedHours = computed(() => + form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek + ? (form.endWeek - form.startWeek + 1) * form.weeklyHours + : 0, +) +const hoursMatch = computed(() => + !selectedCourse.value || plannedHours.value === selectedCourse.value.totalHours, +) const assignableTeachers = computed(() => { if (!selectedCourse.value) return teachers.value const approvedIds = new Set(manualEligibleTeachers.value.map((item) => item.teacherId)) @@ -92,6 +100,21 @@ const publicCourses = computed(() => ['GeneralRequired', 'GeneralElective'].includes(item.nature), ), ) +const selectedGenerationCourse = computed(() => + publicCourses.value.find((item) => item.id === generationForm.courseId), +) +const generationPlannedHours = computed(() => + generationForm.startWeek && + generationForm.endWeek && + generationForm.weeklyHours && + generationForm.endWeek >= generationForm.startWeek + ? (generationForm.endWeek - generationForm.startWeek + 1) * generationForm.weeklyHours + : 0, +) +const generationHoursMatch = computed(() => + !selectedGenerationCourse.value || + generationPlannedHours.value === selectedGenerationCourse.value.totalHours, +) const manageableCourses = computed(() => { if (isSuperAdmin.value) return courses.value return courses.value.filter((item) => { @@ -265,7 +288,8 @@ function resetForm(detail?: any) { capacity: 60, startWeek: 1, endWeek: 16, - weeklyHours: 2, + weeklyHours: 2, + schedulingMode: 'Standard', teacherIds: [], primaryTeacherId: undefined, classIds: [], @@ -325,6 +349,12 @@ async function save() { ElMessage.warning('开始周不能晚于结束周。') return } + if (!hoursMatch.value) { + ElMessage.warning( + `课程总学时为 ${selectedCourse.value.totalHours},当前授课安排合计 ${plannedHours.value} 学时,请调整周次或周学时。`, + ) + return + } try { if (editingId.value) await http.put(`/teaching-tasks/${editingId.value}`, form) else await http.post('/teaching-tasks', form) @@ -466,6 +496,12 @@ async function generatePublicTasks() { ElMessage.warning('该课程还没有审核通过的可授课教师。') return } + if (!generationHoursMatch.value) { + ElMessage.warning( + `课程总学时为 ${selectedGenerationCourse.value.totalHours},当前授课安排合计 ${generationPlannedHours.value} 学时,请调整周次或周学时。`, + ) + return + } try { await ElMessageBox.confirm( `将 ${generationForm.classIds.length} 个行政班分成 ${generationGroupCount.value} 个教学班,并在 ${eligibleTeachers.value.length} 名合格教师中随机均衡分配。生成结果为草稿,确定继续吗?`, @@ -618,7 +654,7 @@ onMounted(async () => { - + @@ -640,7 +676,26 @@ onMounted(async () => { - + +
+ {{ hoursMatch ? '学时匹配' : '学时不匹配' }} + + 第 {{ form.startWeek }}—{{ form.endWeek }} 周 × 每周 {{ form.weeklyHours }} 学时 + = {{ plannedHours }} 学时;课程库总学时 {{ selectedCourse.totalHours }}。 + +
+ + + 正常排课 + 非排时课程 + + + 非排时课程不进入自动排课,不占用星期、节次和场地,并在课表中单独列出。 + + @@ -757,9 +812,19 @@ onMounted(async () => { - - - + + + +
+ {{ generationHoursMatch ? '学时匹配' : '学时不匹配' }} + + 当前合计 {{ generationPlannedHours }} 学时;课程库总学时 + {{ selectedGenerationCourse.totalHours }}。 + +
预计生成 {{ generationGroupCount }} diff --git a/web/src/views/TimetableView.vue b/web/src/views/TimetableView.vue index d952efd..23c614f 100644 --- a/web/src/views/TimetableView.vue +++ b/web/src/views/TimetableView.vue @@ -145,7 +145,28 @@ onMounted(async () => { 发布于 {{ new Date(timetable.plan.publishedAt).toLocaleString('zh-CN') }} 本学期课表尚未发布
- + + +
+
+
+ 非排时课程 + 不占正常上课时间与场地 +
+ 共 {{ timetable.flexibleCourses.length }} 门 +
+
+
+ {{ course.courseCode }} · {{ course.taskNumber }} + {{ course.courseName }} +

{{ course.teacherNames.join('、') || '教师待定' }}

+ + 第 {{ course.startWeek }}—{{ course.endWeek }} 周 · + 每周 {{ course.weeklyHours }} 学时 · 共 {{ course.totalHours }} 学时 + +
+
+
{
@@ -190,21 +211,33 @@ onMounted(async () => {