1110 lines
48 KiB
C#
1110 lines
48 KiB
C#
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.Files;
|
|
using Jiaowu.Api.Infrastructure.OfficialDocuments;
|
|
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,
|
|
OfficialDocumentOptions officialDocumentOptions,
|
|
IAppCache? cache = null) : ControllerBase
|
|
{
|
|
private const string AnalyticsUsers =
|
|
SystemRoles.SuperAdmin + "," +
|
|
SystemRoles.AcademicAdmin + "," +
|
|
SystemRoles.CollegeAdmin + "," +
|
|
SystemRoles.Leader + "," +
|
|
SystemRoles.Teacher;
|
|
private const string ScheduleManagers =
|
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
|
private const string OverallAnalyticsUsers =
|
|
SystemRoles.SuperAdmin + "," +
|
|
SystemRoles.AcademicAdmin + "," +
|
|
SystemRoles.CollegeAdmin + "," +
|
|
SystemRoles.Leader;
|
|
|
|
[HttpGet("courses/{courseId:guid}/comments")]
|
|
public async Task<ActionResult> GetComments(
|
|
Guid courseId,
|
|
DateTime? beforeCreatedAt,
|
|
Guid? beforeId,
|
|
int pageSize = 30,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound();
|
|
if (beforeCreatedAt.HasValue != beforeId.HasValue)
|
|
return BadRequest("评论游标不完整。");
|
|
|
|
pageSize = Math.Clamp(pageSize, 1, 50);
|
|
var currentUserId = currentUserDataScope.Current.UserId;
|
|
var canModerate = CanModerateComments();
|
|
var source = db.GradeAnalyticsComments.AsNoTracking()
|
|
.Where(x => x.CourseId == courseId && x.ParentCommentId == null);
|
|
if (beforeCreatedAt.HasValue)
|
|
{
|
|
var cursorAt = beforeCreatedAt.Value;
|
|
var cursorId = beforeId!.Value;
|
|
source = source.Where(x => x.CreatedAt < cursorAt ||
|
|
x.CreatedAt == cursorAt && x.Id.CompareTo(cursorId) < 0);
|
|
}
|
|
|
|
var rows = await source
|
|
.OrderByDescending(x => x.CreatedAt)
|
|
.ThenByDescending(x => x.Id)
|
|
.Take(pageSize + 1)
|
|
.Join(db.Users.AsNoTracking(), comment => comment.AuthorUserId, user => user.Id,
|
|
(comment, user) => new
|
|
{
|
|
comment.Id,
|
|
comment.Content,
|
|
comment.IsAnonymous,
|
|
comment.CreatedAt,
|
|
AuthorName = comment.IsAnonymous ? "匿名用户" : user.DisplayName,
|
|
CanDelete = comment.AuthorUserId == currentUserId || canModerate,
|
|
ReplyCount = db.GradeAnalyticsComments.Count(reply => reply.ParentCommentId == comment.Id),
|
|
LikeCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Like),
|
|
InsightfulCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Insightful),
|
|
AgreeCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Agree),
|
|
HoorayCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Hooray),
|
|
HeartCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Heart),
|
|
RocketCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Rocket),
|
|
EyesCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Eyes),
|
|
LaughCount = db.GradeAnalyticsCommentReactions.Count(reaction => reaction.CommentId == comment.Id && reaction.Type == GradeAnalyticsCommentReactionType.Laugh),
|
|
HasLiked = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Like),
|
|
HasInsightful = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Insightful),
|
|
HasAgreed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Agree),
|
|
HasHoorayed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Hooray),
|
|
HasHearted = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Heart),
|
|
HasRocketed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Rocket),
|
|
HasEyed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Eyes),
|
|
HasLaughed = db.GradeAnalyticsCommentReactions.Any(reaction => reaction.CommentId == comment.Id && reaction.UserId == currentUserId && reaction.Type == GradeAnalyticsCommentReactionType.Laugh)
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
var hasMore = rows.Count > pageSize;
|
|
var items = rows.Take(pageSize).ToArray();
|
|
var last = items.LastOrDefault();
|
|
return Ok(new
|
|
{
|
|
Items = items,
|
|
HasMore = hasMore,
|
|
NextCursor = hasMore && last is not null
|
|
? new { last.CreatedAt, last.Id }
|
|
: null
|
|
});
|
|
}
|
|
|
|
[HttpPost("courses/{courseId:guid}/comments")]
|
|
public async Task<ActionResult> CreateComment(
|
|
Guid courseId,
|
|
CreateGradeAnalyticsCommentRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound();
|
|
var content = request.Content?.Trim();
|
|
if (string.IsNullOrWhiteSpace(content))
|
|
return ValidationProblem("评论内容不能为空。");
|
|
if (content.Length > 1000)
|
|
return ValidationProblem("评论不能超过 1000 个字符。");
|
|
|
|
var userId = currentUserDataScope.Current.UserId;
|
|
if (userId == Guid.Empty) return Forbid();
|
|
|
|
var comment = new GradeAnalyticsComment
|
|
{
|
|
CourseId = courseId,
|
|
AuthorUserId = userId,
|
|
IsAnonymous = request.IsAnonymous,
|
|
Content = content
|
|
};
|
|
db.GradeAnalyticsComments.Add(comment);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return CreatedAtAction(nameof(GetComments), new { courseId });
|
|
}
|
|
|
|
[HttpGet("courses/{courseId:guid}/comments/{commentId:guid}/replies")]
|
|
public async Task<ActionResult> GetReplies(Guid courseId, Guid commentId, CancellationToken cancellationToken)
|
|
{
|
|
if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound();
|
|
var currentUserId = currentUserDataScope.Current.UserId;
|
|
var canModerate = CanModerateComments();
|
|
var items = await db.GradeAnalyticsComments.AsNoTracking()
|
|
.Where(x => x.CourseId == courseId && x.ParentCommentId == commentId)
|
|
.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id).Take(100)
|
|
.Join(db.Users.AsNoTracking(), comment => comment.AuthorUserId, user => user.Id,
|
|
(comment, user) => new { comment.Id, comment.Content, comment.IsAnonymous, comment.CreatedAt,
|
|
AuthorName = comment.IsAnonymous ? "匿名用户" : user.DisplayName,
|
|
CanDelete = comment.AuthorUserId == currentUserId || canModerate })
|
|
.ToListAsync(cancellationToken);
|
|
return Ok(new { Items = items });
|
|
}
|
|
|
|
[HttpPost("courses/{courseId:guid}/comments/{commentId:guid}/replies")]
|
|
public async Task<ActionResult> CreateReply(Guid courseId, Guid commentId,
|
|
CreateGradeAnalyticsCommentRequest request, CancellationToken cancellationToken)
|
|
{
|
|
if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound();
|
|
if (!await db.GradeAnalyticsComments.AnyAsync(x => x.Id == commentId && x.CourseId == courseId && x.ParentCommentId == null, cancellationToken)) return NotFound();
|
|
var content = request.Content?.Trim();
|
|
if (string.IsNullOrWhiteSpace(content) || content.Length > 1000) return ValidationProblem("回复内容应为 1 到 1000 个字符。");
|
|
var userId = currentUserDataScope.Current.UserId;
|
|
if (userId == Guid.Empty) return Forbid();
|
|
db.GradeAnalyticsComments.Add(new GradeAnalyticsComment { CourseId = courseId, ParentCommentId = commentId, AuthorUserId = userId, IsAnonymous = request.IsAnonymous, Content = content });
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPost("courses/{courseId:guid}/comments/{commentId:guid}/reactions/{reaction}")]
|
|
public async Task<ActionResult> ToggleReaction(Guid courseId, Guid commentId, string reaction, CancellationToken cancellationToken)
|
|
{
|
|
if (!Enum.TryParse<GradeAnalyticsCommentReactionType>(reaction, true, out var type)) return BadRequest("不支持的 reaction。");
|
|
if (!await CanAccessCommentCourseAsync(courseId, cancellationToken)) return NotFound();
|
|
if (!await db.GradeAnalyticsComments.AnyAsync(x => x.Id == commentId && x.CourseId == courseId, cancellationToken)) return NotFound();
|
|
var userId = currentUserDataScope.Current.UserId;
|
|
var existing = await db.GradeAnalyticsCommentReactions.SingleOrDefaultAsync(x => x.CommentId == commentId && x.UserId == userId && x.Type == type, cancellationToken);
|
|
if (existing is null) db.GradeAnalyticsCommentReactions.Add(new GradeAnalyticsCommentReaction { CommentId = commentId, UserId = userId, Type = type }); else db.GradeAnalyticsCommentReactions.Remove(existing);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpDelete("comments/{id:guid}")]
|
|
public async Task<ActionResult> DeleteComment(Guid id, CancellationToken cancellationToken)
|
|
{
|
|
var comment = await db.GradeAnalyticsComments
|
|
.SingleOrDefaultAsync(x => x.Id == id, cancellationToken);
|
|
if (comment is null) return NotFound();
|
|
if (comment.AuthorUserId != currentUserDataScope.Current.UserId && !CanModerateComments())
|
|
return Forbid();
|
|
|
|
db.GradeAnalyticsComments.Remove(comment);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
private bool CanModerateComments() =>
|
|
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
|
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
|
|
|
private Task<bool> CanAccessCommentCourseAsync(
|
|
Guid courseId,
|
|
CancellationToken cancellationToken) =>
|
|
db.TeachingTaskGradeStatistics.AsNoTracking().AnyAsync(x =>
|
|
x.CourseId == courseId &&
|
|
VisibleTeachingTasks().Any(task => task.Id == x.TeachingTaskId),
|
|
cancellationToken);
|
|
|
|
[HttpGet("refresh-schedule")]
|
|
[Authorize(Roles = ScheduleManagers)]
|
|
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
|
|
{
|
|
var setting = await db.CourseGradeStatisticsRefreshSettings.AsNoTracking()
|
|
.SingleOrDefaultAsync(
|
|
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
|
cancellationToken);
|
|
var defaults = new CourseGradeStatisticsRefreshSetting();
|
|
var enabled = setting?.IsEnabled ?? defaults.IsEnabled;
|
|
var intervalSeconds = setting?.IntervalSeconds ?? defaults.IntervalSeconds;
|
|
var batchSize = setting?.BatchSize ?? defaults.BatchSize;
|
|
var lastRunAt = setting?.LastRunAt;
|
|
return Ok(new
|
|
{
|
|
IsEnabled = enabled,
|
|
IntervalSeconds = intervalSeconds,
|
|
BatchSize = batchSize,
|
|
LastRunAt = lastRunAt,
|
|
NextRunAt = enabled && lastRunAt.HasValue
|
|
? lastRunAt.Value.AddSeconds(intervalSeconds)
|
|
: null as DateTime?
|
|
});
|
|
}
|
|
|
|
[HttpPut("refresh-schedule")]
|
|
[Authorize(Roles = ScheduleManagers)]
|
|
public async Task<ActionResult> SaveRefreshSchedule(
|
|
SaveGradeStatisticsRefreshScheduleRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request.IntervalSeconds is < 10 or > 86400)
|
|
return BadRequest(new ProblemDetails
|
|
{
|
|
Title = "刷新间隔应在 10 秒到 24 小时之间。",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
if (request.BatchSize is < 1 or > 5000)
|
|
return BadRequest(new ProblemDetails
|
|
{
|
|
Title = "单次刷新批量应在 1 到 5000 之间。",
|
|
Status = StatusCodes.Status400BadRequest
|
|
});
|
|
|
|
var setting = await db.CourseGradeStatisticsRefreshSettings
|
|
.SingleOrDefaultAsync(
|
|
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
|
|
cancellationToken);
|
|
if (setting is null)
|
|
{
|
|
setting = new CourseGradeStatisticsRefreshSetting();
|
|
db.CourseGradeStatisticsRefreshSettings.Add(setting);
|
|
}
|
|
|
|
setting.IsEnabled = request.IsEnabled;
|
|
setting.IntervalSeconds = request.IntervalSeconds;
|
|
setting.BatchSize = request.BatchSize;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
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,
|
|
string? keyword,
|
|
Guid? collegeId = null,
|
|
string? teacherKeyword = 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);
|
|
if (collegeId.HasValue)
|
|
source = source.Where(x => x.TeachingTask!.Course!.CollegeId == collegeId.Value);
|
|
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));
|
|
if (!string.IsNullOrWhiteSpace(teacherKeyword))
|
|
{
|
|
teacherKeyword = teacherKeyword.Trim();
|
|
source = source.Where(x => x.TeachingTask!.Teachers.Any(item =>
|
|
item.Teacher!.Name.Contains(teacherKeyword) ||
|
|
item.Teacher.TeacherNumber.Contains(teacherKeyword)));
|
|
}
|
|
if (riskOnly == true)
|
|
source = source.Where(x => x.PassRate < 60 || x.AverageScore < 60);
|
|
|
|
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, officialDocumentOptions.FontPath);
|
|
var fileName = DownloadFileName.Export("成绩分析报告", ".docx", report.CourseCode, report.TaskNumber);
|
|
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);
|
|
|
|
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,
|
|
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);
|
|
}
|
|
|
|
public sealed record SaveGradeStatisticsRefreshScheduleRequest(
|
|
bool IsEnabled,
|
|
int IntervalSeconds,
|
|
int BatchSize);
|
|
|
|
public sealed record CreateGradeAnalyticsCommentRequest(
|
|
string? Content,
|
|
bool IsAnonymous);
|