Compare commits
2
Commits
v2.3.0
...
924574256a
No files matched your search
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -250,6 +251,13 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
// Auto-apply: update grade record
|
||||
gm.GradeRecord!.TotalScore = 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 NotificationService.SendAsync(
|
||||
db,
|
||||
|
||||
@@ -3,7 +3,9 @@ using System.Globalization;
|
||||
using Jiaowu.Api.Contracts;
|
||||
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.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -19,7 +21,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/grades")]
|
||||
public sealed class GradesController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache? cache = null) : ControllerBase
|
||||
{
|
||||
private const string SheetUsers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -37,6 +40,9 @@ public sealed class GradesController(
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin;
|
||||
|
||||
private const string StatisticsUsers =
|
||||
SheetUsers + "," + SystemRoles.Leader + "," + SystemRoles.Student;
|
||||
|
||||
[HttpGet("sheets")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> GetSheets(
|
||||
@@ -502,6 +508,7 @@ public sealed class GradesController(
|
||||
}
|
||||
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
@@ -661,6 +668,7 @@ public sealed class GradesController(
|
||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||
sheet.Status = GradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
|
||||
// The grade sheet roster is authoritative at publication time. This also
|
||||
// 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 headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||
headers.AddRange(itemNames);
|
||||
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||
|
||||
var rows = sheet.Records.Select(record =>
|
||||
{
|
||||
@@ -716,6 +724,7 @@ public sealed class GradesController(
|
||||
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
||||
values.Add(score);
|
||||
}
|
||||
values.Add(null);
|
||||
values.Add(record.FinalScore);
|
||||
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||||
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||||
@@ -729,13 +738,56 @@ public sealed class GradesController(
|
||||
{
|
||||
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
|
||||
"成绩列填写 0—100 的数值,留空表示暂未录入。",
|
||||
"总分(自动计算)列由 Excel 按各部分比例自动计算,仅供填写时预览;上传时系统不会采用该列结果。",
|
||||
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
|
||||
$"本成绩单共 {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(
|
||||
"成绩导入", 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;
|
||||
return File(bytes, ExcelWorkbookHelper.ContentType,
|
||||
$"成绩导入模板-{taskName}.xlsx");
|
||||
@@ -768,13 +820,15 @@ public sealed class GradesController(
|
||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||
headers.AddRange(itemNames);
|
||||
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||
|
||||
IReadOnlyList<ExcelRow> rows;
|
||||
try
|
||||
{
|
||||
rows = await ExcelWorkbookHelper.ReadAsync(
|
||||
file, headers, cancellationToken);
|
||||
file,
|
||||
headers.Where(x => x != "总分(自动计算)").ToArray(),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (InvalidDataException exception)
|
||||
{
|
||||
@@ -810,7 +864,7 @@ public sealed class GradesController(
|
||||
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
|
||||
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);
|
||||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||
|
||||
@@ -864,6 +918,7 @@ public sealed class GradesController(
|
||||
if (errors.Count > 0)
|
||||
return ImportValidationProblem(errors);
|
||||
|
||||
QueueCourseStatisticsRefresh(sheet.Id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new { updated, total = rows.Count });
|
||||
}
|
||||
@@ -902,6 +957,7 @@ public sealed class GradesController(
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
GradeSheetId = x.GradeSheetId,
|
||||
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
||||
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTaskId,
|
||||
@@ -918,6 +974,121 @@ public sealed class GradesController(
|
||||
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()
|
||||
{
|
||||
var source = db.TeachingTasks.AsQueryable();
|
||||
@@ -990,6 +1161,7 @@ public sealed class GradesController(
|
||||
{
|
||||
try
|
||||
{
|
||||
QueueCourseStatisticsRefresh(id);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
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) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
@@ -1041,6 +1221,18 @@ public sealed class GradesController(
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
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(
|
||||
|
||||
@@ -146,7 +146,10 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
{
|
||||
var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList();
|
||||
if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count()) return ValidationProblem("同一考试批次中学生不能重复出现。");
|
||||
var students = await db.Students.Where(x => numbers.Contains(x.StudentNumber)).ToDictionaryAsync(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, ct);
|
||||
var studentRows = await db.Students
|
||||
.Where(x => numbers.Contains(x.StudentNumber))
|
||||
.ToListAsync(ct);
|
||||
var students = studentRows.ToDictionary(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase);
|
||||
if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
|
||||
foreach (var item in inputs)
|
||||
{
|
||||
@@ -157,11 +160,30 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
|
||||
var beforeCount = await db.OtherExamResults.AsNoTracking()
|
||||
.Where(x => x.OtherExamBatchId != batch.Id && studentIds.Contains(x.StudentId) && (x.OtherExamBatch!.ExamCode == batch.ExamCode || (x.OtherExamBatch.ExamCode == null && batch.ExamCode == null && x.OtherExamBatch.Name == batch.Name)) && (x.OtherExamBatch.ExamDate < batch.ExamDate || (x.OtherExamBatch.ExamDate == batch.ExamDate && x.OtherExamBatch.CreatedAt < batch.CreatedAt)))
|
||||
.GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Count = x.Count() }).ToDictionaryAsync(x => x.StudentId, x => x.Count, ct);
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult?>(async transaction =>
|
||||
{
|
||||
db.OtherExamResults.RemoveRange(batch.Results);
|
||||
batch.Status = OtherExamBatchStatus.Draft;
|
||||
batch.Results = inputs.Select(x => { var student = students[x.StudentNumber.Trim()]; return new OtherExamResult { OtherExamBatchId = batch.Id, StudentId = student.Id, AttemptNumber = (beforeCount.GetValueOrDefault(student.Id) + 1), Score = x.Score, Level = Normalize(x.Level), IsPassed = x.IsPassed, Notes = Normalize(x.Notes) }; }).ToList();
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
batch.Results = inputs.Select(x =>
|
||||
{
|
||||
var student = students[x.StudentNumber.Trim()];
|
||||
return new OtherExamResult
|
||||
{
|
||||
OtherExamBatchId = batch.Id,
|
||||
StudentId = student.Id,
|
||||
AttemptNumber = beforeCount.GetValueOrDefault(student.Id) + 1,
|
||||
Score = x.Score,
|
||||
Level = Normalize(x.Level),
|
||||
IsPassed = x.IsPassed,
|
||||
Notes = Normalize(x.Notes)
|
||||
};
|
||||
}).ToList();
|
||||
await db.SaveChangesAsync(ct);
|
||||
await transaction.CommitAsync(ct);
|
||||
return null;
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch
|
||||
|
||||
Loaded 3 of 25 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user