From c3e8a426414b95647fffca799ab0d8c72a4cec80 Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 27 Jul 2026 19:31:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=80=83=E8=AF=95=E6=8E=92?= =?UTF-8?q?=E8=80=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Jiaowu.Api/Controllers/ExamsController.cs | 510 +- .../Domain/Academic/ExamEntities.cs | 49 + .../Exams/ExamArrangementService.cs | 663 ++- .../Exams/ExamSignInWorkbookExporter.cs | 11 +- .../Persistence/AppDbContext.cs | 64 + .../Persistence/DevelopmentSqliteMigrator.cs | 123 + .../20260727105716_ExamRoomMixing.Designer.cs | 5139 +++++++++++++++++ .../MySql/20260727105716_ExamRoomMixing.cs | 196 + .../MySql/AppDbContextModelSnapshot.cs | 220 + .../AutomaticScheduleGeneratorTests.cs | 12 + .../ExamArrangementServiceTests.cs | 145 +- tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs | 6 +- .../TeachingWorkflowRosterTests.cs | 80 +- web/src/views/ExamsView.vue | 67 +- 14 files changed, 7013 insertions(+), 272 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260727105716_ExamRoomMixing.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260727105716_ExamRoomMixing.cs diff --git a/src/Jiaowu.Api/Controllers/ExamsController.cs b/src/Jiaowu.Api/Controllers/ExamsController.cs index 21aaab7..44a000b 100644 --- a/src/Jiaowu.Api/Controllers/ExamsController.cs +++ b/src/Jiaowu.Api/Controllers/ExamsController.cs @@ -88,6 +88,10 @@ public sealed class ExamsController( if (plan.Status != ExamPlanStatus.Draft) return ConflictProblem("只有草稿考试计划可以删除。"); + var rooms = await db.ExamRooms + .Where(x => x.ExamPlanId == id) + .ToListAsync(cancellationToken); + db.ExamRooms.RemoveRange(rooms); db.ExamPlans.Remove(plan); try { @@ -147,6 +151,39 @@ public sealed class ExamsController( }).FirstOrDefaultAsync(cancellationToken); if (plan is null) return NotFound(); + var roomEntities = await db.ExamRooms.AsNoTracking() + .Include(x => x.Classroom) + .ThenInclude(x => x!.Building) + .Include(x => x.SessionLinks) + .Include(x => x.Seats) + .Include(x => x.Invigilators) + .ThenInclude(x => x.Teacher) + .Where(x => x.ExamPlanId == id) + .ToListAsync(cancellationToken); + var roomsBySession = roomEntities + .SelectMany(room => room.SessionLinks.Select(link => new + { + link.ExamSessionId, + Room = new + { + link.ExamRoomId, + room.ClassroomId, + ClassroomName = room.Classroom!.Name, + BuildingName = room.Classroom.Building!.Name, + ClassroomCapacity = room.Classroom.Capacity, + SeatCount = room.Seats.Count(seat => + seat.ExamSessionId == link.ExamSessionId), + TotalSeatCount = room.Seats.Count, + IsMixed = room.SessionLinks.Count > 1, + InvigilatorIds = room.Invigilators + .Select(invigilator => invigilator.TeacherId) + .ToList(), + InvigilatorNames = room.Invigilators + .Select(invigilator => invigilator.Teacher!.Name) + .ToList() + } + })) + .ToLookup(x => x.ExamSessionId, x => x.Room); var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( db, plan.Sessions.Select(x => x.TeachingTaskId), @@ -185,6 +222,7 @@ public sealed class ExamsController( item.Notes, item.InvigilatorIds, item.InvigilatorNames, + ExamRooms = roomsBySession[item.Id], StudentCount = rosterCounts.GetValueOrDefault(item.TeachingTaskId) }) }); @@ -358,6 +396,7 @@ public sealed class ExamsController( session.RequiredInvigilatorCount = request.RequiredInvigilatorCount; session.Notes = Normalize(request.Notes); + await RemoveRoomsForSessionsAsync([id], cancellationToken); db.ExamSessionInvigilators.RemoveRange(session.Invigilators); session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid => new ExamSessionInvigilator { TeacherId = tid }).ToList(); @@ -377,6 +416,7 @@ public sealed class ExamsController( if (session is null) return NotFound(); if (session.ExamPlan!.Status != ExamPlanStatus.Draft) return ConflictProblem("已发布的考试计划不能调整场次。"); + await RemoveRoomsForSessionsAsync([id], cancellationToken); db.ExamSessions.Remove(session); return await SaveAsync(id, false, cancellationToken); } @@ -408,6 +448,7 @@ public sealed class ExamsController( return ConflictProblem( "所选考试场次包含不存在或不属于当前计划的记录,请刷新后重新选择。"); + await RemoveRoomsForSessionsAsync(sessionIds, cancellationToken); db.ExamSessions.RemoveRange(sessions); try { @@ -485,6 +526,19 @@ public sealed class ExamsController( var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value) .ToListAsync(cancellationToken); + var occupiedRoomQuery = db.ExamRooms.AsNoTracking() + .Where(x => x.StartsAt < endsAt && startsAt < x.EndsAt); + if (planId.HasValue) + occupiedRoomQuery = occupiedRoomQuery.Where( + x => x.ExamPlanId == planId.Value); + if (excludeSessionId.HasValue) + occupiedRoomQuery = occupiedRoomQuery.Where( + x => !x.SessionLinks.Any(link => + link.ExamSessionId == excludeSessionId.Value)); + occupiedIds.AddRange(await occupiedRoomQuery + .Select(x => x.ClassroomId) + .ToListAsync(cancellationToken)); + occupiedIds = occupiedIds.Distinct().ToList(); if (occupiedIds.Count > 0) query = query.WhereNotIn(occupiedIds, x => x.Id); @@ -528,6 +582,20 @@ public sealed class ExamsController( var busyIds = await busyQuery.Select(x => x.TeacherId) .ToListAsync(cancellationToken); + var roomBusyQuery = db.ExamRoomInvigilators.AsNoTracking() + .Where(x => x.ExamRoom!.StartsAt < endsAt && + startsAt < x.ExamRoom.EndsAt); + if (planId.HasValue) + roomBusyQuery = roomBusyQuery.Where( + x => x.ExamRoom!.ExamPlanId == planId.Value); + if (excludeSessionId.HasValue) + roomBusyQuery = roomBusyQuery.Where( + x => !x.ExamRoom!.SessionLinks.Any(link => + link.ExamSessionId == excludeSessionId.Value)); + busyIds.AddRange(await roomBusyQuery + .Select(x => x.TeacherId) + .ToListAsync(cancellationToken)); + busyIds = busyIds.Distinct().ToList(); return Ok(await db.Teachers.AsNoTracking() .Where(x => x.Status == TeacherStatus.Active) @@ -570,7 +638,17 @@ public sealed class ExamsController( [Authorize(Roles = Managers)] public async Task Publish(Guid id, CancellationToken cancellationToken) { - var plan = await db.ExamPlans.Include(x => x.Sessions) + var plan = await db.ExamPlans + .Include(x => x.Sessions) + .ThenInclude(x => x.Invigilators) + .Include(x => x.Sessions) + .ThenInclude(x => x.RoomLinks) + .ThenInclude(x => x.ExamRoom) + .ThenInclude(x => x!.Seats) + .Include(x => x.Sessions) + .ThenInclude(x => x.RoomLinks) + .ThenInclude(x => x.ExamRoom) + .ThenInclude(x => x!.Invigilators) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (plan is null) return NotFound(); if (plan.Status != ExamPlanStatus.Draft) @@ -578,11 +656,39 @@ public sealed class ExamsController( if (!plan.Sessions.Any()) return ConflictProblem("至少安排一个考试场次后才能发布。"); + var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( + db, + plan.Sessions.Select(x => x.TeachingTaskId), + cancellationToken)) + .GroupBy(x => x.TeachingTaskId) + .ToDictionary(x => x.Key, x => x.Count()); var unassigned = plan.Sessions.Count(x => - !x.ClassroomId.HasValue || x.Invigilators.Count == 0); + { + if (x.RoomLinks.Count == 0) + return !x.ClassroomId.HasValue || x.Invigilators.Count == 0; + var assignedSeatCount = x.RoomLinks + .SelectMany(link => link.ExamRoom!.Seats) + .Count(seat => seat.ExamSessionId == x.Id); + return assignedSeatCount != + rosterCounts.GetValueOrDefault(x.TeachingTaskId) || + x.RoomLinks.Any(link => + link.ExamRoom!.Invigilators.Count < + link.ExamRoom.RequiredInvigilatorCount); + }); if (unassigned > 0) return ConflictProblem( - $"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。"); + $"还有 {unassigned} 个教学班未完成考场座位或监考安排,请先完成自动编排。"); + + var invalidMixedRoomCount = await db.ExamRooms.AsNoTracking() + .CountAsync(room => + room.ExamPlanId == id && + (room.Seats.Count > room.Classroom!.Capacity || + room.SessionLinks.Any(link => + link.ExamSession!.TeachingTask!.CourseId != room.CourseId)), + cancellationToken); + if (invalidMixedRoomCount > 0) + return ConflictProblem( + $"发现 {invalidMixedRoomCount} 个考场容量超限或混入不同课程,请重新编排。"); plan.Status = ExamPlanStatus.Published; plan.PublishedAt = DateTime.UtcNow; @@ -610,6 +716,51 @@ public sealed class ExamsController( }).FirstOrDefaultAsync(cancellationToken); if (session is null) return NotFound(); + var assignedStudents = await db.ExamSeats.AsNoTracking() + .Where(x => x.ExamSessionId == id) + .OrderBy(x => x.ExamRoom!.Classroom!.Building!.Name) + .ThenBy(x => x.ExamRoom!.Classroom!.Name) + .ThenBy(x => x.SeatNumber) + .Select(x => new + { + x.StudentId, + x.Student!.StudentNumber, + x.Student.Name, + ClassName = x.Student.AdministrativeClass!.Name, + x.ExamRoomId, + ClassroomName = x.ExamRoom!.Classroom!.Name, + BuildingName = x.ExamRoom.Classroom.Building!.Name, + x.SeatNumber + }) + .ToListAsync(cancellationToken); + if (assignedStudents.Count > 0) + { + return Ok(new + { + session.Id, + session.TeachingTaskId, + session.TaskNumber, + session.CourseName, + ClassroomName = string.Join( + "、", + assignedStudents + .Select(x => $"{x.BuildingName} · {x.ClassroomName}") + .Distinct()), + session.StartsAt, + Students = assignedStudents.Select(student => new + { + student.StudentId, + student.StudentNumber, + student.Name, + student.ClassName, + student.ExamRoomId, + student.ClassroomName, + student.BuildingName, + SeatNumber = student.SeatNumber.ToString("D3") + }) + }); + } + var students = await TeachingTaskRosterQuery.LoadForTasksAsync( db, [session.TeachingTaskId], @@ -645,45 +796,92 @@ public sealed class ExamsController( .Select(x => new { x.Name, - TermName = x.AcademicTerm!.Name, - Sessions = x.Sessions - .OrderBy(session => session.ExamDate) - .ThenBy(session => session.StartPeriod) - .Select(session => new - { - session.Id, - session.TeachingTaskId, - session.ExamDate, - session.StartsAt, - session.EndsAt, - CourseCode = session.TeachingTask!.Course!.Code, - CourseName = session.TeachingTask.Course.Name, - session.TeachingTask.TaskNumber, - BuildingName = session.Classroom != null - ? session.Classroom.Building!.Name - : null, - ClassroomName = session.Classroom != null - ? session.Classroom.Name - : null, - InvigilatorNames = session.Invigilators - .OrderBy(item => item.Teacher!.TeacherNumber) - .Select(item => item.Teacher!.Name) - }) + TermName = x.AcademicTerm!.Name }) .FirstOrDefaultAsync(cancellationToken); if (plan is null) return NotFound(); - if (!plan.Sessions.Any()) + + var rooms = await db.ExamRooms.AsNoTracking() + .Include(x => x.Course) + .Include(x => x.Classroom) + .ThenInclude(x => x!.Building) + .Include(x => x.Invigilators) + .ThenInclude(x => x.Teacher) + .Include(x => x.SessionLinks) + .ThenInclude(x => x.ExamSession) + .ThenInclude(x => x!.TeachingTask) + .Include(x => x.Seats) + .ThenInclude(x => x.Student) + .ThenInclude(x => x!.AdministrativeClass) + .Where(x => x.ExamPlanId == id) + .OrderBy(x => x.ExamDate) + .ThenBy(x => x.StartPeriod) + .ThenBy(x => x.Classroom!.Building!.Name) + .ThenBy(x => x.Classroom!.Name) + .ToListAsync(cancellationToken); + var legacySessions = await db.ExamSessions.AsNoTracking() + .Where(x => x.ExamPlanId == id) + .Where(x => !x.RoomLinks.Any()) + .OrderBy(x => x.ExamDate) + .ThenBy(x => x.StartPeriod) + .Select(session => new + { + session.Id, + session.TeachingTaskId, + session.ExamDate, + session.StartsAt, + session.EndsAt, + CourseCode = session.TeachingTask!.Course!.Code, + CourseName = session.TeachingTask.Course.Name, + session.TeachingTask.TaskNumber, + BuildingName = session.Classroom != null + ? session.Classroom.Building!.Name + : null, + ClassroomName = session.Classroom != null + ? session.Classroom.Name + : null, + InvigilatorNames = session.Invigilators + .OrderBy(item => item.Teacher!.TeacherNumber) + .Select(item => item.Teacher!.Name) + }) + .ToListAsync(cancellationToken); + if (rooms.Count == 0 && legacySessions.Count == 0) return ConflictProblem("当前考试计划没有可导出的考试场次。"); var roster = await TeachingTaskRosterQuery.LoadForTasksAsync( db, - plan.Sessions.Select(x => x.TeachingTaskId), + legacySessions.Select(x => x.TeachingTaskId), cancellationToken); var studentsByTask = roster.ToLookup(x => x.TeachingTaskId); - var data = new ExamSignInWorkbookData( - plan.Name, - plan.TermName, - plan.Sessions.Select(session => new ExamSignInSessionData( + var sheets = rooms.Select(room => new ExamSignInSessionData( + room.Id, + room.ExamDate, + room.StartsAt, + room.EndsAt, + room.Course!.Code, + room.Course.Name, + string.Join( + "、", + room.SessionLinks + .Select(link => link.ExamSession!.TeachingTask!.TaskNumber) + .Distinct() + .OrderBy(x => x)), + room.Classroom!.Building!.Name, + room.Classroom.Name, + room.Invigilators + .OrderBy(item => item.Teacher!.TeacherNumber) + .Select(item => item.Teacher!.Name) + .ToList(), + room.Seats + .OrderBy(seat => seat.SeatNumber) + .Select(seat => new ExamSignInStudentData( + seat.StudentId, + seat.Student!.StudentNumber, + seat.Student.Name, + seat.Student.AdministrativeClass!.Name, + seat.SeatNumber)) + .ToList())) + .Concat(legacySessions.Select(session => new ExamSignInSessionData( session.Id, session.ExamDate, session.StartsAt, @@ -695,13 +893,18 @@ public sealed class ExamsController( session.ClassroomName, session.InvigilatorNames.ToList(), studentsByTask[session.TeachingTaskId] - .Select(student => new ExamSignInStudentData( + .Select((student, index) => new ExamSignInStudentData( student.StudentId, student.StudentNumber, student.Name, - student.ClassName)) - .ToList())) - .ToList()); + student.ClassName, + index + 1)) + .ToList()))) + .ToList(); + var data = new ExamSignInWorkbookData( + plan.Name, + plan.TermName, + sheets); var bytes = ExamSignInWorkbookExporter.Create(data); return File( @@ -724,11 +927,37 @@ public sealed class ExamsController( .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); + + var assignedSchedule = await db.ExamSeats.AsNoTracking() + .Where(x => + x.StudentId == studentId.Value && + x.ExamRoom!.ExamPlan!.Status == ExamPlanStatus.Published) + .Select(x => new + { + x.ExamSessionId, + PlanName = x.ExamRoom!.ExamPlan!.Name, + x.ExamRoom.ExamDate, + x.ExamRoom.StartPeriod, + x.ExamRoom.PeriodCount, + x.ExamRoom.StartsAt, + x.ExamRoom.EndsAt, + x.ExamSession!.TeachingTask!.TaskNumber, + CourseCode = x.ExamRoom.Course!.Code, + CourseName = x.ExamRoom.Course.Name, + ClassroomName = x.ExamRoom.Classroom!.Name, + BuildingName = x.ExamRoom.Classroom.Building!.Name, + InvigilatorNames = x.ExamRoom.Invigilators + .Select(i => i.Teacher!.Name), + x.SeatNumber + }) + .ToListAsync(cancellationToken); + var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent( db, studentId.Value); - var schedule = await db.ExamSessions.AsNoTracking() + var legacySchedule = await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && + !x.RoomLinks.Any() && rosterTaskIds.Contains(x.TeachingTaskId)) .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) .Select(x => new @@ -750,9 +979,10 @@ public sealed class ExamsController( IsExam = true }) .ToListAsync(cancellationToken); - if (schedule.Count == 0) return Ok(schedule); - - var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray(); + var taskIds = legacySchedule + .Select(x => x.TeachingTaskId) + .Distinct() + .ToArray(); var seatRows = await TeachingTaskRosterQuery.LoadForTasksAsync( db, taskIds, @@ -767,29 +997,77 @@ public sealed class ExamsController( })) .ToDictionary(x => (x.TeachingTaskId, x.StudentId), x => x.SeatNumber); - return Ok(schedule.Select(x => new - { - x.Id, - x.PlanName, - x.ExamDate, - x.StartPeriod, - x.PeriodCount, - x.StartsAt, - x.EndsAt, - x.TaskNumber, - x.CourseCode, - x.CourseName, - x.ClassroomName, - x.BuildingName, - x.InvigilatorNames, - SeatNumber = seatNumbers.GetValueOrDefault((x.TeachingTaskId, studentId.Value)), - x.IsExam - })); + return Ok(assignedSchedule.Select(x => new ExamScheduleResponse( + x.ExamSessionId, + x.PlanName, + x.ExamDate, + x.StartPeriod, + x.PeriodCount, + x.StartsAt, + x.EndsAt, + x.TaskNumber, + x.CourseCode, + x.CourseName, + x.ClassroomName, + x.BuildingName, + x.InvigilatorNames.ToList(), + x.SeatNumber.ToString("D3"), + null, + true)) + .Concat(legacySchedule.Select(x => new ExamScheduleResponse( + x.Id, + x.PlanName, + x.ExamDate, + x.StartPeriod, + x.PeriodCount, + x.StartsAt, + x.EndsAt, + x.TaskNumber, + x.CourseCode, + x.CourseName, + x.ClassroomName, + x.BuildingName, + x.InvigilatorNames.ToList(), + seatNumbers.GetValueOrDefault( + (x.TeachingTaskId, studentId.Value)), + null, + x.IsExam))) + .OrderBy(x => x.ExamDate) + .ThenBy(x => x.StartPeriod)); } if (scope.IsInRole(SystemRoles.Teacher)) { - var schedule = await db.ExamSessions.AsNoTracking() + var roomSchedule = await db.ExamRooms.AsNoTracking() + .Where(x => + x.ExamPlan!.Status == ExamPlanStatus.Published && + x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId)) + .OrderBy(x => x.ExamDate) + .ThenBy(x => x.StartPeriod) + .Select(x => new + { + x.Id, + PlanName = x.ExamPlan!.Name, + x.ExamDate, + x.StartPeriod, + x.PeriodCount, + x.StartsAt, + x.EndsAt, + TaskNumbers = x.SessionLinks + .Select(link => + link.ExamSession!.TeachingTask!.TaskNumber), + CourseCode = x.Course!.Code, + CourseName = x.Course.Name, + ClassroomName = x.Classroom!.Name, + BuildingName = x.Classroom.Building!.Name, + InvigilatorNames = x.Invigilators + .Select(i => i.Teacher!.Name), + StudentCount = x.Seats.Count + }) + .ToListAsync(cancellationToken); + + var legacySchedule = await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && + !x.RoomLinks.Any() && x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId)) .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) .Select(x => new @@ -813,28 +1091,46 @@ public sealed class ExamsController( .ToListAsync(cancellationToken); var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( db, - schedule.Select(x => x.TeachingTaskId), + legacySchedule.Select(x => x.TeachingTaskId), cancellationToken)) .GroupBy(x => x.TeachingTaskId) .ToDictionary(x => x.Key, x => x.Count()); - return Ok(schedule.Select(x => new - { - x.Id, - x.PlanName, - x.ExamDate, - x.StartPeriod, - x.PeriodCount, - x.StartsAt, - x.EndsAt, - x.TaskNumber, - x.CourseCode, - x.CourseName, - x.ClassroomName, - x.BuildingName, - x.InvigilatorNames, - StudentCount = rosterCounts.GetValueOrDefault(x.TeachingTaskId), - x.IsExam - })); + return Ok(roomSchedule.Select(x => new ExamScheduleResponse( + x.Id, + x.PlanName, + x.ExamDate, + x.StartPeriod, + x.PeriodCount, + x.StartsAt, + x.EndsAt, + string.Join("、", x.TaskNumbers.OrderBy(value => value)), + x.CourseCode, + x.CourseName, + x.ClassroomName, + x.BuildingName, + x.InvigilatorNames.ToList(), + null, + x.StudentCount, + true)) + .Concat(legacySchedule.Select(x => new ExamScheduleResponse( + x.Id, + x.PlanName, + x.ExamDate, + x.StartPeriod, + x.PeriodCount, + x.StartsAt, + x.EndsAt, + x.TaskNumber, + x.CourseCode, + x.CourseName, + x.ClassroomName, + x.BuildingName, + x.InvigilatorNames.ToList(), + null, + rosterCounts.GetValueOrDefault(x.TeachingTaskId), + x.IsExam))) + .OrderBy(x => x.ExamDate) + .ThenBy(x => x.StartPeriod)); } return Ok(Array.Empty()); } @@ -843,6 +1139,24 @@ public sealed class ExamsController( // Private helpers // ═══════════════════════════════════════════ + private async Task RemoveRoomsForSessionsAsync( + IReadOnlyCollection sessionIds, + CancellationToken cancellationToken) + { + var ids = sessionIds.Distinct().ToArray(); + if (ids.Length == 0) return; + var roomIds = await db.ExamRoomSessions.AsNoTracking() + .WhereIn(ids, x => x.ExamSessionId) + .Select(x => x.ExamRoomId) + .Distinct() + .ToListAsync(cancellationToken); + if (roomIds.Count == 0) return; + var rooms = await db.ExamRooms + .WhereIn(roomIds, x => x.Id) + .ToListAsync(cancellationToken); + db.ExamRooms.RemoveRange(rooms); + } + private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime( Guid academicTermId, DateOnly examDate, @@ -926,6 +1240,16 @@ public sealed class ExamsController( if (await overlaps.AnyAsync( x => x.ClassroomId == classroomId.Value, cancellationToken)) return ConflictProblem("该时段考场已被占用。"); + if (await db.ExamRooms.AsNoTracking().AnyAsync( + room => + room.ClassroomId == classroomId.Value && + room.StartsAt < endsAt && + startsAt < room.EndsAt && + (!currentId.HasValue || + !room.SessionLinks.Any(link => + link.ExamSessionId == currentId.Value)), + cancellationToken)) + return ConflictProblem("该时段考场已被混排考试占用。"); } if (teacherIds.Length > 0) @@ -935,6 +1259,16 @@ public sealed class ExamsController( .WhereIn(teacherIds, i => i.TeacherId) .AnyAsync(cancellationToken)) return ConflictProblem("监考教师在该时段已有考试任务。"); + if (await db.ExamRoomInvigilators.AsNoTracking() + .Where(i => + i.ExamRoom!.StartsAt < endsAt && + startsAt < i.ExamRoom.EndsAt && + (!currentId.HasValue || + !i.ExamRoom.SessionLinks.Any(link => + link.ExamSessionId == currentId.Value))) + .WhereIn(teacherIds, i => i.TeacherId) + .AnyAsync(cancellationToken)) + return ConflictProblem("监考教师在该时段已有混排考场任务。"); } var overlappingTaskIds = await overlaps @@ -1034,3 +1368,21 @@ public sealed record ExamAutoArrangeRequest( IReadOnlyCollection? SessionIds = null, bool AssignClassrooms = true, bool AssignInvigilators = true); + +public sealed record ExamScheduleResponse( + Guid Id, + string PlanName, + DateOnly ExamDate, + int StartPeriod, + int PeriodCount, + DateTime StartsAt, + DateTime EndsAt, + string TaskNumber, + string CourseCode, + string CourseName, + string? ClassroomName, + string? BuildingName, + IReadOnlyCollection InvigilatorNames, + string? SeatNumber, + int? StudentCount, + bool IsExam); diff --git a/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs b/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs index 0a8be26..91504d2 100644 --- a/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs @@ -11,6 +11,7 @@ public sealed class ExamPlan : EntityBase public string? Notes { get; set; } public DateTime? PublishedAt { get; set; } public ICollection Sessions { get; set; } = []; + public ICollection Rooms { get; set; } = []; } public sealed class ExamSession : EntityBase @@ -31,6 +32,8 @@ public sealed class ExamSession : EntityBase public int RequiredInvigilatorCount { get; set; } = 2; public string? Notes { get; set; } public ICollection Invigilators { get; set; } = []; + public ICollection RoomLinks { get; set; } = []; + public ICollection SeatAssignments { get; set; } = []; } public sealed class ExamSessionInvigilator @@ -41,6 +44,52 @@ public sealed class ExamSessionInvigilator public Teacher? Teacher { get; set; } } +public sealed class ExamRoomAssignment : EntityBase +{ + public Guid ExamPlanId { get; set; } + public ExamPlan? ExamPlan { get; set; } + public Guid CourseId { get; set; } + public Course? Course { get; set; } + public Guid ClassroomId { get; set; } + public Classroom? Classroom { get; set; } + public DateOnly ExamDate { get; set; } + public int StartPeriod { get; set; } + public int PeriodCount { get; set; } + public DateTime StartsAt { get; set; } + public DateTime EndsAt { get; set; } + public int RequiredInvigilatorCount { get; set; } = 2; + public ICollection SessionLinks { get; set; } = []; + public ICollection Seats { get; set; } = []; + public ICollection Invigilators { get; set; } = []; +} + +public sealed class ExamRoomSession +{ + public Guid ExamRoomId { get; set; } + public ExamRoomAssignment? ExamRoom { get; set; } + public Guid ExamSessionId { get; set; } + public ExamSession? ExamSession { get; set; } +} + +public sealed class ExamSeatAssignment +{ + public Guid ExamRoomId { get; set; } + public ExamRoomAssignment? ExamRoom { get; set; } + public Guid ExamSessionId { get; set; } + public ExamSession? ExamSession { get; set; } + public Guid StudentId { get; set; } + public Student? Student { get; set; } + public int SeatNumber { get; set; } +} + +public sealed class ExamRoomInvigilator +{ + public Guid ExamRoomId { get; set; } + public ExamRoomAssignment? ExamRoom { get; set; } + public Guid TeacherId { get; set; } + public Teacher? Teacher { get; set; } +} + public enum ExamPlanStatus { Draft = 1, diff --git a/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs b/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs index 50eff39..91de6cb 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs @@ -7,8 +7,27 @@ namespace Jiaowu.Api.Infrastructure.Exams; public sealed class ExamArrangementService(AppDbContext db) { - private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt); - private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt); + private sealed record RoomGroupKey( + Guid CourseId, + DateOnly ExamDate, + int StartPeriod, + int PeriodCount, + Guid? RequiredBuildingId); + + private sealed record RoomOccupancy( + Guid ClassroomId, + DateTime StartsAt, + DateTime EndsAt); + + private sealed record InvigilatorOccupancy( + Guid TeacherId, + DateTime StartsAt, + DateTime EndsAt); + + private sealed record CandidateSeat( + Guid ExamSessionId, + Guid StudentId, + string StudentNumber); public async Task ArrangeAsync( Guid planId, @@ -30,6 +49,12 @@ public sealed class ExamArrangementService(AppDbContext db) .Include(x => x.Sessions) .ThenInclude(x => x.TeachingTask) .ThenInclude(x => x!.Course) + .Include(x => x.Rooms) + .ThenInclude(x => x.SessionLinks) + .Include(x => x.Rooms) + .ThenInclude(x => x.Seats) + .Include(x => x.Rooms) + .ThenInclude(x => x.Invigilators) .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken); if (plan is null) @@ -46,206 +71,524 @@ public sealed class ExamArrangementService(AppDbContext db) plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count) return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。"); - var sessions = plan.Sessions - .Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id)) - .OrderBy(x => x.ExamDate) - .ThenBy(x => x.StartPeriod) - .ThenByDescending(x => x.RequiredInvigilatorCount) - .ToList(); - if (sessions.Count == 0) - return ExamArrangementResult.Fail("没有可处理的考试场次。"); - - var termId = plan.AcademicTermId; var timeSlots = await db.ScheduleTimeSlots.AsNoTracking() - .Where(x => x.AcademicTermId == termId && x.IsEnabled) + .Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled) .OrderBy(x => x.PeriodNumber) .ToListAsync(cancellationToken); - if (timeSlots.Count == 0) return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。"); var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber); - var rosterCounts = (await TeachingTaskRosterQuery.LoadForTasksAsync( - db, - sessions.Select(x => x.TeachingTaskId), - cancellationToken)) - .GroupBy(x => x.TeachingTaskId) - .ToDictionary(x => x.Key, x => x.Count()); - - int assignedRooms = 0; - int assignedInvigilators = 0; - int unavailableRooms = 0; - int unavailableInvigilators = 0; - var messages = new List(); - - // Track occupied time slots to avoid conflicts - var occupiedRooms = sessions - .Where(x => x.ClassroomId.HasValue) - .Select(x => new RoomOccupancy(x.ClassroomId!.Value, x.StartsAt, x.EndsAt)) - .ToList(); - - var occupiedInvigilators = sessions - .SelectMany(x => x.Invigilators.Select(i => - new InvigilatorOccupancy(i.TeacherId, x.StartsAt, x.EndsAt))) - .ToList(); - - foreach (var session in sessions) - { - // Compute StartsAt/EndsAt from time slots + foreach (var session in plan.Sessions) ComputeTimesFromSlots(session, timeSlotLookup); - var studentCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId); - // ── Auto-assign classroom ── - if (assignClassrooms && !session.ClassroomId.HasValue) + var explicitlySelected = plan.Sessions + .Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id)) + .ToList(); + if (explicitlySelected.Count == 0) + return ExamArrangementResult.Fail("没有可处理的考试场次。"); + + var selectedKeys = explicitlySelected + .Select(GroupKey) + .ToHashSet(); + var sessions = plan.Sessions + .Where(x => selectedKeys.Contains(GroupKey(x))) + .OrderBy(x => x.ExamDate) + .ThenBy(x => x.StartPeriod) + .ThenBy(x => x.TeachingTask!.Course!.Code) + .ThenBy(x => x.TeachingTask!.TaskNumber) + .ToList(); + var sessionIds = sessions.Select(x => x.Id).ToHashSet(); + + var roster = await TeachingTaskRosterQuery.LoadForTasksAsync( + db, + sessions.Select(x => x.TeachingTaskId), + cancellationToken); + var sessionByTaskId = sessions + .GroupBy(x => x.TeachingTaskId) + .ToDictionary(x => x.Key, x => x.First()); + + var replacedRooms = assignClassrooms + ? plan.Rooms + .Where(room => room.SessionLinks.Any(link => + sessionIds.Contains(link.ExamSessionId))) + .ToList() + : []; + var replacedRoomIds = replacedRooms.Select(x => x.Id).ToHashSet(); + if (replacedRooms.Count > 0) + db.ExamRooms.RemoveRange(replacedRooms); + + var occupiedRooms = await LoadOccupiedRoomsAsync( + plan, + sessionIds, + replacedRoomIds, + cancellationToken); + var rooms = await db.Classrooms.AsNoTracking() + .Include(x => x.Building) + .Where(x => x.IsEnabled) + .OrderBy(x => x.Building!.Name) + .ThenBy(x => x.Name) + .ToListAsync(cancellationToken); + + var targetRooms = new List(); + var messages = new List(); + var seatedStudents = 0; + var unavailableStudents = 0; + + if (assignClassrooms) + { + foreach (var session in sessions) { - var room = await FindBestClassroomAsync( - session, studentCount, occupiedRooms, cancellationToken); - if (room is not null) + session.ClassroomId = null; + if (session.Invigilators.Count > 0) { - session.ClassroomId = room.Id; - occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt)); - assignedRooms++; - messages.Add( - $"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)"); - } - else - { - unavailableRooms++; - messages.Add( - $"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)"); + db.ExamSessionInvigilators.RemoveRange(session.Invigilators); + session.Invigilators.Clear(); } } - // ── Auto-assign invigilators ── - var currentInvigilatorCount = session.Invigilators.Count; - var needed = session.RequiredInvigilatorCount - currentInvigilatorCount; - if (assignInvigilators && needed > 0) + + foreach (var group in sessions + .GroupBy(GroupKey) + .OrderBy(x => x.Key.ExamDate) + .ThenBy(x => x.Key.StartPeriod) + .ThenBy(x => x.First().TeachingTask!.Course!.Code)) { - var courseTeacherIds = session.TeachingTask!.Teachers - .Select(x => x.TeacherId).ToHashSet(); - var newlyAssigned = await FindInvigilatorsAsync( - session, needed, courseTeacherIds, - occupiedInvigilators, cancellationToken); - foreach (var teacher in newlyAssigned) + var groupSessions = group.ToList(); + var candidates = InterleaveCandidates( + groupSessions, + roster, + sessionByTaskId); + if (candidates.Count == 0) { - session.Invigilators.Add(new ExamSessionInvigilator + messages.Add( + $"“{groupSessions[0].TeachingTask!.Course!.Name}”没有有效考生。"); + continue; + } + + var availableRooms = rooms + .Where(room => + (!group.Key.RequiredBuildingId.HasValue || + room.BuildingId == group.Key.RequiredBuildingId.Value) && + occupiedRooms.All(occupied => + occupied.ClassroomId != room.Id || + !ExamConflictRules.TimeOverlaps( + occupied.StartsAt, + occupied.EndsAt, + groupSessions[0].StartsAt, + groupSessions[0].EndsAt))) + .ToList(); + var selectedRooms = SelectRooms( + availableRooms, + candidates.Count); + if (selectedRooms.Sum(x => x.Capacity) < candidates.Count) + { + unavailableStudents += candidates.Count; + messages.Add( + $"“{groupSessions[0].TeachingTask!.Course!.Name}”缺少足够考场容量," + + $"需 {candidates.Count} 座、可用 {selectedRooms.Sum(x => x.Capacity)} 座。"); + continue; + } + + var offset = 0; + foreach (var classroom in selectedRooms) + { + var roomCandidates = candidates + .Skip(offset) + .Take(classroom.Capacity) + .ToList(); + if (roomCandidates.Count == 0) break; + offset += roomCandidates.Count; + + var room = new ExamRoomAssignment + { + ExamPlanId = plan.Id, + CourseId = group.Key.CourseId, + ClassroomId = classroom.Id, + ExamDate = group.Key.ExamDate, + StartPeriod = group.Key.StartPeriod, + PeriodCount = group.Key.PeriodCount, + StartsAt = groupSessions[0].StartsAt, + EndsAt = groupSessions[0].EndsAt, + RequiredInvigilatorCount = + groupSessions.Max(x => x.RequiredInvigilatorCount), + SessionLinks = roomCandidates + .Select(x => x.ExamSessionId) + .Distinct() + .Select(sessionId => new ExamRoomSession + { + ExamSessionId = sessionId + }) + .ToList(), + Seats = roomCandidates + .Select((candidate, index) => + new ExamSeatAssignment + { + ExamSessionId = candidate.ExamSessionId, + StudentId = candidate.StudentId, + SeatNumber = index + 1 + }) + .ToList() + }; + db.ExamRooms.Add(room); + targetRooms.Add(room); + occupiedRooms.Add(new RoomOccupancy( + classroom.Id, + room.StartsAt, + room.EndsAt)); + seatedStudents += roomCandidates.Count; + } + + messages.Add( + $"“{groupSessions[0].TeachingTask!.Course!.Name}”" + + $"{groupSessions.Count}个教学班混排至{selectedRooms.Count}个考场。"); + } + } + else + { + targetRooms.AddRange(plan.Rooms.Where(room => + room.SessionLinks.Any(link => + sessionIds.Contains(link.ExamSessionId)))); + var linkedSessionIds = targetRooms + .SelectMany(x => x.SessionLinks) + .Select(x => x.ExamSessionId) + .ToHashSet(); + foreach (var session in sessions.Where(x => + !linkedSessionIds.Contains(x.Id) && + x.ClassroomId.HasValue)) + { + var legacyRoom = CreateLegacyRoom( + plan, + session, + roster.Where(x => + x.TeachingTaskId == session.TeachingTaskId)); + db.ExamRooms.Add(legacyRoom); + targetRooms.Add(legacyRoom); + session.ClassroomId = null; + if (session.Invigilators.Count > 0) + { + db.ExamSessionInvigilators.RemoveRange(session.Invigilators); + session.Invigilators.Clear(); + } + } + seatedStudents = targetRooms.Sum(x => x.Seats.Count); + } + + var assignedInvigilators = 0; + var unavailableInvigilators = 0; + if (assignInvigilators) + { + if (targetRooms.Count == 0) + return ExamArrangementResult.Fail("尚未生成实际考场,请先分配考场。"); + + var occupiedInvigilators = plan.Rooms + .Where(x => !replacedRoomIds.Contains(x.Id) && + !targetRooms.Any(target => target.Id == x.Id)) + .SelectMany(room => room.Invigilators.Select(item => + new InvigilatorOccupancy( + item.TeacherId, + room.StartsAt, + room.EndsAt))) + .Concat(plan.Sessions + .Where(x => !sessionIds.Contains(x.Id)) + .SelectMany(session => session.Invigilators.Select(item => + new InvigilatorOccupancy( + item.TeacherId, + session.StartsAt, + session.EndsAt)))) + .ToList(); + + foreach (var room in targetRooms + .OrderBy(x => x.ExamDate) + .ThenBy(x => x.StartPeriod) + .ThenBy(x => x.ClassroomId)) + { + var needed = room.RequiredInvigilatorCount - + room.Invigilators.Count; + if (needed <= 0) continue; + + var linkedSessionIds = room.SessionLinks + .Select(x => x.ExamSessionId) + .ToHashSet(); + var excludedTeacherIds = sessions + .Where(x => linkedSessionIds.Contains(x.Id)) + .SelectMany(x => x.TeachingTask!.Teachers) + .Select(x => x.TeacherId) + .ToHashSet(); + var teachers = await FindInvigilatorsAsync( + room, + needed, + excludedTeacherIds, + occupiedInvigilators, + cancellationToken); + foreach (var teacher in teachers) + { + room.Invigilators.Add(new ExamRoomInvigilator { - ExamSessionId = session.Id, TeacherId = teacher.Id }); occupiedInvigilators.Add(new InvigilatorOccupancy( - teacher.Id, session.StartsAt, session.EndsAt)); + teacher.Id, + room.StartsAt, + room.EndsAt)); assignedInvigilators++; } - if (newlyAssigned.Count < needed) - { - unavailableInvigilators += needed - newlyAssigned.Count; - messages.Add( - $"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师"); - } + if (teachers.Count < needed) + unavailableInvigilators += needed - teachers.Count; } } await db.SaveChangesAsync(cancellationToken); + var expandedCount = sessions.Count - explicitlySelected.Count; + var detail = messages.Count > 0 + ? $" 详情:{string.Join(";", messages.Take(10))}" + : ""; return new ExamArrangementResult( true, - $"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" + - (unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") + - (unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") + - (messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : "")); + $"{sessions.Count}个教学班场次处理完成,生成{targetRooms.Count}个实际考场," + + $"安排{seatedStudents}名考生、{assignedInvigilators}名监考教师。" + + (expandedCount > 0 + ? $" 为保持混排完整性,自动包含同组{expandedCount}个场次。" + : "") + + (unavailableStudents > 0 + ? $" {unavailableStudents}名考生尚未安排考场。" + : "") + + (unavailableInvigilators > 0 + ? $" 仍缺{unavailableInvigilators}名监考教师。" + : "") + + detail); } + private async Task> LoadOccupiedRoomsAsync( + ExamPlan plan, + IReadOnlySet targetSessionIds, + IReadOnlySet replacedRoomIds, + CancellationToken cancellationToken) + { + var occupied = plan.Rooms + .Where(x => !replacedRoomIds.Contains(x.Id)) + .Select(x => new RoomOccupancy( + x.ClassroomId, + x.StartsAt, + x.EndsAt)) + .ToList(); + occupied.AddRange(plan.Sessions + .Where(x => + !targetSessionIds.Contains(x.Id) && + x.ClassroomId.HasValue) + .Select(x => new RoomOccupancy( + x.ClassroomId!.Value, + x.StartsAt, + x.EndsAt))); + + var externalRooms = await db.ExamRooms.AsNoTracking() + .Where(x => + x.ExamPlanId != plan.Id && + x.ExamPlan!.Status != ExamPlanStatus.Archived) + .Select(x => new RoomOccupancy( + x.ClassroomId, + x.StartsAt, + x.EndsAt)) + .ToListAsync(cancellationToken); + occupied.AddRange(externalRooms); + + var externalLegacyRooms = await db.ExamSessions.AsNoTracking() + .Where(x => + x.ExamPlanId != plan.Id && + x.ExamPlan!.Status != ExamPlanStatus.Archived && + x.ClassroomId != null) + .Select(x => new RoomOccupancy( + x.ClassroomId!.Value, + x.StartsAt, + x.EndsAt)) + .ToListAsync(cancellationToken); + occupied.AddRange(externalLegacyRooms); + return occupied; + } + + private async Task> FindInvigilatorsAsync( + ExamRoomAssignment room, + int needed, + HashSet excludedTeacherIds, + List occupied, + CancellationToken cancellationToken) + { + var busyTeacherIds = occupied + .Where(x => ExamConflictRules.TimeOverlaps( + x.StartsAt, + x.EndsAt, + room.StartsAt, + room.EndsAt)) + .Select(x => x.TeacherId) + .ToHashSet(); + + var databaseBusyIds = await db.ExamRoomInvigilators.AsNoTracking() + .Where(x => + x.ExamRoomId != room.Id && + x.ExamRoom!.ExamPlan!.Status != ExamPlanStatus.Archived && + x.ExamRoom.StartsAt < room.EndsAt && + room.StartsAt < x.ExamRoom.EndsAt) + .Select(x => x.TeacherId) + .ToListAsync(cancellationToken); + foreach (var teacherId in databaseBusyIds) + busyTeacherIds.Add(teacherId); + + var legacyBusyIds = await db.ExamSessionInvigilators.AsNoTracking() + .Where(x => + x.ExamSession!.ExamPlan!.Status != ExamPlanStatus.Archived && + x.ExamSession.StartsAt < room.EndsAt && + room.StartsAt < x.ExamSession.EndsAt) + .Select(x => x.TeacherId) + .ToListAsync(cancellationToken); + foreach (var teacherId in legacyBusyIds) + busyTeacherIds.Add(teacherId); + foreach (var teacherId in excludedTeacherIds) + busyTeacherIds.Add(teacherId); + + var candidates = await InvigilatorCandidateQuery + .Create(db, busyTeacherIds) + .ToListAsync(cancellationToken); + return candidates + .OrderBy(_ => Random.Shared.Next()) + .Take(needed) + .ToList(); + } + + private static List SelectRooms( + IReadOnlyCollection availableRooms, + int candidateCount) + { + var remainingRooms = availableRooms.ToList(); + var selected = new List(); + var remainingSeats = candidateCount; + while (remainingSeats > 0 && remainingRooms.Count > 0) + { + var room = remainingRooms + .Where(x => x.Capacity >= remainingSeats) + .OrderBy(x => x.Capacity) + .ThenBy(x => x.Name) + .FirstOrDefault() + ?? remainingRooms + .OrderByDescending(x => x.Capacity) + .ThenBy(x => x.Name) + .First(); + selected.Add(room); + remainingRooms.Remove(room); + remainingSeats -= room.Capacity; + } + return selected; + } + + private static List InterleaveCandidates( + IReadOnlyCollection sessions, + IReadOnlyCollection roster, + IReadOnlyDictionary sessionByTaskId) + { + var sessionIds = sessions.Select(x => x.Id).ToHashSet(); + var queues = roster + .Where(x => + sessionByTaskId.TryGetValue( + x.TeachingTaskId, + out var session) && + sessionIds.Contains(session.Id)) + .GroupBy(x => x.TeachingTaskId) + .OrderBy(x => sessionByTaskId[x.Key].TeachingTask!.TaskNumber) + .Select(group => new Queue( + group.OrderBy(x => x.StudentNumber) + .Select(x => new CandidateSeat( + sessionByTaskId[x.TeachingTaskId].Id, + x.StudentId, + x.StudentNumber)))) + .ToList(); + + var result = new List(); + var assignedStudentIds = new HashSet(); + while (queues.Any(x => x.Count > 0)) + { + foreach (var queue in queues) + { + while (queue.Count > 0) + { + var candidate = queue.Dequeue(); + if (!assignedStudentIds.Add(candidate.StudentId)) + continue; + result.Add(candidate); + break; + } + } + } + return result; + } + + private static ExamRoomAssignment CreateLegacyRoom( + ExamPlan plan, + ExamSession session, + IEnumerable roster) + { + var students = roster + .OrderBy(x => x.StudentNumber) + .ToList(); + return new ExamRoomAssignment + { + ExamPlanId = plan.Id, + CourseId = session.TeachingTask!.CourseId, + ClassroomId = session.ClassroomId!.Value, + ExamDate = session.ExamDate, + StartPeriod = session.StartPeriod, + PeriodCount = session.PeriodCount, + StartsAt = session.StartsAt, + EndsAt = session.EndsAt, + RequiredInvigilatorCount = session.RequiredInvigilatorCount, + SessionLinks = + [ + new ExamRoomSession + { + ExamSessionId = session.Id + } + ], + Seats = students.Select((student, index) => + new ExamSeatAssignment + { + ExamSessionId = session.Id, + StudentId = student.StudentId, + SeatNumber = index + 1 + }).ToList(), + Invigilators = session.Invigilators.Select(x => + new ExamRoomInvigilator + { + TeacherId = x.TeacherId + }).ToList() + }; + } + + private static RoomGroupKey GroupKey(ExamSession session) => new( + session.TeachingTask!.CourseId, + session.ExamDate, + session.StartPeriod, + session.PeriodCount, + session.RequiredBuildingId); + private static void ComputeTimesFromSlots( ExamSession session, - Dictionary timeSlotLookup) + IReadOnlyDictionary timeSlotLookup) { var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod); var endSlot = timeSlotLookup.GetValueOrDefault( session.StartPeriod + session.PeriodCount - 1); if (startSlot is null || endSlot is null) return; - var examDate = session.ExamDate; - session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc); - session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc); - } - - private async Task FindBestClassroomAsync( - ExamSession session, - int studentCount, - List occupied, - CancellationToken cancellationToken) - { - var query = db.Classrooms.AsNoTracking() - .Where(x => x.IsEnabled && x.Capacity >= studentCount); - - if (session.RequiredBuildingId.HasValue) - query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value); - - // Exclude classrooms already occupied in-memory - var occupiedRoomIds = occupied - .Where(x => ExamConflictRules.TimeOverlaps( - x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt)) - .Select(x => x.ClassroomId) - .ToHashSet(); - - if (occupiedRoomIds.Count > 0) - query = query.WhereNotIn(occupiedRoomIds, x => x.Id); - - // Exclude classrooms occupied by DB sessions not yet tracked in memory - var dbOccupiedRooms = await db.ExamSessions.AsNoTracking() - .Where(x => x.ExamPlanId == session.ExamPlanId && - x.Id != session.Id && - x.ClassroomId != null && - x.StartsAt < session.EndsAt && - session.StartsAt < x.EndsAt) - .Select(x => x.ClassroomId!.Value) - .ToListAsync(cancellationToken); - - if (dbOccupiedRooms.Count > 0) - query = query.WhereNotIn(dbOccupiedRooms, x => x.Id); - - return await query - .OrderBy(x => x.Capacity) - .FirstOrDefaultAsync(cancellationToken); - } - - private async Task> FindInvigilatorsAsync( - ExamSession session, - int needed, - HashSet excludeTeacherIds, - List occupied, - CancellationToken cancellationToken) - { - var busyTeacherIds = occupied - .Where(x => ExamConflictRules.TimeOverlaps( - x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt)) - .Select(x => x.TeacherId) - .ToHashSet(); - - var dbBusyIds = await db.ExamSessionInvigilators.AsNoTracking() - .Where(x => x.ExamSession!.ExamPlanId == session.ExamPlanId && - x.ExamSessionId != session.Id && - x.ExamSession!.StartsAt < session.EndsAt && - session.StartsAt < x.ExamSession.EndsAt) - .Select(x => x.TeacherId) - .ToListAsync(cancellationToken); - - foreach (var id in dbBusyIds) busyTeacherIds.Add(id); - foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id); - - var candidates = await InvigilatorCandidateQuery - .Create(db, busyTeacherIds) - .ToListAsync(cancellationToken); - - return candidates - .OrderBy(_ => Random.Shared.Next()) - .Take(needed) - .ToList(); + session.StartsAt = session.ExamDate.ToDateTime( + startSlot.StartsAt, + DateTimeKind.Utc); + session.EndsAt = session.ExamDate.ToDateTime( + endSlot.EndsAt, + DateTimeKind.Utc); } } public sealed record ExamArrangementResult(bool Success, string Message) { - public static ExamArrangementResult Fail(string message) => new(false, message); + public static ExamArrangementResult Fail(string message) => + new(false, message); } diff --git a/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs b/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs index 14846b2..c387720 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/ExamSignInWorkbookExporter.cs @@ -163,11 +163,15 @@ public static class ExamSignInWorkbookExporter sheet.Row(7).Height = 27; var row = 8; - foreach (var student in session.Students.OrderBy(x => x.StudentNumber)) + foreach (var student in session.Students + .OrderBy(x => x.SeatNumber) + .ThenBy(x => x.StudentNumber)) { var index = row - 7; sheet.Cell(row, 1).Value = index; - sheet.Cell(row, 2).Value = index.ToString("D3"); + sheet.Cell(row, 2).Value = + (student.SeatNumber > 0 ? student.SeatNumber : index) + .ToString("D3"); sheet.Cell(row, 3).Value = student.StudentNumber; sheet.Cell(row, 4).Value = student.Name; sheet.Cell(row, 5).Value = student.ClassName; @@ -297,4 +301,5 @@ public sealed record ExamSignInStudentData( Guid StudentId, string StudentNumber, string Name, - string ClassName); + string ClassName, + int SeatNumber = 0); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index f0db228..a930bf3 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -61,6 +61,11 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet ExamSessions => Set(); public DbSet ExamSessionInvigilators => Set(); + public DbSet ExamRooms => Set(); + public DbSet ExamRoomSessions => Set(); + public DbSet ExamSeats => Set(); + public DbSet ExamRoomInvigilators => + Set(); public DbSet MakeupExamPlans => Set(); public DbSet MakeupExamSessions => Set(); public DbSet MakeupExamSessionInvigilators => @@ -719,6 +724,65 @@ public sealed class AppDbContext(DbContextOptions options) entity.HasOne(x => x.Teacher).WithMany() .HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.ToTable("ExamRooms"); + entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt }) + .HasDatabaseName("IX_ExamRooms_Plan_Time"); + entity.HasIndex(x => new + { + x.ExamPlanId, + x.ClassroomId, + x.StartsAt + }) + .HasDatabaseName("IX_ExamRooms_Plan_Room_Time"); + entity.HasIndex(x => x.CourseId) + .HasDatabaseName("IX_ExamRooms_CourseId"); + entity.HasOne(x => x.ExamPlan).WithMany(x => x.Rooms) + .HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Course).WithMany() + .HasForeignKey(x => x.CourseId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.Classroom).WithMany() + .HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict); + }); + builder.Entity(entity => + { + entity.ToTable("ExamRoomSessions"); + entity.HasKey(x => new { x.ExamRoomId, x.ExamSessionId }); + entity.HasIndex(x => x.ExamSessionId) + .HasDatabaseName("IX_ExamRoomSessions_SessionId"); + entity.HasOne(x => x.ExamRoom).WithMany(x => x.SessionLinks) + .HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.ExamSession).WithMany(x => x.RoomLinks) + .HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict); + }); + builder.Entity(entity => + { + entity.ToTable("ExamSeats"); + entity.HasKey(x => new { x.ExamRoomId, x.StudentId }); + entity.HasIndex(x => new { x.ExamSessionId, x.StudentId }) + .IsUnique() + .HasDatabaseName("UX_ExamSeats_Session_Student"); + entity.HasIndex(x => x.StudentId) + .HasDatabaseName("IX_ExamSeats_StudentId"); + entity.HasOne(x => x.ExamRoom).WithMany(x => x.Seats) + .HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.ExamSession).WithMany(x => x.SeatAssignments) + .HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.Student).WithMany() + .HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict); + }); + builder.Entity(entity => + { + entity.ToTable("ExamRoomInvigilators"); + entity.HasKey(x => new { x.ExamRoomId, x.TeacherId }); + entity.HasIndex(x => x.TeacherId) + .HasDatabaseName("IX_ExamRoomInvigilators_TeacherId"); + entity.HasOne(x => x.ExamRoom).WithMany(x => x.Invigilators) + .HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Teacher).WithMany() + .HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict); + }); 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 83bcb0e..e9d2ac1 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -64,6 +64,8 @@ public sealed class DevelopmentSqliteMigrator( "20260727_34_course_adjustment_occurrences"; private const string AcademicPlanningPrerequisitesMigration = "20260727_35_academic_planning_prerequisites"; + private const string ExamRoomMixingMigration = + "20260727_36_exam_room_mixing"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -468,6 +470,19 @@ public sealed class DevelopmentSqliteMigrator( AcademicPlanningPrerequisitesMigration, coursePrerequisitesExist ? [] : AcademicPlanningPrerequisiteStatements, cancellationToken); + + var examRoomsExist = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM sqlite_master + WHERE type = 'table' AND name = 'ExamRooms' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + ExamRoomMixingMigration, + examRoomsExist ? [] : ExamRoomMixingStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -2132,4 +2147,112 @@ public sealed class DevelopmentSqliteMigrator( ON "CoursePrerequisites" ("PrerequisiteCourseId"); """ ]; + + private static readonly string[] ExamRoomMixingStatements = + [ + """ + CREATE TABLE "ExamRooms" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ExamRooms" PRIMARY KEY, + "ExamPlanId" TEXT NOT NULL, + "CourseId" TEXT NOT NULL, + "ClassroomId" TEXT NOT NULL, + "ExamDate" TEXT NOT NULL, + "StartPeriod" INTEGER NOT NULL, + "PeriodCount" INTEGER NOT NULL, + "StartsAt" TEXT NOT NULL, + "EndsAt" TEXT NOT NULL, + "RequiredInvigilatorCount" INTEGER NOT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_ExamRooms_ExamPlans_ExamPlanId" + FOREIGN KEY ("ExamPlanId") REFERENCES "ExamPlans" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_ExamRooms_Courses_CourseId" + FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") + ON DELETE RESTRICT, + CONSTRAINT "FK_ExamRooms_Classrooms_ClassroomId" + FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") + ON DELETE RESTRICT + ); + """, + """ + CREATE INDEX "IX_ExamRooms_Plan_Time" + ON "ExamRooms" ("ExamPlanId", "StartsAt"); + """, + """ + CREATE INDEX "IX_ExamRooms_Plan_Room_Time" + ON "ExamRooms" ("ExamPlanId", "ClassroomId", "StartsAt"); + """, + """ + CREATE INDEX "IX_ExamRooms_CourseId" + ON "ExamRooms" ("CourseId"); + """, + """ + CREATE INDEX "IX_ExamRooms_ClassroomId" + ON "ExamRooms" ("ClassroomId"); + """, + """ + CREATE TABLE "ExamRoomSessions" ( + "ExamRoomId" TEXT NOT NULL, + "ExamSessionId" TEXT NOT NULL, + CONSTRAINT "PK_ExamRoomSessions" + PRIMARY KEY ("ExamRoomId", "ExamSessionId"), + CONSTRAINT "FK_ExamRoomSessions_ExamRooms_ExamRoomId" + FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_ExamRoomSessions_ExamSessions_ExamSessionId" + FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id") + ON DELETE RESTRICT + ); + """, + """ + CREATE INDEX "IX_ExamRoomSessions_SessionId" + ON "ExamRoomSessions" ("ExamSessionId"); + """, + """ + CREATE TABLE "ExamSeats" ( + "ExamRoomId" TEXT NOT NULL, + "ExamSessionId" TEXT NOT NULL, + "StudentId" TEXT NOT NULL, + "SeatNumber" INTEGER NOT NULL, + CONSTRAINT "PK_ExamSeats" + PRIMARY KEY ("ExamRoomId", "StudentId"), + CONSTRAINT "FK_ExamSeats_ExamRooms_ExamRoomId" + FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_ExamSeats_ExamSessions_ExamSessionId" + FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id") + ON DELETE RESTRICT, + CONSTRAINT "FK_ExamSeats_Students_StudentId" + FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") + ON DELETE RESTRICT + ); + """, + """ + CREATE UNIQUE INDEX "UX_ExamSeats_Session_Student" + ON "ExamSeats" ("ExamSessionId", "StudentId"); + """, + """ + CREATE INDEX "IX_ExamSeats_StudentId" + ON "ExamSeats" ("StudentId"); + """, + """ + CREATE TABLE "ExamRoomInvigilators" ( + "ExamRoomId" TEXT NOT NULL, + "TeacherId" TEXT NOT NULL, + CONSTRAINT "PK_ExamRoomInvigilators" + PRIMARY KEY ("ExamRoomId", "TeacherId"), + CONSTRAINT "FK_ExamRoomInvigilators_ExamRooms_ExamRoomId" + FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id") + ON DELETE CASCADE, + CONSTRAINT "FK_ExamRoomInvigilators_Teachers_TeacherId" + FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") + ON DELETE RESTRICT + ); + """, + """ + CREATE INDEX "IX_ExamRoomInvigilators_TeacherId" + ON "ExamRoomInvigilators" ("TeacherId"); + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260727105716_ExamRoomMixing.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260727105716_ExamRoomMixing.Designer.cs new file mode 100644 index 0000000..d320950 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260727105716_ExamRoomMixing.Designer.cs @@ -0,0 +1,5139 @@ +// +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("20260727105716_ExamRoomMixing")] + partial class ExamRoomMixing + { + /// + 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("ArchivedAt") + .HasColumnType("datetime(6)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsArchived") + .HasColumnType("tinyint(1)"); + + 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("IsArchived"); + + 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.AttendanceRecord", b => + { + b.Property("AttendanceSheetId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("AppealReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("AppealReviewComment") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("AppealReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("AppealStatus") + .HasColumnType("int"); + + b.Property("AppealSubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInAccuracyMeters") + .HasColumnType("double"); + + b.Property("CheckInAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInDistanceMeters") + .HasColumnType("double"); + + b.Property("CheckInLatitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("CheckInLongitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("CheckedInMethod") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("AttendanceSheetId", "StudentId"); + + b.HasIndex("AppealStatus"); + + b.HasIndex("StudentId"); + + b.ToTable("AttendanceRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AttendanceDate") + .HasColumnType("datetime(6)"); + + b.Property("CheckInEndsAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInMethod") + .HasColumnType("int"); + + b.Property("CheckInStartsAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInToken") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LocationRadiusMeters") + .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("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetLatitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("TargetLongitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CheckInToken") + .IsUnique(); + + b.HasIndex("TeachingTaskId", "AttendanceDate"); + + b.ToTable("AttendanceSheets"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ActiveSchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedTasks") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedEntries") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MessagesJson") + .HasColumnType("longtext"); + + b.Property("ProcessedTasks") + .HasColumnType("int"); + + b.Property("RequestedByUserId") + .HasColumnType("char(36)"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalTasks") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveSchedulePlanId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("AutomaticScheduleJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Buildings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Campuses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BuildingId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Equipment") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RoomType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("ApplicantCollegeId") + .HasColumnType("char(36)"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ApplicantUserId") + .HasColumnType("char(36)"); + + b.Property("AttendeeCount") + .HasColumnType("int"); + + b.Property("CancelledAt") + .HasColumnType("datetime(6)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("ContactPhone") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ReservationDate") + .HasColumnType("date"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("ApplicantCollegeId", "Status", "ReservationDate"); + + b.HasIndex("ApplicantUserId", "Status", "CreatedAt"); + + b.HasIndex("ClassroomId", "ReservationDate", "Status", "StartPeriod"); + + b.ToTable("ClassroomReservations"); + }); + + 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.CourseAdjustment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApplicantUserId") + .HasColumnType("char(36)"); + + b.Property("CancelDate") + .HasColumnType("date"); + + b.Property("CancelWeek") + .HasColumnType("int"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("SourceClassroomId") + .HasColumnType("char(36)"); + + b.Property("SourceDate") + .HasColumnType("date"); + + b.Property("SourcePeriodCount") + .HasColumnType("int"); + + b.Property("SourceScheduleEntryId") + .HasColumnType("char(36)"); + + b.Property("SourceStartPeriod") + .HasColumnType("int"); + + b.Property("SourceWeek") + .HasColumnType("int"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("SubstituteTeacherId") + .HasColumnType("char(36)"); + + b.Property("TargetDate") + .HasColumnType("date"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantUserId"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("SubstituteTeacherId"); + + b.HasIndex("SourceScheduleEntryId", "SourceWeek"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("TeachingTaskId", "Status"); + + b.ToTable("CourseAdjustments"); + }); + + 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("EnrollmentType") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WaitlistedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawnAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseSelectionOfferingId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.HasIndex("CourseSelectionOfferingId", "Status", "WaitlistedAt") + .HasDatabaseName("IX_CE_Offering_Status_WaitlistedAt"); + + b.ToTable("CourseEnrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseExemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("StudentId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("CourseExemptions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("PrerequisiteCourseId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("PrerequisiteCourseId"); + + b.HasIndex("CourseId", "PrerequisiteCourseId") + .IsUnique(); + + b.ToTable("CoursePrerequisites"); + }); + + 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("MaxCourseCount") + .HasColumnType("int"); + + 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.CourseSelectionRoundGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseSelectionRoundId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Grade"); + + b.HasIndex("CourseSelectionRoundId", "Grade") + .IsUnique(); + + b.ToTable("CourseSelectionRoundGrades"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OriginalCourseId") + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("SubstituteCourseId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OriginalCourseId"); + + b.HasIndex("SubstituteCourseId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("StudentId", "OriginalCourseId") + .IsUnique(); + + b.ToTable("CourseSubstitutions"); + }); + + 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.DeferredExam", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("StudentId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("DeferredExams"); + }); + + 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.EvaluationDimension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EvaluationSetupId") + .HasColumnType("char(36)"); + + b.Property("MaxScore") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("EvaluationSetupId", "SortOrder"); + + b.ToTable("EvaluationDimensions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EvaluationSetupId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("EvaluationSetupId", "StudentId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("EvaluationRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationScore", b => + { + b.Property("EvaluationRecordId") + .HasColumnType("char(36)"); + + b.Property("EvaluationDimensionId") + .HasColumnType("char(36)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("EvaluationRecordId", "EvaluationDimensionId"); + + b.HasIndex("EvaluationDimensionId"); + + b.ToTable("EvaluationScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", 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("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("EvaluationSetups"); + }); + + 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.ExamRoomAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamDate") + .HasColumnType("date"); + + b.Property("ExamPlanId") + .HasColumnType("char(36)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("RequiredInvigilatorCount") + .HasColumnType("int"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("CourseId") + .HasDatabaseName("IX_ExamRooms_CourseId"); + + b.HasIndex("ExamPlanId", "StartsAt") + .HasDatabaseName("IX_ExamRooms_Plan_Time"); + + b.HasIndex("ExamPlanId", "ClassroomId", "StartsAt") + .HasDatabaseName("IX_ExamRooms_Plan_Room_Time"); + + b.ToTable("ExamRooms", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomInvigilator", b => + { + b.Property("ExamRoomId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("ExamRoomId", "TeacherId"); + + b.HasIndex("TeacherId") + .HasDatabaseName("IX_ExamRoomInvigilators_TeacherId"); + + b.ToTable("ExamRoomInvigilators", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomSession", b => + { + b.Property("ExamRoomId") + .HasColumnType("char(36)"); + + b.Property("ExamSessionId") + .HasColumnType("char(36)"); + + b.HasKey("ExamRoomId", "ExamSessionId"); + + b.HasIndex("ExamSessionId") + .HasDatabaseName("IX_ExamRoomSessions_SessionId"); + + b.ToTable("ExamRoomSessions", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSeatAssignment", b => + { + b.Property("ExamRoomId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("ExamSessionId") + .HasColumnType("char(36)"); + + b.Property("SeatNumber") + .HasColumnType("int"); + + b.HasKey("ExamRoomId", "StudentId"); + + b.HasIndex("StudentId") + .HasDatabaseName("IX_ExamSeats_StudentId"); + + b.HasIndex("ExamSessionId", "StudentId") + .IsUnique() + .HasDatabaseName("UX_ExamSeats_Session_Student"); + + b.ToTable("ExamSeats", (string)null); + }); + + 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("ExamDate") + .HasColumnType("date"); + + b.Property("ExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredInvigilatorCount") + .HasColumnType("int"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + 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("RequiredBuildingId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("ExamPlanId", "ExamDate"); + + 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.GradeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GradeSheetId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Weight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.HasKey("Id"); + + b.HasIndex("GradeSheetId", "SortOrder"); + + b.ToTable("GradeItems"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItemScore", b => + { + b.Property("GradeRecordId") + .HasColumnType("char(36)"); + + b.Property("GradeItemId") + .HasColumnType("char(36)"); + + b.Property("Score") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.HasKey("GradeRecordId", "GradeItemId"); + + b.HasIndex("GradeItemId"); + + b.ToTable("GradeItemScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeModification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApplicantUserId") + .HasColumnType("char(36)"); + + b.Property("CollegeReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("CollegeReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurrentScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("FinalReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("FinalReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("GradeRecordId") + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RequestedScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GradeRecordId"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("GradeModifications"); + }); + + 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("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("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.MakeupExamAutoJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedSessions") + .HasColumnType("int"); + + b.Property("EnrolledStudents") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MakeupExamPlanId") + .HasColumnType("char(36)"); + + b.Property("MessagesJson") + .HasColumnType("longtext"); + + b.Property("ProcessedCourses") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCourses") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MakeupExamPlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("MakeupExamAutoJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamEnrollment", b => + { + b.Property("MakeupExamSessionId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("MakeupScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("Reason") + .HasColumnType("int"); + + b.Property("SourceDeferredExamId") + .HasColumnType("char(36)"); + + b.Property("SourceGradeRecordId") + .HasColumnType("char(36)"); + + b.HasKey("MakeupExamSessionId", "StudentId"); + + b.HasIndex("SourceDeferredExamId"); + + b.HasIndex("SourceGradeRecordId"); + + b.HasIndex("StudentId", "MakeupExamSessionId"); + + b.ToTable("MakeupExamEnrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamPlan", 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("MakeupExamPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSession", 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("ExamDate") + .HasColumnType("date"); + + b.Property("MakeupExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredInvigilatorCount") + .HasColumnType("int"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + 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("RequiredBuildingId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("MakeupExamPlanId", "ExamDate"); + + b.HasIndex("MakeupExamPlanId", "StartsAt"); + + b.ToTable("MakeupExamSessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSessionInvigilator", b => + { + b.Property("MakeupExamSessionId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("MakeupExamSessionId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("MakeupExamSessionInvigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AudienceId") + .HasColumnType("char(36)"); + + b.Property("AudienceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("AudienceType") + .HasColumnType("int"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LinkUrl") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RecipientCount") + .HasColumnType("int"); + + b.Property("SenderName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SenderUserId") + .HasColumnType("char(36)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SenderUserId", "CreatedAt"); + + b.ToTable("MessageDispatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRead") + .HasColumnType("tinyint(1)"); + + b.Property("LinkUrl") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MessageDispatchId") + .HasColumnType("char(36)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("MessageDispatchId"); + + b.HasIndex("UserId", "IsRead"); + + b.HasIndex("UserId", "Category", "CreatedAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocumentNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvalidatedAt") + .HasColumnType("datetime(6)"); + + b.Property("InvalidatedByUserId") + .HasColumnType("char(36)"); + + b.Property("InvalidationReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IssuedAt") + .HasColumnType("datetime(6)"); + + b.Property("IssuedByUserId") + .HasColumnType("char(36)"); + + b.Property("PdfContent") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("PdfSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Purpose") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ReissuedFromDocumentId") + .HasColumnType("char(36)"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VerificationCodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentNumber") + .IsUnique(); + + b.HasIndex("InvalidatedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("ReissuedFromDocumentId") + .IsUnique(); + + b.HasIndex("VerificationCodeHash") + .IsUnique(); + + b.HasIndex("Status", "IssuedAt"); + + b.HasIndex("StudentId", "IssuedAt"); + + b.ToTable("OfficialDocuments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DownloadedByUserId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OfficialDocumentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("DownloadedByUserId", "CreatedAt"); + + b.HasIndex("OfficialDocumentId", "CreatedAt"); + + b.ToTable("OfficialDocumentDownloads"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeekPattern") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("SchedulePlanId", "DayOfWeek", "StartPeriod"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.HasIndex("AcademicTermId", "Version") + .IsUnique(); + + b.ToTable("SchedulePlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("ActiveAcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedSteps") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurrentStep") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("RequestedByUserId") + .HasColumnType("char(36)"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalSteps") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAcademicTermId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("SchedulePublishJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("time"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("PeriodNumber") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "PeriodNumber") + .IsUnique(); + + b.ToTable("ScheduleTimeSlots"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EnrollmentDate") + .HasColumnType("date"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentYear"); + + b.HasIndex("StudentNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("AdministrativeClassId", "Status"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OriginalStatus") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("State") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetStatus") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId", "State"); + + b.ToTable("StudentStatusChanges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("IsExternal") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeacherNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Title") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("TeacherNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("CollegeId", "Status"); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Statement") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("TeacherId"); + + b.HasIndex("Status", "AcademicTermId"); + + b.HasIndex("AcademicTermId", "TeacherId", "CourseId") + .IsUnique(); + + b.ToTable("TeacherCourseApplications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("GenerationBatchCode") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("SchedulingMode") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeeklyHours") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("TaskNumber") + .IsUnique(); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("TeachingTasks"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.Property("TeachingTaskScheduleConstraintId") + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("TeachingTaskAllowedClassrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskId", "AdministrativeClassId"); + + b.HasIndex("AdministrativeClassId"); + + b.ToTable("TeachingTaskClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AllowedDayOfWeeks") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EarliestPeriod") + .HasColumnType("int"); + + b.Property("LatestPeriod") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredCampusId") + .HasColumnType("char(36)"); + + b.Property("RequiresClassroom") + .HasColumnType("tinyint(1)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("RequiredCampusId"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("TeachingTaskScheduleConstraints"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.HasKey("TeachingTaskId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("TeachingTaskTeachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("AcknowledgeComment") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("TriggerValue") + .HasPrecision(7, 2) + .HasColumnType("decimal(7,2)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("StudentId", "AcademicTermId", "Type") + .IsUnique(); + + b.ToTable("WarningRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("AutoCheckEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("CheckDayOfWeek") + .HasColumnType("int"); + + b.Property("CheckHour") + .HasColumnType("int"); + + b.Property("CheckMinute") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastCheckAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NotifyCounselor") + .HasColumnType("tinyint(1)"); + + b.Property("NotifyStudent") + .HasColumnType("tinyint(1)"); + + b.Property("Threshold") + .HasPrecision(7, 2) + .HasColumnType("decimal(7,2)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Type") + .IsUnique(); + + b.ToTable("WarningRules"); + }); + + 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("CalendarSubscriptionCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CalendarSubscriptionStamp") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + 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("Jiaowu.Api.Domain.System.BackgroundJobOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("JobId") + .HasColumnType("char(36)"); + + b.Property("JobKind") + .HasColumnType("int"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("ProcessingAttempts") + .HasColumnType("int"); + + b.Property("ProcessingToken") + .HasColumnType("char(36)"); + + b.Property("PublishAttempts") + .HasColumnType("int"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("State") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("LeaseExpiresAt"); + + b.HasIndex("JobKind", "JobId") + .IsUnique(); + + b.HasIndex("State", "CompletedAt"); + + b.HasIndex("State", "CreatedAt"); + + b.ToTable("BackgroundJobOutboxMessages"); + }); + + 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.AttendanceRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AttendanceSheet", "AttendanceSheet") + .WithMany("Records") + .HasForeignKey("AttendanceSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AttendanceSheet"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", 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.AutomaticScheduleJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("RequestedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany() + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchedulePlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "Building") + .WithMany() + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.College", "ApplicantCollege") + .WithMany() + .HasForeignKey("ApplicantCollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ApplicantUser") + .WithMany() + .HasForeignKey("ApplicantUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ReviewedByUser") + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AcademicTerm"); + + b.Navigation("ApplicantCollege"); + + b.Navigation("ApplicantUser"); + + b.Navigation("Classroom"); + + b.Navigation("ReviewedByUser"); + }); + + 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.CourseAdjustment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "SubstituteTeacher") + .WithMany() + .HasForeignKey("SubstituteTeacherId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SubstituteTeacher"); + + b.Navigation("TeachingTask"); + }); + + 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.CourseExemption", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany("Prerequisites") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "PrerequisiteCourse") + .WithMany("RequiredByCourses") + .HasForeignKey("PrerequisiteCourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("PrerequisiteCourse"); + }); + + 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.CourseSelectionRoundGrade", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound") + .WithMany("EligibleGrades") + .HasForeignKey("CourseSelectionRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CourseSelectionRound"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "OriginalCourse") + .WithMany() + .HasForeignKey("OriginalCourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "SubstituteCourse") + .WithMany() + .HasForeignKey("SubstituteCourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OriginalCourse"); + + b.Navigation("Student"); + + b.Navigation("SubstituteCourse"); + }); + + 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.DeferredExam", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + 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.EvaluationDimension", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationSetup", "EvaluationSetup") + .WithMany("Dimensions") + .HasForeignKey("EvaluationSetupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EvaluationSetup"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationSetup", "EvaluationSetup") + .WithMany("Records") + .HasForeignKey("EvaluationSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EvaluationSetup"); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationScore", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationDimension", "EvaluationDimension") + .WithMany("Scores") + .HasForeignKey("EvaluationDimensionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationRecord", "EvaluationRecord") + .WithMany("Scores") + .HasForeignKey("EvaluationRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EvaluationDimension"); + + b.Navigation("EvaluationRecord"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", 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.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.ExamRoomAssignment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan") + .WithMany("Rooms") + .HasForeignKey("ExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("Course"); + + b.Navigation("ExamPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", "ExamRoom") + .WithMany("Invigilators") + .HasForeignKey("ExamRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamRoom"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", "ExamRoom") + .WithMany("SessionLinks") + .HasForeignKey("ExamRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession") + .WithMany("RoomLinks") + .HasForeignKey("ExamSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamRoom"); + + b.Navigation("ExamSession"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSeatAssignment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", "ExamRoom") + .WithMany("Seats") + .HasForeignKey("ExamRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession") + .WithMany("SeatAssignments") + .HasForeignKey("ExamSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamRoom"); + + b.Navigation("ExamSession"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan") + .WithMany("Sessions") + .HasForeignKey("ExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("ExamPlan"); + + b.Navigation("RequiredBuilding"); + + 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.GradeItem", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") + .WithMany("Items") + .HasForeignKey("GradeSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GradeSheet"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItemScore", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeItem", "GradeItem") + .WithMany("Scores") + .HasForeignKey("GradeItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "GradeRecord") + .WithMany("ItemScores") + .HasForeignKey("GradeRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GradeItem"); + + b.Navigation("GradeRecord"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeModification", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "GradeRecord") + .WithMany() + .HasForeignKey("GradeRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GradeRecord"); + }); + + 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.MakeupExamAutoJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamPlan", "MakeupExamPlan") + .WithMany() + .HasForeignKey("MakeupExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MakeupExamPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamEnrollment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamSession", "MakeupExamSession") + .WithMany("Enrollments") + .HasForeignKey("MakeupExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.DeferredExam", "SourceDeferredExam") + .WithMany() + .HasForeignKey("SourceDeferredExamId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "SourceGradeRecord") + .WithMany() + .HasForeignKey("SourceGradeRecordId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MakeupExamSession"); + + b.Navigation("SourceDeferredExam"); + + b.Navigation("SourceGradeRecord"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamPlan", 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.MakeupExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamPlan", "MakeupExamPlan") + .WithMany("Sessions") + .HasForeignKey("MakeupExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("MakeupExamPlan"); + + b.Navigation("RequiredBuilding"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSessionInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamSession", "MakeupExamSession") + .WithMany("Invigilators") + .HasForeignKey("MakeupExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MakeupExamSession"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MessageDispatch", "MessageDispatch") + .WithMany("Notifications") + .HasForeignKey("MessageDispatchId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("MessageDispatch"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "InvalidatedByUser") + .WithMany() + .HasForeignKey("InvalidatedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "ReissuedFromDocument") + .WithMany() + .HasForeignKey("ReissuedFromDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("InvalidatedByUser"); + + b.Navigation("IssuedByUser"); + + b.Navigation("ReissuedFromDocument"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "DownloadedByUser") + .WithMany() + .HasForeignKey("DownloadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "OfficialDocument") + .WithMany("Downloads") + .HasForeignKey("OfficialDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DownloadedByUser"); + + b.Navigation("OfficialDocument"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany("Entries") + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SchedulePlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("RequestedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany() + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchedulePlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany("Students") + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AdministrativeClass"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", "TeachingTaskScheduleConstraint") + .WithMany("AllowedClassrooms") + .HasForeignKey("TeachingTaskScheduleConstraintId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("TeachingTaskScheduleConstraint"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany() + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Classes") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdministrativeClass"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "RequiredCampus") + .WithMany() + .HasForeignKey("RequiredCampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RequiredBuilding"); + + b.Navigation("RequiredCampus"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Teachers") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Teacher"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRecord", 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.WarningRule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + 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.AttendanceSheet", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.Navigation("Prerequisites"); + + b.Navigation("RequiredByCourses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Navigation("EligibleGrades"); + + 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.EvaluationDimension", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", b => + { + b.Navigation("Dimensions"); + + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Navigation("Rooms"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", b => + { + b.Navigation("Invigilators"); + + b.Navigation("Seats"); + + b.Navigation("SessionLinks"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Navigation("Invigilators"); + + b.Navigation("RoomLinks"); + + b.Navigation("SeatAssignments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.Navigation("ItemScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Navigation("Items"); + + 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.MakeupExamPlan", b => + { + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSession", b => + { + b.Navigation("Enrollments"); + + b.Navigation("Invigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.Navigation("Downloads"); + }); + + 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/20260727105716_ExamRoomMixing.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260727105716_ExamRoomMixing.cs new file mode 100644 index 0000000..54a457d --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260727105716_ExamRoomMixing.cs @@ -0,0 +1,196 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class ExamRoomMixing : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExamRooms", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + ExamPlanId = table.Column(type: "char(36)", nullable: false), + CourseId = table.Column(type: "char(36)", nullable: false), + ClassroomId = table.Column(type: "char(36)", nullable: false), + ExamDate = table.Column(type: "date", nullable: false), + StartPeriod = table.Column(type: "int", nullable: false), + PeriodCount = table.Column(type: "int", nullable: false), + StartsAt = table.Column(type: "datetime(6)", nullable: false), + EndsAt = table.Column(type: "datetime(6)", nullable: false), + RequiredInvigilatorCount = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExamRooms", x => x.Id); + table.ForeignKey( + name: "FK_ExamRooms_Classrooms_ClassroomId", + column: x => x.ClassroomId, + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ExamRooms_Courses_CourseId", + column: x => x.CourseId, + principalTable: "Courses", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ExamRooms_ExamPlans_ExamPlanId", + column: x => x.ExamPlanId, + principalTable: "ExamPlans", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ExamRoomInvigilators", + columns: table => new + { + ExamRoomId = table.Column(type: "char(36)", nullable: false), + TeacherId = table.Column(type: "char(36)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExamRoomInvigilators", x => new { x.ExamRoomId, x.TeacherId }); + table.ForeignKey( + name: "FK_ExamRoomInvigilators_ExamRooms_ExamRoomId", + column: x => x.ExamRoomId, + principalTable: "ExamRooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ExamRoomInvigilators_Teachers_TeacherId", + column: x => x.TeacherId, + principalTable: "Teachers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ExamRoomSessions", + columns: table => new + { + ExamRoomId = table.Column(type: "char(36)", nullable: false), + ExamSessionId = table.Column(type: "char(36)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExamRoomSessions", x => new { x.ExamRoomId, x.ExamSessionId }); + table.ForeignKey( + name: "FK_ExamRoomSessions_ExamRooms_ExamRoomId", + column: x => x.ExamRoomId, + principalTable: "ExamRooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ExamRoomSessions_ExamSessions_ExamSessionId", + column: x => x.ExamSessionId, + principalTable: "ExamSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ExamSeats", + columns: table => new + { + ExamRoomId = table.Column(type: "char(36)", nullable: false), + StudentId = table.Column(type: "char(36)", nullable: false), + ExamSessionId = table.Column(type: "char(36)", nullable: false), + SeatNumber = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExamSeats", x => new { x.ExamRoomId, x.StudentId }); + table.ForeignKey( + name: "FK_ExamSeats_ExamRooms_ExamRoomId", + column: x => x.ExamRoomId, + principalTable: "ExamRooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ExamSeats_ExamSessions_ExamSessionId", + column: x => x.ExamSessionId, + principalTable: "ExamSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ExamSeats_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_ExamRoomInvigilators_TeacherId", + table: "ExamRoomInvigilators", + column: "TeacherId"); + + migrationBuilder.CreateIndex( + name: "IX_ExamRooms_ClassroomId", + table: "ExamRooms", + column: "ClassroomId"); + + migrationBuilder.CreateIndex( + name: "IX_ExamRooms_CourseId", + table: "ExamRooms", + column: "CourseId"); + + migrationBuilder.CreateIndex( + name: "IX_ExamRooms_Plan_Room_Time", + table: "ExamRooms", + columns: new[] { "ExamPlanId", "ClassroomId", "StartsAt" }); + + migrationBuilder.CreateIndex( + name: "IX_ExamRooms_Plan_Time", + table: "ExamRooms", + columns: new[] { "ExamPlanId", "StartsAt" }); + + migrationBuilder.CreateIndex( + name: "IX_ExamRoomSessions_SessionId", + table: "ExamRoomSessions", + column: "ExamSessionId"); + + migrationBuilder.CreateIndex( + name: "IX_ExamSeats_StudentId", + table: "ExamSeats", + column: "StudentId"); + + migrationBuilder.CreateIndex( + name: "UX_ExamSeats_Session_Student", + table: "ExamSeats", + columns: new[] { "ExamSessionId", "StudentId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ExamRoomInvigilators"); + + migrationBuilder.DropTable( + name: "ExamRoomSessions"); + + migrationBuilder.DropTable( + name: "ExamSeats"); + + migrationBuilder.DropTable( + name: "ExamRooms"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index 6d717ae..57d9286 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -1558,6 +1558,119 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("ExamPlans"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamDate") + .HasColumnType("date"); + + b.Property("ExamPlanId") + .HasColumnType("char(36)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("RequiredInvigilatorCount") + .HasColumnType("int"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("CourseId") + .HasDatabaseName("IX_ExamRooms_CourseId"); + + b.HasIndex("ExamPlanId", "StartsAt") + .HasDatabaseName("IX_ExamRooms_Plan_Time"); + + b.HasIndex("ExamPlanId", "ClassroomId", "StartsAt") + .HasDatabaseName("IX_ExamRooms_Plan_Room_Time"); + + b.ToTable("ExamRooms", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomInvigilator", b => + { + b.Property("ExamRoomId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("ExamRoomId", "TeacherId"); + + b.HasIndex("TeacherId") + .HasDatabaseName("IX_ExamRoomInvigilators_TeacherId"); + + b.ToTable("ExamRoomInvigilators", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomSession", b => + { + b.Property("ExamRoomId") + .HasColumnType("char(36)"); + + b.Property("ExamSessionId") + .HasColumnType("char(36)"); + + b.HasKey("ExamRoomId", "ExamSessionId"); + + b.HasIndex("ExamSessionId") + .HasDatabaseName("IX_ExamRoomSessions_SessionId"); + + b.ToTable("ExamRoomSessions", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSeatAssignment", b => + { + b.Property("ExamRoomId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("ExamSessionId") + .HasColumnType("char(36)"); + + b.Property("SeatNumber") + .HasColumnType("int"); + + b.HasKey("ExamRoomId", "StudentId"); + + b.HasIndex("StudentId") + .HasDatabaseName("IX_ExamSeats_StudentId"); + + b.HasIndex("ExamSessionId", "StudentId") + .IsUnique() + .HasDatabaseName("UX_ExamSeats_Session_Student"); + + b.ToTable("ExamSeats", (string)null); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => { b.Property("Id") @@ -4097,6 +4210,98 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("AcademicTerm"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan") + .WithMany("Rooms") + .HasForeignKey("ExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("Course"); + + b.Navigation("ExamPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", "ExamRoom") + .WithMany("Invigilators") + .HasForeignKey("ExamRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamRoom"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", "ExamRoom") + .WithMany("SessionLinks") + .HasForeignKey("ExamRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession") + .WithMany("RoomLinks") + .HasForeignKey("ExamSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamRoom"); + + b.Navigation("ExamSession"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSeatAssignment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", "ExamRoom") + .WithMany("Seats") + .HasForeignKey("ExamRoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession") + .WithMany("SeatAssignments") + .HasForeignKey("ExamSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamRoom"); + + b.Navigation("ExamSession"); + + b.Navigation("Student"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => { b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") @@ -4832,12 +5037,27 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => { + b.Navigation("Rooms"); + b.Navigation("Sessions"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamRoomAssignment", b => + { + b.Navigation("Invigilators"); + + b.Navigation("Seats"); + + b.Navigation("SessionLinks"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => { b.Navigation("Invigilators"); + + b.Navigation("RoomLinks"); + + b.Navigation("SeatAssignments"); }); modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => diff --git a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs index 91fb3d9..0a7115d 100644 --- a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs +++ b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs @@ -21,6 +21,14 @@ public sealed class AutomaticScheduleGeneratorTests await db.Database.EnsureCreatedAsync(); await db.Database.ExecuteSqlRawAsync( "DROP TABLE \"BackgroundJobOutboxMessages\""); + await db.Database.ExecuteSqlRawAsync( + "DROP TABLE \"ExamRoomInvigilators\""); + await db.Database.ExecuteSqlRawAsync( + "DROP TABLE \"ExamSeats\""); + await db.Database.ExecuteSqlRawAsync( + "DROP TABLE \"ExamRoomSessions\""); + await db.Database.ExecuteSqlRawAsync( + "DROP TABLE \"ExamRooms\""); var migrator = new DevelopmentSqliteMigrator( db, @@ -30,6 +38,10 @@ public sealed class AutomaticScheduleGeneratorTests Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0); Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0); Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0); + Assert.True(await db.ExamRooms.CountAsync() == 0); + Assert.True(await db.ExamRoomSessions.CountAsync() == 0); + Assert.True(await db.ExamSeats.CountAsync() == 0); + Assert.True(await db.ExamRoomInvigilators.CountAsync() == 0); Assert.True(await db.Database .SqlQueryRaw( """ diff --git a/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs b/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs index 7b73410..d6b4a1f 100644 --- a/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs +++ b/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs @@ -9,7 +9,7 @@ namespace Jiaowu.Api.Tests; public sealed class ExamArrangementServiceTests { [Fact] - public async Task Normal_arrangement_only_updates_selected_session() + public async Task Normal_arrangement_creates_room_seats_for_selected_session() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); @@ -40,23 +40,76 @@ public sealed class ExamArrangementServiceTests assignInvigilators: true, CancellationToken.None); - var persistedSelected = await db.ExamSessions + var room = await db.ExamRooms + .Include(x => x.SessionLinks) + .Include(x => x.Seats) .Include(x => x.Invigilators) - .SingleAsync(x => x.Id == selected.Id); - var persistedUntouched = await db.ExamSessions - .Include(x => x.Invigilators) - .SingleAsync(x => x.Id == untouched.Id); + .SingleAsync(); Assert.True(result.Success); - Assert.NotNull(persistedSelected.ClassroomId); - Assert.Equal(seed.LargeClassroom.Id, persistedSelected.ClassroomId); - Assert.Null(persistedUntouched.ClassroomId); - Assert.Single(persistedSelected.Invigilators); - Assert.Empty(persistedUntouched.Invigilators); - Assert.Contains("1个场次处理完成", result.Message); + Assert.Equal(seed.LargeClassroom.Id, room.ClassroomId); + Assert.Equal(selected.Id, Assert.Single(room.SessionLinks).ExamSessionId); + Assert.Equal(3, room.Seats.Count); + Assert.Single(room.Invigilators); + Assert.False(await db.ExamRoomSessions.AnyAsync( + x => x.ExamSessionId == untouched.Id)); + Assert.Contains("1个教学班场次处理完成", result.Message); Assert.Contains("1名监考教师", result.Message); Assert.Contains("程序设计", result.Message); } + [Fact] + public async Task Same_course_sessions_are_interleaved_and_split_across_rooms() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + var seed = await SeedBaseDataAsync(db, "MIXED"); + seed.LargeClassroom.Capacity = 4; + seed.SmallClassroom.Capacity = 4; + + var plan = new ExamPlan + { + AcademicTermId = seed.Term.Id, + Name = "期末考试" + }; + var first = NewExamSession(plan.Id, seed.FirstTask.Id); + var second = NewExamSession(plan.Id, seed.SameCourseTask.Id); + plan.Sessions.Add(first); + plan.Sessions.Add(second); + db.ExamPlans.Add(plan); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + + var result = await new ExamArrangementService(db).ArrangeAsync( + plan.Id, + [first.Id], + assignClassrooms: true, + assignInvigilators: false, + CancellationToken.None); + + var rooms = await db.ExamRooms + .Include(x => x.SessionLinks) + .Include(x => x.Seats) + .OrderByDescending(x => x.Seats.Count) + .ToListAsync(); + Assert.True(result.Success); + Assert.Equal(2, rooms.Count); + Assert.Equal([4, 2], rooms.Select(x => x.Seats.Count).ToArray()); + Assert.All(rooms, room => + { + Assert.Equal(2, room.SessionLinks.Count); + Assert.Equal(2, room.Seats.Select(x => x.ExamSessionId).Distinct().Count()); + Assert.True(room.Seats.Count <= 4); + }); + Assert.Equal(6, await db.ExamSeats.CountAsync()); + Assert.Contains("2个教学班混排至2个考场", result.Message); + Assert.Contains("自动包含同组1个场次", result.Message); + } + [Fact] public async Task Makeup_arrangement_only_updates_selected_session() { @@ -181,6 +234,13 @@ public sealed class ExamArrangementServiceTests MajorId = major.Id, Grade = 2026 }; + var secondAdministrativeClass = new AdministrativeClass + { + Code = $"CLASS-2-{suffix}", + Name = "计科 2026-2 班", + MajorId = major.Id, + Grade = 2026 + }; var firstCourse = new Course { Code = $"COURSE-1-{suffix}", @@ -216,6 +276,21 @@ public sealed class ExamArrangementServiceTests CourseId = secondCourse.Id, Status = TeachingTaskStatus.Published }; + var sameCourseTask = new TeachingTask + { + TaskNumber = $"TASK-3-{suffix}", + Name = "程序设计第二教学班", + AcademicTermId = term.Id, + CourseId = firstCourse.Id, + Status = TeachingTaskStatus.Published, + Classes = + [ + new TeachingTaskClass + { + AdministrativeClassId = secondAdministrativeClass.Id + } + ] + }; var invigilator = new Teacher { TeacherNumber = $"INVIGILATOR-{suffix}", @@ -233,6 +308,7 @@ public sealed class ExamArrangementServiceTests smallClassroom, major, administrativeClass, + secondAdministrativeClass, new Student { StudentNumber = $"STUDENT-1-{suffix}", @@ -249,10 +325,43 @@ public sealed class ExamArrangementServiceTests EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1) }, + new Student + { + StudentNumber = $"STUDENT-3-{suffix}", + Name = "学生丙", + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1) + }, + new Student + { + StudentNumber = $"STUDENT-4-{suffix}", + Name = "学生丁", + AdministrativeClassId = secondAdministrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1) + }, + new Student + { + StudentNumber = $"STUDENT-5-{suffix}", + Name = "学生戊", + AdministrativeClassId = secondAdministrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1) + }, + new Student + { + StudentNumber = $"STUDENT-6-{suffix}", + Name = "学生己", + AdministrativeClassId = secondAdministrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1) + }, firstCourse, secondCourse, firstTask, secondTask, + sameCourseTask, invigilator, new ScheduleTimeSlot { @@ -271,12 +380,20 @@ public sealed class ExamArrangementServiceTests EndsAt = new TimeOnly(9, 50) }); await db.SaveChangesAsync(); - return new SeedData(term, firstTask, secondTask, classroom); + return new SeedData( + term, + firstTask, + secondTask, + sameCourseTask, + classroom, + smallClassroom); } private sealed record SeedData( AcademicTerm Term, TeachingTask FirstTask, TeachingTask SecondTask, - Classroom LargeClassroom); + TeachingTask SameCourseTask, + Classroom LargeClassroom, + Classroom SmallClassroom); } diff --git a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs index 72c06d0..d021809 100644 --- a/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs +++ b/tests/Jiaowu.Api.Tests/MySqlMigrationTests.cs @@ -11,7 +11,7 @@ namespace Jiaowu.Api.Tests; public sealed class MySqlMigrationTests { private const string LatestMigration = - "20260726190000_PersonalCalendarSubscription"; + "20260727105716_ExamRoomMixing"; [Fact] public void Production_migration_is_discoverable_and_generates_mysql_sql() @@ -54,6 +54,10 @@ public sealed class MySqlMigrationTests Assert.Contains( "ADD `CalendarSubscriptionCreatedAt` datetime(6) NULL", script); + Assert.Contains("CREATE TABLE `ExamRooms`", script); + Assert.Contains("CREATE TABLE `ExamRoomSessions`", script); + Assert.Contains("CREATE TABLE `ExamSeats`", script); + Assert.Contains("CREATE TABLE `ExamRoomInvigilators`", script); Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script); Assert.Contains("DEFAULT 1", script); Assert.DoesNotContain("0001-01-01", script); diff --git a/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs b/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs index 16ff8a7..91bdb90 100644 --- a/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs +++ b/tests/Jiaowu.Api.Tests/TeachingWorkflowRosterTests.cs @@ -41,7 +41,26 @@ public sealed class TeachingWorkflowRosterTests NormalizedUserName = "202601001", DisplayName = "周同学" }; - var college = new College { Code = "CS", Name = "计算机学院" }; + var campus = new Campus { Code = "MAIN", Name = "主校区" }; + var college = new College + { + Code = "CS", + Name = "计算机学院", + CampusId = campus.Id + }; + var building = new Building + { + Code = "B01", + Name = "第一教学楼", + CampusId = campus.Id + }; + var classroom = new Classroom + { + Code = "B01-101", + Name = "101", + BuildingId = building.Id, + Capacity = 60 + }; var major = new Major { Code = "080901", @@ -120,7 +139,10 @@ public sealed class TeachingWorkflowRosterTests db.AddRange( teacherUser, studentUser, + campus, college, + building, + classroom, major, administrativeClass, student, @@ -130,22 +152,57 @@ public sealed class TeachingWorkflowRosterTests task); await db.SaveChangesAsync(); + var examSession = new ExamSession + { + TeachingTaskId = task.Id, + ExamDate = new DateOnly(2027, 1, 8), + StartPeriod = 1, + PeriodCount = 2, + StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc), + EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc), + RequiredInvigilatorCount = 1 + }; var examPlan = new ExamPlan { AcademicTermId = term.Id, Name = "期末考试", - Status = ExamPlanStatus.Published, - Sessions = + Status = ExamPlanStatus.Draft, + Sessions = [examSession], + Rooms = [ - new ExamSession + new ExamRoomAssignment { - TeachingTaskId = task.Id, + CourseId = course.Id, + ClassroomId = classroom.Id, ExamDate = new DateOnly(2027, 1, 8), StartPeriod = 1, PeriodCount = 2, StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc), EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc), - RequiredInvigilatorCount = 1 + RequiredInvigilatorCount = 1, + SessionLinks = + [ + new ExamRoomSession + { + ExamSessionId = examSession.Id + } + ], + Seats = + [ + new ExamSeatAssignment + { + ExamSessionId = examSession.Id, + StudentId = student.Id, + SeatNumber = 1 + } + ], + Invigilators = + [ + new ExamRoomInvigilator + { + TeacherId = teacher.Id + } + ] } ] }; @@ -195,10 +252,14 @@ public sealed class TeachingWorkflowRosterTests managerScope, new ExamArrangementService(db), NoOpAppCache.Instance); + var publishResult = await exams.Publish(examPlan.Id, CancellationToken.None); + Assert.IsType(publishResult); + var planResult = Assert.IsType( await exams.GetPlan(examPlan.Id, CancellationToken.None)); var planSessions = ReadEnumerableProperty(planResult.Value!, "Sessions"); Assert.Equal(1, ReadIntProperty(planSessions.Single(), "StudentCount")); + Assert.Single(ReadEnumerableProperty(planSessions.Single(), "ExamRooms")); var examSessionId = examPlan.Sessions.Single().Id; var rosterResult = Assert.IsType( @@ -212,6 +273,13 @@ public sealed class TeachingWorkflowRosterTests NoOpAppCache.Instance); Assert.Single(ReadItems( await studentExams.GetMySchedule(CancellationToken.None))); + var teacherExams = new ExamsController( + db, + scope, + new ExamArrangementService(db), + NoOpAppCache.Instance); + Assert.Single(ReadItems( + await teacherExams.GetMySchedule(CancellationToken.None))); var exportResult = Assert.IsType( await exams.ExportSignInSheets(examPlan.Id, CancellationToken.None)); diff --git a/web/src/views/ExamsView.vue b/web/src/views/ExamsView.vue index 3e17215..0f3c404 100644 --- a/web/src/views/ExamsView.vue +++ b/web/src/views/ExamsView.vue @@ -68,17 +68,39 @@ const filteredTasks = computed(() => { const filteredSessions = computed(() => { const keyword = sessionFilter.keyword.trim().toLowerCase() return (selected.value?.sessions ?? []).filter((session: any) => { - if (sessionFilter.allocation === 'room' && session.classroomId) return false + if (sessionFilter.allocation === 'room' && hasRoomAssignment(session)) return false if (sessionFilter.allocation === 'invigilator' && - session.invigilatorIds.length >= session.requiredInvigilatorCount) return false + hasCompleteInvigilators(session)) return false if (sessionFilter.allocation === 'complete' && - (!session.classroomId || session.invigilatorIds.length < session.requiredInvigilatorCount)) return false + (!hasRoomAssignment(session) || !hasCompleteInvigilators(session))) return false if (!keyword) return true return [session.taskNumber, session.courseCode, session.courseName, session.taskName] .some((value: any) => String(value ?? '').toLowerCase().includes(keyword)) }) }) +function sessionRooms(session: any) { + return session.examRooms ?? [] +} +function hasRoomAssignment(session: any) { + return sessionRooms(session).length > 0 || Boolean(session.classroomId) +} +function hasCompleteInvigilators(session: any) { + const roomAssignments = sessionRooms(session) + if (roomAssignments.length > 0) { + return roomAssignments.every((room: any) => + room.invigilatorIds.length >= session.requiredInvigilatorCount) + } + return session.invigilatorIds.length >= session.requiredInvigilatorCount +} +function sessionInvigilatorNames(session: any) { + const roomAssignments = sessionRooms(session) + const names = roomAssignments.length > 0 + ? roomAssignments.flatMap((room: any) => room.invigilatorNames) + : session.invigilatorNames + return Array.from(new Set(names)) +} + function timeText(startsAt: string) { return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) } @@ -144,7 +166,18 @@ async function savePlan() { await load() } catch (error) { ElMessage.error(apiErrorMessage(error)) } } -function openSession(existing?: any) { +async function openSession(existing?: any) { + if (existing && sessionRooms(existing).length > 0) { + try { + await ElMessageBox.confirm( + '修改该教学班的课程或考试时间会清除相关混排考场和座位,需要重新执行考场编排。', + '修改混排场次', + { type: 'warning', confirmButtonText: '继续修改' }, + ) + } catch { + return + } + } editingSession.value = existing ?? null selectedTaskIds.value = existing ? [existing.teachingTaskId] : [] Object.assign(taskFilter, { keyword: '', collegeId: '', courseNature: '' }) @@ -263,8 +296,11 @@ async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') { : '当前计划全部场次' const action = mode === 'rooms' ? '分配考场' : mode === 'invigilators' ? '分配监考教师' : '分配考场和监考教师' + const arrangementNotice = mode === 'invigilators' + ? '已有考场和座位不会改变。' + : '系统会按同一课程、同一时间生成完整混排组,并重建相关考场和座位。' await ElMessageBox.confirm( - `系统将为${target}${action},已有安排不会被覆盖。`, + `系统将为${target}${action}。${arrangementNotice}`, `一键${action}`, { type: 'info', confirmButtonText: '开始分配' }) arrangeLoading.value = true const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`, { @@ -373,7 +409,7 @@ onMounted(async () => {
EXAMINATION OFFICE

{{ isManager ? '考试安排与考场' : isTeacher ? '我的监考' : '我的考试' }}

-

{{ isManager ? '基于课表节次安排考试,自动匹配考场与监考教师。' : '查看学校已经正式发布的考试日程。' }}

+

{{ isManager ? '基于课表节次安排考试,同课程教学班混排考场并自动匹配监考教师。' : '查看学校已经正式发布的考试日程。' }}

新建考试计划 刷新日程 @@ -438,7 +474,7 @@ onMounted(async () => {
-
+
监考 - {{ session.invigilatorNames.length ? session.invigilatorNames.join('、') : '待分配' }} + {{ sessionInvigilatorNames(session).length ? sessionInvigilatorNames(session).join('、') : '待分配' }} {{ timeText(session.startsAt) }}—{{ timeText(session.endsAt) }}
@@ -607,6 +650,8 @@ onMounted(async () => { + + @@ -655,6 +700,10 @@ onMounted(async () => { .exam-timeline article.unassigned { border-left-color: #e6a23c; } +.exam-room-chip { + display: block; + margin: 3px 0; +} @media (max-width: 720px) { .exam-actions { justify-content: flex-start;