Compare commits
5
Commits
v2.3.0
..
3c9863580b
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -250,6 +251,13 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
// Auto-apply: update grade record
|
// Auto-apply: update grade record
|
||||||
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
gm.GradeRecord!.TotalScore = gm.RequestedScore;
|
||||||
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
|
||||||
|
var statisticsJob = new CourseGradeStatisticsRefreshJob
|
||||||
|
{
|
||||||
|
GradeSheetId = gm.GradeRecord.GradeSheetId
|
||||||
|
};
|
||||||
|
db.CourseGradeStatisticsRefreshJobs.Add(statisticsJob);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh, statisticsJob.Id));
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
await NotificationService.SendAsync(
|
await NotificationService.SendAsync(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -0,0 +1,528 @@
|
|||||||
|
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.Grades;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("teaching-classes/{gradeSheetId:guid}/report.docx")]
|
||||||
|
public async Task<ActionResult> ExportTeachingClassAnalysisReport(
|
||||||
|
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);
|
||||||
|
if (report.IsRefreshing || report.Summary is null)
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "成绩统计尚未生成",
|
||||||
|
Detail = "请先重新计算当前教学班,待统计完成后再导出。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
var content = GradeAnalysisWordReportGenerator.Generate(report, DateTime.Now);
|
||||||
|
var fileName = $"{SanitizeFileName(report.CourseCode)}-{SanitizeFileName(report.TaskNumber)}-成绩分析报告.docx";
|
||||||
|
return File(
|
||||||
|
content,
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 static string SanitizeFileName(string value)
|
||||||
|
{
|
||||||
|
var invalid = Path.GetInvalidFileNameChars();
|
||||||
|
return string.Concat(value.Select(character => invalid.Contains(character) ? '_' : character));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ using System.Globalization;
|
|||||||
using Jiaowu.Api.Contracts;
|
using Jiaowu.Api.Contracts;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Excel;
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
using Jiaowu.Api.Infrastructure.Grades;
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -19,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
[Route("api/grades")]
|
[Route("api/grades")]
|
||||||
public sealed class GradesController(
|
public sealed class GradesController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
|
IAppCache? cache = null) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string SheetUsers =
|
private const string SheetUsers =
|
||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
@@ -37,6 +40,9 @@ public sealed class GradesController(
|
|||||||
SystemRoles.SuperAdmin + "," +
|
SystemRoles.SuperAdmin + "," +
|
||||||
SystemRoles.AcademicAdmin;
|
SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
private const string StatisticsUsers =
|
||||||
|
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
|
||||||
|
|
||||||
[HttpGet("sheets")]
|
[HttpGet("sheets")]
|
||||||
[Authorize(Roles = SheetUsers)]
|
[Authorize(Roles = SheetUsers)]
|
||||||
public async Task<ActionResult> GetSheets(
|
public async Task<ActionResult> GetSheets(
|
||||||
@@ -502,6 +508,7 @@ public sealed class GradesController(
|
|||||||
}
|
}
|
||||||
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||||
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||||
|
QueueCourseStatisticsRefresh(sheet.Id);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -661,6 +668,7 @@ public sealed class GradesController(
|
|||||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||||
sheet.Status = GradeSheetStatus.Published;
|
sheet.Status = GradeSheetStatus.Published;
|
||||||
sheet.PublishedAt = DateTime.UtcNow;
|
sheet.PublishedAt = DateTime.UtcNow;
|
||||||
|
QueueCourseStatisticsRefresh(sheet.Id);
|
||||||
|
|
||||||
// The grade sheet roster is authoritative at publication time. This also
|
// The grade sheet roster is authoritative at publication time. This also
|
||||||
// covers students added through approved roster corrections.
|
// covers students added through approved roster corrections.
|
||||||
@@ -699,7 +707,7 @@ public sealed class GradesController(
|
|||||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||||
headers.AddRange(itemNames);
|
headers.AddRange(itemNames);
|
||||||
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||||
|
|
||||||
var rows = sheet.Records.Select(record =>
|
var rows = sheet.Records.Select(record =>
|
||||||
{
|
{
|
||||||
@@ -716,6 +724,7 @@ public sealed class GradesController(
|
|||||||
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
||||||
values.Add(score);
|
values.Add(score);
|
||||||
}
|
}
|
||||||
|
values.Add(null);
|
||||||
values.Add(record.FinalScore);
|
values.Add(record.FinalScore);
|
||||||
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||||||
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||||||
@@ -729,13 +738,56 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
|
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
|
||||||
"成绩列填写 0—100 的数值,留空表示暂未录入。",
|
"成绩列填写 0—100 的数值,留空表示暂未录入。",
|
||||||
|
"总分(自动计算)列由 Excel 按各部分比例自动计算,仅供填写时预览;上传时系统不会采用该列结果。",
|
||||||
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
|
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
|
||||||
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("、", itemNames)}。",
|
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("、", itemNames)}。",
|
||||||
"导入后会自动重新计算总评成绩和绩点。"
|
"导入后会自动重新计算总评成绩和绩点。"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var regularColumn = 4;
|
||||||
|
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
|
||||||
|
var totalColumn = regularColumn + itemColumns.Length + 1;
|
||||||
|
var finalColumn = totalColumn + 1;
|
||||||
|
var statusColumn = finalColumn + 1;
|
||||||
|
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
|
||||||
|
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
|
||||||
|
(itemColumns[index], item.Weight)));
|
||||||
|
weightedColumns.Add((finalColumn, sheet.FinalWeight));
|
||||||
|
var requiredColumns = weightedColumns.Where(x => x.Weight > 0).ToArray();
|
||||||
|
var scoreColumns = weightedColumns.Select(x => x.Column)
|
||||||
|
.Append(totalColumn)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var bytes = ExcelWorkbookHelper.Create(
|
var bytes = ExcelWorkbookHelper.Create(
|
||||||
"成绩导入", headers, rows, instructions);
|
"成绩导入", headers, rows, instructions,
|
||||||
|
(worksheet, rowNumber) =>
|
||||||
|
{
|
||||||
|
var componentReferences = requiredColumns
|
||||||
|
.Select(x => $"{ColumnLetter(x.Column)}{rowNumber}")
|
||||||
|
.ToArray();
|
||||||
|
var weightedExpression = string.Join("+", weightedColumns.Select(x =>
|
||||||
|
$"{ColumnLetter(x.Column)}{rowNumber}*{x.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
|
||||||
|
var statusReference = $"{ColumnLetter(statusColumn)}{rowNumber}";
|
||||||
|
var formula =
|
||||||
|
$"=IF(OR({statusReference}=\"缺考\",{statusReference}=\"缓考\",{statusReference}=\"免修\"),\"\",IF(COUNT({string.Join(",", componentReferences)})={requiredColumns.Length},ROUND({weightedExpression},1),\"\"))";
|
||||||
|
var cell = worksheet.Cell(rowNumber, totalColumn);
|
||||||
|
cell.FormulaA1 = formula;
|
||||||
|
cell.Style.NumberFormat.Format = "0.0";
|
||||||
|
cell.Style.Font.Bold = true;
|
||||||
|
cell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
|
||||||
|
|
||||||
|
foreach (var scoreColumn in scoreColumns)
|
||||||
|
{
|
||||||
|
var conditionalFormat = worksheet
|
||||||
|
.Range(rowNumber, scoreColumn, rowNumber, scoreColumn)
|
||||||
|
.AddConditionalFormat();
|
||||||
|
var failingScoreFormat = conditionalFormat.WhenLessThan(60);
|
||||||
|
failingScoreFormat.Fill.BackgroundColor =
|
||||||
|
ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
|
||||||
|
failingScoreFormat.Font.FontColor =
|
||||||
|
ClosedXML.Excel.XLColor.FromHtml("#B42318");
|
||||||
|
}
|
||||||
|
});
|
||||||
var taskName = sheet.TeachingTask!.Name;
|
var taskName = sheet.TeachingTask!.Name;
|
||||||
return File(bytes, ExcelWorkbookHelper.ContentType,
|
return File(bytes, ExcelWorkbookHelper.ContentType,
|
||||||
$"成绩导入模板-{taskName}.xlsx");
|
$"成绩导入模板-{taskName}.xlsx");
|
||||||
@@ -768,13 +820,15 @@ public sealed class GradesController(
|
|||||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||||
headers.AddRange(itemNames);
|
headers.AddRange(itemNames);
|
||||||
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||||
|
|
||||||
IReadOnlyList<ExcelRow> rows;
|
IReadOnlyList<ExcelRow> rows;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
rows = await ExcelWorkbookHelper.ReadAsync(
|
rows = await ExcelWorkbookHelper.ReadAsync(
|
||||||
file, headers, cancellationToken);
|
file,
|
||||||
|
headers.Where(x => x != "总分(自动计算)").ToArray(),
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
catch (InvalidDataException exception)
|
catch (InvalidDataException exception)
|
||||||
{
|
{
|
||||||
@@ -810,7 +864,7 @@ public sealed class GradesController(
|
|||||||
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
|
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
|
||||||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||||
|
|
||||||
// Parse final score
|
// Parse final score. The formula-driven total column is intentionally ignored.
|
||||||
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
|
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
|
||||||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||||
|
|
||||||
@@ -864,6 +918,7 @@ public sealed class GradesController(
|
|||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
return ImportValidationProblem(errors);
|
return ImportValidationProblem(errors);
|
||||||
|
|
||||||
|
QueueCourseStatisticsRefresh(sheet.Id);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new { updated, total = rows.Count });
|
return Ok(new { updated, total = rows.Count });
|
||||||
}
|
}
|
||||||
@@ -902,6 +957,7 @@ public sealed class GradesController(
|
|||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Id,
|
x.Id,
|
||||||
|
GradeSheetId = x.GradeSheetId,
|
||||||
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
||||||
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||||
x.GradeSheet.TeachingTaskId,
|
x.GradeSheet.TeachingTaskId,
|
||||||
@@ -918,6 +974,121 @@ public sealed class GradesController(
|
|||||||
return Ok(new { Student = student, Records = records });
|
return Ok(new { Student = student, Records = records });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("sheets/{id:guid}/statistics")]
|
||||||
|
[Authorize(Roles = StatisticsUsers)]
|
||||||
|
public async Task<ActionResult> GetCourseStatistics(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
var sheet = await db.GradeSheets.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Status,
|
||||||
|
x.TeachingTask!.CourseId,
|
||||||
|
x.TeachingTask.AcademicTermId,
|
||||||
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
|
CourseCode = x.TeachingTask.Course.Code,
|
||||||
|
TermName = x.TeachingTask.AcademicTerm!.Name
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
|
||||||
|
Guid? classId = null;
|
||||||
|
Guid? majorId = null;
|
||||||
|
Guid? collegeId = null;
|
||||||
|
if (scope.IsInRole(SystemRoles.Student))
|
||||||
|
{
|
||||||
|
if (sheet.Status != GradeSheetStatus.Published)
|
||||||
|
return NotFound();
|
||||||
|
var student = await db.GradeRecords.AsNoTracking()
|
||||||
|
.Where(x => x.GradeSheetId == id && x.Student!.UserId == scope.UserId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Student!.AdministrativeClassId,
|
||||||
|
MajorId = x.Student.AdministrativeClass!.MajorId,
|
||||||
|
CollegeId = x.Student.AdministrativeClass.Major!.CollegeId
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (student is null) return Forbid();
|
||||||
|
classId = student.AdministrativeClassId;
|
||||||
|
majorId = student.MajorId;
|
||||||
|
collegeId = student.CollegeId;
|
||||||
|
}
|
||||||
|
else if (scope.Scope == DataScope.College)
|
||||||
|
{
|
||||||
|
collegeId = scope.RestrictedCollegeId;
|
||||||
|
if (collegeId == Guid.Empty || !scope.CanAccessCollege(
|
||||||
|
await db.Courses.Where(x => x.Id == sheet.CourseId)
|
||||||
|
.Select(x => x.CollegeId).FirstAsync(cancellationToken)))
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
else if (scope.IsInRole(SystemRoles.Counselor))
|
||||||
|
{
|
||||||
|
var allowedClassIds = await db.AdministrativeClasses.AsNoTracking()
|
||||||
|
.Where(x => x.CounselorUserId == scope.UserId)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (allowedClassIds.Count == 0) return Forbid();
|
||||||
|
// A counselor sees their classes plus the matching major/college
|
||||||
|
// benchmarks, never an unrelated class-level statistic.
|
||||||
|
classId = allowedClassIds.First();
|
||||||
|
majorId = await db.AdministrativeClasses.Where(x => x.Id == classId)
|
||||||
|
.Select(x => x.MajorId).FirstAsync(cancellationToken);
|
||||||
|
collegeId = await db.Majors.Where(x => x.Id == majorId)
|
||||||
|
.Select(x => x.CollegeId).FirstAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheKey = AppCacheKeys.CourseGradeStatistics(id);
|
||||||
|
var statistics = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(cacheKey, async token =>
|
||||||
|
{
|
||||||
|
var source = db.CourseGradeStatistics.AsNoTracking()
|
||||||
|
.Where(x => x.CourseId == sheet.CourseId &&
|
||||||
|
x.AcademicTermId == sheet.AcademicTermId);
|
||||||
|
return await source.Select(x => new
|
||||||
|
{
|
||||||
|
x.Scope, x.ScopeEntityId, x.StudentCount, x.PassedCount,
|
||||||
|
x.Below60Count, x.From60To69Count, x.From70To79Count,
|
||||||
|
x.From80To89Count, x.From90To100Count,
|
||||||
|
x.HighestScore, x.AverageScore, x.LowestScore, x.PassRate,
|
||||||
|
x.CalculatedAt
|
||||||
|
}).ToListAsync(token);
|
||||||
|
}, AppCacheProfile.Analytics,
|
||||||
|
[AppCacheTags.CourseGradeStatistics], cancellationToken);
|
||||||
|
|
||||||
|
object? Find(CourseGradeStatisticScope statisticScope, Guid? entityId)
|
||||||
|
{
|
||||||
|
var item = statistics.FirstOrDefault(x => x.Scope == statisticScope &&
|
||||||
|
x.ScopeEntityId == entityId);
|
||||||
|
return item is null ? null : new
|
||||||
|
{
|
||||||
|
item.Scope, item.ScopeEntityId, item.StudentCount, item.PassedCount,
|
||||||
|
item.HighestScore, item.AverageScore, item.LowestScore, item.PassRate,
|
||||||
|
item.CalculatedAt,
|
||||||
|
Distribution = new[]
|
||||||
|
{
|
||||||
|
new { Range = "0–59", Count = item.Below60Count },
|
||||||
|
new { Range = "60–69", Count = item.From60To69Count },
|
||||||
|
new { Range = "70–79", Count = item.From70To79Count },
|
||||||
|
new { Range = "80–89", Count = item.From80To89Count },
|
||||||
|
new { Range = "90–100", Count = item.From90To100Count }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
sheet.CourseName, sheet.CourseCode, sheet.TermName,
|
||||||
|
IsRefreshing = !statistics.Any(),
|
||||||
|
Class = classId.HasValue ? Find(CourseGradeStatisticScope.AdministrativeClass, classId) : null,
|
||||||
|
Major = majorId.HasValue ? Find(CourseGradeStatisticScope.Major, majorId) : null,
|
||||||
|
College = collegeId.HasValue ? Find(CourseGradeStatisticScope.College, collegeId) : null,
|
||||||
|
University = scope.Scope == DataScope.All || scope.IsInRole(SystemRoles.Student)
|
||||||
|
? Find(CourseGradeStatisticScope.University, null) : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private IQueryable<TeachingTask> AccessibleTasks()
|
private IQueryable<TeachingTask> AccessibleTasks()
|
||||||
{
|
{
|
||||||
var source = db.TeachingTasks.AsQueryable();
|
var source = db.TeachingTasks.AsQueryable();
|
||||||
@@ -990,6 +1161,7 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
QueueCourseStatisticsRefresh(id);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||||
}
|
}
|
||||||
@@ -999,6 +1171,14 @@ public sealed class GradesController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void QueueCourseStatisticsRefresh(Guid gradeSheetId)
|
||||||
|
{
|
||||||
|
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
|
||||||
|
db.CourseGradeStatisticsRefreshJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.CourseGradeStatisticsRefresh, job.Id));
|
||||||
|
}
|
||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) =>
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
Conflict(new ProblemDetails
|
Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
@@ -1041,6 +1221,18 @@ public sealed class GradesController(
|
|||||||
|
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static string ColumnLetter(int column)
|
||||||
|
{
|
||||||
|
var result = string.Empty;
|
||||||
|
while (column > 0)
|
||||||
|
{
|
||||||
|
column--;
|
||||||
|
result = (char)('A' + column % 26) + result;
|
||||||
|
column /= 26;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record GradeSheetRequest(
|
public sealed record GradeSheetRequest(
|
||||||
|
|||||||
Loaded 3 of 42 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user