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);
}