410 lines
16 KiB
C#
410 lines
16 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/graduation-audits")]
|
|
public sealed class GraduationAuditsController(
|
|
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.GraduationAuditBatches.AsNoTracking()
|
|
.OrderByDescending(x => x.GraduationYear)
|
|
.ThenByDescending(x => x.CreatedAt)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
x.Name,
|
|
x.GraduationYear,
|
|
x.EnrollmentYear,
|
|
x.Status,
|
|
x.Notes,
|
|
x.CalculatedAt,
|
|
x.PublishedAt,
|
|
x.CreatedAt,
|
|
ResultCount = x.Results.Count(result =>
|
|
!collegeId.HasValue ||
|
|
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
|
|
EligibleCount = x.Results.Count(result =>
|
|
(!collegeId.HasValue ||
|
|
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
|
|
result.Conclusion == GraduationAuditConclusion.Eligible),
|
|
IneligibleCount = x.Results.Count(result =>
|
|
(!collegeId.HasValue ||
|
|
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId) &&
|
|
result.Conclusion == GraduationAuditConclusion.Ineligible),
|
|
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] int page = 1,
|
|
[FromQuery] int pageSize = 20,
|
|
[FromQuery] string? keyword = null,
|
|
[FromQuery] GraduationAuditConclusion? conclusion = null,
|
|
CancellationToken token = default)
|
|
{
|
|
page = Math.Max(page, 1);
|
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
|
var batch = await db.GraduationAuditBatches.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.Id == id, token);
|
|
if (batch is null) return NotFound();
|
|
|
|
var collegeId = RestrictedCollegeId();
|
|
var results = db.GraduationAuditResults.AsNoTracking()
|
|
.Where(x => x.GraduationAuditBatchId == id);
|
|
if (collegeId.HasValue)
|
|
results = results.Where(x =>
|
|
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
|
|
|
var summary = await results
|
|
.GroupBy(_ => 1)
|
|
.Select(group => new
|
|
{
|
|
ResultCount = group.Count(),
|
|
EligibleCount = group.Count(x =>
|
|
x.Conclusion == GraduationAuditConclusion.Eligible),
|
|
IneligibleCount = group.Count(x =>
|
|
x.Conclusion == GraduationAuditConclusion.Ineligible),
|
|
OverrideCount = group.Count(x => x.IsOverridden)
|
|
})
|
|
.FirstOrDefaultAsync(token);
|
|
|
|
var normalizedKeyword = keyword?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
|
results = results.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)
|
|
results = results.Where(x => x.Conclusion == conclusion.Value);
|
|
|
|
var total = await results.CountAsync(token);
|
|
var items = await results
|
|
.OrderBy(x => x.Student!.StudentNumber)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.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.StudentStatusSnapshot,
|
|
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
|
|
x.RequiredCredits,
|
|
x.EarnedCredits,
|
|
x.RequiredCourseCount,
|
|
x.PassedRequiredCourseCount,
|
|
x.FailedCourseCount,
|
|
x.MissingCourseNames,
|
|
x.CalculatedConclusion,
|
|
x.Conclusion,
|
|
x.IsOverridden,
|
|
x.ReviewComment,
|
|
x.ReviewedAt
|
|
}).ToListAsync(token);
|
|
|
|
return Ok(new
|
|
{
|
|
batch.Id,
|
|
batch.Name,
|
|
batch.GraduationYear,
|
|
batch.EnrollmentYear,
|
|
batch.Status,
|
|
batch.Notes,
|
|
batch.CalculatedAt,
|
|
batch.PublishedAt,
|
|
ResultCount = summary?.ResultCount ?? 0,
|
|
EligibleCount = summary?.EligibleCount ?? 0,
|
|
IneligibleCount = summary?.IneligibleCount ?? 0,
|
|
OverrideCount = summary?.OverrideCount ?? 0,
|
|
Results = new
|
|
{
|
|
Items = items,
|
|
Total = total,
|
|
Page = page,
|
|
PageSize = pageSize
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost("batches")]
|
|
[Authorize(Roles = Managers)]
|
|
public async Task<ActionResult> CreateBatch(
|
|
GraduationAuditBatchRequest request,
|
|
CancellationToken token)
|
|
{
|
|
if (request.EnrollmentYear > request.GraduationYear)
|
|
return ValidationProblem("入学年级不能晚于毕业年份。");
|
|
var batch = new GraduationAuditBatch
|
|
{
|
|
Name = request.Name.Trim(),
|
|
GraduationYear = request.GraduationYear,
|
|
EnrollmentYear = request.EnrollmentYear,
|
|
Notes = request.Notes?.Trim()
|
|
};
|
|
db.GraduationAuditBatches.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.GraduationAuditBatches
|
|
.FirstOrDefaultAsync(x => x.Id == id, token);
|
|
if (batch is null) return NotFound();
|
|
if (batch.Status != GraduationAuditBatchStatus.Draft)
|
|
return ConflictProblem("已发布批次不能重新计算。");
|
|
|
|
var students = await db.Students.AsNoTracking()
|
|
.Include(x => x.AdministrativeClass)
|
|
.ThenInclude(x => x!.Major)
|
|
.Where(x => x.EnrollmentYear == batch.EnrollmentYear &&
|
|
(x.Status == StudentStatus.Active ||
|
|
x.Status == StudentStatus.Suspended))
|
|
.OrderBy(x => x.StudentNumber)
|
|
.ToListAsync(token);
|
|
var plans = await db.CurriculumPlans.AsNoTracking()
|
|
.Include(x => x.Modules)
|
|
.ThenInclude(x => x.Courses)
|
|
.ThenInclude(x => x.Course)
|
|
.Where(x => x.EffectiveGrade == batch.EnrollmentYear &&
|
|
x.Status == CurriculumPlanStatus.Published)
|
|
.ToListAsync(token);
|
|
var grades = await db.GradeRecords.AsNoTracking()
|
|
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published)
|
|
.Where(x => x.Student!.EnrollmentYear == batch.EnrollmentYear &&
|
|
(x.Student.Status == StudentStatus.Active ||
|
|
x.Student.Status == StudentStatus.Suspended))
|
|
.Select(x => new GradeSnapshot(
|
|
x.StudentId,
|
|
x.GradeSheet!.TeachingTask!.CourseId,
|
|
x.GradeSheet.TeachingTask.Course!.Name,
|
|
x.GradeSheet.TeachingTask.Course.Credits,
|
|
x.TotalScore,
|
|
x.ExamStatus))
|
|
.ToListAsync(token);
|
|
|
|
var plansByMajor = plans
|
|
.GroupBy(x => x.MajorId)
|
|
.ToDictionary(group => group.Key, group => group.First());
|
|
var gradesByStudent = grades
|
|
.GroupBy(x => x.StudentId)
|
|
.ToDictionary(group => group.Key, group => group.ToList());
|
|
|
|
await db.GraduationAuditResults
|
|
.Where(x => x.GraduationAuditBatchId == id)
|
|
.ExecuteDeleteAsync(token);
|
|
|
|
foreach (var student in students)
|
|
{
|
|
plansByMajor.TryGetValue(
|
|
student.AdministrativeClass!.MajorId,
|
|
out var plan);
|
|
var studentGrades = gradesByStudent.GetValueOrDefault(student.Id) ?? [];
|
|
var passedCourseIds = studentGrades
|
|
.Where(IsPassed)
|
|
.Select(x => x.CourseId)
|
|
.Distinct()
|
|
.ToHashSet();
|
|
var earnedCredits = studentGrades
|
|
.Where(IsPassed)
|
|
.GroupBy(x => x.CourseId)
|
|
.Sum(x => x.Max(item => item.Credits));
|
|
var completion = CurriculumCompletionRules.Evaluate(
|
|
plan?.Modules ?? [],
|
|
passedCourseIds);
|
|
var missingCourseNames = plan is null
|
|
? "未匹配已发布的培养方案"
|
|
: string.Join("、", completion.MissingRequirements);
|
|
if (missingCourseNames.Length > 2000)
|
|
missingCourseNames = missingCourseNames[..2000];
|
|
var failedCourseCount = studentGrades
|
|
.GroupBy(x => x.CourseId)
|
|
.Count(x => !x.Any(IsPassed));
|
|
var conclusion = GraduationAuditRules.Evaluate(
|
|
plan is not null,
|
|
student.Status,
|
|
plan?.TotalCredits ?? 0,
|
|
earnedCredits,
|
|
completion.RequirementCount,
|
|
completion.PassedRequirementCount,
|
|
failedCourseCount);
|
|
db.GraduationAuditResults.Add(new GraduationAuditResult
|
|
{
|
|
GraduationAuditBatchId = batch.Id,
|
|
StudentId = student.Id,
|
|
CurriculumPlanId = plan?.Id,
|
|
StudentStatusSnapshot = student.Status,
|
|
RequiredCredits = plan?.TotalCredits ?? 0,
|
|
EarnedCredits = earnedCredits,
|
|
RequiredCourseCount = completion.RequirementCount,
|
|
PassedRequiredCourseCount = completion.PassedRequirementCount,
|
|
FailedCourseCount = failedCourseCount,
|
|
MissingCourseNames = missingCourseNames,
|
|
CalculatedConclusion = conclusion,
|
|
Conclusion = conclusion
|
|
});
|
|
}
|
|
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> ReviewResult(
|
|
Guid id,
|
|
GraduationAuditDecisionRequest request,
|
|
CancellationToken token)
|
|
{
|
|
var result = await db.GraduationAuditResults
|
|
.Include(x => x.GraduationAuditBatch)
|
|
.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.GraduationAuditBatch!.Status != GraduationAuditBatchStatus.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.GraduationAuditBatches
|
|
.Include(x => x.Results)
|
|
.ThenInclude(x => x.Student)
|
|
.FirstOrDefaultAsync(x => x.Id == id, token);
|
|
if (batch is null) return NotFound();
|
|
if (batch.Status != GraduationAuditBatchStatus.Draft)
|
|
return ConflictProblem("该批次已经发布。");
|
|
if (batch.Results.Count == 0)
|
|
return ConflictProblem("请先计算毕业资格,再发布结果。");
|
|
|
|
batch.Status = GraduationAuditBatchStatus.Published;
|
|
batch.PublishedAt = DateTime.UtcNow;
|
|
foreach (var result in batch.Results.Where(x =>
|
|
x.Conclusion == GraduationAuditConclusion.Eligible &&
|
|
x.Student!.Status == StudentStatus.Active))
|
|
result.Student!.Status = StudentStatus.Graduated;
|
|
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.GraduationAuditResults.AsNoTracking()
|
|
.Where(x => x.Student!.UserId == userId &&
|
|
x.GraduationAuditBatch!.Status ==
|
|
GraduationAuditBatchStatus.Published)
|
|
.OrderByDescending(x => x.GraduationAuditBatch!.GraduationYear)
|
|
.ThenByDescending(x => x.GraduationAuditBatch!.PublishedAt)
|
|
.Select(x => new
|
|
{
|
|
x.Id,
|
|
BatchName = x.GraduationAuditBatch!.Name,
|
|
x.GraduationAuditBatch.GraduationYear,
|
|
x.Student!.StudentNumber,
|
|
x.Student.Name,
|
|
MajorName = x.Student.AdministrativeClass!.Major!.Name,
|
|
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
|
|
x.RequiredCredits,
|
|
x.EarnedCredits,
|
|
x.RequiredCourseCount,
|
|
x.PassedRequiredCourseCount,
|
|
x.FailedCourseCount,
|
|
x.MissingCourseNames,
|
|
x.Conclusion,
|
|
x.IsOverridden,
|
|
x.ReviewComment,
|
|
x.GraduationAuditBatch.PublishedAt
|
|
}).FirstOrDefaultAsync(token);
|
|
return Ok(result);
|
|
}
|
|
|
|
private Guid? RestrictedCollegeId()
|
|
{
|
|
var scope = currentUserDataScope.Current;
|
|
return scope.IsInRole(SystemRoles.CollegeAdmin)
|
|
? scope.CollegeId ?? Guid.Empty
|
|
: null;
|
|
}
|
|
|
|
private static bool IsPassed(GradeSnapshot grade) =>
|
|
grade.ExamStatus == GradeExamStatus.Exempt ||
|
|
grade.TotalScore is decimal score && score >= 60;
|
|
|
|
private sealed record GradeSnapshot(
|
|
Guid StudentId,
|
|
Guid CourseId,
|
|
string CourseName,
|
|
decimal Credits,
|
|
decimal? TotalScore,
|
|
GradeExamStatus ExamStatus);
|
|
|
|
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
|
|
{
|
|
Title = "无法完成毕业审核操作",
|
|
Detail = detail,
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
}
|
|
|
|
public sealed record GraduationAuditBatchRequest(
|
|
[Required, MinLength(3), MaxLength(120)] string Name,
|
|
[Range(2000, 2200)] int GraduationYear,
|
|
[Range(2000, 2200)] int EnrollmentYear,
|
|
[MaxLength(500)] string? Notes);
|
|
|
|
public sealed record GraduationAuditDecisionRequest(
|
|
GraduationAuditConclusion Conclusion,
|
|
[Required, MinLength(5), MaxLength(500)] string Comment);
|