Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/GraduationAuditsController.cs
T
biss d7cf3f9e76 课程库 Excel 导入导出已完成:
支持下载标准导入模板。
导出严格沿用当前关键词、学院、分类、课程性质和状态筛选。
以课程编码为唯一键:存在则更新,不存在则新增。
校验学院编码、课程分类、课程性质、学分学时及考核方式。
整批原子导入,任一行错误则全部不写入,并提示具体行号。
学院管理员仍只能维护本学院的专业课和实践课,无法借 Excel 越权。
界面入口沿用现有课程库工具栏样式。
2026-07-24 18:56:25 +08:00

327 lines
14 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, CancellationToken token)
{
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);
return Ok(new
{
batch.Id, batch.Name, batch.GraduationYear, batch.EnrollmentYear,
batch.Status, batch.Notes, batch.CalculatedAt, batch.PublishedAt,
Results = await results
.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.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)
});
}
[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 studentIds = students.Select(x => x.Id).ToArray();
var grades = await db.GradeRecords.AsNoTracking()
.Where(x => studentIds.Contains(x.StudentId) &&
x.GradeSheet!.Status == GradeSheetStatus.Published)
.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 oldResults = await db.GraduationAuditResults
.Where(x => x.GraduationAuditBatchId == id).ToListAsync(token);
db.GraduationAuditResults.RemoveRange(oldResults);
await db.SaveChangesAsync(token);
foreach (var student in students)
{
var plan = plans.FirstOrDefault(x =>
x.MajorId == student.AdministrativeClass!.MajorId);
var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList();
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);