using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; 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/experiment-grades")] public sealed class ExperimentGradesController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Teacher; private const string Reviewers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; private const string Publishers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; [HttpGet("management")] [Authorize(Roles = Managers)] public async Task GetManagement( Guid? academicTermId, ExperimentGradeSheetStatus? status, CancellationToken cancellationToken) { var source = ScopedProjects().AsNoTracking() .Where(x => x.Status == ExperimentProjectStatus.Published || x.Status == ExperimentProjectStatus.Closed); if (academicTermId.HasValue) source = source.Where(x => x.TeachingTask!.AcademicTermId == academicTermId); if (status.HasValue) source = source.Where(x => x.GradeSheet != null && x.GradeSheet.Status == status.Value); return Ok(await source .OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate) .ThenBy(x => x.TeachingTask!.TaskNumber) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.ArrangementMode, ProjectStatus = x.Status, x.TeachingTaskId, x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, AcademicTermId = x.TeachingTask.AcademicTermId, TermName = x.TeachingTask.AcademicTerm!.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, CollegeName = x.TeachingTask.Course.College!.Name, TeacherNames = x.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), Sheet = x.GradeSheet == null ? null : new { x.GradeSheet.Id, x.GradeSheet.Status, x.GradeSheet.ContributionWeight, x.GradeSheet.PassScore, ItemCount = x.GradeSheet.Items.Count, StudentCount = x.GradeSheet.Records.Count, CompletedCount = x.GradeSheet.Records.Count(record => record.ParticipationStatus != ExperimentParticipationStatus.Pending), ScoredCount = x.GradeSheet.Records.Count(record => record.TotalScore != null), PassedCount = x.GradeSheet.Records.Count(record => record.IsPassed == true), x.GradeSheet.SubmittedAt, x.GradeSheet.ReviewedAt, x.GradeSheet.PublishedAt } }) .ToListAsync(cancellationToken)); } [HttpGet("mine")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMine(CancellationToken cancellationToken) { var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ConflictProblem("当前账号未关联有效学生档案。"); return Ok(await db.ExperimentGradeRecords.AsNoTracking() .Where(x => x.StudentId == student.Id && x.ExperimentGradeSheet!.Status == ExperimentGradeSheetStatus.Published) .OrderByDescending(x => x.ExperimentGradeSheet!.PublishedAt) .ThenBy(x => x.ExperimentGradeSheet!.ExperimentProject!.Code) .Select(x => new { x.Id, x.ExperimentGradeSheetId, ProjectId = x.ExperimentGradeSheet!.ExperimentProjectId, ProjectCode = x.ExperimentGradeSheet.ExperimentProject!.Code, ProjectName = x.ExperimentGradeSheet.ExperimentProject.Name, x.ExperimentGradeSheet.ExperimentProject.ArrangementMode, CourseCode = x.ExperimentGradeSheet.ExperimentProject .TeachingTask!.Course!.Code, CourseName = x.ExperimentGradeSheet.ExperimentProject .TeachingTask.Course.Name, TermName = x.ExperimentGradeSheet.ExperimentProject .TeachingTask.AcademicTerm!.Name, x.ExperimentGradeSheet.PassScore, x.ParticipationStatus, x.TotalScore, x.IsPassed, x.SafetyViolation, x.AttemptNumber, x.SubmissionReference, x.SubmittedAt, x.IsLate, x.TeacherComment, Items = x.ItemScores .OrderBy(score => score.ExperimentGradeItem!.SortOrder) .Select(score => new { score.ExperimentGradeItemId, score.ExperimentGradeItem!.Name, score.ExperimentGradeItem.Kind, score.ExperimentGradeItem.Weight, score.Score, score.Comment }), x.ExperimentGradeSheet.PublishedAt }) .ToListAsync(cancellationToken)); } [HttpPost("sheets")] [Authorize(Roles = Managers)] public async Task CreateSheet( ExperimentGradeSheetRequest request, CancellationToken cancellationToken) { var items = BuildItems(request.Items); if (!ExperimentGradeCalculator.AreWeightsValid( request.ContributionWeight, request.PassScore, items)) return ValidationProblem( "实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。"); var project = await ScopedProjects() .Include(x => x.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(x => x.Id == request.ExperimentProjectId, cancellationToken); if (project is null) return NotFound(); if (project.Status is not ( ExperimentProjectStatus.Published or ExperimentProjectStatus.Closed)) return ConflictProblem("只有已发布或已关闭实验项目可以建立成绩单。"); if (!CanInitialize(project.TeachingTask!)) return Forbid(); if (await db.ExperimentGradeSheets.AnyAsync(x => x.ExperimentProjectId == project.Id, cancellationToken)) return ConflictProblem("该实验项目已经建立成绩单。"); var participants = await LoadParticipantsAsync( project, cancellationToken); if (participants.Count == 0) return ConflictProblem("实验项目当前没有可评分学生。"); var sheet = new ExperimentGradeSheet { ExperimentProjectId = project.Id, ContributionWeight = request.ContributionWeight, PassScore = request.PassScore, Items = items, Records = participants.Select(participant => new ExperimentGradeRecord { StudentId = participant.StudentId, ExperimentSessionId = participant.SessionId, ItemScores = items.Select(item => new ExperimentGradeItemScore { ExperimentGradeItemId = item.Id }).ToList() }).ToList() }; db.ExperimentGradeSheets.Add(sheet); await db.SaveChangesAsync(cancellationToken); return Created(string.Empty, new { sheet.Id }); } [HttpGet("sheets/{id:guid}")] [Authorize(Roles = Managers)] 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 ScopedSheets().AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Id, x.ExperimentProjectId, ProjectCode = x.ExperimentProject!.Code, ProjectName = x.ExperimentProject.Name, x.ExperimentProject.ArrangementMode, x.ExperimentProject.TeachingTaskId, x.ExperimentProject.TeachingTask!.TaskNumber, TaskName = x.ExperimentProject.TeachingTask.Name, AcademicTermId = x.ExperimentProject.TeachingTask.AcademicTermId, TermName = x.ExperimentProject.TeachingTask .AcademicTerm!.Name, CourseCollegeId = x.ExperimentProject.TeachingTask .Course!.CollegeId, CourseCollegeName = x.ExperimentProject.TeachingTask .Course.College!.Name, CourseCode = x.ExperimentProject.TeachingTask.Course.Code, CourseName = x.ExperimentProject.TeachingTask.Course.Name, TeacherNames = x.ExperimentProject.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), x.ContributionWeight, x.PassScore, Items = x.Items.OrderBy(item => item.SortOrder) .Select(item => new { item.Id, item.Name, item.Kind, 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.ExperimentGradeRecords.AsNoTracking() .Where(x => x.ExperimentGradeSheetId == id); if (studentKeyword is not null) recordsSource = recordsSource.Where(x => x.Student!.StudentNumber.Contains(studentKeyword) || x.Student.Name.Contains(studentKeyword) || x.Student.AdministrativeClass!.Name.Contains(studentKeyword)); var recordTotal = await recordsSource.CountAsync(cancellationToken); var records = await recordsSource .OrderBy(x => x.Student!.StudentNumber) .Skip((recordPage - 1) * recordPageSize) .Take(recordPageSize) .Select(x => new { x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name, ClassName = x.Student.AdministrativeClass!.Name, x.ExperimentSessionId, Session = x.ExperimentSession == null ? null : new { x.ExperimentSession.SessionDate, x.ExperimentSession.StartPeriod, x.ExperimentSession.PeriodCount, ClassroomName = x.ExperimentSession.Classroom!.Name }, x.ParticipationStatus, x.TotalScore, x.IsPassed, x.SafetyViolation, x.AttemptNumber, x.SubmissionReference, x.SubmittedAt, x.IsLate, x.TeacherComment, ItemScores = x.ItemScores .OrderBy(score => score.ExperimentGradeItem!.SortOrder) .Select(score => new { score.ExperimentGradeItemId, score.ExperimentGradeItem!.Name, score.Score, score.Comment }), x.UpdatedAt }) .ToListAsync(cancellationToken); var task = await db.TeachingTasks.AsNoTracking() .Include(x => x.Teachers) .ThenInclude(x => x.Teacher) .SingleAsync( x => x.Id == sheet.TeachingTaskId, cancellationToken); var canReview = CanReviewCollege(sheet.CourseCollegeId) && sheet.Status == ExperimentGradeSheetStatus.Submitted; return Ok(new { Sheet = new { sheet.Id, sheet.ExperimentProjectId, sheet.ProjectCode, sheet.ProjectName, sheet.ArrangementMode, sheet.TeachingTaskId, sheet.TaskNumber, sheet.TaskName, sheet.AcademicTermId, sheet.TermName, sheet.CourseCollegeName, sheet.CourseCode, sheet.CourseName, sheet.TeacherNames, sheet.ContributionWeight, sheet.PassScore, 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 = CanEdit(task) && sheet.Status is ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Returned, CanReview = canReview, CanPublish = IsPublisher() && sheet.Status == ExperimentGradeSheetStatus.Approved }); } [HttpPut("sheets/{id:guid}/settings")] [Authorize(Roles = Managers)] public async Task UpdateSettings( Guid id, ExperimentGradeSettingsRequest request, CancellationToken cancellationToken) { var replacementItems = BuildItems(request.Items); if (!ExperimentGradeCalculator.AreWeightsValid( request.ContributionWeight, request.PassScore, replacementItems)) return ValidationProblem( "实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。"); var sheet = await ScopedSheets() .Include(x => x.Items) .Include(x => x.Records) .ThenInclude(x => x.ItemScores) .Include(x => x.ExperimentProject) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid(); if (sheet.Status is not ( ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Returned)) return ConflictProblem("当前状态不能修改实验评分方案。"); sheet.ContributionWeight = request.ContributionWeight; sheet.PassScore = request.PassScore; db.ExperimentGradeItems.RemoveRange(sheet.Items); foreach (var record in sheet.Records) { record.ItemScores.Clear(); record.TotalScore = null; record.IsPassed = null; } sheet.Items = replacementItems; foreach (var record in sheet.Records) foreach (var item in replacementItems) record.ItemScores.Add(new ExperimentGradeItemScore { ExperimentGradeItemId = item.Id }); await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPut("sheets/{id:guid}/records")] [Authorize(Roles = Managers)] public async Task UpdateRecords( Guid id, ExperimentGradeRecordsRequest request, CancellationToken cancellationToken) { var sheet = await ScopedSheets() .Include(x => x.Items) .Include(x => x.Records) .ThenInclude(x => x.ItemScores) .Include(x => x.ExperimentProject) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid(); if (sheet.Status is not ( ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.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(x => x.Id).ToHashSet(); foreach (var item in request.Records) { if (item.AttemptNumber is < 1 or > 20) return ValidationProblem("实验尝试次数应在 1—20 次之间。"); if (item.ItemScores.Select(x => x.ExperimentGradeItemId) .Distinct().Count() != item.ItemScores.Count || item.ItemScores.Any(x => !itemIds.Contains(x.ExperimentGradeItemId) || !ValidScore(x.Score))) return ValidationProblem("包含无效、重复或超出 0—100 分的评分项。"); var record = records[item.Id]; record.ParticipationStatus = item.ParticipationStatus; record.SafetyViolation = item.SafetyViolation; record.AttemptNumber = item.AttemptNumber; record.SubmissionReference = Normalize(item.SubmissionReference); record.SubmittedAt = item.SubmittedAt; record.IsLate = item.IsLate; record.TeacherComment = Normalize(item.TeacherComment); var scores = record.ItemScores.ToDictionary( x => x.ExperimentGradeItemId); foreach (var score in item.ItemScores) { var target = scores[score.ExperimentGradeItemId]; target.Score = score.Score; target.Comment = Normalize(score.Comment); } Recalculate(sheet, record); } await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPost("sheets/{id:guid}/sync-participants")] [Authorize(Roles = Managers)] public async Task SyncParticipants( Guid id, CancellationToken cancellationToken) { var sheet = await ScopedSheets() .Include(x => x.Items) .Include(x => x.Records) .ThenInclude(x => x.ItemScores) .Include(x => x.ExperimentProject) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (sheet is null) return NotFound(); if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid(); if (sheet.Status is not ( ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Returned)) return ConflictProblem("成绩单提交后不能同步参与学生。"); var expected = await LoadParticipantsAsync( sheet.ExperimentProject, cancellationToken); var expectedByStudent = expected.ToDictionary(x => x.StudentId); var existingByStudent = sheet.Records.ToDictionary(x => x.StudentId); var added = 0; var removed = 0; var retained = 0; var recordsToRemove = new List(); var recordsToAdd = new List(); foreach (var participant in expected) { if (existingByStudent.TryGetValue( participant.StudentId, out var record)) { if (record.ParticipationStatus == ExperimentParticipationStatus.Pending && record.ItemScores.All(x => !x.Score.HasValue)) record.ExperimentSessionId = participant.SessionId; retained++; continue; } var newRecord = new ExperimentGradeRecord { ExperimentGradeSheetId = sheet.Id, StudentId = participant.StudentId, ExperimentSessionId = participant.SessionId }; db.ExperimentGradeRecords.Add(newRecord); recordsToAdd.Add(newRecord); added++; } foreach (var record in sheet.Records .Where(x => !expectedByStudent.ContainsKey(x.StudentId)) .ToList()) { if (record.ParticipationStatus != ExperimentParticipationStatus.Pending || record.ItemScores.Any(x => x.Score.HasValue)) { retained++; continue; } db.ExperimentGradeItemScores.RemoveRange(record.ItemScores); recordsToRemove.Add(record); removed++; } if (recordsToRemove.Count > 0 || recordsToAdd.Count > 0) { await db.SaveChangesAsync(cancellationToken); foreach (var record in recordsToRemove) sheet.Records.Remove(record); db.ExperimentGradeRecords.RemoveRange(recordsToRemove); } foreach (var record in recordsToAdd) foreach (var item in sheet.Items) record.ItemScores.Add(new ExperimentGradeItemScore { ExperimentGradeItemId = item.Id }); await db.SaveChangesAsync(cancellationToken); return Ok(new { Added = added, Removed = removed, Retained = retained, Total = await db.ExperimentGradeRecords.CountAsync( x => x.ExperimentGradeSheetId == sheet.Id, cancellationToken) }); } [HttpPost("sheets/{id:guid}/submit")] [Authorize(Roles = Managers)] public async Task Submit( Guid id, CancellationToken cancellationToken) { var sheet = await LoadForWorkflowAsync(id, cancellationToken); if (sheet is null) return NotFound(); if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid(); if (sheet.Status is not ( ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Returned)) return ConflictProblem("只有草稿或已退回实验成绩单可以提交。"); if (sheet.Records.Count == 0) return ConflictProblem("实验成绩单没有学生记录。"); foreach (var record in sheet.Records) Recalculate(sheet, record); if (sheet.Records.Any(x => x.ParticipationStatus == ExperimentParticipationStatus.Pending)) return ConflictProblem("仍有学生未登记实验参与状态。"); if (sheet.Records.Any(x => x.ParticipationStatus is ExperimentParticipationStatus.Completed or ExperimentParticipationStatus.Makeup && !x.TotalScore.HasValue)) return ConflictProblem("仍有已完成或补做学生缺少必填评分项。"); sheet.Status = ExperimentGradeSheetStatus.Submitted; sheet.SubmittedAt = DateTime.UtcNow; sheet.ReviewComment = null; await db.SaveChangesAsync(cancellationToken); await NotificationService.SendToRoleAsync( db, SystemRoles.CollegeAdmin, "实验成绩待审核", $"“{sheet.ExperimentProject!.Name}”实验成绩已提交,请及时审核。", sheet.ExperimentProject.TeachingTask!.Course!.CollegeId, "/experiment-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 LoadForWorkflowAsync(id, cancellationToken); if (sheet is null) return NotFound(); var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId; if (!CanReviewCollege(collegeId)) return Forbid(); if (sheet.Status != ExperimentGradeSheetStatus.Submitted) return ConflictProblem("只有待审核实验成绩单可以通过审核。"); sheet.Status = ExperimentGradeSheetStatus.Approved; sheet.ReviewedAt = DateTime.UtcNow; sheet.ReviewComment = null; await db.SaveChangesAsync(cancellationToken); await NotifyTeachersAsync( sheet.ExperimentProject.TeachingTaskId, "实验成绩审核通过", $"“{sheet.ExperimentProject.Name}”实验成绩已通过学院审核。", cancellationToken); return NoContent(); } [HttpPost("sheets/{id:guid}/return")] [Authorize(Roles = Reviewers)] public async Task Return( Guid id, ExperimentGradeReviewRequest request, CancellationToken cancellationToken) { var sheet = await LoadForWorkflowAsync(id, cancellationToken); if (sheet is null) return NotFound(); var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId; if (!CanReviewCollege(collegeId)) return Forbid(); if (sheet.Status != ExperimentGradeSheetStatus.Submitted) return ConflictProblem("只有待审核实验成绩单可以退回。"); if (string.IsNullOrWhiteSpace(request.Comment)) return ValidationProblem("退回时必须填写修改意见。"); sheet.Status = ExperimentGradeSheetStatus.Returned; sheet.ReviewedAt = DateTime.UtcNow; sheet.ReviewComment = request.Comment.Trim(); await db.SaveChangesAsync(cancellationToken); await NotifyTeachersAsync( sheet.ExperimentProject.TeachingTaskId, "实验成绩被退回", $"“{sheet.ExperimentProject.Name}”实验成绩被退回:{sheet.ReviewComment}", cancellationToken); return NoContent(); } [HttpPost("sheets/{id:guid}/publish")] [Authorize(Roles = Publishers)] public async Task Publish( Guid id, CancellationToken cancellationToken) { var sheet = await LoadForWorkflowAsync(id, cancellationToken); if (sheet is null) return NotFound(); if (!IsPublisher()) return Forbid(); if (sheet.Status != ExperimentGradeSheetStatus.Approved) return ConflictProblem("只有审核通过的实验成绩单可以发布。"); sheet.Status = ExperimentGradeSheetStatus.Published; sheet.PublishedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); var userIds = await db.ExperimentGradeRecords .Where(x => x.ExperimentGradeSheetId == sheet.Id) .Select(x => x.Student!.UserId) .Where(x => x != null) .Select(x => x!.Value) .Distinct() .ToListAsync(cancellationToken); await NotificationService.SendToUserIdsAsync( db, userIds, "实验成绩已发布", $"“{sheet.ExperimentProject!.Name}”实验成绩已发布。", "/experiment-grades", cancellationToken, NotificationCategory.Grade); return NoContent(); } private async Task LoadForWorkflowAsync( Guid id, CancellationToken cancellationToken) { if (!await ScopedSheets().AnyAsync( x => x.Id == id, cancellationToken)) return null; return await db.ExperimentGradeSheets .Include(x => x.Items) .Include(x => x.Records) .ThenInclude(x => x.ItemScores) .Include(x => x.ExperimentProject) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Course) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); } private async Task> LoadParticipantsAsync( ExperimentProject project, CancellationToken cancellationToken) { if (project.ArrangementMode == ExperimentArrangementMode.Centralized) { return await TeachingTaskRosterQuery .ForTask(db, project.TeachingTaskId) .AsNoTracking() .OrderBy(x => x.StudentNumber) .Select(x => new ExperimentParticipantSeed(x.Id, null)) .ToListAsync(cancellationToken); } return await db.ExperimentBookings.AsNoTracking() .Where(x => x.ExperimentProjectId == project.Id && x.Status == ExperimentBookingStatus.Booked && x.Student!.Status == StudentStatus.Active) .OrderBy(x => x.Student!.StudentNumber) .Select(x => new ExperimentParticipantSeed( x.StudentId, x.ExperimentSessionId)) .ToListAsync(cancellationToken); } private static List BuildItems( IReadOnlyCollection? requests) => requests?.Select((item, index) => new ExperimentGradeItem { Name = item.Name.Trim(), Kind = item.Kind, Weight = item.Weight, SortOrder = index }).ToList() ?? []; private static void Recalculate( ExperimentGradeSheet sheet, ExperimentGradeRecord record) { record.TotalScore = ExperimentGradeCalculator.CalculateTotal( record, sheet.Items.ToList()); record.IsPassed = record.TotalScore.HasValue ? record.TotalScore.Value >= sheet.PassScore && !record.SafetyViolation : null; } private IQueryable AccessibleTeachingTasks() { 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.Teacher)) return source.Where(x => x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId)); return source.Where(_ => false); } private IQueryable ScopedProjects() { var taskIds = AccessibleTeachingTasks().Select(x => x.Id); return db.ExperimentProjects.Where(x => taskIds.Contains(x.TeachingTaskId)); } private IQueryable ScopedSheets() { var projectIds = ScopedProjects().Select(x => x.Id); return db.ExperimentGradeSheets.Where(x => projectIds.Contains(x.ExperimentProjectId)); } private bool CanInitialize(TeachingTask task) => currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) || IsAssignedTeacher(task); private bool CanEdit(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 CanReviewCollege(Guid collegeId) { var scope = currentUserDataScope.Current; if (!scope.IsInRole(SystemRoles.SuperAdmin) && !scope.IsInRole(SystemRoles.AcademicAdmin) && !scope.IsInRole(SystemRoles.CollegeAdmin)) return false; return scope.Scope == DataScope.All || scope.RestrictedCollegeId == collegeId; } private bool IsPublisher() => currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin); private Task CurrentStudentAsync( CancellationToken cancellationToken) => db.Students.FirstOrDefaultAsync(x => x.UserId == currentUserDataScope.Current.UserId && x.Status == StudentStatus.Active, cancellationToken); private async Task NotifyTeachersAsync( Guid teachingTaskId, string title, string content, CancellationToken cancellationToken) { var userIds = await db.TeachingTaskTeachers .Where(x => x.TeachingTaskId == teachingTaskId) .Select(x => x.Teacher!.UserId) .Where(x => x != null) .Select(x => x!.Value) .Distinct() .ToListAsync(cancellationToken); await NotificationService.SendToUserIdsAsync( db, userIds, title, content, "/experiment-grades", cancellationToken, NotificationCategory.Grade); } private static bool ValidScore(decimal? score) => !score.HasValue || score.Value is >= 0 and <= 100; private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "无法完成实验成绩操作", Detail = detail, Status = StatusCodes.Status409Conflict }); private sealed record ExperimentParticipantSeed( Guid StudentId, Guid? SessionId); } public sealed record ExperimentGradeSheetRequest( Guid ExperimentProjectId, [Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight, [Range(typeof(decimal), "0", "100")] decimal PassScore, IReadOnlyCollection Items); public sealed record ExperimentGradeSettingsRequest( [Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight, [Range(typeof(decimal), "0", "100")] decimal PassScore, IReadOnlyCollection Items); public sealed record ExperimentGradeItemRequest( [Required, MaxLength(60)] string Name, ExperimentGradeItemKind Kind, [Range(typeof(decimal), "0.1", "100")] decimal Weight); public sealed record ExperimentGradeRecordsRequest( IReadOnlyCollection Records); public sealed record ExperimentGradeRecordRequest( Guid Id, ExperimentParticipationStatus ParticipationStatus, bool SafetyViolation, [Range(1, 20)] int AttemptNumber, [MaxLength(500)] string? SubmissionReference, DateTime? SubmittedAt, bool IsLate, [MaxLength(500)] string? TeacherComment, IReadOnlyCollection ItemScores); public sealed record ExperimentGradeItemScoreRequest( Guid ExperimentGradeItemId, decimal? Score, [MaxLength(300)] string? Comment); public sealed record ExperimentGradeReviewRequest( [MaxLength(500)] string? Comment);