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