using System.Text.Json; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Exams; public sealed class MakeupExamAutoJobProcessor( AppDbContext db, MakeupExamEligibilityService eligibilityService, ILogger logger) { public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken) { try { var job = await db.MakeupExamAutoJobs .FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken); if (job is null || job.Status is MakeupExamAutoJobStatus.Succeeded or MakeupExamAutoJobStatus.Failed) { return; } var plan = await db.MakeupExamPlans .Include(x => x.Sessions) .FirstOrDefaultAsync(x => x.Id == job.MakeupExamPlanId, stoppingToken); if (plan is null || plan.Status != MakeupExamPlanStatus.Draft) throw new InvalidOperationException("补考计划草稿不存在或已不允许修改。"); job.Status = MakeupExamAutoJobStatus.Running; job.StartedAt = DateTime.UtcNow; job.CompletedAt = null; job.ErrorMessage = null; job.MessagesJson = null; await db.SaveChangesAsync(stoppingToken); // Get time slots for the term var timeSlots = await db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled) .OrderBy(x => x.PeriodNumber) .ToListAsync(stoppingToken); if (timeSlots.Count == 0) throw new InvalidOperationException("当前学期未配置上课时间表。"); var slotLookup = timeSlots.ToDictionary(x => x.PeriodNumber); // Get all published teaching tasks for this term with grade sheets var tasks = await db.TeachingTasks.AsNoTracking() .Where(x => x.AcademicTermId == plan.AcademicTermId && x.Status == TeachingTaskStatus.Published) .Select(x => new { x.Id, x.TaskNumber, x.Name, CourseName = x.Course!.Name }) .OrderBy(x => x.TaskNumber) .ToListAsync(stoppingToken); job.TotalCourses = tasks.Count; job.ProcessedCourses = 0; job.CreatedSessions = 0; job.EnrolledStudents = 0; await db.SaveChangesAsync(stoppingToken); var messages = new List(); var existingTaskIds = plan.Sessions.Select(s => s.TeachingTaskId).ToHashSet(); // Get available classrooms for mixed-room assignment var allRooms = await db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled) .OrderBy(x => x.Capacity) .Select(x => new { x.Id, x.Name, x.Capacity, x.BuildingId }) .ToListAsync(stoppingToken); // Get available teachers var allTeachers = await db.Teachers.AsNoTracking() .Where(x => x.Status == TeacherStatus.Active) .Select(x => new { x.Id, x.Name }) .ToListAsync(stoppingToken); // Auto-assign dates: spread sessions across available dates // Start from tomorrow, skip weekends, assign one session per time slot per day var startDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(7)); var dateCursor = startDate; var periodSlots = timeSlots .Where(x => x.PeriodNumber <= timeSlots.Count - 1) .Select(x => x.PeriodNumber) .ToList(); foreach (var task in tasks) { stoppingToken.ThrowIfCancellationRequested(); // Skip if session already exists for this task if (existingTaskIds.Contains(task.Id)) { job.ProcessedCourses++; continue; } // Query eligible students var eligible = await eligibilityService.GetEligibleStudentsAsync( task.Id, stoppingToken); if (eligible.Count == 0) { job.ProcessedCourses++; continue; } // Skip weekends for date assignment while (dateCursor.DayOfWeek == DayOfWeek.Saturday || dateCursor.DayOfWeek == DayOfWeek.Sunday) dateCursor = dateCursor.AddDays(1); // Determine time slot (rotate through available period slots) var periodIdx = (job.CreatedSessions) % Math.Max(periodSlots.Count, 1); var startPeriod = periodSlots[periodIdx]; var periodCount = 2; // Resolve time var startSlot = slotLookup.GetValueOrDefault(startPeriod); var endSlot = slotLookup.GetValueOrDefault(startPeriod + periodCount - 1); if (startSlot is null || endSlot is null) { job.ProcessedCourses++; continue; } var startsAt = dateCursor.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc); var endsAt = dateCursor.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc); // Find a classroom (mixed-subject: no room conflict check) var room = allRooms.FirstOrDefault(r => r.Capacity >= eligible.Count); // Create session var session = new MakeupExamSession { MakeupExamPlanId = plan.Id, TeachingTaskId = task.Id, ClassroomId = room?.Id, ExamDate = dateCursor, StartPeriod = startPeriod, PeriodCount = periodCount, StartsAt = startsAt, EndsAt = endsAt, RequiredInvigilatorCount = 2, Enrollments = eligible.Select(e => new MakeupExamEnrollment { StudentId = e.StudentId, Reason = e.Reason, SourceGradeRecordId = e.SourceGradeRecordId, SourceDeferredExamId = e.SourceDeferredExamId }).ToList() }; // Auto-assign invigilators var excludeTeacherIds = new HashSet(); var assigned = 0; foreach (var teacher in allTeachers) { if (assigned >= 2) break; if (excludeTeacherIds.Contains(teacher.Id)) continue; session.Invigilators.Add(new MakeupExamSessionInvigilator { MakeupExamSessionId = session.Id, TeacherId = teacher.Id }); excludeTeacherIds.Add(teacher.Id); assigned++; } db.MakeupExamSessions.Add(session); existingTaskIds.Add(task.Id); job.CreatedSessions++; job.EnrolledStudents += eligible.Count; job.ProcessedCourses++; messages.Add( $"\"{task.CourseName}\"→{dateCursor:MM/dd} 第{startPeriod}节 " + $"{(room != null ? room.Name : "无可用教室")} · {eligible.Count}人"); // Advance date cursor every few sessions if (job.CreatedSessions % periodSlots.Count == 0) dateCursor = dateCursor.AddDays(1); // Persist progress periodically if (job.ProcessedCourses % 10 == 0 || job.ProcessedCourses >= job.TotalCourses) await PersistProgressAsync(job, stoppingToken); } job.Status = MakeupExamAutoJobStatus.Succeeded; job.MessagesJson = JsonSerializer.Serialize(messages.Take(20).ToList()); job.CompletedAt = DateTime.UtcNow; await db.SaveChangesAsync(stoppingToken); logger.LogInformation( "Makeup exam auto job {JobId} completed: {Sessions} sessions, {Students} students.", job.Id, job.CreatedSessions, job.EnrolledStudents); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { logger.LogInformation( "Makeup exam auto job {JobId} was interrupted by application shutdown.", jobId); throw; } catch (Exception exception) { logger.LogError(exception, "Makeup exam auto job {JobId} failed.", jobId); await MarkFailedAsync(jobId, exception); } } private async Task PersistProgressAsync( MakeupExamAutoJob job, CancellationToken cancellationToken) { try { await db.SaveChangesAsync(cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } } private async Task MarkFailedAsync(Guid jobId, Exception exception) { db.ChangeTracker.Clear(); var job = await db.MakeupExamAutoJobs.FirstOrDefaultAsync( x => x.Id == jobId, CancellationToken.None); if (job is null) return; var message = exception.GetBaseException().Message; job.Status = MakeupExamAutoJobStatus.Failed; job.ErrorMessage = message.Length <= 2000 ? message : message[..2000]; job.CompletedAt = DateTime.UtcNow; await db.SaveChangesAsync(CancellationToken.None); } }