1422 lines
59 KiB
C#
1422 lines
59 KiB
C#
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<ActionResult> 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<ActionResult> 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<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.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<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;
|
|
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<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.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<IActionResult> 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<object?>
|
|
{
|
|
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<object?>)values;
|
|
})
|
|
.ToList();
|
|
var itemCount = sheet.Items.Count;
|
|
var totalColumn = 7 + itemCount;
|
|
var scoreColumns = Enumerable.Range(5, itemCount).Append(totalColumn);
|
|
var instructions = new List<string>
|
|
{
|
|
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配本实验成绩单的学生。",
|
|
"参与状态填写:待登记、已完成、缺席、请假、补做 或 免做。",
|
|
"评分项填写 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<ActionResult> 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<ExcelRow> 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<string>();
|
|
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<decimal?>();
|
|
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<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?sheetId={sheet.Id}",
|
|
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.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<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)
|
|
.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<ExperimentGradeSheet?> 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<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)
|
|
{
|
|
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<ExperimentProject> 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<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 CanEditBoundSessionRecords(
|
|
ExperimentGradeSheet sheet,
|
|
IReadOnlyCollection<ExperimentGradeRecordRequest> 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<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 List<string> ExperimentImportHeaders(
|
|
IEnumerable<ExperimentGradeItem> items)
|
|
{
|
|
var headers = new List<string> { "学号", "姓名", "班级", "参与状态" };
|
|
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<string> errors)
|
|
{
|
|
var value = row[header];
|
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
|
if (decimal.TryParse(value, NumberStyles.Number,
|
|
CultureInfo.InvariantCulture, out var result) &&
|
|
result >= minimum && result <= maximum)
|
|
return result;
|
|
errors.Add($"第 {row.RowNumber} 行:“{header}”请填写 {minimum:0}—{maximum:0} 的数值或留空。");
|
|
return null;
|
|
}
|
|
|
|
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
|
{
|
|
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
|
|
if (errors.Count > 50)
|
|
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
|
|
return ValidationProblem(ModelState);
|
|
}
|
|
|
|
private static 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<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);
|