251 lines
10 KiB
C#
251 lines
10 KiB
C#
using Jiaowu.Api.Domain.Academic;
|
||
using Jiaowu.Api.Infrastructure.Caching;
|
||
using Jiaowu.Api.Infrastructure.Persistence;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace Jiaowu.Api.Infrastructure.Grades;
|
||
|
||
/// <summary>
|
||
/// Rebuilds a course/term's denormalized result statistics. The operation is
|
||
/// intentionally idempotent: duplicate RabbitMQ deliveries are safe.
|
||
/// </summary>
|
||
public sealed class CourseGradeStatisticsRefreshJobProcessor(
|
||
AppDbContext db,
|
||
IAppCache cache,
|
||
ILogger<CourseGradeStatisticsRefreshJobProcessor> 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<CourseGradeStatistic>();
|
||
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<IGrouping<Guid, ScoreRow>> groups,
|
||
ICollection<CourseGradeStatistic> 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<ScoreRow> scores,
|
||
ICollection<CourseGradeStatistic> 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<decimal> 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<decimal> 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);
|
||
}
|