using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Grades; /// /// Rebuilds a course/term's denormalized result statistics. The operation is /// intentionally idempotent: duplicate RabbitMQ deliveries are safe. /// public sealed class CourseGradeStatisticsRefreshJobProcessor( AppDbContext db, IAppCache cache, ILogger logger) { public async Task ProcessAsync(Guid jobId, CancellationToken cancellationToken) { var job = await db.CourseGradeStatisticsRefreshJobs .FirstOrDefaultAsync(x => x.Id == jobId, cancellationToken); if (job is null || job.Status == CourseGradeStatisticsRefreshJobStatus.Succeeded) return; job.Status = CourseGradeStatisticsRefreshJobStatus.Running; job.StartedAt = DateTime.UtcNow; job.ErrorMessage = null; await db.SaveChangesAsync(cancellationToken); var sheetData = await db.GradeSheets.AsNoTracking() .Where(x => x.Id == job.GradeSheetId) .Select(x => new { x.Id, x.TeachingTask!.CourseId, x.TeachingTask.AcademicTermId }) .FirstOrDefaultAsync(cancellationToken); if (sheetData is null) { job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded; job.CompletedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); return; } var target = new StatisticsTarget(sheetData.CourseId, sheetData.AcademicTermId); try { // Statistics shown to students are based only on formally published // scores. This prevents an unfinished class from exposing data. var scores = await db.GradeRecords.AsNoTracking() .Where(x => x.TotalScore != null && x.GradeSheet!.Status == GradeSheetStatus.Published && x.GradeSheet.TeachingTask!.CourseId == target.CourseId && x.GradeSheet.TeachingTask.AcademicTermId == target.AcademicTermId) .Select(x => new ScoreRow( x.GradeSheetId, x.GradeSheet!.TeachingTaskId, x.TotalScore!.Value, x.Student!.AdministrativeClassId, x.Student.AdministrativeClass!.MajorId, x.Student.AdministrativeClass.Major!.CollegeId)) .ToListAsync(cancellationToken); var now = DateTime.UtcNow; var rebuilt = new List(); AddStatistics(CourseGradeStatisticScope.AdministrativeClass, scores.GroupBy(x => x.ClassId), rebuilt, target, now); AddStatistics(CourseGradeStatisticScope.Major, scores.GroupBy(x => x.MajorId), rebuilt, target, now); AddStatistics(CourseGradeStatisticScope.College, scores.GroupBy(x => x.CollegeId), rebuilt, target, now); AddUniversityStatistic(scores, rebuilt, target, now); var rebuiltTeachingTasks = scores .GroupBy(x => new { x.GradeSheetId, x.TeachingTaskId }) .Select(group => CreateTeachingTaskStatistic( group.Key.GradeSheetId, group.Key.TeachingTaskId, group.Select(x => x.Score), target, now)) .ToList(); await db.CourseGradeStatistics .Where(x => x.CourseId == target.CourseId && x.AcademicTermId == target.AcademicTermId) .ExecuteDeleteAsync(cancellationToken); if (rebuilt.Count > 0) db.CourseGradeStatistics.AddRange(rebuilt); var oldTeachingTaskStatisticIds = await db.TeachingTaskGradeStatistics .Where(x => x.CourseId == target.CourseId && x.AcademicTermId == target.AcademicTermId) .Select(x => x.Id) .ToListAsync(cancellationToken); if (oldTeachingTaskStatisticIds.Count > 0) { await db.TeachingTaskGradeScoreBands .Where(x => oldTeachingTaskStatisticIds.Contains( x.TeachingTaskGradeStatisticId)) .ExecuteDeleteAsync(cancellationToken); await db.TeachingTaskGradeStatistics .Where(x => oldTeachingTaskStatisticIds.Contains(x.Id)) .ExecuteDeleteAsync(cancellationToken); } if (rebuiltTeachingTasks.Count > 0) db.TeachingTaskGradeStatistics.AddRange(rebuiltTeachingTasks); job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded; job.CompletedAt = now; await db.SaveChangesAsync(cancellationToken); await cache.RemoveByTagAsync(AppCacheTags.CourseGradeStatistics, cancellationToken); } catch (Exception exception) { job.Status = CourseGradeStatisticsRefreshJobStatus.Failed; job.ErrorMessage = exception.GetBaseException().Message[..Math.Min(2000, exception.GetBaseException().Message.Length)]; await db.SaveChangesAsync(CancellationToken.None); logger.LogError(exception, "Course grade statistics refresh {JobId} failed.", jobId); throw; } } private static void AddStatistics( CourseGradeStatisticScope scope, IEnumerable> groups, ICollection target, StatisticsTarget targetInfo, DateTime calculatedAt) { foreach (var group in groups) target.Add(Create(scope, group.Key, group.Select(x => x.Score), targetInfo, calculatedAt)); } private static void AddUniversityStatistic( IReadOnlyCollection scores, ICollection target, StatisticsTarget targetInfo, DateTime calculatedAt) { if (scores.Count > 0) target.Add(Create(CourseGradeStatisticScope.University, null, scores.Select(x => x.Score), targetInfo, calculatedAt)); } private static CourseGradeStatistic Create( CourseGradeStatisticScope scope, Guid? scopeEntityId, IEnumerable source, StatisticsTarget targetInfo, DateTime calculatedAt) { var scores = source.ToArray(); var passed = scores.Count(x => x >= 60m); return new CourseGradeStatistic { CourseId = targetInfo.CourseId, AcademicTermId = targetInfo.AcademicTermId, Scope = scope, ScopeEntityId = scopeEntityId, StudentCount = scores.Length, PassedCount = passed, Below60Count = scores.Count(x => x < 60m), From60To69Count = scores.Count(x => x >= 60m && x < 70m), From70To79Count = scores.Count(x => x >= 70m && x < 80m), From80To89Count = scores.Count(x => x >= 80m && x < 90m), From90To100Count = scores.Count(x => x >= 90m), HighestScore = scores.Max(), AverageScore = Math.Round(scores.Average(), 1), LowestScore = scores.Min(), PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2), CalculatedAt = calculatedAt }; } private static TeachingTaskGradeStatistic CreateTeachingTaskStatistic( Guid gradeSheetId, Guid teachingTaskId, IEnumerable source, StatisticsTarget targetInfo, DateTime calculatedAt) { var scores = source.OrderBy(x => x).ToArray(); var passed = scores.Count(x => x >= 60m); var excellent = scores.Count(x => x >= 90m); var average = scores.Average(); var middle = scores.Length / 2; var median = scores.Length % 2 == 0 ? (scores[middle - 1] + scores[middle]) / 2m : scores[middle]; var variance = scores.Average(x => (double)((x - average) * (x - average))); var statistic = new TeachingTaskGradeStatistic { GradeSheetId = gradeSheetId, TeachingTaskId = teachingTaskId, CourseId = targetInfo.CourseId, AcademicTermId = targetInfo.AcademicTermId, StudentCount = scores.Length, PassedCount = passed, ExcellentCount = excellent, HighestScore = scores.Max(), AverageScore = Math.Round(average, 1), MedianScore = Math.Round(median, 1), LowestScore = scores.Min(), StandardDeviation = Math.Round((decimal)Math.Sqrt(variance), 2), PassRate = Math.Round((decimal)passed / scores.Length * 100m, 2), ExcellentRate = Math.Round((decimal)excellent / scores.Length * 100m, 2), CalculatedAt = calculatedAt }; statistic.ScoreBands = [ CreateBand(statistic.Id, "0–59", 0m, 60m, scores.Count(x => x < 60m), 0), CreateBand(statistic.Id, "60–69", 60m, 70m, scores.Count(x => x >= 60m && x < 70m), 1), CreateBand(statistic.Id, "70–79", 70m, 80m, scores.Count(x => x >= 70m && x < 80m), 2), CreateBand(statistic.Id, "80–89", 80m, 90m, scores.Count(x => x >= 80m && x < 90m), 3), CreateBand(statistic.Id, "90–100", 90m, null, scores.Count(x => x >= 90m), 4) ]; return statistic; } private static TeachingTaskGradeScoreBand CreateBand( Guid statisticId, string label, decimal lowerBound, decimal? upperBound, int count, int sortOrder) => new() { TeachingTaskGradeStatisticId = statisticId, Label = label, LowerBound = lowerBound, UpperBound = upperBound, StudentCount = count, SortOrder = sortOrder }; private sealed record ScoreRow( Guid GradeSheetId, Guid TeachingTaskId, decimal Score, Guid ClassId, Guid MajorId, Guid CollegeId); private sealed record StatisticsTarget(Guid CourseId, Guid AcademicTermId); }