diff --git a/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs b/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs index 9b4bfd3..6184a8d 100644 --- a/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs +++ b/src/Jiaowu.Api/Controllers/DegreeAwardsController.cs @@ -60,8 +60,16 @@ public sealed class DegreeAwardsController( [HttpGet("batches/{id:guid}")] [Authorize(Roles = Reviewers)] - public async Task GetBatch(Guid id, CancellationToken token) + public async Task GetBatch( + Guid id, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? keyword = null, + [FromQuery] DegreeAwardConclusion? conclusion = null, + CancellationToken token = default) { + page = Math.Max(page, 1); + pageSize = Math.Clamp(pageSize, 10, 100); var batch = await db.DegreeAwardBatches.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == id, token); if (batch is null) return NotFound(); @@ -71,6 +79,52 @@ public sealed class DegreeAwardsController( if (collegeId.HasValue) source = source.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId); + + 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); + var items = await source.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.AverageGradePoint, + x.CalculatedConclusion, + x.Conclusion, + x.ExceptionReason, + x.IsOverridden, + x.ReviewComment, + x.ReviewedAt + }).ToListAsync(token); + return Ok(new { batch.Id, @@ -82,24 +136,17 @@ public sealed class DegreeAwardsController( batch.Notes, batch.CalculatedAt, batch.PublishedAt, - Results = await source.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.AverageGradePoint, - x.CalculatedConclusion, - x.Conclusion, - x.ExceptionReason, - x.IsOverridden, - x.ReviewComment, - x.ReviewedAt - }).ToListAsync(token) + ResultCount = summary?.ResultCount ?? 0, + GrantedCount = summary?.GrantedCount ?? 0, + NotGrantedCount = summary?.NotGrantedCount ?? 0, + OverrideCount = summary?.OverrideCount ?? 0, + Results = new + { + Items = items, + Total = total, + Page = page, + PageSize = pageSize + } }); } @@ -133,25 +180,30 @@ public sealed class DegreeAwardsController( return ConflictProblem("已发布批次不能重新计算。"); var auditCandidates = await db.GraduationAuditResults.AsNoTracking() - .Include(x => x.GraduationAuditBatch) - .Include(x => x.Student) - .ThenInclude(x => x!.AdministrativeClass) - .ThenInclude(x => x!.Major) .Where(x => x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear && x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published && x.Conclusion == GraduationAuditConclusion.Eligible) .OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt) + .Select(x => new + { + x.Id, + x.StudentId, + StudentStatus = x.Student!.Status + }) .ToListAsync(token); var audits = auditCandidates .GroupBy(x => x.StudentId) .Select(x => x.First()) .ToList(); - var studentIds = audits.Select(x => x.StudentId).ToArray(); var gradePoints = await db.GradeRecords.AsNoTracking() .Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published && x.GradePoint.HasValue) - .WhereIn(studentIds, x => x.StudentId) + .Where(x => db.GraduationAuditResults.Any(audit => + audit.StudentId == x.StudentId && + audit.GraduationAuditBatch!.GraduationYear == batch.GraduationYear && + audit.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published && + audit.Conclusion == GraduationAuditConclusion.Eligible)) .Select(x => new { x.StudentId, @@ -159,14 +211,17 @@ public sealed class DegreeAwardsController( x.GradeSheet!.TeachingTask!.Course!.Credits }).ToListAsync(token); - var oldResults = await db.DegreeAwardResults - .Where(x => x.DegreeAwardBatchId == id).ToListAsync(token); - db.DegreeAwardResults.RemoveRange(oldResults); - await db.SaveChangesAsync(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 audit in audits) { - var grades = gradePoints.Where(x => x.StudentId == audit.StudentId).ToList(); + var grades = gradePointsByStudent.GetValueOrDefault(audit.StudentId) ?? []; var credits = grades.Sum(x => x.Credits); var average = credits == 0 ? 0 @@ -175,8 +230,8 @@ public sealed class DegreeAwardsController( 2, MidpointRounding.AwayFromZero); var conclusion = DegreeAwardRules.Evaluate( - true, audit.Student!.Status, average, batch.MinimumGradePoint); - var reason = audit.Student.Status != StudentStatus.Graduated + true, audit.StudentStatus, average, batch.MinimumGradePoint); + var reason = audit.StudentStatus != StudentStatus.Graduated ? "学籍状态尚未转为毕业" : average < batch.MinimumGradePoint ? $"平均绩点 {average:0.00},低于批次要求 {batch.MinimumGradePoint:0.00}" diff --git a/src/Jiaowu.Api/Controllers/GraduationAuditsController.cs b/src/Jiaowu.Api/Controllers/GraduationAuditsController.cs index cc98122..5275a5c 100644 --- a/src/Jiaowu.Api/Controllers/GraduationAuditsController.cs +++ b/src/Jiaowu.Api/Controllers/GraduationAuditsController.cs @@ -61,8 +61,16 @@ public sealed class GraduationAuditsController( [HttpGet("batches/{id:guid}")] [Authorize(Roles = Reviewers)] - public async Task GetBatch(Guid id, CancellationToken token) + public async Task 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(); @@ -73,6 +81,59 @@ public sealed class GraduationAuditsController( 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, @@ -83,31 +144,17 @@ public sealed class GraduationAuditsController( 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) + 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 + } }); } @@ -156,10 +203,11 @@ public sealed class GraduationAuditsController( .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 => x.GradeSheet!.Status == GradeSheetStatus.Published) - .WhereIn(studentIds, x => x.StudentId) + .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, @@ -169,16 +217,23 @@ public sealed class GraduationAuditsController( x.ExamStatus)) .ToListAsync(token); - var oldResults = await db.GraduationAuditResults - .Where(x => x.GraduationAuditBatchId == id).ToListAsync(token); - db.GraduationAuditResults.RemoveRange(oldResults); - await db.SaveChangesAsync(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) { - var plan = plans.FirstOrDefault(x => - x.MajorId == student.AdministrativeClass!.MajorId); - var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList(); + plansByMajor.TryGetValue( + student.AdministrativeClass!.MajorId, + out var plan); + var studentGrades = gradesByStudent.GetValueOrDefault(student.Id) ?? []; var passedCourseIds = studentGrades .Where(IsPassed) .Select(x => x.CourseId) diff --git a/src/Jiaowu.Api/Controllers/GraduationClearanceController.cs b/src/Jiaowu.Api/Controllers/GraduationClearanceController.cs index 258df36..ec87dda 100644 --- a/src/Jiaowu.Api/Controllers/GraduationClearanceController.cs +++ b/src/Jiaowu.Api/Controllers/GraduationClearanceController.cs @@ -34,31 +34,46 @@ public sealed class GraduationClearanceController( x.Id, x.Name, x.GraduationYear, x.Status, x.Notes, x.ClosedAt, x.CreatedAt, ItemCount = x.Items.Count }).ToListAsync(token); - var records = await ScopedRecords().AsNoTracking() - .Select(x => new + var statistics = await ScopedRecords().AsNoTracking() + .GroupBy(x => x.GraduationClearanceItem!.GraduationClearanceBatchId) + .Select(group => new { - BatchId = x.GraduationClearanceItem!.GraduationClearanceBatchId, - x.StudentId, x.Status - }).ToListAsync(token); + BatchId = group.Key, + StudentCount = group.Select(x => x.StudentId).Distinct().Count(), + RecordCount = group.Count(), + CompletedCount = group.Count(x => + x.Status != GraduationClearanceRecordStatus.Pending) + }).ToDictionaryAsync(x => x.BatchId, token); return Ok(batches.Select(batch => { - var scoped = records.Where(x => x.BatchId == batch.Id).ToList(); + statistics.TryGetValue(batch.Id, out var summary); return new { batch.Id, batch.Name, batch.GraduationYear, batch.Status, batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.ItemCount, - StudentCount = scoped.Select(x => x.StudentId).Distinct().Count(), - RecordCount = scoped.Count, - CompletedCount = scoped.Count(x => - x.Status != GraduationClearanceRecordStatus.Pending) + StudentCount = summary?.StudentCount ?? 0, + RecordCount = summary?.RecordCount ?? 0, + CompletedCount = summary?.CompletedCount ?? 0 }; })); } [HttpGet("batches/{id:guid}")] [Authorize(Roles = Workers)] - public async Task GetBatch(Guid id, CancellationToken token) + public async Task GetBatch( + Guid id, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? keyword = null, + [FromQuery] string? progress = null, + CancellationToken token = default) { + page = Math.Max(page, 1); + pageSize = Math.Clamp(pageSize, 10, 100); + var normalizedProgress = progress?.Trim(); + if (normalizedProgress is not null and not "Pending" and not "Completed") + return ValidationProblem("离校办理进度筛选值无效。"); + var batch = await db.GraduationClearanceBatches.AsNoTracking() .Where(x => x.Id == id) .Select(x => new @@ -72,8 +87,56 @@ public sealed class GraduationClearanceController( }) }).FirstOrDefaultAsync(token); if (batch is null) return NotFound(); - var records = await ScopedRecords().AsNoTracking() - .Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id) + + var records = ScopedRecords().AsNoTracking() + .Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id); + var summary = await records + .GroupBy(_ => 1) + .Select(group => new + { + StudentCount = group.Select(x => x.StudentId).Distinct().Count(), + RecordCount = group.Count(), + CompletedCount = group.Count(x => + x.Status != GraduationClearanceRecordStatus.Pending) + }) + .FirstOrDefaultAsync(token); + + var students = db.Students.AsNoTracking() + .Where(student => records.Any(record => record.StudentId == student.Id)); + var normalizedKeyword = keyword?.Trim(); + if (!string.IsNullOrWhiteSpace(normalizedKeyword)) + students = students.Where(student => + student.StudentNumber.Contains(normalizedKeyword) || + student.Name.Contains(normalizedKeyword) || + student.AdministrativeClass!.Name.Contains(normalizedKeyword) || + student.AdministrativeClass.Major!.Name.Contains(normalizedKeyword)); + if (normalizedProgress == "Pending") + students = students.Where(student => records.Any(record => + record.StudentId == student.Id && + record.GraduationClearanceItem!.IsRequired && + record.Status == GraduationClearanceRecordStatus.Pending)); + else if (normalizedProgress == "Completed") + students = students.Where(student => !records.Any(record => + record.StudentId == student.Id && + record.GraduationClearanceItem!.IsRequired && + record.Status == GraduationClearanceRecordStatus.Pending)); + + var total = await students.CountAsync(token); + var studentPage = await students + .OrderBy(student => student.StudentNumber) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(student => new + { + student.Id, + student.StudentNumber, + student.Name, + ClassName = student.AdministrativeClass!.Name, + MajorName = student.AdministrativeClass.Major!.Name + }).ToListAsync(token); + var studentIds = studentPage.Select(student => student.Id).ToArray(); + var pageRecords = await records + .WhereIn(studentIds, x => x.StudentId) .OrderBy(x => x.Student!.StudentNumber) .ThenBy(x => x.GraduationClearanceItem!.SortOrder) .Select(x => new @@ -89,11 +152,32 @@ public sealed class GraduationClearanceController( x.GraduationClearanceItem.IsRequired, x.Status, x.Notes, x.CompletedAt }).ToListAsync(token); + var recordsByStudent = pageRecords + .GroupBy(record => record.StudentId) + .ToDictionary(group => group.Key, group => group.ToList()); + var studentItems = studentPage.Select(student => new + { + StudentId = student.Id, + student.StudentNumber, + student.Name, + student.ClassName, + student.MajorName, + Records = recordsByStudent.GetValueOrDefault(student.Id) ?? [] + }).ToList(); return Ok(new { batch.Id, batch.Name, batch.GraduationYear, batch.Status, batch.Notes, batch.ClosedAt, batch.CreatedAt, batch.Items, - Records = records + StudentCount = summary?.StudentCount ?? 0, + RecordCount = summary?.RecordCount ?? 0, + CompletedCount = summary?.CompletedCount ?? 0, + Students = new + { + Items = studentItems, + Total = total, + Page = page, + PageSize = pageSize + } }); } @@ -150,16 +234,15 @@ public sealed class GraduationClearanceController( if (batch.Status != GraduationClearanceBatchStatus.Open) return ConflictProblem("已关闭批次不能重新生成办理记录。"); - var eligibleAudits = await db.GraduationAuditResults.AsNoTracking() + var studentIds = await db.GraduationAuditResults.AsNoTracking() .Where(x => x.GraduationAuditBatch!.GraduationYear == batch.GraduationYear && x.GraduationAuditBatch.Status == GraduationAuditBatchStatus.Published && x.Conclusion == GraduationAuditConclusion.Eligible && x.Student!.Status == StudentStatus.Graduated) - .OrderByDescending(x => x.GraduationAuditBatch!.PublishedAt) - .Select(x => new { x.StudentId }) + .Select(x => x.StudentId) + .Distinct() .ToListAsync(token); - var studentIds = eligibleAudits.Select(x => x.StudentId).Distinct().ToArray(); var existing = await db.GraduationClearanceRecords.AsNoTracking() .Where(x => x.GraduationClearanceItem!.GraduationClearanceBatchId == id) .Select(x => new { x.GraduationClearanceItemId, x.StudentId }) @@ -180,7 +263,7 @@ public sealed class GraduationClearanceController( created++; } await db.SaveChangesAsync(token); - return Ok(new { StudentCount = studentIds.Length, CreatedRecordCount = created }); + return Ok(new { StudentCount = studentIds.Count, CreatedRecordCount = created }); } [HttpPut("records/{id:guid}")] @@ -221,15 +304,17 @@ public sealed class GraduationClearanceController( public async Task Close(Guid id, CancellationToken token) { var batch = await db.GraduationClearanceBatches - .Include(x => x.Items) - .ThenInclude(x => x.Records) .FirstOrDefaultAsync(x => x.Id == id, token); if (batch is null) return NotFound(); if (batch.Status != GraduationClearanceBatchStatus.Open) return ConflictProblem("该批次已经关闭。"); - var records = batch.Items.SelectMany(item => item.Records.Select(record => - (item.IsRequired, record.Status))); - if (!GraduationClearanceRules.CanClose(records)) + var hasIncompleteRequiredItem = await db.GraduationClearanceRecords + .AnyAsync(record => + record.GraduationClearanceItem!.GraduationClearanceBatchId == id && + record.GraduationClearanceItem.IsRequired && + record.Status == GraduationClearanceRecordStatus.Pending, + token); + if (hasIncompleteRequiredItem) return ConflictProblem("仍有必办离校事项尚未完成。"); batch.Status = GraduationClearanceBatchStatus.Closed; batch.ClosedAt = DateTime.UtcNow; diff --git a/tests/Jiaowu.Api.Tests/GraduationAuditsControllerTests.cs b/tests/Jiaowu.Api.Tests/GraduationAuditsControllerTests.cs new file mode 100644 index 0000000..f556d5f --- /dev/null +++ b/tests/Jiaowu.Api.Tests/GraduationAuditsControllerTests.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class GraduationAuditsControllerTests +{ + [Fact] + public async Task GetBatch_ReturnsPagedResultsAndWholeBatchSummary() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var college = new College { Code = "CS", Name = "计算机学院" }; + var major = new Major + { + Code = "SE", + Name = "软件工程", + CollegeId = college.Id, + DegreeType = "工学" + }; + var administrativeClass = new AdministrativeClass + { + Code = "SE2601", + Name = "软件工程2601班", + MajorId = major.Id, + Grade = 2026 + }; + var batch = new GraduationAuditBatch + { + Name = "2030届毕业资格审核", + GraduationYear = 2030, + EnrollmentYear = 2026 + }; + db.AddRange(college, major, administrativeClass, batch); + + for (var index = 1; index <= 25; index++) + { + var student = new Student + { + StudentNumber = $"2026{index:0000}", + Name = index == 17 ? "分页检索学生" : $"学生{index:00}", + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1) + }; + db.Students.Add(student); + db.GraduationAuditResults.Add(new GraduationAuditResult + { + GraduationAuditBatchId = batch.Id, + StudentId = student.Id, + StudentStatusSnapshot = StudentStatus.Active, + RequiredCredits = 160, + EarnedCredits = 150, + RequiredCourseCount = 40, + PassedRequiredCourseCount = 38, + FailedCourseCount = 1, + MissingCourseNames = "毕业设计", + CalculatedConclusion = index % 2 == 0 + ? GraduationAuditConclusion.Eligible + : GraduationAuditConclusion.Ineligible, + Conclusion = index % 2 == 0 + ? GraduationAuditConclusion.Eligible + : GraduationAuditConclusion.Ineligible, + IsOverridden = index == 1 + }); + } + await db.SaveChangesAsync(); + + var controller = new GraduationAuditsController(db, new AllDataScope()); + var pageResult = Assert.IsType(await controller.GetBatch( + batch.Id, + page: 2, + pageSize: 10)); + using var pageJson = ToJson(pageResult.Value); + + Assert.Equal(25, pageJson.RootElement.GetProperty("resultCount").GetInt32()); + Assert.Equal(12, pageJson.RootElement.GetProperty("eligibleCount").GetInt32()); + Assert.Equal(13, pageJson.RootElement.GetProperty("ineligibleCount").GetInt32()); + Assert.Equal(1, pageJson.RootElement.GetProperty("overrideCount").GetInt32()); + var pagedResults = pageJson.RootElement.GetProperty("results"); + Assert.Equal(25, pagedResults.GetProperty("total").GetInt32()); + Assert.Equal(2, pagedResults.GetProperty("page").GetInt32()); + Assert.Equal(10, pagedResults.GetProperty("items").GetArrayLength()); + + var filteredResult = Assert.IsType(await controller.GetBatch( + batch.Id, + keyword: "分页检索", + conclusion: GraduationAuditConclusion.Ineligible)); + using var filteredJson = ToJson(filteredResult.Value); + var filteredResults = filteredJson.RootElement.GetProperty("results"); + Assert.Equal(1, filteredResults.GetProperty("total").GetInt32()); + Assert.Equal( + "分页检索学生", + filteredResults.GetProperty("items")[0].GetProperty("name").GetString()); + } + + private static JsonDocument ToJson(object? value) => JsonDocument.Parse( + JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web))); + + private sealed class AllDataScope : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), + "校级教务", + null, + DataScope.All, + new HashSet([SystemRoles.AcademicAdmin])); + } +} diff --git a/web/src/views/DegreeAwardsView.vue b/web/src/views/DegreeAwardsView.vue index de9e220..c2c089e 100644 --- a/web/src/views/DegreeAwardsView.vue +++ b/web/src/views/DegreeAwardsView.vue @@ -1,5 +1,5 @@ @@ -160,25 +192,33 @@ onMounted(load) 授予结果已锁定
-
参审人数{{ selected.results.length }}
-
建议授予{{ selected.results.filter((x: any) => x.conclusion === 'Granted').length }}
-
暂不授予{{ selected.results.filter((x: any) => x.conclusion === 'NotGranted').length }}
-
人工调整{{ selected.results.filter((x: any) => x.isOverridden).length }}
+
参审人数{{ selected.resultCount }}
+
建议授予{{ selected.grantedCount }}
+
暂不授予{{ selected.notGrantedCount }}
+
人工调整{{ selected.overrideCount }}
- 显示 {{ filteredResults.length }} 条 + 共 {{ resultTotal }} 条
-
+
{{ row.studentNumber }} · {{ row.className }}

{{ row.name }}

{{ row.majorName }}

加权平均绩点{{ row.averageGradePoint }}批次要求 ≥ {{ selected.minimumGradePoint }}
规则说明{{ row.exceptionReason || '毕业资格与绩点均符合' }}
{{ conclusionLabels[row.conclusion] }}{{ row.isOverridden ? '人工复核' : '规则计算' }}
人工复核
- + +
diff --git a/web/src/views/GraduationAuditsView.vue b/web/src/views/GraduationAuditsView.vue index 753c9bb..af689d1 100644 --- a/web/src/views/GraduationAuditsView.vue +++ b/web/src/views/GraduationAuditsView.vue @@ -1,5 +1,5 @@ @@ -187,10 +219,10 @@ onMounted(load)
-
参审学生{{ selected.results.length }}
-
符合条件{{ selected.results.filter((x: any) => x.conclusion === 'Eligible').length }}
-
暂不符合{{ selected.results.filter((x: any) => x.conclusion === 'Ineligible').length }}
-
人工调整{{ selected.results.filter((x: any) => x.isOverridden).length }}
+
参审学生{{ selected.resultCount }}
+
符合条件{{ selected.eligibleCount }}
+
暂不符合{{ selected.ineligibleCount }}
+
人工调整{{ selected.overrideCount }}
@@ -199,18 +231,26 @@ onMounted(load) - 显示 {{ filteredResults.length }} 条 + 共 {{ resultTotal }} 条
-
+
{{ row.studentNumber }} · {{ row.className }}

{{ row.name }}

{{ row.majorName }} · {{ row.planName || '未匹配培养方案' }}

学分完成度{{ row.earnedCredits }} / {{ row.requiredCredits }}
必修要求{{ row.passedRequiredCourseCount }} / {{ row.requiredCourseCount }}缺:{{ row.missingCourseNames }}指定必修与课程组均已完成
{{ conclusionLabels[row.conclusion] }}人工复核规则计算
人工复核
- + +
diff --git a/web/src/views/GraduationClearanceView.vue b/web/src/views/GraduationClearanceView.vue index 510bc4c..0f19951 100644 --- a/web/src/views/GraduationClearanceView.vue +++ b/web/src/views/GraduationClearanceView.vue @@ -1,5 +1,5 @@ @@ -195,15 +218,23 @@ onMounted(load)
-
CLEARANCE LEDGER

{{ selected.name }}

{{ selected.items.length }} 项离校手续 · {{ studentLedgers.length }} 名毕业生

+
CLEARANCE LEDGER

{{ selected.name }}

{{ selected.items.length }} 项离校手续 · {{ selected.studentCount }} 名毕业生

补齐名单关闭批次
批次已锁定
{{ item.code }}{{ item.name }}{{ item.responsibleUnit }} · {{ roleLabels[item.responsibleRole] }}
+
+ + + + + + 共 {{ studentTotal }} 名学生 +
-
+
{{ student.studentNumber }} · {{ student.className }}

{{ student.name }}

{{ student.majorName }}

{{ completedCount(student.records) }}/{{ student.records.length }}
@@ -218,7 +249,15 @@ onMounted(load)
- + +