using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Controllers; [ApiController] [Authorize] [Route("api/course-adjustments")] public sealed class CourseAdjustmentsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string Applicants = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin + "," + SystemRoles.Teacher; private const string Reviewers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; [HttpGet("task-options")] [Authorize(Roles = Applicants)] public async Task GetTaskOptions( Guid? academicTermId, CancellationToken cancellationToken) { var source = AccessibleTeachingTasks().AsNoTracking() .Where(x => x.Status == TeachingTaskStatus.Published); if (academicTermId.HasValue) source = source.Where(x => x.AcademicTermId == academicTermId); return Ok(await source .OrderByDescending(x => x.AcademicTerm!.StartDate) .ThenBy(x => x.Course!.Code) .ThenBy(x => x.TaskNumber) .Select(x => new { x.Id, x.TaskNumber, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, CourseCode = x.Course!.Code, CourseName = x.Course.Name, ClassNames = x.Classes .OrderBy(item => item.AdministrativeClass!.Code) .Select(item => item.AdministrativeClass!.Name) }) .ToListAsync(cancellationToken)); } // ═══════════════ My adjustments ═══════════════ [HttpGet("mine")] [Authorize(Roles = Applicants)] public async Task GetMine( Guid? academicTermId, CourseAdjustmentStatus? status, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var source = db.CourseAdjustments.AsNoTracking() .Where(x => x.ApplicantUserId == userId); if (academicTermId.HasValue) source = source.Where(x => x.TeachingTask!.AcademicTermId == academicTermId); if (status.HasValue) source = source.Where(x => x.Status == status); return Ok(await source .OrderByDescending(x => x.CreatedAt) .Select(AdjustmentProjection()) .ToListAsync(cancellationToken)); } // ═══════════════ Pending reviews ═══════════════ [HttpGet("pending-reviews")] [Authorize(Roles = Reviewers)] public async Task GetPendingReviews( Guid? academicTermId, CancellationToken cancellationToken) { var scope = currentUserDataScope.Current; var source = db.CourseAdjustments.AsNoTracking() .Where(x => x.Status == CourseAdjustmentStatus.Submitted); if (scope.Scope == DataScope.College) source = source.Where(x => x.TeachingTask!.Course!.CollegeId == scope.RestrictedCollegeId); if (academicTermId.HasValue) source = source.Where(x => x.TeachingTask!.AcademicTermId == academicTermId); return Ok(await source .OrderByDescending(x => x.SubmittedAt) .Select(AdjustmentProjection()) .ToListAsync(cancellationToken)); } // ═══════════════ Detail ═══════════════ [HttpGet("{id:guid}")] [Authorize(Roles = Applicants)] public async Task GetDetail(Guid id, CancellationToken cancellationToken) { var adj = await db.CourseAdjustments.AsNoTracking() .Where(x => x.Id == id) .Select(AdjustmentProjection()) .FirstOrDefaultAsync(cancellationToken); return adj is null ? NotFound() : Ok(adj); } // ═══════════════ Create ═══════════════ [HttpPost] [Authorize(Roles = Applicants)] public async Task Create( CourseAdjustmentRequest request, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var validation = await ValidateRequestAsync(request, cancellationToken); if (validation is not null) return validation; var adj = new CourseAdjustment { TeachingTaskId = request.TeachingTaskId, Type = request.Type, ApplicantUserId = userId, Reason = request.Reason.Trim(), TargetDate = request.TargetDate, DayOfWeek = request.DayOfWeek, StartPeriod = request.StartPeriod, PeriodCount = request.PeriodCount, ClassroomId = request.ClassroomId, SubstituteTeacherId = request.SubstituteTeacherId, CancelWeek = request.CancelWeek, CancelDate = request.CancelDate }; if (request.Submit) { adj.Status = CourseAdjustmentStatus.Submitted; adj.SubmittedAt = DateTime.UtcNow; } db.CourseAdjustments.Add(adj); await db.SaveChangesAsync(cancellationToken); if (request.Submit) { var taskInfo = await db.TeachingTasks .Where(x => x.Id == request.TeachingTaskId) .Select(x => new { x.Course!.Name, x.Course.CollegeId }) .FirstOrDefaultAsync(cancellationToken); if (taskInfo is not null) { var tl = TypeLabel(request.Type); await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, $"新的{tl}申请", $"《{taskInfo.Name}》提交了{tl}申请,请及时审核。", taskInfo.CollegeId, "/course-adjustments", cancellationToken, NotificationCategory.Schedule); } } return Created(string.Empty, new { adj.Id }); } // ═══════════════ Submit ═══════════════ [HttpPost("{id:guid}/submit")] [Authorize(Roles = Applicants)] public async Task Submit(Guid id, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var adj = await db.CourseAdjustments .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) .FirstOrDefaultAsync(x => x.Id == id && x.ApplicantUserId == userId, cancellationToken); if (adj is null) return NotFound(); if (adj.Status != CourseAdjustmentStatus.Draft) return ConflictProblem("只有草稿可以提交。"); adj.Status = CourseAdjustmentStatus.Submitted; adj.SubmittedAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); var courseName = adj.TeachingTask!.Course!.Name; await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, $"新的{TypeLabel(adj.Type)}申请", $"《{courseName}》提交了{TypeLabel(adj.Type)}申请,请及时审核。", adj.TeachingTask.Course.CollegeId, "/course-adjustments", cancellationToken, NotificationCategory.Schedule); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, $"新的{TypeLabel(adj.Type)}申请", $"《{courseName}》提交了{TypeLabel(adj.Type)}申请。", null, "/course-adjustments", cancellationToken, NotificationCategory.Schedule); return NoContent(); } // ═══════════════ Approve ═══════════════ [HttpPost("{id:guid}/approve")] [Authorize(Roles = Reviewers)] public async Task Approve(Guid id, CancellationToken cancellationToken) { var adj = await db.CourseAdjustments .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) .ThenInclude(x => x!.College) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (adj is null) return NotFound(); var scope = currentUserDataScope.Current; if (scope.Scope == DataScope.College && adj.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId) return ConflictProblem("只能审核本学院课程的调停课申请。"); if (adj.Status != CourseAdjustmentStatus.Submitted) return ConflictProblem("只有待审核的申请可以审批。"); adj.Status = CourseAdjustmentStatus.Approved; adj.ReviewedAt = DateTime.UtcNow; adj.ReviewedByUserId = currentUserDataScope.Current.UserId; // Apply schedule changes await ApplyScheduleChangesAsync(adj, cancellationToken); await db.SaveChangesAsync(cancellationToken); // Notify applicant await NotificationService.SendAsync(db, adj.ApplicantUserId, "调停课申请已通过", $"您的{TypeLabel(adj.Type)}申请({adj.TeachingTask!.Course!.Name})已通过审核。", "/course-adjustments", cancellationToken, NotificationCategory.Schedule); // Notify affected students var studentUserIds = await db.CourseEnrollments .Where(x => x.CourseSelectionOffering!.TeachingTaskId == adj.TeachingTaskId && x.Status == CourseEnrollmentStatus.Enrolled) .Select(x => x.Student!.UserId) .Where(uid => uid != null) .Select(uid => uid!.Value) .Distinct() .ToListAsync(cancellationToken); if (studentUserIds.Count > 0) { await NotificationService.SendToUserIdsAsync(db, studentUserIds, "课程变动通知", $"《{adj.TeachingTask.Course.Name}》有{TypeLabel(adj.Type)}变动,请查看课表。", "/my-timetable", cancellationToken, NotificationCategory.Schedule); } return NoContent(); } private async Task ApplyScheduleChangesAsync( CourseAdjustment adj, CancellationToken ct) { switch (adj.Type) { case CourseAdjustmentType.Reschedule: // Update existing schedule entries for this teaching task if (adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue) { var entries = await db.ScheduleEntries .Where(x => x.TeachingTaskId == adj.TeachingTaskId) .ToListAsync(ct); foreach (var entry in entries) { entry.DayOfWeek = adj.DayOfWeek.Value; entry.StartPeriod = adj.StartPeriod.Value; entry.PeriodCount = adj.PeriodCount ?? entry.PeriodCount; } if (adj.ClassroomId.HasValue) { foreach (var entry in entries) entry.ClassroomId = adj.ClassroomId.Value; } } break; case CourseAdjustmentType.Cancel: // Cancel: remove schedule entries for the specified week if (adj.CancelWeek.HasValue) { var entries = await db.ScheduleEntries .Where(x => x.TeachingTaskId == adj.TeachingTaskId && x.StartWeek <= adj.CancelWeek.Value && x.EndWeek >= adj.CancelWeek.Value) .ToListAsync(ct); foreach (var entry in entries) { // Split the entry to exclude the cancelled week if (entry.StartWeek == adj.CancelWeek.Value && entry.EndWeek == adj.CancelWeek.Value) { db.ScheduleEntries.Remove(entry); } else if (entry.StartWeek == adj.CancelWeek.Value) { entry.StartWeek = adj.CancelWeek.Value + 1; } else if (entry.EndWeek == adj.CancelWeek.Value) { entry.EndWeek = adj.CancelWeek.Value - 1; } } } break; case CourseAdjustmentType.Makeup: // Makeup: add a temp schedule entry for the makeup date if (adj.TargetDate.HasValue && adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue) { var publishedPlan = await db.SchedulePlans .Where(x => x.AcademicTermId == adj.TeachingTask!.AcademicTermId && x.Status == SchedulePlanStatus.Published) .FirstOrDefaultAsync(ct); if (publishedPlan is not null) { db.ScheduleEntries.Add(new ScheduleEntry { SchedulePlanId = publishedPlan.Id, TeachingTaskId = adj.TeachingTaskId, ClassroomId = adj.ClassroomId, DayOfWeek = adj.DayOfWeek.Value, StartPeriod = adj.StartPeriod.Value, PeriodCount = adj.PeriodCount ?? 2, StartWeek = 1, EndWeek = 1, WeekPattern = WeekPattern.All, Notes = $"补课(原申请 {adj.CreatedAt:yyyy-MM-dd})" }); } } break; case CourseAdjustmentType.Substitute: // Substitute: add substitute teacher to the teaching task if (adj.SubstituteTeacherId.HasValue) { var alreadyExists = await db.TeachingTaskTeachers .AnyAsync(x => x.TeachingTaskId == adj.TeachingTaskId && x.TeacherId == adj.SubstituteTeacherId.Value, ct); if (!alreadyExists) { db.TeachingTaskTeachers.Add(new TeachingTaskTeacher { TeachingTaskId = adj.TeachingTaskId, TeacherId = adj.SubstituteTeacherId.Value, IsPrimary = false }); } } break; } } private static string TypeLabel(CourseAdjustmentType type) => type switch { CourseAdjustmentType.Reschedule => "调课", CourseAdjustmentType.Cancel => "停课", CourseAdjustmentType.Makeup => "补课", CourseAdjustmentType.Substitute => "代课", _ => "调停课" }; // ═══════════════ Reject ═══════════════ [HttpPost("{id:guid}/reject")] [Authorize(Roles = Reviewers)] public async Task Reject( Guid id, RejectionRequest request, CancellationToken cancellationToken) { var adj = await db.CourseAdjustments .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (adj is null) return NotFound(); var scope = currentUserDataScope.Current; if (scope.Scope == DataScope.College && adj.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId) return ConflictProblem("只能审核本学院课程的调停课申请。"); if (adj.Status != CourseAdjustmentStatus.Submitted) return ConflictProblem("只有待审核的申请可以退回。"); adj.Status = CourseAdjustmentStatus.Rejected; adj.ReviewComment = request.Comment?.Trim(); adj.ReviewedAt = DateTime.UtcNow; adj.ReviewedByUserId = currentUserDataScope.Current.UserId; await db.SaveChangesAsync(cancellationToken); var msg = adj.ReviewComment is not null ? $"您的{TypeLabel(adj.Type)}申请已退回。审核意见:{adj.ReviewComment}" : $"您的{TypeLabel(adj.Type)}申请已退回。"; await NotificationService.SendAsync(db, adj.ApplicantUserId, $"{TypeLabel(adj.Type)}申请已退回", msg, "/course-adjustments", cancellationToken, NotificationCategory.Schedule); return NoContent(); } // ═══════════════ Helpers ═══════════════ private async Task ValidateRequestAsync( CourseAdjustmentRequest request, CancellationToken cancellationToken) { var task = await AccessibleTeachingTasks().AsNoTracking() .Include(x => x.Course) .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId && x.Status == TeachingTaskStatus.Published, cancellationToken); if (task is null) return ValidationProblem("教学班不存在或未发布。"); switch (request.Type) { case CourseAdjustmentType.Reschedule: case CourseAdjustmentType.Makeup: if (!request.DayOfWeek.HasValue || request.DayOfWeek is < 1 or > 7) return ValidationProblem("请选择有效的上课日。"); if (!request.StartPeriod.HasValue || request.StartPeriod < 1) return ValidationProblem("请选择起始节次。"); if (!request.PeriodCount.HasValue || request.PeriodCount < 1) return ValidationProblem("请选择持续节数。"); if (request.Type == CourseAdjustmentType.Makeup && !request.TargetDate.HasValue) return ValidationProblem("补课必须指定日期。"); break; case CourseAdjustmentType.Substitute: if (!request.SubstituteTeacherId.HasValue) return ValidationProblem("请选择代课教师。"); if (!await db.Teachers.AnyAsync(x => x.Id == request.SubstituteTeacherId && x.Status == TeacherStatus.Active, cancellationToken)) return ValidationProblem("代课教师不存在或已离职。"); break; case CourseAdjustmentType.Cancel: if (!request.CancelWeek.HasValue && !request.CancelDate.HasValue) return ValidationProblem("停课需指定周次或日期。"); break; } return null; } private IQueryable AccessibleTeachingTasks() { var source = db.TeachingTasks.AsQueryable(); var scope = currentUserDataScope.Current; if (scope.Scope == DataScope.All) return source; if (scope.Scope == DataScope.College) return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId); if (scope.IsInRole(SystemRoles.Teacher)) return source.Where(x => x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId)); return source.Where(_ => false); } private static System.Linq.Expressions.Expression< Func> AdjustmentProjection() => x => new { x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, CollegeName = x.TeachingTask.Course.College!.Name, TeacherNames = x.TeachingTask.Teachers .OrderByDescending(t => t.IsPrimary) .Select(t => t.Teacher!.Name), x.Type, x.Status, x.Reason, x.ReviewComment, x.TargetDate, x.DayOfWeek, x.StartPeriod, x.PeriodCount, ClassroomName = x.Classroom != null ? x.Classroom.Name : null, BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null, SubstituteTeacherName = x.SubstituteTeacher != null ? x.SubstituteTeacher.Name : null, x.CancelWeek, x.CancelDate, x.ApplicantUserId, x.SubmittedAt, x.ReviewedAt, x.CreatedAt }; private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "无法完成操作", Detail = detail, Status = StatusCodes.Status409Conflict }); } // ═══════════════ Request records ═══════════════ public sealed record CourseAdjustmentRequest( Guid TeachingTaskId, CourseAdjustmentType Type, [Required, MaxLength(500)] string Reason, bool Submit, DateOnly? TargetDate, [Range(1, 7)] int? DayOfWeek, [Range(1, 30)] int? StartPeriod, [Range(1, 6)] int? PeriodCount, Guid? ClassroomId, Guid? SubstituteTeacherId, int? CancelWeek, DateOnly? CancelDate); public sealed record RejectionRequest( [MaxLength(500)] string? Comment);