各类申请

This commit is contained in:
2026-07-25 16:51:12 +08:00 Unverified
parent d02b0c56a1
commit 5e4c76a9e4
10 changed files with 901 additions and 4 deletions
@@ -0,0 +1,395 @@
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 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 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);
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 NotifyRole(SystemRoles.AcademicAdmin, "成绩修改待校级审核", $"{gm.GradeRecord!.Student!.Name} — 《{gm.GradeRecord.GradeSheet!.TeachingTask!.Course!.Name}》", ct);
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).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, "成绩修改已通过", "您的成绩修改申请已通过三级审批并生效。", cancellationToken: ct);
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 ?? "审核未通过。", cancellationToken: ct);
return NoContent();
}
// ═══════════════ Course Substitution ═══════════════
[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 (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 enrolled courses ═══════════════
[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.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 })
.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)
.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)
.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 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); }
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, cancellationToken: ct); }
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); }
private async Task NotifyRole(string role, string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, role, title, content, cancellationToken: ct); }
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 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);
@@ -155,8 +155,32 @@ public sealed class AttendanceController(
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
// Load exemption/deferred in separate query to avoid SQL APPLY (unsupported on SQLite)
var exemptStudentIds = await db.CourseExemptions
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
var deferredStudentIds = await db.DeferredExams
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
var canEdit = sheet.Status == AttendanceSheetStatus.Draft;
return Ok(new { Sheet = sheet, CanEdit = canEdit });
return Ok(new
{
Sheet = new
{
sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate,
sheet.Status, sheet.Notes, sheet.SubmittedAt,
sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName,
Records = sheet.Records.Select(r => new
{
r.StudentId, r.StudentNumber, r.Name, r.ClassName,
r.Status, r.Notes,
IsExempt = exemptStudentIds.Contains(r.StudentId),
IsDeferred = deferredStudentIds.Contains(r.StudentId)
})
},
CanEdit = canEdit
});
}
[HttpPut("sheets/{id:guid}/records")]
@@ -430,8 +454,7 @@ public sealed class AttendanceController(
var source = db.AttendanceRecords.AsNoTracking()
.Where(r =>
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted &&
r.Student!.AdministrativeClassId != null &&
targetClassIds.Contains(r.Student.AdministrativeClassId));
targetClassIds.Contains(r.Student!.AdministrativeClassId));
if (academicTermId.HasValue)
source = source.Where(r =>
r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);