diff --git a/src/Jiaowu.Api/Controllers/ExamsController.cs b/src/Jiaowu.Api/Controllers/ExamsController.cs index 4ff3eff..3c128cb 100644 --- a/src/Jiaowu.Api/Controllers/ExamsController.cs +++ b/src/Jiaowu.Api/Controllers/ExamsController.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -14,11 +15,16 @@ namespace Jiaowu.Api.Controllers; [Route("api/exams")] public sealed class ExamsController( AppDbContext db, - ICurrentUserDataScope currentUserDataScope) : ControllerBase + ICurrentUserDataScope currentUserDataScope, + ExamArrangementService examArrangementService) : ControllerBase { private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; + // ═══════════════════════════════════════════ + // Plans + // ═══════════════════════════════════════════ + [HttpGet("plans")] public async Task GetPlans( Guid? academicTermId, @@ -68,16 +74,29 @@ public sealed class ExamsController( { x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, x.Status, x.Notes, x.PublishedAt, - Sessions = x.Sessions.OrderBy(item => item.StartsAt).Select(item => new + Sessions = x.Sessions.OrderBy(item => item.ExamDate) + .ThenBy(item => item.StartPeriod).Select(item => new { - item.Id, item.TeachingTaskId, item.TeachingTask!.TaskNumber, + item.Id, + item.TeachingTaskId, + item.TeachingTask!.TaskNumber, TaskName = item.TeachingTask.Name, CourseCode = item.TeachingTask.Course!.Code, CourseName = item.TeachingTask.Course.Name, - item.ClassroomId, ClassroomName = item.Classroom!.Name, - BuildingName = item.Classroom.Building!.Name, - ClassroomCapacity = item.Classroom.Capacity, - item.StartsAt, item.EndsAt, item.Notes, + item.ClassroomId, + ClassroomName = item.Classroom != null ? item.Classroom.Name : null, + BuildingName = item.Classroom != null ? item.Classroom.Building!.Name : null, + ClassroomCapacity = item.Classroom != null ? (int?)item.Classroom.Capacity : null, + item.ExamDate, + item.StartPeriod, + item.PeriodCount, + item.StartsAt, + item.EndsAt, + item.RequiredBuildingId, + RequiredBuildingName = item.RequiredBuilding != null + ? item.RequiredBuilding.Name : null, + item.RequiredInvigilatorCount, + item.Notes, InvigilatorIds = item.Invigilators.Select(i => i.TeacherId), InvigilatorNames = item.Invigilators.Select(i => i.Teacher!.Name), StudentCount = db.CourseEnrollments.Count(e => @@ -88,34 +107,97 @@ public sealed class ExamsController( return plan is null ? NotFound() : Ok(plan); } + // ═══════════════════════════════════════════ + // Sessions + // ═══════════════════════════════════════════ + [HttpPost("plans/{planId:guid}/sessions")] [Authorize(Roles = Managers)] public async Task CreateSession( Guid planId, - ExamSessionRequest request, + CreateExamSessionRequest request, CancellationToken cancellationToken) { var plan = await db.ExamPlans.FindAsync([planId], cancellationToken); if (plan is null) return NotFound(); if (plan.Status != ExamPlanStatus.Draft) return ConflictProblem("已发布的考试计划不能调整场次。"); - var validation = await ValidateSessionAsync(plan, null, request, cancellationToken); + + // Resolve time from slots + var timeResult = ResolveExamTime(plan.AcademicTermId, + request.ExamDate, request.StartPeriod, request.PeriodCount); + if (timeResult.Error is not null) return timeResult.Error; + var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); + + var validation = await ValidateSessionAsync(plan, null, + request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds, + startsAt, endsAt, cancellationToken); if (validation is not null) return validation; + var session = new ExamSession { ExamPlanId = planId, TeachingTaskId = request.TeachingTaskId, ClassroomId = request.ClassroomId, - StartsAt = request.StartsAt.ToUniversalTime(), - EndsAt = request.EndsAt.ToUniversalTime(), + ExamDate = request.ExamDate, + StartPeriod = request.StartPeriod, + PeriodCount = request.PeriodCount, + StartsAt = startsAt, + EndsAt = endsAt, + RequiredBuildingId = request.RequiredBuildingId, + RequiredInvigilatorCount = request.RequiredInvigilatorCount, Notes = Normalize(request.Notes), - Invigilators = request.InvigilatorIds.Distinct().Select(id => + Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id => new ExamSessionInvigilator { TeacherId = id }).ToList() }; db.ExamSessions.Add(session); return await SaveAsync(session.Id, true, cancellationToken); } + [HttpPut("plans/{planId:guid}/sessions/{id:guid}")] + [Authorize(Roles = Managers)] + public async Task UpdateSession( + Guid planId, + Guid id, + CreateExamSessionRequest request, + CancellationToken cancellationToken) + { + var session = await db.ExamSessions + .Include(x => x.ExamPlan) + .Include(x => x.Invigilators) + .FirstOrDefaultAsync(x => x.Id == id && x.ExamPlanId == planId, cancellationToken); + if (session is null) return NotFound(); + if (session.ExamPlan!.Status != ExamPlanStatus.Draft) + return ConflictProblem("已发布的考试计划不能调整场次。"); + + var timeResult = ResolveExamTime(session.ExamPlan.AcademicTermId, + request.ExamDate, request.StartPeriod, request.PeriodCount); + if (timeResult.Error is not null) return timeResult.Error; + var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); + + var validation = await ValidateSessionAsync(session.ExamPlan, id, + request.TeachingTaskId, request.ClassroomId, request.InvigilatorIds, + startsAt, endsAt, cancellationToken); + if (validation is not null) return validation; + + session.TeachingTaskId = request.TeachingTaskId; + session.ClassroomId = request.ClassroomId; + session.ExamDate = request.ExamDate; + session.StartPeriod = request.StartPeriod; + session.PeriodCount = request.PeriodCount; + session.StartsAt = startsAt; + session.EndsAt = endsAt; + session.RequiredBuildingId = request.RequiredBuildingId; + session.RequiredInvigilatorCount = request.RequiredInvigilatorCount; + session.Notes = Normalize(request.Notes); + + db.ExamSessionInvigilators.RemoveRange(session.Invigilators); + session.Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(tid => + new ExamSessionInvigilator { TeacherId = tid }).ToList(); + + return await SaveAsync(id, false, cancellationToken); + } + [HttpDelete("plans/{planId:guid}/sessions/{id:guid}")] [Authorize(Roles = Managers)] public async Task DeleteSession( @@ -132,6 +214,141 @@ public sealed class ExamsController( return await SaveAsync(id, false, cancellationToken); } + // ═══════════════════════════════════════════ + // Auto-arrange + // ═══════════════════════════════════════════ + + [HttpPost("plans/{planId:guid}/auto-arrange")] + [Authorize(Roles = Managers)] + public async Task AutoArrange( + Guid planId, + CancellationToken cancellationToken) + { + var result = await examArrangementService.ArrangeAsync(planId, cancellationToken); + if (!result.Success) + return ConflictProblem(result.Message); + return Ok(new { message = result.Message }); + } + + // ═══════════════════════════════════════════ + // Query helpers + // ═══════════════════════════════════════════ + + [HttpGet("available-classrooms")] + [Authorize(Roles = Managers)] + public async Task GetAvailableClassrooms( + Guid academicTermId, + DateOnly examDate, + int startPeriod, + int periodCount, + Guid? requiredBuildingId, + Guid? planId, + Guid? excludeSessionId, + CancellationToken cancellationToken) + { + var timeResult = ResolveExamTime( + academicTermId, examDate, startPeriod, periodCount); + if (timeResult.Error is not null) return timeResult.Error; + var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); + + var query = db.Classrooms.AsNoTracking() + .Where(x => x.IsEnabled); + + if (requiredBuildingId.HasValue) + query = query.Where(x => x.BuildingId == requiredBuildingId.Value); + + // Exclude classrooms already occupied + var occupiedQuery = db.ExamSessions.AsNoTracking() + .Where(x => x.ClassroomId != null && + x.StartsAt < endsAt && startsAt < x.EndsAt); + if (planId.HasValue) + occupiedQuery = occupiedQuery.Where(x => x.ExamPlanId == planId.Value); + if (excludeSessionId.HasValue) + occupiedQuery = occupiedQuery.Where(x => x.Id != excludeSessionId.Value); + + var occupiedIds = await occupiedQuery.Select(x => x.ClassroomId!.Value) + .ToListAsync(cancellationToken); + + if (occupiedIds.Count > 0) + query = query.Where(x => !occupiedIds.Contains(x.Id)); + + return Ok(await query.OrderBy(x => x.Building!.Name) + .ThenBy(x => x.Capacity) + .Select(x => new + { + x.Id, + x.Name, + x.Capacity, + x.RoomType, + x.BuildingId, + BuildingName = x.Building!.Name + }).ToListAsync(cancellationToken)); + } + + [HttpGet("available-teachers")] + [Authorize(Roles = Managers)] + public async Task GetAvailableTeachers( + Guid academicTermId, + DateOnly examDate, + int startPeriod, + int periodCount, + Guid? planId, + Guid? excludeSessionId, + CancellationToken cancellationToken) + { + var timeResult = ResolveExamTime( + academicTermId, examDate, startPeriod, periodCount); + if (timeResult.Error is not null) return timeResult.Error; + var (startsAt, endsAt) = (timeResult.StartsAt, timeResult.EndsAt); + + var busyQuery = db.ExamSessionInvigilators.AsNoTracking() + .Where(x => x.ExamSession!.StartsAt < endsAt && + startsAt < x.ExamSession!.EndsAt); + if (planId.HasValue) + busyQuery = busyQuery.Where(x => x.ExamSession!.ExamPlanId == planId.Value); + if (excludeSessionId.HasValue) + busyQuery = busyQuery.Where(x => x.ExamSessionId != excludeSessionId.Value); + + var busyIds = await busyQuery.Select(x => x.TeacherId) + .ToListAsync(cancellationToken); + + return Ok(await db.Teachers.AsNoTracking() + .Where(x => x.Status == TeacherStatus.Active && + !busyIds.Contains(x.Id)) + .OrderBy(x => x.Name) + .Select(x => new + { + x.Id, + x.TeacherNumber, + x.Name, + x.Title, + CollegeName = x.College!.Name + }).ToListAsync(cancellationToken)); + } + + [HttpGet("time-slots-for-term")] + [Authorize(Roles = Managers)] + public async Task GetTimeSlotsForTerm( + Guid academicTermId, + CancellationToken cancellationToken) => + Ok(await db.ScheduleTimeSlots.AsNoTracking() + .Where(x => x.AcademicTermId == academicTermId && x.IsEnabled) + .OrderBy(x => x.PeriodNumber) + .Select(x => new + { + x.Id, + x.PeriodNumber, + x.Name, + StartsAt = x.StartsAt.ToString("HH:mm"), + EndsAt = x.EndsAt.ToString("HH:mm"), + x.IsEnabled + }) + .ToListAsync(cancellationToken)); + + // ═══════════════════════════════════════════ + // Publish + // ═══════════════════════════════════════════ + [HttpPost("plans/{id:guid}/publish")] [Authorize(Roles = Managers)] public async Task Publish(Guid id, CancellationToken cancellationToken) @@ -143,11 +360,22 @@ public sealed class ExamsController( return ConflictProblem("只有草稿考试计划可以发布。"); if (plan.Sessions.Count == 0) return ConflictProblem("至少安排一个考试场次后才能发布。"); + + var unassigned = plan.Sessions.Count(x => + !x.ClassroomId.HasValue || x.Invigilators.Count == 0); + if (unassigned > 0) + return ConflictProblem( + $"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。"); + plan.Status = ExamPlanStatus.Published; plan.PublishedAt = DateTime.UtcNow; return await SaveAsync(id, false, cancellationToken); } + // ═══════════════════════════════════════════ + // Roster + // ═══════════════════════════════════════════ + [HttpGet("sessions/{id:guid}/roster")] [Authorize(Roles = Managers)] public async Task GetRoster(Guid id, CancellationToken cancellationToken) @@ -156,22 +384,31 @@ public sealed class ExamsController( .Where(x => x.Id == id) .Select(x => new { - x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, + x.Id, + x.TeachingTaskId, + x.TeachingTask!.TaskNumber, CourseName = x.TeachingTask.Course!.Name, - ClassroomName = x.Classroom!.Name, x.StartsAt, + ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配", + x.StartsAt, Students = db.CourseEnrollments .Where(e => e.Status == CourseEnrollmentStatus.Enrolled && e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId) .OrderBy(e => e.Student!.StudentNumber) .Select(e => new { - e.StudentId, e.Student!.StudentNumber, e.Student.Name, + e.StudentId, + e.Student!.StudentNumber, + e.Student.Name, ClassName = e.Student.AdministrativeClass!.Name }).ToList() }).FirstOrDefaultAsync(cancellationToken); return session is null ? NotFound() : Ok(session); } + // ═══════════════════════════════════════════ + // My schedule + // ═══════════════════════════════════════════ + [HttpGet("my-schedule")] public async Task GetMySchedule(CancellationToken cancellationToken) { @@ -180,20 +417,30 @@ public sealed class ExamsController( { var studentId = await db.Students.Where(x => x.UserId == scope.UserId) .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken); - if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); + if (!studentId.HasValue) + return ConflictProblem("当前账号未关联学生档案。"); return Ok(await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && db.CourseEnrollments.Any(e => e.StudentId == studentId && e.Status == CourseEnrollmentStatus.Enrolled && e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)) - .OrderBy(x => x.StartsAt).Select(x => new + .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) + .Select(x => new { - x.Id, PlanName = x.ExamPlan!.Name, x.StartsAt, x.EndsAt, - x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, + x.Id, + PlanName = x.ExamPlan!.Name, + x.ExamDate, + x.StartPeriod, + x.PeriodCount, + x.StartsAt, + x.EndsAt, + x.TeachingTask!.TaskNumber, + CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, - ClassroomName = x.Classroom!.Name, - BuildingName = x.Classroom.Building!.Name, - InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name) + ClassroomName = x.Classroom != null ? x.Classroom.Name : null, + BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null, + InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name), + IsExam = true }) .ToListAsync(cancellationToken)); } @@ -202,57 +449,127 @@ public sealed class ExamsController( return Ok(await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && x.Invigilators.Any(i => i.Teacher!.UserId == scope.UserId)) - .OrderBy(x => x.StartsAt).Select(x => new + .OrderBy(x => x.ExamDate).ThenBy(x => x.StartPeriod) + .Select(x => new { - x.Id, PlanName = x.ExamPlan!.Name, x.StartsAt, x.EndsAt, - x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, + x.Id, + PlanName = x.ExamPlan!.Name, + x.ExamDate, + x.StartPeriod, + x.PeriodCount, + x.StartsAt, + x.EndsAt, + x.TeachingTask!.TaskNumber, + CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, - ClassroomName = x.Classroom!.Name, - BuildingName = x.Classroom.Building!.Name, + ClassroomName = x.Classroom != null ? x.Classroom.Name : null, + BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null, InvigilatorNames = x.Invigilators.Select(i => i.Teacher!.Name), StudentCount = db.CourseEnrollments.Count(e => e.Status == CourseEnrollmentStatus.Enrolled && - e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId) + e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId), + IsExam = true }) .ToListAsync(cancellationToken)); } return Ok(Array.Empty()); } + // ═══════════════════════════════════════════ + // Private helpers + // ═══════════════════════════════════════════ + + private (DateTime StartsAt, DateTime EndsAt, ActionResult? Error) ResolveExamTime( + Guid academicTermId, + DateOnly examDate, + int startPeriod, + int periodCount) + { + var slots = db.ScheduleTimeSlots.AsNoTracking() + .Where(x => x.AcademicTermId == academicTermId && x.IsEnabled) + .OrderBy(x => x.PeriodNumber) + .ToList(); + + if (slots.Count == 0) + return (default, default, + ValidationProblem("当前学期未配置上课时间表,请先在排课设置中配置节次时间。")); + + var slotDict = slots.ToDictionary(x => x.PeriodNumber); + var startSlot = slotDict.GetValueOrDefault(startPeriod); + var endSlot = slotDict.GetValueOrDefault(startPeriod + periodCount - 1); + + if (startSlot is null) + return (default, default, + ValidationProblem($"起始节次 {startPeriod} 不在已配置的时间表中。")); + if (endSlot is null) + return (default, default, + ValidationProblem( + $"结束节次 {startPeriod + periodCount - 1} 不在已配置的时间表中。")); + + return ( + examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc), + examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc), + null + ); + } + private async Task ValidateSessionAsync( ExamPlan plan, Guid? currentId, - ExamSessionRequest request, + Guid teachingTaskId, + Guid? classroomId, + IReadOnlyCollection? invigilatorIds, + DateTime startsAt, + DateTime endsAt, CancellationToken cancellationToken) { - var starts = request.StartsAt.ToUniversalTime(); - var ends = request.EndsAt.ToUniversalTime(); - if (starts >= ends) return ValidationProblem("考试开始时间必须早于结束时间。"); + if (startsAt >= endsAt) return ValidationProblem("考试开始时间必须早于结束时间。"); var task = await db.TeachingTasks.AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); + .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken); if (task is null || task.AcademicTermId != plan.AcademicTermId) return ValidationProblem("教学班与考试计划必须属于同一学期。"); - var room = await db.Classrooms.AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == request.ClassroomId && x.IsEnabled, cancellationToken); - if (room is null) return ValidationProblem("所选考场不存在或已停用。"); + var studentCount = await db.CourseEnrollments.CountAsync(x => x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.TeachingTaskId == task.Id, cancellationToken); - if (studentCount > room.Capacity) - return ConflictProblem($"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。"); - var teacherIds = request.InvigilatorIds.Distinct().ToArray(); - if (teacherIds.Length == 0) return ValidationProblem("至少安排一名监考教师。"); - if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) && - x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) - return ValidationProblem("存在无效监考教师。"); + + if (classroomId.HasValue) + { + var room = await db.Classrooms.AsNoTracking() + .FirstOrDefaultAsync( + x => x.Id == classroomId.Value && x.IsEnabled, cancellationToken); + if (room is null) return ValidationProblem("所选考场不存在或已停用。"); + if (studentCount > room.Capacity) + return ConflictProblem( + $"考场容量不足:需容纳 {studentCount} 人,教室容量为 {room.Capacity}。"); + } + + var teacherIds = (invigilatorIds ?? []).Distinct().ToArray(); + if (teacherIds.Length > 0) + { + if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) && + x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length) + return ValidationProblem("存在无效监考教师。"); + } var overlaps = db.ExamSessions.Where(x => x.ExamPlanId == plan.Id && - x.Id != currentId && x.StartsAt < ends && starts < x.EndsAt); - if (await overlaps.AnyAsync(x => x.ClassroomId == room.Id, cancellationToken)) - return ConflictProblem("该时段考场已被占用。"); - if (await overlaps.AnyAsync(x => - x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), cancellationToken)) - return ConflictProblem("监考教师在该时段已有考试任务。"); + x.Id != currentId && x.StartsAt < endsAt && startsAt < x.EndsAt); + + if (classroomId.HasValue) + { + if (await overlaps.AnyAsync( + x => x.ClassroomId == classroomId.Value, cancellationToken)) + return ConflictProblem("该时段考场已被占用。"); + } + + if (teacherIds.Length > 0) + { + if (await overlaps.AnyAsync(x => + x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)), + cancellationToken)) + return ConflictProblem("监考教师在该时段已有考试任务。"); + } + var studentIds = db.CourseEnrollments.Where(x => x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.TeachingTaskId == task.Id) @@ -271,7 +588,8 @@ public sealed class ExamsController( currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); - private async Task SaveAsync(Guid id, bool created, CancellationToken token) + private async Task SaveAsync(Guid id, bool created, + CancellationToken token) { try { @@ -286,22 +604,30 @@ public sealed class ExamsController( private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { - Title = "无法完成考务操作", Detail = detail, + Title = "无法完成考务操作", + Detail = detail, Status = StatusCodes.Status409Conflict }); private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } +// ═══════════════════════════════════════════ +// Request/Response models +// ═══════════════════════════════════════════ + public sealed record ExamPlanRequest( Guid AcademicTermId, [Required, MaxLength(120)] string Name, [MaxLength(500)] string? Notes); -public sealed record ExamSessionRequest( +public sealed record CreateExamSessionRequest( Guid TeachingTaskId, - Guid ClassroomId, - DateTime StartsAt, - DateTime EndsAt, - IReadOnlyCollection InvigilatorIds, + Guid? ClassroomId, + DateOnly ExamDate, + [Range(1, 30)] int StartPeriod, + [Range(1, 6)] int PeriodCount, + Guid? RequiredBuildingId, + [Range(1, 10)] int RequiredInvigilatorCount, + IReadOnlyCollection? InvigilatorIds, [MaxLength(500)] string? Notes); diff --git a/src/Jiaowu.Api/Controllers/GradesController.cs b/src/Jiaowu.Api/Controllers/GradesController.cs index b7ff903..a6087b0 100644 --- a/src/Jiaowu.Api/Controllers/GradesController.cs +++ b/src/Jiaowu.Api/Controllers/GradesController.cs @@ -176,7 +176,8 @@ public sealed class GradesController( TermName = x.TeachingTask.AcademicTerm!.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, - CollegeName = x.TeachingTask.Course.College!.Name, + CourseCollegeId = x.TeachingTask.Course.CollegeId, + CourseCollegeName = x.TeachingTask.Course.College!.Name, x.TeachingTask.Course.Credits, TeacherNames = x.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) @@ -231,13 +232,21 @@ public sealed class GradesController( .Include(x => x.Teachers) .ThenInclude(x => x.Teacher) .SingleAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken); + + // Determine if current user is from the course's college + var scope = currentUserDataScope.Current; + var isCourseCollegeReviewer = IsReviewer() && + (scope.Scope == DataScope.All || + scope.RestrictedCollegeId == sheet.CourseCollegeId); + return Ok(new { Sheet = sheet, CanEdit = CanEditScores(task) && sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned, - CanReview = IsReviewer() && sheet.Status == GradeSheetStatus.Submitted, - CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved + CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted, + CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved, + NeedsCollegeReview = sheet.CourseCollegeName }); } @@ -376,6 +385,11 @@ public sealed class GradesController( return ConflictProblem("只有草稿或已退回成绩单可以提交。"); if (sheet.Records.Count == 0) return ConflictProblem("成绩单没有学生记录。"); + + // Final recalculation pass before submit + foreach (var record in sheet.Records) + Recalculate(sheet, record); + if (sheet.Records.Any(x => x.ExamStatus == GradeExamStatus.Normal && !x.TotalScore.HasValue)) @@ -390,11 +404,22 @@ public sealed class GradesController( [Authorize(Roles = Reviewers)] public async Task Approve(Guid id, CancellationToken cancellationToken) { - var sheet = await AccessibleSheets().FirstOrDefaultAsync( - x => x.Id == id, - cancellationToken); + var sheet = await db.GradeSheets + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Course) + .ThenInclude(x => x!.College) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!IsReviewer()) return Forbid(); + + // College-specific check: CollegeAdmin must belong to the course's college + var scope = currentUserDataScope.Current; + if (scope.Scope == DataScope.College && + sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId) + return ConflictProblem( + $"该课程属于{sheet.TeachingTask.Course.College!.Name}," + + "您只能审核本学院课程的成绩单。"); + if (sheet.Status != GradeSheetStatus.Submitted) return ConflictProblem("只有待审核成绩单可以通过审核。"); sheet.Status = GradeSheetStatus.Approved; @@ -410,11 +435,22 @@ public sealed class GradesController( GradeReviewRequest request, CancellationToken cancellationToken) { - var sheet = await AccessibleSheets().FirstOrDefaultAsync( - x => x.Id == id, - cancellationToken); + var sheet = await db.GradeSheets + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Course) + .ThenInclude(x => x!.College) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!IsReviewer()) return Forbid(); + + // College-specific check + var scope = currentUserDataScope.Current; + if (scope.Scope == DataScope.College && + sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId) + return ConflictProblem( + $"该课程属于{sheet.TeachingTask.Course.College!.Name}," + + "您只能审核本学院课程的成绩单。"); + if (sheet.Status != GradeSheetStatus.Submitted) return ConflictProblem("只有待审核成绩单可以退回。"); if (string.IsNullOrWhiteSpace(request.Comment)) diff --git a/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs b/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs index 4df4e72..0a8be26 100644 --- a/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs @@ -19,10 +19,16 @@ public sealed class ExamSession : EntityBase public ExamPlan? ExamPlan { get; set; } public Guid TeachingTaskId { get; set; } public TeachingTask? TeachingTask { get; set; } - public Guid ClassroomId { get; set; } + public Guid? ClassroomId { get; set; } public Classroom? Classroom { get; set; } + public DateOnly ExamDate { get; set; } + public int StartPeriod { get; set; } + public int PeriodCount { get; set; } = 2; public DateTime StartsAt { get; set; } public DateTime EndsAt { get; set; } + public Guid? RequiredBuildingId { get; set; } + public Building? RequiredBuilding { get; set; } + public int RequiredInvigilatorCount { get; set; } = 2; public string? Notes { get; set; } public ICollection Invigilators { get; set; } = []; } diff --git a/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs b/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs new file mode 100644 index 0000000..bb98eb0 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs @@ -0,0 +1,227 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +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); + + public async Task ArrangeAsync( + Guid planId, + CancellationToken cancellationToken) + { + var plan = await db.ExamPlans + .Include(x => x.AcademicTerm) + .Include(x => x.Sessions) + .ThenInclude(x => x.Invigilators) + .Include(x => x.Sessions) + .ThenInclude(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken); + + if (plan is null) + return ExamArrangementResult.Fail("考试计划不存在。"); + if (plan.Status != ExamPlanStatus.Draft) + return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。"); + + var sessions = plan.Sessions.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) + .OrderBy(x => x.PeriodNumber) + .ToListAsync(cancellationToken); + + if (timeSlots.Count == 0) + return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。"); + + var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber); + + int assignedRooms = 0; + int assignedInvigilators = 0; + int skippedRooms = 0; + int skippedInvigilators = 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 + ComputeTimesFromSlots(session, timeSlotLookup); + var studentCount = await db.CourseEnrollments.CountAsync( + x => x.Status == CourseEnrollmentStatus.Enrolled && + x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId, + cancellationToken); + + // ── Auto-assign classroom ── + if (!session.ClassroomId.HasValue) + { + var room = await FindBestClassroomAsync( + session, studentCount, occupiedRooms, cancellationToken); + if (room is not null) + { + 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 + { + skippedRooms++; + messages.Add( + $"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)"); + } + } + else + { + skippedRooms++; + } + + // ── Auto-assign invigilators ── + var currentInvigilatorCount = session.Invigilators.Count; + var needed = session.RequiredInvigilatorCount - currentInvigilatorCount; + if (needed > 0) + { + var courseTeacherIds = session.TeachingTask!.Teachers + .Select(x => x.TeacherId).ToHashSet(); + var newlyAssigned = await FindInvigilatorsAsync( + session, needed, courseTeacherIds, + occupiedInvigilators, cancellationToken); + foreach (var teacher in newlyAssigned) + { + session.Invigilators.Add(new ExamSessionInvigilator + { + ExamSessionId = session.Id, + TeacherId = teacher.Id + }); + occupiedInvigilators.Add(new InvigilatorOccupancy( + teacher.Id, session.StartsAt, session.EndsAt)); + assignedInvigilators++; + } + + if (newlyAssigned.Count < needed) + messages.Add( + $"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师"); + } + else + { + skippedInvigilators++; + } + } + + await db.SaveChangesAsync(cancellationToken); + + return new ExamArrangementResult( + true, + $"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" + + (skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") + + (messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : "")); + } + + private static void ComputeTimesFromSlots( + ExamSession session, + Dictionary 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.Where(x => !occupiedRoomIds.Contains(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.Where(x => !dbOccupiedRooms.Contains(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); + + return await db.Teachers.AsNoTracking() + .Where(x => x.Status == TeacherStatus.Active && + !busyTeacherIds.Contains(x.Id)) + .OrderBy(x => Guid.NewGuid()) + .Take(needed) + .ToListAsync(cancellationToken); + } +} + +public sealed record ExamArrangementResult(bool Success, string Message) +{ + public static ExamArrangementResult Fail(string message) => new(false, message); +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 1051df5..58fb2ce 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -564,13 +564,16 @@ public sealed class AppDbContext(DbContextOptions options) entity.Property(x => x.Notes).HasMaxLength(500); entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt }); entity.HasIndex(x => x.TeachingTaskId); - entity.HasIndex(x => x.ClassroomId); + entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate }); + entity.HasIndex(x => x.RequiredBuildingId); entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions) .HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade); entity.HasOne(x => x.TeachingTask).WithMany() .HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict); entity.HasOne(x => x.Classroom).WithMany() - .HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict); + .HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(x => x.RequiredBuilding).WithMany() + .HasForeignKey(x => x.RequiredBuildingId).OnDelete(DeleteBehavior.SetNull); }); builder.Entity(entity => { diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs index c9b39a6..0ca5380 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs @@ -583,8 +583,12 @@ public sealed class DatabaseInitializer( { TeachingTaskId = task.Id, ClassroomId = room.Id, + ExamDate = new DateOnly(2027, 1, 8), + StartPeriod = 1, + PeriodCount = 2, StartsAt = new DateTime(2027, 1, 8, 9, 0, 0, DateTimeKind.Utc), EndsAt = new DateTime(2027, 1, 8, 11, 0, 0, DateTimeKind.Utc), + RequiredInvigilatorCount = 2, Invigilators = [ new ExamSessionInvigilator { TeacherId = teacher.Id } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index ed7c8c2..2ff166c 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -32,6 +32,8 @@ public sealed class DevelopmentSqliteMigrator( "20260725_18_schedule_publish_jobs"; private const string FlexibleGradesMigration = "20260725_19_flexible_grades"; + private const string ExamSchedulingOptimizationMigration = + "20260725_20_exam_scheduling_optimization"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -208,6 +210,21 @@ public sealed class DevelopmentSqliteMigrator( AttendanceMigration, attendanceSheetsExist ? [] : AttendanceStatements, cancellationToken); + + var examSchedulingOptimizationExists = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM pragma_table_info('ExamSessions') + WHERE name = 'ExamDate' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + ExamSchedulingOptimizationMigration, + examSchedulingOptimizationExists + ? [] + : ExamSchedulingOptimizationStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -1367,4 +1384,81 @@ public sealed class DevelopmentSqliteMigrator( ON "SchedulePublishJobs" ("RequestedByUserId"); """ ]; + + private static readonly string[] ExamSchedulingOptimizationStatements = + [ + """ + ALTER TABLE "ExamSessions" ADD COLUMN "ExamDate" TEXT NOT NULL DEFAULT '2027-01-01'; + """, + """ + ALTER TABLE "ExamSessions" ADD COLUMN "StartPeriod" INTEGER NOT NULL DEFAULT 1; + """, + """ + ALTER TABLE "ExamSessions" ADD COLUMN "PeriodCount" INTEGER NOT NULL DEFAULT 2; + """, + """ + ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingId" TEXT NULL; + """, + """ + ALTER TABLE "ExamSessions" ADD COLUMN "RequiredInvigilatorCount" INTEGER NOT NULL DEFAULT 2; + """, + """ + CREATE TABLE "ExamSessions_Temp" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ExamSessions" PRIMARY KEY, + "ExamPlanId" TEXT NOT NULL, + "TeachingTaskId" TEXT NOT NULL, + "ClassroomId" TEXT NULL, + "ExamDate" TEXT NOT NULL, + "StartPeriod" INTEGER NOT NULL, + "PeriodCount" INTEGER NOT NULL, + "StartsAt" TEXT NOT NULL, + "EndsAt" TEXT NOT NULL, + "RequiredBuildingId" TEXT NULL, + "RequiredInvigilatorCount" INTEGER NOT NULL, + "Notes" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_ExamSessions_ExamPlans_ExamPlanId" + FOREIGN KEY ("ExamPlanId") REFERENCES "ExamPlans" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ExamSessions_TeachingTasks_TeachingTaskId" + FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_ExamSessions_Classrooms_ClassroomId" + FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL, + CONSTRAINT "FK_ExamSessions_Buildings_RequiredBuildingId" + FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL + ); + """, + """ + INSERT INTO "ExamSessions_Temp" + ("Id","ExamPlanId","TeachingTaskId","ClassroomId","ExamDate", + "StartPeriod","PeriodCount","StartsAt","EndsAt", + "RequiredBuildingId","RequiredInvigilatorCount", + "Notes","CreatedAt","UpdatedAt") + SELECT "Id","ExamPlanId","TeachingTaskId","ClassroomId", + '2027-01-01', + 1,2, + "StartsAt","EndsAt", + NULL,2, + "Notes","CreatedAt","UpdatedAt" + FROM "ExamSessions"; + """, + "DROP TABLE \"ExamSessions\";", + "ALTER TABLE \"ExamSessions_Temp\" RENAME TO \"ExamSessions\";", + """ + CREATE INDEX "IX_ExamSessions_ExamPlanId_StartsAt" + ON "ExamSessions" ("ExamPlanId", "StartsAt"); + """, + """ + CREATE INDEX "IX_ExamSessions_TeachingTaskId" + ON "ExamSessions" ("TeachingTaskId"); + """, + """ + CREATE INDEX "IX_ExamSessions_ExamPlanId_ExamDate" + ON "ExamSessions" ("ExamPlanId", "ExamDate"); + """, + """ + CREATE INDEX "IX_ExamSessions_RequiredBuildingId" + ON "ExamSessions" ("RequiredBuildingId"); + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725100000_ExamSchedulingOptimization.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725100000_ExamSchedulingOptimization.cs new file mode 100644 index 0000000..714daee --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725100000_ExamSchedulingOptimization.cs @@ -0,0 +1,158 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class ExamSchedulingOptimization : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Drop old FK/index on ClassroomId + migrationBuilder.DropForeignKey( + name: "FK_ExamSessions_Classrooms_ClassroomId", + table: "ExamSessions"); + + migrationBuilder.DropIndex( + name: "IX_ExamSessions_ClassroomId", + table: "ExamSessions"); + + // Make ClassroomId nullable + migrationBuilder.AlterColumn( + name: "ClassroomId", + table: "ExamSessions", + type: "char(36)", + nullable: true, + oldClrType: typeof(Guid), + oldType: "char(36)"); + + // Add new columns + migrationBuilder.AddColumn( + name: "ExamDate", + table: "ExamSessions", + type: "date", + nullable: false, + defaultValue: new DateOnly(2027, 1, 1)); + + migrationBuilder.AddColumn( + name: "StartPeriod", + table: "ExamSessions", + type: "int", + nullable: false, + defaultValue: 1); + + migrationBuilder.AddColumn( + name: "PeriodCount", + table: "ExamSessions", + type: "int", + nullable: false, + defaultValue: 2); + + migrationBuilder.AddColumn( + name: "RequiredBuildingId", + table: "ExamSessions", + type: "char(36)", + nullable: true); + + migrationBuilder.AddColumn( + name: "RequiredInvigilatorCount", + table: "ExamSessions", + type: "int", + nullable: false, + defaultValue: 2); + + // Re-add FK/index on ClassroomId (nullable, SetNull) + migrationBuilder.CreateIndex( + name: "IX_ExamSessions_ClassroomId", + table: "ExamSessions", + column: "ClassroomId"); + + migrationBuilder.AddForeignKey( + name: "FK_ExamSessions_Classrooms_ClassroomId", + table: "ExamSessions", + column: "ClassroomId", + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + // New indices + migrationBuilder.CreateIndex( + name: "IX_ExamSessions_ExamPlanId_ExamDate", + table: "ExamSessions", + columns: new[] { "ExamPlanId", "ExamDate" }); + + migrationBuilder.CreateIndex( + name: "IX_ExamSessions_RequiredBuildingId", + table: "ExamSessions", + column: "RequiredBuildingId"); + + migrationBuilder.AddForeignKey( + name: "FK_ExamSessions_Buildings_RequiredBuildingId", + table: "ExamSessions", + column: "RequiredBuildingId", + principalTable: "Buildings", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Remove new FK/index + migrationBuilder.DropForeignKey( + name: "FK_ExamSessions_Buildings_RequiredBuildingId", + table: "ExamSessions"); + + migrationBuilder.DropForeignKey( + name: "FK_ExamSessions_Classrooms_ClassroomId", + table: "ExamSessions"); + + migrationBuilder.DropIndex( + name: "IX_ExamSessions_ClassroomId", + table: "ExamSessions"); + + migrationBuilder.DropIndex( + name: "IX_ExamSessions_ExamPlanId_ExamDate", + table: "ExamSessions"); + + migrationBuilder.DropIndex( + name: "IX_ExamSessions_RequiredBuildingId", + table: "ExamSessions"); + + // Drop new columns + migrationBuilder.DropColumn(name: "RequiredInvigilatorCount", table: "ExamSessions"); + migrationBuilder.DropColumn(name: "RequiredBuildingId", table: "ExamSessions"); + migrationBuilder.DropColumn(name: "PeriodCount", table: "ExamSessions"); + migrationBuilder.DropColumn(name: "StartPeriod", table: "ExamSessions"); + migrationBuilder.DropColumn(name: "ExamDate", table: "ExamSessions"); + + // Restore ClassroomId to non-nullable + migrationBuilder.AlterColumn( + name: "ClassroomId", + table: "ExamSessions", + type: "char(36)", + nullable: false, + defaultValue: Guid.Empty, + oldClrType: typeof(Guid), + oldType: "char(36)", + oldNullable: true); + + // Restore original FK/index + migrationBuilder.CreateIndex( + name: "IX_ExamSessions_ClassroomId", + table: "ExamSessions", + column: "ClassroomId"); + + migrationBuilder.AddForeignKey( + name: "FK_ExamSessions_Classrooms_ClassroomId", + table: "ExamSessions", + column: "ClassroomId", + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs index 6275cab..e82b376 100644 --- a/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs +++ b/src/Jiaowu.Api/Infrastructure/Timetables/TimetableDataService.cs @@ -109,6 +109,11 @@ public sealed class TimetableDataService(AppDbContext db) term.Id, studentId, cancellationToken); + + // Load exam sessions for student/teacher timetables + var examEntries = await LoadExamEntriesAsync( + resourceType, resourceId, term.Id, studentId, slots, cancellationToken); + return new TimetableData( term, subject, @@ -117,7 +122,8 @@ public sealed class TimetableDataService(AppDbContext db) plan, slots, entries, - flexibleCourses); + flexibleCourses, + examEntries); } private async Task LoadSubjectAsync( @@ -286,6 +292,101 @@ public sealed class TimetableDataService(AppDbContext db) x.Notes)) .ToListAsync(cancellationToken); } + + private async Task> LoadExamEntriesAsync( + TimetableResourceType resourceType, + Guid resourceId, + Guid academicTermId, + Guid? studentId, + IReadOnlyList slots, + CancellationToken cancellationToken) + { + if (resourceType == TimetableResourceType.Classroom) return []; + + var slotLookup = slots.ToDictionary(x => x.PeriodNumber); + + IQueryable source = db.ExamSessions.AsNoTracking() + .Where(x => x.ExamPlan!.AcademicTermId == academicTermId && + x.ExamPlan.Status == ExamPlanStatus.Published && + x.ClassroomId != null); + + if (resourceType == TimetableResourceType.Teacher) + { + source = source.Where(x => + x.Invigilators.Any(i => i.TeacherId == resourceId)); + } + else if (studentId.HasValue) + { + source = source.Where(x => + db.CourseEnrollments.Any(e => + e.StudentId == studentId.Value && + e.Status == CourseEnrollmentStatus.Enrolled && + e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId)); + } + else + { + // Class timetable: exams for the class's teaching tasks + source = source.Where(x => + x.TeachingTask!.Classes.Any(c => + c.AdministrativeClassId == resourceId)); + } + + var sessions = await source + .OrderBy(x => x.ExamDate) + .ThenBy(x => x.StartPeriod) + .Select(x => new + { + x.Id, + x.TeachingTaskId, + x.TeachingTask!.TaskNumber, + TaskName = x.TeachingTask.Name, + CourseCode = x.TeachingTask.Course!.Code, + CourseName = x.TeachingTask.Course.Name, + TeacherNames = x.TeachingTask.Teachers + .OrderByDescending(t => t.IsPrimary) + .Select(t => t.Teacher!.Name), + ClassNames = x.TeachingTask.Classes + .Select(c => c.AdministrativeClass!.Name), + ClassroomName = x.Classroom!.Name, + BuildingName = x.Classroom.Building!.Name, + CampusName = x.Classroom.Building.Campus!.Name, + x.ExamDate, + x.StartPeriod, + x.PeriodCount, + x.ExamPlan!.Name, + InvigilatorNames = x.Invigilators + .Select(i => i.Teacher!.Name), + x.Notes + }) + .ToListAsync(cancellationToken); + + return sessions.Select(x => + { + var dayOfWeek = x.ExamDate.DayOfWeek == 0 ? 7 : (int)x.ExamDate.DayOfWeek; + return new TimetableEntryDto( + x.Id, + x.TeachingTaskId, + x.TaskNumber, + x.TaskName, + x.CourseCode, + x.CourseName, + x.TeacherNames.Concat( + new[] { "监考:" + string.Join("、", x.InvigilatorNames) }), + x.ClassNames, + x.ClassroomName, + x.BuildingName, + x.CampusName, + dayOfWeek, + x.StartPeriod, + x.PeriodCount, + 1, 1, + WeekPattern.All, + x.Notes, + true, + x.Name, + x.ExamDate); + }).ToList(); + } } public enum TimetableResourceType @@ -303,7 +404,8 @@ public sealed record TimetableData( TimetablePlanDto? Plan, IReadOnlyList Slots, IReadOnlyList Entries, - IReadOnlyList FlexibleCourses); + IReadOnlyList FlexibleCourses, + IReadOnlyList ExamEntries); public sealed record TimetableTermDto( Guid Id, @@ -362,7 +464,10 @@ public sealed record TimetableEntryDto( int StartWeek, int EndWeek, WeekPattern WeekPattern, - string? Notes); + string? Notes, + bool IsExam = false, + string? ExamPlanName = null, + DateOnly? ExamDate = null); public sealed record FlexibleCourseDto( Guid Id, diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 330c783..08bc8f4 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -2,6 +2,7 @@ using System.Text; using System.Text.Json.Serialization; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Middleware; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; @@ -100,6 +101,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); +builder.Services.AddScoped(); builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) diff --git a/web/src/views/ExamsView.vue b/web/src/views/ExamsView.vue index f91801d..215b437 100644 --- a/web/src/views/ExamsView.vue +++ b/web/src/views/ExamsView.vue @@ -1,6 +1,6 @@