成绩统计图
This commit is contained in:
1 parent
5f30dd6766
commit
924574256a
20 files changed
+7379
-4
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,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -910,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 });
|
||||||
}
|
}
|
||||||
@@ -948,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,
|
||||||
@@ -964,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();
|
||||||
@@ -1036,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();
|
||||||
}
|
}
|
||||||
@@ -1045,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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,57 @@ public sealed class GradeItemScore
|
|||||||
public decimal? Score { get; set; }
|
public decimal? Score { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persisted course-result aggregate. One course/term is materialized at each
|
||||||
|
/// organizational level so the result-analysis page never aggregates raw
|
||||||
|
/// grade records on request.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CourseGradeStatistic : EntityBase
|
||||||
|
{
|
||||||
|
public Guid CourseId { get; set; }
|
||||||
|
public Guid AcademicTermId { get; set; }
|
||||||
|
public CourseGradeStatisticScope Scope { get; set; }
|
||||||
|
public Guid? ScopeEntityId { get; set; }
|
||||||
|
public int StudentCount { get; set; }
|
||||||
|
public int PassedCount { get; set; }
|
||||||
|
public int Below60Count { get; set; }
|
||||||
|
public int From60To69Count { get; set; }
|
||||||
|
public int From70To79Count { get; set; }
|
||||||
|
public int From80To89Count { get; set; }
|
||||||
|
public int From90To100Count { get; set; }
|
||||||
|
public decimal HighestScore { get; set; }
|
||||||
|
public decimal AverageScore { get; set; }
|
||||||
|
public decimal LowestScore { get; set; }
|
||||||
|
public decimal PassRate { get; set; }
|
||||||
|
public DateTime CalculatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CourseGradeStatisticScope
|
||||||
|
{
|
||||||
|
AdministrativeClass = 1,
|
||||||
|
Major = 2,
|
||||||
|
College = 3,
|
||||||
|
University = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CourseGradeStatisticsRefreshJob : EntityBase
|
||||||
|
{
|
||||||
|
public Guid GradeSheetId { get; set; }
|
||||||
|
public CourseGradeStatisticsRefreshJobStatus Status { get; set; } =
|
||||||
|
CourseGradeStatisticsRefreshJobStatus.Queued;
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CourseGradeStatisticsRefreshJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
|
}
|
||||||
|
|
||||||
public enum GradeSheetStatus
|
public enum GradeSheetStatus
|
||||||
{
|
{
|
||||||
Draft = 1,
|
Draft = 1,
|
||||||
|
|||||||
Loaded 3 of 20 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user