using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/approvals")] public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope scope) : ControllerBase { private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; private const string Teachers = Managers + "," + SystemRoles.Teacher; private const string Students = Managers + "," + SystemRoles.Student; // ═══════════════ Aggregated pending ═══════════════ [HttpGet("pending")] [Authorize(Roles = Managers)] public async Task GetPending(CancellationToken ct) { var items = new List(); items.AddRange(await Scoped(x => x.Status == ApprovalStatus.Submitted) .Select(x => new ApprovalItem(x.Id, "CourseExemption", "免修", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name)) .ToListAsync(ct)); items.AddRange(await Scoped(x => x.Status == ApprovalStatus.Submitted) .Select(x => new ApprovalItem(x.Id, "DeferredExam", "缓考", $"{x.Student!.Name} — 《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt, x.TeachingTask.Course!.College!.Name)) .ToListAsync(ct)); items.AddRange(await Scoped(x => x.Status == GradeModificationStatus.TeacherSubmitted || x.Status == GradeModificationStatus.CollegeApproved) .Select(x => new ApprovalItem(x.Id, "GradeModification", "成绩修改", $"{x.GradeRecord!.Student!.Name} — 《{x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", $"{x.CurrentScore} → {x.RequestedScore}:{x.Reason}", x.SubmittedAt, x.GradeRecord.GradeSheet.TeachingTask.Course!.College!.Name)) .ToListAsync(ct)); items.AddRange(await Scoped(x => x.Status == ApprovalStatus.Submitted) .Select(x => new ApprovalItem(x.Id, "CourseSubstitution", "课程替代", $"{x.Student!.Name}:{x.SubstituteCourse!.Name} → {x.OriginalCourse!.Name}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name)) .ToListAsync(ct)); items.AddRange(await Scoped(x => x.State != StudentStatusChangeState.Approved && x.State != StudentStatusChangeState.Rejected && x.State != StudentStatusChangeState.Cancelled) .Select(x => new ApprovalItem(x.Id, "StudentStatusChange", "学籍异动", $"{x.Student!.Name} — {SSCLabel(x.Type)}", x.Reason, x.SubmittedAt, x.Student.AdministrativeClass!.Major!.College!.Name)) .ToListAsync(ct)); items.AddRange(await Scoped(x => x.Status == CourseAdjustmentStatus.Submitted) .Select(x => new ApprovalItem(x.Id, "CourseAdjustment", CALabel(x.Type), $"《{x.TeachingTask!.Course!.Name}》", x.Reason, x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name)) .ToListAsync(ct)); items.AddRange(await Scoped(x => x.Status == GradeSheetStatus.Submitted) .Select(x => new ApprovalItem(x.Id, "GradeSheet", "成绩审核", $"《{x.TeachingTask!.Course!.Name}》— {x.Records.Count}人", "教师已提交成绩", x.SubmittedAt!.Value, x.TeachingTask.Course.College!.Name)) .ToListAsync(ct)); items.AddRange(await Scoped(x => x.AppealStatus == AttendanceAppealStatus.Pending) .Select(x => new ApprovalItem(x.StudentId, "AttendanceAppeal", "考勤申诉", $"{x.Student!.Name} — 《{x.AttendanceSheet!.TeachingTask!.Course!.Name}》", x.AppealReason ?? "", x.AppealSubmittedAt!.Value, x.Student.AdministrativeClass!.Major!.College!.Name)) .ToListAsync(ct)); return Ok(items.OrderByDescending(x => x.Time).ToList()); } // ═══════════════ Course Exemption ═══════════════ [HttpGet("exemptions/mine")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMyExemptions(CancellationToken ct) { var studentId = await GetStudentIdAsync(ct); if (studentId is null) return StudentNotFound(); return Ok(await db.CourseExemptions.AsNoTracking() .Where(x => x.StudentId == studentId).OrderByDescending(x => x.CreatedAt) .Select(x => new { x.Id, x.Status, x.Reason, x.ReviewComment, x.SubmittedAt, CourseName = x.TeachingTask!.Course!.Name }) .ToListAsync(ct)); } [HttpPost("exemptions")] [Authorize(Roles = SystemRoles.Student)] public async Task ApplyExemption(ExemptionRequest req, CancellationToken ct) { 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() }; db.CourseExemptions.Add(ex); await db.SaveChangesAsync(ct); // Reload to get nav properties for notification var info = await db.CourseExemptions.Where(x => x.Id == ex.Id) .Select(x => new { StudentName = x.Student!.Name, CourseName = x.TeachingTask!.Course!.Name }) .FirstAsync(ct); await NotifyManagers("免修申请", $"{info.StudentName} 申请免修《{info.CourseName}》", ct); return Created("", new { ex.Id }); } [HttpPost("exemptions/{id:guid}/approve")] [Authorize(Roles = Managers)] public async Task ApproveExemption(Guid id, CancellationToken ct) { var ex = await db.CourseExemptions.Include(x => x.TeachingTask).ThenInclude(x => x!.Course).FirstOrDefaultAsync(x => x.Id == id, ct); if (ex is null) return NotFound(); ex.Status = ApprovalStatus.Approved; ex.ReviewedAt = DateTime.UtcNow; ex.ReviewedByUserId = scope.Current.UserId; // Auto-apply: mark grade record as exempt var sheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == ex.TeachingTaskId, ct); if (sheet is not null) { var record = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == sheet.Id && x.StudentId == ex.StudentId, ct); if (record is not null) { record.ExamStatus = GradeExamStatus.Exempt; record.RegularScore = null; record.FinalScore = null; record.TotalScore = null; record.GradePoint = null; } } await db.SaveChangesAsync(ct); await NotifyStudent(ex.StudentId, "免修申请已通过", $"《{ex.TeachingTask!.Course!.Name}》免修已批准。", ct); return NoContent(); } [HttpPost("exemptions/{id:guid}/reject")] [Authorize(Roles = Managers)] public async Task RejectExemption(Guid id, [FromBody] ReviewBody body, CancellationToken ct) { var ex = await db.CourseExemptions.FindAsync([id], ct); if (ex is null) return NotFound(); ex.Status = ApprovalStatus.Rejected; ex.ReviewedAt = DateTime.UtcNow; ex.ReviewComment = body.Comment?.Trim(); ex.ReviewedByUserId = scope.Current.UserId; await db.SaveChangesAsync(ct); await NotifyStudent(ex.StudentId, "免修申请已驳回", ex.ReviewComment ?? "审核未通过。", ct); return NoContent(); } // ═══════════════ Deferred Exam ═══════════════ [HttpGet("deferred/mine")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMyDeferred(CancellationToken ct) { var sid = await GetStudentIdAsync(ct); if (sid is null) return StudentNotFound(); return Ok(await db.DeferredExams.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt) .Select(x => new { x.Id, x.Status, x.Reason, x.ReviewComment, x.SubmittedAt, CourseName = x.TeachingTask!.Course!.Name }).ToListAsync(ct)); } [HttpPost("deferred")] [Authorize(Roles = SystemRoles.Student)] public async Task ApplyDeferred(ExemptionRequest req, CancellationToken ct) { 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() }; db.DeferredExams.Add(d); await db.SaveChangesAsync(ct); var info = await db.DeferredExams.Where(x => x.Id == d.Id) .Select(x => new { StudentName = x.Student!.Name, CourseName = x.TeachingTask!.Course!.Name }) .FirstAsync(ct); await NotifyManagers("缓考申请", $"{info.StudentName} 申请《{info.CourseName}》缓考", ct); return Created("", new { d.Id }); } [HttpPost("deferred/{id:guid}/approve")] [Authorize(Roles = Managers)] public async Task ApproveDeferred(Guid id, CancellationToken ct) { var d = await db.DeferredExams.Include(x => x.TeachingTask).ThenInclude(x => x!.Course).FirstOrDefaultAsync(x => x.Id == id, ct); if (d is null) return NotFound(); d.Status = ApprovalStatus.Approved; d.ReviewedAt = DateTime.UtcNow; d.ReviewedByUserId = scope.Current.UserId; // Auto-apply: set grade record to deferred var sheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == d.TeachingTaskId, ct); if (sheet is not null) { var record = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == sheet.Id && x.StudentId == d.StudentId, ct); if (record is not null) record.ExamStatus = GradeExamStatus.Deferred; } await db.SaveChangesAsync(ct); await NotifyStudent(d.StudentId, "缓考申请已通过", $"《{d.TeachingTask!.Course!.Name}》缓考已批准。", ct); return NoContent(); } [HttpPost("deferred/{id:guid}/reject")] [Authorize(Roles = Managers)] public async Task RejectDeferred(Guid id, [FromBody] ReviewBody body, CancellationToken ct) { var d = await db.DeferredExams.FindAsync([id], ct); if (d is null) return NotFound(); d.Status = ApprovalStatus.Rejected; d.ReviewedAt = DateTime.UtcNow; d.ReviewComment = body.Comment?.Trim(); d.ReviewedByUserId = scope.Current.UserId; await db.SaveChangesAsync(ct); await NotifyStudent(d.StudentId, "缓考申请已驳回", d.ReviewComment ?? "审核未通过。", ct); return NoContent(); } // ═══════════════ Grade Modification (3-level) ═══════════════ [HttpPost("grade-modifications")] [Authorize(Roles = Teachers)] public async Task ApplyGradeMod(GradeModRequest req, CancellationToken ct) { var record = await db.GradeRecords.Include(x => x.GradeSheet).FirstOrDefaultAsync(x => x.Id == req.GradeRecordId, ct); if (record is null) return NotFound(); if (record.GradeSheet!.Status != GradeSheetStatus.Published) return ConflictProblem("只能修改已发布的成绩。"); var gm = new GradeModification { GradeRecordId = req.GradeRecordId, CurrentScore = record.TotalScore ?? 0, RequestedScore = req.RequestedScore, Reason = req.Reason.Trim(), ApplicantUserId = scope.Current.UserId }; db.GradeModifications.Add(gm); await db.SaveChangesAsync(ct); var gmInfo = await db.GradeModifications.Where(x => x.Id == gm.Id) .Select(x => new { StudentName = x.GradeRecord!.Student!.Name, CourseName = x.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name, x.CurrentScore, x.RequestedScore }) .FirstAsync(ct); await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gmInfo.StudentName} — 《{gmInfo.CourseName}》{gmInfo.CurrentScore}→{gmInfo.RequestedScore}", cancellationToken: ct, category: NotificationCategory.Grade); return Created("", new { gm.Id }); } [HttpPost("grade-modifications/{id:guid}/college-approve")] [Authorize(Roles = Managers)] public async Task CollegeApproveGradeMod(Guid id, CancellationToken ct) { var gm = await db.GradeModifications.Include(x => x.GradeRecord).ThenInclude(x => x!.GradeSheet).ThenInclude(x => x!.TeachingTask).ThenInclude(x => x!.Course).FirstOrDefaultAsync(x => x.Id == id, ct); if (gm is null) return NotFound(); if (gm.Status != GradeModificationStatus.TeacherSubmitted) return ConflictProblem("状态不正确。"); gm.Status = GradeModificationStatus.CollegeApproved; gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId; await db.SaveChangesAsync(ct); await NotificationService.SendToRoleAsync( db, SystemRoles.AcademicAdmin, "成绩修改待校级审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", linkUrl: "/approvals", cancellationToken: ct, category: NotificationCategory.Grade); return NoContent(); } [HttpPost("grade-modifications/{id:guid}/approve")] [Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)] public async Task FinalApproveGradeMod(Guid id, CancellationToken ct) { var gm = await db.GradeModifications .Include(x => x.GradeRecord) .ThenInclude(x => x!.Student) .Include(x => x.GradeRecord) .ThenInclude(x => x!.GradeSheet) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Course) .FirstOrDefaultAsync(x => x.Id == id, ct); if (gm is null) return NotFound(); if (gm.Status != GradeModificationStatus.CollegeApproved) return ConflictProblem("需先通过学院审核。"); gm.Status = GradeModificationStatus.Approved; gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; // Auto-apply: update grade record gm.GradeRecord!.TotalScore = gm.RequestedScore; gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore); await db.SaveChangesAsync(ct); await NotificationService.SendAsync( db, gm.ApplicantUserId, "成绩修改已通过", "您的成绩修改申请已通过三级审批并生效。", "/approvals", ct, NotificationCategory.Grade); var studentUserId = gm.GradeRecord.Student?.UserId; if (studentUserId.HasValue && studentUserId.Value != gm.ApplicantUserId) { await NotificationService.SendAsync( db, studentUserId.Value, "成绩已更新", $"《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》成绩已由 {gm.CurrentScore} 调整为 {gm.RequestedScore}。", "/grades", ct, NotificationCategory.Grade); } return NoContent(); } [HttpPost("grade-modifications/{id:guid}/reject")] [Authorize(Roles = Managers)] public async Task RejectGradeMod(Guid id, [FromBody] ReviewBody body, CancellationToken ct) { var gm = await db.GradeModifications.FindAsync([id], ct); if (gm is null) return NotFound(); gm.Status = GradeModificationStatus.Rejected; gm.ReviewComment = body.Comment?.Trim(); if (gm.Status == GradeModificationStatus.TeacherSubmitted) { gm.CollegeReviewedAt = DateTime.UtcNow; gm.CollegeReviewedByUserId = scope.Current.UserId; } else { gm.FinalReviewedAt = DateTime.UtcNow; gm.FinalReviewedByUserId = scope.Current.UserId; } await db.SaveChangesAsync(ct); await NotificationService.SendAsync( db, gm.ApplicantUserId, "成绩修改已驳回", gm.ReviewComment ?? "审核未通过。", "/approvals", ct, NotificationCategory.Grade); return NoContent(); } // ═══════════════ 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() }; db.CourseSubstitutions.Add(cs); await db.SaveChangesAsync(ct); var info = await db.CourseSubstitutions.Where(x => x.Id == cs.Id) .Select(x => new { StudentName = x.Student!.Name, SubName = x.SubstituteCourse!.Name, OrigName = x.OriginalCourse!.Name }) .FirstAsync(ct); await NotifyManagers("课程替代申请", $"{info.StudentName} 申请用《{info.SubName}》替代《{info.OrigName}》", ct); return Created("", new { cs.Id }); } [HttpPost("substitutions/{id:guid}/approve")] [Authorize(Roles = Managers)] public async Task ApproveSubstitution(Guid id, CancellationToken ct) { var cs = await db.CourseSubstitutions.Include(x => x.Student).Include(x => x.OriginalCourse).Include(x => x.SubstituteCourse).FirstOrDefaultAsync(x => x.Id == id, ct); if (cs is null) return NotFound(); cs.Status = ApprovalStatus.Approved; cs.ReviewedAt = DateTime.UtcNow; cs.ReviewedByUserId = scope.Current.UserId; // Auto-apply: copy passed grade to original course var currentTerm = await db.AcademicTerms.FirstOrDefaultAsync(x => x.IsCurrent, ct); if (currentTerm is not null) { var passedRecord = await db.GradeRecords .Where(x => x.StudentId == cs.StudentId && x.GradeSheet!.TeachingTask!.CourseId == cs.SubstituteCourseId && x.TotalScore >= 60) .OrderByDescending(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate) .FirstOrDefaultAsync(ct); if (passedRecord is not null) { // Find or create grade record for original course var origTask = await db.TeachingTasks.FirstOrDefaultAsync(x => x.CourseId == cs.OriginalCourseId && x.AcademicTermId == currentTerm.Id, ct); if (origTask is not null) { var origSheet = await db.GradeSheets.FirstOrDefaultAsync(x => x.TeachingTaskId == origTask.Id, ct); if (origSheet is not null) { var origRecord = await db.GradeRecords.FirstOrDefaultAsync(x => x.GradeSheetId == origSheet.Id && x.StudentId == cs.StudentId, ct); if (origRecord is not null) { origRecord.TotalScore = passedRecord.TotalScore; origRecord.GradePoint = passedRecord.GradePoint; origRecord.Notes = $"课程替代:{cs.SubstituteCourse!.Code} {cs.SubstituteCourse.Name}"; } } } } } await db.SaveChangesAsync(ct); await NotifyStudent(cs.StudentId, "课程替代申请已通过", $"《{cs.SubstituteCourse!.Name}》替代《{cs.OriginalCourse!.Name}》已生效。", ct); return NoContent(); } [HttpPost("substitutions/{id:guid}/reject")] [Authorize(Roles = Managers)] public async Task RejectSubstitution(Guid id, [FromBody] ReviewBody body, CancellationToken ct) { var cs = await db.CourseSubstitutions.FindAsync([id], ct); if (cs is null) return NotFound(); cs.Status = ApprovalStatus.Rejected; cs.ReviewedAt = DateTime.UtcNow; cs.ReviewComment = body.Comment?.Trim(); cs.ReviewedByUserId = scope.Current.UserId; await db.SaveChangesAsync(ct); await NotifyStudent(cs.StudentId, "课程替代申请已驳回", cs.ReviewComment ?? "审核未通过。", ct); return NoContent(); } // ═══════════════ 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.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); } [HttpGet("my-grades")] [Authorize(Roles = SystemRoles.Student)] public async Task GetMyGrades(CancellationToken ct) { var sid = await GetStudentIdAsync(ct); if (sid is null) return StudentNotFound(); var grades = await db.GradeRecords.AsNoTracking() .Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published) .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); } [HttpGet("grade-records")] [Authorize(Roles = Teachers)] public async Task GetGradeRecordsForMod(CancellationToken ct) { var userId = scope.Current.UserId; var records = await db.GradeRecords.AsNoTracking() .Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published && x.GradeSheet.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId)) .Select(x => new { x.Id, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, CourseName = x.GradeSheet!.TeachingTask!.Course!.Name, x.TotalScore }) .ToListAsync(ct); return Ok(records); } // ═══════════════ Helpers ═══════════════ private IQueryable Scoped(System.Linq.Expressions.Expression> predicate) where T : class { var q = db.Set().AsNoTracking().Where(predicate); if (scope.Current.Scope == DataScope.All) return q; var cid = scope.Current.RestrictedCollegeId; if (typeof(T) == typeof(CourseExemption)) return (IQueryable)((IQueryable)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid); if (typeof(T) == typeof(DeferredExam)) return (IQueryable)((IQueryable)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid); if (typeof(T) == typeof(GradeModification)) return (IQueryable)((IQueryable)(object)q).Where(x => x.GradeRecord!.GradeSheet!.TeachingTask!.Course!.CollegeId == cid); if (typeof(T) == typeof(CourseSubstitution)) return (IQueryable)((IQueryable)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid); if (typeof(T) == typeof(StudentStatusChange)) return (IQueryable)((IQueryable)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid); if (typeof(T) == typeof(CourseAdjustment)) return (IQueryable)((IQueryable)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid); if (typeof(T) == typeof(GradeSheet)) return (IQueryable)((IQueryable)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid); if (typeof(T) == typeof(AttendanceRecord)) return (IQueryable)((IQueryable)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid); return q; } 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, category: NotificationCategory.Approval); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct, category: NotificationCategory.Approval); } private async Task NotifyStudent(Guid sid, string title, string content, CancellationToken ct) { var uid = await db.Students.Where(s => s.Id == sid).Select(s => s.UserId).FirstOrDefaultAsync(ct); if (uid.HasValue) await NotificationService.SendAsync(db, uid.Value, title, content, "/approvals", ct, NotificationCategory.Approval); } private async Task NotifyCollege(GradeModification gm, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, "成绩修改待审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》{gm.CurrentScore}→{gm.RequestedScore}", cancellationToken: ct, category: NotificationCategory.Grade); } private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct, category: NotificationCategory.Approval); } private static string SSCLabel(StudentStatusChangeType t) => t switch { StudentStatusChangeType.Suspension => "休学", StudentStatusChangeType.Resumption => "复学", StudentStatusChangeType.Withdrawal => "退学", _ => "异动" }; private static string CALabel(CourseAdjustmentType t) => t switch { CourseAdjustmentType.Reschedule => "调课", CourseAdjustmentType.Cancel => "停课", CourseAdjustmentType.Makeup => "补课", CourseAdjustmentType.Substitute => "代课", _ => "调停课" }; private ActionResult ConflictProblem(string d) => Conflict(new ProblemDetails { Title = "无法完成", Detail = d, Status = 409 }); } 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); public sealed record ReviewBody([MaxLength(500)] string? Comment);