Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs
T

466 lines
20 KiB
C#

using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Files;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Domain.System;
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.EnrollmentYear,
x.DegreeName,
x.MinimumGradePoint,
x.Status,
x.Notes,
x.CalculatedAt,
x.CalculationStatus,
x.CalculationError,
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,
[FromQuery] string? cursor = null,
[FromQuery] int pageSize = 20,
[FromQuery] string? keyword = null,
[FromQuery] DegreeAwardConclusion? conclusion = null,
[FromQuery] Guid? majorId = null,
[FromQuery] Guid? administrativeClassId = null,
CancellationToken token = default)
{
pageSize = Math.Clamp(pageSize, 10, 100);
if (!CursorToken.TryDecode<DegreeResultCursor>(cursor, out var after))
return BadRequest("学位授予结果游标无效。");
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);
if (majorId.HasValue)
source = source.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId);
if (administrativeClassId.HasValue)
source = source.Where(x => x.Student!.AdministrativeClassId == administrativeClassId);
var summary = await source
.GroupBy(_ => 1)
.Select(group => new
{
ResultCount = group.Count(),
GrantedCount = group.Count(x =>
x.Conclusion == DegreeAwardConclusion.Granted),
NotGrantedCount = group.Count(x =>
x.Conclusion == DegreeAwardConclusion.NotGranted),
OverrideCount = group.Count(x => x.IsOverridden)
})
.FirstOrDefaultAsync(token);
var normalizedKeyword = keyword?.Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
source = source.Where(x =>
x.Student!.StudentNumber.Contains(normalizedKeyword) ||
x.Student.Name.Contains(normalizedKeyword) ||
x.Student.AdministrativeClass!.Name.Contains(normalizedKeyword) ||
x.Student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword));
if (conclusion.HasValue)
source = source.Where(x => x.Conclusion == conclusion.Value);
var total = await source.CountAsync(token);
if (after is not null)
{
source = source.Where(x => x.Student!.StudentNumber.CompareTo(after.StudentNumber) > 0 ||
x.Student.StudentNumber == after.StudentNumber && x.Id.CompareTo(after.Id) > 0);
}
var rows = await source.OrderBy(x => x.Student!.StudentNumber).ThenBy(x => x.Id)
.Take(pageSize + 1)
.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);
var hasMore = rows.Count > pageSize;
var items = rows.Take(pageSize).ToArray();
var last = items.LastOrDefault();
return Ok(new
{
batch.Id,
batch.Name,
batch.GraduationYear,
batch.EnrollmentYear,
batch.DegreeName,
batch.MinimumGradePoint,
batch.Status,
batch.Notes,
batch.CalculatedAt,
batch.CalculationStatus,
batch.CalculationError,
batch.PublishedAt,
ResultCount = summary?.ResultCount ?? 0,
GrantedCount = summary?.GrantedCount ?? 0,
NotGrantedCount = summary?.NotGrantedCount ?? 0,
OverrideCount = summary?.OverrideCount ?? 0,
Results = new
{
Items = items,
Total = total,
HasMore = hasMore,
NextCursor = hasMore && last is not null
? CursorToken.Encode(new DegreeResultCursor(last.StudentNumber, last.Id)) : null,
PageSize = pageSize
}
});
}
private sealed record DegreeResultCursor(string StudentNumber, Guid Id);
[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,
EnrollmentYear = request.EnrollmentYear,
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> QueueCalculation(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("已发布批次不能重新计算。");
if (batch.CalculationStatus is AuditCalculationStatus.Queued or AuditCalculationStatus.Running)
return ConflictProblem("该批次正在后台计算,请稍候。");
batch.CalculationStatus = AuditCalculationStatus.Queued;
batch.CalculationError = null;
var outbox = await db.BackgroundJobOutboxMessages.FirstOrDefaultAsync(x =>
x.JobKind == BackgroundJobKind.DegreeAwardCalculation && x.JobId == id, token);
if (outbox is null)
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(BackgroundJobKind.DegreeAwardCalculation, id));
else
{
outbox.State = BackgroundJobOutboxState.Pending;
outbox.CompletedAt = null;
outbox.ProcessingToken = null;
outbox.LeaseExpiresAt = null;
outbox.LastError = null;
}
await db.SaveChangesAsync(token);
return Accepted(new { batch.Id, batch.CalculationStatus });
}
[NonAction]
public async Task<ActionResult> CalculateNow(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 students = await db.Students.AsNoTracking()
.Where(x => x.EnrollmentYear == batch.EnrollmentYear &&
(x.Status == StudentStatus.Active || x.Status == StudentStatus.Suspended ||
x.Status == StudentStatus.Graduated))
.OrderBy(x => x.StudentNumber)
.Select(x => new
{
x.Id,
x.Status
})
.ToListAsync(token);
var auditCandidates = await db.GraduationAuditResults.AsNoTracking()
.Where(x => x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear &&
x.GraduationAuditBatch.EnrollmentYear == batch.EnrollmentYear)
.OrderByDescending(x => x.GraduationAuditBatch!.CalculatedAt)
.Select(x => new { x.Id, x.StudentId, x.Conclusion, x.MissingCourseNames })
.ToListAsync(token);
var auditsByStudent = auditCandidates
.GroupBy(x => x.StudentId)
.Select(x => x.First())
.ToDictionary(x => x.StudentId);
var gradePoints = await db.GradeRecords.AsNoTracking()
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradePoint.HasValue)
.Where(x => x.Student!.EnrollmentYear == batch.EnrollmentYear)
.Select(x => new
{
x.StudentId,
GradePoint = x.GradePoint!.Value,
x.GradeSheet!.TeachingTask!.Course!.Credits
}).ToListAsync(token);
var gradePointsByStudent = gradePoints
.GroupBy(x => x.StudentId)
.ToDictionary(group => group.Key, group => group.ToList());
await db.DegreeAwardResults
.Where(x => x.DegreeAwardBatchId == id)
.ExecuteDeleteAsync(token);
foreach (var student in students)
{
var audit = auditsByStudent.GetValueOrDefault(student.Id);
var grades = gradePointsByStudent.GetValueOrDefault(student.Id) ?? [];
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(
audit?.Conclusion == GraduationAuditConclusion.Eligible,
average, batch.MinimumGradePoint);
var reason = audit is null
? "尚未生成对应的毕业资格审核结果"
: audit.Conclusion != GraduationAuditConclusion.Eligible
? $"毕业资格审核暂不符合:{audit.MissingCourseNames ?? "请核对毕业审核结果"}"
: average < batch.MinimumGradePoint
? $"平均绩点 {average:0.00},低于批次要求 {batch.MinimumGradePoint:0.00}"
: string.Empty;
db.DegreeAwardResults.Add(new DegreeAwardResult
{
DegreeAwardBatchId = batch.Id,
StudentId = student.Id,
GraduationAuditResultId = audit?.Id,
AverageGradePoint = average,
CalculatedConclusion = conclusion,
Conclusion = conclusion,
ExceptionReason = reason
});
}
batch.CalculatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(token);
return Ok(new { ResultCount = students.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();
}
[HttpDelete("batches/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Delete(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("已发布批次不能删除。");
db.DegreeAwardBatches.Remove(batch);
await db.SaveChangesAsync(token);
return NoContent();
}
[HttpGet("batches/{id:guid}/export.xlsx")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Export(
Guid id,
[FromQuery] string? keyword,
[FromQuery] DegreeAwardConclusion? conclusion,
[FromQuery] Guid? majorId,
[FromQuery] Guid? administrativeClassId,
CancellationToken token)
{
var batch = await db.DegreeAwardBatches.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, token);
if (batch is null) return NotFound();
var results = db.DegreeAwardResults.AsNoTracking().Where(x => x.DegreeAwardBatchId == id);
var collegeId = RestrictedCollegeId();
if (collegeId.HasValue) results = results.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
if (majorId.HasValue) results = results.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId);
if (administrativeClassId.HasValue) results = results.Where(x => x.Student!.AdministrativeClassId == administrativeClassId);
var normalizedKeyword = keyword?.Trim();
if (!string.IsNullOrWhiteSpace(normalizedKeyword)) results = results.Where(x => x.Student!.StudentNumber.Contains(normalizedKeyword) || x.Student.Name.Contains(normalizedKeyword));
if (conclusion.HasValue) results = results.Where(x => x.Conclusion == conclusion.Value);
var rows = await results.OrderBy(x => x.Student!.StudentNumber).Select(x => new
{
x.Student!.StudentNumber, x.Student.Name,
College = x.Student.AdministrativeClass!.Major!.College!.Name,
Major = x.Student.AdministrativeClass.Major!.Name, Class = x.Student.AdministrativeClass.Name,
x.AverageGradePoint, x.ExceptionReason, x.Conclusion, x.IsOverridden, x.ReviewComment
}).ToListAsync(token);
var bytes = ExcelWorkbookHelper.Create("学位授予审核",
["学号", "姓名", "学院", "专业", "班级", "平均绩点", "结论", "规则说明", "人工复核", "复核意见"],
rows.Select(x => (IReadOnlyList<object?>)[x.StudentNumber, x.Name, x.College, x.Major, x.Class,
x.AverageGradePoint, x.Conclusion == DegreeAwardConclusion.Granted ? "建议授予" : "暂不授予",
x.ExceptionReason, x.IsOverridden ? "是" : "否", x.ReviewComment]).ToList());
return File(bytes, ExcelWorkbookHelper.ContentType, DownloadFileName.Export("学位授予审核", ".xlsx", batch.GraduationYear.ToString(), batch.EnrollmentYear.ToString()));
}
[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,
[Range(2000, 2200)] int EnrollmentYear,
[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);