成绩统计图
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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.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);
|
||||
|
||||
await db.CourseGradeStatistics
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.AcademicTermId == target.AcademicTermId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
if (rebuilt.Count > 0)
|
||||
db.CourseGradeStatistics.AddRange(rebuilt);
|
||||
|
||||
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 sealed record ScoreRow(decimal Score, Guid ClassId, Guid MajorId, Guid CollegeId);
|
||||
private sealed record StatisticsTarget(Guid CourseId, Guid AcademicTermId);
|
||||
}
|
||||
Reference in New Issue
Block a user