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 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 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 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 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 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 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 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);