成绩分析
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user