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 Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Teaching; 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)); } [HttpGet("tasks/{teachingTaskId:guid}/session-options")] [Authorize(Roles = Applicants)] public async Task GetSessionOptions( Guid teachingTaskId, CancellationToken cancellationToken) { var task = await AccessibleTeachingTasks().AsNoTracking() .Where(x => x.Id == teachingTaskId && x.Status == TeachingTaskStatus.Published) .Select(x => new { x.Id, x.TaskNumber, CourseName = x.Course!.Name, x.AcademicTermId, TermStartDate = x.AcademicTerm!.StartDate, TermEndDate = x.AcademicTerm.EndDate }) .FirstOrDefaultAsync(cancellationToken); if (task is null) return NotFound(); var entries = await db.ScheduleEntries.AsNoTracking() .Where(x => x.TeachingTaskId == teachingTaskId && x.SchedulePlan!.Status == SchedulePlanStatus.Published) .OrderBy(x => x.DayOfWeek) .ThenBy(x => x.StartPeriod) .Select(x => new { x.Id, x.DayOfWeek, x.StartPeriod, x.PeriodCount, x.StartWeek, x.EndWeek, x.WeekPattern, x.ClassroomId, ClassroomName = x.Classroom != null ? x.Classroom.Name : null, BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null }) .ToListAsync(cancellationToken); var occupied = await db.CourseAdjustments.AsNoTracking() .Where(x => x.TeachingTaskId == teachingTaskId && x.SourceScheduleEntryId != null && x.SourceWeek != null && (x.Status == CourseAdjustmentStatus.Submitted || x.Status == CourseAdjustmentStatus.Approved)) .Select(x => new { x.SourceScheduleEntryId, x.SourceWeek }) .ToListAsync(cancellationToken); var occupiedOccurrences = occupied .Select(x => (x.SourceScheduleEntryId!.Value, x.SourceWeek!.Value)) .ToHashSet(); var sessions = entries .SelectMany(entry => Enumerable.Range( entry.StartWeek, entry.EndWeek - entry.StartWeek + 1) .Where(week => IncludesWeek(entry.WeekPattern, week)) .Select(week => new { ScheduleEntryId = entry.Id, Week = week, Date = ResolveDate(task.TermStartDate, week, entry.DayOfWeek), entry.DayOfWeek, entry.StartPeriod, entry.PeriodCount, entry.ClassroomId, entry.ClassroomName, entry.BuildingName, HasExistingAdjustment = occupiedOccurrences.Contains((entry.Id, week)) })) .Where(x => x.Date >= task.TermStartDate && x.Date <= task.TermEndDate) .OrderBy(x => x.Date) .ThenBy(x => x.StartPeriod) .ToList(); var classrooms = await db.Classrooms.AsNoTracking() .Where(x => x.IsEnabled) .OrderBy(x => x.Building!.Campus!.SortOrder) .ThenBy(x => x.Building!.SortOrder) .ThenBy(x => x.SortOrder) .Select(x => new { x.Id, x.Name, BuildingName = x.Building!.Name, CampusName = x.Building.Campus!.Name, x.Capacity }) .ToListAsync(cancellationToken); var periods = await db.ScheduleTimeSlots.AsNoTracking() .Where(x => x.AcademicTermId == task.AcademicTermId && x.IsEnabled) .OrderBy(x => x.PeriodNumber) .Select(x => new { x.PeriodNumber, x.Name, StartsAt = x.StartsAt.ToString("HH:mm"), EndsAt = x.EndsAt.ToString("HH:mm") }) .ToListAsync(cancellationToken); return Ok(new { Task = task, Sessions = sessions, Classrooms = classrooms, Periods = periods }); } // ═══════════════ 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 source = RequiresSourceOccurrence(request.Type) ? await LoadSourceOccurrenceAsync( request.TeachingTaskId, request.SourceScheduleEntryId, request.SourceWeek, cancellationToken) : null; var targetDayOfWeek = request.TargetDate.HasValue ? ToIsoDayOfWeek(request.TargetDate.Value.DayOfWeek) : request.DayOfWeek; var adj = new CourseAdjustment { TeachingTaskId = request.TeachingTaskId, Type = request.Type, ApplicantUserId = userId, Reason = request.Reason.Trim(), SourceScheduleEntryId = source?.Entry.Id, SourceWeek = source?.Week, SourceDate = source?.Date, SourceStartPeriod = source?.Entry.StartPeriod, SourcePeriodCount = source?.Entry.PeriodCount, SourceClassroomId = source?.Entry.ClassroomId, TargetDate = request.TargetDate, DayOfWeek = targetDayOfWeek, StartPeriod = request.StartPeriod, PeriodCount = source?.Entry.PeriodCount ?? request.PeriodCount, ClassroomId = request.ClassroomId, SubstituteTeacherId = request.SubstituteTeacherId, CancelWeek = request.Type == CourseAdjustmentType.Cancel ? source?.Week : request.CancelWeek, CancelDate = request.Type == CourseAdjustmentType.Cancel ? source?.Date : 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("只有草稿可以提交。"); var validation = await ValidateRequestAsync( new CourseAdjustmentRequest( adj.TeachingTaskId, adj.Type, adj.Reason, true, adj.TargetDate, adj.DayOfWeek, adj.StartPeriod, adj.PeriodCount, adj.ClassroomId, adj.SubstituteTeacherId, adj.CancelWeek, adj.CancelDate, adj.SourceScheduleEntryId, adj.SourceWeek), cancellationToken); if (validation is not null) return validation; 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) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Teachers) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Classes) .Include(x => x.TeachingTask) .ThenInclude(x => x!.AcademicTerm) .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("只有待审核的申请可以审批。"); var scheduleProblem = await ValidateApprovalScheduleAsync( adj, cancellationToken); if (scheduleProblem is not null) return ConflictProblem(scheduleProblem); 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 TeachingTaskRosterQuery .ForTask(db, adj.TeachingTaskId) .Select(x => x.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: var rescheduleSource = await LoadSourceEntryAsync(adj, ct); ExcludeWeek(rescheduleSource, adj.SourceWeek!.Value); db.ScheduleEntries.Add(CreateTargetEntry(adj, rescheduleSource.SchedulePlanId)); break; case CourseAdjustmentType.Cancel: var cancelSource = await LoadSourceEntryAsync(adj, ct); ExcludeWeek(cancelSource, adj.SourceWeek!.Value); break; case CourseAdjustmentType.Makeup: var makeupSource = await LoadSourceEntryAsync(adj, ct); db.ScheduleEntries.Add(CreateTargetEntry(adj, makeupSource.SchedulePlanId)); 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.TargetDate.HasValue) return ValidationProblem( request.Type == CourseAdjustmentType.Reschedule ? "请选择调课后的日期。" : "请选择补课日期。"); if (!request.StartPeriod.HasValue || request.StartPeriod < 1) 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: break; } if (RequiresSourceOccurrence(request.Type)) { var source = await LoadSourceOccurrenceAsync( request.TeachingTaskId, request.SourceScheduleEntryId, request.SourceWeek, cancellationToken); if (source is null) return ValidationProblem("请选择当前已发布课表中的具体原课次。"); var duplicate = await db.CourseAdjustments.AsNoTracking().AnyAsync(x => x.SourceScheduleEntryId == source.Entry.Id && x.SourceWeek == source.Week && (x.Status == CourseAdjustmentStatus.Submitted || x.Status == CourseAdjustmentStatus.Approved), cancellationToken); if (duplicate) return ConflictProblem("该课次已有待审核或已通过的调停课记录。"); if (request.TargetDate.HasValue && (request.TargetDate < source.Term.StartDate || request.TargetDate > source.Term.EndDate)) return ValidationProblem("目标日期必须在本学期起止日期内。"); } if (request.ClassroomId.HasValue && !await db.Classrooms.AsNoTracking().AnyAsync(x => x.Id == request.ClassroomId && x.IsEnabled, cancellationToken)) return ValidationProblem("目标教室不存在或已停用。"); return null; } private async Task ValidateApprovalScheduleAsync( CourseAdjustment adjustment, CancellationToken cancellationToken) { if (!RequiresSourceOccurrence(adjustment.Type)) return null; var source = await LoadSourceOccurrenceAsync( adjustment.TeachingTaskId, adjustment.SourceScheduleEntryId, adjustment.SourceWeek, cancellationToken); if (source is null) return "原课次已不在当前发布课表中,请退回后重新选择。"; if (adjustment.Type == CourseAdjustmentType.Cancel) return null; if (!adjustment.TargetDate.HasValue || !adjustment.StartPeriod.HasValue) return "目标上课日期或节次不完整。"; var targetWeek = ResolveWeek(source.Term.StartDate, adjustment.TargetDate.Value); var targetDay = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek); var target = new ScheduleEntry { TeachingTaskId = adjustment.TeachingTaskId, TeachingTask = adjustment.TeachingTask, ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId, DayOfWeek = targetDay, StartPeriod = adjustment.StartPeriod.Value, PeriodCount = adjustment.SourcePeriodCount ?? source.Entry.PeriodCount, StartWeek = targetWeek, EndWeek = targetWeek, WeekPattern = WeekPattern.All }; var possibleConflicts = await db.ScheduleEntries.AsNoTracking() .Include(x => x.TeachingTask) .ThenInclude(x => x!.Teachers) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Classes) .Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published && x.Id != adjustment.SourceScheduleEntryId && x.DayOfWeek == targetDay && x.StartWeek <= targetWeek && x.EndWeek >= targetWeek && x.StartPeriod < target.StartPeriod + target.PeriodCount && target.StartPeriod < x.StartPeriod + x.PeriodCount) .ToListAsync(cancellationToken); foreach (var existing in possibleConflicts.Where(x => IncludesWeek(x.WeekPattern, targetWeek))) { var reason = ScheduleConflictDetector.ConflictReason(target, existing); if (reason is not null) return $"目标时间存在{reason}冲突:{existing.TeachingTask!.Name}。"; } return null; } private async Task LoadSourceOccurrenceAsync( Guid teachingTaskId, Guid? scheduleEntryId, int? week, CancellationToken cancellationToken) { if (!scheduleEntryId.HasValue || !week.HasValue) return null; var entry = await db.ScheduleEntries.AsNoTracking() .Include(x => x.TeachingTask) .ThenInclude(x => x!.AcademicTerm) .FirstOrDefaultAsync(x => x.Id == scheduleEntryId.Value && x.TeachingTaskId == teachingTaskId && x.SchedulePlan!.Status == SchedulePlanStatus.Published, cancellationToken); if (entry?.TeachingTask?.AcademicTerm is null || week < entry.StartWeek || week > entry.EndWeek || !IncludesWeek(entry.WeekPattern, week.Value)) return null; var date = ResolveDate( entry.TeachingTask.AcademicTerm.StartDate, week.Value, entry.DayOfWeek); if (date < entry.TeachingTask.AcademicTerm.StartDate || date > entry.TeachingTask.AcademicTerm.EndDate) return null; return new SourceOccurrence( entry, entry.TeachingTask.AcademicTerm, week.Value, date); } private Task LoadSourceEntryAsync( CourseAdjustment adjustment, CancellationToken cancellationToken) => db.ScheduleEntries.SingleAsync(x => x.Id == adjustment.SourceScheduleEntryId && x.TeachingTaskId == adjustment.TeachingTaskId && x.SchedulePlan!.Status == SchedulePlanStatus.Published, cancellationToken); private void ExcludeWeek(ScheduleEntry entry, int week) { db.ScheduleEntries.Remove(entry); if (entry.StartWeek <= week - 1 && HasIncludedWeek(entry.WeekPattern, entry.StartWeek, week - 1)) db.ScheduleEntries.Add(CloneEntry(entry, entry.StartWeek, week - 1)); if (week + 1 <= entry.EndWeek && HasIncludedWeek(entry.WeekPattern, week + 1, entry.EndWeek)) db.ScheduleEntries.Add(CloneEntry(entry, week + 1, entry.EndWeek)); } private ScheduleEntry CreateTargetEntry( CourseAdjustment adjustment, Guid schedulePlanId) { var targetWeek = ResolveWeek( adjustment.TeachingTask!.AcademicTerm!.StartDate, adjustment.TargetDate!.Value); return new ScheduleEntry { SchedulePlanId = schedulePlanId, TeachingTaskId = adjustment.TeachingTaskId, ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId, DayOfWeek = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek), StartPeriod = adjustment.StartPeriod!.Value, PeriodCount = adjustment.SourcePeriodCount!.Value, StartWeek = targetWeek, EndWeek = targetWeek, WeekPattern = WeekPattern.All, Notes = adjustment.Type == CourseAdjustmentType.Reschedule ? $"调课(原第 {adjustment.SourceWeek} 周)" : $"补课(对应原第 {adjustment.SourceWeek} 周课次)" }; } private static ScheduleEntry CloneEntry( ScheduleEntry source, int startWeek, int endWeek) => new() { SchedulePlanId = source.SchedulePlanId, TeachingTaskId = source.TeachingTaskId, ClassroomId = source.ClassroomId, DayOfWeek = source.DayOfWeek, StartPeriod = source.StartPeriod, PeriodCount = source.PeriodCount, StartWeek = startWeek, EndWeek = endWeek, WeekPattern = source.WeekPattern, Notes = source.Notes }; private static bool RequiresSourceOccurrence(CourseAdjustmentType type) => type is CourseAdjustmentType.Reschedule or CourseAdjustmentType.Cancel or CourseAdjustmentType.Makeup; private static bool IncludesWeek(WeekPattern pattern, int week) => pattern == WeekPattern.All || pattern == WeekPattern.Odd && week % 2 == 1 || pattern == WeekPattern.Even && week % 2 == 0; private static bool HasIncludedWeek(WeekPattern pattern, int startWeek, int endWeek) => Enumerable.Range(startWeek, endWeek - startWeek + 1) .Any(week => IncludesWeek(pattern, week)); private static DateOnly ResolveDate(DateOnly termStartDate, int week, int dayOfWeek) { var startDay = (int)termStartDate.DayOfWeek; var daysSinceMonday = (startDay + 6) % 7; var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday); return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1); } private static int ResolveWeek(DateOnly termStartDate, DateOnly date) { var startDay = (int)termStartDate.DayOfWeek; var daysSinceMonday = (startDay + 6) % 7; var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday); return (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1; } private static int ToIsoDayOfWeek(DayOfWeek dayOfWeek) => dayOfWeek == System.DayOfWeek.Sunday ? 7 : (int)dayOfWeek; 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 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.SourceScheduleEntryId, x.SourceWeek, x.SourceDate, x.SourceStartPeriod, x.SourcePeriodCount, SourceClassroomName = x.SourceClassroomId == null ? null : db.Classrooms .Where(room => room.Id == x.SourceClassroomId) .Select(room => room.Building!.Name + " " + room.Name) .FirstOrDefault(), 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 sealed record SourceOccurrence( ScheduleEntry Entry, AcademicTerm Term, int Week, DateOnly Date); 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, Guid? SourceScheduleEntryId = null, int? SourceWeek = null); public sealed record RejectionRequest( [MaxLength(500)] string? Comment);