using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Exams; public sealed class MakeupExamArrangementService(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, 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) .ThenInclude(x => x.Invigilators) .Include(x => x.Sessions) .ThenInclude(x => x.TeachingTask) .ThenInclude(x => x!.Teachers) .Include(x => x.Sessions) .ThenInclude(x => x.TeachingTask) .ThenInclude(x => x!.Course) .AsSplitQuery() .FirstOrDefaultAsync(x => x.Id == planId, cancellationToken); if (plan is null) return ExamArrangementResult.Fail("补考计划不存在。"); if (plan.Status != MakeupExamPlanStatus.Draft) return ExamArrangementResult.Fail("只有草稿状态的补考计划可以自动编排。"); 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("没有可处理的补考场次。"); 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 unavailableRooms = 0; int unavailableInvigilators = 0; var messages = new List(); 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) { ComputeTimesFromSlots(session, timeSlotLookup); var enrolledCount = await db.MakeupExamEnrollments .CountAsync(x => x.MakeupExamSessionId == session.Id, cancellationToken); // Auto-assign classroom if (assignClassrooms && !session.ClassroomId.HasValue) { var room = await FindBestClassroomAsync( session, enrolledCount, 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 / 2}座)"); } else { unavailableRooms++; messages.Add( $"\"{session.TeachingTask!.Course!.Name}\":无可用考场(需≥{enrolledCount}座)"); } } // Auto-assign invigilators var currentInvigilatorCount = session.Invigilators.Count; var needed = session.RequiredInvigilatorCount - currentInvigilatorCount; if (assignInvigilators && 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 MakeupExamSessionInvigilator { MakeupExamSessionId = session.Id, TeacherId = teacher.Id }); occupiedInvigilators.Add(new InvigilatorOccupancy( teacher.Id, session.StartsAt, session.EndsAt)); assignedInvigilators++; } if (newlyAssigned.Count < needed) { unavailableInvigilators += needed - newlyAssigned.Count; messages.Add( $"\"{session.TeachingTask!.Course!.Name}\":仅找到{newlyAssigned.Count}/{needed}名监考教师"); } } } await db.SaveChangesAsync(cancellationToken); return new ExamArrangementResult( true, $"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" + (unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") + (unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") + (messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : "")); } private static void ComputeTimesFromSlots( MakeupExamSession 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( MakeupExamSession session, int enrolledCount, List occupied, CancellationToken cancellationToken) { var query = db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled && x.Capacity >= enrolledCount * 2); var buildingIds = ParseBuildingIds(session.RequiredBuildingIds); if (session.RequiredBuildingId.HasValue) buildingIds.Add(session.RequiredBuildingId.Value); if (buildingIds.Count > 0) query = query.Where(x => buildingIds.Contains(x.BuildingId)); 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); var dbOccupiedRooms = await db.MakeupExamSessions.AsNoTracking() .Where(x => x.MakeupExamPlanId == session.MakeupExamPlanId && 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( MakeupExamSession 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.MakeupExamSessionInvigilators.AsNoTracking() .Where(x => x.MakeupExamSession!.MakeupExamPlanId == session.MakeupExamPlanId && x.MakeupExamSessionId != session.Id && x.MakeupExamSession!.StartsAt < session.EndsAt && session.StartsAt < x.MakeupExamSession.EndsAt) .Select(x => x.TeacherId) .ToListAsync(cancellationToken); foreach (var id in dbBusyIds) busyTeacherIds.Add(id); foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id); var candidates = await InvigilatorCandidateQuery .Create(db, busyTeacherIds) .ToListAsync(cancellationToken); return candidates .OrderBy(_ => Random.Shared.Next()) .Take(needed) .ToList(); } private static HashSet ParseBuildingIds(string? json) { if (string.IsNullOrWhiteSpace(json)) return []; try { return System.Text.Json.JsonSerializer.Deserialize>(json) ?? []; } catch { return []; } } }