diff --git a/src/Jiaowu.Api/Controllers/ExamsController.cs b/src/Jiaowu.Api/Controllers/ExamsController.cs index aac1ad3..31ffa20 100644 --- a/src/Jiaowu.Api/Controllers/ExamsController.cs +++ b/src/Jiaowu.Api/Controllers/ExamsController.cs @@ -169,6 +169,95 @@ public sealed class ExamsController( return await SaveAsync(session.Id, true, cancellationToken); } + [HttpPost("plans/{planId:guid}/sessions/batch")] + [Authorize(Roles = Managers)] + public async Task CreateSessionsBatch( + Guid planId, + CreateExamSessionsBatchRequest 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 taskIds = request.TeachingTaskIds.Distinct().ToArray(); + if (taskIds.Length == 0) + return ValidationProblem("请至少选择一个教学班。"); + if (taskIds.Length > 100) + return ValidationProblem("一次最多选择100个教学班。"); + + 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 validTaskIds = await db.TeachingTasks.AsNoTracking() + .Where(x => x.AcademicTermId == plan.AcademicTermId && + x.Status == TeachingTaskStatus.Published) + .WhereIn(taskIds, x => x.Id) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + if (validTaskIds.Count != taskIds.Length) + return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。"); + + var duplicateTaskIds = await db.ExamSessions.AsNoTracking() + .Where(x => x.ExamPlanId == planId) + .WhereIn(taskIds, x => x.TeachingTaskId) + .Select(x => x.TeachingTaskId) + .ToListAsync(cancellationToken); + if (duplicateTaskIds.Count > 0) + return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前计划中安排考试。"); + + var enrollmentPairs = await db.CourseEnrollments.AsNoTracking() + .Where(x => x.Status == CourseEnrollmentStatus.Enrolled) + .WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId) + .Select(x => new + { + x.StudentId, + x.CourseSelectionOffering!.TeachingTaskId + }) + .ToListAsync(cancellationToken); + var conflictedStudentCount = enrollmentPairs + .GroupBy(x => x.StudentId) + .Count(group => group.Select(x => x.TeachingTaskId).Distinct().Skip(1).Any()); + if (conflictedStudentCount > 0) + return ConflictProblem( + $"所选教学班在同一时段安排会造成 {conflictedStudentCount} 名学生考试冲突,请分批选择不同时间。"); + + foreach (var taskId in taskIds) + { + var validation = await ValidateSessionAsync(plan, null, + taskId, null, null, startsAt, endsAt, cancellationToken); + if (validation is not null) return validation; + } + + db.ExamSessions.AddRange(taskIds.Select(taskId => new ExamSession + { + ExamPlanId = planId, + TeachingTaskId = taskId, + ExamDate = request.ExamDate, + StartPeriod = request.StartPeriod, + PeriodCount = request.PeriodCount, + StartsAt = startsAt, + EndsAt = endsAt, + RequiredBuildingId = request.RequiredBuildingId, + RequiredInvigilatorCount = request.RequiredInvigilatorCount, + Notes = Normalize(request.Notes) + })); + + try + { + await db.SaveChangesAsync(cancellationToken); + await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken); + return Ok(new { createdCount = taskIds.Length }); + } + catch (DbUpdateException) + { + return ConflictProblem("批量创建考试场次失败,关联数据可能已发生变化。"); + } + } + [HttpPut("plans/{planId:guid}/sessions/{id:guid}")] [Authorize(Roles = Managers)] public async Task UpdateSession( @@ -237,11 +326,19 @@ public sealed class ExamsController( [Authorize(Roles = Managers)] public async Task AutoArrange( Guid planId, + ExamAutoArrangeRequest? request, CancellationToken cancellationToken) { - var result = await examArrangementService.ArrangeAsync(planId, cancellationToken); + request ??= new ExamAutoArrangeRequest(); + var result = await examArrangementService.ArrangeAsync( + planId, + request.SessionIds, + request.AssignClassrooms, + request.AssignInvigilators, + cancellationToken); if (!result.Success) return ConflictProblem(result.Message); + await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken); return Ok(new { message = result.Message }); } @@ -404,20 +501,40 @@ public sealed class ExamsController( x.TeachingTask!.TaskNumber, CourseName = x.TeachingTask.Course!.Name, 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, - ClassName = e.Student.AdministrativeClass!.Name - }).ToList() + x.StartsAt }).FirstOrDefaultAsync(cancellationToken); - return session is null ? NotFound() : Ok(session); + if (session is null) return NotFound(); + + var students = await db.CourseEnrollments.AsNoTracking() + .Where(e => e.Status == CourseEnrollmentStatus.Enrolled && + e.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId) + .OrderBy(e => e.Student!.StudentNumber) + .Select(e => new + { + e.StudentId, + e.Student!.StudentNumber, + e.Student.Name, + ClassName = e.Student.AdministrativeClass!.Name + }) + .ToListAsync(cancellationToken); + + return Ok(new + { + session.Id, + session.TeachingTaskId, + session.TaskNumber, + session.CourseName, + session.ClassroomName, + session.StartsAt, + Students = students.Select((student, index) => new + { + student.StudentId, + student.StudentNumber, + student.Name, + student.ClassName, + SeatNumber = (index + 1).ToString("D3") + }) + }); } // ═══════════════════════════════════════════ @@ -434,7 +551,7 @@ public sealed class ExamsController( .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); - return Ok(await db.ExamSessions.AsNoTracking() + var schedule = await db.ExamSessions.AsNoTracking() .Where(x => x.ExamPlan!.Status == ExamPlanStatus.Published && db.CourseEnrollments.Any(e => e.StudentId == studentId && e.Status == CourseEnrollmentStatus.Enrolled && @@ -455,9 +572,52 @@ public sealed class ExamsController( 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), + x.TeachingTaskId, IsExam = true }) - .ToListAsync(cancellationToken)); + .ToListAsync(cancellationToken); + if (schedule.Count == 0) return Ok(schedule); + + var taskIds = schedule.Select(x => x.TeachingTaskId).Distinct().ToArray(); + var seatRows = await db.CourseEnrollments.AsNoTracking() + .Where(x => x.Status == CourseEnrollmentStatus.Enrolled) + .WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId) + .OrderBy(x => x.Student!.StudentNumber) + .Select(x => new + { + x.CourseSelectionOffering!.TeachingTaskId, + x.StudentId, + x.Student!.StudentNumber + }) + .ToListAsync(cancellationToken); + var seatNumbers = seatRows + .GroupBy(x => x.TeachingTaskId) + .SelectMany(group => group.Select((row, index) => new + { + row.TeachingTaskId, + row.StudentId, + SeatNumber = (index + 1).ToString("D3") + })) + .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 + })); } if (scope.IsInRole(SystemRoles.Teacher)) { @@ -650,3 +810,17 @@ public sealed record CreateExamSessionRequest( [Range(1, 10)] int RequiredInvigilatorCount, IReadOnlyCollection? InvigilatorIds, [MaxLength(500)] string? Notes); + +public sealed record CreateExamSessionsBatchRequest( + [Required] IReadOnlyCollection TeachingTaskIds, + DateOnly ExamDate, + [Range(1, 30)] int StartPeriod, + [Range(1, 6)] int PeriodCount, + Guid? RequiredBuildingId, + [Range(1, 10)] int RequiredInvigilatorCount, + [MaxLength(500)] string? Notes); + +public sealed record ExamAutoArrangeRequest( + IReadOnlyCollection? SessionIds = null, + bool AssignClassrooms = true, + bool AssignInvigilators = true); diff --git a/src/Jiaowu.Api/Controllers/MakeupExamsController.cs b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs index fad9d7f..b9b1e23 100644 --- a/src/Jiaowu.Api/Controllers/MakeupExamsController.cs +++ b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs @@ -224,6 +224,78 @@ public sealed class MakeupExamsController( return await SaveAsync(session.Id, true, cancellationToken); } + [HttpPost("plans/{planId:guid}/sessions/batch")] + [Authorize(Roles = Managers)] + public async Task CreateSessionsBatch( + Guid planId, + CreateMakeupExamSessionsBatchRequest request, + CancellationToken cancellationToken) + { + var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != MakeupExamPlanStatus.Draft) + return ConflictProblem("已发布的补考计划不能调整场次。"); + + var taskIds = request.TeachingTaskIds.Distinct().ToArray(); + if (taskIds.Length == 0) + return ValidationProblem("请至少选择一个教学班。"); + if (taskIds.Length > 100) + return ValidationProblem("一次最多选择100个教学班。"); + + 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 validTaskIds = await db.TeachingTasks.AsNoTracking() + .Where(x => x.AcademicTermId == plan.AcademicTermId && + x.Status == TeachingTaskStatus.Published) + .WhereIn(taskIds, x => x.Id) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + if (validTaskIds.Count != taskIds.Length) + return ValidationProblem("所选教学班包含不存在、跨学期或未发布的教学班。"); + + var duplicateTaskIds = await db.MakeupExamSessions.AsNoTracking() + .Where(x => x.MakeupExamPlanId == planId) + .WhereIn(taskIds, x => x.TeachingTaskId) + .Select(x => x.TeachingTaskId) + .ToListAsync(cancellationToken); + if (duplicateTaskIds.Count > 0) + return ConflictProblem($"所选教学班中有 {duplicateTaskIds.Count} 个已在当前补考计划中安排。"); + + foreach (var taskId in taskIds) + { + var validation = await ValidateSessionAsync(plan, null, + taskId, null, null, startsAt, endsAt, cancellationToken); + if (validation is not null) return validation; + } + + db.MakeupExamSessions.AddRange(taskIds.Select(taskId => new MakeupExamSession + { + MakeupExamPlanId = planId, + TeachingTaskId = taskId, + ExamDate = request.ExamDate, + StartPeriod = request.StartPeriod, + PeriodCount = request.PeriodCount, + StartsAt = startsAt, + EndsAt = endsAt, + RequiredBuildingId = request.RequiredBuildingId, + RequiredInvigilatorCount = request.RequiredInvigilatorCount, + Notes = Normalize(request.Notes) + })); + + try + { + await db.SaveChangesAsync(cancellationToken); + return Ok(new { createdCount = taskIds.Length }); + } + catch (DbUpdateException) + { + return ConflictProblem("批量创建补考场次失败,关联数据可能已发生变化。"); + } + } + [HttpPut("plans/{planId:guid}/sessions/{id:guid}")] [Authorize(Roles = Managers)] public async Task UpdateSession( @@ -292,9 +364,16 @@ public sealed class MakeupExamsController( [Authorize(Roles = Managers)] public async Task AutoArrange( Guid planId, + ExamAutoArrangeRequest? request, CancellationToken cancellationToken) { - var result = await arrangementService.ArrangeAsync(planId, cancellationToken); + request ??= new ExamAutoArrangeRequest(); + var result = await arrangementService.ArrangeAsync( + planId, + request.SessionIds, + request.AssignClassrooms, + request.AssignInvigilators, + cancellationToken); if (!result.Success) return ConflictProblem(result.Message); return Ok(new { message = result.Message }); @@ -555,19 +634,43 @@ public sealed class MakeupExamsController( x.TeachingTask!.TaskNumber, CourseName = x.TeachingTask.Course!.Name, ClassroomName = x.Classroom != null ? x.Classroom.Name : "待分配", - x.StartsAt, - Students = x.Enrollments.OrderBy(e => e.Student!.StudentNumber) - .Select(e => new - { - e.StudentId, - e.Student!.StudentNumber, - e.Student.Name, - ClassName = e.Student.AdministrativeClass!.Name, - e.Reason, - e.MakeupScore - }).ToList() + x.StartsAt }).FirstOrDefaultAsync(cancellationToken); - return session is null ? NotFound() : Ok(session); + if (session is null) return NotFound(); + + var students = await db.MakeupExamEnrollments.AsNoTracking() + .Where(e => e.MakeupExamSessionId == id) + .OrderBy(e => e.Student!.StudentNumber) + .Select(e => new + { + e.StudentId, + e.Student!.StudentNumber, + e.Student.Name, + ClassName = e.Student.AdministrativeClass!.Name, + e.Reason, + e.MakeupScore + }) + .ToListAsync(cancellationToken); + + return Ok(new + { + session.Id, + session.TeachingTaskId, + session.TaskNumber, + session.CourseName, + session.ClassroomName, + session.StartsAt, + Students = students.Select((student, index) => new + { + student.StudentId, + student.StudentNumber, + student.Name, + student.ClassName, + student.Reason, + student.MakeupScore, + SeatNumber = (index + 1).ToString("D3") + }) + }); } // ═══════════════════════════════════════════ @@ -699,7 +802,7 @@ public sealed class MakeupExamsController( .Select(x => (Guid?)x.Id).FirstOrDefaultAsync(cancellationToken); if (!studentId.HasValue) return ConflictProblem("当前账号未关联学生档案。"); - return Ok(await db.MakeupExamEnrollments.AsNoTracking() + var schedule = await db.MakeupExamEnrollments.AsNoTracking() .Where(x => x.StudentId == studentId && x.MakeupExamSession!.MakeupExamPlan!.Status == MakeupExamPlanStatus.Published) .OrderBy(x => x.MakeupExamSession!.ExamDate) @@ -724,7 +827,49 @@ public sealed class MakeupExamsController( x.MakeupScore, IsMakeup = true }) - .ToListAsync(cancellationToken)); + .ToListAsync(cancellationToken); + if (schedule.Count == 0) return Ok(schedule); + + var sessionIds = schedule.Select(x => x.Id).Distinct().ToArray(); + var seatRows = await db.MakeupExamEnrollments.AsNoTracking() + .WhereIn(sessionIds, x => x.MakeupExamSessionId) + .OrderBy(x => x.Student!.StudentNumber) + .Select(x => new + { + x.MakeupExamSessionId, + x.StudentId, + x.Student!.StudentNumber + }) + .ToListAsync(cancellationToken); + var seatNumbers = seatRows + .GroupBy(x => x.MakeupExamSessionId) + .SelectMany(group => group.Select((row, index) => new + { + row.MakeupExamSessionId, + row.StudentId, + SeatNumber = (index + 1).ToString("D3") + })) + .ToDictionary(x => (x.MakeupExamSessionId, 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.Reason, + x.MakeupScore, + SeatNumber = seatNumbers.GetValueOrDefault((x.Id, studentId.Value)), + x.IsMakeup + })); } if (scope.IsInRole(SystemRoles.Teacher)) { @@ -941,6 +1086,15 @@ public sealed record CreateMakeupExamSessionRequest( IReadOnlyCollection? InvigilatorIds, [MaxLength(500)] string? Notes); +public sealed record CreateMakeupExamSessionsBatchRequest( + [Required] IReadOnlyCollection TeachingTaskIds, + DateOnly ExamDate, + [Range(1, 30)] int StartPeriod, + [Range(1, 6)] int PeriodCount, + Guid? RequiredBuildingId, + [Range(1, 10)] int RequiredInvigilatorCount, + [MaxLength(500)] string? Notes); + public sealed record EnrollStudentsRequest( [Required] IReadOnlyCollection StudentIds); diff --git a/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs b/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs index 5095d2e..9075af5 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/ExamArrangementService.cs @@ -11,8 +11,14 @@ public sealed class ExamArrangementService(AppDbContext db) public async Task ArrangeAsync( Guid planId, + IReadOnlyCollection? requestedSessionIds, + bool assignClassrooms, + bool assignInvigilators, CancellationToken cancellationToken) { + if (!assignClassrooms && !assignInvigilators) + return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。"); + var plan = await db.ExamPlans .Include(x => x.AcademicTerm) .Include(x => x.Sessions) @@ -27,9 +33,23 @@ public sealed class ExamArrangementService(AppDbContext db) if (plan.Status != ExamPlanStatus.Draft) return ExamArrangementResult.Fail("只有草稿状态的考试计划可以自动编排。"); - var sessions = plan.Sessions.ToList(); + var requestedIds = (requestedSessionIds ?? []) + .Distinct() + .ToHashSet(); + if (requestedIds.Count > 100) + return ExamArrangementResult.Fail("一次最多处理100个考试场次。"); + if (requestedIds.Count > 0 && + 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("考试计划中没有场次。"); + return ExamArrangementResult.Fail("没有可处理的考试场次。"); var termId = plan.AcademicTermId; var timeSlots = await db.ScheduleTimeSlots.AsNoTracking() @@ -44,8 +64,8 @@ public sealed class ExamArrangementService(AppDbContext db) int assignedRooms = 0; int assignedInvigilators = 0; - int skippedRooms = 0; - int skippedInvigilators = 0; + int unavailableRooms = 0; + int unavailableInvigilators = 0; var messages = new List(); // Track occupied time slots to avoid conflicts @@ -69,7 +89,7 @@ public sealed class ExamArrangementService(AppDbContext db) cancellationToken); // ── Auto-assign classroom ── - if (!session.ClassroomId.HasValue) + if (assignClassrooms && !session.ClassroomId.HasValue) { var room = await FindBestClassroomAsync( session, studentCount, occupiedRooms, cancellationToken); @@ -83,20 +103,15 @@ public sealed class ExamArrangementService(AppDbContext db) } else { - skippedRooms++; + unavailableRooms++; 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) + if (assignInvigilators && needed > 0) { var courseTeacherIds = session.TeachingTask!.Teachers .Select(x => x.TeacherId).ToHashSet(); @@ -116,12 +131,11 @@ public sealed class ExamArrangementService(AppDbContext db) } if (newlyAssigned.Count < needed) + { + unavailableInvigilators += needed - newlyAssigned.Count; messages.Add( $"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师"); - } - else - { - skippedInvigilators++; + } } } @@ -129,8 +143,9 @@ public sealed class ExamArrangementService(AppDbContext db) return new ExamArrangementResult( true, - $"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" + - (skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") + + $"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" + + (unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") + + (unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") + (messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : "")); } diff --git a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs index 36cce7c..5dd7275 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamArrangementService.cs @@ -11,8 +11,14 @@ public sealed class MakeupExamArrangementService(AppDbContext db) public async Task ArrangeAsync( Guid planId, + IReadOnlyCollection? requestedSessionIds, + bool assignClassrooms, + bool assignInvigilators, CancellationToken cancellationToken) { + if (!assignClassrooms && !assignInvigilators) + return ExamArrangementResult.Fail("请至少选择分配考场或分配监考教师。"); + var plan = await db.MakeupExamPlans .Include(x => x.AcademicTerm) .Include(x => x.Sessions) @@ -27,9 +33,23 @@ public sealed class MakeupExamArrangementService(AppDbContext db) if (plan.Status != MakeupExamPlanStatus.Draft) return ExamArrangementResult.Fail("只有草稿状态的补考计划可以自动编排。"); - var sessions = plan.Sessions.ToList(); + var requestedIds = (requestedSessionIds ?? []) + .Distinct() + .ToHashSet(); + if (requestedIds.Count > 100) + return ExamArrangementResult.Fail("一次最多处理100个补考场次。"); + if (requestedIds.Count > 0 && + 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("补考计划中没有场次。"); + return ExamArrangementResult.Fail("没有可处理的补考场次。"); var termId = plan.AcademicTermId; var timeSlots = await db.ScheduleTimeSlots.AsNoTracking() @@ -44,8 +64,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db) int assignedRooms = 0; int assignedInvigilators = 0; - int skippedRooms = 0; - int skippedInvigilators = 0; + int unavailableRooms = 0; + int unavailableInvigilators = 0; var messages = new List(); var occupiedRooms = sessions @@ -66,7 +86,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db) .CountAsync(x => x.MakeupExamSessionId == session.Id, cancellationToken); // Auto-assign classroom - if (!session.ClassroomId.HasValue) + if (assignClassrooms && !session.ClassroomId.HasValue) { var room = await FindBestClassroomAsync( session, enrolledCount, occupiedRooms, cancellationToken); @@ -80,20 +100,15 @@ public sealed class MakeupExamArrangementService(AppDbContext db) } else { - skippedRooms++; + unavailableRooms++; messages.Add( $"\"{session.TeachingTask!.Course!.Name}\":无可用考场(需≥{enrolledCount}座)"); } } - else - { - skippedRooms++; - } - // Auto-assign invigilators var currentInvigilatorCount = session.Invigilators.Count; var needed = session.RequiredInvigilatorCount - currentInvigilatorCount; - if (needed > 0) + if (assignInvigilators && needed > 0) { var courseTeacherIds = session.TeachingTask!.Teachers .Select(x => x.TeacherId).ToHashSet(); @@ -113,12 +128,11 @@ public sealed class MakeupExamArrangementService(AppDbContext db) } if (newlyAssigned.Count < needed) + { + unavailableInvigilators += needed - newlyAssigned.Count; messages.Add( $"\"{session.TeachingTask!.Course!.Name}\":仅找到{newlyAssigned.Count}/{needed}名监考教师"); - } - else - { - skippedInvigilators++; + } } } @@ -126,8 +140,9 @@ public sealed class MakeupExamArrangementService(AppDbContext db) return new ExamArrangementResult( true, - $"{assignedRooms}个考场、{assignedInvigilators}名监考已分配。" + - (skippedRooms > 0 ? $" {skippedRooms}个场次无可用考场。" : "") + + $"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" + + (unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") + + (unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") + (messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : "")); } diff --git a/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs b/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs new file mode 100644 index 0000000..71120e6 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/ExamArrangementServiceTests.cs @@ -0,0 +1,205 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Exams; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class ExamArrangementServiceTests +{ + [Fact] + public async Task Normal_arrangement_only_updates_selected_sessions_and_requested_resource() + { + 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, "NORMAL"); + + var plan = new ExamPlan + { + AcademicTermId = seed.Term.Id, + Name = "期末考试" + }; + var selected = NewExamSession(plan.Id, seed.FirstTask.Id); + var untouched = NewExamSession(plan.Id, seed.SecondTask.Id); + plan.Sessions.Add(selected); + plan.Sessions.Add(untouched); + db.ExamPlans.Add(plan); + await db.SaveChangesAsync(); + + var result = await new ExamArrangementService(db).ArrangeAsync( + plan.Id, + [selected.Id], + assignClassrooms: true, + assignInvigilators: false, + CancellationToken.None); + + Assert.True(result.Success); + Assert.NotNull(selected.ClassroomId); + Assert.Null(untouched.ClassroomId); + Assert.Empty(selected.Invigilators); + Assert.Contains("1个场次处理完成", result.Message); + } + + [Fact] + public async Task Makeup_arrangement_only_updates_selected_sessions_and_requested_resource() + { + 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, "MAKEUP"); + + var plan = new MakeupExamPlan + { + AcademicTermId = seed.Term.Id, + Name = "补考计划" + }; + var selected = NewMakeupExamSession(plan.Id, seed.FirstTask.Id); + var untouched = NewMakeupExamSession(plan.Id, seed.SecondTask.Id); + plan.Sessions.Add(selected); + plan.Sessions.Add(untouched); + db.MakeupExamPlans.Add(plan); + await db.SaveChangesAsync(); + + var result = await new MakeupExamArrangementService(db).ArrangeAsync( + plan.Id, + [selected.Id], + assignClassrooms: true, + assignInvigilators: false, + CancellationToken.None); + + Assert.True(result.Success); + Assert.NotNull(selected.ClassroomId); + Assert.Null(untouched.ClassroomId); + Assert.Empty(selected.Invigilators); + Assert.Contains("1个场次处理完成", result.Message); + } + + private static ExamSession NewExamSession(Guid planId, Guid taskId) => new() + { + ExamPlanId = planId, + TeachingTaskId = taskId, + ExamDate = new DateOnly(2026, 12, 28), + StartPeriod = 1, + PeriodCount = 2, + StartsAt = new DateTime(2026, 12, 28, 8, 0, 0, DateTimeKind.Utc), + EndsAt = new DateTime(2026, 12, 28, 9, 50, 0, DateTimeKind.Utc), + RequiredInvigilatorCount = 1 + }; + + private static MakeupExamSession NewMakeupExamSession(Guid planId, Guid taskId) => new() + { + MakeupExamPlanId = planId, + TeachingTaskId = taskId, + ExamDate = new DateOnly(2026, 12, 28), + StartPeriod = 1, + PeriodCount = 2, + StartsAt = new DateTime(2026, 12, 28, 8, 0, 0, DateTimeKind.Utc), + EndsAt = new DateTime(2026, 12, 28, 9, 50, 0, DateTimeKind.Utc), + RequiredInvigilatorCount = 1 + }; + + private static async Task SeedBaseDataAsync(AppDbContext db, string suffix) + { + var term = new AcademicTerm + { + Code = $"2026-{suffix}", + Name = $"2026 {suffix}", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 15) + }; + var campus = new Campus { Code = $"C-{suffix}", Name = "主校区" }; + var college = new College + { + Code = $"COL-{suffix}", + Name = "计算机学院", + CampusId = campus.Id + }; + var building = new Building + { + Code = $"B-{suffix}", + Name = "第一教学楼", + CampusId = campus.Id + }; + var classroom = new Classroom + { + Code = $"R-{suffix}", + Name = "101", + BuildingId = building.Id, + Capacity = 80 + }; + var firstCourse = new Course + { + Code = $"COURSE-1-{suffix}", + Name = "程序设计", + CollegeId = college.Id + }; + var secondCourse = new Course + { + Code = $"COURSE-2-{suffix}", + Name = "大学英语", + CollegeId = college.Id + }; + var firstTask = new TeachingTask + { + TaskNumber = $"TASK-1-{suffix}", + Name = "程序设计教学班", + AcademicTermId = term.Id, + CourseId = firstCourse.Id, + Status = TeachingTaskStatus.Published + }; + var secondTask = new TeachingTask + { + TaskNumber = $"TASK-2-{suffix}", + Name = "大学英语教学班", + AcademicTermId = term.Id, + CourseId = secondCourse.Id, + Status = TeachingTaskStatus.Published + }; + + db.AddRange( + term, + campus, + college, + building, + classroom, + firstCourse, + secondCourse, + firstTask, + secondTask, + new ScheduleTimeSlot + { + AcademicTermId = term.Id, + PeriodNumber = 1, + Name = "第1节", + StartsAt = new TimeOnly(8, 0), + EndsAt = new TimeOnly(8, 45) + }, + new ScheduleTimeSlot + { + AcademicTermId = term.Id, + PeriodNumber = 2, + Name = "第2节", + StartsAt = new TimeOnly(9, 0), + EndsAt = new TimeOnly(9, 50) + }); + await db.SaveChangesAsync(); + return new SeedData(term, firstTask, secondTask); + } + + private sealed record SeedData( + AcademicTerm Term, + TeachingTask FirstTask, + TeachingTask SecondTask); +} diff --git a/web/src/views/ExamsView.vue b/web/src/views/ExamsView.vue index 7e1266e..1c40d43 100644 --- a/web/src/views/ExamsView.vue +++ b/web/src/views/ExamsView.vue @@ -25,11 +25,55 @@ const sessionDialog = ref(false) const editingSession = ref(null) const rosterDrawer = ref(false) const roster = ref(null) +const selectedTaskIds = ref([]) +const selectedSessionIds = ref([]) const planForm = reactive>({}) const sessionForm = reactive>({}) +const taskFilter = reactive({ keyword: '', collegeId: '', courseNature: '' }) +const sessionFilter = reactive({ keyword: '', allocation: '' }) const statusLabels: Record = { Draft: '草稿', Published: '已发布', Archived: '已归档', } +const courseNatureLabels: Record = { + GeneralRequired: '公共必修', + GeneralElective: '公共选修', + MajorRequired: '专业必修', + MajorElective: '专业选修', + Practice: '实践教学', +} +const taskColleges = computed(() => { + const values = new Map() + tasks.value.forEach((task: any) => values.set(task.collegeId, task.collegeName)) + return Array.from(values, ([id, name]) => ({ id, name })) +}) +const filteredTasks = computed(() => { + const keyword = taskFilter.keyword.trim().toLowerCase() + const arrangedIds = new Set((selected.value?.sessions ?? []) + .filter((session: any) => session.id !== editingSession.value?.id) + .map((session: any) => session.teachingTaskId)) + return tasks.value.filter((task: any) => { + if (arrangedIds.has(task.id)) return false + if (taskFilter.collegeId && task.collegeId !== taskFilter.collegeId) return false + if (taskFilter.courseNature && task.courseNature !== taskFilter.courseNature) return false + if (!keyword) return true + return [task.taskNumber, task.courseCode, task.courseName, task.name, + ...(task.teacherNames ?? []), ...(task.classNames ?? [])] + .some((value: any) => String(value ?? '').toLowerCase().includes(keyword)) + }) +}) +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 === 'invigilator' && + session.invigilatorIds.length >= session.requiredInvigilatorCount) return false + if (sessionFilter.allocation === 'complete' && + (!session.classroomId || session.invigilatorIds.length < session.requiredInvigilatorCount)) return false + if (!keyword) return true + return [session.taskNumber, session.courseCode, session.courseName, session.taskName] + .some((value: any) => String(value ?? '').toLowerCase().includes(keyword)) + }) +}) function timeText(startsAt: string) { return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) @@ -63,6 +107,18 @@ async function load() { } async function selectPlan(id: string) { selected.value = (await http.get(`/exams/plans/${id}`)).data + selectedSessionIds.value = [] + await loadPlanResources(selected.value.academicTermId) +} +async function loadPlanResources(academicTermId: string) { + const [taskRes, slotRes] = await Promise.all([ + http.get('/teaching-tasks/options', { + params: { academicTermId, status: 'Published' }, + }), + http.get('/exams/time-slots-for-term', { params: { academicTermId } }), + ]) + tasks.value = taskRes.data + timeSlots.value = slotRes.data } function openPlan() { Object.assign(planForm, { @@ -80,6 +136,8 @@ async function savePlan() { } function openSession(existing?: any) { editingSession.value = existing ?? null + selectedTaskIds.value = existing ? [existing.teachingTaskId] : [] + Object.assign(taskFilter, { keyword: '', collegeId: '', courseNature: '' }) const firstSlot = timeSlots.value[0] Object.assign(sessionForm, { teachingTaskId: existing?.teachingTaskId ?? undefined, @@ -100,7 +158,20 @@ async function saveSession() { if (editingSession.value) { await http.put(`/exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload) } else { - await http.post(`/exams/plans/${selected.value.id}/sessions`, payload) + if (selectedTaskIds.value.length === 0) { + ElMessage.warning('请至少选择一个教学班') + return + } + const res = await http.post(`/exams/plans/${selected.value.id}/sessions/batch`, { + teachingTaskIds: selectedTaskIds.value, + examDate: payload.examDate, + startPeriod: payload.startPeriod, + periodCount: payload.periodCount, + requiredBuildingId: payload.requiredBuildingId, + requiredInvigilatorCount: payload.requiredInvigilatorCount, + notes: payload.notes, + }) + ElMessage.success(`已批量创建 ${res.data.createdCount} 个考试场次`) } sessionDialog.value = false editingSession.value = null @@ -116,13 +187,52 @@ async function removeSession(row: any) { if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) } } -async function autoArrange() { +function selectFilteredTasks() { + selectedTaskIds.value = Array.from(new Set([ + ...selectedTaskIds.value, + ...filteredTasks.value.map((task: any) => task.id), + ])).slice(0, 100) +} +function clearFilteredTasks() { + const visibleIds = new Set(filteredTasks.value.map((task: any) => task.id)) + selectedTaskIds.value = selectedTaskIds.value.filter(id => !visibleIds.has(id)) +} +function toggleTaskSelection(id: string, checked: boolean) { + if (checked && selectedTaskIds.value.length >= 100) { + ElMessage.warning('一次最多选择100个教学班') + return + } + selectedTaskIds.value = checked + ? Array.from(new Set([...selectedTaskIds.value, id])) + : selectedTaskIds.value.filter(value => value !== id) +} +function selectFilteredSessions() { + selectedSessionIds.value = filteredSessions.value.map((session: any) => session.id) +} +function clearSessionSelection() { + selectedSessionIds.value = [] +} +function toggleSessionSelection(id: string, checked: boolean) { + selectedSessionIds.value = checked + ? Array.from(new Set([...selectedSessionIds.value, id])) + : selectedSessionIds.value.filter(value => value !== id) +} +async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') { try { + const target = selectedSessionIds.value.length + ? `所选 ${selectedSessionIds.value.length} 个场次` + : '当前计划全部场次' + const action = mode === 'rooms' ? '分配考场' + : mode === 'invigilators' ? '分配监考教师' : '分配考场和监考教师' await ElMessageBox.confirm( - '系统将为未分配考场的场次自动匹配教室,为未满监考的场次自动分配教师。', - '自动编排', { type: 'info', confirmButtonText: '开始编排' }) + `系统将为${target}${action},已有安排不会被覆盖。`, + `一键${action}`, { type: 'info', confirmButtonText: '开始分配' }) arrangeLoading.value = true - const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`) + const res = await http.post(`/exams/plans/${selected.value.id}/auto-arrange`, { + sessionIds: selectedSessionIds.value, + assignClassrooms: mode !== 'invigilators', + assignInvigilators: mode !== 'rooms', + }) ElMessage.success(res.data.message) await selectPlan(selected.value.id) } catch (error: any) { @@ -164,21 +274,16 @@ function filteredRooms() { onMounted(async () => { try { if (isManager.value) { - const currentTermId = (await http.get('/base-data/terms')).data.find((t: any) => t.isCurrent)?.id - const [termRes, taskRes, roomRes, teacherRes, buildingRes, slotRes] = await Promise.all([ + const [termRes, roomRes, teacherRes, buildingRes] = await Promise.all([ http.get('/base-data/terms'), - http.get('/teaching-tasks', { params: { page: 1, pageSize: 200 } }), http.get('/base-data/classrooms'), http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }), http.get('/base-data/buildings'), - currentTermId ? http.get('/exams/time-slots-for-term', { params: { academicTermId: currentTermId } }) : Promise.resolve({ data: [] }), ]) terms.value = termRes.data - tasks.value = taskRes.data.items rooms.value = roomRes.data teachers.value = teacherRes.data.items buildings.value = buildingRes.data - timeSlots.value = slotRes.data } await load() } catch (error) { ElMessage.error(apiErrorMessage(error)) } @@ -212,14 +317,35 @@ onMounted(async () => {

{{ selected.termName }} · {{ selected.sessions.length }} 个考试场次

- 自动编排 - 安排场次 + 一键分配考场 + 一键分配监考 + 一键完成 + 批量安排场次 发布计划
+
+ + + + + + + 显示 {{ filteredSessions.length }}/{{ selected.sessions.length }} 个场次 + +
-
+
@@ -245,7 +371,8 @@ onMounted(async () => { 移除
- + + @@ -260,6 +387,7 @@ onMounted(async () => { {{ item.courseCode }} · {{ item.taskNumber }}

{{ item.courseName }}

{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}

+ 座位号 {{ item.seatNumber }}