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 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, item.SourceType, item.SourceSnapshotAt }), 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(items, total, page, pageSize)); } [HttpPost("sheets")] [Authorize(Roles = SheetUsers)] public async Task 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 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, item.SourceType, item.SourceSnapshotAt }), 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 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 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 itemsById = sheet.Items.ToDictionary(item => item.Id); 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 (!itemsById.TryGetValue( scoreEntry.GradeItemId, out var gradeItem) || !scoreMap.TryGetValue( scoreEntry.GradeItemId, out var existing)) continue; if (gradeItem.SourceType == GradeItemSourceType.ExperimentSummary && existing.Score != scoreEntry.Score) return ConflictProblem( $"“{gradeItem.Name}”来自实验成绩快照,不能手工修改;请重新导入实验成绩。"); existing.Score = scoreEntry.Score; } } Recalculate(sheet, record); } return await SaveAsync(id, false, cancellationToken); } [HttpPost("sheets/{id:guid}/import-experiment-scores")] [Authorize(Roles = SheetUsers)] public async Task ImportExperimentScores( Guid id, ImportExperimentScoresRequest 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 targetItem = sheet.Items.FirstOrDefault(x => x.Id == request.GradeItemId); if (targetItem is null) return ValidationProblem("请选择当前成绩单中的有效分项。"); var aggregate = await ExperimentGradeAggregationService.CalculateAsync( db, sheet.TeachingTaskId, sheet.Records.Select(x => x.StudentId).ToArray(), cancellationToken); if (aggregate.PublishedProjectCount == 0) return ConflictProblem( "该教学任务尚无已发布的实验项目成绩,无法导入。"); var imported = 0; var missing = 0; foreach (var record in sheet.Records) { var targetScore = record.ItemScores.First(x => x.GradeItemId == targetItem.Id); targetScore.Score = aggregate.Scores[record.StudentId]; if (targetScore.Score.HasValue) imported++; else missing++; Recalculate(sheet, record); } targetItem.SourceType = GradeItemSourceType.ExperimentSummary; targetItem.SourceSnapshotAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); return Ok(new { aggregate.PublishedProjectCount, aggregate.Projects, ImportedCount = imported, MissingCount = missing, targetItem.SourceSnapshotAt }); } [HttpPost("sheets/{id:guid}/submit")] [Authorize(Roles = SheetUsers)] public async Task 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 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 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 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 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 { "学号", "姓名", "班级", "平时成绩" }; headers.AddRange(itemNames); headers.AddRange(["期末成绩", "考试状态", "备注"]); var rows = sheet.Records.Select(record => { var values = new List { 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)values; }).ToList(); var instructions = new List { "请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。", "成绩列填写 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 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 { "学号", "姓名", "班级", "平时成绩" }; headers.AddRange(itemNames); headers.AddRange(["期末成绩", "考试状态", "备注"]); IReadOnlyList 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(); 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 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 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 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 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 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 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? 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? Items); public sealed record GradeRecordsRequest( IReadOnlyCollection Records); public sealed record GradeRecordRequest( Guid Id, decimal? RegularScore, decimal? FinalScore, IReadOnlyCollection? ItemScores, GradeExamStatus ExamStatus, [MaxLength(300)] string? Notes); public sealed record GradeItemScoreRequest( Guid GradeItemId, decimal? Score); public sealed record ImportExperimentScoresRequest(Guid GradeItemId); public sealed record GradeReviewRequest( [MaxLength(500)] string? Comment);