实验成绩计算
This commit is contained in:
1 parent
d4abdd3db4
commit
c25f16ec20
12 files changed
+7622
-259
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Data;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
@@ -138,7 +139,7 @@ public sealed class ExperimentGradesController(
|
|||||||
if (student is null)
|
if (student is null)
|
||||||
return ConflictProblem("当前账号未关联有效学生档案。");
|
return ConflictProblem("当前账号未关联有效学生档案。");
|
||||||
|
|
||||||
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
|
var results = await db.ExperimentGradeRecords.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.StudentId == student.Id &&
|
x.StudentId == student.Id &&
|
||||||
x.ExperimentGradeSheet!.Status ==
|
x.ExperimentGradeSheet!.Status ==
|
||||||
@@ -157,12 +158,19 @@ public sealed class ExperimentGradesController(
|
|||||||
ProjectName =
|
ProjectName =
|
||||||
x.ExperimentGradeSheet.ExperimentProject.Name,
|
x.ExperimentGradeSheet.ExperimentProject.Name,
|
||||||
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
|
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
|
||||||
|
TeachingTaskId = x.ExperimentGradeSheet.ExperimentProject
|
||||||
|
.TeachingTaskId,
|
||||||
|
AcademicTermId = x.ExperimentGradeSheet.ExperimentProject
|
||||||
|
.TeachingTask!.AcademicTermId,
|
||||||
|
TaskNumber = x.ExperimentGradeSheet.ExperimentProject
|
||||||
|
.TeachingTask.TaskNumber,
|
||||||
CourseCode = x.ExperimentGradeSheet.ExperimentProject
|
CourseCode = x.ExperimentGradeSheet.ExperimentProject
|
||||||
.TeachingTask!.Course!.Code,
|
.TeachingTask!.Course!.Code,
|
||||||
CourseName = x.ExperimentGradeSheet.ExperimentProject
|
CourseName = x.ExperimentGradeSheet.ExperimentProject
|
||||||
.TeachingTask.Course.Name,
|
.TeachingTask.Course.Name,
|
||||||
TermName = x.ExperimentGradeSheet.ExperimentProject
|
TermName = x.ExperimentGradeSheet.ExperimentProject
|
||||||
.TeachingTask.AcademicTerm!.Name,
|
.TeachingTask.AcademicTerm!.Name,
|
||||||
|
x.ExperimentGradeSheet.ContributionWeight,
|
||||||
x.ExperimentGradeSheet.PassScore,
|
x.ExperimentGradeSheet.PassScore,
|
||||||
x.ParticipationStatus,
|
x.ParticipationStatus,
|
||||||
x.TotalScore,
|
x.TotalScore,
|
||||||
@@ -187,7 +195,40 @@ public sealed class ExperimentGradesController(
|
|||||||
}),
|
}),
|
||||||
x.ExperimentGradeSheet.PublishedAt
|
x.ExperimentGradeSheet.PublishedAt
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken);
|
||||||
|
var taskIds = results.Select(x => x.TeachingTaskId).Distinct().ToArray();
|
||||||
|
var aggregates = await db.ExperimentCourseGrades.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
taskIds.Contains(x.TeachingTaskId))
|
||||||
|
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||||
|
var courses = results
|
||||||
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var first = group.First();
|
||||||
|
aggregates.TryGetValue(group.Key, out var aggregate);
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
TeachingTaskId = group.Key,
|
||||||
|
first.AcademicTermId,
|
||||||
|
first.TermName,
|
||||||
|
first.TaskNumber,
|
||||||
|
first.CourseCode,
|
||||||
|
first.CourseName,
|
||||||
|
ExperimentCourseScore = aggregate?.WeightedAverageScore,
|
||||||
|
PublishedProjectCount =
|
||||||
|
aggregate?.PublishedProjectCount ?? group.Count(),
|
||||||
|
TotalWeight = aggregate?.TotalWeight ??
|
||||||
|
group.Sum(x => x.ContributionWeight),
|
||||||
|
RefreshedAt = aggregate?.RefreshedAt,
|
||||||
|
Projects = group.OrderBy(x => x.ProjectCode).ToList()
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Projects.Max(project => project.PublishedAt))
|
||||||
|
.ThenBy(x => x.CourseCode)
|
||||||
|
.ToList();
|
||||||
|
return Ok(courses);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sheets")]
|
[HttpPost("sheets")]
|
||||||
@@ -744,7 +785,15 @@ public sealed class ExperimentGradesController(
|
|||||||
|
|
||||||
sheet.Status = ExperimentGradeSheetStatus.Published;
|
sheet.Status = ExperimentGradeSheetStatus.Published;
|
||||||
sheet.PublishedAt = DateTime.UtcNow;
|
sheet.PublishedAt = DateTime.UtcNow;
|
||||||
|
await db.ExecuteInRetriableTransactionAsync(async transaction =>
|
||||||
|
{
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await ExperimentGradeAggregationService.RefreshTeachingTaskAsync(
|
||||||
|
db,
|
||||||
|
sheet.ExperimentProject!.TeachingTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}, cancellationToken, IsolationLevel.Serializable);
|
||||||
var userIds = await db.ExperimentGradeRecords
|
var userIds = await db.ExperimentGradeRecords
|
||||||
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
|
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
|
||||||
.Select(x => x.Student!.UserId)
|
.Select(x => x.Student!.UserId)
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ public sealed class ExperimentGradeRecord : EntityBase
|
|||||||
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
|
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A student's persisted experiment-part score for one teaching task.
|
||||||
|
/// The score is the weighted average of every published experiment project.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExperimentCourseGrade : EntityBase
|
||||||
|
{
|
||||||
|
public Guid TeachingTaskId { get; set; }
|
||||||
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public decimal? WeightedAverageScore { get; set; }
|
||||||
|
public decimal TotalWeight { get; set; }
|
||||||
|
public int PublishedProjectCount { get; set; }
|
||||||
|
public DateTime RefreshedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ExperimentGradeItemScore
|
public sealed class ExperimentGradeItemScore
|
||||||
{
|
{
|
||||||
public Guid ExperimentGradeRecordId { get; set; }
|
public Guid ExperimentGradeRecordId { get; set; }
|
||||||
|
|||||||
@@ -5,13 +5,85 @@ namespace Jiaowu.Api.Infrastructure.Grades;
|
|||||||
|
|
||||||
public static class ExperimentGradeAggregationService
|
public static class ExperimentGradeAggregationService
|
||||||
{
|
{
|
||||||
|
public static async Task RefreshTeachingTaskAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
Guid teachingTaskId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheets = await LoadPublishedSheetsAsync(
|
||||||
|
db,
|
||||||
|
teachingTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
var existing = await db.ExperimentCourseGrades
|
||||||
|
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||||||
|
.ToDictionaryAsync(x => x.StudentId, cancellationToken);
|
||||||
|
var studentIds = sheets
|
||||||
|
.SelectMany(x => x.Scores.Select(score => score.StudentId))
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
var refreshedAt = DateTime.UtcNow;
|
||||||
|
var totalWeight = sheets.Sum(x => x.ContributionWeight);
|
||||||
|
|
||||||
|
foreach (var studentId in studentIds)
|
||||||
|
{
|
||||||
|
var score = CalculateStudentScore(sheets, studentId);
|
||||||
|
if (!existing.Remove(studentId, out var aggregate))
|
||||||
|
{
|
||||||
|
aggregate = new Domain.Academic.ExperimentCourseGrade
|
||||||
|
{
|
||||||
|
TeachingTaskId = teachingTaskId,
|
||||||
|
StudentId = studentId
|
||||||
|
};
|
||||||
|
db.ExperimentCourseGrades.Add(aggregate);
|
||||||
|
}
|
||||||
|
aggregate.WeightedAverageScore = score;
|
||||||
|
aggregate.TotalWeight = totalWeight;
|
||||||
|
aggregate.PublishedProjectCount = sheets.Count;
|
||||||
|
aggregate.RefreshedAt = refreshedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ExperimentCourseGrades.RemoveRange(existing.Values);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
|
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
Guid teachingTaskId,
|
Guid teachingTaskId,
|
||||||
IReadOnlyCollection<Guid> studentIds,
|
IReadOnlyCollection<Guid> studentIds,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sheets = await db.ExperimentGradeSheets.AsNoTracking()
|
var sheets = await LoadPublishedSheetsAsync(
|
||||||
|
db,
|
||||||
|
teachingTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
var requestedStudentIds = studentIds.Distinct().ToArray();
|
||||||
|
var persistedScores = await db.ExperimentCourseGrades.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.TeachingTaskId == teachingTaskId &&
|
||||||
|
requestedStudentIds.Contains(x.StudentId))
|
||||||
|
.ToDictionaryAsync(
|
||||||
|
x => x.StudentId,
|
||||||
|
x => x.WeightedAverageScore,
|
||||||
|
cancellationToken);
|
||||||
|
var scores = requestedStudentIds.ToDictionary(
|
||||||
|
studentId => studentId,
|
||||||
|
studentId => persistedScores.GetValueOrDefault(studentId));
|
||||||
|
|
||||||
|
return new ExperimentGradeAggregateResult(
|
||||||
|
sheets.Count,
|
||||||
|
sheets.Select(x => new ExperimentGradeAggregateProject(
|
||||||
|
x.Id,
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.ContributionWeight)).ToList(),
|
||||||
|
scores);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<List<PublishedExperimentSheet>> LoadPublishedSheetsAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
Guid teachingTaskId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
db.ExperimentGradeSheets.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.Status ==
|
x.Status ==
|
||||||
Domain.Academic.ExperimentGradeSheetStatus.Published &&
|
Domain.Academic.ExperimentGradeSheetStatus.Published &&
|
||||||
@@ -28,25 +100,22 @@ public static class ExperimentGradeAggregationService
|
|||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var scores = new Dictionary<Guid, decimal?>();
|
private static decimal? CalculateStudentScore(
|
||||||
foreach (var studentId in studentIds.Distinct())
|
IReadOnlyCollection<PublishedExperimentSheet> sheets,
|
||||||
|
Guid studentId)
|
||||||
{
|
{
|
||||||
decimal weightedTotal = 0;
|
decimal weightedTotal = 0;
|
||||||
decimal totalWeight = 0;
|
decimal totalWeight = 0;
|
||||||
var complete = sheets.Count > 0;
|
if (sheets.Count == 0) return null;
|
||||||
foreach (var sheet in sheets)
|
foreach (var sheet in sheets)
|
||||||
{
|
{
|
||||||
var score = sheet.Scores.FirstOrDefault(x =>
|
var score = sheet.Scores.FirstOrDefault(x =>
|
||||||
x.StudentId == studentId);
|
x.StudentId == studentId);
|
||||||
if (score?.TotalScore is not decimal totalScore)
|
if (score?.TotalScore is not decimal totalScore) return null;
|
||||||
{
|
|
||||||
complete = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
weightedTotal += totalScore * sheet.ContributionWeight;
|
weightedTotal += totalScore * sheet.ContributionWeight;
|
||||||
totalWeight += sheet.ContributionWeight;
|
totalWeight += sheet.ContributionWeight;
|
||||||
}
|
}
|
||||||
scores[studentId] = complete && totalWeight > 0
|
return totalWeight > 0
|
||||||
? Math.Round(
|
? Math.Round(
|
||||||
weightedTotal / totalWeight,
|
weightedTotal / totalWeight,
|
||||||
1,
|
1,
|
||||||
@@ -54,16 +123,6 @@ public static class ExperimentGradeAggregationService
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ExperimentGradeAggregateResult(
|
|
||||||
sheets.Count,
|
|
||||||
sheets.Select(x => new ExperimentGradeAggregateProject(
|
|
||||||
x.Id,
|
|
||||||
x.Code,
|
|
||||||
x.Name,
|
|
||||||
x.ContributionWeight)).ToList(),
|
|
||||||
scores);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record PublishedExperimentSheet(
|
private sealed record PublishedExperimentSheet(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Code,
|
string Code,
|
||||||
|
|||||||
Loaded 3 of 12 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user