diff --git a/src/Jiaowu.Api/Controllers/ApprovalsController.cs b/src/Jiaowu.Api/Controllers/ApprovalsController.cs index dc7897f..36c09fe 100644 --- a/src/Jiaowu.Api/Controllers/ApprovalsController.cs +++ b/src/Jiaowu.Api/Controllers/ApprovalsController.cs @@ -80,6 +80,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s { var studentId = await GetStudentIdAsync(ct); if (studentId is null) return StudentNotFound(); + if (!await IsStudentCourseAvailableForApplicationAsync( + studentId.Value, req.TeachingTaskId, ct)) + return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。"); if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct)) return ConflictProblem("该课程已有免修申请在审核中。"); var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() }; @@ -141,6 +144,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s { var sid = await GetStudentIdAsync(ct); if (sid is null) return StudentNotFound(); + if (!await IsStudentCourseAvailableForApplicationAsync( + sid.Value, req.TeachingTaskId, ct)) + return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。"); if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct)) return ConflictProblem("该课程已有缓考申请在审核中。"); var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() }; @@ -248,12 +254,75 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s } // ═══════════════ Course Substitution ═══════════════ + [HttpGet("substitutions/mine")] + [Authorize(Roles = SystemRoles.Student)] + public async Task GetMySubstitutions(CancellationToken ct) + { + var sid = await GetStudentIdAsync(ct); + if (sid is null) return StudentNotFound(); + return Ok(await db.CourseSubstitutions.AsNoTracking() + .Where(x => x.StudentId == sid) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new StudentCourseSubstitutionRecord( + x.Id, + x.Status, + x.Reason, + x.ReviewComment, + x.SubmittedAt, + x.OriginalCourse!.Code, + x.OriginalCourse.Name, + x.SubstituteCourse!.Code, + x.SubstituteCourse.Name)) + .ToListAsync(ct)); + } + [HttpPost("substitutions")] [Authorize(Roles = SystemRoles.Student)] public async Task ApplySubstitution(SubstitutionRequest req, CancellationToken ct) { var sid = await GetStudentIdAsync(ct); if (sid is null) return StudentNotFound(); + if (req.OriginalCourseId == req.SubstituteCourseId) + return ConflictProblem("被替代课程和替代课程不能相同。"); + var isAssignedCourse = await db.TeachingTasks.AsNoTracking() + .AnyAsync(task => + task.CourseId == req.OriginalCourseId && + task.Status == TeachingTaskStatus.Published && + !task.AcademicTerm!.IsArchived && + (task.AcademicTerm.IsCurrent || + db.ScheduleEntries.Any(entry => + entry.TeachingTaskId == task.Id && + entry.SchedulePlan!.Status == + SchedulePlanStatus.Published)) && + (task.Classes.Any(item => + item.AdministrativeClass!.Students.Any(student => + student.Id == sid.Value)) || + db.CourseEnrollments.Any(enrollment => + enrollment.StudentId == sid.Value && + enrollment.Status == CourseEnrollmentStatus.Enrolled && + enrollment.CourseSelectionOffering!.TeachingTaskId == + task.Id)), + ct); + var hasFailedOriginal = await db.GradeRecords.AsNoTracking() + .AnyAsync(record => + record.StudentId == sid.Value && + record.GradeSheet!.Status == GradeSheetStatus.Published && + record.GradeSheet.TeachingTask!.CourseId == + req.OriginalCourseId && + record.TotalScore < 60, + ct); + var canReplaceOriginal = isAssignedCourse || hasFailedOriginal; + if (!canReplaceOriginal) + return ConflictProblem("被替代课程必须是当前或已发布课表的修读课程,或已有未通过成绩的课程。"); + var hasPassedSubstitute = await db.GradeRecords.AsNoTracking() + .AnyAsync(x => + x.StudentId == sid.Value && + x.GradeSheet!.Status == GradeSheetStatus.Published && + x.GradeSheet.TeachingTask!.CourseId == req.SubstituteCourseId && + x.TotalScore >= 60, + ct); + if (!hasPassedSubstitute) + return ConflictProblem("替代课程必须具有已发布且及格的成绩。"); if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct)) return ConflictProblem("该课程已有替代申请在审核中。"); var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() }; @@ -319,16 +388,50 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s return NoContent(); } - // ═══════════════ Student enrolled courses ═══════════════ + // ═══════════════ Student course options ═══════════════ [HttpGet("my-courses")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMyEnrolledCourses(CancellationToken ct) { var sid = await GetStudentIdAsync(ct); if (sid is null) return StudentNotFound(); - var courses = await db.CourseEnrollments.AsNoTracking() - .Where(x => x.StudentId == sid && x.Status == CourseEnrollmentStatus.Enrolled) - .Select(x => new { x.CourseSelectionOffering!.TeachingTaskId, x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name }) + var courses = await db.TeachingTasks.AsNoTracking() + .Where(task => + task.Status == TeachingTaskStatus.Published && + !task.AcademicTerm!.IsArchived && + (task.AcademicTerm.IsCurrent || + db.ScheduleEntries.Any(entry => + entry.TeachingTaskId == task.Id && + entry.SchedulePlan!.Status == + SchedulePlanStatus.Published)) && + (task.Classes.Any(item => + item.AdministrativeClass!.Students.Any(student => + student.Id == sid.Value)) || + db.CourseEnrollments.Any(enrollment => + enrollment.StudentId == sid.Value && + enrollment.Status == CourseEnrollmentStatus.Enrolled && + enrollment.CourseSelectionOffering!.TeachingTaskId == + task.Id))) + .OrderByDescending(x => x.AcademicTerm!.IsCurrent) + .ThenByDescending(x => x.AcademicTerm!.StartDate) + .ThenBy(x => x.Course!.Code) + .ThenBy(x => x.TaskNumber) + .Select(x => new StudentApprovalCourseOption( + x.Id, + x.CourseId, + x.TaskNumber, + x.Course!.Code, + x.Course.Name, + x.Course.Credits, + x.AcademicTerm!.Name, + x.Teachers + .OrderByDescending(item => item.IsPrimary) + .ThenBy(item => item.Teacher!.Name) + .Select(item => item.Teacher!.Name), + db.ScheduleEntries.Any(entry => + entry.TeachingTaskId == x.Id && + entry.SchedulePlan!.Status == SchedulePlanStatus.Published), + x.SchedulingMode)) .ToListAsync(ct); return Ok(courses); } @@ -341,8 +444,18 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s if (sid is null) return StudentNotFound(); var grades = await db.GradeRecords.AsNoTracking() .Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published) - .Select(x => new { CourseId = x.GradeSheet!.TeachingTask!.CourseId, CourseCode = x.GradeSheet.TeachingTask.Course!.Code, CourseName = x.GradeSheet.TeachingTask.Course.Name, x.TotalScore, x.GradePoint, x.ExamStatus }) - .OrderBy(x => x.CourseCode) + .OrderByDescending(x => x.GradeSheet!.PublishedAt) + .Select(x => new StudentApprovalGradeOption( + x.GradeSheet!.TeachingTask!.CourseId, + x.GradeSheet.TeachingTask.Course!.Code, + x.GradeSheet.TeachingTask.Course.Name, + x.GradeSheet.TeachingTask.Course.Credits, + x.TotalScore, + x.GradePoint, + x.ExamStatus, + x.GradeSheet.TeachingTask.AcademicTerm!.Name, + x.GradeSheet.TeachingTask.TaskNumber, + x.GradeSheet.PublishedAt)) .ToListAsync(ct); return Ok(grades); } @@ -377,6 +490,27 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s } private async Task GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct); + private async Task IsStudentCourseAvailableForApplicationAsync( + Guid studentId, + Guid teachingTaskId, + CancellationToken ct) => + await db.TeachingTasks.AsNoTracking().AnyAsync(task => + task.Id == teachingTaskId && + task.Status == TeachingTaskStatus.Published && + !task.AcademicTerm!.IsArchived && + (task.AcademicTerm.IsCurrent || + db.ScheduleEntries.Any(entry => + entry.TeachingTaskId == task.Id && + entry.SchedulePlan!.Status == SchedulePlanStatus.Published)) && + (task.Classes.Any(item => + item.AdministrativeClass!.Students.Any(student => + student.Id == studentId)) || + db.CourseEnrollments.Any(enrollment => + enrollment.StudentId == studentId && + enrollment.Status == CourseEnrollmentStatus.Enrolled && + enrollment.CourseSelectionOffering!.TeachingTaskId == + task.Id)), + ct); private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 }); private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); } @@ -390,6 +524,38 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s } public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College); +public sealed record StudentApprovalCourseOption( + Guid TeachingTaskId, + Guid CourseId, + string TaskNumber, + string CourseCode, + string CourseName, + decimal Credits, + string AcademicTermName, + IEnumerable TeacherNames, + bool HasPublishedSchedule, + TeachingTaskSchedulingMode SchedulingMode); +public sealed record StudentApprovalGradeOption( + Guid CourseId, + string CourseCode, + string CourseName, + decimal Credits, + decimal? TotalScore, + decimal? GradePoint, + GradeExamStatus ExamStatus, + string AcademicTermName, + string TaskNumber, + DateTime? PublishedAt); +public sealed record StudentCourseSubstitutionRecord( + Guid Id, + ApprovalStatus Status, + string Reason, + string? ReviewComment, + DateTime SubmittedAt, + string OriginalCourseCode, + string OriginalCourseName, + string SubstituteCourseCode, + string SubstituteCourseName); public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason); public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason); public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason); diff --git a/tests/Jiaowu.Api.Tests/ApprovalsControllerTests.cs b/tests/Jiaowu.Api.Tests/ApprovalsControllerTests.cs new file mode 100644 index 0000000..f45c4ce --- /dev/null +++ b/tests/Jiaowu.Api.Tests/ApprovalsControllerTests.cs @@ -0,0 +1,301 @@ +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 ApprovalsControllerTests +{ + [Fact] + public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges() + { + await using var fixture = await ApprovalFixture.CreateAsync(); + fixture.CurrentTerm.IsCurrent = false; + fixture.Db.AcademicTerms.Add(new AcademicTerm + { + Code = "2026-2", + Name = "2026—2027 学年第二学期", + AcademicYear = "2026-2027", + Season = TermSeason.Spring, + StartDate = new DateOnly(2027, 2, 20), + EndDate = new DateOnly(2027, 7, 1), + IsCurrent = true + }); + fixture.Db.SchedulePlans.Add(new SchedulePlan + { + AcademicTermId = fixture.CurrentTerm.Id, + Name = "正式课表", + Version = "v1", + Status = SchedulePlanStatus.Published, + Entries = + [ + new ScheduleEntry + { + TeachingTaskId = fixture.CurrentTask.Id, + DayOfWeek = 1, + StartPeriod = 1, + PeriodCount = 2, + StartWeek = 1, + EndWeek = 16, + WeekPattern = WeekPattern.All + } + ] + }); + await fixture.Db.SaveChangesAsync(); + + var result = await fixture.Controller.GetMyEnrolledCourses( + CancellationToken.None); + + var ok = Assert.IsType(result); + var options = Assert.IsAssignableFrom< + IEnumerable>(ok.Value).ToList(); + var option = Assert.Single(options); + Assert.Equal(fixture.CurrentTask.Id, option.TeachingTaskId); + Assert.Equal(fixture.CurrentCourse.Id, option.CourseId); + Assert.True(option.HasPublishedSchedule); + } + + [Fact] + public async Task Exemption_RejectsTeachingTaskOutsideStudentsCurrentCourses() + { + await using var fixture = await ApprovalFixture.CreateAsync(); + var unrelatedTask = new TeachingTask + { + TaskNumber = "2026-1-OTHER-01", + Name = "其他教学班", + AcademicTermId = fixture.CurrentTerm.Id, + CourseId = fixture.SubstituteCourse.Id, + Capacity = 30, + Status = TeachingTaskStatus.Published + }; + fixture.Db.TeachingTasks.Add(unrelatedTask); + await fixture.Db.SaveChangesAsync(); + + var result = await fixture.Controller.ApplyExemption( + new ExemptionRequest(unrelatedTask.Id, "申请原因"), + CancellationToken.None); + + Assert.IsType(result); + Assert.Empty(fixture.Db.CourseExemptions); + } + + [Fact] + public async Task Substitution_AcceptsCurrentCourseAndPublishedPassedCourse() + { + await using var fixture = await ApprovalFixture.CreateAsync(); + var historicalTerm = new AcademicTerm + { + Code = "2025-2", + Name = "2025—2026 学年第二学期", + AcademicYear = "2025-2026", + Season = TermSeason.Spring, + StartDate = new DateOnly(2026, 2, 20), + EndDate = new DateOnly(2026, 7, 1) + }; + var historicalTask = new TeachingTask + { + TaskNumber = "2025-2-PASS-01", + Name = "已通过课程教学班", + AcademicTermId = historicalTerm.Id, + CourseId = fixture.SubstituteCourse.Id, + Capacity = 30, + Status = TeachingTaskStatus.Closed + }; + var sheet = new GradeSheet + { + TeachingTaskId = historicalTask.Id, + Status = GradeSheetStatus.Published, + PublishedAt = DateTime.UtcNow.AddDays(-30), + Records = + [ + new GradeRecord + { + StudentId = fixture.Student.Id, + TotalScore = 86, + GradePoint = 3.6m + } + ] + }; + fixture.Db.AddRange(historicalTerm, historicalTask, sheet); + await fixture.Db.SaveChangesAsync(); + + var result = await fixture.Controller.ApplySubstitution( + new SubstitutionRequest( + fixture.CurrentCourse.Id, + fixture.SubstituteCourse.Id, + "课程内容相近"), + CancellationToken.None); + + Assert.IsType(result); + var substitution = Assert.Single(fixture.Db.CourseSubstitutions); + Assert.Equal(fixture.CurrentCourse.Id, substitution.OriginalCourseId); + Assert.Equal( + fixture.SubstituteCourse.Id, + substitution.SubstituteCourseId); + } + + private sealed class ApprovalFixture : IAsyncDisposable + { + private ApprovalFixture( + SqliteConnection connection, + AppDbContext db, + Student student, + AcademicTerm currentTerm, + Course currentCourse, + Course substituteCourse, + TeachingTask currentTask, + ApprovalsController controller) + { + Connection = connection; + Db = db; + Student = student; + CurrentTerm = currentTerm; + CurrentCourse = currentCourse; + SubstituteCourse = substituteCourse; + CurrentTask = currentTask; + Controller = controller; + } + + private SqliteConnection Connection { get; } + public AppDbContext Db { get; } + public Student Student { get; } + public AcademicTerm CurrentTerm { get; } + public Course CurrentCourse { get; } + public Course SubstituteCourse { get; } + public TeachingTask CurrentTask { get; } + public ApprovalsController Controller { get; } + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var college = new College { Code = "CS", Name = "计算机学院" }; + var major = new Major + { + Code = "080901", + Name = "计算机科学与技术", + CollegeId = college.Id, + DegreeType = "工学学士" + }; + var administrativeClass = new AdministrativeClass + { + Code = "CS2026-01", + Name = "计科 2026-1 班", + MajorId = major.Id, + Grade = 2026 + }; + var user = new ApplicationUser + { + Id = Guid.NewGuid(), + UserName = "202601001", + NormalizedUserName = "202601001", + DisplayName = "测试学生" + }; + var student = new Student + { + StudentNumber = "202601001", + Name = "测试学生", + UserId = user.Id, + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1) + }; + var currentTerm = new AcademicTerm + { + Code = "2026-1", + Name = "2026—2027 学年第一学期", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 20), + IsCurrent = true + }; + var currentCourse = CreateCourse(college.Id, "CS101", "程序设计基础"); + var substituteCourse = CreateCourse(college.Id, "CS102", "程序设计进阶"); + var currentTask = new TeachingTask + { + TaskNumber = "2026-1-CS101-01", + Name = "程序设计基础教学班", + AcademicTermId = currentTerm.Id, + CourseId = currentCourse.Id, + Capacity = 60, + Status = TeachingTaskStatus.Published, + Classes = + [ + new TeachingTaskClass + { + AdministrativeClassId = administrativeClass.Id + } + ] + }; + db.AddRange( + college, + major, + administrativeClass, + user, + student, + currentTerm, + currentCourse, + substituteCourse, + currentTask); + await db.SaveChangesAsync(); + + var controller = new ApprovalsController( + db, + new StudentDataScope(user.Id)); + return new ApprovalFixture( + connection, + db, + student, + currentTerm, + currentCourse, + substituteCourse, + currentTask, + controller); + } + + public async ValueTask DisposeAsync() + { + await Db.DisposeAsync(); + await Connection.DisposeAsync(); + } + + private static Course CreateCourse( + Guid collegeId, + string code, + string name) => + new() + { + Code = code, + Name = name, + CollegeId = collegeId, + Credits = 3, + TotalHours = 48, + LectureHours = 32, + PracticeHours = 16, + Nature = CourseNature.MajorRequired, + AssessmentMethod = AssessmentMethod.Examination + }; + } + + private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + userId, + "测试学生", + null, + DataScope.Self, + new HashSet([SystemRoles.Student])); + } +} diff --git a/web/src/views/ApprovalCenterView.vue b/web/src/views/ApprovalCenterView.vue index 47efbd2..3782400 100644 --- a/web/src/views/ApprovalCenterView.vue +++ b/web/src/views/ApprovalCenterView.vue @@ -1,9 +1,53 @@