using System.ComponentModel.DataAnnotations; using System.Data; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.CourseSelection; using Jiaowu.Api.Infrastructure.Excel; using Jiaowu.Api.Infrastructure.Persistence; 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-selections")] public sealed class CourseSelectionsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope) : ControllerBase { private const string RoundManagers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; private const string OfferingManagers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin; private const string SelectionUsers = OfferingManagers + "," + SystemRoles.Student; private const string RosterReaders = OfferingManagers + "," + SystemRoles.Teacher; [HttpGet("rounds")] [Authorize(Roles = SelectionUsers)] public async Task GetRounds( Guid? academicTermId, CancellationToken cancellationToken) { var source = db.CourseSelectionRounds.AsNoTracking().AsQueryable(); if (academicTermId.HasValue) source = source.Where(x => x.AcademicTermId == academicTermId); if (currentUserDataScope.Current.IsInRole(SystemRoles.Student)) { var userId = currentUserDataScope.Current.UserId; var studentGrade = await db.Students.AsNoTracking() .Where(x => x.UserId == userId) .Select(x => (int?)x.AdministrativeClass!.Grade) .FirstOrDefaultAsync(cancellationToken); source = source.Where(x => x.Status != CourseSelectionRoundStatus.Draft); if (studentGrade.HasValue) { source = source.Where(x => !x.EligibleGrades.Any() || x.EligibleGrades.Any(item => item.Grade == studentGrade.Value)); } else { source = source.Where(_ => false); } } var now = DateTime.UtcNow; return Ok(await source .OrderByDescending(x => x.AcademicTerm!.StartDate) .ThenByDescending(x => x.StartsAt) .Select(x => new { x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, TermIsCurrent = x.AcademicTerm.IsCurrent, TermIsArchived = x.AcademicTerm.IsArchived, x.StartsAt, x.EndsAt, x.WithdrawalEndsAt, x.MaxCredits, x.MaxCourseCount, EligibleGrades = x.EligibleGrades .OrderBy(item => item.Grade) .Select(item => item.Grade), x.Status, IsAvailableNow = x.Status == CourseSelectionRoundStatus.Open && now >= x.StartsAt && now <= x.EndsAt, OfferingCount = x.Offerings.Count, x.Notes, x.UpdatedAt }) .ToListAsync(cancellationToken)); } [HttpGet("configuration-options")] [Authorize(Roles = RoundManagers)] public async Task GetConfigurationOptions( CancellationToken cancellationToken) { var grades = await db.AdministrativeClasses.AsNoTracking() .Where(x => x.IsEnabled) .Select(x => x.Grade) .Distinct() .OrderByDescending(x => x) .ToListAsync(cancellationToken); return Ok(new { Grades = grades }); } [HttpPost("rounds")] [Authorize(Roles = RoundManagers)] public async Task CreateRound( CourseSelectionRoundRequest request, CancellationToken cancellationToken) { var validation = await ValidateRoundAsync(request, cancellationToken); if (validation is not null) return validation; var round = new CourseSelectionRound { AcademicTermId = request.AcademicTermId, Name = request.Name.Trim(), StartsAt = request.StartsAt.ToUniversalTime(), EndsAt = request.EndsAt.ToUniversalTime(), WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(), MaxCredits = request.MaxCredits, MaxCourseCount = request.MaxCourseCount, EligibleGrades = NormalizeGrades(request.EligibleGrades) .Select(grade => new CourseSelectionRoundGrade { Grade = grade }) .ToList(), Notes = Normalize(request.Notes) }; db.CourseSelectionRounds.Add(round); return await SaveAsync(round.Id, true, cancellationToken); } [HttpPut("rounds/{id:guid}")] [Authorize(Roles = RoundManagers)] public async Task UpdateRound( Guid id, CourseSelectionRoundRequest request, CancellationToken cancellationToken) { var round = await db.CourseSelectionRounds .Include(x => x.EligibleGrades) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (round is null) return NotFound(); if (round.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("只有草稿选课批次可以修改。"); var validation = await ValidateRoundAsync(request, cancellationToken); if (validation is not null) return validation; round.AcademicTermId = request.AcademicTermId; round.Name = request.Name.Trim(); round.StartsAt = request.StartsAt.ToUniversalTime(); round.EndsAt = request.EndsAt.ToUniversalTime(); round.WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(); round.MaxCredits = request.MaxCredits; round.MaxCourseCount = request.MaxCourseCount; var requestedGrades = NormalizeGrades(request.EligibleGrades); var requestedGradeSet = requestedGrades.ToHashSet(); db.CourseSelectionRoundGrades.RemoveRange( round.EligibleGrades.Where(x => !requestedGradeSet.Contains(x.Grade))); foreach (var grade in requestedGrades.Where(grade => round.EligibleGrades.All(x => x.Grade != grade))) { round.EligibleGrades.Add(new CourseSelectionRoundGrade { CourseSelectionRoundId = round.Id, Grade = grade }); } round.Notes = Normalize(request.Notes); return await SaveAsync(id, false, cancellationToken); } [HttpDelete("rounds/{id:guid}")] [Authorize(Roles = RoundManagers)] public async Task DeleteRound(Guid id, CancellationToken cancellationToken) { var round = await db.CourseSelectionRounds.FindAsync([id], cancellationToken); if (round is null) return NotFound(); if (round.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("只有草稿选课批次可以删除。"); db.CourseSelectionRounds.Remove(round); return await SaveAsync(id, false, cancellationToken); } [HttpPost("rounds/{id:guid}/open")] [Authorize(Roles = RoundManagers)] public async Task OpenRound(Guid id, CancellationToken cancellationToken) { var round = await db.CourseSelectionRounds .Include(x => x.Offerings) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (round is null) return NotFound(); if (round.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("只有草稿选课批次可以开放。"); if (round.Offerings.Count == 0) return ConflictProblem("至少配置一个可选教学班后才能开放。"); round.Status = CourseSelectionRoundStatus.Open; return await SaveAsync(id, false, cancellationToken); } [HttpPost("rounds/{id:guid}/close")] [Authorize(Roles = RoundManagers)] public async Task CloseRound(Guid id, CancellationToken cancellationToken) { return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); var round = await db.CourseSelectionRounds .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (round is null) return NotFound(); if (round.Status != CourseSelectionRoundStatus.Open) return ConflictProblem("只有开放中的选课批次可以关闭。"); var enrollments = await db.CourseEnrollments .Include(x => x.Student) .Include(x => x.CourseSelectionOffering) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Course) .Where(x => x.CourseSelectionOffering!.CourseSelectionRoundId == id) .ToListAsync(cancellationToken); var waitlisted = enrollments .Where(x => x.Status == CourseEnrollmentStatus.Waitlisted) .ToList(); var now = DateTime.UtcNow; foreach (var enrollment in waitlisted) { enrollment.Status = CourseEnrollmentStatus.Expired; enrollment.WithdrawnAt = now; } var participantGroups = enrollments .Where(x => x.Student!.UserId.HasValue) .GroupBy(x => new { x.StudentId, UserId = x.Student!.UserId!.Value }) .ToList(); foreach (var participant in participantGroups) { var selectedCourses = participant .Where(x => x.Status == CourseEnrollmentStatus.Enrolled) .Select(x => x.CourseSelectionOffering!.TeachingTask!.Course!.Name) .Distinct() .OrderBy(x => x) .ToArray(); var unsuccessfulCourses = participant .Where(x => x.Status == CourseEnrollmentStatus.Expired) .Select(x => x.CourseSelectionOffering!.TeachingTask!.Course!.Name) .Distinct() .OrderBy(x => x) .ToArray(); db.Notifications.Add(new Notification { UserId = participant.Key.UserId, Title = $"{round.Name}结果", Content = BuildRoundResultContent( round.Name, selectedCourses, unsuccessfulCourses), Category = NotificationCategory.CourseSelection, LinkUrl = "/course-selections" }); } round.Status = CourseSelectionRoundStatus.Closed; await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return Ok(new { ExpiredWaitlistCount = waitlisted.Count, NotifiedStudentCount = participantGroups.Count }); }, cancellationToken, IsolationLevel.Serializable); } [HttpGet("rounds/{roundId:guid}/offerings")] [Authorize(Roles = OfferingManagers)] public async Task GetOfferings( Guid roundId, CancellationToken cancellationToken) { var source = ScopedOfferings() .AsNoTracking() .Where(x => x.CourseSelectionRoundId == roundId); return Ok(await source .OrderBy(x => x.TeachingTask!.Course!.Code) .ThenBy(x => x.TeachingTask!.TaskNumber) .Select(x => new { x.Id, x.CourseSelectionRoundId, x.TeachingTaskId, x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, CourseNature = x.TeachingTask.Course.Nature, CollegeName = x.TeachingTask.Course.College!.Name, x.TeachingTask.Course.Credits, TeacherNames = x.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), ClassNames = x.TeachingTask.Classes .Select(item => item.AdministrativeClass!.Name), x.Capacity, EnrolledCount = x.Enrollments.Count(item => item.Status == CourseEnrollmentStatus.Enrolled), WaitlistedCount = x.Enrollments.Count(item => item.Status == CourseEnrollmentStatus.Waitlisted), x.IsOpenToAll, x.Notes, x.UpdatedAt }) .ToListAsync(cancellationToken)); } [HttpPost("rounds/{roundId:guid}/offerings")] [Authorize(Roles = OfferingManagers)] public async Task CreateOffering( Guid roundId, CourseSelectionOfferingRequest request, CancellationToken cancellationToken) { var round = await db.CourseSelectionRounds.FindAsync([roundId], cancellationToken); if (round is null) return NotFound(); if (round.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("选课批次开放后不能调整教学班。"); var task = await FindAccessibleTaskAsync(request.TeachingTaskId, cancellationToken); var validation = ValidateOffering(round, task, request); if (validation is not null) return validation; var offering = new CourseSelectionOffering { CourseSelectionRoundId = roundId, TeachingTaskId = request.TeachingTaskId, Capacity = request.Capacity, IsOpenToAll = request.IsOpenToAll, Notes = Normalize(request.Notes) }; db.CourseSelectionOfferings.Add(offering); return await SaveAsync(offering.Id, true, cancellationToken); } [HttpPut("rounds/{roundId:guid}/offerings/{id:guid}")] [Authorize(Roles = OfferingManagers)] public async Task UpdateOffering( Guid roundId, Guid id, CourseSelectionOfferingRequest request, CancellationToken cancellationToken) { var offering = await ScopedOfferings() .Include(x => x.CourseSelectionRound) .FirstOrDefaultAsync( x => x.Id == id && x.CourseSelectionRoundId == roundId, cancellationToken); if (offering is null) return NotFound(); if (offering.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("选课批次开放后不能调整教学班。"); var task = await FindAccessibleTaskAsync(request.TeachingTaskId, cancellationToken); var validation = ValidateOffering(offering.CourseSelectionRound, task, request); if (validation is not null) return validation; offering.TeachingTaskId = request.TeachingTaskId; offering.Capacity = request.Capacity; offering.IsOpenToAll = request.IsOpenToAll; offering.Notes = Normalize(request.Notes); return await SaveAsync(id, false, cancellationToken); } [HttpDelete("rounds/{roundId:guid}/offerings/{id:guid}")] [Authorize(Roles = OfferingManagers)] public async Task DeleteOffering( Guid roundId, Guid id, CancellationToken cancellationToken) { var offering = await ScopedOfferings() .Include(x => x.CourseSelectionRound) .FirstOrDefaultAsync( x => x.Id == id && x.CourseSelectionRoundId == roundId, cancellationToken); if (offering is null) return NotFound(); if (offering.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft) return ConflictProblem("选课批次开放后不能调整教学班。"); db.CourseSelectionOfferings.Remove(offering); return await SaveAsync(id, false, cancellationToken); } [HttpGet("offerings/{id:guid}/roster")] [Authorize(Roles = RosterReaders)] public async Task GetRoster(Guid id, CancellationToken cancellationToken) { var offering = await db.CourseSelectionOfferings.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course!.Name, CourseNature = x.TeachingTask.Course.Nature, CollegeId = x.TeachingTask.Course.CollegeId, TeacherUserIds = x.TeachingTask.Teachers .Select(item => item.Teacher!.UserId), x.CourseSelectionRoundId, RoundStatus = x.CourseSelectionRound!.Status, x.Capacity }) .FirstOrDefaultAsync(cancellationToken); if (offering is null) return NotFound(); var scope = currentUserDataScope.Current; var isAssignedTeacher = scope.IsInRole(SystemRoles.Teacher) && offering.TeacherUserIds.Contains(scope.UserId); if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId)) return Forbid(); var students = await db.CourseEnrollments.AsNoTracking() .Where(x => x.CourseSelectionOfferingId == id && x.Status == CourseEnrollmentStatus.Enrolled) .OrderBy(x => x.Student!.StudentNumber) .Select(x => new { x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name, ClassName = x.Student.AdministrativeClass!.Name, MajorName = x.Student.AdministrativeClass.Major!.Name, Grade = x.Student.AdministrativeClass.Grade, x.EnrolledAt }) .ToListAsync(cancellationToken); var waitlistedRows = await db.CourseEnrollments.AsNoTracking() .Where(x => x.CourseSelectionOfferingId == id && x.Status == CourseEnrollmentStatus.Waitlisted) .OrderBy(x => x.WaitlistedAt) .ThenBy(x => x.CreatedAt) .Select(x => new { x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name, ClassName = x.Student.AdministrativeClass!.Name, MajorName = x.Student.AdministrativeClass.Major!.Name, Grade = x.Student.AdministrativeClass.Grade, x.WaitlistedAt }) .ToListAsync(cancellationToken); var waitlist = waitlistedRows .Select((item, index) => new { item.Id, item.StudentId, item.StudentNumber, item.Name, item.ClassName, item.MajorName, item.Grade, item.WaitlistedAt, Position = index + 1 }) .ToList(); return Ok(new { offering.Id, offering.TeachingTaskId, offering.TaskNumber, offering.TaskName, offering.CourseCode, offering.CourseName, offering.CourseNature, offering.Capacity, EnrolledCount = students.Count, Students = students, WaitlistedCount = waitlist.Count, Waitlist = waitlist, CanManageWaitlist = offering.RoundStatus == CourseSelectionRoundStatus.Open && (scope.IsInRole(SystemRoles.SuperAdmin) || scope.IsInRole(SystemRoles.AcademicAdmin) || scope.IsInRole(SystemRoles.CollegeAdmin)), CanProxyEnroll = CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature) && offering.RoundStatus != CourseSelectionRoundStatus.Draft && (currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin)) }); } [HttpGet("offerings/{id:guid}/eligible-students")] [Authorize(Roles = OfferingManagers)] public async Task GetEligibleStudents( Guid id, string? keyword = null, int page = 1, int pageSize = 20, bool forceMode = false, CancellationToken cancellationToken = default) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); var offering = await db.CourseSelectionOfferings.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Id, x.IsOpenToAll, CourseNature = x.TeachingTask!.Course!.Nature, CollegeId = x.TeachingTask.Course.CollegeId, RoundStatus = x.CourseSelectionRound!.Status, EligibleGrades = x.CourseSelectionRound.EligibleGrades .Select(item => item.Grade), ClassIds = x.TeachingTask.Classes .Select(item => item.AdministrativeClassId) }) .FirstOrDefaultAsync(cancellationToken); if (offering is null) return NotFound(); if (!currentUserDataScope.Current.CanAccessCollege(offering.CollegeId)) return Forbid(); if (!forceMode && !CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature)) return ConflictProblem("管理员代选仅适用于公共必修课。"); if (offering.RoundStatus == CourseSelectionRoundStatus.Draft) return ConflictProblem("选课批次开放后才能办理。"); var source = db.Students.AsNoTracking() .Where(x => x.Status == StudentStatus.Active && !db.CourseEnrollments.Any(enrollment => enrollment.CourseSelectionOfferingId == id && enrollment.StudentId == x.Id && enrollment.Status == CourseEnrollmentStatus.Enrolled)); if (!forceMode && offering.EligibleGrades.Any()) { source = source.WhereIn( offering.EligibleGrades, x => x.AdministrativeClass!.Grade); } if (!forceMode && !offering.IsOpenToAll) source = source.WhereIn(offering.ClassIds, x => x.AdministrativeClassId); if (!string.IsNullOrWhiteSpace(keyword)) { keyword = keyword.Trim(); source = source.Where(x => x.StudentNumber.Contains(keyword) || x.Name.Contains(keyword) || x.AdministrativeClass!.Name.Contains(keyword)); } var total = await source.CountAsync(cancellationToken); var items = await source .OrderBy(x => x.StudentNumber) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(x => new { x.Id, x.StudentNumber, x.Name, ClassName = x.AdministrativeClass!.Name, Grade = x.AdministrativeClass.Grade, MajorName = x.AdministrativeClass.Major!.Name, CollegeName = x.AdministrativeClass.Major.College!.Name }) .ToListAsync(cancellationToken); return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize }); } [HttpPost("offerings/{id:guid}/admin-enrollments")] [Authorize(Roles = RoundManagers)] public async Task AdminEnroll( Guid id, AdminEnrollmentRequest request, CancellationToken cancellationToken) { var studentIds = request.StudentIds.Distinct().ToArray(); if (studentIds.Length == 0) return ValidationProblem("请至少选择一名学生。"); if (studentIds.Length > 100) return ValidationProblem("单次最多可为 100 名学生代选。"); return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); var offering = await db.CourseSelectionOfferings .Include(x => x.CourseSelectionRound) .ThenInclude(x => x!.EligibleGrades) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Classes) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (offering is null) return NotFound(); var round = offering.CourseSelectionRound!; var task = offering.TeachingTask!; if (!CourseSelectionRules.SupportsProxyEnrollment(task.Course!.Nature)) return ConflictProblem("管理员代选仅适用于公共必修课。"); if (round.Status == CourseSelectionRoundStatus.Draft) return ConflictProblem("选课批次开放后才能办理管理员代选。"); if (task.Status != TeachingTaskStatus.Published) return ConflictProblem("该教学班当前不可办理代选。"); var students = await db.Students .Include(x => x.AdministrativeClass) .WhereIn(studentIds, x => x.Id) .OrderBy(x => x.StudentNumber) .ToListAsync(cancellationToken); if (students.Count != studentIds.Length) return ValidationProblem("存在无效的学生档案。"); var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active); if (inactive is not null) return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。"); var eligibleGrades = round.EligibleGrades .Select(x => x.Grade) .ToArray(); var wrongGrade = students.FirstOrDefault(student => !CourseSelectionRules.IsGradeEligible( eligibleGrades, student.AdministrativeClass!.Grade)); if (wrongGrade is not null) { return ConflictProblem( $"学生 {wrongGrade.StudentNumber} {wrongGrade.Name} 所在年级不属于本轮选课对象。"); } var outOfScope = students.FirstOrDefault(student => !offering.IsOpenToAll && !task.Classes.Any(item => item.AdministrativeClassId == student.AdministrativeClassId)); if (outOfScope is not null) { return ConflictProblem( $"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。"); } var existingEnrollments = await db.CourseEnrollments .Where(x => x.CourseSelectionOfferingId == id) .WhereIn(studentIds, x => x.StudentId) .ToListAsync(cancellationToken); var alreadyEnrolled = existingEnrollments.FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled); if (alreadyEnrolled is not null) { var student = students.First(x => x.Id == alreadyEnrolled.StudentId); return ConflictProblem( $"学生 {student.StudentNumber} {student.Name} 已在该教学班名单中。"); } var enrolledCount = await db.CourseEnrollments.CountAsync( x => x.CourseSelectionOfferingId == id && x.Status == CourseEnrollmentStatus.Enrolled, cancellationToken); if (enrolledCount + students.Count > offering.Capacity) { return ConflictProblem( $"教学班仅剩 {Math.Max(0, offering.Capacity - enrolledCount)} 个名额,无法完成本次代选。"); } var duplicateStudentIds = await db.CourseEnrollments.AsNoTracking() .Where(x => x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId == round.AcademicTermId) .WhereIn(studentIds, x => x.StudentId) .Select(x => x.StudentId) .Distinct() .ToListAsync(cancellationToken); if (duplicateStudentIds.Count > 0) { var student = students.First(x => duplicateStudentIds.Contains(x.Id)); return ConflictProblem( $"学生 {student.StudentNumber} {student.Name} 本学期已选择相同课程。"); } var candidateEntries = await PublishedScheduleEntries( round.AcademicTermId, [task.Id], cancellationToken); if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) && candidateEntries.Count == 0) return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。"); foreach (var student in students) { var selectedCredits = await db.CourseEnrollments .Where(x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id) .SumAsync( x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits, cancellationToken) ?? 0; if (selectedCredits + task.Course.Credits > round.MaxCredits) { return ConflictProblem( $"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。"); } var selectedCourseCount = await db.CourseEnrollments.CountAsync( x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id, cancellationToken); if (CourseSelectionRules.HasReachedCourseLimit( round.MaxCourseCount, selectedCourseCount)) { return ConflictProblem( $"学生 {student.StudentNumber} {student.Name} 已达到本轮最多 " + $"{round.MaxCourseCount} 门课程限制。"); } var selectedTaskIds = await db.CourseEnrollments.AsNoTracking() .Where(x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == round.AcademicTermId) .Select(x => x.CourseSelectionOffering!.TeachingTaskId) .Distinct() .ToArrayAsync(cancellationToken); var selectedEntries = await PublishedScheduleEntries( round.AcademicTermId, selectedTaskIds, cancellationToken); if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries)) { return ConflictProblem( $"学生 {student.StudentNumber} {student.Name} 的已选课程与该教学班时间冲突。"); } } var now = DateTime.UtcNow; foreach (var student in students) { var enrollment = existingEnrollments.FirstOrDefault(x => x.StudentId == student.Id); if (enrollment is null) { enrollment = new CourseEnrollment { CourseSelectionOfferingId = id, StudentId = student.Id, EnrolledAt = now }; db.CourseEnrollments.Add(enrollment); } else { enrollment.Status = CourseEnrollmentStatus.Enrolled; enrollment.EnrolledAt = now; enrollment.WaitlistedAt = null; enrollment.WithdrawnAt = null; } await ExpireOtherWaitlistsForCourseAsync( student.Id, task.CourseId, round.AcademicTermId, enrollment.Id, now, cancellationToken); } await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return Ok(new { EnrolledCount = students.Count }); }, cancellationToken, IsolationLevel.Serializable); } [HttpPost("offerings/{offeringId:guid}/force-enroll")] [Authorize(Roles = OfferingManagers)] public async Task ForceEnroll( Guid offeringId, ForceEnrollmentRequest request, CancellationToken cancellationToken) { var studentIds = request.StudentIds.Distinct().ToArray(); if (studentIds.Length == 0) return ValidationProblem("请至少选择一名学生。"); if (studentIds.Length > 100) return ValidationProblem("单次最多可为 100 名学生强制选课。"); return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); var offering = await db.CourseSelectionOfferings .Include(x => x.CourseSelectionRound) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) .FirstOrDefaultAsync(x => x.Id == offeringId, cancellationToken); if (offering is null) return NotFound(); var round = offering.CourseSelectionRound!; var task = offering.TeachingTask!; if (!currentUserDataScope.Current.CanAccessCollege(task.Course!.CollegeId)) return Forbid(); if (round.Status == CourseSelectionRoundStatus.Draft) return ConflictProblem("选课批次开放后才能办理强制选课。"); if (task.Status != TeachingTaskStatus.Published) return ConflictProblem("该教学班当前不可选。"); var students = await db.Students .Include(x => x.AdministrativeClass) .WhereIn(studentIds, x => x.Id) .OrderBy(x => x.StudentNumber) .ToListAsync(cancellationToken); if (students.Count != studentIds.Length) return ValidationProblem("存在无效的学生档案。"); var existingEnrollments = await db.CourseEnrollments .Where(x => x.CourseSelectionOfferingId == offeringId) .WhereIn(studentIds, x => x.StudentId) .ToListAsync(cancellationToken); var alreadyEnrolled = existingEnrollments .FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled); if (alreadyEnrolled is not null) { var dup = students.First(x => x.Id == alreadyEnrolled.StudentId); return ConflictProblem( $"学生 {dup.StudentNumber} {dup.Name} 已在该教学班名单中。"); } var now = DateTime.UtcNow; var enrolled = 0; foreach (var student in students) { var enrollment = existingEnrollments .FirstOrDefault(x => x.StudentId == student.Id); if (enrollment is null) { enrollment = new CourseEnrollment { CourseSelectionOfferingId = offeringId, StudentId = student.Id, EnrollmentType = EnrollmentType.Retake, EnrolledAt = now }; db.CourseEnrollments.Add(enrollment); } else { enrollment.Status = CourseEnrollmentStatus.Enrolled; enrollment.EnrolledAt = now; enrollment.WaitlistedAt = null; enrollment.WithdrawnAt = null; enrollment.EnrollmentType = EnrollmentType.Retake; } await ExpireOtherWaitlistsForCourseAsync( student.Id, task.CourseId, round.AcademicTermId, enrollment.Id, now, cancellationToken); enrolled++; } await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return Ok(new { EnrolledCount = enrolled }); }, cancellationToken, IsolationLevel.Serializable); } [HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")] [Authorize(Roles = RoundManagers)] public async Task AdminWithdraw( Guid offeringId, Guid enrollmentId, CancellationToken cancellationToken) { return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); var enrollment = await db.CourseEnrollments .Include(x => x.CourseSelectionOffering) .ThenInclude(x => x!.CourseSelectionRound) .ThenInclude(x => x!.EligibleGrades) .Include(x => x.CourseSelectionOffering) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Course) .FirstOrDefaultAsync( x => x.Id == enrollmentId && x.CourseSelectionOfferingId == offeringId, cancellationToken); if (enrollment is null) return NotFound(); var offering = enrollment.CourseSelectionOffering!; if (!CourseSelectionRules.SupportsProxyEnrollment( offering.TeachingTask!.Course!.Nature)) return ConflictProblem("管理员名单调整仅适用于公共必修课。"); if (offering.TeachingTask.Status != TeachingTaskStatus.Published) return ConflictProblem("该教学班当前不可调整名单。"); if (enrollment.Status != CourseEnrollmentStatus.Enrolled) return ConflictProblem("该学生已不在教学班名单中。"); enrollment.Status = CourseEnrollmentStatus.Withdrawn; enrollment.WithdrawnAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); var promoted = await PromoteNextWaitlistedAsync( offering, cancellationToken); await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return Ok(new { PromotedStudentId = promoted?.StudentId }); }, cancellationToken, IsolationLevel.Serializable); } [HttpDelete("offerings/{offeringId:guid}/waitlist/{enrollmentId:guid}")] [Authorize(Roles = OfferingManagers)] public async Task AdminCancelWaitlist( Guid offeringId, Guid enrollmentId, CancellationToken cancellationToken) { return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); if (!await ScopedOfferings().AnyAsync( x => x.Id == offeringId, cancellationToken)) return NotFound(); var enrollment = await db.CourseEnrollments .Include(x => x.Student) .Include(x => x.CourseSelectionOffering) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Course) .FirstOrDefaultAsync( x => x.Id == enrollmentId && x.CourseSelectionOfferingId == offeringId && x.Status == CourseEnrollmentStatus.Waitlisted, cancellationToken); if (enrollment is null) return NotFound(); enrollment.Status = CourseEnrollmentStatus.Cancelled; enrollment.WithdrawnAt = DateTime.UtcNow; if (enrollment.Student!.UserId is Guid userId) { db.Notifications.Add(new Notification { UserId = userId, Title = "课程候补已取消", Content = $"管理员已将你移出“{enrollment.CourseSelectionOffering!.TeachingTask!.Course!.Name}”" + "候补队列。", Category = NotificationCategory.CourseSelection, LinkUrl = "/course-selections" }); } await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return NoContent(); }, cancellationToken, IsolationLevel.Serializable); } [HttpGet("student/options")] [Authorize(Roles = SystemRoles.Student)] public async Task GetStudentOptions( Guid roundId, CancellationToken cancellationToken) { var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ProfileNotFound(); var round = await db.CourseSelectionRounds.AsNoTracking() .Include(x => x.EligibleGrades) .FirstOrDefaultAsync(x => x.Id == roundId, cancellationToken); if (round is null || round.Status == CourseSelectionRoundStatus.Draft) return NotFound(); var eligibleGrades = round.EligibleGrades .Select(x => x.Grade) .ToArray(); if (!CourseSelectionRules.IsGradeEligible( eligibleGrades, student.AdministrativeClass!.Grade)) { return Forbid(); } var offerings = await db.CourseSelectionOfferings.AsNoTracking() .Where(x => x.CourseSelectionRoundId == roundId && x.TeachingTask!.Status == TeachingTaskStatus.Published && (x.IsOpenToAll || x.TeachingTask.Classes.Any(item => item.AdministrativeClassId == student.AdministrativeClassId))) .OrderBy(x => x.TeachingTask!.Course!.Code) .Select(x => new StudentOfferingDto( x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, x.TeachingTask.Course!.Code, x.TeachingTask.Course.Name, x.TeachingTask.Course.Credits, x.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), x.Capacity, x.Enrollments.Count(item => item.Status == CourseEnrollmentStatus.Enrolled), x.Enrollments.Count(item => item.Status == CourseEnrollmentStatus.Waitlisted), x.IsOpenToAll, x.Enrollments .Where(item => item.StudentId == student.Id) .Select(item => (CourseEnrollmentStatus?)item.Status) .FirstOrDefault(), x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible, db.CourseEnrollments.Any(e => e.StudentId == student.Id && (e.Status == CourseEnrollmentStatus.Enrolled || e.Status == CourseEnrollmentStatus.Withdrawn) && e.CourseSelectionOffering!.TeachingTask!.CourseId == x.TeachingTask.CourseId && e.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId != round.AcademicTermId), db.ScheduleEntries .Where(entry => entry.TeachingTaskId == x.TeachingTaskId && entry.SchedulePlan!.AcademicTermId == round.AcademicTermId && entry.SchedulePlan.Status == SchedulePlanStatus.Published) .OrderBy(entry => entry.DayOfWeek) .ThenBy(entry => entry.StartPeriod) .Select(entry => new StudentScheduleDto( entry.DayOfWeek, entry.StartPeriod, entry.PeriodCount, entry.StartWeek, entry.EndWeek, entry.WeekPattern, entry.Classroom == null ? "不占用教室" : entry.Classroom.Name)) .ToList(), null)) .ToListAsync(cancellationToken); var waitlistedOfferingIds = offerings .Where(x => x.EnrollmentStatus == CourseEnrollmentStatus.Waitlisted) .Select(x => x.Id) .ToArray(); if (waitlistedOfferingIds.Length > 0) { var queueRows = await db.CourseEnrollments.AsNoTracking() .Where(x => x.Status == CourseEnrollmentStatus.Waitlisted) .WhereIn(waitlistedOfferingIds, x => x.CourseSelectionOfferingId) .Select(x => new { x.CourseSelectionOfferingId, x.StudentId, x.WaitlistedAt, x.CreatedAt }) .ToListAsync(cancellationToken); var positions = queueRows .GroupBy(x => x.CourseSelectionOfferingId) .SelectMany(group => group .OrderBy(x => x.WaitlistedAt) .ThenBy(x => x.CreatedAt) .Select((item, index) => new { item.CourseSelectionOfferingId, item.StudentId, Position = index + 1 })) .Where(x => x.StudentId == student.Id) .ToDictionary(x => x.CourseSelectionOfferingId, x => x.Position); offerings = offerings .Select(x => x with { WaitlistPosition = positions.GetValueOrDefault(x.Id) }) .ToList(); } return Ok(new { Round = new { round.Id, round.Name, round.StartsAt, round.EndsAt, round.WithdrawalEndsAt, round.MaxCredits, round.MaxCourseCount, EligibleGrades = eligibleGrades, round.Status, IsAvailableNow = CourseSelectionRules.IsSelectionOpen( round, DateTime.UtcNow) }, Student = new { student.Id, student.StudentNumber, student.Name, ClassName = student.AdministrativeClass!.Name, Grade = student.AdministrativeClass.Grade }, Offerings = offerings }); } [HttpGet("student/enrollments")] [Authorize(Roles = SystemRoles.Student)] public async Task GetStudentEnrollments( Guid? academicTermId, CancellationToken cancellationToken) { var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ProfileNotFound(); var source = db.CourseEnrollments.AsNoTracking() .Where(x => x.StudentId == student.Id); if (academicTermId.HasValue) { source = source.Where(x => x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == academicTermId); } return Ok(await source .OrderByDescending(x => x.EnrolledAt) .Select(x => new { x.Id, x.CourseSelectionOfferingId, RoundId = x.CourseSelectionOffering!.CourseSelectionRoundId, RoundName = x.CourseSelectionOffering.CourseSelectionRound!.Name, TermName = x.CourseSelectionOffering.CourseSelectionRound.AcademicTerm!.Name, x.CourseSelectionOffering.TeachingTaskId, TaskNumber = x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name, x.CourseSelectionOffering.TeachingTask.Course.Credits, TeacherNames = x.CourseSelectionOffering.TeachingTask.Teachers .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), x.Status, x.EnrollmentType, x.EnrolledAt, x.WaitlistedAt, x.WithdrawnAt, CanWithdraw = (x.Status == CourseEnrollmentStatus.Enrolled || x.Status == CourseEnrollmentStatus.Waitlisted) && x.CourseSelectionOffering.CourseSelectionRound.Status == CourseSelectionRoundStatus.Open && DateTime.UtcNow <= x.CourseSelectionOffering.CourseSelectionRound.WithdrawalEndsAt }) .ToListAsync(cancellationToken)); } [HttpPost("student/enrollments")] [Authorize(Roles = SystemRoles.Student)] public async Task Enroll( StudentEnrollmentRequest request, CancellationToken cancellationToken) { return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ProfileNotFound(); if (student.Status != StudentStatus.Active) return ConflictProblem("只有在籍学生可以选课。"); var offering = await db.CourseSelectionOfferings .Include(x => x.CourseSelectionRound) .ThenInclude(x => x!.EligibleGrades) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Classes) .FirstOrDefaultAsync(x => x.Id == request.OfferingId, cancellationToken); if (offering is null) return NotFound(); var round = offering.CourseSelectionRound!; var task = offering.TeachingTask!; var now = DateTime.UtcNow; if (!CourseSelectionRules.IsSelectionOpen(round, now)) return ConflictProblem("当前不在该选课批次的开放时间内。"); if (task.Status != TeachingTaskStatus.Published) return ConflictProblem("该教学班当前不可选。"); if (!CourseSelectionRules.IsGradeEligible( round.EligibleGrades.Select(x => x.Grade), student.AdministrativeClass!.Grade)) { return ConflictProblem("你所在的年级不属于本轮选课对象。"); } if (!offering.IsOpenToAll && !task.Classes.Any(x => x.AdministrativeClassId == student.AdministrativeClassId)) return Forbid(); // Detect retake: student previously took the same course in any term var isRetake = await db.CourseEnrollments.AnyAsync( x => x.StudentId == student.Id && (x.Status == CourseEnrollmentStatus.Enrolled || x.Status == CourseEnrollmentStatus.Withdrawn) && x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId != round.AcademicTermId, cancellationToken); var existing = await db.CourseEnrollments.FirstOrDefaultAsync( x => x.CourseSelectionOfferingId == offering.Id && x.StudentId == student.Id, cancellationToken); if (existing?.Status == CourseEnrollmentStatus.Enrolled) return ConflictProblem("你已经选择了该教学班。"); var enrolledCount = await db.CourseEnrollments.CountAsync( x => x.CourseSelectionOfferingId == offering.Id && x.Status == CourseEnrollmentStatus.Enrolled, cancellationToken); var effectiveCapacity = CourseSelectionRules.EffectiveCapacity( offering.Capacity, isRetake); if (enrolledCount >= effectiveCapacity) return ConflictProblem("该教学班名额已满。"); // Normal enrollment: no duplicate course in same term if (!isRetake) { var duplicateCourse = await db.CourseEnrollments.AnyAsync( x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId == round.AcademicTermId, cancellationToken); if (duplicateCourse) return ConflictProblem("同一学期不能重复选择相同课程。"); } // Credit limit check var selectedCredits = await db.CourseEnrollments .Where(x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id) .SumAsync( x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits, cancellationToken) ?? 0; if (selectedCredits + task.Course!.Credits > round.MaxCredits) { return ConflictProblem( $"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," + $"超过本轮 {round.MaxCredits:0.#} 学分上限。"); } var selectedCourseCount = await db.CourseEnrollments.CountAsync( x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id, cancellationToken); if (CourseSelectionRules.HasReachedCourseLimit( round.MaxCourseCount, selectedCourseCount)) { return ConflictProblem( $"你已达到本轮最多 {round.MaxCourseCount} 门课程限制。"); } // Schedule conflict check var candidateEntries = await PublishedScheduleEntries( round.AcademicTermId, [task.Id], cancellationToken); if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) && candidateEntries.Count == 0) return ConflictProblem("该教学班尚未发布课表,暂时不能选课。"); var selectedTaskIds = await db.CourseEnrollments .Where(x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == round.AcademicTermId) .Select(x => x.CourseSelectionOffering!.TeachingTaskId) .Distinct() .ToArrayAsync(cancellationToken); var selectedEntries = await PublishedScheduleEntries( round.AcademicTermId, selectedTaskIds, cancellationToken); if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries)) { if (!isRetake) return ConflictProblem("该教学班与已选课程的上课时间冲突。"); // Retake: allow ≤50% overlap var overlap = CourseSelectionRules.CalculateScheduleOverlap( candidateEntries, selectedEntries); if (overlap > 50) return ConflictProblem( $"重修课程时间冲突 {overlap:F0}%,超过 50% 上限,无法选课。"); } if (existing is null) { existing = new CourseEnrollment { CourseSelectionOfferingId = offering.Id, StudentId = student.Id, EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal }; db.CourseEnrollments.Add(existing); } else { existing.Status = CourseEnrollmentStatus.Enrolled; existing.EnrolledAt = now; existing.WaitlistedAt = null; existing.WithdrawnAt = null; existing.EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal; } await ExpireOtherWaitlistsForCourseAsync( student.Id, task.CourseId, round.AcademicTermId, existing.Id, now, cancellationToken); await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); return Created(string.Empty, new { existing.Id, IsRetake = isRetake }); }, cancellationToken, IsolationLevel.Serializable); } [HttpPost("student/waitlist")] [Authorize(Roles = SystemRoles.Student)] public async Task JoinWaitlist( StudentEnrollmentRequest request, CancellationToken cancellationToken) { return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ProfileNotFound(); var offering = await db.CourseSelectionOfferings .Include(x => x.CourseSelectionRound) .ThenInclude(x => x!.EligibleGrades) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Course) .Include(x => x.TeachingTask) .ThenInclude(x => x!.Classes) .FirstOrDefaultAsync(x => x.Id == request.OfferingId, cancellationToken); if (offering is null) return NotFound(); var round = offering.CourseSelectionRound!; var now = DateTime.UtcNow; if (!CourseSelectionRules.IsSelectionOpen(round, now)) return ConflictProblem("当前不在该选课批次的开放时间内。"); var existing = await db.CourseEnrollments.FirstOrDefaultAsync( x => x.CourseSelectionOfferingId == offering.Id && x.StudentId == student.Id, cancellationToken); if (existing?.Status == CourseEnrollmentStatus.Enrolled) return ConflictProblem("你已经选择了该教学班。"); if (existing?.Status == CourseEnrollmentStatus.Waitlisted) return ConflictProblem("你已经在该教学班的候补队列中。"); var eligibility = await EvaluateEnrollmentEligibilityAsync( student, offering, cancellationToken); if (eligibility.Error is not null) return ConflictProblem(eligibility.Error); var sameCourseWaitlistExists = await db.CourseEnrollments.AnyAsync( x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Waitlisted && x.CourseSelectionOffering!.TeachingTask!.CourseId == offering.TeachingTask!.CourseId && x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId == round.AcademicTermId, cancellationToken); if (sameCourseWaitlistExists) return ConflictProblem("你已在本学期同一课程的其他教学班候补。"); var enrolledCount = await db.CourseEnrollments.CountAsync( x => x.CourseSelectionOfferingId == offering.Id && x.Status == CourseEnrollmentStatus.Enrolled, cancellationToken); var effectiveCapacity = CourseSelectionRules.EffectiveCapacity( offering.Capacity, eligibility.IsRetake); if (enrolledCount < effectiveCapacity) return ConflictProblem("该教学班当前仍有名额,请直接选择课程。"); if (existing is null) { existing = new CourseEnrollment { CourseSelectionOfferingId = offering.Id, StudentId = student.Id, Status = CourseEnrollmentStatus.Waitlisted, EnrollmentType = eligibility.IsRetake ? EnrollmentType.Retake : EnrollmentType.Normal, WaitlistedAt = now }; db.CourseEnrollments.Add(existing); } else { existing.Status = CourseEnrollmentStatus.Waitlisted; existing.EnrollmentType = eligibility.IsRetake ? EnrollmentType.Retake : EnrollmentType.Normal; existing.WaitlistedAt = now; existing.WithdrawnAt = null; } await db.SaveChangesAsync(cancellationToken); var position = await db.CourseEnrollments.CountAsync( x => x.CourseSelectionOfferingId == offering.Id && x.Status == CourseEnrollmentStatus.Waitlisted && x.WaitlistedAt <= now, cancellationToken); await transaction.CommitAsync(cancellationToken); return Accepted(new { existing.Id, Status = CourseEnrollmentStatus.Waitlisted, Position = position, IsRetake = eligibility.IsRetake }); }, cancellationToken, IsolationLevel.Serializable); } [HttpDelete("student/enrollments/{id:guid}")] [Authorize(Roles = SystemRoles.Student)] public async Task Withdraw(Guid id, CancellationToken cancellationToken) { return await db.ExecuteInRetriableTransactionAsync( async transaction => { db.ChangeTracker.Clear(); var student = await CurrentStudentAsync(cancellationToken); if (student is null) return ProfileNotFound(); var enrollment = await db.CourseEnrollments .Include(x => x.CourseSelectionOffering) .ThenInclude(x => x!.CourseSelectionRound) .ThenInclude(x => x!.EligibleGrades) .Include(x => x.CourseSelectionOffering) .ThenInclude(x => x!.TeachingTask) .ThenInclude(x => x!.Course) .FirstOrDefaultAsync( x => x.Id == id && x.StudentId == student.Id, cancellationToken); if (enrollment is null) return NotFound(); var wasEnrolled = enrollment.Status == CourseEnrollmentStatus.Enrolled; var wasWaitlisted = enrollment.Status == CourseEnrollmentStatus.Waitlisted; if (!wasEnrolled && !wasWaitlisted) return ConflictProblem("该课程已经退选或候补已经结束。"); if (!CourseSelectionRules.CanWithdraw( enrollment.CourseSelectionOffering!.CourseSelectionRound!, DateTime.UtcNow)) return ConflictProblem("当前批次已停止退课和候补调整。"); enrollment.Status = wasWaitlisted ? CourseEnrollmentStatus.Cancelled : CourseEnrollmentStatus.Withdrawn; enrollment.WithdrawnAt = DateTime.UtcNow; await db.SaveChangesAsync(cancellationToken); CourseEnrollment? promoted = null; if (wasEnrolled) { promoted = await PromoteNextWaitlistedAsync( enrollment.CourseSelectionOffering, cancellationToken); await db.SaveChangesAsync(cancellationToken); } await transaction.CommitAsync(cancellationToken); return Ok(new { CancelledWaitlist = wasWaitlisted, PromotedStudentId = promoted?.StudentId }); }, cancellationToken, IsolationLevel.Serializable); } [HttpGet("my-offerings")] [Authorize(Roles = SystemRoles.Teacher)] public async Task GetMyOfferings( Guid? academicTermId, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var offeringsQuery = db.CourseSelectionOfferings.AsNoTracking() .Where(x => x.TeachingTask!.Teachers.Any(t => t.Teacher!.UserId == userId) && x.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft); if (academicTermId.HasValue) offeringsQuery = offeringsQuery.Where(x => x.CourseSelectionRound!.AcademicTermId == academicTermId); var offerings = await offeringsQuery .OrderByDescending(x => x.CourseSelectionRound!.AcademicTerm!.StartDate) .ThenBy(x => x.TeachingTask!.Course!.Code) .Select(x => new { x.Id, x.TeachingTaskId, x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, x.TeachingTask.AcademicTermId, TermName = x.TeachingTask.AcademicTerm!.Name, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, RoundName = x.CourseSelectionRound!.Name, x.Capacity, EnrolledCount = x.Enrollments.Count(e => e.Status == CourseEnrollmentStatus.Enrolled), RoundStatus = x.CourseSelectionRound.Status }) .ToListAsync(cancellationToken); return Ok(offerings); } [HttpGet("my-teaching-tasks")] [Authorize(Roles = SystemRoles.Teacher)] public async Task GetMyTeachingTasks( Guid? academicTermId, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; var source = db.TeachingTasks.AsNoTracking() .Where(x => (x.Status == TeachingTaskStatus.Published || x.Status == TeachingTaskStatus.Closed) && x.Teachers.Any(item => item.Teacher!.UserId == userId)); 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, TaskName = x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name, CourseCode = x.Course!.Code, CourseName = x.Course.Name, x.Capacity, StudentCount = db.Students.Count(student => (student.Status == StudentStatus.Active && x.Classes.Any(assignment => assignment.AdministrativeClassId == student.AdministrativeClassId)) || db.CourseEnrollments.Any(enrollment => enrollment.StudentId == student.Id && enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id)) }) .ToListAsync(cancellationToken)); } [HttpGet("teaching-tasks/{id:guid}/roster")] [Authorize(Roles = SystemRoles.Teacher)] public async Task GetTeachingTaskRoster( Guid id, CancellationToken cancellationToken) { var task = await GetAssignedTeachingTaskAsync(id, cancellationToken); if (task is null) return NotFound(); var students = await LoadTeachingTaskRosterAsync(id, cancellationToken); return Ok(new { task.Id, task.TaskNumber, task.TaskName, task.CourseCode, task.CourseName, task.Capacity, StudentCount = students.Count, Students = students }); } [HttpGet("teaching-tasks/{id:guid}/roster/export.xlsx")] [Authorize(Roles = SystemRoles.Teacher)] public async Task ExportTeachingTaskRoster( Guid id, CancellationToken cancellationToken) { var task = await GetAssignedTeachingTaskAsync(id, cancellationToken); if (task is null) return NotFound(); var students = await LoadTeachingTaskRosterAsync(id, cancellationToken); var bytes = ExcelWorkbookHelper.Create( "教学班名单", [ "学号", "姓名", "班级", "专业", "联系电话", "电子邮箱", "微信", "紧急联系人", "与本人关系", "紧急联系电话", "特殊标记", "特殊情况说明", "进入方式", "选课时间" ], students.Select(student => new List { student.StudentNumber, student.Name, student.ClassName, student.MajorName, student.Phone, student.Email, student.WeChat, student.EmergencyContactName, student.EmergencyContactRelationship, student.EmergencyContactPhone, student.SpecialTags, student.SpecialNeeds, student.EnrolledAt.HasValue ? "选课" : "行政班关联", student.EnrolledAt?.ToString("yyyy-MM-dd HH:mm") ?? "-" }).ToList>()); return File( bytes, ExcelWorkbookHelper.ContentType, $"教学班名单-{task.TaskNumber}.xlsx"); } [HttpGet("offerings/{id:guid}/roster/export.xlsx")] [Authorize(Roles = RosterReaders)] public async Task ExportRoster( Guid id, CancellationToken cancellationToken) { var offering = await db.CourseSelectionOfferings.AsNoTracking() .Where(x => x.Id == id) .Select(x => new { x.Id, x.TeachingTask!.TaskNumber, CourseCode = x.TeachingTask.Course!.Code, CourseName = x.TeachingTask.Course.Name, RoundName = x.CourseSelectionRound!.Name, CollegeId = x.TeachingTask.Course.CollegeId, TeacherUserIds = x.TeachingTask.Teachers .Select(item => item.Teacher!.UserId) }) .FirstOrDefaultAsync(cancellationToken); if (offering is null) return NotFound(); var scope = currentUserDataScope.Current; var isAssignedTeacher = scope.IsInRole(SystemRoles.Teacher) && offering.TeacherUserIds.Contains(scope.UserId); if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId)) return Forbid(); var students = await db.CourseEnrollments.AsNoTracking() .Where(x => x.CourseSelectionOfferingId == id && x.Status == CourseEnrollmentStatus.Enrolled) .OrderBy(x => x.Student!.StudentNumber) .Select(x => new { x.Student!.StudentNumber, x.Student.Name, ClassName = x.Student.AdministrativeClass!.Name, MajorName = x.Student.AdministrativeClass.Major!.Name, x.EnrolledAt }) .ToListAsync(cancellationToken); var bytes = ExcelWorkbookHelper.Create( "选课名单", ["学号", "姓名", "班级", "专业", "选课时间"], students.Select(s => new List { s.StudentNumber, s.Name, s.ClassName, s.MajorName, s.EnrolledAt.ToString("yyyy-MM-dd HH:mm") }).ToList>()); return File(bytes, ExcelWorkbookHelper.ContentType, $"选课名单-{offering.TaskNumber}.xlsx"); } private IQueryable ScopedOfferings() { var source = db.CourseSelectionOfferings.AsQueryable(); var scope = currentUserDataScope.Current; return scope.Scope == DataScope.All ? source : source.Where(x => x.TeachingTask!.Course!.CollegeId == scope.RestrictedCollegeId); } private Task GetAssignedTeachingTaskAsync( Guid id, CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; return db.TeachingTasks.AsNoTracking() .Where(x => x.Id == id && (x.Status == TeachingTaskStatus.Published || x.Status == TeachingTaskStatus.Closed) && x.Teachers.Any(item => item.Teacher!.UserId == userId)) .Select(x => new TeacherTeachingTaskInfo( x.Id, x.TaskNumber, x.Name, x.Course!.Code, x.Course.Name, x.Capacity)) .FirstOrDefaultAsync(cancellationToken); } private Task> LoadTeachingTaskRosterAsync( Guid teachingTaskId, CancellationToken cancellationToken) => TeachingTaskRosterQuery.ForTask(db, teachingTaskId) .AsNoTracking() .OrderBy(x => x.StudentNumber) .Select(x => new TeachingTaskRosterStudent( x.Id, x.StudentNumber, x.Name, x.AdministrativeClass!.Name, x.AdministrativeClass.Major!.Name, x.Phone, x.Email, x.WeChat, x.EmergencyContactName, x.EmergencyContactRelationship, x.EmergencyContactPhone, x.SpecialTags, x.SpecialNeeds, db.CourseEnrollments .Where(enrollment => enrollment.StudentId == x.Id && enrollment.Status == CourseEnrollmentStatus.Enrolled && enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId) .OrderByDescending(enrollment => enrollment.EnrolledAt) .Select(enrollment => (DateTime?)enrollment.EnrolledAt) .FirstOrDefault())) .ToListAsync(cancellationToken); private async Task FindAccessibleTaskAsync( Guid id, CancellationToken cancellationToken) { var scope = currentUserDataScope.Current; var source = db.TeachingTasks.AsNoTracking().AsQueryable(); if (scope.Scope != DataScope.All) { source = source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId); } return await source .Include(x => x.Course) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); } private ActionResult? ValidateOffering( CourseSelectionRound round, TeachingTask? task, CourseSelectionOfferingRequest request) { if (task is null) return ValidationProblem("所选教学班不存在或超出数据范围。"); if (task.AcademicTermId != round.AcademicTermId) return ValidationProblem("教学班与选课批次必须属于同一学期。"); if (task.Status != TeachingTaskStatus.Published) return ValidationProblem("只有已发布的教学班可以进入选课。"); if (request.Capacity > task.Capacity) return ValidationProblem($"选课容量不能超过教学班容量 {task.Capacity}。"); return null; } private async Task ValidateRoundAsync( CourseSelectionRoundRequest request, CancellationToken cancellationToken) { var startsAt = request.StartsAt.ToUniversalTime(); var endsAt = request.EndsAt.ToUniversalTime(); var withdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(); if (startsAt >= endsAt) return ValidationProblem("选课开始时间必须早于结束时间。"); if (withdrawalEndsAt < endsAt) return ValidationProblem("退课截止时间不能早于选课结束时间。"); var eligibleGrades = NormalizeGrades(request.EligibleGrades); if (eligibleGrades.Length > 20) return ValidationProblem("单个选课批次最多配置 20 个适用年级。"); if (eligibleGrades.Any(grade => grade is < 2000 or > 2200)) return ValidationProblem("适用年级必须在 2000—2200 之间。"); if (!await db.AcademicTerms.AnyAsync( x => x.Id == request.AcademicTermId && x.IsEnabled, cancellationToken)) return ValidationProblem("所选学期不存在或已停用。"); return null; } private async Task CurrentStudentAsync( CancellationToken cancellationToken) { var userId = currentUserDataScope.Current.UserId; return await db.Students .Include(x => x.AdministrativeClass) .FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken); } private async Task> PublishedScheduleEntries( Guid academicTermId, IReadOnlyCollection taskIds, CancellationToken cancellationToken) { if (taskIds.Count == 0) return []; return await db.ScheduleEntries.AsNoTracking() .Where(x => x.SchedulePlan!.AcademicTermId == academicTermId && x.SchedulePlan.Status == SchedulePlanStatus.Published) .WhereIn(taskIds, x => x.TeachingTaskId) .ToListAsync(cancellationToken); } private async Task EvaluateEnrollmentEligibilityAsync( Student student, CourseSelectionOffering offering, CancellationToken cancellationToken) { var round = offering.CourseSelectionRound!; var task = offering.TeachingTask!; if (student.Status != StudentStatus.Active) return new(false, "只有在籍学生可以选课或候补。"); if (task.Status != TeachingTaskStatus.Published) return new(false, "该教学班当前不可选。"); if (!CourseSelectionRules.IsGradeEligible( round.EligibleGrades.Select(x => x.Grade), student.AdministrativeClass!.Grade)) { return new(false, "你所在的年级不属于本轮选课对象。"); } if (!offering.IsOpenToAll && !await db.TeachingTaskClasses.AnyAsync( x => x.TeachingTaskId == task.Id && x.AdministrativeClassId == student.AdministrativeClassId, cancellationToken)) return new(false, "你不属于该教学班的选课对象。"); var isRetake = await db.CourseEnrollments.AnyAsync( x => x.StudentId == student.Id && (x.Status == CourseEnrollmentStatus.Enrolled || x.Status == CourseEnrollmentStatus.Withdrawn) && x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId != round.AcademicTermId, cancellationToken); if (!isRetake) { var duplicateCourse = await db.CourseEnrollments.AnyAsync( x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId == round.AcademicTermId, cancellationToken); if (duplicateCourse) return new(false, "同一学期不能重复选择相同课程。"); } var selectedCredits = await db.CourseEnrollments .Where(x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id) .SumAsync( x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits, cancellationToken) ?? 0; if (selectedCredits + task.Course!.Credits > round.MaxCredits) { return new( isRetake, $"获得名额后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," + $"超过本轮 {round.MaxCredits:0.#} 学分上限。"); } var selectedCourseCount = await db.CourseEnrollments.CountAsync( x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id, cancellationToken); if (CourseSelectionRules.HasReachedCourseLimit( round.MaxCourseCount, selectedCourseCount)) { return new( isRetake, $"你已达到本轮最多 {round.MaxCourseCount} 门课程限制。"); } var candidateEntries = await PublishedScheduleEntries( round.AcademicTermId, [task.Id], cancellationToken); if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) && candidateEntries.Count == 0) return new(isRetake, "该教学班尚未发布课表,暂时不能选课或候补。"); var selectedTaskIds = await db.CourseEnrollments.AsNoTracking() .Where(x => x.StudentId == student.Id && x.Status == CourseEnrollmentStatus.Enrolled && x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == round.AcademicTermId) .Select(x => x.CourseSelectionOffering!.TeachingTaskId) .Distinct() .ToArrayAsync(cancellationToken); var selectedEntries = await PublishedScheduleEntries( round.AcademicTermId, selectedTaskIds, cancellationToken); if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries)) { if (!isRetake) return new(false, "该教学班与已选课程的上课时间冲突。"); var overlap = CourseSelectionRules.CalculateScheduleOverlap( candidateEntries, selectedEntries); if (overlap > 50) { return new( true, $"重修课程时间冲突 {overlap:F0}%,超过 50% 上限。"); } } return new(isRetake, null); } private async Task PromoteNextWaitlistedAsync( CourseSelectionOffering offering, CancellationToken cancellationToken) { var round = offering.CourseSelectionRound!; if (!CourseSelectionRules.CanPromoteWaitlist(round, DateTime.UtcNow)) return null; var waitlist = await db.CourseEnrollments .Include(x => x.Student) .ThenInclude(x => x!.AdministrativeClass) .Where(x => x.CourseSelectionOfferingId == offering.Id && x.Status == CourseEnrollmentStatus.Waitlisted) .OrderBy(x => x.WaitlistedAt) .ThenBy(x => x.CreatedAt) .ToListAsync(cancellationToken); if (waitlist.Count == 0) return null; var enrolledCount = await db.CourseEnrollments.CountAsync( x => x.CourseSelectionOfferingId == offering.Id && x.Status == CourseEnrollmentStatus.Enrolled, cancellationToken); var now = DateTime.UtcNow; foreach (var candidate in waitlist) { var eligibility = await EvaluateEnrollmentEligibilityAsync( candidate.Student!, offering, cancellationToken); if (eligibility.Error is not null) { candidate.Status = CourseEnrollmentStatus.Expired; candidate.WithdrawnAt = now; if (candidate.Student!.UserId is Guid invalidUserId) { db.Notifications.Add(new Notification { UserId = invalidUserId, Title = "课程候补已失效", Content = $"“{offering.TeachingTask!.Course!.Name}”候补未能递补:" + eligibility.Error, Category = NotificationCategory.CourseSelection, LinkUrl = "/course-selections" }); } continue; } var effectiveCapacity = CourseSelectionRules.EffectiveCapacity( offering.Capacity, eligibility.IsRetake); if (enrolledCount >= effectiveCapacity) continue; candidate.Status = CourseEnrollmentStatus.Enrolled; candidate.EnrollmentType = eligibility.IsRetake ? EnrollmentType.Retake : EnrollmentType.Normal; candidate.EnrolledAt = now; candidate.WithdrawnAt = null; await ExpireOtherWaitlistsForCourseAsync( candidate.StudentId, offering.TeachingTask!.CourseId, round.AcademicTermId, candidate.Id, now, cancellationToken); if (candidate.Student!.UserId is Guid userId) { db.Notifications.Add(new Notification { UserId = userId, Title = "课程候补递补成功", Content = $"“{offering.TeachingTask.Course!.Name}”已释放名额," + "你已自动进入正式选课名单。", Category = NotificationCategory.CourseSelection, LinkUrl = "/course-selections" }); } return candidate; } return null; } private async Task ExpireOtherWaitlistsForCourseAsync( Guid studentId, Guid courseId, Guid academicTermId, Guid retainedEnrollmentId, DateTime now, CancellationToken cancellationToken) { var otherWaitlists = await db.CourseEnrollments .Where(x => x.Id != retainedEnrollmentId && x.StudentId == studentId && x.Status == CourseEnrollmentStatus.Waitlisted && x.CourseSelectionOffering!.TeachingTask!.CourseId == courseId && x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId == academicTermId) .ToListAsync(cancellationToken); foreach (var waitlist in otherWaitlists) { waitlist.Status = CourseEnrollmentStatus.Expired; waitlist.WithdrawnAt = now; } } private async Task SaveAsync( Guid id, bool created, CancellationToken cancellationToken) { try { await db.SaveChangesAsync(cancellationToken); return created ? Created(string.Empty, new { id }) : NoContent(); } catch (DbUpdateException) { return ConflictProblem("记录重复、容量已变化,或关联数据已失效。"); } } private static string BuildRoundResultContent( string roundName, IReadOnlyCollection selectedCourses, IReadOnlyCollection unsuccessfulCourses) { var parts = new List { $"{roundName}已结束。" }; parts.Add(selectedCourses.Count == 0 ? "本轮未选中课程。" : $"最终选中 {selectedCourses.Count} 门:{FormatCourseNames(selectedCourses)}。"); if (unsuccessfulCourses.Count > 0) { parts.Add( $"候补未成功 {unsuccessfulCourses.Count} 门:" + $"{FormatCourseNames(unsuccessfulCourses)}。"); } return string.Concat(parts); } private static string FormatCourseNames(IReadOnlyCollection courses) { const int visibleCount = 8; const int visibleNameLength = 40; var names = courses.Take(visibleCount) .Select(name => name.Length <= visibleNameLength ? $"《{name}》" : $"《{name[..visibleNameLength]}…》"); var result = string.Join("、", names); return courses.Count > visibleCount ? $"{result}等 {courses.Count} 门课程" : result; } private ActionResult ProfileNotFound() => ConflictProblem("当前账号未关联有效学生档案,请联系教务管理员。"); private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails { Title = "无法完成选课操作", Detail = detail, Status = StatusCodes.Status409Conflict }); private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static int[] NormalizeGrades(IEnumerable? values) => values?.Distinct().OrderBy(x => x).ToArray() ?? []; private sealed record EnrollmentEligibility(bool IsRetake, string? Error); private sealed record TeacherTeachingTaskInfo( Guid Id, string TaskNumber, string TaskName, string CourseCode, string CourseName, int Capacity); private sealed record TeachingTaskRosterStudent( Guid StudentId, string StudentNumber, string Name, string ClassName, string MajorName, string? Phone, string? Email, string? WeChat, string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone, string? SpecialTags, string? SpecialNeeds, DateTime? EnrolledAt); } public sealed record CourseSelectionRoundRequest( Guid AcademicTermId, [Required, MaxLength(120)] string Name, DateTime StartsAt, DateTime EndsAt, DateTime WithdrawalEndsAt, [Range(typeof(decimal), "0.5", "99")] decimal MaxCredits, [Range(1, 100)] int? MaxCourseCount, IReadOnlyCollection? EligibleGrades, [MaxLength(500)] string? Notes); public sealed record CourseSelectionOfferingRequest( Guid TeachingTaskId, [Range(1, 10000)] int Capacity, bool IsOpenToAll, [MaxLength(500)] string? Notes); public sealed record StudentEnrollmentRequest(Guid OfferingId); public sealed record AdminEnrollmentRequest( [MinLength(1)] IReadOnlyCollection StudentIds); public sealed record ForceEnrollmentRequest( [MinLength(1)] IReadOnlyCollection StudentIds); public sealed record StudentOfferingDto( Guid Id, Guid TeachingTaskId, string TaskNumber, string CourseCode, string CourseName, decimal Credits, IEnumerable TeacherNames, int Capacity, int EnrolledCount, int WaitlistedCount, bool IsOpenToAll, CourseEnrollmentStatus? EnrollmentStatus, bool IsFlexible, bool IsRetake, IEnumerable Schedules, int? WaitlistPosition); public sealed record StudentScheduleDto( int DayOfWeek, int StartPeriod, int PeriodCount, int StartWeek, int EndWeek, WeekPattern WeekPattern, string ClassroomName);