diff --git a/README.md b/README.md index 27a493d..09bb0ba 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 -当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。 +当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。 权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。 diff --git a/src/Jiaowu.Api/Controllers/BaseDataController.cs b/src/Jiaowu.Api/Controllers/BaseDataController.cs index 3958254..874a5db 100644 --- a/src/Jiaowu.Api/Controllers/BaseDataController.cs +++ b/src/Jiaowu.Api/Controllers/BaseDataController.cs @@ -349,6 +349,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase .Select(x => new { x.Id, x.Code, x.Name, x.BuildingId, + CampusId = x.Building!.CampusId, BuildingName = x.Building!.Name, CampusName = x.Building.Campus!.Name, x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index 6dc9113..6f9d337 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -376,7 +376,7 @@ public sealed class CourseSelectionsController( entry.StartWeek, entry.EndWeek, entry.WeekPattern, - entry.Classroom!.Name)) + entry.Classroom == null ? "不占用教室" : entry.Classroom.Name)) .ToList())) .ToListAsync(cancellationToken); diff --git a/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs new file mode 100644 index 0000000..3cb8f1b --- /dev/null +++ b/src/Jiaowu.Api/Controllers/ScheduleSettingsController.cs @@ -0,0 +1,220 @@ +using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)] +[Route("api/schedules")] +public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase +{ + [HttpGet("time-slots")] + public async Task GetTimeSlots( + Guid academicTermId, + CancellationToken cancellationToken) => + Ok(await db.ScheduleTimeSlots.AsNoTracking() + .Where(x => x.AcademicTermId == academicTermId) + .OrderBy(x => x.PeriodNumber) + .Select(x => new + { + x.Id, + x.PeriodNumber, + x.Name, + StartsAt = x.StartsAt.ToString("HH:mm"), + EndsAt = x.EndsAt.ToString("HH:mm"), + x.IsEnabled + }) + .ToListAsync(cancellationToken)); + + [HttpPut("time-slots/{academicTermId:guid}")] + public async Task ReplaceTimeSlots( + Guid academicTermId, + IReadOnlyList requests, + CancellationToken cancellationToken) + { + if (!await db.AcademicTerms.AnyAsync( + x => x.Id == academicTermId && x.IsEnabled, + cancellationToken)) + return ValidationProblem("学期不存在或已停用。"); + if (requests.Count == 0) + return ValidationProblem("上课时间表至少需要一个节次。"); + if (requests.Select(x => x.PeriodNumber).Distinct().Count() != requests.Count) + return ValidationProblem("节次编号不能重复。"); + if (requests.Any(x => x.StartsAt >= x.EndsAt)) + return ValidationProblem("每个节次的上课时间必须早于下课时间。"); + var ordered = requests.OrderBy(x => x.StartsAt).ToList(); + if (ordered.Zip(ordered.Skip(1)).Any(pair => pair.First.EndsAt > pair.Second.StartsAt)) + return ValidationProblem("上课时间段不能相互重叠。"); + + var existing = await db.ScheduleTimeSlots + .Where(x => x.AcademicTermId == academicTermId) + .ToListAsync(cancellationToken); + db.ScheduleTimeSlots.RemoveRange(existing); + db.ScheduleTimeSlots.AddRange(requests.Select(request => new ScheduleTimeSlot + { + AcademicTermId = academicTermId, + PeriodNumber = request.PeriodNumber, + Name = request.Name.Trim(), + StartsAt = request.StartsAt, + EndsAt = request.EndsAt, + IsEnabled = request.IsEnabled + })); + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpGet("constraints")] + public async Task GetConstraints( + Guid academicTermId, + CancellationToken cancellationToken) + { + var tasks = await db.TeachingTasks.AsNoTracking() + .Where(x => + x.AcademicTermId == academicTermId && + x.Status == TeachingTaskStatus.Published) + .OrderBy(x => x.TaskNumber) + .Select(x => new + { + x.Id, + x.TaskNumber, + x.Name, + CourseName = x.Course!.Name, + TeacherNames = x.Teachers + .OrderByDescending(item => item.IsPrimary) + .Select(item => item.Teacher!.Name), + x.Capacity, + x.WeeklyHours + }) + .ToListAsync(cancellationToken); + var taskIds = tasks.Select(x => x.Id).ToList(); + var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() + .Where(x => taskIds.Contains(x.TeachingTaskId)) + .Include(x => x.AllowedClassrooms) + .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); + return Ok(tasks.Select(task => + { + constraints.TryGetValue(task.Id, out var constraint); + return new + { + task.Id, + task.TaskNumber, + task.Name, + task.CourseName, + task.TeacherNames, + task.Capacity, + task.WeeklyHours, + RequiresClassroom = constraint?.RequiresClassroom ?? true, + constraint?.RequiredCampusId, + constraint?.RequiredBuildingId, + AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks), + constraint?.EarliestPeriod, + constraint?.LatestPeriod, + AllowedClassroomIds = constraint?.AllowedClassrooms + .Select(x => x.ClassroomId) ?? [] + }; + })); + } + + [HttpPut("constraints/{teachingTaskId:guid}")] + public async Task SaveConstraint( + Guid teachingTaskId, + TeachingTaskScheduleConstraintRequest request, + CancellationToken cancellationToken) + { + if (request.AllowedDayOfWeeks.Any(day => day is < 1 or > 7)) + return ValidationProblem("允许上课日必须位于星期一至星期日。"); + if (request.EarliestPeriod.HasValue && + request.LatestPeriod.HasValue && + request.EarliestPeriod > request.LatestPeriod) + return ValidationProblem("最早节次不能晚于最晚节次。"); + var task = await db.TeachingTasks + .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); + if (task is null) return NotFound(); + + Building? building = null; + if (request.RequiredBuildingId.HasValue) + { + building = await db.Buildings.AsNoTracking() + .FirstOrDefaultAsync( + x => x.Id == request.RequiredBuildingId && x.IsEnabled, + cancellationToken); + if (building is null) return ValidationProblem("指定教学楼不存在或已停用。"); + if (request.RequiredCampusId.HasValue && + building.CampusId != request.RequiredCampusId) + return ValidationProblem("指定教学楼不属于所选校区。"); + } + if (request.RequiredCampusId.HasValue && + !await db.Campuses.AnyAsync( + x => x.Id == request.RequiredCampusId && x.IsEnabled, + cancellationToken)) + return ValidationProblem("指定校区不存在或已停用。"); + + var allowedRooms = await db.Classrooms.AsNoTracking() + .Where(x => request.AllowedClassroomIds.Contains(x.Id) && x.IsEnabled) + .Include(x => x.Building) + .ToListAsync(cancellationToken); + if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count()) + return ValidationProblem("部分指定教室不存在或已停用。"); + if (building is not null && allowedRooms.Any(x => x.BuildingId != building.Id)) + return ValidationProblem("指定教室必须位于所选教学楼。"); + if (request.RequiredCampusId.HasValue && + allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId)) + return ValidationProblem("指定教室必须位于所选校区。"); + + var constraint = await db.TeachingTaskScheduleConstraints + .Include(x => x.AllowedClassrooms) + .FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken); + if (constraint is null) + { + constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = teachingTaskId }; + db.TeachingTaskScheduleConstraints.Add(constraint); + } + constraint.RequiresClassroom = request.RequiresClassroom; + constraint.RequiredCampusId = request.RequiresClassroom + ? request.RequiredCampusId + : null; + constraint.RequiredBuildingId = request.RequiresClassroom + ? request.RequiredBuildingId + : null; + constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0 + ? null + : string.Join(',', request.AllowedDayOfWeeks.Distinct().Order()); + constraint.EarliestPeriod = request.EarliestPeriod; + constraint.LatestPeriod = request.LatestPeriod; + db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms); + constraint.AllowedClassrooms = request.RequiresClassroom + ? request.AllowedClassroomIds.Distinct().Select(classroomId => + new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList() + : []; + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + private static int[] ParseDays(string? value) => + string.IsNullOrWhiteSpace(value) + ? [] + : value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse) + .ToArray(); +} + +public sealed record ScheduleTimeSlotRequest( + [Range(1, 30)] int PeriodNumber, + [Required, MaxLength(40)] string Name, + TimeOnly StartsAt, + TimeOnly EndsAt, + bool IsEnabled); + +public sealed record TeachingTaskScheduleConstraintRequest( + bool RequiresClassroom, + Guid? RequiredCampusId, + Guid? RequiredBuildingId, + IReadOnlyList AllowedClassroomIds, + IReadOnlyList AllowedDayOfWeeks, + [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 071c160..9921ad3 100644 --- a/src/Jiaowu.Api/Controllers/SchedulesController.cs +++ b/src/Jiaowu.Api/Controllers/SchedulesController.cs @@ -12,7 +12,9 @@ namespace Jiaowu.Api.Controllers; [ApiController] [Authorize(Roles = ManagementRoles)] [Route("api/schedules")] -public sealed class SchedulesController(AppDbContext db) : ControllerBase +public sealed class SchedulesController( + AppDbContext db, + AutomaticScheduleGenerator scheduleGenerator) : ControllerBase { private const string ManagementRoles = SystemRoles.SuperAdmin + "," + @@ -76,9 +78,15 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase ClassNames = entry.TeachingTask.Classes .Select(item => item.AdministrativeClass!.Name), entry.ClassroomId, - ClassroomName = entry.Classroom!.Name, - BuildingName = entry.Classroom.Building!.Name, - CampusName = entry.Classroom.Building.Campus!.Name, + ClassroomName = entry.Classroom == null + ? null + : entry.Classroom.Name, + BuildingName = entry.Classroom == null + ? null + : entry.Classroom.Building!.Name, + CampusName = entry.Classroom == null + ? null + : entry.Classroom.Building!.Campus!.Name, entry.DayOfWeek, entry.StartPeriod, entry.PeriodCount, @@ -194,6 +202,41 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase if (plan.Entries.Count == 0) return ConflictProblem("排课版本中至少需要一条课表安排。"); + foreach (var entry in plan.Entries) + { + var validation = await ValidateEntryAsync( + plan, + entry.Id, + new ScheduleEntryRequest( + entry.TeachingTaskId, + entry.ClassroomId, + entry.DayOfWeek, + entry.StartPeriod, + entry.PeriodCount, + entry.StartWeek, + entry.EndWeek, + entry.WeekPattern, + entry.Notes), + cancellationToken); + if (validation is not null) return validation; + } + + var requiredTasks = await db.TeachingTasks.AsNoTracking() + .Where(x => + x.AcademicTermId == plan.AcademicTermId && + x.Status == TeachingTaskStatus.Published) + .Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours }) + .ToListAsync(cancellationToken); + var scheduledHours = plan.Entries + .GroupBy(x => x.TeachingTaskId) + .ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount)); + var incomplete = requiredTasks.FirstOrDefault(task => + !scheduledHours.TryGetValue(task.Id, out var hours) || + hours < task.WeeklyHours); + if (incomplete is not null) + return ConflictProblem( + $"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 {incomplete.WeeklyHours} 学时,不能发布。"); + var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList()); if (conflict is not null) return ConflictProblem(conflict); @@ -227,6 +270,17 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase return await SaveAsync(entry.Id, true, cancellationToken); } + [HttpPost("plans/{planId:guid}/auto-schedule")] + public async Task AutoSchedule( + Guid planId, + CancellationToken cancellationToken) + { + var plan = await DraftPlanAsync(planId, cancellationToken); + if (plan is null) return NotFound(); + var result = await scheduleGenerator.GenerateAsync(plan, cancellationToken); + return Ok(result); + } + [HttpPut("plans/{planId:guid}/entries/{entryId:guid}")] public async Task UpdateEntry( Guid planId, @@ -286,8 +340,15 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase { if (request.StartWeek > request.EndWeek) return ValidationProblem("开始周不能晚于结束周。"); - if (request.StartPeriod + request.PeriodCount - 1 > 12) - return ValidationProblem("结束节次不能超过第 12 节。"); + var activePeriods = await db.ScheduleTimeSlots.AsNoTracking() + .Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled) + .Select(x => x.PeriodNumber) + .ToListAsync(cancellationToken); + if (activePeriods.Count == 0) + return ValidationProblem("请先维护该学期的上课时间表。"); + if (Enumerable.Range(request.StartPeriod, request.PeriodCount) + .Any(period => !activePeriods.Contains(period))) + return ValidationProblem("所选节次包含未启用或不存在的上课时间。"); var task = await db.TeachingTasks.AsNoTracking() .Include(x => x.Teachers) @@ -302,17 +363,55 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek) return ValidationProblem("排课周次必须位于教学任务的授课周次内。"); - var classroom = await db.Classrooms.AsNoTracking() + var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking() + .Include(x => x.AllowedClassrooms) .FirstOrDefaultAsync( - x => x.Id == request.ClassroomId && x.IsEnabled, + x => x.TeachingTaskId == request.TeachingTaskId, cancellationToken); - if (classroom is null) return ValidationProblem("所选教室不存在或已停用。"); + var requiresClassroom = constraint?.RequiresClassroom ?? true; + if (requiresClassroom && !request.ClassroomId.HasValue) + return ValidationProblem("该课程需要占用教室,请选择教室。"); + if (!requiresClassroom && request.ClassroomId.HasValue) + return ValidationProblem("该课程已设置为不占用教室,请清空教室。"); + var allowedDays = ParseDays(constraint?.AllowedDayOfWeeks); + if (allowedDays.Count > 0 && !allowedDays.Contains(request.DayOfWeek)) + return ValidationProblem("所选星期不在该教学任务允许的上课日内。"); + if (constraint?.EarliestPeriod is int earliest && + request.StartPeriod < earliest) + return ValidationProblem($"该教学任务最早只能从第 {earliest} 节开始。"); + if (constraint?.LatestPeriod is int latest && + request.StartPeriod + request.PeriodCount - 1 > latest) + return ValidationProblem($"该教学任务最晚必须在第 {latest} 节结束。"); + + Classroom? classroom = null; + if (request.ClassroomId.HasValue) + { + classroom = await db.Classrooms.AsNoTracking() + .Include(x => x.Building) + .FirstOrDefaultAsync( + x => x.Id == request.ClassroomId && x.IsEnabled, + cancellationToken); + if (classroom is null) return ValidationProblem("所选教室不存在或已停用。"); + if (constraint?.RequiredCampusId is Guid campusId && + classroom.Building!.CampusId != campusId) + return ValidationProblem("所选教室不在该课程指定的校区。"); + if (constraint?.RequiredBuildingId is Guid buildingId && + classroom.BuildingId != buildingId) + return ValidationProblem("所选教室不在该课程指定的教学楼。"); + var allowedClassroomIds = constraint?.AllowedClassrooms + .Select(x => x.ClassroomId) + .ToHashSet() ?? []; + if (allowedClassroomIds.Count > 0 && + !allowedClassroomIds.Contains(classroom.Id)) + return ValidationProblem("所选教室不在该课程指定的教室范围内。"); + } var studentCount = task.Classes.Sum(x => x.AdministrativeClass!.Students.Count(student => student.Status == StudentStatus.Active)); - if (studentCount > classroom.Capacity) + var requiredCapacity = Math.Max(task.Capacity, studentCount); + if (classroom is not null && requiredCapacity > classroom.Capacity) return ConflictProblem( - $"教室容量不足:教学班有 {studentCount} 名学生,教室仅容纳 {classroom.Capacity} 人。"); + $"教室容量不足:教学任务容量为 {requiredCapacity} 人,教室仅容纳 {classroom.Capacity} 人。"); var candidates = await db.ScheduleEntries.AsNoTracking() .Where(x => @@ -356,6 +455,13 @@ public sealed class SchedulesController(AppDbContext db) : ControllerBase Notes = Normalize(request.Notes) }; + private static HashSet ParseDays(string? value) => + string.IsNullOrWhiteSpace(value) + ? [] + : value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse) + .ToHashSet(); + private async Task SaveAsync( Guid id, bool created, @@ -396,9 +502,9 @@ public sealed record CloneSchedulePlanRequest( public sealed record ScheduleEntryRequest( Guid TeachingTaskId, - Guid ClassroomId, + Guid? ClassroomId, [Range(1, 7)] int DayOfWeek, - [Range(1, 12)] int StartPeriod, + [Range(1, 30)] int StartPeriod, [Range(1, 6)] int PeriodCount, [Range(1, 30)] int StartWeek, [Range(1, 30)] int EndWeek, diff --git a/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs b/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs index 9cc5e52..2bce59e 100644 --- a/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs @@ -20,7 +20,7 @@ public sealed class ScheduleEntry : EntityBase public SchedulePlan? SchedulePlan { get; set; } public Guid TeachingTaskId { get; set; } public TeachingTask? TeachingTask { get; set; } - public Guid ClassroomId { get; set; } + public Guid? ClassroomId { get; set; } public Classroom? Classroom { get; set; } public int DayOfWeek { get; set; } public int StartPeriod { get; set; } @@ -31,6 +31,40 @@ public sealed class ScheduleEntry : EntityBase public string? Notes { get; set; } } +public sealed class ScheduleTimeSlot : EntityBase +{ + public Guid AcademicTermId { get; set; } + public AcademicTerm? AcademicTerm { get; set; } + public int PeriodNumber { get; set; } + public required string Name { get; set; } + public TimeOnly StartsAt { get; set; } + public TimeOnly EndsAt { get; set; } + public bool IsEnabled { get; set; } = true; +} + +public sealed class TeachingTaskScheduleConstraint : EntityBase +{ + public Guid TeachingTaskId { get; set; } + public TeachingTask? TeachingTask { get; set; } + public bool RequiresClassroom { get; set; } = true; + public Guid? RequiredCampusId { get; set; } + public Campus? RequiredCampus { get; set; } + public Guid? RequiredBuildingId { get; set; } + public Building? RequiredBuilding { get; set; } + public string? AllowedDayOfWeeks { get; set; } + public int? EarliestPeriod { get; set; } + public int? LatestPeriod { get; set; } + public ICollection AllowedClassrooms { get; set; } = []; +} + +public sealed class TeachingTaskAllowedClassroom +{ + public Guid TeachingTaskScheduleConstraintId { get; set; } + public TeachingTaskScheduleConstraint? TeachingTaskScheduleConstraint { get; set; } + public Guid ClassroomId { get; set; } + public Classroom? Classroom { get; set; } +} + public enum SchedulePlanStatus { Draft = 1, diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 382d549..90ba10a 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -29,6 +29,11 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet TeachingTaskClasses => Set(); public DbSet SchedulePlans => Set(); public DbSet ScheduleEntries => Set(); + public DbSet ScheduleTimeSlots => Set(); + public DbSet TeachingTaskScheduleConstraints => + Set(); + public DbSet TeachingTaskAllowedClassrooms => + Set(); public DbSet CourseSelectionRounds => Set(); public DbSet CourseSelectionOfferings => @@ -293,6 +298,51 @@ public sealed class AppDbContext(DbContextOptions options) .WithMany() .HasForeignKey(x => x.TeachingTaskId) .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.Classroom) + .WithMany() + .HasForeignKey(x => x.ClassroomId) + .OnDelete(DeleteBehavior.SetNull); + }); + + builder.Entity(entity => + { + entity.Property(x => x.Name).HasMaxLength(40); + entity.HasIndex(x => new { x.AcademicTermId, x.PeriodNumber }).IsUnique(); + entity.HasOne(x => x.AcademicTerm) + .WithMany() + .HasForeignKey(x => x.AcademicTermId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(entity => + { + entity.Property(x => x.AllowedDayOfWeeks).HasMaxLength(20); + entity.HasIndex(x => x.TeachingTaskId).IsUnique(); + entity.HasOne(x => x.TeachingTask) + .WithMany() + .HasForeignKey(x => x.TeachingTaskId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.RequiredCampus) + .WithMany() + .HasForeignKey(x => x.RequiredCampusId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.RequiredBuilding) + .WithMany() + .HasForeignKey(x => x.RequiredBuildingId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.HasKey(x => new + { + x.TeachingTaskScheduleConstraintId, + x.ClassroomId + }); + entity.HasOne(x => x.TeachingTaskScheduleConstraint) + .WithMany(x => x.AllowedClassrooms) + .HasForeignKey(x => x.TeachingTaskScheduleConstraintId) + .OnDelete(DeleteBehavior.Cascade); entity.HasOne(x => x.Classroom) .WithMany() .HasForeignKey(x => x.ClassroomId) diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 59f1cb3..c528bc9 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -19,6 +19,8 @@ public sealed class DevelopmentSqliteMigrator( private const string DegreeAwardsMigration = "20260724_11_degree_awards"; private const string GraduationClearanceMigration = "20260724_12_graduation_clearance"; private const string CourseCategoriesMigration = "20260724_13_course_categories"; + private const string SchedulingOptimizationMigration = + "20260724_14_scheduling_optimization"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -107,6 +109,18 @@ public sealed class DevelopmentSqliteMigrator( "ALTER TABLE", StringComparison.OrdinalIgnoreCase)) : CourseCategoriesStatements, cancellationToken); + var schedulingOptimizationExists = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM sqlite_master + WHERE type = 'table' AND name = 'ScheduleTimeSlots' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + SchedulingOptimizationMigration, + schedulingOptimizationExists ? [] : SchedulingOptimizationStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -814,4 +828,129 @@ public sealed class DevelopmentSqliteMigrator( ON "Courses" ("CourseCategoryId"); """ ]; + + private static readonly string[] SchedulingOptimizationStatements = + [ + """ + CREATE TABLE "ScheduleEntries_New" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ScheduleEntries" PRIMARY KEY, + "SchedulePlanId" TEXT NOT NULL, + "TeachingTaskId" TEXT NOT NULL, + "ClassroomId" TEXT NULL, + "DayOfWeek" INTEGER NOT NULL, + "StartPeriod" INTEGER NOT NULL, + "PeriodCount" INTEGER NOT NULL, + "StartWeek" INTEGER NOT NULL, + "EndWeek" INTEGER NOT NULL, + "WeekPattern" INTEGER NOT NULL, + "Notes" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_ScheduleEntries_SchedulePlans_SchedulePlanId" + FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_ScheduleEntries_TeachingTasks_TeachingTaskId" + FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") + ON DELETE RESTRICT, + CONSTRAINT "FK_ScheduleEntries_Classrooms_ClassroomId" + FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") + ON DELETE SET NULL + ); + """, + """ + INSERT INTO "ScheduleEntries_New" + SELECT "Id", "SchedulePlanId", "TeachingTaskId", "ClassroomId", + "DayOfWeek", "StartPeriod", "PeriodCount", "StartWeek", "EndWeek", + "WeekPattern", "Notes", "CreatedAt", "UpdatedAt" + FROM "ScheduleEntries"; + """, + """DROP TABLE "ScheduleEntries";""", + """ALTER TABLE "ScheduleEntries_New" RENAME TO "ScheduleEntries";""", + """ + CREATE INDEX "IX_ScheduleEntries_SchedulePlanId_DayOfWeek_StartPeriod" + ON "ScheduleEntries" ("SchedulePlanId", "DayOfWeek", "StartPeriod"); + """, + """ + CREATE INDEX "IX_ScheduleEntries_TeachingTaskId" + ON "ScheduleEntries" ("TeachingTaskId"); + """, + """ + CREATE INDEX "IX_ScheduleEntries_ClassroomId" + ON "ScheduleEntries" ("ClassroomId"); + """, + """ + CREATE TABLE "ScheduleTimeSlots" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ScheduleTimeSlots" PRIMARY KEY, + "AcademicTermId" TEXT NOT NULL, + "PeriodNumber" INTEGER NOT NULL, + "Name" TEXT NOT NULL, + "StartsAt" TEXT NOT NULL, + "EndsAt" TEXT NOT NULL, + "IsEnabled" INTEGER NOT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_ScheduleTimeSlots_AcademicTerms_AcademicTermId" + FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") + ON DELETE CASCADE + ); + """, + """ + CREATE UNIQUE INDEX "IX_ScheduleTimeSlots_AcademicTermId_PeriodNumber" + ON "ScheduleTimeSlots" ("AcademicTermId", "PeriodNumber"); + """, + """ + CREATE TABLE "TeachingTaskScheduleConstraints" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTaskScheduleConstraints" PRIMARY KEY, + "TeachingTaskId" TEXT NOT NULL, + "RequiresClassroom" INTEGER NOT NULL, + "RequiredCampusId" TEXT NULL, + "RequiredBuildingId" TEXT NULL, + "AllowedDayOfWeeks" TEXT NULL, + "EarliestPeriod" INTEGER NULL, + "LatestPeriod" INTEGER NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_TeachingTaskScheduleConstraints_TeachingTasks_TeachingTaskId" + FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_TeachingTaskScheduleConstraints_Campuses_RequiredCampusId" + FOREIGN KEY ("RequiredCampusId") REFERENCES "Campuses" ("Id") + ON DELETE RESTRICT, + CONSTRAINT "FK_TeachingTaskScheduleConstraints_Buildings_RequiredBuildingId" + FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") + ON DELETE RESTRICT + ); + """, + """ + CREATE UNIQUE INDEX "IX_TeachingTaskScheduleConstraints_TeachingTaskId" + ON "TeachingTaskScheduleConstraints" ("TeachingTaskId"); + """, + """ + CREATE INDEX "IX_TeachingTaskScheduleConstraints_RequiredCampusId" + ON "TeachingTaskScheduleConstraints" ("RequiredCampusId"); + """, + """ + CREATE INDEX "IX_TeachingTaskScheduleConstraints_RequiredBuildingId" + ON "TeachingTaskScheduleConstraints" ("RequiredBuildingId"); + """, + """ + CREATE TABLE "TeachingTaskAllowedClassrooms" ( + "TeachingTaskScheduleConstraintId" TEXT NOT NULL, + "ClassroomId" TEXT NOT NULL, + CONSTRAINT "PK_TeachingTaskAllowedClassrooms" + PRIMARY KEY ("TeachingTaskScheduleConstraintId", "ClassroomId"), + CONSTRAINT "FK_TeachingTaskAllowedClassrooms_Constraints" + FOREIGN KEY ("TeachingTaskScheduleConstraintId") + REFERENCES "TeachingTaskScheduleConstraints" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_TeachingTaskAllowedClassrooms_Classrooms_ClassroomId" + FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") + ON DELETE RESTRICT + ); + """, + """ + CREATE INDEX "IX_TeachingTaskAllowedClassrooms_ClassroomId" + ON "TeachingTaskAllowedClassrooms" ("ClassroomId"); + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724111113_SchedulingOptimization.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724111113_SchedulingOptimization.Designer.cs new file mode 100644 index 0000000..b6fe550 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724111113_SchedulingOptimization.Designer.cs @@ -0,0 +1,2701 @@ +// +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("20260724111113_SchedulingOptimization")] + partial class SchedulingOptimization + { + /// + 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.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.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("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeeklyHours") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("TaskNumber") + .IsUnique(); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("TeachingTasks"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.Property("TeachingTaskScheduleConstraintId") + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("TeachingTaskAllowedClassrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskId", "AdministrativeClassId"); + + b.HasIndex("AdministrativeClassId"); + + b.ToTable("TeachingTaskClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AllowedDayOfWeeks") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EarliestPeriod") + .HasColumnType("int"); + + b.Property("LatestPeriod") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredCampusId") + .HasColumnType("char(36)"); + + b.Property("RequiresClassroom") + .HasColumnType("tinyint(1)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("RequiredCampusId"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("TeachingTaskScheduleConstraints"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.HasKey("TeachingTaskId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("TeachingTaskTeachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("DataScope") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("StaffNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("StaffNumber"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "CounselorUser") + .WithMany() + .HasForeignKey("CounselorUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CounselorUser"); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.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.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/20260724111113_SchedulingOptimization.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724111113_SchedulingOptimization.cs new file mode 100644 index 0000000..97cec8c --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724111113_SchedulingOptimization.cs @@ -0,0 +1,187 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class SchedulingOptimization : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ScheduleEntries_Classrooms_ClassroomId", + table: "ScheduleEntries"); + + migrationBuilder.AlterColumn( + name: "ClassroomId", + table: "ScheduleEntries", + type: "char(36)", + nullable: true, + oldClrType: typeof(Guid), + oldType: "char(36)"); + + migrationBuilder.CreateTable( + name: "ScheduleTimeSlots", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + AcademicTermId = table.Column(type: "char(36)", nullable: false), + PeriodNumber = table.Column(type: "int", nullable: false), + Name = table.Column(type: "varchar(40)", maxLength: 40, nullable: false), + StartsAt = table.Column(type: "time", nullable: false), + EndsAt = table.Column(type: "time", nullable: false), + IsEnabled = table.Column(type: "tinyint(1)", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduleTimeSlots", x => x.Id); + table.ForeignKey( + name: "FK_ScheduleTimeSlots_AcademicTerms_AcademicTermId", + column: x => x.AcademicTermId, + principalTable: "AcademicTerms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "TeachingTaskScheduleConstraints", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + TeachingTaskId = table.Column(type: "char(36)", nullable: false), + RequiresClassroom = table.Column(type: "tinyint(1)", nullable: false), + RequiredCampusId = table.Column(type: "char(36)", nullable: true), + RequiredBuildingId = table.Column(type: "char(36)", nullable: true), + AllowedDayOfWeeks = table.Column(type: "varchar(20)", maxLength: 20, nullable: true), + EarliestPeriod = table.Column(type: "int", nullable: true), + LatestPeriod = table.Column(type: "int", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TeachingTaskScheduleConstraints", x => x.Id); + table.ForeignKey( + name: "FK_TeachingTaskScheduleConstraints_Buildings_RequiredBuildingId", + column: x => x.RequiredBuildingId, + principalTable: "Buildings", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TeachingTaskScheduleConstraints_Campuses_RequiredCampusId", + column: x => x.RequiredCampusId, + principalTable: "Campuses", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TeachingTaskScheduleConstraints_TeachingTasks_TeachingTaskId", + column: x => x.TeachingTaskId, + principalTable: "TeachingTasks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "TeachingTaskAllowedClassrooms", + columns: table => new + { + TeachingTaskScheduleConstraintId = table.Column(type: "char(36)", nullable: false), + ClassroomId = table.Column(type: "char(36)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TeachingTaskAllowedClassrooms", x => new { x.TeachingTaskScheduleConstraintId, x.ClassroomId }); + table.ForeignKey( + name: "FK_TeachingTaskAllowedClassrooms_Classrooms_ClassroomId", + column: x => x.ClassroomId, + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TeachingTaskAllowedClassrooms_TeachingTaskScheduleConstraint~", + column: x => x.TeachingTaskScheduleConstraintId, + principalTable: "TeachingTaskScheduleConstraints", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleTimeSlots_AcademicTermId_PeriodNumber", + table: "ScheduleTimeSlots", + columns: new[] { "AcademicTermId", "PeriodNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TeachingTaskAllowedClassrooms_ClassroomId", + table: "TeachingTaskAllowedClassrooms", + column: "ClassroomId"); + + migrationBuilder.CreateIndex( + name: "IX_TeachingTaskScheduleConstraints_RequiredBuildingId", + table: "TeachingTaskScheduleConstraints", + column: "RequiredBuildingId"); + + migrationBuilder.CreateIndex( + name: "IX_TeachingTaskScheduleConstraints_RequiredCampusId", + table: "TeachingTaskScheduleConstraints", + column: "RequiredCampusId"); + + migrationBuilder.CreateIndex( + name: "IX_TeachingTaskScheduleConstraints_TeachingTaskId", + table: "TeachingTaskScheduleConstraints", + column: "TeachingTaskId", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_ScheduleEntries_Classrooms_ClassroomId", + table: "ScheduleEntries", + column: "ClassroomId", + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ScheduleEntries_Classrooms_ClassroomId", + table: "ScheduleEntries"); + + migrationBuilder.DropTable( + name: "ScheduleTimeSlots"); + + migrationBuilder.DropTable( + name: "TeachingTaskAllowedClassrooms"); + + migrationBuilder.DropTable( + name: "TeachingTaskScheduleConstraints"); + + migrationBuilder.AlterColumn( + name: "ClassroomId", + table: "ScheduleEntries", + type: "char(36)", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "char(36)", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_ScheduleEntries_Classrooms_ClassroomId", + table: "ScheduleEntries", + column: "ClassroomId", + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index a63257f..9ba4f74 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -1292,7 +1292,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql .ValueGeneratedOnAdd() .HasColumnType("char(36)"); - b.Property("ClassroomId") + b.Property("ClassroomId") .HasColumnType("char(36)"); b.Property("CreatedAt") @@ -1385,6 +1385,46 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("SchedulePlans"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.Property("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") @@ -1636,6 +1676,21 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql 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") @@ -1651,6 +1706,52 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql 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") @@ -2277,8 +2378,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") .WithMany() .HasForeignKey("ClassroomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + .OnDelete(DeleteBehavior.SetNull); b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") .WithMany("Entries") @@ -2310,6 +2410,17 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("AcademicTerm"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => { b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") @@ -2372,6 +2483,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Course"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", "TeachingTaskScheduleConstraint") + .WithMany("AllowedClassrooms") + .HasForeignKey("TeachingTaskScheduleConstraintId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("TeachingTaskScheduleConstraint"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => { b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") @@ -2391,6 +2521,31 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("TeachingTask"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "RequiredCampus") + .WithMany() + .HasForeignKey("RequiredCampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RequiredBuilding"); + + b.Navigation("RequiredCampus"); + + b.Navigation("TeachingTask"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => { b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") @@ -2532,6 +2687,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Teachers"); }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Navigation("AllowedClassrooms"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs new file mode 100644 index 0000000..65a7d8d --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleGenerator.cs @@ -0,0 +1,219 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.Scheduling; + +public sealed class AutomaticScheduleGenerator(AppDbContext db) +{ + public async Task GenerateAsync( + SchedulePlan plan, + CancellationToken cancellationToken) + { + var timeSlots = await db.ScheduleTimeSlots.AsNoTracking() + .Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled) + .OrderBy(x => x.PeriodNumber) + .ToListAsync(cancellationToken); + if (timeSlots.Count == 0) + return new(0, 0, ["请先维护该学期的上课时间表。"]); + + var activePeriods = timeSlots.Select(x => x.PeriodNumber).ToHashSet(); + var tasks = await db.TeachingTasks + .Where(x => + x.AcademicTermId == plan.AcademicTermId && + x.Status == TeachingTaskStatus.Published) + .Include(x => x.Teachers) + .Include(x => x.Classes) + .ThenInclude(x => x.AdministrativeClass) + .ThenInclude(x => x!.Students) + .OrderByDescending(x => x.Classes.Count + x.Teachers.Count) + .ThenByDescending(x => x.Capacity) + .ThenBy(x => x.TaskNumber) + .ToListAsync(cancellationToken); + var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking() + .Where(x => tasks.Select(task => task.Id).Contains(x.TeachingTaskId)) + .Include(x => x.AllowedClassrooms) + .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); + var classrooms = await db.Classrooms.AsNoTracking() + .Where(x => x.IsEnabled) + .Include(x => x.Building) + .OrderBy(x => x.Capacity) + .ThenBy(x => x.Name) + .ToListAsync(cancellationToken); + var entries = await db.ScheduleEntries + .Where(x => x.SchedulePlanId == plan.Id) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Classes) + .ToListAsync(cancellationToken); + + var created = 0; + var completedTasks = 0; + var messages = new List(); + foreach (var task in tasks) + { + constraints.TryGetValue(task.Id, out var constraint); + var scheduledHours = entries + .Where(x => x.TeachingTaskId == task.Id) + .Sum(x => x.PeriodCount); + var remainingHours = Math.Max(0, task.WeeklyHours - scheduledHours); + if (remainingHours == 0) + { + completedTasks++; + continue; + } + + while (remainingHours > 0) + { + var desiredBlock = remainingHours >= 2 ? 2 : 1; + var candidate = FindBestCandidate( + plan.Id, + task, + constraint, + desiredBlock, + activePeriods, + classrooms, + entries); + if (candidate is null && desiredBlock > 1) + { + candidate = FindBestCandidate( + plan.Id, + task, + constraint, + 1, + activePeriods, + classrooms, + entries); + } + if (candidate is null) break; + + db.ScheduleEntries.Add(candidate); + entries.Add(candidate); + created++; + remainingHours -= candidate.PeriodCount; + } + + if (remainingHours == 0) + { + completedTasks++; + } + else + { + messages.Add( + $"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。"); + } + } + + if (created > 0) + await db.SaveChangesAsync(cancellationToken); + return new(created, completedTasks, messages); + } + + private static ScheduleEntry? FindBestCandidate( + Guid planId, + TeachingTask task, + TeachingTaskScheduleConstraint? constraint, + int periodCount, + HashSet activePeriods, + IReadOnlyList classrooms, + IReadOnlyList entries) + { + var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks); + var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min(); + var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max(); + var rooms = EligibleRooms(task, constraint, classrooms); + if ((constraint?.RequiresClassroom ?? true) && rooms.Count == 0) + return null; + + var candidates = new List<(ScheduleEntry Entry, int Score)>(); + foreach (var day in allowedDays) + { + for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++) + { + if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period))) + continue; + + var roomOptions = constraint?.RequiresClassroom == false + ? new Classroom?[] { null } + : rooms.Cast().ToArray(); + foreach (var room in roomOptions) + { + var proposed = new ScheduleEntry + { + SchedulePlanId = planId, + TeachingTaskId = task.Id, + TeachingTask = task, + ClassroomId = room?.Id, + Classroom = room, + DayOfWeek = day, + StartPeriod = start, + PeriodCount = periodCount, + StartWeek = task.StartWeek, + EndWeek = task.EndWeek, + WeekPattern = WeekPattern.All, + Notes = "自动排课" + }; + if (entries.Any(existing => + ScheduleConflictDetector.TimeOverlaps(existing, proposed) && + ScheduleConflictDetector.ConflictReason(existing, proposed) is not null)) + continue; + + var sameTaskDay = entries.Count(x => + x.TeachingTaskId == task.Id && x.DayOfWeek == day); + var dayLoad = entries.Count(x => x.DayOfWeek == day); + var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity); + var score = sameTaskDay * 1000 + dayLoad * 10 + start + roomWaste / 10; + candidates.Add((proposed, score)); + } + } + } + return candidates + .OrderBy(x => x.Score) + .ThenBy(x => x.Entry.DayOfWeek) + .ThenBy(x => x.Entry.StartPeriod) + .Select(x => x.Entry) + .FirstOrDefault(); + } + + private static IReadOnlyList EligibleRooms( + TeachingTask task, + TeachingTaskScheduleConstraint? constraint, + IReadOnlyList classrooms) + { + if (constraint?.RequiresClassroom == false) return []; + var allowedRoomIds = constraint?.AllowedClassrooms + .Select(x => x.ClassroomId) + .ToHashSet() ?? []; + var minimumCapacity = Math.Max( + task.Capacity, + task.Classes.Sum(x => + x.AdministrativeClass?.Students.Count(student => + student.Status == StudentStatus.Active) ?? 0)); + return classrooms.Where(room => + room.Capacity >= minimumCapacity && + (!constraint?.RequiredCampusId.HasValue ?? true || + room.Building!.CampusId == constraint.RequiredCampusId) && + (!constraint?.RequiredBuildingId.HasValue ?? true || + room.BuildingId == constraint.RequiredBuildingId) && + (allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id))) + .ToList(); + } + + private static int[] ParseAllowedDays(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return [1, 2, 3, 4, 5]; + var days = value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(item => int.TryParse(item, out var day) ? day : 0) + .Where(day => day is >= 1 and <= 7) + .Distinct() + .Order() + .ToArray(); + return days.Length == 0 ? [1, 2, 3, 4, 5] : days; + } +} + +public sealed record AutomaticScheduleResult( + int CreatedEntries, + int CompletedTasks, + IReadOnlyList Messages); diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/ScheduleConflictDetector.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/ScheduleConflictDetector.cs index 69ae9ba..229aa92 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/ScheduleConflictDetector.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/ScheduleConflictDetector.cs @@ -29,7 +29,10 @@ public static class ScheduleConflictDetector public static string? ConflictReason(ScheduleEntry first, ScheduleEntry second) { - if (first.ClassroomId == second.ClassroomId) return "教室"; + if (first.ClassroomId.HasValue && + second.ClassroomId.HasValue && + first.ClassroomId == second.ClassroomId) + return "教室"; var firstTeachers = first.TeachingTask!.Teachers.Select(x => x.TeacherId).ToHashSet(); if (second.TeachingTask!.Teachers.Any(x => firstTeachers.Contains(x.TeacherId))) return "教师"; diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index be62bac..40d624f 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Middleware; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Scheduling; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -86,6 +87,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) diff --git a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs new file mode 100644 index 0000000..73d172b --- /dev/null +++ b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs @@ -0,0 +1,126 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Scheduling; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Jiaowu.Api.Tests; + +public sealed class AutomaticScheduleGeneratorTests +{ + [Fact] + public async Task Development_migration_chain_accepts_current_sqlite_schema() + { + 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 migrator = new DevelopmentSqliteMigrator( + db, + NullLogger.Instance); + await migrator.MigrateAsync(); + + Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0); + 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)); + } + + [Fact] + public async Task Generator_schedules_roomless_course_within_allowed_time() + { + 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-A", + 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 = "CS", Name = "计算机学院" }; + var course = new Course + { + Code = "MOOC-01", + Name = "在线专题", + College = college, + Credits = 1, + TotalHours = 32, + LectureHours = 32 + }; + var task = new TeachingTask + { + TaskNumber = "TASK-MOOC", + Name = "在线专题教学班", + AcademicTerm = term, + Course = course, + Capacity = 200, + StartWeek = 1, + EndWeek = 16, + WeeklyHours = 2, + Status = TeachingTaskStatus.Published + }; + var plan = new SchedulePlan + { + AcademicTerm = term, + Name = "自动排课测试", + Version = "V1" + }; + db.AddRange(term, college, course, task, plan); + db.ScheduleTimeSlots.AddRange( + new ScheduleTimeSlot + { + AcademicTerm = term, + PeriodNumber = 1, + Name = "第 1 节", + StartsAt = new TimeOnly(8, 0), + EndsAt = new TimeOnly(8, 45) + }, + new ScheduleTimeSlot + { + AcademicTerm = term, + PeriodNumber = 2, + Name = "第 2 节", + StartsAt = new TimeOnly(8, 55), + EndsAt = new TimeOnly(9, 40) + }); + db.TeachingTaskScheduleConstraints.Add(new TeachingTaskScheduleConstraint + { + TeachingTask = task, + RequiresClassroom = false, + AllowedDayOfWeeks = "3", + EarliestPeriod = 1, + LatestPeriod = 2 + }); + await db.SaveChangesAsync(); + + var result = await new AutomaticScheduleGenerator(db) + .GenerateAsync(plan, CancellationToken.None); + var entry = await db.ScheduleEntries.SingleAsync(); + + Assert.Equal(1, result.CreatedEntries); + Assert.Empty(result.Messages); + Assert.Equal(3, entry.DayOfWeek); + Assert.Equal(1, entry.StartPeriod); + Assert.Equal(2, entry.PeriodCount); + Assert.Null(entry.ClassroomId); + } +} diff --git a/tests/Jiaowu.Api.Tests/ScheduleConflictTests.cs b/tests/Jiaowu.Api.Tests/ScheduleConflictTests.cs index 363bea6..826fe4d 100644 --- a/tests/Jiaowu.Api.Tests/ScheduleConflictTests.cs +++ b/tests/Jiaowu.Api.Tests/ScheduleConflictTests.cs @@ -63,8 +63,26 @@ public sealed class ScheduleConflictTests Assert.Equal("教师", ScheduleConflictDetector.ConflictReason(first, second)); } + [Fact] + public void Two_roomless_courses_do_not_create_a_classroom_conflict() + { + var first = Entry(null, WeekPattern.All, new TeachingTask + { + TaskNumber = "ONLINE-1", + Name = "线上课程一" + }); + var second = Entry(null, WeekPattern.All, new TeachingTask + { + TaskNumber = "ONLINE-2", + Name = "线上课程二" + }); + + Assert.True(ScheduleConflictDetector.TimeOverlaps(first, second)); + Assert.Null(ScheduleConflictDetector.ConflictReason(first, second)); + } + private static ScheduleEntry Entry( - Guid classroomId, + Guid? classroomId, WeekPattern weekPattern, TeachingTask task) => new() diff --git a/web/src/components.d.ts b/web/src/components.d.ts index 44bb231..009a62b 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -14,6 +14,7 @@ declare module 'vue' { ElAlert: typeof import('element-plus/es')['ElAlert'] ElButton: typeof import('element-plus/es')['ElButton'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElDrawer: typeof import('element-plus/es')['ElDrawer'] @@ -35,7 +36,10 @@ declare module 'vue' { ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTabPane: typeof import('element-plus/es')['ElTabPane'] + ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] + ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/web/src/style.css b/web/src/style.css index 4ff1284..83b589a 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -337,6 +337,21 @@ button { cursor: pointer; } .schedule-card small, .schedule-card i { overflow: hidden; color: #c6cfe6; font-size: 8px; font-style: normal; white-space: nowrap; text-overflow: ellipsis; } .schedule-card > button { position: absolute; right: 4px; top: 3px; border: none; color: #b9c4df; background: transparent; font-size: 14px; } .schedule-card > button:hover { color: white; } +.schedule-settings-drawer .el-drawer__body { padding-top: 0; } +.settings-lead { margin: 4px 0 16px; padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 3px solid var(--teal); background: #f4faf9; } +.settings-lead > div:first-child { display: grid; gap: 4px; } +.settings-lead b { color: #223052; font-family: "STZhongsong", "Songti SC", serif; font-size: 17px; } +.settings-lead span { color: var(--muted); font-size: 10px; line-height: 1.6; } +.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-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; } +.constraint-list article b { overflow: hidden; color: #263453; font-size: 12px; white-space: nowrap; text-overflow: ellipsis; } +.constraint-list article small { color: var(--muted); font-size: 9px; } +.constraint-badges { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 5px; } +.constraint-title { margin-bottom: 16px; padding: 12px 14px; color: #eef4ff; background: linear-gradient(120deg, #243d7a, #182b5d); font-size: 13px; font-weight: 650; } .selection-page { min-width: 0; } .selection-round-strip { @@ -950,6 +965,10 @@ button { cursor: pointer; } .schedule-search { flex-wrap: wrap; } .schedule-search .el-input { width: 100%; } .schedule-search > span { width: 100%; margin-left: 0; } + .settings-lead { align-items: stretch; flex-direction: column; } + .settings-lead > div:last-child { flex-wrap: wrap; } + .constraint-list article { grid-template-columns: 1fr; } + .constraint-badges { justify-content: flex-start; } .selection-window { grid-template-columns: 82px 1fr; } .credit-meter, .round-actions { grid-column: 1 / -1; width: auto; border-top: 1px solid rgba(255,255,255,.13); diff --git a/web/src/views/SchedulesView.vue b/web/src/views/SchedulesView.vue index 28a6ee5..24f1f5f 100644 --- a/web/src/views/SchedulesView.vue +++ b/web/src/views/SchedulesView.vue @@ -1,6 +1,6 @@ @@ -233,13 +384,16 @@ onMounted(async () => {
TIMETABLE BOARD

排课与课表

-

在发布前消除教师、行政班与教室冲突,并保留每次发布的版本。

+

用作息时间和课程约束驱动教师、班级、场地自动分配,生成后仍可逐项微调。

+
+
+ 排课规则与作息 + 新建排课版本
- 新建排课版本
- +
@@ -268,6 +422,16 @@ onMounted(async () => {
编辑版本 复制调整 + + 自动排课 + 添加排课 发布课表 删除草稿 @@ -287,7 +451,7 @@ onMounted(async () => {