教师成绩报告
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = AnalyticsUsers)]
|
||||
[Route("api/grade-analytics")]
|
||||
public sealed class GradeAnalyticsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache? cache = null) : ControllerBase
|
||||
{
|
||||
private const string AnalyticsUsers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Leader + "," +
|
||||
SystemRoles.Teacher;
|
||||
|
||||
[HttpGet("teaching-classes")]
|
||||
public async Task<ActionResult> GetTeachingClasses(
|
||||
Guid? academicTermId,
|
||||
string? keyword,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(1, page);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
|
||||
|
||||
var source = db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
if (keyword is not null)
|
||||
source = source.Where(x =>
|
||||
x.TeachingTask!.TaskNumber.Contains(keyword) ||
|
||||
x.TeachingTask.Name.Contains(keyword) ||
|
||||
x.TeachingTask.Course!.Code.Contains(keyword) ||
|
||||
x.TeachingTask.Course.Name.Contains(keyword));
|
||||
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
{
|
||||
x.GradeSheetId,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
TaskName = x.TeachingTask.Name,
|
||||
CourseCode = x.TeachingTask.Course!.Code,
|
||||
CourseName = x.TeachingTask.Course.Name,
|
||||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||
x.AcademicTermId,
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
ClassNames = x.TeachingTask.Classes
|
||||
.Select(item => item.AdministrativeClass!.Name),
|
||||
x.StudentCount,
|
||||
x.AverageScore,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
x.CalculatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
|
||||
}
|
||||
|
||||
[HttpGet("teaching-classes/{gradeSheetId:guid}")]
|
||||
public async Task<ActionResult> GetTeachingClassAnalysis(
|
||||
Guid gradeSheetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await db.GradeSheets.AsNoTracking()
|
||||
.Where(x => x.Id == gradeSheetId &&
|
||||
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId))
|
||||
.Select(x => new AnalysisTarget(
|
||||
x.Id,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.CourseId,
|
||||
x.TeachingTask.AcademicTermId,
|
||||
x.TeachingTask.Course!.CollegeId,
|
||||
x.TeachingTask.TaskNumber,
|
||||
x.TeachingTask.Name,
|
||||
x.TeachingTask.Course.Code,
|
||||
x.TeachingTask.Course.Name,
|
||||
x.TeachingTask.AcademicTerm!.Name))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
var report = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
|
||||
AppCacheKeys.TeachingTaskGradeAnalytics(gradeSheetId),
|
||||
token => BuildReportAsync(sheet, token),
|
||||
AppCacheProfile.Analytics,
|
||||
[AppCacheTags.CourseGradeStatistics],
|
||||
cancellationToken);
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
[HttpPost("teaching-classes/{gradeSheetId:guid}/refresh")]
|
||||
public async Task<ActionResult> RefreshTeachingClassAnalysis(
|
||||
Guid gradeSheetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await db.GradeSheets.AsNoTracking()
|
||||
.AnyAsync(x => x.Id == gradeSheetId &&
|
||||
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
|
||||
cancellationToken);
|
||||
if (!exists) return NotFound();
|
||||
|
||||
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
||||
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||
BackgroundJobKind.CourseGradeStatisticsRefresh,
|
||||
job.Id));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Accepted(new { job.Id });
|
||||
}
|
||||
|
||||
private async Task<TeachingClassAnalysisReport> BuildReportAsync(
|
||||
AnalysisTarget target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var statistic = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == target.GradeSheetId)
|
||||
.Select(x => new TeachingClassMetrics(
|
||||
x.StudentCount,
|
||||
x.PassedCount,
|
||||
x.ExcellentCount,
|
||||
x.HighestScore,
|
||||
x.AverageScore,
|
||||
x.MedianScore,
|
||||
x.LowestScore,
|
||||
x.StandardDeviation,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
x.CalculatedAt,
|
||||
x.ScoreBands.OrderBy(band => band.SortOrder)
|
||||
.Select(band => new ScoreBand(
|
||||
band.Label,
|
||||
band.LowerBound,
|
||||
band.UpperBound,
|
||||
band.StudentCount))
|
||||
.ToArray()))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (statistic is null)
|
||||
return new TeachingClassAnalysisReport(
|
||||
true,
|
||||
target.GradeSheetId,
|
||||
target.TeachingTaskId,
|
||||
target.TaskNumber,
|
||||
target.TaskName,
|
||||
target.CourseCode,
|
||||
target.CourseName,
|
||||
target.TermName,
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null);
|
||||
|
||||
var peerRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.AcademicTermId == target.AcademicTermId)
|
||||
.OrderByDescending(x => x.AverageScore)
|
||||
.Select(x => new
|
||||
{
|
||||
x.GradeSheetId,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
TaskName = x.TeachingTask.Name,
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name).ToArray(),
|
||||
ClassNames = x.TeachingTask.Classes
|
||||
.Select(item => item.AdministrativeClass!.Name).ToArray(),
|
||||
x.StudentCount,
|
||||
x.HighestScore,
|
||||
x.AverageScore,
|
||||
x.MedianScore,
|
||||
x.LowestScore,
|
||||
x.StandardDeviation,
|
||||
x.PassRate,
|
||||
x.ExcellentRate
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var peers = peerRows.Select(x => new TeachingClassComparison(
|
||||
x.GradeSheetId,
|
||||
x.TeachingTaskId,
|
||||
x.TaskNumber,
|
||||
x.TaskName,
|
||||
string.Join("、", x.TeacherNames),
|
||||
string.Join("、", x.ClassNames),
|
||||
x.StudentCount,
|
||||
x.HighestScore,
|
||||
x.AverageScore,
|
||||
x.MedianScore,
|
||||
x.LowestScore,
|
||||
x.StandardDeviation,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
x.GradeSheetId == target.GradeSheetId)).ToArray();
|
||||
|
||||
var classProfiles = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == target.GradeSheetId)
|
||||
.Select(x => new ClassProfile(
|
||||
x.Student!.AdministrativeClassId,
|
||||
x.Student.AdministrativeClass!.Name,
|
||||
x.Student.AdministrativeClass.MajorId,
|
||||
x.Student.AdministrativeClass.Major!.Name,
|
||||
x.Student.AdministrativeClass.Major.CollegeId,
|
||||
x.Student.AdministrativeClass.Major.College!.Name))
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
var scopeStatistics = await db.CourseGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.AcademicTermId == target.AcademicTermId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var benchmarks = BuildBenchmarks(classProfiles, scopeStatistics);
|
||||
|
||||
var selectedTeacherIds = await db.TeachingTaskTeachers.AsNoTracking()
|
||||
.Where(x => x.TeachingTaskId == target.TeachingTaskId)
|
||||
.Select(x => x.TeacherId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var historicalTaskRows = await db.TeachingTaskGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.TeachingTask!.Teachers.Any(link =>
|
||||
selectedTeacherIds.Contains(link.TeacherId)))
|
||||
.Select(x => new
|
||||
{
|
||||
x.AcademicTermId,
|
||||
TermName = x.TeachingTask!.AcademicTerm!.Name,
|
||||
x.TeachingTask.AcademicTerm.StartDate,
|
||||
x.StudentCount,
|
||||
x.PassedCount,
|
||||
x.ExcellentCount,
|
||||
x.AverageScore
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var courseHistory = await db.CourseGradeStatistics.AsNoTracking()
|
||||
.Where(x => x.CourseId == target.CourseId &&
|
||||
x.Scope == CourseGradeStatisticScope.University)
|
||||
.Select(x => new
|
||||
{
|
||||
x.AcademicTermId,
|
||||
TermName = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
|
||||
.Select(term => term.Name).First(),
|
||||
StartDate = db.AcademicTerms.Where(term => term.Id == x.AcademicTermId)
|
||||
.Select(term => term.StartDate).First(),
|
||||
x.StudentCount,
|
||||
x.AverageScore,
|
||||
x.PassRate,
|
||||
ExcellentRate = x.StudentCount == 0 ? 0m :
|
||||
Math.Round((decimal)x.From90To100Count / x.StudentCount * 100m, 2)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var teacherByTerm = historicalTaskRows
|
||||
.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate })
|
||||
.ToDictionary(group => group.Key.AcademicTermId, group =>
|
||||
{
|
||||
var count = group.Sum(x => x.StudentCount);
|
||||
return new HistoricalSeriesValue(
|
||||
count,
|
||||
count == 0 ? 0m : Math.Round(
|
||||
group.Sum(x => x.AverageScore * x.StudentCount) / count, 1),
|
||||
count == 0 ? 0m : Math.Round(
|
||||
(decimal)group.Sum(x => x.PassedCount) / count * 100m, 2),
|
||||
count == 0 ? 0m : Math.Round(
|
||||
(decimal)group.Sum(x => x.ExcellentCount) / count * 100m, 2));
|
||||
});
|
||||
var history = courseHistory
|
||||
.OrderBy(x => x.StartDate)
|
||||
.Select(x => new HistoricalComparison(
|
||||
x.AcademicTermId,
|
||||
x.TermName,
|
||||
x.StudentCount,
|
||||
x.AverageScore,
|
||||
x.PassRate,
|
||||
x.ExcellentRate,
|
||||
teacherByTerm.GetValueOrDefault(x.AcademicTermId)))
|
||||
.ToArray();
|
||||
|
||||
var university = scopeStatistics.FirstOrDefault(x =>
|
||||
x.Scope == CourseGradeStatisticScope.University);
|
||||
return new TeachingClassAnalysisReport(
|
||||
false,
|
||||
target.GradeSheetId,
|
||||
target.TeachingTaskId,
|
||||
target.TaskNumber,
|
||||
target.TaskName,
|
||||
target.CourseCode,
|
||||
target.CourseName,
|
||||
target.TermName,
|
||||
statistic,
|
||||
peers,
|
||||
benchmarks,
|
||||
history,
|
||||
university is null ? null : new ComparisonDelta(
|
||||
Math.Round(statistic.AverageScore - university.AverageScore, 1),
|
||||
Math.Round(statistic.PassRate - university.PassRate, 2),
|
||||
university.AverageScore,
|
||||
university.PassRate));
|
||||
}
|
||||
|
||||
private static ScopeBenchmark[] BuildBenchmarks(
|
||||
IEnumerable<ClassProfile> classProfiles,
|
||||
IReadOnlyCollection<CourseGradeStatistic> statistics)
|
||||
{
|
||||
var profiles = classProfiles.ToArray();
|
||||
var rows = new List<ScopeBenchmark>();
|
||||
foreach (var profile in profiles)
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.AdministrativeClass,
|
||||
profile.ClassId, "行政班", profile.ClassName);
|
||||
foreach (var profile in profiles.GroupBy(x => x.MajorId).Select(x => x.First()))
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.Major,
|
||||
profile.MajorId, "专业", profile.MajorName);
|
||||
foreach (var profile in profiles.GroupBy(x => x.CollegeId).Select(x => x.First()))
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.College,
|
||||
profile.CollegeId, "学院", profile.CollegeName);
|
||||
AddBenchmark(rows, statistics, CourseGradeStatisticScope.University,
|
||||
null, "全校", "全校同课程");
|
||||
return rows.ToArray();
|
||||
}
|
||||
|
||||
private static void AddBenchmark(
|
||||
ICollection<ScopeBenchmark> target,
|
||||
IEnumerable<CourseGradeStatistic> source,
|
||||
CourseGradeStatisticScope scope,
|
||||
Guid? entityId,
|
||||
string scopeLabel,
|
||||
string name)
|
||||
{
|
||||
var item = source.FirstOrDefault(x =>
|
||||
x.Scope == scope && x.ScopeEntityId == entityId);
|
||||
if (item is null || target.Any(x => x.Scope == scopeLabel && x.Name == name)) return;
|
||||
target.Add(new ScopeBenchmark(
|
||||
scopeLabel,
|
||||
name,
|
||||
item.StudentCount,
|
||||
item.HighestScore,
|
||||
item.AverageScore,
|
||||
item.LowestScore,
|
||||
item.PassRate));
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> VisibleTeachingTasks()
|
||||
{
|
||||
var scope = currentUserDataScope.Current;
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
if (scope.Scope == DataScope.All) return source;
|
||||
if (scope.Scope == DataScope.College)
|
||||
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
|
||||
if (scope.IsInRole(SystemRoles.Teacher))
|
||||
return source.Where(x =>
|
||||
x.Teachers.Any(link => link.Teacher!.UserId == scope.UserId));
|
||||
return source.Where(_ => false);
|
||||
}
|
||||
|
||||
private sealed record AnalysisTarget(
|
||||
Guid GradeSheetId,
|
||||
Guid TeachingTaskId,
|
||||
Guid CourseId,
|
||||
Guid AcademicTermId,
|
||||
Guid CourseCollegeId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
string TermName);
|
||||
|
||||
private sealed record ClassProfile(
|
||||
Guid ClassId,
|
||||
string ClassName,
|
||||
Guid MajorId,
|
||||
string MajorName,
|
||||
Guid CollegeId,
|
||||
string CollegeName);
|
||||
|
||||
public sealed record ScoreBand(
|
||||
string Label,
|
||||
decimal LowerBound,
|
||||
decimal? UpperBound,
|
||||
int StudentCount);
|
||||
|
||||
public sealed record TeachingClassMetrics(
|
||||
int StudentCount,
|
||||
int PassedCount,
|
||||
int ExcellentCount,
|
||||
decimal HighestScore,
|
||||
decimal AverageScore,
|
||||
decimal MedianScore,
|
||||
decimal LowestScore,
|
||||
decimal StandardDeviation,
|
||||
decimal PassRate,
|
||||
decimal ExcellentRate,
|
||||
DateTime CalculatedAt,
|
||||
IReadOnlyList<ScoreBand> ScoreBands);
|
||||
|
||||
public sealed record TeachingClassComparison(
|
||||
Guid GradeSheetId,
|
||||
Guid TeachingTaskId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
string TeacherNames,
|
||||
string ClassNames,
|
||||
int StudentCount,
|
||||
decimal HighestScore,
|
||||
decimal AverageScore,
|
||||
decimal MedianScore,
|
||||
decimal LowestScore,
|
||||
decimal StandardDeviation,
|
||||
decimal PassRate,
|
||||
decimal ExcellentRate,
|
||||
bool IsSelected);
|
||||
|
||||
public sealed record ScopeBenchmark(
|
||||
string Scope,
|
||||
string Name,
|
||||
int StudentCount,
|
||||
decimal HighestScore,
|
||||
decimal AverageScore,
|
||||
decimal LowestScore,
|
||||
decimal PassRate);
|
||||
|
||||
public sealed record HistoricalSeriesValue(
|
||||
int StudentCount,
|
||||
decimal AverageScore,
|
||||
decimal PassRate,
|
||||
decimal ExcellentRate);
|
||||
|
||||
public sealed record HistoricalComparison(
|
||||
Guid AcademicTermId,
|
||||
string TermName,
|
||||
int CourseStudentCount,
|
||||
decimal CourseAverageScore,
|
||||
decimal CoursePassRate,
|
||||
decimal CourseExcellentRate,
|
||||
HistoricalSeriesValue? Instructor);
|
||||
|
||||
public sealed record ComparisonDelta(
|
||||
decimal AverageScoreDifference,
|
||||
decimal PassRateDifference,
|
||||
decimal UniversityAverageScore,
|
||||
decimal UniversityPassRate);
|
||||
|
||||
public sealed record TeachingClassAnalysisReport(
|
||||
bool IsRefreshing,
|
||||
Guid GradeSheetId,
|
||||
Guid TeachingTaskId,
|
||||
string TaskNumber,
|
||||
string TaskName,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
string TermName,
|
||||
TeachingClassMetrics? Summary,
|
||||
IReadOnlyList<TeachingClassComparison> PeerTeachingClasses,
|
||||
IReadOnlyList<ScopeBenchmark> ScopeBenchmarks,
|
||||
IReadOnlyList<HistoricalComparison> History,
|
||||
ComparisonDelta? UniversityDelta);
|
||||
}
|
||||
Reference in New Issue
Block a user