From e6d8eb0662e8ac3a409a56fd72ac041dd837a34b Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 24 Jul 2026 19:35:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8E=92=E8=AF=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../TeacherCourseApplicationsController.cs | 243 ++ .../Controllers/TeachingTasksController.cs | 198 ++ .../Domain/Academic/TeachingTaskEntities.cs | 26 + .../Persistence/AppDbContext.cs | 32 + .../Persistence/DevelopmentSqliteMigrator.cs | 69 + ...2805_TeacherCourseApplications.Designer.cs | 2794 +++++++++++++++++ ...0260724112805_TeacherCourseApplications.cs | 106 + .../MySql/AppDbContextModelSnapshot.cs | 93 + .../PublicCourseTaskAssignmentPlanner.cs | 33 + .../PublicCourseTaskAssignmentPlannerTests.cs | 47 + web/src/components.d.ts | 2 + web/src/layouts/AdminLayout.vue | 7 + web/src/router/index.ts | 8 + web/src/style.css | 12 + web/src/views/TeachingPreferencesView.vue | 234 ++ web/src/views/TeachingTasksView.vue | 173 +- 17 files changed, 4073 insertions(+), 6 deletions(-) create mode 100644 src/Jiaowu.Api/Controllers/TeacherCourseApplicationsController.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724112805_TeacherCourseApplications.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724112805_TeacherCourseApplications.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Teaching/PublicCourseTaskAssignmentPlanner.cs create mode 100644 tests/Jiaowu.Api.Tests/PublicCourseTaskAssignmentPlannerTests.cs create mode 100644 web/src/views/TeachingPreferencesView.vue diff --git a/README.md b/README.md index 09bb0ba..1055f94 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/TeacherCourseApplicationsController.cs b/src/Jiaowu.Api/Controllers/TeacherCourseApplicationsController.cs new file mode 100644 index 0000000..30b141b --- /dev/null +++ b/src/Jiaowu.Api/Controllers/TeacherCourseApplicationsController.cs @@ -0,0 +1,243 @@ +using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/teacher-course-applications")] +public sealed class TeacherCourseApplicationsController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase +{ + private const string ReviewRoles = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin; + + [HttpGet("mine")] + [Authorize(Roles = SystemRoles.Teacher)] + public async Task GetMine( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var teacher = await CurrentTeacherAsync(cancellationToken); + if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。"); + var source = db.TeacherCourseApplications.AsNoTracking() + .Where(x => x.TeacherId == teacher.Id); + if (academicTermId.HasValue) + source = source.Where(x => x.AcademicTermId == academicTermId); + return Ok(await source + .OrderByDescending(x => x.AcademicTerm!.StartDate) + .ThenBy(x => x.Course!.Code) + .Select(x => new + { + x.Id, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + x.CourseId, + CourseCode = x.Course!.Code, + CourseName = x.Course.Name, + CollegeName = x.Course.College!.Name, + x.Status, + x.Statement, + x.ReviewComment, + x.SubmittedAt, + x.ReviewedAt + }) + .ToListAsync(cancellationToken)); + } + + [HttpPost("mine")] + [Authorize(Roles = SystemRoles.Teacher)] + public async Task Submit( + TeacherCourseApplicationRequest request, + CancellationToken cancellationToken) + { + var teacher = await CurrentTeacherAsync(cancellationToken); + if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。"); + if (!await db.AcademicTerms.AnyAsync( + x => x.Id == request.AcademicTermId && x.IsEnabled, + cancellationToken)) + return ValidationProblem("所选学期不存在或已停用。"); + if (!await db.Courses.AnyAsync( + x => x.Id == request.CourseId && x.IsEnabled, + cancellationToken)) + return ValidationProblem("所选课程不存在或已停用。"); + + var application = await db.TeacherCourseApplications + .FirstOrDefaultAsync(x => + x.AcademicTermId == request.AcademicTermId && + x.TeacherId == teacher.Id && + x.CourseId == request.CourseId, + cancellationToken); + if (application is not null && + application.Status is TeacherCourseApplicationStatus.Pending or + TeacherCourseApplicationStatus.Approved) + return ConflictProblem("该学期的课程申报已提交或已审核通过。"); + if (application is null) + { + application = new TeacherCourseApplication + { + AcademicTermId = request.AcademicTermId, + TeacherId = teacher.Id, + CourseId = request.CourseId + }; + db.TeacherCourseApplications.Add(application); + } + application.Status = TeacherCourseApplicationStatus.Pending; + application.Statement = Normalize(request.Statement); + application.ReviewComment = null; + application.SubmittedAt = DateTime.UtcNow; + application.ReviewedAt = null; + application.ReviewedByUserId = null; + await db.SaveChangesAsync(cancellationToken); + return Created(string.Empty, new { application.Id }); + } + + [HttpDelete("mine/{id:guid}")] + [Authorize(Roles = SystemRoles.Teacher)] + public async Task Withdraw(Guid id, CancellationToken cancellationToken) + { + var teacher = await CurrentTeacherAsync(cancellationToken); + if (teacher is null) return ConflictProblem("当前账号尚未关联在职教师档案。"); + var application = await db.TeacherCourseApplications.FirstOrDefaultAsync( + x => x.Id == id && x.TeacherId == teacher.Id, + cancellationToken); + if (application is null) return NotFound(); + if (application.Status != TeacherCourseApplicationStatus.Pending) + return ConflictProblem("只有待审核申报可以撤回。"); + application.Status = TeacherCourseApplicationStatus.Withdrawn; + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpGet("reviews")] + [Authorize(Roles = ReviewRoles)] + public async Task GetReviews( + Guid? academicTermId, + TeacherCourseApplicationStatus? status, + CancellationToken cancellationToken) + { + var source = ScopedApplications().AsNoTracking(); + if (academicTermId.HasValue) + source = source.Where(x => x.AcademicTermId == academicTermId); + if (status.HasValue) source = source.Where(x => x.Status == status); + return Ok(await source + .OrderBy(x => x.Status) + .ThenByDescending(x => x.SubmittedAt) + .Select(x => new + { + x.Id, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + x.TeacherId, + x.Teacher!.TeacherNumber, + TeacherName = x.Teacher.Name, + TeacherCollegeName = x.Teacher.College!.Name, + x.CourseId, + CourseCode = x.Course!.Code, + CourseName = x.Course.Name, + CourseCollegeName = x.Course.College!.Name, + x.Status, + x.Statement, + x.ReviewComment, + x.SubmittedAt, + x.ReviewedAt + }) + .ToListAsync(cancellationToken)); + } + + [HttpPost("{id:guid}/review")] + [Authorize(Roles = ReviewRoles)] + public async Task Review( + Guid id, + TeacherCourseReviewRequest request, + CancellationToken cancellationToken) + { + if (request.Status is not ( + TeacherCourseApplicationStatus.Approved or + TeacherCourseApplicationStatus.Rejected)) + return ValidationProblem("审核结论只能为通过或驳回。"); + var application = await ScopedApplications() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (application is null) return NotFound(); + if (application.Status != TeacherCourseApplicationStatus.Pending) + return ConflictProblem("只有待审核申报可以审核。"); + application.Status = request.Status; + application.ReviewComment = Normalize(request.ReviewComment); + application.ReviewedAt = DateTime.UtcNow; + application.ReviewedByUserId = currentUserDataScope.Current.UserId; + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpGet("eligible")] + [Authorize(Roles = ReviewRoles)] + public async Task GetEligible( + Guid academicTermId, + Guid courseId, + CancellationToken cancellationToken) + { + var source = ScopedApplications().AsNoTracking() + .Where(x => + x.AcademicTermId == academicTermId && + x.CourseId == courseId && + x.Status == TeacherCourseApplicationStatus.Approved && + x.Teacher!.Status == TeacherStatus.Active); + return Ok(await source + .OrderBy(x => x.Teacher!.TeacherNumber) + .Select(x => new + { + x.TeacherId, + x.Teacher!.TeacherNumber, + x.Teacher.Name, + x.Teacher.Title, + CollegeName = x.Teacher.College!.Name + }) + .ToListAsync(cancellationToken)); + } + + private IQueryable ScopedApplications() + { + var source = db.TeacherCourseApplications.AsQueryable(); + var collegeId = currentUserDataScope.Current.RestrictedCollegeId; + return collegeId.HasValue + ? source.Where(x => x.Teacher!.CollegeId == collegeId.Value) + : source; + } + + private Task CurrentTeacherAsync(CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + return db.Teachers.FirstOrDefaultAsync( + x => x.UserId == userId && x.Status == TeacherStatus.Active, + cancellationToken); + } + + private ActionResult ConflictProblem(string detail) => + Conflict(new ProblemDetails + { + Title = "无法完成操作", + Detail = detail, + Status = StatusCodes.Status409Conflict + }); + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +public sealed record TeacherCourseApplicationRequest( + Guid AcademicTermId, + Guid CourseId, + [MaxLength(500)] string? Statement); + +public sealed record TeacherCourseReviewRequest( + TeacherCourseApplicationStatus Status, + [MaxLength(500)] string? ReviewComment); diff --git a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs index 3cf190a..7283f91 100644 --- a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs +++ b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs @@ -1,9 +1,11 @@ using System.ComponentModel.DataAnnotations; +using System.Data; using Jiaowu.Api.Contracts; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -72,6 +74,7 @@ public sealed class TeachingTasksController( x.StartWeek, x.EndWeek, x.WeeklyHours, + x.GenerationBatchCode, x.Status, TeacherNames = x.Teachers .OrderByDescending(item => item.IsPrimary) @@ -108,6 +111,7 @@ public sealed class TeachingTasksController( x.StartWeek, x.EndWeek, x.WeeklyHours, + x.GenerationBatchCode, x.Status, x.Notes, x.PublishedAt, @@ -252,6 +256,163 @@ public sealed class TeachingTasksController( return await SaveAsync(id, false, cancellationToken); } + [HttpPost("generate-public-course")] + public async Task GeneratePublicCourseTasks( + PublicCourseTaskGenerationRequest request, + CancellationToken cancellationToken) + { + if (request.StartWeek > request.EndWeek) + return ValidationProblem("开始周不能晚于结束周。"); + var term = await db.AcademicTerms.AsNoTracking() + .FirstOrDefaultAsync( + x => x.Id == request.AcademicTermId && x.IsEnabled, + cancellationToken); + if (term is null) return ValidationProblem("所选学期不存在或已停用。"); + var course = await db.Courses.AsNoTracking() + .FirstOrDefaultAsync( + x => x.Id == request.CourseId && x.IsEnabled, + cancellationToken); + if (course is null) return ValidationProblem("所选课程不存在或已停用。"); + if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective)) + return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。"); + var scopedCollegeId = ScopedCollegeId(); + if (scopedCollegeId.HasValue && course.CollegeId != scopedCollegeId.Value) + return Forbid(); + + var classIds = request.ClassIds.Distinct().ToArray(); + if (classIds.Length == 0) return ValidationProblem("请至少选择一个行政班。"); + var classesQuery = db.AdministrativeClasses + .Where(x => classIds.Contains(x.Id) && x.IsEnabled) + .Include(x => x.Major) + .Include(x => x.Students) + .AsQueryable(); + if (scopedCollegeId.HasValue) + classesQuery = classesQuery.Where(x => x.Major!.CollegeId == scopedCollegeId.Value); + var classes = await classesQuery + .OrderBy(x => x.Code) + .ToListAsync(cancellationToken); + if (classes.Count != classIds.Length) + return ValidationProblem("存在无效或不在当前数据范围内的行政班。"); + + await using var transaction = await db.Database.BeginTransactionAsync( + IsolationLevel.Serializable, + cancellationToken); + var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking() + .Where(x => + classIds.Contains(x.AdministrativeClassId) && + x.TeachingTask!.AcademicTermId == request.AcademicTermId && + x.TeachingTask.CourseId == request.CourseId) + .Select(x => x.AdministrativeClassId) + .Distinct() + .ToListAsync(cancellationToken); + if (assignedClassIds.Count > 0) + { + var names = classes + .Where(x => assignedClassIds.Contains(x.Id)) + .Select(x => x.Name); + return ConflictProblem( + $"以下行政班已生成该课程教学任务:{string.Join('、', names)}。"); + } + + var eligibleTeachers = await db.TeacherCourseApplications.AsNoTracking() + .Where(x => + x.AcademicTermId == request.AcademicTermId && + x.CourseId == request.CourseId && + x.Status == TeacherCourseApplicationStatus.Approved && + x.Teacher!.Status == TeacherStatus.Active) + .Where(x => !scopedCollegeId.HasValue || + x.Teacher!.CollegeId == scopedCollegeId.Value) + .Select(x => x.Teacher!) + .ToListAsync(cancellationToken); + if (eligibleTeachers.Count == 0) + return ConflictProblem("该课程没有学院审核通过的可授课教师,无法生成教学任务。"); + + var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray(); + var existingLoads = await db.TeachingTaskTeachers.AsNoTracking() + .Where(x => + eligibleTeacherIds.Contains(x.TeacherId) && + x.TeachingTask!.AcademicTermId == request.AcademicTermId && + x.TeachingTask.Status != TeachingTaskStatus.Closed) + .GroupBy(x => x.TeacherId) + .Select(group => new { TeacherId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken); + var existingNumbers = await db.TeachingTasks.AsNoTracking() + .Where(x => x.AcademicTermId == request.AcademicTermId) + .Select(x => x.TaskNumber) + .ToHashSetAsync(cancellationToken); + var batchCode = + $"AUTO-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..31]; + var groups = classes.Chunk(request.ClassesPerTask).ToList(); + var teacherAssignments = PublicCourseTaskAssignmentPlanner.AssignTeachers( + eligibleTeacherIds, + existingLoads, + groups.Count); + var created = new List(); + for (var index = 0; index < groups.Count; index++) + { + var teacher = eligibleTeachers.First(x => x.Id == teacherAssignments[index]); + var group = groups[index]; + var taskNumber = NextTaskNumber( + term.Code, + course.Code, + index + 1, + existingNumbers); + existingNumbers.Add(taskNumber); + var studentCount = group.Sum(administrativeClass => + administrativeClass.Students.Count(student => + student.Status == StudentStatus.Active)); + var task = new TeachingTask + { + TaskNumber = taskNumber, + Name = $"{course.Name}教学班 {index + 1:D2}", + AcademicTermId = term.Id, + CourseId = course.Id, + Capacity = Math.Max(1, studentCount), + StartWeek = request.StartWeek, + EndWeek = request.EndWeek, + WeeklyHours = request.WeeklyHours, + GenerationBatchCode = batchCode, + Notes = "公共课合班自动生成,发布前可继续调整。", + Teachers = + [ + new TeachingTaskTeacher + { + TeacherId = teacher.Id, + IsPrimary = true + } + ], + Classes = group.Select(administrativeClass => + new TeachingTaskClass + { + AdministrativeClassId = administrativeClass.Id + }).ToList() + }; + created.Add(task); + } + + db.TeachingTasks.AddRange(created); + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new + { + BatchCode = batchCode, + CreatedCount = created.Count, + Tasks = created.Select(task => new + { + task.Id, + task.TaskNumber, + task.Name, + TeacherName = eligibleTeachers + .First(x => x.Id == task.Teachers.Single().TeacherId).Name, + ClassNames = classes + .Where(x => task.Classes.Any(item => + item.AdministrativeClassId == x.Id)) + .Select(x => x.Name), + task.Capacity + }) + }); + } + private IQueryable ScopedTasks() { var source = db.TeachingTasks.AsQueryable(); @@ -285,6 +446,16 @@ public sealed class TeachingTasksController( x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) return ValidationProblem("存在无效或非在职授课教师。"); + if (teacherIds.Length > 0 && + course.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective && + await db.TeacherCourseApplications.CountAsync( + x => + x.AcademicTermId == request.AcademicTermId && + x.CourseId == request.CourseId && + teacherIds.Contains(x.TeacherId) && + x.Status == TeacherCourseApplicationStatus.Approved, + cancellationToken) != teacherIds.Length) + return ValidationProblem("公共课授课教师必须已完成课程申报并经学院审核通过。"); var classIds = request.ClassIds.Distinct().ToArray(); var classes = db.AdministrativeClasses.AsNoTracking() @@ -344,6 +515,24 @@ public sealed class TeachingTasksController( private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string NextTaskNumber( + string termCode, + string courseCode, + int sequence, + IReadOnlySet existing) + { + var prefix = $"{termCode}-{courseCode}" + .Replace(" ", string.Empty, StringComparison.Ordinal); + if (prefix.Length > 31) prefix = prefix[..31]; + var candidateSequence = sequence; + while (true) + { + var candidate = $"{prefix}-A{candidateSequence:D2}"; + if (!existing.Contains(candidate)) return candidate; + candidateSequence++; + } + } } public sealed record TeachingTaskRequest( @@ -359,3 +548,12 @@ public sealed record TeachingTaskRequest( Guid? PrimaryTeacherId, IReadOnlyCollection ClassIds, [MaxLength(500)] string? Notes); + +public sealed record PublicCourseTaskGenerationRequest( + Guid AcademicTermId, + Guid CourseId, + [Range(1, 10)] int ClassesPerTask, + [Range(1, 30)] int StartWeek, + [Range(1, 30)] int EndWeek, + [Range(1, 40)] int WeeklyHours, + IReadOnlyCollection ClassIds); diff --git a/src/Jiaowu.Api/Domain/Academic/TeachingTaskEntities.cs b/src/Jiaowu.Api/Domain/Academic/TeachingTaskEntities.cs index 5a9a303..707d952 100644 --- a/src/Jiaowu.Api/Domain/Academic/TeachingTaskEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/TeachingTaskEntities.cs @@ -15,12 +15,30 @@ public sealed class TeachingTask : EntityBase public int EndWeek { get; set; } = 16; public int WeeklyHours { get; set; } = 2; public TeachingTaskStatus Status { get; set; } = TeachingTaskStatus.Draft; + public string? GenerationBatchCode { get; set; } public string? Notes { get; set; } public DateTime? PublishedAt { get; set; } public ICollection Teachers { get; set; } = []; public ICollection Classes { get; set; } = []; } +public sealed class TeacherCourseApplication : EntityBase +{ + public Guid AcademicTermId { get; set; } + public AcademicTerm? AcademicTerm { get; set; } + public Guid TeacherId { get; set; } + public Teacher? Teacher { get; set; } + public Guid CourseId { get; set; } + public Course? Course { get; set; } + public TeacherCourseApplicationStatus Status { get; set; } = + TeacherCourseApplicationStatus.Pending; + public string? Statement { get; set; } + public string? ReviewComment { get; set; } + public DateTime SubmittedAt { get; set; } = DateTime.UtcNow; + public DateTime? ReviewedAt { get; set; } + public Guid? ReviewedByUserId { get; set; } +} + public sealed class TeachingTaskTeacher { public Guid TeachingTaskId { get; set; } @@ -44,3 +62,11 @@ public enum TeachingTaskStatus Published = 2, Closed = 3 } + +public enum TeacherCourseApplicationStatus +{ + Pending = 1, + Approved = 2, + Rejected = 3, + Withdrawn = 4 +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 90ba10a..3b0a639 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -27,6 +27,8 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet TeachingTasks => Set(); public DbSet TeachingTaskTeachers => Set(); public DbSet TeachingTaskClasses => Set(); + public DbSet TeacherCourseApplications => + Set(); public DbSet SchedulePlans => Set(); public DbSet ScheduleEntries => Set(); public DbSet ScheduleTimeSlots => Set(); @@ -229,6 +231,7 @@ public sealed class AppDbContext(DbContextOptions options) { entity.Property(x => x.TaskNumber).HasMaxLength(40); entity.Property(x => x.Name).HasMaxLength(120); + entity.Property(x => x.GenerationBatchCode).HasMaxLength(40); entity.Property(x => x.Notes).HasMaxLength(500); entity.HasIndex(x => x.TaskNumber).IsUnique(); entity.HasIndex(x => new { x.AcademicTermId, x.Status }); @@ -268,6 +271,35 @@ public sealed class AppDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.Property(x => x.Statement).HasMaxLength(500); + entity.Property(x => x.ReviewComment).HasMaxLength(500); + entity.HasIndex(x => new + { + x.AcademicTermId, + x.TeacherId, + x.CourseId + }).IsUnique(); + entity.HasIndex(x => new { x.Status, x.AcademicTermId }); + entity.HasOne(x => x.AcademicTerm) + .WithMany() + .HasForeignKey(x => x.AcademicTermId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.Teacher) + .WithMany() + .HasForeignKey(x => x.TeacherId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Course) + .WithMany() + .HasForeignKey(x => x.CourseId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.ReviewedByUserId) + .OnDelete(DeleteBehavior.SetNull); + }); + builder.Entity(entity => { entity.Property(x => x.Name).HasMaxLength(120); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index c528bc9..71a7dc9 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -21,6 +21,8 @@ public sealed class DevelopmentSqliteMigrator( private const string CourseCategoriesMigration = "20260724_13_course_categories"; private const string SchedulingOptimizationMigration = "20260724_14_scheduling_optimization"; + private const string TeacherCourseApplicationsMigration = + "20260724_15_teacher_course_applications"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -121,6 +123,18 @@ public sealed class DevelopmentSqliteMigrator( SchedulingOptimizationMigration, schedulingOptimizationExists ? [] : SchedulingOptimizationStatements, cancellationToken); + var teacherCourseApplicationsExist = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM sqlite_master + WHERE type = 'table' AND name = 'TeacherCourseApplications' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + TeacherCourseApplicationsMigration, + teacherCourseApplicationsExist ? [] : TeacherCourseApplicationStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -953,4 +967,59 @@ public sealed class DevelopmentSqliteMigrator( ON "TeachingTaskAllowedClassrooms" ("ClassroomId"); """ ]; + + private static readonly string[] TeacherCourseApplicationStatements = + [ + """ + ALTER TABLE "TeachingTasks" ADD COLUMN "GenerationBatchCode" TEXT NULL; + """, + """ + CREATE TABLE "TeacherCourseApplications" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_TeacherCourseApplications" PRIMARY KEY, + "AcademicTermId" TEXT NOT NULL, + "TeacherId" TEXT NOT NULL, + "CourseId" TEXT NOT NULL, + "Status" INTEGER NOT NULL, + "Statement" TEXT NULL, + "ReviewComment" TEXT NULL, + "SubmittedAt" TEXT NOT NULL, + "ReviewedAt" TEXT NULL, + "ReviewedByUserId" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_TeacherCourseApplications_AcademicTerms" + FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") + ON DELETE RESTRICT, + CONSTRAINT "FK_TeacherCourseApplications_Teachers" + FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_TeacherCourseApplications_Courses" + FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") + ON DELETE RESTRICT, + CONSTRAINT "FK_TeacherCourseApplications_ReviewedBy" + FOREIGN KEY ("ReviewedByUserId") REFERENCES "AspNetUsers" ("Id") + ON DELETE SET NULL + ); + """, + """ + CREATE UNIQUE INDEX "IX_TeacherCourseApplications_Term_Teacher_Course" + ON "TeacherCourseApplications" ("AcademicTermId", "TeacherId", "CourseId"); + """, + """ + CREATE INDEX "IX_TeacherCourseApplications_Status_AcademicTermId" + ON "TeacherCourseApplications" ("Status", "AcademicTermId"); + """, + """ + CREATE INDEX "IX_TeacherCourseApplications_TeacherId" + ON "TeacherCourseApplications" ("TeacherId"); + """, + """ + CREATE INDEX "IX_TeacherCourseApplications_CourseId" + ON "TeacherCourseApplications" ("CourseId"); + """, + """ + CREATE INDEX "IX_TeacherCourseApplications_ReviewedByUserId" + ON "TeacherCourseApplications" ("ReviewedByUserId"); + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724112805_TeacherCourseApplications.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724112805_TeacherCourseApplications.Designer.cs new file mode 100644 index 0000000..093d6a7 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724112805_TeacherCourseApplications.Designer.cs @@ -0,0 +1,2794 @@ +// +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("20260724112805_TeacherCourseApplications")] + partial class TeacherCourseApplications + { + /// + 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.TeacherCourseApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Statement") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("TeacherId"); + + b.HasIndex("Status", "AcademicTermId"); + + b.HasIndex("AcademicTermId", "TeacherId", "CourseId") + .IsUnique(); + + b.ToTable("TeacherCourseApplications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("GenerationBatchCode") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeeklyHours") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("TaskNumber") + .IsUnique(); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("TeachingTasks"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.Property("TeachingTaskScheduleConstraintId") + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("TeachingTaskAllowedClassrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskId", "AdministrativeClassId"); + + b.HasIndex("AdministrativeClassId"); + + b.ToTable("TeachingTaskClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AllowedDayOfWeeks") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EarliestPeriod") + .HasColumnType("int"); + + b.Property("LatestPeriod") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredCampusId") + .HasColumnType("char(36)"); + + b.Property("RequiresClassroom") + .HasColumnType("tinyint(1)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("RequiredCampusId"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("TeachingTaskScheduleConstraints"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.HasKey("TeachingTaskId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("TeachingTaskTeachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("DataScope") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("StaffNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("StaffNumber"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "CounselorUser") + .WithMany() + .HasForeignKey("CounselorUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CounselorUser"); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "Building") + .WithMany() + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CourseCategory", "CourseCategory") + .WithMany() + .HasForeignKey("CourseCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("College"); + + b.Navigation("CourseCategory"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", "CourseSelectionOffering") + .WithMany("Enrollments") + .HasForeignKey("CourseSelectionOfferingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionOffering"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound") + .WithMany("Offerings") + .HasForeignKey("CourseSelectionRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionRound"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumModule", "CurriculumModule") + .WithMany("Courses") + .HasForeignKey("CurriculumModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("CurriculumModule"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany("Modules") + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", "DegreeAwardBatch") + .WithMany("Results") + .HasForeignKey("DegreeAwardBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditResult", "GraduationAuditResult") + .WithMany() + .HasForeignKey("GraduationAuditResultId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DegreeAwardBatch"); + + b.Navigation("GraduationAuditResult"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan") + .WithMany("Sessions") + .HasForeignKey("ExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("ExamPlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession") + .WithMany("Invigilators") + .HasForeignKey("ExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamSession"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") + .WithMany("Records") + .HasForeignKey("GradeSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GradeSheet"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany() + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", "GraduationAuditBatch") + .WithMany("Results") + .HasForeignKey("GraduationAuditBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + + b.Navigation("GraduationAuditBatch"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", "GraduationClearanceBatch") + .WithMany("Items") + .HasForeignKey("GraduationClearanceBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GraduationClearanceBatch"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", "GraduationClearanceItem") + .WithMany("Records") + .HasForeignKey("GraduationClearanceItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GraduationClearanceItem"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany("Entries") + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SchedulePlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany("Students") + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AdministrativeClass"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", "TeachingTaskScheduleConstraint") + .WithMany("AllowedClassrooms") + .HasForeignKey("TeachingTaskScheduleConstraintId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("TeachingTaskScheduleConstraint"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany() + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Classes") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdministrativeClass"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "RequiredCampus") + .WithMany() + .HasForeignKey("RequiredCampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RequiredBuilding"); + + b.Navigation("RequiredCampus"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Teachers") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Teacher"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Navigation("Offerings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Navigation("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Navigation("Invigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Navigation("Classes"); + + b.Navigation("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Navigation("AllowedClassrooms"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724112805_TeacherCourseApplications.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724112805_TeacherCourseApplications.cs new file mode 100644 index 0000000..09548a7 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724112805_TeacherCourseApplications.cs @@ -0,0 +1,106 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class TeacherCourseApplications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GenerationBatchCode", + table: "TeachingTasks", + type: "varchar(40)", + maxLength: 40, + nullable: true); + + migrationBuilder.CreateTable( + name: "TeacherCourseApplications", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + AcademicTermId = table.Column(type: "char(36)", nullable: false), + TeacherId = table.Column(type: "char(36)", nullable: false), + CourseId = table.Column(type: "char(36)", nullable: false), + Status = table.Column(type: "int", nullable: false), + Statement = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + ReviewComment = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + SubmittedAt = table.Column(type: "datetime(6)", nullable: false), + ReviewedAt = table.Column(type: "datetime(6)", nullable: true), + ReviewedByUserId = table.Column(type: "char(36)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TeacherCourseApplications", x => x.Id); + table.ForeignKey( + name: "FK_TeacherCourseApplications_AcademicTerms_AcademicTermId", + column: x => x.AcademicTermId, + principalTable: "AcademicTerms", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TeacherCourseApplications_AspNetUsers_ReviewedByUserId", + column: x => x.ReviewedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_TeacherCourseApplications_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TeacherCourseApplications_Teachers_TeacherId", + column: x => x.TeacherId, + principalTable: "Teachers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_TeacherCourseApplications_AcademicTermId_TeacherId_CourseId", + table: "TeacherCourseApplications", + columns: new[] { "AcademicTermId", "TeacherId", "CourseId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TeacherCourseApplications_CourseId", + table: "TeacherCourseApplications", + column: "CourseId"); + + migrationBuilder.CreateIndex( + name: "IX_TeacherCourseApplications_ReviewedByUserId", + table: "TeacherCourseApplications", + column: "ReviewedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_TeacherCourseApplications_Status_AcademicTermId", + table: "TeacherCourseApplications", + columns: new[] { "Status", "AcademicTermId" }); + + migrationBuilder.CreateIndex( + name: "IX_TeacherCourseApplications_TeacherId", + table: "TeacherCourseApplications", + column: "TeacherId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TeacherCourseApplications"); + + migrationBuilder.DropColumn( + name: "GenerationBatchCode", + table: "TeachingTasks"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index 9ba4f74..e35fe20 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -1614,6 +1614,63 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("Teachers"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Statement") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("TeacherId"); + + b.HasIndex("Status", "AcademicTermId"); + + b.HasIndex("AcademicTermId", "TeacherId", "CourseId") + .IsUnique(); + + b.ToTable("TeacherCourseApplications"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => { b.Property("Id") @@ -1635,6 +1692,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Property("EndWeek") .HasColumnType("int"); + b.Property("GenerationBatchCode") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + b.Property("Name") .IsRequired() .HasMaxLength(120) @@ -2464,6 +2525,38 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("College"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + + b.Navigation("Teacher"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => { b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") diff --git a/src/Jiaowu.Api/Infrastructure/Teaching/PublicCourseTaskAssignmentPlanner.cs b/src/Jiaowu.Api/Infrastructure/Teaching/PublicCourseTaskAssignmentPlanner.cs new file mode 100644 index 0000000..17299f5 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Teaching/PublicCourseTaskAssignmentPlanner.cs @@ -0,0 +1,33 @@ +namespace Jiaowu.Api.Infrastructure.Teaching; + +public static class PublicCourseTaskAssignmentPlanner +{ + public static IReadOnlyList AssignTeachers( + IReadOnlyCollection eligibleTeacherIds, + IReadOnlyDictionary existingLoads, + int groupCount, + Random? random = null) + { + if (eligibleTeacherIds.Count == 0) + throw new ArgumentException("至少需要一名可分配教师。", nameof(eligibleTeacherIds)); + if (groupCount < 1) + throw new ArgumentOutOfRangeException(nameof(groupCount)); + random ??= Random.Shared; + var teachers = eligibleTeacherIds.Distinct().ToArray(); + var loads = teachers.ToDictionary( + teacherId => teacherId, + teacherId => existingLoads.GetValueOrDefault(teacherId)); + var assignments = new List(groupCount); + for (var index = 0; index < groupCount; index++) + { + var minimumLoad = loads.Values.Min(); + var candidates = teachers + .Where(teacherId => loads[teacherId] == minimumLoad) + .ToArray(); + var selected = candidates[random.Next(candidates.Length)]; + assignments.Add(selected); + loads[selected]++; + } + return assignments; + } +} diff --git a/tests/Jiaowu.Api.Tests/PublicCourseTaskAssignmentPlannerTests.cs b/tests/Jiaowu.Api.Tests/PublicCourseTaskAssignmentPlannerTests.cs new file mode 100644 index 0000000..b4806a6 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/PublicCourseTaskAssignmentPlannerTests.cs @@ -0,0 +1,47 @@ +using Jiaowu.Api.Infrastructure.Teaching; + +namespace Jiaowu.Api.Tests; + +public sealed class PublicCourseTaskAssignmentPlannerTests +{ + [Fact] + public void Assignments_are_randomized_among_least_loaded_eligible_teachers() + { + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + var third = Guid.NewGuid(); + var assignments = PublicCourseTaskAssignmentPlanner.AssignTeachers( + [first, second, third], + new Dictionary + { + [first] = 2, + [second] = 0, + [third] = 0 + }, + 6, + new Random(42)); + + Assert.Equal(6, assignments.Count); + Assert.All(assignments, teacherId => + Assert.Contains(teacherId, new[] { first, second, third })); + var finalLoads = new Dictionary + { + [first] = 2, + [second] = 0, + [third] = 0 + }; + foreach (var teacherId in assignments) finalLoads[teacherId]++; + Assert.True(finalLoads.Values.Max() - finalLoads.Values.Min() <= 1); + } + + [Fact] + public void Assignment_requires_at_least_one_eligible_teacher() + { + Assert.Throws(() => + PublicCourseTaskAssignmentPlanner.AssignTeachers( + [], + new Dictionary(), + 1, + new Random(1))); + } +} diff --git a/web/src/components.d.ts b/web/src/components.d.ts index 009a62b..1dd762a 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -16,6 +16,8 @@ declare module 'vue' { ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDialog: typeof import('element-plus/es')['ElDialog'] ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElEmpty: typeof import('element-plus/es')['ElEmpty'] diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index dde3d42..b23cd86 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -79,6 +79,13 @@ const navigationGroups = computed(() => [ { path: '/base-data/course-categories', label: '课程分类' }, ), { path: '/courses', label: '课程库' }, + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']), + { + path: '/teaching-preferences', + label: isTeacher.value ? '授课科目申报' : '授课资格审核', + }, + ), ...whenVisible(isTeachingAdmin.value, { path: '/curriculum', label: '培养方案' }), ...whenVisible(isTeachingAdmin.value, { path: '/teaching-tasks', label: '教学任务' }), ], diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 5584a36..1e7b756 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -100,6 +100,14 @@ const router = createRouter({ component: () => import('../views/TeachingTasksView.vue'), meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] }, }, + { + path: 'teaching-preferences', + name: 'teaching-preferences', + component: () => import('../views/TeachingPreferencesView.vue'), + meta: { + roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'], + }, + }, { path: 'schedules', name: 'schedules', diff --git a/web/src/style.css b/web/src/style.css index 83b589a..ce19ffd 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -352,6 +352,16 @@ button { cursor: pointer; } .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; } +.eligibility-flow { padding: 14px 18px; display: grid; grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr; align-items: center; gap: 12px; border: 1px solid var(--line); background: #f8fafc; } +.eligibility-flow > div { min-width: 0; display: grid; gap: 4px; } +.eligibility-flow span { color: var(--teal); font: 700 9px/1 Consolas, monospace; } +.eligibility-flow b { color: #2b3854; font-size: 11px; } +.eligibility-flow i { color: #aeb7c6; font-style: normal; } +.generation-notice { margin-bottom: 16px; padding: 12px 14px; color: #526078; border-left: 3px solid var(--teal); background: #f3f9f8; font-size: 10px; line-height: 1.7; } +.generation-preview { padding: 14px 18px; display: flex; align-items: baseline; gap: 8px; color: #718096; background: #f6f8fb; border: 1px solid var(--line); } +.generation-preview b { color: var(--indigo); font: 700 25px/1 Consolas, monospace; } +.generation-preview span, .generation-preview small { font-size: 10px; } +.field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 9px; } .selection-page { min-width: 0; } .selection-round-strip { @@ -969,6 +979,8 @@ button { cursor: pointer; } .settings-lead > div:last-child { flex-wrap: wrap; } .constraint-list article { grid-template-columns: 1fr; } .constraint-badges { justify-content: flex-start; } + .eligibility-flow { grid-template-columns: 1fr; } + .eligibility-flow i { display: none; } .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/TeachingPreferencesView.vue b/web/src/views/TeachingPreferencesView.vue new file mode 100644 index 0000000..8d47e63 --- /dev/null +++ b/web/src/views/TeachingPreferencesView.vue @@ -0,0 +1,234 @@ + + + diff --git a/web/src/views/TeachingTasksView.vue b/web/src/views/TeachingTasksView.vue index c600903..dbb6e2f 100644 --- a/web/src/views/TeachingTasksView.vue +++ b/web/src/views/TeachingTasksView.vue @@ -9,6 +9,8 @@ const loading = ref(false) const rows = ref([]) const total = ref(0) const dialogVisible = ref(false) +const generationDialog = ref(false) +const generating = ref(false) const editingId = ref('') const terms = ref([]) const courses = ref([]) @@ -23,6 +25,9 @@ const query = reactive({ status: undefined as string | undefined, }) const form = reactive>({}) +const generationForm = reactive>({}) +const eligibleTeachers = ref([]) +const manualEligibleTeachers = ref([]) const statusLabels: Record = { Draft: '草稿', Published: '已发布', @@ -35,8 +40,29 @@ const availableClasses = computed(() => { ) return classes.value.filter((item) => ids.has(item.majorId)) }) +const selectedCourse = computed(() => + courses.value.find((item) => item.id === form.courseId), +) +const manualCourseIsPublic = computed(() => + ['GeneralRequired', 'GeneralElective'].includes(selectedCourse.value?.nature), +) +const assignableTeachers = computed(() => { + if (!manualCourseIsPublic.value) return teachers.value + const approvedIds = new Set(manualEligibleTeachers.value.map((item) => item.teacherId)) + return teachers.value.filter((item) => + approvedIds.has(item.id) || form.teacherIds?.includes(item.id), + ) +}) const selectedTeachers = computed(() => - teachers.value.filter((item) => form.teacherIds?.includes(item.id)), + assignableTeachers.value.filter((item) => form.teacherIds?.includes(item.id)), +) +const publicCourses = computed(() => + courses.value.filter((item) => + ['GeneralRequired', 'GeneralElective'].includes(item.nature), + ), +) +const generationGroupCount = computed(() => + Math.ceil((generationForm.classIds?.length ?? 0) / (generationForm.classesPerTask || 1)), ) async function load() { @@ -97,12 +123,28 @@ async function openEdit(row: any) { const detail = (await http.get(`/teaching-tasks/${row.id}`)).data editingId.value = row.id resetForm(detail) + await loadManualEligibleTeachers() dialogVisible.value = true } catch (error) { ElMessage.error(apiErrorMessage(error)) } } +async function loadManualEligibleTeachers() { + manualEligibleTeachers.value = [] + if (!form.academicTermId || !form.courseId || !manualCourseIsPublic.value) return + try { + manualEligibleTeachers.value = (await http.get('/teacher-course-applications/eligible', { + params: { + academicTermId: form.academicTermId, + courseId: form.courseId, + }, + })).data + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } +} + function onTeachersChanged(values: string[]) { if (form.primaryTeacherId && !values.includes(form.primaryTeacherId)) { form.primaryTeacherId = undefined @@ -170,6 +212,65 @@ async function closeTask(row: any) { } } +function openGeneration() { + const currentTerm = terms.value.find((item) => item.isCurrent) + Object.assign(generationForm, { + academicTermId: query.academicTermId ?? currentTerm?.id, + courseId: undefined, + classesPerTask: 3, + startWeek: 1, + endWeek: 16, + weeklyHours: 2, + classIds: [], + }) + eligibleTeachers.value = [] + generationDialog.value = true +} + +async function loadEligibleTeachers() { + eligibleTeachers.value = [] + if (!generationForm.academicTermId || !generationForm.courseId) return + try { + eligibleTeachers.value = (await http.get('/teacher-course-applications/eligible', { + params: { + academicTermId: generationForm.academicTermId, + courseId: generationForm.courseId, + }, + })).data + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } +} + +async function generatePublicTasks() { + if (!generationForm.academicTermId || + !generationForm.courseId || + !generationForm.classIds?.length) { + ElMessage.warning('请选择学期、公共课和需要合班的行政班。') + return + } + if (!eligibleTeachers.value.length) { + ElMessage.warning('该课程还没有审核通过的可授课教师。') + return + } + try { + await ElMessageBox.confirm( + `将 ${generationForm.classIds.length} 个行政班分成 ${generationGroupCount.value} 个教学班,并在 ${eligibleTeachers.value.length} 名合格教师中随机均衡分配。生成结果为草稿,确定继续吗?`, + '批量生成公共课教学任务', + { type: 'warning', confirmButtonText: '生成草稿', cancelButtonText: '取消' }, + ) + generating.value = true + const { data } = await http.post('/teaching-tasks/generate-public-course', generationForm) + generationDialog.value = false + ElMessage.success(`已生成 ${data.createdCount} 个教学任务草稿`) + await load() + } catch (error: any) { + if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) + } finally { + generating.value = false + } +} + onMounted(async () => { const [termRes, courseRes, teacherRes, classRes, majorRes] = await Promise.all([ http.get('/base-data/terms'), @@ -196,7 +297,10 @@ onMounted(async () => {

教学任务

将学期、课程、授课教师和行政班组合为可排课、可选课的教学班。

- 新建教学任务 +
+ 公共课合班生成 + 新建教学任务 +
@@ -252,8 +356,8 @@ onMounted(async () => {
- - + +
@@ -263,8 +367,9 @@ onMounted(async () => { - + + 公共课仅显示本学期已申报且学院审核通过的教师。 @@ -280,5 +385,63 @@ onMounted(async () => { + + +
+ 行政班按编码顺序分组;教师只从学院审核通过的名单中选择,并优先分配给本学期教学任务较少的教师。 +
+ +
+ + + + + + + + + + +
+ + + + + + + + +
+ + + + + + + + + +
+ + + +
+ 预计生成 + {{ generationGroupCount }} + 个教学任务草稿 +
+
+ +