Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/GradesController.cs
T
2026-07-27 08:59:02 +08:00

1003 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/grades")]
public sealed class GradesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string SheetUsers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Counselor + "," +
SystemRoles.Teacher;
private const string Reviewers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string Publishers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
[HttpGet("sheets")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> GetSheets(
Guid? academicTermId,
GradeSheetStatus? status,
string? keyword = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 50);
var source = AccessibleTasks().AsNoTracking()
.Where(x =>
x.Status == TeachingTaskStatus.Published ||
x.Status == TeachingTaskStatus.Closed);
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (status.HasValue)
source = source.Where(x => db.GradeSheets.Any(sheet =>
sheet.TeachingTaskId == x.Id && sheet.Status == status.Value));
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
x.TaskNumber.Contains(keyword) ||
x.Name.Contains(keyword) ||
x.Course!.Code.Contains(keyword) ||
x.Course.Name.Contains(keyword));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.TaskNumber,
TaskName = x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
CollegeName = x.Course.College!.Name,
x.Course.Credits,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name),
Sheet = db.GradeSheets
.Where(sheet => sheet.TeachingTaskId == x.Id)
.Select(sheet => new
{
sheet.Id,
sheet.Status,
sheet.RegularWeight,
sheet.FinalWeight,
Items = sheet.Items.OrderBy(item => item.SortOrder).Select(item => new
{
item.Id,
item.Name,
item.Weight
}),
StudentCount = sheet.Records.Count,
CompletedCount = sheet.Records.Count(record =>
record.TotalScore != null ||
record.ExamStatus != GradeExamStatus.Normal),
PassedCount = sheet.Records.Count(record =>
record.TotalScore >= 60),
sheet.SubmittedAt,
sheet.ReviewedAt,
sheet.PublishedAt,
sheet.UpdatedAt
})
.FirstOrDefault()
})
.ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpPost("sheets")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> CreateSheet(
GradeSheetRequest request,
CancellationToken cancellationToken)
{
var items = request.Items?
.Select((item, index) => new GradeItem
{
Name = item.Name.Trim(),
Weight = item.Weight,
SortOrder = index
})
.ToList() ?? [];
if (!GradeCalculator.AreWeightsValid(
request.RegularWeight,
request.FinalWeight,
items))
return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");
var task = await AccessibleTasks()
.Include(x => x.Teachers)
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null) return NotFound();
if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed))
return ConflictProblem("只有已发布或已结课的教学班可以建立成绩单。");
if (!CanInitialize(task)) return Forbid();
if (await db.GradeSheets.AnyAsync(
x => x.TeachingTaskId == task.Id,
cancellationToken))
return ConflictProblem("该教学班已经建立成绩单。");
var studentIds = await TeachingTaskRosterQuery
.ForTask(db, task.Id)
.AsNoTracking()
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (studentIds.Count == 0)
return ConflictProblem("教学班当前没有有效学生,无法建立成绩单。");
var sheet = new GradeSheet
{
TeachingTaskId = task.Id,
RegularWeight = request.RegularWeight,
FinalWeight = request.FinalWeight,
Items = items,
Records = studentIds.Select(studentId => new GradeRecord
{
StudentId = studentId,
ItemScores = items.Select(item => new GradeItemScore
{
GradeItemId = item.Id
}).ToList()
}).ToList()
};
db.GradeSheets.Add(sheet);
return await SaveAsync(sheet.Id, true, cancellationToken);
}
[HttpGet("sheets/{id:guid}")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> GetSheet(
Guid id,
int recordPage = 1,
int recordPageSize = 50,
string? studentKeyword = null,
CancellationToken cancellationToken = default)
{
recordPage = Math.Max(1, recordPage);
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
? null
: studentKeyword.Trim();
var sheet = await AccessibleSheets().AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
x.TeachingTask.AcademicTermId,
TermName = x.TeachingTask.AcademicTerm!.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CourseCollegeId = x.TeachingTask.Course.CollegeId,
CourseCollegeName = x.TeachingTask.Course.College!.Name,
x.TeachingTask.Course.Credits,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
x.RegularWeight,
x.FinalWeight,
Items = x.Items.OrderBy(item => item.SortOrder).Select(item => new
{
item.Id,
item.Name,
item.Weight
}),
x.Status,
x.ReviewComment,
x.SubmittedAt,
x.ReviewedAt,
x.PublishedAt,
x.CreatedAt,
x.UpdatedAt
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var recordsSource = db.GradeRecords.AsNoTracking()
.Where(record => record.GradeSheetId == id);
if (studentKeyword is not null)
recordsSource = recordsSource.Where(record =>
record.Student!.StudentNumber.Contains(studentKeyword) ||
record.Student.Name.Contains(studentKeyword) ||
record.Student.AdministrativeClass!.Name.Contains(studentKeyword));
var recordTotal = await recordsSource.CountAsync(cancellationToken);
var records = await recordsSource
.OrderBy(record => record.Student!.StudentNumber)
.Skip((recordPage - 1) * recordPageSize)
.Take(recordPageSize)
.Select(record => new
{
record.Id,
record.StudentId,
record.Student!.StudentNumber,
record.Student.Name,
ClassName = record.Student.AdministrativeClass!.Name,
record.RegularScore,
record.FinalScore,
ItemScores = record.ItemScores
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
.Select(itemScore => new
{
itemScore.GradeItemId,
itemScore.GradeItem!.Name,
itemScore.Score
}),
record.TotalScore,
record.GradePoint,
record.ExamStatus,
record.Notes,
record.UpdatedAt
})
.ToListAsync(cancellationToken);
var task = await db.TeachingTasks.AsNoTracking()
.Include(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.SingleAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken);
// Determine if current user is from the course's college
var scope = currentUserDataScope.Current;
var isCourseCollegeReviewer = IsReviewer() &&
(scope.Scope == DataScope.All ||
scope.RestrictedCollegeId == sheet.CourseCollegeId);
return Ok(new
{
Sheet = new
{
sheet.Id,
sheet.TeachingTaskId,
sheet.TaskNumber,
sheet.TaskName,
sheet.AcademicTermId,
sheet.TermName,
sheet.CourseCode,
sheet.CourseName,
sheet.CourseCollegeId,
sheet.CourseCollegeName,
sheet.Credits,
sheet.TeacherNames,
sheet.ClassNames,
sheet.RegularWeight,
sheet.FinalWeight,
sheet.Items,
sheet.Status,
sheet.ReviewComment,
sheet.SubmittedAt,
sheet.ReviewedAt,
sheet.PublishedAt,
RecordTotal = recordTotal,
RecordPage = recordPage,
RecordPageSize = recordPageSize,
Records = records,
sheet.CreatedAt,
sheet.UpdatedAt
},
CanEdit = CanEditScores(task) &&
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted,
CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved,
NeedsCollegeReview = sheet.CourseCollegeName
});
}
[HttpPut("sheets/{id:guid}/weights")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> UpdateWeights(
Guid id,
GradeWeightsRequest request,
CancellationToken cancellationToken)
{
var items = request.Items?
.Select((item, index) => new GradeItem
{
Name = item.Name.Trim(),
Weight = item.Weight,
SortOrder = index
})
.ToList() ?? [];
if (!GradeCalculator.AreWeightsValid(
request.RegularWeight,
request.FinalWeight,
items))
return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("当前状态不能修改成绩构成。");
sheet.RegularWeight = request.RegularWeight;
sheet.FinalWeight = request.FinalWeight;
db.GradeItems.RemoveRange(sheet.Items);
var itemIdsToRemove = sheet.Items.Select(item => item.Id).ToHashSet();
foreach (var record in sheet.Records)
{
var toRemove = record.ItemScores
.Where(itemScore => itemIdsToRemove.Contains(itemScore.GradeItemId))
.ToList();
foreach (var removed in toRemove)
record.ItemScores.Remove(removed);
}
sheet.Items = items;
foreach (var record in sheet.Records)
{
foreach (var item in items)
{
record.ItemScores.Add(new GradeItemScore { GradeItemId = item.Id });
}
Recalculate(sheet, record);
}
return await SaveAsync(id, false, cancellationToken);
}
[HttpPut("sheets/{id:guid}/records")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> UpdateRecords(
Guid id,
GradeRecordsRequest request,
CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能继续修改。");
var records = sheet.Records.ToDictionary(x => x.Id);
if (request.Records.Select(x => x.Id).Distinct().Count() != request.Records.Count ||
request.Records.Any(x => !records.ContainsKey(x.Id)))
return ValidationProblem("包含无效或重复的成绩记录。");
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet();
foreach (var item in request.Records)
{
if (!ValidScore(item.RegularScore) ||
!ValidScore(item.FinalScore))
return ValidationProblem("成绩必须在 0—100 分之间。");
var record = records[item.Id];
record.RegularScore = item.RegularScore;
record.FinalScore = item.FinalScore;
record.ExamStatus = item.ExamStatus;
record.Notes = Normalize(item.Notes);
if (item.ItemScores is not null)
{
var scoreMap = record.ItemScores.ToDictionary(s => s.GradeItemId);
foreach (var scoreEntry in item.ItemScores)
{
if (!ValidScore(scoreEntry.Score))
return ValidationProblem("分项成绩必须在 0—100 分之间。");
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing))
existing.Score = scoreEntry.Score;
}
}
Recalculate(sheet, record);
}
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("只有草稿或已退回成绩单可以提交。");
if (sheet.Records.Count == 0)
return ConflictProblem("成绩单没有学生记录。");
// Final recalculation pass before submit
foreach (var record in sheet.Records)
Recalculate(sheet, record);
if (sheet.Records.Any(x =>
x.ExamStatus == GradeExamStatus.Normal &&
!x.TotalScore.HasValue))
return ConflictProblem("仍有学生缺少必填成绩分项,请补充后再提交。");
sheet.Status = GradeSheetStatus.Submitted;
sheet.SubmittedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
await db.SaveChangesAsync(cancellationToken);
// Notify college admins
var courseName = sheet.TeachingTask!.Course!.Name;
var collegeId = sheet.TeachingTask.Course.CollegeId;
await NotificationService.SendToRoleAsync(db,
SystemRoles.CollegeAdmin,
"成绩单待审核",
$"《{courseName}》成绩已提交,请及时审核。",
collegeId,
"/grades",
cancellationToken,
NotificationCategory.Grade);
return NoContent();
}
[HttpPost("sheets/{id:guid}/approve")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Approve(Guid id, CancellationToken cancellationToken)
{
var sheet = await db.GradeSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.ThenInclude(x => x!.College)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!IsReviewer()) return Forbid();
// College-specific check: CollegeAdmin must belong to the course's college
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.College &&
sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
return ConflictProblem(
$"该课程属于{sheet.TeachingTask.Course.College!.Name}" +
"您只能审核本学院课程的成绩单。");
if (sheet.Status != GradeSheetStatus.Submitted)
return ConflictProblem("只有待审核成绩单可以通过审核。");
sheet.Status = GradeSheetStatus.Approved;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
await db.SaveChangesAsync(cancellationToken);
// Notify teachers
var teacherUserIds = await db.TeachingTaskTeachers
.Where(x => x.TeachingTaskId == sheet.TeachingTaskId)
.Select(x => x.Teacher!.UserId)
.Where(id => id != null)
.Select(id => id!.Value)
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
"成绩审核通过",
$"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。",
"/grades", cancellationToken, NotificationCategory.Grade);
return NoContent();
}
[HttpPost("sheets/{id:guid}/return")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Return(
Guid id,
GradeReviewRequest request,
CancellationToken cancellationToken)
{
var sheet = await db.GradeSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.ThenInclude(x => x!.College)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!IsReviewer()) return Forbid();
// College-specific check
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.College &&
sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
return ConflictProblem(
$"该课程属于{sheet.TeachingTask.Course.College!.Name}" +
"您只能审核本学院课程的成绩单。");
if (sheet.Status != GradeSheetStatus.Submitted)
return ConflictProblem("只有待审核成绩单可以退回。");
if (string.IsNullOrWhiteSpace(request.Comment))
return ValidationProblem("退回时必须填写修改意见。");
sheet.Status = GradeSheetStatus.Returned;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = request.Comment.Trim();
await db.SaveChangesAsync(cancellationToken);
var teacherUserIds = await db.TeachingTaskTeachers
.Where(x => x.TeachingTaskId == sheet.TeachingTaskId)
.Select(x => x.Teacher!.UserId)
.Where(id => id != null)
.Select(id => id!.Value)
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
"成绩被退回",
$"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}",
"/grades", cancellationToken, NotificationCategory.Grade);
return NoContent();
}
[HttpPost("sheets/{id:guid}/publish")]
[Authorize(Roles = Publishers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(
x => x.Id == id,
cancellationToken);
if (sheet is null) return NotFound();
if (!IsPublisher()) return Forbid();
if (sheet.Status != GradeSheetStatus.Approved)
return ConflictProblem("只有审核通过的成绩单可以发布。");
sheet.Status = GradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
// The grade sheet roster is authoritative at publication time. This also
// covers students added through approved roster corrections.
var studentUserIds = await db.GradeRecords
.Where(x => x.GradeSheetId == sheet.Id)
.Select(x => x.Student!.UserId)
.Where(id => id != null)
.Select(id => id!.Value)
.Distinct()
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
"成绩已发布",
$"《{sheet.TeachingTask!.Course!.Name}》成绩已正式发布,请前往成绩单查看。",
"/grades", cancellationToken, NotificationCategory.Grade);
return NoContent();
}
[HttpGet("sheets/{id:guid}/template")]
[Authorize(Roles = SheetUsers)]
public async Task<IActionResult> DownloadTemplate(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets()
.Include(x => x.Items.OrderBy(i => i.SortOrder))
.Include(x => x.Records.OrderBy(r => r.Student!.StudentNumber))
.ThenInclude(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]);
var rows = sheet.Records.Select(record =>
{
var values = new List<object?>
{
record.Student!.StudentNumber,
record.Student.Name,
record.Student.AdministrativeClass!.Name,
record.RegularScore
};
foreach (var item in sheet.Items)
{
var score = record.ItemScores
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
values.Add(score);
}
values.Add(record.FinalScore);
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
record.ExamStatus == GradeExamStatus.Exempt ? "免修" : "正常");
values.Add(record.Notes);
return (IReadOnlyList<object?>)values;
}).ToList();
var instructions = new List<string>
{
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
"成绩列填写 0—100 的数值,留空表示暂未录入。",
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("", itemNames)}。",
"导入后会自动重新计算总评成绩和绩点。"
};
var bytes = ExcelWorkbookHelper.Create(
"成绩导入", headers, rows, instructions);
var taskName = sheet.TeachingTask!.Name;
return File(bytes, ExcelWorkbookHelper.ContentType,
$"成绩导入模板-{taskName}.xlsx");
}
[HttpPost("sheets/{id:guid}/import")]
[Authorize(Roles = SheetUsers)]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult> ImportGrades(
Guid id,
IFormFile file,
CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets()
.Include(x => x.Items.OrderBy(i => i.SortOrder))
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.Records)
.ThenInclude(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("只有草稿或已退回成绩单可以导入成绩。");
var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]);
IReadOnlyList<ExcelRow> rows;
try
{
rows = await ExcelWorkbookHelper.ReadAsync(
file, headers, cancellationToken);
}
catch (InvalidDataException exception)
{
return ValidationProblem(exception.Message);
}
if (rows.Count == 0)
return ValidationProblem("Excel 中没有可导入的成绩数据。");
var studentMap = sheet.Records.ToDictionary(
x => x.Student!.StudentNumber,
StringComparer.OrdinalIgnoreCase);
var itemMap = sheet.Items.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase);
var errors = new List<string>();
var updated = 0;
foreach (var row in rows)
{
var studentNumber = row["学号"]?.Trim();
if (string.IsNullOrWhiteSpace(studentNumber))
{
errors.Add($"第 {row.RowNumber} 行:学号不能为空。");
continue;
}
if (!studentMap.TryGetValue(studentNumber, out var record))
{
errors.Add($"第 {row.RowNumber} 行:学号“{studentNumber}”不在本成绩单中。");
continue;
}
// Parse regular score
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
// Parse final score
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
// Parse item scores
var itemScores = new List<(Guid GradeItemId, decimal? Score)>();
foreach (var itemName in itemNames)
{
if (itemMap.TryGetValue(itemName, out var item))
{
var score = ParseOptionalDecimal(row, itemName, 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString()))
break;
itemScores.Add((item.Id, score));
}
}
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
// Parse exam status
var examStatusText = row["考试状态"]?.Trim();
var examStatus = GradeExamStatus.Normal;
if (!string.IsNullOrWhiteSpace(examStatusText))
{
if (examStatusText == "缺考") examStatus = GradeExamStatus.Absent;
else if (examStatusText == "缓考") examStatus = GradeExamStatus.Deferred;
else if (examStatusText == "免修") examStatus = GradeExamStatus.Exempt;
else if (examStatusText != "正常")
{
errors.Add($"第 {row.RowNumber} 行:考试状态“{examStatusText}”无效,请填写正常、缺考、缓考或免修。");
continue;
}
}
// Apply
record.RegularScore = regularScore;
record.FinalScore = finalScore;
record.ExamStatus = examStatus;
record.Notes = Normalize(row["备注"]);
foreach (var (itemId, score) in itemScores)
{
var existingScore = record.ItemScores
.FirstOrDefault(s => s.GradeItemId == itemId);
if (existingScore != null)
existingScore.Score = score;
}
Recalculate(sheet, record);
updated++;
}
if (errors.Count > 0)
return ImportValidationProblem(errors);
await db.SaveChangesAsync(cancellationToken);
return Ok(new { updated, total = rows.Count });
}
[HttpGet("student/transcript")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetStudentTranscript(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => new
{
x.Id,
x.StudentNumber,
x.Name,
ClassName = x.AdministrativeClass!.Name,
MajorName = x.AdministrativeClass.Major!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
var source = db.GradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.GradeSheet!.Status == GradeSheetStatus.Published);
if (academicTermId.HasValue)
source = source.Where(x =>
x.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId);
var records = await source
.OrderByDescending(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.GradeSheet!.TeachingTask!.Course!.Code)
.Select(x => new
{
x.Id,
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
x.GradeSheet.TeachingTaskId,
x.GradeSheet.TeachingTask.TaskNumber,
CourseCode = x.GradeSheet.TeachingTask.Course!.Code,
CourseName = x.GradeSheet.TeachingTask.Course.Name,
x.GradeSheet.TeachingTask.Course.Credits,
x.TotalScore,
x.GradePoint,
x.ExamStatus,
x.GradeSheet.PublishedAt
})
.ToListAsync(cancellationToken);
return Ok(new { Student = student, Records = records });
}
private IQueryable<TeachingTask> AccessibleTasks()
{
var source = db.TeachingTasks.AsQueryable();
var scope = currentUserDataScope.Current;
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.Counselor))
{
// Counselor sees grades for courses in their managed classes' college
var collegeIds = db.AdministrativeClasses
.Where(c => c.CounselorUserId == scope.UserId)
.Select(c => c.Major!.CollegeId)
.Distinct();
return source.Where(x => collegeIds.Contains(x.Course!.CollegeId));
}
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private IQueryable<GradeSheet> AccessibleSheets() =>
db.GradeSheets.Where(x => AccessibleTasks().Any(task =>
task.Id == x.TeachingTaskId));
private bool CanInitialize(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
IsAssignedTeacher(task);
private bool CanEditScores(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
IsAssignedTeacher(task);
private bool IsAssignedTeacher(TeachingTask task) =>
task.Teachers.Any(x =>
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
private bool IsReviewer() =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin);
private bool IsPublisher() =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private static void Recalculate(GradeSheet sheet, GradeRecord record)
{
record.TotalScore = GradeCalculator.CalculateTotal(
record.RegularScore,
record.FinalScore,
record.ItemScores.ToList(),
sheet.RegularWeight,
sheet.FinalWeight,
sheet.Items.ToList(),
record.ExamStatus);
record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore);
}
private static bool ValidScore(decimal? score) =>
!score.HasValue || score.Value is >= 0 and <= 100;
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("成绩单已存在,或关联学生与教学班已发生变化。");
}
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成成绩操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50))
ModelState.AddModelError("file", error);
if (errors.Count > 50)
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
return ValidationProblem(ModelState);
}
private static decimal? ParseOptionalDecimal(
ExcelRow row,
string header,
decimal minimum,
decimal maximum,
List<string> errors)
{
var value = row[header];
if (string.IsNullOrWhiteSpace(value)) return null;
if (decimal.TryParse(
value,
NumberStyles.Number,
CultureInfo.InvariantCulture,
out var result) &&
result >= minimum &&
result <= maximum)
return result;
errors.Add(
$"第 {row.RowNumber} 行:" +
$"“{header}”请填写 {minimum:0}—{maximum:0} 的数值或留空。");
return null;
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record GradeSheetRequest(
Guid TeachingTaskId,
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
[Range(typeof(decimal), "0", "100")] decimal FinalWeight,
IReadOnlyCollection<GradeItemRequest>? Items);
public sealed record GradeItemRequest(
[MaxLength(60)] string Name,
[Range(typeof(decimal), "0", "100")] decimal Weight);
public sealed record GradeWeightsRequest(
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
[Range(typeof(decimal), "0", "100")] decimal FinalWeight,
IReadOnlyCollection<GradeItemRequest>? Items);
public sealed record GradeRecordsRequest(
IReadOnlyCollection<GradeRecordRequest> Records);
public sealed record GradeRecordRequest(
Guid Id,
decimal? RegularScore,
decimal? FinalScore,
IReadOnlyCollection<GradeItemScoreRequest>? ItemScores,
GradeExamStatus ExamStatus,
[MaxLength(300)] string? Notes);
public sealed record GradeItemScoreRequest(
Guid GradeItemId,
decimal? Score);
public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment);