毕业审核:批次计算、缺失课程检查、人工复核、结果发布。 学位授予:毕业资格与 GPA 计算、人工调整、发布授予结果。 毕业离校:离校事项配置、责任角色分工、逐项办理及批次关闭。
279 lines
12 KiB
C#
279 lines
12 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.Graduation;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Jiaowu.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Authorize]
|
|
[Route("api/degree-awards")]
|
|
public sealed class DegreeAwardsController(
|
|
AppDbContext db,
|
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
|
{
|
|
private const string Managers =
|
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
|
private const string Reviewers = Managers + "," + SystemRoles.CollegeAdmin;
|
|
|
|
[HttpGet("batches")]
|
|
[Authorize(Roles = Reviewers)]
|
|
public async Task<ActionResult> GetBatches(CancellationToken token)
|
|
{
|
|
var collegeId = RestrictedCollegeId();
|
|
return Ok(await db.DegreeAwardBatches.AsNoTracking()
|
|
.OrderByDescending(x => x.GraduationYear)
|
|
.ThenByDescending(x => x.CreatedAt)
|
|
.Select(x => new
|
|
{
|
|
x.Id, x.Name, x.GraduationYear, x.DegreeName,
|
|
x.MinimumGradePoint, x.Status, x.Notes,
|
|
x.CalculatedAt, x.PublishedAt,
|
|
ResultCount = x.Results.Count(result =>
|
|
!collegeId.HasValue ||
|
|
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
|
|
GrantedCount = x.Results.Count(result =>
|
|
(!collegeId.HasValue ||
|
|
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
|
|
result.Conclusion == DegreeAwardConclusion.Granted),
|
|
NotGrantedCount = x.Results.Count(result =>
|
|
(!collegeId.HasValue ||
|
|
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
|
|
result.Conclusion == DegreeAwardConclusion.NotGranted),
|
|
OverrideCount = x.Results.Count(result =>
|
|
(!collegeId.HasValue ||
|
|
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
|
|
result.IsOverridden)
|
|
}).ToListAsync(token));
|
|
}
|
|
|
|
[HttpGet("batches/{id:guid}")]
|
|
[Authorize(Roles = Reviewers)]
|
|
public async Task<ActionResult> GetBatch(Guid id, CancellationToken token)
|
|
{
|
|
var batch = await db.DegreeAwardBatches.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.Id == id, token);
|
|
if (batch is null) return NotFound();
|
|
var collegeId = RestrictedCollegeId();
|
|
var source = db.DegreeAwardResults.AsNoTracking()
|
|
.Where(x => x.DegreeAwardBatchId == id);
|
|
if (collegeId.HasValue)
|
|
source = source.Where(x =>
|
|
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
|
return Ok(new
|
|
{
|
|
batch.Id, batch.Name, batch.GraduationYear, batch.DegreeName,
|
|
batch.MinimumGradePoint, batch.Status, batch.Notes,
|
|
batch.CalculatedAt, batch.PublishedAt,
|
|
Results = await source.OrderBy(x => x.Student!.StudentNumber)
|
|
.Select(x => new
|
|
{
|
|
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
|
|
ClassName = x.Student.AdministrativeClass!.Name,
|
|
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
|
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
|
x.AverageGradePoint, x.CalculatedConclusion, x.Conclusion,
|
|
x.ExceptionReason, x.IsOverridden,
|
|
x.ReviewComment, x.ReviewedAt
|
|
}).ToListAsync(token)
|
|
});
|
|
}
|
|
|
|
[HttpPost("batches")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> Create(
|
|
DegreeAwardBatchRequest request,
|
|
CancellationToken token)
|
|
{
|
|
var batch = new DegreeAwardBatch
|
|
{
|
|
Name = request.Name.Trim(),
|
|
GraduationYear = request.GraduationYear,
|
|
DegreeName = request.DegreeName.Trim(),
|
|
MinimumGradePoint = request.MinimumGradePoint,
|
|
Notes = request.Notes?.Trim()
|
|
};
|
|
db.DegreeAwardBatches.Add(batch);
|
|
await db.SaveChangesAsync(token);
|
|
return Created(string.Empty, new { batch.Id });
|
|
}
|
|
|
|
[HttpPost("batches/{id:guid}/calculate")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> Calculate(Guid id, CancellationToken token)
|
|
{
|
|
var batch = await db.DegreeAwardBatches
|
|
.FirstOrDefaultAsync(x => x.Id == id, token);
|
|
if (batch is null) return NotFound();
|
|
if (batch.Status != DegreeAwardBatchStatus.Draft)
|
|
return ConflictProblem("已发布批次不能重新计算。");
|
|
|
|
var auditCandidates = await db.GraduationAuditResults.AsNoTracking()
|
|
.Include(x => x.GraduationAuditBatch)
|
|
.Include(x => x.Student)
|
|
.ThenInclude(x => x!.AdministrativeClass)
|
|
.ThenInclude(x => x!.Major)
|
|
.Where(x =>
|
|
x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
|
|
x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published &&
|
|
x.Conclusion == GraduationAuditConclusion.Eligible)
|
|
.OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
|
.ToListAsync(token);
|
|
var audits = auditCandidates
|
|
.GroupBy(x => x.StudentId)
|
|
.Select(x => x.First())
|
|
.ToList();
|
|
var studentIds = audits.Select(x => x.StudentId).ToArray();
|
|
var gradePoints = await db.GradeRecords.AsNoTracking()
|
|
.Where(x => studentIds.Contains(x.StudentId) &&
|
|
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
|
x.GradePoint.HasValue)
|
|
.Select(x => new
|
|
{
|
|
x.StudentId,
|
|
GradePoint = x.GradePoint!.Value,
|
|
x.GradeSheet!.TeachingTask!.Course!.Credits
|
|
}).ToListAsync(token);
|
|
|
|
var oldResults = await db.DegreeAwardResults
|
|
.Where(x => x.DegreeAwardBatchId == id).ToListAsync(token);
|
|
db.DegreeAwardResults.RemoveRange(oldResults);
|
|
await db.SaveChangesAsync(token);
|
|
|
|
foreach (var audit in audits)
|
|
{
|
|
var grades = gradePoints.Where(x => x.StudentId == audit.StudentId).ToList();
|
|
var credits = grades.Sum(x => x.Credits);
|
|
var average = credits == 0
|
|
? 0
|
|
: Math.Round(
|
|
grades.Sum(x => x.GradePoint * x.Credits) / credits,
|
|
2,
|
|
MidpointRounding.AwayFromZero);
|
|
var conclusion = DegreeAwardRules.Evaluate(
|
|
true, audit.Student!.Status, average, batch.MinimumGradePoint);
|
|
var reason = audit.Student.Status != StudentStatus.Graduated
|
|
? "学籍状态尚未转为毕业"
|
|
: average < batch.MinimumGradePoint
|
|
? $"平均绩点 {average:0.00},低于批次要求 {batch.MinimumGradePoint:0.00}"
|
|
: string.Empty;
|
|
db.DegreeAwardResults.Add(new DegreeAwardResult
|
|
{
|
|
DegreeAwardBatchId = batch.Id,
|
|
StudentId = audit.StudentId,
|
|
GraduationAuditResultId = audit.Id,
|
|
AverageGradePoint = average,
|
|
CalculatedConclusion = conclusion,
|
|
Conclusion = conclusion,
|
|
ExceptionReason = reason
|
|
});
|
|
}
|
|
batch.CalculatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(token);
|
|
return Ok(new { ResultCount = audits.Count });
|
|
}
|
|
|
|
[HttpPut("results/{id:guid}")]
|
|
[Authorize(Roles = Reviewers)]
|
|
public async Task<ActionResult> Review(
|
|
Guid id,
|
|
DegreeAwardDecisionRequest request,
|
|
CancellationToken token)
|
|
{
|
|
var result = await db.DegreeAwardResults
|
|
.Include(x => x.DegreeAwardBatch)
|
|
.Include(x => x.Student)
|
|
.ThenInclude(x => x!.AdministrativeClass)
|
|
.ThenInclude(x => x!.Major)
|
|
.FirstOrDefaultAsync(x => x.Id == id, token);
|
|
if (result is null) return NotFound();
|
|
if (result.DegreeAwardBatch!.Status != DegreeAwardBatchStatus.Draft)
|
|
return ConflictProblem("已发布的授予结果不能修改。");
|
|
var collegeId = RestrictedCollegeId();
|
|
if (collegeId.HasValue &&
|
|
result.Student!.AdministrativeClass!.Major!.CollegeId != collegeId)
|
|
return Forbid();
|
|
|
|
result.Conclusion = request.Conclusion;
|
|
result.IsOverridden = request.Conclusion != result.CalculatedConclusion;
|
|
result.ReviewComment = request.Comment.Trim();
|
|
result.ReviewedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(token);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPost("batches/{id:guid}/publish")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> Publish(Guid id, CancellationToken token)
|
|
{
|
|
var batch = await db.DegreeAwardBatches.Include(x => x.Results)
|
|
.FirstOrDefaultAsync(x => x.Id == id, token);
|
|
if (batch is null) return NotFound();
|
|
if (batch.Status != DegreeAwardBatchStatus.Draft)
|
|
return ConflictProblem("该批次已经发布。");
|
|
if (batch.Results.Count == 0)
|
|
return ConflictProblem("没有可发布的学位授予审核结果。");
|
|
batch.Status = DegreeAwardBatchStatus.Published;
|
|
batch.PublishedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync(token);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpGet("my-result")]
|
|
[Authorize(Roles = SystemRoles.Student)]
|
|
public async Task<ActionResult> GetMyResult(CancellationToken token)
|
|
{
|
|
var userId = currentUserDataScope.Current.UserId;
|
|
var result = await db.DegreeAwardResults.AsNoTracking()
|
|
.Where(x => x.Student!.UserId == userId &&
|
|
x.DegreeAwardBatch!.Status == DegreeAwardBatchStatus.Published)
|
|
.OrderByDescending(x => x.DegreeAwardBatch!.GraduationYear)
|
|
.ThenByDescending(x => x.DegreeAwardBatch!.PublishedAt)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
BatchName = x.DegreeAwardBatch!.Name,
|
|
x.DegreeAwardBatch.GraduationYear,
|
|
x.DegreeAwardBatch.DegreeName,
|
|
x.DegreeAwardBatch.MinimumGradePoint,
|
|
x.Student!.StudentNumber, x.Student.Name,
|
|
MajorName = x.Student.AdministrativeClass!.Major!.Name,
|
|
x.AverageGradePoint, x.Conclusion, x.ExceptionReason,
|
|
x.IsOverridden, x.ReviewComment,
|
|
x.DegreeAwardBatch.PublishedAt
|
|
}).FirstOrDefaultAsync(token);
|
|
return Ok(result);
|
|
}
|
|
|
|
private Guid? RestrictedCollegeId()
|
|
{
|
|
var scope = currentUserDataScope.Current;
|
|
return scope.IsInRole(SystemRoles.CollegeAdmin)
|
|
? scope.CollegeId ?? Guid.Empty
|
|
: null;
|
|
}
|
|
|
|
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
|
|
{
|
|
Title = "无法完成学位授予操作",
|
|
Detail = detail,
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
}
|
|
|
|
public sealed record DegreeAwardBatchRequest(
|
|
[Required, MinLength(3), MaxLength(120)] string Name,
|
|
[Range(2000, 2200)] int GraduationYear,
|
|
[Required, MinLength(2), MaxLength(80)] string DegreeName,
|
|
[Range(typeof(decimal), "0", "5")] decimal MinimumGradePoint,
|
|
[MaxLength(500)] string? Notes);
|
|
|
|
public sealed record DegreeAwardDecisionRequest(
|
|
DegreeAwardConclusion Conclusion,
|
|
[Required, MinLength(5), MaxLength(500)] string Comment);
|