Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/ApprovalsController.cs
T
biss b0679a253d 成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。
手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。
选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。
关闭选课界面会明确提示发送通知,并显示实际通知人数。
2026-07-26 19:12:42 +08:00

606 lines
35 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ActionResult> GetPending(CancellationToken ct)
{
var items = new List<ApprovalItem>();
items.AddRange(await Scoped<CourseExemption>(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<DeferredExam>(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<GradeModification>(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<CourseSubstitution>(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<StudentStatusChange>(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<CourseAdjustment>(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<GradeSheet>(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<AttendanceRecord>(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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<T> Scoped<T>(System.Linq.Expressions.Expression<Func<T, bool>> predicate) where T : class
{
var q = db.Set<T>().AsNoTracking().Where(predicate);
if (scope.Current.Scope == DataScope.All) return q;
var cid = scope.Current.RestrictedCollegeId;
if (typeof(T) == typeof(CourseExemption)) return (IQueryable<T>)((IQueryable<CourseExemption>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(DeferredExam)) return (IQueryable<T>)((IQueryable<DeferredExam>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(GradeModification)) return (IQueryable<T>)((IQueryable<GradeModification>)(object)q).Where(x => x.GradeRecord!.GradeSheet!.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(CourseSubstitution)) return (IQueryable<T>)((IQueryable<CourseSubstitution>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
if (typeof(T) == typeof(StudentStatusChange)) return (IQueryable<T>)((IQueryable<StudentStatusChange>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
if (typeof(T) == typeof(CourseAdjustment)) return (IQueryable<T>)((IQueryable<CourseAdjustment>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(GradeSheet)) return (IQueryable<T>)((IQueryable<GradeSheet>)(object)q).Where(x => x.TeachingTask!.Course!.CollegeId == cid);
if (typeof(T) == typeof(AttendanceRecord)) return (IQueryable<T>)((IQueryable<AttendanceRecord>)(object)q).Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == cid);
return q;
}
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
private async Task<bool> 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<string> 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);