1003 lines
40 KiB
C#
1003 lines
40 KiB
C#
using System.ComponentModel.DataAnnotations;
|
||
using System.Globalization;
|
||
using Jiaowu.Api.Contracts;
|
||
using Jiaowu.Api.Domain.Academic;
|
||
using Jiaowu.Api.Domain.Identity;
|
||
using Jiaowu.Api.Infrastructure.Auth;
|
||
using Jiaowu.Api.Infrastructure.Excel;
|
||
using Jiaowu.Api.Infrastructure.Grades;
|
||
using Jiaowu.Api.Infrastructure.Persistence;
|
||
using Jiaowu.Api.Infrastructure.Teaching;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace Jiaowu.Api.Controllers;
|
||
|
||
[ApiController]
|
||
[Authorize]
|
||
[Route("api/grades")]
|
||
public sealed class GradesController(
|
||
AppDbContext db,
|
||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||
{
|
||
private const string SheetUsers =
|
||
SystemRoles.SuperAdmin + "," +
|
||
SystemRoles.AcademicAdmin + "," +
|
||
SystemRoles.CollegeAdmin + "," +
|
||
SystemRoles.Counselor + "," +
|
||
SystemRoles.Teacher;
|
||
|
||
private const string Reviewers =
|
||
SystemRoles.SuperAdmin + "," +
|
||
SystemRoles.AcademicAdmin + "," +
|
||
SystemRoles.CollegeAdmin;
|
||
|
||
private const string Publishers =
|
||
SystemRoles.SuperAdmin + "," +
|
||
SystemRoles.AcademicAdmin;
|
||
|
||
[HttpGet("sheets")]
|
||
[Authorize(Roles = SheetUsers)]
|
||
public async Task<ActionResult> GetSheets(
|
||
Guid? academicTermId,
|
||
GradeSheetStatus? status,
|
||
string? keyword = null,
|
||
int page = 1,
|
||
int pageSize = 20,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
page = Math.Max(1, page);
|
||
pageSize = Math.Clamp(pageSize, 10, 50);
|
||
var source = AccessibleTasks().AsNoTracking()
|
||
.Where(x =>
|
||
x.Status == TeachingTaskStatus.Published ||
|
||
x.Status == TeachingTaskStatus.Closed);
|
||
if (academicTermId.HasValue)
|
||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||
if (status.HasValue)
|
||
source = source.Where(x => db.GradeSheets.Any(sheet =>
|
||
sheet.TeachingTaskId == x.Id && sheet.Status == status.Value));
|
||
if (!string.IsNullOrWhiteSpace(keyword))
|
||
{
|
||
keyword = keyword.Trim();
|
||
source = source.Where(x =>
|
||
x.TaskNumber.Contains(keyword) ||
|
||
x.Name.Contains(keyword) ||
|
||
x.Course!.Code.Contains(keyword) ||
|
||
x.Course.Name.Contains(keyword));
|
||
}
|
||
|
||
var total = await source.CountAsync(cancellationToken);
|
||
var items = await source
|
||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||
.ThenBy(x => x.TaskNumber)
|
||
.Skip((page - 1) * pageSize)
|
||
.Take(pageSize)
|
||
.Select(x => new
|
||
{
|
||
x.Id,
|
||
x.TaskNumber,
|
||
TaskName = x.Name,
|
||
x.AcademicTermId,
|
||
TermName = x.AcademicTerm!.Name,
|
||
CourseCode = x.Course!.Code,
|
||
CourseName = x.Course.Name,
|
||
CollegeName = x.Course.College!.Name,
|
||
x.Course.Credits,
|
||
TeacherNames = x.Teachers
|
||
.OrderByDescending(item => item.IsPrimary)
|
||
.Select(item => item.Teacher!.Name),
|
||
ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name),
|
||
Sheet = db.GradeSheets
|
||
.Where(sheet => sheet.TeachingTaskId == x.Id)
|
||
.Select(sheet => new
|
||
{
|
||
sheet.Id,
|
||
sheet.Status,
|
||
sheet.RegularWeight,
|
||
sheet.FinalWeight,
|
||
Items = sheet.Items.OrderBy(item => item.SortOrder).Select(item => new
|
||
{
|
||
item.Id,
|
||
item.Name,
|
||
item.Weight
|
||
}),
|
||
StudentCount = sheet.Records.Count,
|
||
CompletedCount = sheet.Records.Count(record =>
|
||
record.TotalScore != null ||
|
||
record.ExamStatus != GradeExamStatus.Normal),
|
||
PassedCount = sheet.Records.Count(record =>
|
||
record.TotalScore >= 60),
|
||
sheet.SubmittedAt,
|
||
sheet.ReviewedAt,
|
||
sheet.PublishedAt,
|
||
sheet.UpdatedAt
|
||
})
|
||
.FirstOrDefault()
|
||
})
|
||
.ToListAsync(cancellationToken);
|
||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||
}
|
||
|
||
[HttpPost("sheets")]
|
||
[Authorize(Roles = SheetUsers)]
|
||
public async Task<ActionResult> CreateSheet(
|
||
GradeSheetRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var items = request.Items?
|
||
.Select((item, index) => new GradeItem
|
||
{
|
||
Name = item.Name.Trim(),
|
||
Weight = item.Weight,
|
||
SortOrder = index
|
||
})
|
||
.ToList() ?? [];
|
||
|
||
if (!GradeCalculator.AreWeightsValid(
|
||
request.RegularWeight,
|
||
request.FinalWeight,
|
||
items))
|
||
return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");
|
||
|
||
var task = await AccessibleTasks()
|
||
.Include(x => x.Teachers)
|
||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
||
if (task is null) return NotFound();
|
||
if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed))
|
||
return ConflictProblem("只有已发布或已结课的教学班可以建立成绩单。");
|
||
if (!CanInitialize(task)) return Forbid();
|
||
if (await db.GradeSheets.AnyAsync(
|
||
x => x.TeachingTaskId == task.Id,
|
||
cancellationToken))
|
||
return ConflictProblem("该教学班已经建立成绩单。");
|
||
|
||
var studentIds = await TeachingTaskRosterQuery
|
||
.ForTask(db, task.Id)
|
||
.AsNoTracking()
|
||
.Select(x => x.Id)
|
||
.ToListAsync(cancellationToken);
|
||
if (studentIds.Count == 0)
|
||
return ConflictProblem("教学班当前没有有效学生,无法建立成绩单。");
|
||
|
||
var sheet = new GradeSheet
|
||
{
|
||
TeachingTaskId = task.Id,
|
||
RegularWeight = request.RegularWeight,
|
||
FinalWeight = request.FinalWeight,
|
||
Items = items,
|
||
Records = studentIds.Select(studentId => new GradeRecord
|
||
{
|
||
StudentId = studentId,
|
||
ItemScores = items.Select(item => new GradeItemScore
|
||
{
|
||
GradeItemId = item.Id
|
||
}).ToList()
|
||
}).ToList()
|
||
};
|
||
db.GradeSheets.Add(sheet);
|
||
return await SaveAsync(sheet.Id, true, cancellationToken);
|
||
}
|
||
|
||
[HttpGet("sheets/{id:guid}")]
|
||
[Authorize(Roles = SheetUsers)]
|
||
public async Task<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 AccessibleSheets().AsNoTracking()
|
||
.Where(x => x.Id == id)
|
||
.Select(x => new
|
||
{
|
||
x.Id,
|
||
x.TeachingTaskId,
|
||
x.TeachingTask!.TaskNumber,
|
||
TaskName = x.TeachingTask.Name,
|
||
x.TeachingTask.AcademicTermId,
|
||
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||
CourseCode = x.TeachingTask.Course!.Code,
|
||
CourseName = x.TeachingTask.Course.Name,
|
||
CourseCollegeId = x.TeachingTask.Course.CollegeId,
|
||
CourseCollegeName = x.TeachingTask.Course.College!.Name,
|
||
x.TeachingTask.Course.Credits,
|
||
TeacherNames = x.TeachingTask.Teachers
|
||
.OrderByDescending(item => item.IsPrimary)
|
||
.Select(item => item.Teacher!.Name),
|
||
ClassNames = x.TeachingTask.Classes
|
||
.Select(item => item.AdministrativeClass!.Name),
|
||
x.RegularWeight,
|
||
x.FinalWeight,
|
||
Items = x.Items.OrderBy(item => item.SortOrder).Select(item => new
|
||
{
|
||
item.Id,
|
||
item.Name,
|
||
item.Weight
|
||
}),
|
||
x.Status,
|
||
x.ReviewComment,
|
||
x.SubmittedAt,
|
||
x.ReviewedAt,
|
||
x.PublishedAt,
|
||
x.CreatedAt,
|
||
x.UpdatedAt
|
||
})
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
if (sheet is null) return NotFound();
|
||
|
||
var recordsSource = db.GradeRecords.AsNoTracking()
|
||
.Where(record => record.GradeSheetId == id);
|
||
if (studentKeyword is not null)
|
||
recordsSource = recordsSource.Where(record =>
|
||
record.Student!.StudentNumber.Contains(studentKeyword) ||
|
||
record.Student.Name.Contains(studentKeyword) ||
|
||
record.Student.AdministrativeClass!.Name.Contains(studentKeyword));
|
||
var recordTotal = await recordsSource.CountAsync(cancellationToken);
|
||
var records = await recordsSource
|
||
.OrderBy(record => record.Student!.StudentNumber)
|
||
.Skip((recordPage - 1) * recordPageSize)
|
||
.Take(recordPageSize)
|
||
.Select(record => new
|
||
{
|
||
record.Id,
|
||
record.StudentId,
|
||
record.Student!.StudentNumber,
|
||
record.Student.Name,
|
||
ClassName = record.Student.AdministrativeClass!.Name,
|
||
record.RegularScore,
|
||
record.FinalScore,
|
||
ItemScores = record.ItemScores
|
||
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
|
||
.Select(itemScore => new
|
||
{
|
||
itemScore.GradeItemId,
|
||
itemScore.GradeItem!.Name,
|
||
itemScore.Score
|
||
}),
|
||
record.TotalScore,
|
||
record.GradePoint,
|
||
record.ExamStatus,
|
||
record.Notes,
|
||
record.UpdatedAt
|
||
})
|
||
.ToListAsync(cancellationToken);
|
||
|
||
var task = await db.TeachingTasks.AsNoTracking()
|
||
.Include(x => x.Teachers)
|
||
.ThenInclude(x => x.Teacher)
|
||
.SingleAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken);
|
||
|
||
// Determine if current user is from the course's college
|
||
var scope = currentUserDataScope.Current;
|
||
var isCourseCollegeReviewer = IsReviewer() &&
|
||
(scope.Scope == DataScope.All ||
|
||
scope.RestrictedCollegeId == sheet.CourseCollegeId);
|
||
|
||
return Ok(new
|
||
{
|
||
Sheet = new
|
||
{
|
||
sheet.Id,
|
||
sheet.TeachingTaskId,
|
||
sheet.TaskNumber,
|
||
sheet.TaskName,
|
||
sheet.AcademicTermId,
|
||
sheet.TermName,
|
||
sheet.CourseCode,
|
||
sheet.CourseName,
|
||
sheet.CourseCollegeId,
|
||
sheet.CourseCollegeName,
|
||
sheet.Credits,
|
||
sheet.TeacherNames,
|
||
sheet.ClassNames,
|
||
sheet.RegularWeight,
|
||
sheet.FinalWeight,
|
||
sheet.Items,
|
||
sheet.Status,
|
||
sheet.ReviewComment,
|
||
sheet.SubmittedAt,
|
||
sheet.ReviewedAt,
|
||
sheet.PublishedAt,
|
||
RecordTotal = recordTotal,
|
||
RecordPage = recordPage,
|
||
RecordPageSize = recordPageSize,
|
||
Records = records,
|
||
sheet.CreatedAt,
|
||
sheet.UpdatedAt
|
||
},
|
||
CanEdit = CanEditScores(task) &&
|
||
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
|
||
CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted,
|
||
CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved,
|
||
NeedsCollegeReview = sheet.CourseCollegeName
|
||
});
|
||
}
|
||
|
||
[HttpPut("sheets/{id:guid}/weights")]
|
||
[Authorize(Roles = SheetUsers)]
|
||
public async Task<ActionResult> UpdateWeights(
|
||
Guid id,
|
||
GradeWeightsRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var items = request.Items?
|
||
.Select((item, index) => new GradeItem
|
||
{
|
||
Name = item.Name.Trim(),
|
||
Weight = item.Weight,
|
||
SortOrder = index
|
||
})
|
||
.ToList() ?? [];
|
||
|
||
if (!GradeCalculator.AreWeightsValid(
|
||
request.RegularWeight,
|
||
request.FinalWeight,
|
||
items))
|
||
return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");
|
||
|
||
var sheet = await AccessibleSheets()
|
||
.Include(x => x.Items)
|
||
.Include(x => x.Records)
|
||
.ThenInclude(x => x.ItemScores)
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.Teachers)
|
||
.ThenInclude(x => x.Teacher)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (sheet is null) return NotFound();
|
||
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
|
||
return ConflictProblem("当前状态不能修改成绩构成。");
|
||
|
||
sheet.RegularWeight = request.RegularWeight;
|
||
sheet.FinalWeight = request.FinalWeight;
|
||
|
||
db.GradeItems.RemoveRange(sheet.Items);
|
||
var itemIdsToRemove = sheet.Items.Select(item => item.Id).ToHashSet();
|
||
foreach (var record in sheet.Records)
|
||
{
|
||
var toRemove = record.ItemScores
|
||
.Where(itemScore => itemIdsToRemove.Contains(itemScore.GradeItemId))
|
||
.ToList();
|
||
foreach (var removed in toRemove)
|
||
record.ItemScores.Remove(removed);
|
||
}
|
||
|
||
sheet.Items = items;
|
||
foreach (var record in sheet.Records)
|
||
{
|
||
foreach (var item in items)
|
||
{
|
||
record.ItemScores.Add(new GradeItemScore { GradeItemId = item.Id });
|
||
}
|
||
Recalculate(sheet, record);
|
||
}
|
||
|
||
return await SaveAsync(id, false, cancellationToken);
|
||
}
|
||
|
||
[HttpPut("sheets/{id:guid}/records")]
|
||
[Authorize(Roles = SheetUsers)]
|
||
public async Task<ActionResult> UpdateRecords(
|
||
Guid id,
|
||
GradeRecordsRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var sheet = await AccessibleSheets()
|
||
.Include(x => x.Items)
|
||
.Include(x => x.Records)
|
||
.ThenInclude(x => x.ItemScores)
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.Teachers)
|
||
.ThenInclude(x => x.Teacher)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (sheet is null) return NotFound();
|
||
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
|
||
return ConflictProblem("成绩单提交后不能继续修改。");
|
||
|
||
var records = sheet.Records.ToDictionary(x => x.Id);
|
||
if (request.Records.Select(x => x.Id).Distinct().Count() != request.Records.Count ||
|
||
request.Records.Any(x => !records.ContainsKey(x.Id)))
|
||
return ValidationProblem("包含无效或重复的成绩记录。");
|
||
|
||
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet();
|
||
foreach (var item in request.Records)
|
||
{
|
||
if (!ValidScore(item.RegularScore) ||
|
||
!ValidScore(item.FinalScore))
|
||
return ValidationProblem("成绩必须在 0—100 分之间。");
|
||
|
||
var record = records[item.Id];
|
||
record.RegularScore = item.RegularScore;
|
||
record.FinalScore = item.FinalScore;
|
||
record.ExamStatus = item.ExamStatus;
|
||
record.Notes = Normalize(item.Notes);
|
||
|
||
if (item.ItemScores is not null)
|
||
{
|
||
var scoreMap = record.ItemScores.ToDictionary(s => s.GradeItemId);
|
||
foreach (var scoreEntry in item.ItemScores)
|
||
{
|
||
if (!ValidScore(scoreEntry.Score))
|
||
return ValidationProblem("分项成绩必须在 0—100 分之间。");
|
||
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing))
|
||
existing.Score = scoreEntry.Score;
|
||
}
|
||
}
|
||
|
||
Recalculate(sheet, record);
|
||
}
|
||
return await SaveAsync(id, false, cancellationToken);
|
||
}
|
||
|
||
[HttpPost("sheets/{id:guid}/submit")]
|
||
[Authorize(Roles = SheetUsers)]
|
||
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
||
{
|
||
var sheet = await AccessibleSheets()
|
||
.Include(x => x.Items)
|
||
.Include(x => x.Records)
|
||
.ThenInclude(x => x.ItemScores)
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.Teachers)
|
||
.ThenInclude(x => x.Teacher)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (sheet is null) return NotFound();
|
||
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
|
||
return ConflictProblem("只有草稿或已退回成绩单可以提交。");
|
||
if (sheet.Records.Count == 0)
|
||
return ConflictProblem("成绩单没有学生记录。");
|
||
|
||
// Final recalculation pass before submit
|
||
foreach (var record in sheet.Records)
|
||
Recalculate(sheet, record);
|
||
|
||
if (sheet.Records.Any(x =>
|
||
x.ExamStatus == GradeExamStatus.Normal &&
|
||
!x.TotalScore.HasValue))
|
||
return ConflictProblem("仍有学生缺少必填成绩分项,请补充后再提交。");
|
||
sheet.Status = GradeSheetStatus.Submitted;
|
||
sheet.SubmittedAt = DateTime.UtcNow;
|
||
sheet.ReviewComment = null;
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
|
||
// Notify college admins
|
||
var courseName = sheet.TeachingTask!.Course!.Name;
|
||
var collegeId = sheet.TeachingTask.Course.CollegeId;
|
||
await NotificationService.SendToRoleAsync(db,
|
||
SystemRoles.CollegeAdmin,
|
||
"成绩单待审核",
|
||
$"《{courseName}》成绩已提交,请及时审核。",
|
||
collegeId,
|
||
"/grades",
|
||
cancellationToken,
|
||
NotificationCategory.Grade);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("sheets/{id:guid}/approve")]
|
||
[Authorize(Roles = Reviewers)]
|
||
public async Task<ActionResult> Approve(Guid id, CancellationToken cancellationToken)
|
||
{
|
||
var sheet = await db.GradeSheets
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.Course)
|
||
.ThenInclude(x => x!.College)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (sheet is null) return NotFound();
|
||
if (!IsReviewer()) return Forbid();
|
||
|
||
// College-specific check: CollegeAdmin must belong to the course's college
|
||
var scope = currentUserDataScope.Current;
|
||
if (scope.Scope == DataScope.College &&
|
||
sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
|
||
return ConflictProblem(
|
||
$"该课程属于{sheet.TeachingTask.Course.College!.Name}," +
|
||
"您只能审核本学院课程的成绩单。");
|
||
|
||
if (sheet.Status != GradeSheetStatus.Submitted)
|
||
return ConflictProblem("只有待审核成绩单可以通过审核。");
|
||
sheet.Status = GradeSheetStatus.Approved;
|
||
sheet.ReviewedAt = DateTime.UtcNow;
|
||
sheet.ReviewComment = null;
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
|
||
// Notify teachers
|
||
var teacherUserIds = await db.TeachingTaskTeachers
|
||
.Where(x => x.TeachingTaskId == sheet.TeachingTaskId)
|
||
.Select(x => x.Teacher!.UserId)
|
||
.Where(id => id != null)
|
||
.Select(id => id!.Value)
|
||
.ToListAsync(cancellationToken);
|
||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||
"成绩审核通过",
|
||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已通过学院审核,等待校级发布。",
|
||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("sheets/{id:guid}/return")]
|
||
[Authorize(Roles = Reviewers)]
|
||
public async Task<ActionResult> Return(
|
||
Guid id,
|
||
GradeReviewRequest request,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var sheet = await db.GradeSheets
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.Course)
|
||
.ThenInclude(x => x!.College)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (sheet is null) return NotFound();
|
||
if (!IsReviewer()) return Forbid();
|
||
|
||
// College-specific check
|
||
var scope = currentUserDataScope.Current;
|
||
if (scope.Scope == DataScope.College &&
|
||
sheet.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId)
|
||
return ConflictProblem(
|
||
$"该课程属于{sheet.TeachingTask.Course.College!.Name}," +
|
||
"您只能审核本学院课程的成绩单。");
|
||
|
||
if (sheet.Status != GradeSheetStatus.Submitted)
|
||
return ConflictProblem("只有待审核成绩单可以退回。");
|
||
if (string.IsNullOrWhiteSpace(request.Comment))
|
||
return ValidationProblem("退回时必须填写修改意见。");
|
||
sheet.Status = GradeSheetStatus.Returned;
|
||
sheet.ReviewedAt = DateTime.UtcNow;
|
||
sheet.ReviewComment = request.Comment.Trim();
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
|
||
var teacherUserIds = await db.TeachingTaskTeachers
|
||
.Where(x => x.TeachingTaskId == sheet.TeachingTaskId)
|
||
.Select(x => x.Teacher!.UserId)
|
||
.Where(id => id != null)
|
||
.Select(id => id!.Value)
|
||
.ToListAsync(cancellationToken);
|
||
await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
|
||
"成绩被退回",
|
||
$"《{sheet.TeachingTask!.Course!.Name}》成绩被退回修改:{sheet.ReviewComment}",
|
||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("sheets/{id:guid}/publish")]
|
||
[Authorize(Roles = Publishers)]
|
||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||
{
|
||
var sheet = await AccessibleSheets()
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.Course)
|
||
.FirstOrDefaultAsync(
|
||
x => x.Id == id,
|
||
cancellationToken);
|
||
if (sheet is null) return NotFound();
|
||
if (!IsPublisher()) return Forbid();
|
||
if (sheet.Status != GradeSheetStatus.Approved)
|
||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||
sheet.Status = GradeSheetStatus.Published;
|
||
sheet.PublishedAt = DateTime.UtcNow;
|
||
|
||
// The grade sheet roster is authoritative at publication time. This also
|
||
// covers students added through approved roster corrections.
|
||
var studentUserIds = await db.GradeRecords
|
||
.Where(x => x.GradeSheetId == sheet.Id)
|
||
.Select(x => x.Student!.UserId)
|
||
.Where(id => id != null)
|
||
.Select(id => id!.Value)
|
||
.Distinct()
|
||
.ToListAsync(cancellationToken);
|
||
await NotificationService.SendToUserIdsAsync(db, studentUserIds,
|
||
"成绩已发布",
|
||
$"《{sheet.TeachingTask!.Course!.Name}》成绩已正式发布,请前往成绩单查看。",
|
||
"/grades", cancellationToken, NotificationCategory.Grade);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpGet("sheets/{id:guid}/template")]
|
||
[Authorize(Roles = SheetUsers)]
|
||
public async Task<IActionResult> DownloadTemplate(
|
||
Guid id,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var sheet = await AccessibleSheets()
|
||
.Include(x => x.Items.OrderBy(i => i.SortOrder))
|
||
.Include(x => x.Records.OrderBy(r => r.Student!.StudentNumber))
|
||
.ThenInclude(x => x.Student)
|
||
.ThenInclude(x => x!.AdministrativeClass)
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.Teachers)
|
||
.ThenInclude(x => x.Teacher)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (sheet is null) return NotFound();
|
||
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||
|
||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||
headers.AddRange(itemNames);
|
||
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
||
|
||
var rows = sheet.Records.Select(record =>
|
||
{
|
||
var values = new List<object?>
|
||
{
|
||
record.Student!.StudentNumber,
|
||
record.Student.Name,
|
||
record.Student.AdministrativeClass!.Name,
|
||
record.RegularScore
|
||
};
|
||
foreach (var item in sheet.Items)
|
||
{
|
||
var score = record.ItemScores
|
||
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
||
values.Add(score);
|
||
}
|
||
values.Add(record.FinalScore);
|
||
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
|
||
record.ExamStatus == GradeExamStatus.Exempt ? "免修" : "正常");
|
||
values.Add(record.Notes);
|
||
return (IReadOnlyList<object?>)values;
|
||
}).ToList();
|
||
|
||
var instructions = new List<string>
|
||
{
|
||
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
|
||
"成绩列填写 0—100 的数值,留空表示暂未录入。",
|
||
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
|
||
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("、", itemNames)}。",
|
||
"导入后会自动重新计算总评成绩和绩点。"
|
||
};
|
||
|
||
var bytes = ExcelWorkbookHelper.Create(
|
||
"成绩导入", headers, rows, instructions);
|
||
var taskName = sheet.TeachingTask!.Name;
|
||
return File(bytes, ExcelWorkbookHelper.ContentType,
|
||
$"成绩导入模板-{taskName}.xlsx");
|
||
}
|
||
|
||
[HttpPost("sheets/{id:guid}/import")]
|
||
[Authorize(Roles = SheetUsers)]
|
||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||
public async Task<ActionResult> ImportGrades(
|
||
Guid id,
|
||
IFormFile file,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var sheet = await AccessibleSheets()
|
||
.Include(x => x.Items.OrderBy(i => i.SortOrder))
|
||
.Include(x => x.Records)
|
||
.ThenInclude(x => x.ItemScores)
|
||
.Include(x => x.Records)
|
||
.ThenInclude(x => x.Student)
|
||
.ThenInclude(x => x!.AdministrativeClass)
|
||
.Include(x => x.TeachingTask)
|
||
.ThenInclude(x => x!.Teachers)
|
||
.ThenInclude(x => x.Teacher)
|
||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||
if (sheet is null) return NotFound();
|
||
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
|
||
return ConflictProblem("只有草稿或已退回成绩单可以导入成绩。");
|
||
|
||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||
headers.AddRange(itemNames);
|
||
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
||
|
||
IReadOnlyList<ExcelRow> rows;
|
||
try
|
||
{
|
||
rows = await ExcelWorkbookHelper.ReadAsync(
|
||
file, headers, cancellationToken);
|
||
}
|
||
catch (InvalidDataException exception)
|
||
{
|
||
return ValidationProblem(exception.Message);
|
||
}
|
||
|
||
if (rows.Count == 0)
|
||
return ValidationProblem("Excel 中没有可导入的成绩数据。");
|
||
|
||
var studentMap = sheet.Records.ToDictionary(
|
||
x => x.Student!.StudentNumber,
|
||
StringComparer.OrdinalIgnoreCase);
|
||
var itemMap = sheet.Items.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase);
|
||
|
||
var errors = new List<string>();
|
||
var updated = 0;
|
||
|
||
foreach (var row in rows)
|
||
{
|
||
var studentNumber = row["学号"]?.Trim();
|
||
if (string.IsNullOrWhiteSpace(studentNumber))
|
||
{
|
||
errors.Add($"第 {row.RowNumber} 行:学号不能为空。");
|
||
continue;
|
||
}
|
||
if (!studentMap.TryGetValue(studentNumber, out var record))
|
||
{
|
||
errors.Add($"第 {row.RowNumber} 行:学号“{studentNumber}”不在本成绩单中。");
|
||
continue;
|
||
}
|
||
|
||
// Parse regular score
|
||
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
|
||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||
|
||
// Parse final score
|
||
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
|
||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||
|
||
// Parse item scores
|
||
var itemScores = new List<(Guid GradeItemId, decimal? Score)>();
|
||
foreach (var itemName in itemNames)
|
||
{
|
||
if (itemMap.TryGetValue(itemName, out var item))
|
||
{
|
||
var score = ParseOptionalDecimal(row, itemName, 0, 100, errors);
|
||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString()))
|
||
break;
|
||
itemScores.Add((item.Id, score));
|
||
}
|
||
}
|
||
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||
|
||
// Parse exam status
|
||
var examStatusText = row["考试状态"]?.Trim();
|
||
var examStatus = GradeExamStatus.Normal;
|
||
if (!string.IsNullOrWhiteSpace(examStatusText))
|
||
{
|
||
if (examStatusText == "缺考") examStatus = GradeExamStatus.Absent;
|
||
else if (examStatusText == "缓考") examStatus = GradeExamStatus.Deferred;
|
||
else if (examStatusText == "免修") examStatus = GradeExamStatus.Exempt;
|
||
else if (examStatusText != "正常")
|
||
{
|
||
errors.Add($"第 {row.RowNumber} 行:考试状态“{examStatusText}”无效,请填写正常、缺考、缓考或免修。");
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Apply
|
||
record.RegularScore = regularScore;
|
||
record.FinalScore = finalScore;
|
||
record.ExamStatus = examStatus;
|
||
record.Notes = Normalize(row["备注"]);
|
||
|
||
foreach (var (itemId, score) in itemScores)
|
||
{
|
||
var existingScore = record.ItemScores
|
||
.FirstOrDefault(s => s.GradeItemId == itemId);
|
||
if (existingScore != null)
|
||
existingScore.Score = score;
|
||
}
|
||
|
||
Recalculate(sheet, record);
|
||
updated++;
|
||
}
|
||
|
||
if (errors.Count > 0)
|
||
return ImportValidationProblem(errors);
|
||
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
return Ok(new { updated, total = rows.Count });
|
||
}
|
||
|
||
[HttpGet("student/transcript")]
|
||
[Authorize(Roles = SystemRoles.Student)]
|
||
public async Task<ActionResult> GetStudentTranscript(
|
||
Guid? academicTermId,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var userId = currentUserDataScope.Current.UserId;
|
||
var student = await db.Students.AsNoTracking()
|
||
.Where(x => x.UserId == userId)
|
||
.Select(x => new
|
||
{
|
||
x.Id,
|
||
x.StudentNumber,
|
||
x.Name,
|
||
ClassName = x.AdministrativeClass!.Name,
|
||
MajorName = x.AdministrativeClass.Major!.Name
|
||
})
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
if (student is null)
|
||
return ConflictProblem("当前账号未关联有效学生档案。");
|
||
|
||
var source = db.GradeRecords.AsNoTracking()
|
||
.Where(x =>
|
||
x.StudentId == student.Id &&
|
||
x.GradeSheet!.Status == GradeSheetStatus.Published);
|
||
if (academicTermId.HasValue)
|
||
source = source.Where(x =>
|
||
x.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId);
|
||
var records = await source
|
||
.OrderByDescending(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
|
||
.ThenBy(x => x.GradeSheet!.TeachingTask!.Course!.Code)
|
||
.Select(x => new
|
||
{
|
||
x.Id,
|
||
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
||
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||
x.GradeSheet.TeachingTaskId,
|
||
x.GradeSheet.TeachingTask.TaskNumber,
|
||
CourseCode = x.GradeSheet.TeachingTask.Course!.Code,
|
||
CourseName = x.GradeSheet.TeachingTask.Course.Name,
|
||
x.GradeSheet.TeachingTask.Course.Credits,
|
||
x.TotalScore,
|
||
x.GradePoint,
|
||
x.ExamStatus,
|
||
x.GradeSheet.PublishedAt
|
||
})
|
||
.ToListAsync(cancellationToken);
|
||
return Ok(new { Student = student, Records = records });
|
||
}
|
||
|
||
private IQueryable<TeachingTask> AccessibleTasks()
|
||
{
|
||
var source = db.TeachingTasks.AsQueryable();
|
||
var scope = currentUserDataScope.Current;
|
||
if (scope.Scope == DataScope.All) return source;
|
||
if (scope.Scope == DataScope.College)
|
||
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
|
||
if (scope.IsInRole(SystemRoles.Counselor))
|
||
{
|
||
// Counselor sees grades for courses in their managed classes' college
|
||
var collegeIds = db.AdministrativeClasses
|
||
.Where(c => c.CounselorUserId == scope.UserId)
|
||
.Select(c => c.Major!.CollegeId)
|
||
.Distinct();
|
||
return source.Where(x => collegeIds.Contains(x.Course!.CollegeId));
|
||
}
|
||
if (scope.IsInRole(SystemRoles.Teacher))
|
||
return source.Where(x =>
|
||
x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId));
|
||
return source.Where(_ => false);
|
||
}
|
||
|
||
private IQueryable<GradeSheet> AccessibleSheets() =>
|
||
db.GradeSheets.Where(x => AccessibleTasks().Any(task =>
|
||
task.Id == x.TeachingTaskId));
|
||
|
||
private bool CanInitialize(TeachingTask task) =>
|
||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
|
||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
|
||
IsAssignedTeacher(task);
|
||
|
||
private bool CanEditScores(TeachingTask task) =>
|
||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||
IsAssignedTeacher(task);
|
||
|
||
private bool IsAssignedTeacher(TeachingTask task) =>
|
||
task.Teachers.Any(x =>
|
||
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
||
|
||
private bool IsReviewer() =>
|
||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
|
||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin);
|
||
|
||
private bool IsPublisher() =>
|
||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||
|
||
private static void Recalculate(GradeSheet sheet, GradeRecord record)
|
||
{
|
||
record.TotalScore = GradeCalculator.CalculateTotal(
|
||
record.RegularScore,
|
||
record.FinalScore,
|
||
record.ItemScores.ToList(),
|
||
sheet.RegularWeight,
|
||
sheet.FinalWeight,
|
||
sheet.Items.ToList(),
|
||
record.ExamStatus);
|
||
record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore);
|
||
}
|
||
|
||
private static bool ValidScore(decimal? score) =>
|
||
!score.HasValue || score.Value is >= 0 and <= 100;
|
||
|
||
private async Task<ActionResult> SaveAsync(
|
||
Guid id,
|
||
bool created,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
await db.SaveChangesAsync(cancellationToken);
|
||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||
}
|
||
catch (DbUpdateException)
|
||
{
|
||
return ConflictProblem("成绩单已存在,或关联学生与教学班已发生变化。");
|
||
}
|
||
}
|
||
|
||
private ActionResult ConflictProblem(string detail) =>
|
||
Conflict(new ProblemDetails
|
||
{
|
||
Title = "无法完成成绩操作",
|
||
Detail = detail,
|
||
Status = StatusCodes.Status409Conflict
|
||
});
|
||
|
||
private ActionResult ImportValidationProblem(IReadOnlyList<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 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 static string? Normalize(string? value) =>
|
||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||
}
|
||
|
||
public sealed record GradeSheetRequest(
|
||
Guid TeachingTaskId,
|
||
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
|
||
[Range(typeof(decimal), "0", "100")] decimal FinalWeight,
|
||
IReadOnlyCollection<GradeItemRequest>? Items);
|
||
|
||
public sealed record GradeItemRequest(
|
||
[MaxLength(60)] string Name,
|
||
[Range(typeof(decimal), "0", "100")] decimal Weight);
|
||
|
||
public sealed record GradeWeightsRequest(
|
||
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
|
||
[Range(typeof(decimal), "0", "100")] decimal FinalWeight,
|
||
IReadOnlyCollection<GradeItemRequest>? Items);
|
||
|
||
public sealed record GradeRecordsRequest(
|
||
IReadOnlyCollection<GradeRecordRequest> Records);
|
||
|
||
public sealed record GradeRecordRequest(
|
||
Guid Id,
|
||
decimal? RegularScore,
|
||
decimal? FinalScore,
|
||
IReadOnlyCollection<GradeItemScoreRequest>? ItemScores,
|
||
GradeExamStatus ExamStatus,
|
||
[MaxLength(300)] string? Notes);
|
||
|
||
public sealed record GradeItemScoreRequest(
|
||
Guid GradeItemId,
|
||
decimal? Score);
|
||
|
||
public sealed record GradeReviewRequest(
|
||
[MaxLength(500)] string? Comment);
|