每个实验项目独立建成绩单,支持评分项、参与状态、补做次数、安全违规、报告地址和教师评语。 流程为:录入 → 学院审核 → 教务发布;学生只能查看已发布成绩。 自主预约实验支持同步有效预约名单,已评分记录不会被误删。 课程成绩可将已发布实验成绩汇总为快照导入指定分项;导入后锁定,不能手工篡改。 实验项目成绩不完整时,对应课程分项保持空白,不按 0 分处理。 一级“实验管理”菜单下增加“项目与场次”“实验成绩”;学生端增加“实验安排”“实验成绩”。 已生成 MySQL 正式迁移及 SQLite 开发迁移,尚未操作生产数据库。 界面采用“实验项目 → 独立评分 → 审核发布”工作台设计。
946 lines
37 KiB
C#
946 lines
37 KiB
C#
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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> GetSheet(
|
|
Guid id,
|
|
int recordPage = 1,
|
|
int recordPageSize = 50,
|
|
string? studentKeyword = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
recordPage = Math.Max(1, recordPage);
|
|
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
|
|
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
|
|
? null
|
|
: studentKeyword.Trim();
|
|
|
|
var sheet = await 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ExperimentGradeRecord>();
|
|
var recordsToAdd = new List<ExperimentGradeRecord>();
|
|
|
|
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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ExperimentGradeSheet?> 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<List<ExperimentParticipantSeed>> 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<ExperimentGradeItem> BuildItems(
|
|
IReadOnlyCollection<ExperimentGradeItemRequest>? 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<TeachingTask> 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<ExperimentProject> ScopedProjects()
|
|
{
|
|
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
|
|
return db.ExperimentProjects.Where(x =>
|
|
taskIds.Contains(x.TeachingTaskId));
|
|
}
|
|
|
|
private IQueryable<ExperimentGradeSheet> 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<Student?> 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<ExperimentGradeItemRequest> Items);
|
|
|
|
public sealed record ExperimentGradeSettingsRequest(
|
|
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
|
|
[Range(typeof(decimal), "0", "100")] decimal PassScore,
|
|
IReadOnlyCollection<ExperimentGradeItemRequest> 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<ExperimentGradeRecordRequest> 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<ExperimentGradeItemScoreRequest> ItemScores);
|
|
|
|
public sealed record ExperimentGradeItemScoreRequest(
|
|
Guid ExperimentGradeItemId,
|
|
decimal? Score,
|
|
[MaxLength(300)] string? Comment);
|
|
|
|
public sealed record ExperimentGradeReviewRequest(
|
|
[MaxLength(500)] string? Comment);
|