using System.ComponentModel.DataAnnotations; using System.Data; using System.Globalization; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Files; 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 + "," + SystemRoles.CollegeAdmin; [HttpGet("management")] [Authorize(Roles = Managers)] public async Task GetManagement( Guid? academicTermId, ExperimentGradeSheetStatus? status, Guid? collegeId = null, string? keyword = null, int page = 1, int pageSize = 20, CancellationToken cancellationToken = default, Guid? sheetId = null) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 50); keyword = Normalize(keyword); 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); if (sheetId.HasValue) source = source.Where(x => x.GradeSheet != null && x.GradeSheet.Id == sheetId.Value); if (collegeId.HasValue) source = source.Where(x => x.TeachingTask!.Course!.CollegeId == collegeId.Value); if (keyword is not null) source = source.Where(x => x.Code.Contains(keyword) || x.Name.Contains(keyword) || x.TeachingTask!.TaskNumber.Contains(keyword) || x.TeachingTask.Name.Contains(keyword) || x.TeachingTask.Course!.Code.Contains(keyword) || x.TeachingTask.Course.Name.Contains(keyword) || x.TeachingTask.Teachers.Any(item => item.Teacher!.TeacherNumber.Contains(keyword) || item.Teacher.Name.Contains(keyword))); var total = await source.Select(x => x.TeachingTaskId) .Distinct() .CountAsync(cancellationToken); var taskIds = await source .GroupBy(x => new { x.TeachingTaskId, StartDate = x.TeachingTask!.AcademicTerm!.StartDate, x.TeachingTask.TaskNumber }) .OrderByDescending(x => x.Key.StartDate) .ThenBy(x => x.Key.TaskNumber) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(x => x.Key.TeachingTaskId) .ToListAsync(cancellationToken); var projects = await source .Where(x => taskIds.Contains(x.TeachingTaskId)) .OrderBy(x => x.ScheduleWeek ?? int.MaxValue) .ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.DayOfWeek) .ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.StartPeriod) .ThenBy(x => x.StartDate) .ThenBy(x => x.Code) .Select(x => new { x.Id, x.Code, x.Name, x.ArrangementMode, x.ScheduleWeek, 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), ScheduleEntry = x.ScheduleEntry == null ? null : new { x.ScheduleEntry.DayOfWeek, x.ScheduleEntry.StartPeriod, x.ScheduleEntry.PeriodCount }, 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); var order = taskIds.Select((id, index) => new { id, index }) .ToDictionary(x => x.id, x => x.index); var items = projects.GroupBy(x => x.TeachingTaskId) .OrderBy(group => order[group.Key]) .Select(group => { var first = group.First(); return new { TeachingTaskId = group.Key, first.TaskNumber, first.TaskName, first.AcademicTermId, first.TermName, first.CourseCode, first.CourseName, first.CollegeName, first.TeacherNames, ProjectCount = group.Count(), SheetCount = group.Count(project => project.Sheet != null), ScoredCount = group.Sum(project => project.Sheet?.ScoredCount ?? 0), StudentCount = group.Sum(project => project.Sheet?.StudentCount ?? 0), Projects = group.ToList() }; }) .ToList(); return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize }); } [HttpGet("mine")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMine(CancellationToken cancellationToken) { var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ConflictProblem("当前账号未关联有效学生档案。"); var results = 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, TeachingTaskId = x.ExperimentGradeSheet.ExperimentProject .TeachingTaskId, AcademicTermId = x.ExperimentGradeSheet.ExperimentProject .TeachingTask!.AcademicTermId, TaskNumber = x.ExperimentGradeSheet.ExperimentProject .TeachingTask.TaskNumber, CourseCode = x.ExperimentGradeSheet.ExperimentProject .TeachingTask!.Course!.Code, CourseName = x.ExperimentGradeSheet.ExperimentProject .TeachingTask.Course.Name, TermName = x.ExperimentGradeSheet.ExperimentProject .TeachingTask.AcademicTerm!.Name, x.ExperimentGradeSheet.ContributionWeight, 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); var taskIds = results.Select(x => x.TeachingTaskId).Distinct().ToArray(); var aggregates = await db.ExperimentCourseGrades.AsNoTracking() .Where(x => x.StudentId == student.Id) .WhereIn(taskIds, x => x.TeachingTaskId) .ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken); var courses = results .GroupBy(x => x.TeachingTaskId) .Select(group => { var first = group.First(); aggregates.TryGetValue(group.Key, out var aggregate); return new { TeachingTaskId = group.Key, first.AcademicTermId, first.TermName, first.TaskNumber, first.CourseCode, first.CourseName, ExperimentCourseScore = aggregate?.WeightedAverageScore, PublishedProjectCount = aggregate?.PublishedProjectCount ?? group.Count(), TotalWeight = aggregate?.TotalWeight ?? group.Sum(x => x.ContributionWeight), RefreshedAt = aggregate?.RefreshedAt, Projects = group.OrderBy(x => x.ProjectCode).ToList() }; }) .OrderByDescending(x => x.Projects.Max(project => project.PublishedAt)) .ThenBy(x => x.CourseCode) .ToList(); return Ok(courses); } [HttpPost("sheets")] [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.ScheduleWeek, ScheduleEntry = x.ExperimentProject.ScheduleEntry == null ? null : new { x.ExperimentProject.ScheduleEntry.DayOfWeek, x.ExperimentProject.ScheduleEntry.StartPeriod, x.ExperimentProject.ScheduleEntry.PeriodCount }, 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); var scope = currentUserDataScope.Current; var isBoundInstructor = scope.Scope == DataScope.Self && scope.IsInRole(SystemRoles.Teacher) && sheet.ArrangementMode == ExperimentArrangementMode.SelfScheduled; if (isBoundInstructor) recordsSource = recordsSource.Where(x => x.ExperimentSession != null && x.ExperimentSession.Instructors.Any( instructor => instructor.Teacher!.UserId == scope.UserId)); 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) || isBoundInstructor) && 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; var existingItems = sheet.Items.ToList(); var existingItemScores = sheet.Records .SelectMany(record => record.ItemScores) .ToList(); // MySQL must see each replacement item before its score rows. Do not // rely on a single mixed insert/delete batch to infer that order. await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); db.ExperimentGradeItemScores.RemoveRange(existingItemScores); db.ExperimentGradeItems.RemoveRange(existingItems); foreach (var record in sheet.Records) { record.TotalScore = null; record.IsPassed = null; } await db.SaveChangesAsync(cancellationToken); foreach (var item in replacementItems) item.ExperimentGradeSheetId = sheet.Id; db.ExperimentGradeItems.AddRange(replacementItems); await db.SaveChangesAsync(cancellationToken); db.ExperimentGradeItemScores.AddRange( sheet.Records.SelectMany(record => replacementItems.Select(item => new ExperimentGradeItemScore { ExperimentGradeRecordId = record.Id, ExperimentGradeItemId = item.Id }))); await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(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.Records) .ThenInclude(x => x.ExperimentSession) .ThenInclude(x => x!.Instructors) .ThenInclude(x => x.Teacher) .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!) && !CanEditBoundSessionRecords(sheet, request.Records)) 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(); } [HttpGet("sheets/{id:guid}/template")] [Authorize(Roles = Managers)] public async Task DownloadTemplate( Guid id, CancellationToken cancellationToken) { var sheet = await LoadEditableSheetAsync(id, cancellationToken); if (sheet is null) return NotFound(); if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid(); var headers = ExperimentImportHeaders(sheet.Items); var rows = sheet.Records .OrderBy(record => record.Student!.StudentNumber) .Select(record => { var values = new List { record.Student!.StudentNumber, record.Student.Name, record.Student.AdministrativeClass!.Name, ParticipationLabel(record.ParticipationStatus) }; foreach (var item in sheet.Items.OrderBy(item => item.SortOrder)) values.Add(record.ItemScores.FirstOrDefault(score => score.ExperimentGradeItemId == item.Id)?.Score); values.Add(record.SafetyViolation); values.Add(record.AttemptNumber); values.Add(null); values.Add(record.TeacherComment); return (IReadOnlyList)values; }) .ToList(); var itemCount = sheet.Items.Count; var totalColumn = 7 + itemCount; var scoreColumns = Enumerable.Range(5, itemCount).Append(totalColumn); var instructions = new List { "请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配本实验成绩单的学生。", "参与状态填写:待登记、已完成、缺席、请假、补做 或 免做。", "评分项填写 0—100 的数值,留空表示暂未录入。", "安全违规填写 是 或 否;实验次数填写 1—20。", "实验总评(自动计算)仅供 Excel 预览;上传时系统会按评分项、参与状态和安全违规重新计算。", $"本实验共 {itemCount} 个评分项:{string.Join("、", sheet.Items.OrderBy(item => item.SortOrder).Select(item => item.Name))}。" }; var bytes = ExcelWorkbookHelper.Create( "实验成绩导入", headers, rows, instructions, (worksheet, rowNumber) => { var itemReferences = Enumerable.Range(5, itemCount) .Select(column => $"{ColumnLetter(column)}{rowNumber}") .ToArray(); var weightedExpression = string.Join("+", sheet.Items .OrderBy(item => item.SortOrder) .Select((item, index) => $"{ColumnLetter(index + 5)}{rowNumber}*{item.Weight.ToString(CultureInfo.InvariantCulture)}/100")); var statusReference = $"D{rowNumber}"; var safetyReference = $"{ColumnLetter(5 + itemCount)}{rowNumber}"; worksheet.Cell(rowNumber, totalColumn).FormulaA1 = $"=IF(OR({statusReference}=\"缺席\",{safetyReference}=\"是\"),0,IF(OR({statusReference}=\"待登记\",{statusReference}=\"请假\",{statusReference}=\"免做\"),\"\",IF(COUNT({string.Join(",", itemReferences)})={itemCount},ROUND({weightedExpression},1),\"\")))"; var totalCell = worksheet.Cell(rowNumber, totalColumn); totalCell.Style.NumberFormat.Format = "0.0"; totalCell.Style.Font.Bold = true; totalCell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB"); foreach (var column in scoreColumns) { var format = worksheet.Range(rowNumber, column, rowNumber, column) .AddConditionalFormat().WhenLessThan(60); format.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#FDECEC"); format.Font.FontColor = ClosedXML.Excel.XLColor.FromHtml("#B42318"); } }); return File(bytes, ExcelWorkbookHelper.ContentType, DownloadFileName.Template("实验成绩", ".xlsx", sheet.ExperimentProject!.Code)); } [HttpPost("sheets/{id:guid}/import")] [Authorize(Roles = Managers)] [RequestSizeLimit(10 * 1024 * 1024)] public async Task ImportRecords( Guid id, IFormFile file, CancellationToken cancellationToken) { var sheet = await LoadEditableSheetAsync(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 headers = ExperimentImportHeaders(sheet.Items); IReadOnlyList rows; try { rows = await ExcelWorkbookHelper.ReadAsync(file, headers.Where(header => header != "实验总评(自动计算)").ToArray(), cancellationToken); } catch (InvalidDataException exception) { return ValidationProblem(exception.Message); } if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的实验成绩数据。"); var records = sheet.Records.ToDictionary(record => record.Student!.StudentNumber, StringComparer.OrdinalIgnoreCase); var items = sheet.Items.OrderBy(item => item.SortOrder).ToList(); var errors = new List(); var updated = 0; foreach (var row in rows) { var studentNumber = row["学号"]; if (string.IsNullOrWhiteSpace(studentNumber)) { errors.Add($"第 {row.RowNumber} 行:学号不能为空。"); continue; } if (!records.TryGetValue(studentNumber, out var record)) { errors.Add($"第 {row.RowNumber} 行:学号“{studentNumber}”不在本实验成绩单中。"); continue; } if (!TryParseParticipationStatus(row, out var participationStatus, out var participationError)) { errors.Add($"第 {row.RowNumber} 行:{participationError}"); continue; } if (!TryParseYesNo(row["安全违规"], out var safetyViolation)) { errors.Add($"第 {row.RowNumber} 行:“安全违规”请填写是或否。"); continue; } if (!int.TryParse(row["实验次数"], out var attemptNumber) || attemptNumber is < 1 or > 20) { errors.Add($"第 {row.RowNumber} 行:“实验次数”请填写 1—20 的整数。"); continue; } var scores = new List(); foreach (var item in items) { var score = ParseOptionalDecimal(row, item.Name, 0, 100, errors); if (errors.Count > 0 && errors[^1].Contains($"第 {row.RowNumber} 行")) break; scores.Add(score); } if (scores.Count != items.Count) continue; record.ParticipationStatus = participationStatus; record.SafetyViolation = safetyViolation; record.AttemptNumber = attemptNumber; record.TeacherComment = Normalize(row["教师评语"]); var scoreMap = record.ItemScores.ToDictionary(score => score.ExperimentGradeItemId); for (var index = 0; index < items.Count; index++) scoreMap[items[index].Id].Score = scores[index]; Recalculate(sheet, record); updated++; } if (errors.Count > 0) return ImportValidationProblem(errors); await db.SaveChangesAsync(cancellationToken); return Ok(new { Updated = updated, Total = rows.Count }); } [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?sheetId={sheet.Id}", 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.ExecuteInRetriableTransactionAsync(async transaction => { await db.SaveChangesAsync(cancellationToken); await ExperimentGradeAggregationService.RefreshTeachingTaskAsync( db, sheet.ExperimentProject!.TeachingTaskId, cancellationToken); await transaction.CommitAsync(cancellationToken); }, cancellationToken, IsolationLevel.Serializable); 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) .Include(x => x.ExperimentProject) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); } private async Task LoadEditableSheetAsync( Guid id, CancellationToken cancellationToken) => await ScopedSheets() .Include(x => x.Items) .Include(x => x.Records) .ThenInclude(x => x.ItemScores) .Include(x => x.Records) .ThenInclude(x => x.Student) .ThenInclude(x => x!.AdministrativeClass) .Include(x => x.ExperimentProject) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Teachers) .ThenInclude(x => x.Teacher) .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) { if (!scope.CollegeId.HasValue) return source.Where(_ => false); var collegeId = scope.CollegeId.Value; return source.Where(x => x.Course!.CollegeId == collegeId || x.Teachers.Any(item => item.Teacher!.CollegeId == 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 ScopedProjects() { var scope = currentUserDataScope.Current; if (scope.Scope == DataScope.Self && scope.IsInRole(SystemRoles.Teacher)) return db.ExperimentProjects.Where(x => (x.ArrangementMode == ExperimentArrangementMode.Centralized && x.TeachingTask!.Teachers.Any(item => item.Teacher!.UserId == scope.UserId)) || (x.ArrangementMode == ExperimentArrangementMode.SelfScheduled && x.Sessions.Any(session => session.Instructors.Any(instructor => instructor.Teacher!.UserId == scope.UserId)))); 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 CanEditBoundSessionRecords( ExperimentGradeSheet sheet, IReadOnlyCollection requestedRecords) { var scope = currentUserDataScope.Current; if (sheet.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled || scope.Scope != DataScope.Self || !scope.IsInRole(SystemRoles.Teacher) || requestedRecords.Count == 0) return false; var requestedIds = requestedRecords.Select(x => x.Id).ToHashSet(); return requestedIds.All(recordId => sheet.Records.Any(record => record.Id == recordId && record.ExperimentSession?.Instructors.Any(instructor => instructor.Teacher?.UserId == scope.UserId) == true)); } 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) || currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin); 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 List ExperimentImportHeaders( IEnumerable items) { var headers = new List { "学号", "姓名", "班级", "参与状态" }; headers.AddRange(items.OrderBy(item => item.SortOrder).Select(item => item.Name)); headers.AddRange(["安全违规", "实验次数", "实验总评(自动计算)", "教师评语"]); return headers; } private static string ParticipationLabel( ExperimentParticipationStatus status) => status switch { ExperimentParticipationStatus.Pending => "待登记", ExperimentParticipationStatus.Completed => "已完成", ExperimentParticipationStatus.Absent => "缺席", ExperimentParticipationStatus.Excused => "请假", ExperimentParticipationStatus.Makeup => "补做", ExperimentParticipationStatus.Exempt => "免做", _ => "待登记" }; private static bool TryParseParticipationStatus( ExcelRow row, out ExperimentParticipationStatus status, out string error) { error = string.Empty; switch (row["参与状态"]) { case "待登记": status = ExperimentParticipationStatus.Pending; return true; case "已完成": status = ExperimentParticipationStatus.Completed; return true; case "缺席": status = ExperimentParticipationStatus.Absent; return true; case "请假": status = ExperimentParticipationStatus.Excused; return true; case "补做": status = ExperimentParticipationStatus.Makeup; return true; case "免做": status = ExperimentParticipationStatus.Exempt; return true; default: status = default; error = "“参与状态”请填写待登记、已完成、缺席、请假、补做或免做。"; return false; } } private static bool TryParseYesNo(string value, out bool result) { if (value == "是") { result = true; return true; } if (value == "否") { result = false; return true; } result = false; return false; } 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 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 string ColumnLetter(int column) { var result = string.Empty; while (column > 0) { column--; result = (char)('A' + column % 26) + result; column /= 26; } return result; } 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);