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.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); return await db.Teachers.AsNoTracking() .Where(x => x.Status == TeacherStatus.Active) .WhereNotIn(busyTeacherIds, x => 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); }