教师成绩报告

This commit is contained in:
2026-08-09 11:09:43 +08:00 Unverified
parent 924574256a
commit ca8232ad5e
12 changed files with 8048 additions and 17 deletions
@@ -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);
}