成绩分析

This commit is contained in:
biss committed 2026-08-22 16:24:56 +08:00
1 parent 0a0500dd70
commit 9ba83c3dc1
4 files changed
+754 -33

No files matched your search

@@ -5,6 +5,7 @@ using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Files;
using Jiaowu.Api.Infrastructure.OfficialDocuments;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -18,6 +19,7 @@ namespace Jiaowu.Api.Controllers;
public sealed class GradeAnalyticsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope,
OfficialDocumentOptions officialDocumentOptions,
IAppCache? cache = null) : ControllerBase
{
private const string AnalyticsUsers =
@@ -28,6 +30,11 @@ public sealed class GradeAnalyticsController(
SystemRoles.Teacher;
private const string ScheduleManagers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
private const string OverallAnalyticsUsers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader;
[HttpGet("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)]
@@ -90,6 +97,224 @@ public sealed class GradeAnalyticsController(
return NoContent();
}
[HttpGet("courses")]
[Authorize(Roles = OverallAnalyticsUsers)]
public async Task<ActionResult> GetCourseOverviews(
Guid? academicTermId,
string? keyword,
Guid? collegeId = null,
bool? riskOnly = null,
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.Value);
if (collegeId.HasValue)
source = source.Where(x => x.TeachingTask!.Course!.CollegeId == collegeId.Value);
if (keyword is not null)
source = source.Where(x =>
x.TeachingTask!.Course!.Code.Contains(keyword) ||
x.TeachingTask.Course.Name.Contains(keyword));
var rows = await source.Select(x => new CourseOverviewSourceRow(
x.CourseId,
x.TeachingTask!.Course!.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.Course.CollegeId,
x.TeachingTask.Course.College!.Name,
x.AcademicTermId,
x.StudentCount,
x.PassedCount,
x.ExcellentCount,
x.HighestScore,
x.AverageScore,
x.LowestScore,
x.CalculatedAt))
.ToListAsync(cancellationToken);
var overviews = rows.GroupBy(x => new
{ x.CourseId, x.CourseCode, x.CourseName, x.CollegeId, x.CollegeName })
.Select(group => BuildCourseOverview(group.Key.CourseId, group.Key.CourseCode,
group.Key.CourseName, group.Key.CollegeId, group.Key.CollegeName, group))
.Where(x => riskOnly != true || x.PassRate < 60m || x.AverageScore < 60m)
.OrderBy(x => x.PassRate < 60m || x.AverageScore < 60m ? 0 : 1)
.ThenBy(x => x.CourseCode)
.ToArray();
var total = overviews.Length;
var items = overviews.Skip((page - 1) * pageSize).Take(pageSize).ToArray();
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
}
[HttpGet("courses/{courseId:guid}")]
[Authorize(Roles = OverallAnalyticsUsers)]
public async Task<ActionResult> GetCourseOverview(
Guid courseId,
Guid? academicTermId,
CancellationToken cancellationToken)
{
var report = await BuildCourseOverallAnalysisReportAsync(
courseId, academicTermId, cancellationToken);
return report is null ? NotFound() : Ok(report);
}
[HttpGet("courses/{courseId:guid}/report.docx")]
[Authorize(Roles = OverallAnalyticsUsers)]
public async Task<ActionResult> ExportCourseOverallAnalysisReport(
Guid courseId,
Guid? academicTermId,
CancellationToken cancellationToken)
{
var report = await BuildCourseOverallAnalysisReportAsync(
courseId, academicTermId, cancellationToken);
if (report is null) return NotFound();
var content = GradeAnalysisWordReportGenerator.Generate(
report, DateTime.Now, officialDocumentOptions.FontPath);
var fileName = DownloadFileName.Export("科目总体成绩分析报告", ".docx",
report.Summary.CourseCode, academicTermId.HasValue ? "指定学期" : "全部学期");
return File(content,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
fileName);
}
private async Task<CourseOverallAnalysisReport?> BuildCourseOverallAnalysisReportAsync(
Guid courseId,
Guid? academicTermId,
CancellationToken cancellationToken)
{
var source = db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == courseId &&
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId));
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId.Value);
var rows = await source.Select(x => new CourseDetailSourceRow(
x.GradeSheetId,
x.TeachingTaskId,
x.CourseId,
x.TeachingTask!.Course!.Code,
x.TeachingTask.Course.Name,
x.TeachingTask.Course.CollegeId,
x.TeachingTask.Course.College!.Name,
x.AcademicTermId,
x.TeachingTask.AcademicTerm!.Name,
x.TeachingTask.AcademicTerm.StartDate,
x.TeachingTask.TaskNumber,
x.TeachingTask.Name,
x.TeachingTask.Teachers.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name).ToArray(),
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()))
.ToListAsync(cancellationToken);
if (rows.Count == 0) return null;
var first = rows[0];
var summary = BuildCourseOverview(first.CourseId, first.CourseCode, first.CourseName,
first.CollegeId, first.CollegeName, rows.Select(x => new CourseOverviewSourceRow(
x.CourseId, x.CourseCode, x.CourseName, x.CollegeId, x.CollegeName,
x.AcademicTermId, x.StudentCount, x.PassedCount, x.ExcellentCount,
x.HighestScore, x.AverageScore, x.LowestScore, x.CalculatedAt)));
var termIds = rows.Select(x => x.AcademicTermId).Distinct().ToArray();
var originRows = await db.CourseGradeStatistics.AsNoTracking()
.Where(x => x.CourseId == courseId &&
termIds.Contains(x.AcademicTermId) &&
x.Scope == CourseGradeStatisticScope.College)
.Join(db.Colleges.AsNoTracking(), x => x.ScopeEntityId, x => x.Id,
(statistic, college) => new { college.Name, statistic.StudentCount,
statistic.PassedCount, statistic.From90To100Count, statistic.AverageScore })
.ToListAsync(cancellationToken);
var distribution = rows.SelectMany(x => x.ScoreBands)
.GroupBy(x => new { x.Label, x.LowerBound, x.UpperBound })
.OrderBy(x => x.Key.LowerBound)
.Select(x => new ScoreBand(x.Key.Label, x.Key.LowerBound, x.Key.UpperBound,
x.Sum(item => item.StudentCount))).ToArray();
var terms = rows.GroupBy(x => new { x.AcademicTermId, x.TermName, x.StartDate })
.OrderBy(x => x.Key.StartDate)
.Select(x => BuildCourseTermOverview(x.Key.AcademicTermId, x.Key.TermName,
x.Select(item => new CourseOverviewSourceRow(item.CourseId, item.CourseCode,
item.CourseName, item.CollegeId, item.CollegeName, item.AcademicTermId,
item.StudentCount, item.PassedCount, item.ExcellentCount,
item.HighestScore, item.AverageScore, item.LowestScore, item.CalculatedAt))))
.ToArray();
var studentOrigins = originRows.GroupBy(x => x.Name)
.Select(x => new CourseStudentOrigin(
x.Key,
x.Sum(item => item.StudentCount),
WeightedAverage(x.Select(item => (item.AverageScore, item.StudentCount))),
Percentage(x.Sum(item => item.PassedCount), x.Sum(item => item.StudentCount)),
Percentage(x.Sum(item => item.From90To100Count), x.Sum(item => item.StudentCount))))
.OrderByDescending(x => x.StudentCount).ToArray();
var teachingClasses = rows.OrderByDescending(x => x.AverageScore)
.Select(x => new CourseTeachingClassOverview(x.GradeSheetId, x.TeachingTaskId,
x.TermName, x.TaskNumber, x.TaskName, string.Join("、", x.TeacherNames),
x.StudentCount, x.HighestScore, x.AverageScore, x.MedianScore, x.LowestScore,
x.StandardDeviation, x.PassRate, x.ExcellentRate)).ToArray();
return new CourseOverallAnalysisReport(summary, distribution, terms,
studentOrigins, teachingClasses);
}
private static CourseOverview BuildCourseOverview(
Guid courseId,
string courseCode,
string courseName,
Guid collegeId,
string collegeName,
IEnumerable<CourseOverviewSourceRow> source)
{
var rows = source.ToArray();
var studentCount = rows.Sum(x => x.StudentCount);
return new CourseOverview(courseId, courseCode, courseName, collegeId, collegeName,
rows.Select(x => x.AcademicTermId).Distinct().Count(), rows.Length, studentCount,
rows.Sum(x => x.PassedCount), rows.Sum(x => x.ExcellentCount),
rows.Max(x => x.HighestScore), WeightedAverage(rows.Select(x => (x.AverageScore, x.StudentCount))),
rows.Min(x => x.LowestScore), Percentage(rows.Sum(x => x.PassedCount), studentCount),
Percentage(rows.Sum(x => x.ExcellentCount), studentCount), rows.Max(x => x.CalculatedAt));
}
private static CourseTermOverview BuildCourseTermOverview(
Guid academicTermId,
string termName,
IEnumerable<CourseOverviewSourceRow> source)
{
var rows = source.ToArray();
var studentCount = rows.Sum(x => x.StudentCount);
return new CourseTermOverview(academicTermId, termName, rows.Length, studentCount,
WeightedAverage(rows.Select(x => (x.AverageScore, x.StudentCount))),
Percentage(rows.Sum(x => x.PassedCount), studentCount),
Percentage(rows.Sum(x => x.ExcellentCount), studentCount));
}
private static decimal WeightedAverage(IEnumerable<(decimal Value, int Weight)> source)
{
var values = source.ToArray();
var weight = values.Sum(x => x.Weight);
return weight == 0 ? 0m : Math.Round(values.Sum(x => x.Value * x.Weight) / weight, 1);
}
private static decimal Percentage(int numerator, int denominator) => denominator == 0
? 0m
: Math.Round((decimal)numerator / denominator * 100m, 2);
[HttpGet("teaching-classes")]
public async Task<ActionResult> GetTeachingClasses(
Guid? academicTermId,
@@ -227,7 +452,8 @@ public sealed class GradeAnalyticsController(
Status = StatusCodes.Status409Conflict
});
var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now);
var content = GradeAnalysisWordReportGenerator.Generate(
report, DateTime.Now, officialDocumentOptions.FontPath);
var fileName = DownloadFileName.Export("成绩分析报告", ".docx", report.CourseCode, report.TaskNumber);
return File(
content,
@@ -522,6 +748,105 @@ public sealed class GradeAnalyticsController(
Guid CollegeId,
string CollegeName);
private sealed record CourseOverviewSourceRow(
Guid CourseId,
string CourseCode,
string CourseName,
Guid CollegeId,
string CollegeName,
Guid AcademicTermId,
int StudentCount,
int PassedCount,
int ExcellentCount,
decimal HighestScore,
decimal AverageScore,
decimal LowestScore,
DateTime CalculatedAt);
private sealed record CourseDetailSourceRow(
Guid GradeSheetId,
Guid TeachingTaskId,
Guid CourseId,
string CourseCode,
string CourseName,
Guid CollegeId,
string CollegeName,
Guid AcademicTermId,
string TermName,
DateOnly StartDate,
string TaskNumber,
string TaskName,
string[] TeacherNames,
int StudentCount,
int PassedCount,
int ExcellentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate,
DateTime CalculatedAt,
ScoreBand[] ScoreBands);
public sealed record CourseOverview(
Guid CourseId,
string CourseCode,
string CourseName,
Guid CollegeId,
string CollegeName,
int TermCount,
int TeachingClassCount,
int StudentCount,
int PassedCount,
int ExcellentCount,
decimal HighestScore,
decimal AverageScore,
decimal LowestScore,
decimal PassRate,
decimal ExcellentRate,
DateTime CalculatedAt);
public sealed record CourseTermOverview(
Guid AcademicTermId,
string TermName,
int TeachingClassCount,
int StudentCount,
decimal AverageScore,
decimal PassRate,
decimal ExcellentRate);
public sealed record CourseStudentOrigin(
string CollegeName,
int StudentCount,
decimal AverageScore,
decimal PassRate,
decimal ExcellentRate);
public sealed record CourseTeachingClassOverview(
Guid GradeSheetId,
Guid TeachingTaskId,
string TermName,
string TaskNumber,
string TaskName,
string TeacherNames,
int StudentCount,
decimal HighestScore,
decimal AverageScore,
decimal MedianScore,
decimal LowestScore,
decimal StandardDeviation,
decimal PassRate,
decimal ExcellentRate);
public sealed record CourseOverallAnalysisReport(
CourseOverview Summary,
IReadOnlyList<ScoreBand> Distribution,
IReadOnlyList<CourseTermOverview> Terms,
IReadOnlyList<CourseStudentOrigin> StudentOrigins,
IReadOnlyList<CourseTeachingClassOverview> TeachingClasses);
public sealed record ScoreBand(
string Label,
decimal LowerBound,