diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index 1d79619..75aedba 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -337,12 +337,13 @@ public sealed class CourseSelectionsController( } [HttpGet("offerings/{id:guid}/eligible-students")] - [Authorize(Roles = RoundManagers)] + [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); @@ -360,10 +361,10 @@ public sealed class CourseSelectionsController( }) .FirstOrDefaultAsync(cancellationToken); if (offering is null) return NotFound(); - if (!CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature)) + if (!forceMode && !CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature)) return ConflictProblem("管理员代选仅适用于公共必修课。"); if (offering.RoundStatus == CourseSelectionRoundStatus.Draft) - return ConflictProblem("选课批次开放后才能办理管理员代选。"); + return ConflictProblem("选课批次开放后才能办理。"); var source = db.Students.AsNoTracking() .Where(x => @@ -567,6 +568,88 @@ public sealed class CourseSelectionsController( return Ok(new { EnrolledCount = students.Count }); } + [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 名学生强制选课。"); + + await using var transaction = await db.Database.BeginTransactionAsync( + IsolationLevel.Serializable, cancellationToken); + 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 (round.Status == CourseSelectionRoundStatus.Draft) + return ConflictProblem("选课批次开放后才能办理强制选课。"); + if (task.Status != TeachingTaskStatus.Published) + return ConflictProblem("该教学班当前不可选。"); + + var students = await db.Students + .Include(x => x.AdministrativeClass) + .Where(x => studentIds.Contains(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 && + studentIds.Contains(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) + { + db.CourseEnrollments.Add(new CourseEnrollment + { + CourseSelectionOfferingId = offeringId, + StudentId = student.Id, + EnrollmentType = EnrollmentType.Retake, + EnrolledAt = now + }); + } + else + { + enrollment.Status = CourseEnrollmentStatus.Enrolled; + enrollment.EnrolledAt = now; + enrollment.WithdrawnAt = null; + enrollment.EnrollmentType = EnrollmentType.Retake; + } + enrolled++; + } + + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return Ok(new { EnrolledCount = enrolled }); + } + [HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")] [Authorize(Roles = RoundManagers)] public async Task AdminWithdraw( @@ -638,6 +721,12 @@ public sealed class CourseSelectionsController( .Select(item => (CourseEnrollmentStatus?)item.Status) .FirstOrDefault(), x.TeachingTask.SchedulingMode == TeachingTaskSchedulingMode.Flexible, + db.CourseEnrollments.Any(e => + e.StudentId == student.Id && + e.CourseSelectionOffering!.TeachingTask!.CourseId == + x.TeachingTask.CourseId && + e.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId != + round.AcademicTermId), db.ScheduleEntries .Where(entry => entry.TeachingTaskId == x.TeachingTaskId && @@ -716,6 +805,7 @@ public sealed class CourseSelectionsController( .OrderByDescending(item => item.IsPrimary) .Select(item => item.Teacher!.Name), x.Status, + x.EnrollmentType, x.EnrolledAt, x.WithdrawnAt, CanWithdraw = @@ -762,6 +852,15 @@ public sealed class CourseSelectionsController( 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.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId && + x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId != + round.AcademicTermId, + cancellationToken); + var existing = await db.CourseEnrollments.FirstOrDefaultAsync( x => x.CourseSelectionOfferingId == offering.Id && @@ -769,25 +868,34 @@ public sealed class CourseSelectionsController( cancellationToken); if (existing?.Status == CourseEnrollmentStatus.Enrolled) return ConflictProblem("你已经选择了该教学班。"); + var enrolledCount = await db.CourseEnrollments.CountAsync( x => x.CourseSelectionOfferingId == offering.Id && x.Status == CourseEnrollmentStatus.Enrolled, cancellationToken); - if (enrolledCount >= offering.Capacity) + var effectiveCapacity = isRetake + ? CourseSelectionRules.RetakeCapacity(offering.Capacity) + : offering.Capacity; + if (enrolledCount >= effectiveCapacity) return ConflictProblem("该教学班名额已满。"); - 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("同一学期不能重复选择相同课程。"); + // 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 && @@ -799,16 +907,17 @@ public sealed class CourseSelectionsController( if (selectedCredits + task.Course!.Credits > round.MaxCredits) { return ConflictProblem( - $"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分,超过本轮 {round.MaxCredits:0.#} 学分上限。"); + $"选课后将达到 {selectedCredits + task.Course.Credits:0.#} 学分," + + $"超过本轮 {round.MaxCredits:0.#} 学分上限。"); } + // Schedule conflict check var candidateEntries = await PublishedScheduleEntries( - round.AcademicTermId, - [task.Id], - cancellationToken); + round.AcademicTermId, [task.Id], cancellationToken); if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) && candidateEntries.Count == 0) - return ConflictProblem("该教学班尚未发布课表,暂时不能选课。"); + return ConflictProblem("该教学班尚未发布课表,暂时不能选课。"); + var selectedTaskIds = await db.CourseEnrollments .Where(x => x.StudentId == student.Id && @@ -819,18 +928,28 @@ public sealed class CourseSelectionsController( .Distinct() .ToArrayAsync(cancellationToken); var selectedEntries = await PublishedScheduleEntries( - round.AcademicTermId, - selectedTaskIds, - cancellationToken); + round.AcademicTermId, selectedTaskIds, cancellationToken); + if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries)) - return ConflictProblem("该教学班与已选课程的上课时间冲突。"); + { + 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 + StudentId = student.Id, + EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal }; db.CourseEnrollments.Add(existing); } @@ -839,10 +958,11 @@ public sealed class CourseSelectionsController( existing.Status = CourseEnrollmentStatus.Enrolled; existing.EnrolledAt = now; existing.WithdrawnAt = null; + existing.EnrollmentType = isRetake ? EnrollmentType.Retake : EnrollmentType.Normal; } await db.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); - return Created(string.Empty, new { existing.Id }); + return Created(string.Empty, new { existing.Id, IsRetake = isRetake }); } [HttpDelete("student/enrollments/{id:guid}")] @@ -1096,6 +1216,9 @@ 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, @@ -1109,6 +1232,7 @@ public sealed record StudentOfferingDto( bool IsOpenToAll, CourseEnrollmentStatus? EnrollmentStatus, bool IsFlexible, + bool IsRetake, IEnumerable Schedules); public sealed record StudentScheduleDto( diff --git a/src/Jiaowu.Api/Domain/Academic/CourseSelectionEntities.cs b/src/Jiaowu.Api/Domain/Academic/CourseSelectionEntities.cs index 3c6feb4..867e3c5 100644 --- a/src/Jiaowu.Api/Domain/Academic/CourseSelectionEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/CourseSelectionEntities.cs @@ -36,6 +36,7 @@ public sealed class CourseEnrollment : EntityBase public Guid StudentId { get; set; } public Student? Student { get; set; } public CourseEnrollmentStatus Status { get; set; } = CourseEnrollmentStatus.Enrolled; + public EnrollmentType EnrollmentType { get; set; } = EnrollmentType.Normal; public DateTime EnrolledAt { get; set; } = DateTime.UtcNow; public DateTime? WithdrawnAt { get; set; } } @@ -52,3 +53,9 @@ public enum CourseEnrollmentStatus Enrolled = 1, Withdrawn = 2 } + +public enum EnrollmentType +{ + Normal = 1, + Retake = 2 +} diff --git a/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs b/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs index 702e28c..e816ed4 100644 --- a/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs +++ b/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs @@ -26,4 +26,46 @@ public static class CourseSelectionRules candidateEntries.Any(candidate => selectedEntries.Any(selected => ScheduleConflictDetector.TimeOverlaps(candidate, selected))); + + /// + /// Returns the overlap percentage of candidate schedule entries with selected entries. + /// 0 = no conflict, 100 = fully overlapping. + /// Used for retake enrollment where ≤50% overlap is allowed. + /// + public static double CalculateScheduleOverlap( + IReadOnlyCollection candidateEntries, + IReadOnlyCollection selectedEntries) + { + if (candidateEntries.Count == 0 || selectedEntries.Count == 0) + return 0; + + int totalCandidatePeriods = 0; + int overlappedPeriods = 0; + + foreach (var candidate in candidateEntries) + { + totalCandidatePeriods += candidate.PeriodCount; + foreach (var selected in selectedEntries) + { + if (!ScheduleConflictDetector.TimeOverlaps(candidate, selected)) + continue; + // Calculate overlapping periods + var overlapStart = Math.Max( + candidate.StartPeriod, selected.StartPeriod); + var overlapEnd = Math.Min( + candidate.StartPeriod + candidate.PeriodCount, + selected.StartPeriod + selected.PeriodCount); + if (overlapEnd > overlapStart) + overlappedPeriods += overlapEnd - overlapStart; + } + } + + return totalCandidatePeriods == 0 + ? 0 + : (double)overlappedPeriods / totalCandidatePeriods * 100; + } + + /// Retake expanded capacity: ceiling(original * 1.15). + public static int RetakeCapacity(int originalCapacity) => + (int)Math.Ceiling(originalCapacity * 1.15); } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 2ff166c..e1ee4cc 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -34,6 +34,8 @@ public sealed class DevelopmentSqliteMigrator( "20260725_19_flexible_grades"; private const string ExamSchedulingOptimizationMigration = "20260725_20_exam_scheduling_optimization"; + private const string RetakeEnrollmentMigration = + "20260725_21_retake_enrollment"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -225,6 +227,19 @@ public sealed class DevelopmentSqliteMigrator( ? [] : ExamSchedulingOptimizationStatements, cancellationToken); + + var retakeEnrollmentExists = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM pragma_table_info('CourseEnrollments') + WHERE name = 'EnrollmentType' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + RetakeEnrollmentMigration, + retakeEnrollmentExists ? [] : RetakeEnrollmentStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -1461,4 +1476,12 @@ public sealed class DevelopmentSqliteMigrator( ON "ExamSessions" ("RequiredBuildingId"); """ ]; + + private static readonly string[] RetakeEnrollmentStatements = + [ + """ + ALTER TABLE "CourseEnrollments" + ADD COLUMN "EnrollmentType" INTEGER NOT NULL DEFAULT 1; + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725150000_RetakeEnrollment.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725150000_RetakeEnrollment.cs new file mode 100644 index 0000000..0f2e1ec --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725150000_RetakeEnrollment.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class RetakeEnrollment : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnrollmentType", + table: "CourseEnrollments", + type: "int", + nullable: false, + defaultValue: 1); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnrollmentType", + table: "CourseEnrollments"); + } + } +} diff --git a/web/src/views/CourseSelectionView.vue b/web/src/views/CourseSelectionView.vue index d01dd70..aa272fb 100644 --- a/web/src/views/CourseSelectionView.vue +++ b/web/src/views/CourseSelectionView.vue @@ -125,16 +125,44 @@ function conflictingSelectedCourses(offering: any) { function offeringBlockReason(offering: any) { if (offering.enrollmentStatus === 'Enrolled') return '' if (!selectedRound.value?.isAvailableNow) return '当前不在选课开放时间内' - if (offering.enrolledCount >= offering.capacity) return '教学班名额已满' + const effectiveCap = offering.isRetake + ? Math.ceil(offering.capacity * 1.15) + : offering.capacity + if (offering.enrolledCount >= effectiveCap) return '教学班名额已满' if (!offering.isFlexible && !offering.schedules.length) return '正式课表尚未发布' - if (selectedOfferings.value.some((item) => + if (!offering.isRetake && selectedOfferings.value.some((item) => item.id !== offering.id && item.courseCode === offering.courseCode, )) return '本学期已选择同一课程' if (selectedCredits.value + Number(offering.credits) > Number(selectedRound.value.maxCredits)) { return `选择后将超过 ${selectedRound.value.maxCredits} 学分上限` } const conflicts = conflictingSelectedCourses(offering) - return conflicts.length ? `与已选“${conflicts.join('、')}”时间冲突` : '' + if (conflicts.length) { + if (!offering.isRetake) return `与已选”${conflicts.join('、')}”时间冲突` + // Retake: calculate overlap (client-side rough estimate) + if (!calcRetakeOverlapOk(offering)) return `重修时间冲突超过 50%,无法选课` + return '' // retake with acceptable overlap + } + return '' +} + +function calcRetakeOverlapOk(offering: any): boolean { + let totalPeriods = 0, overlapPeriods = 0 + for (const candidate of offering.schedules) { + totalPeriods += candidate.periodCount + for (const selected of selectedOfferings.value) { + for (const existing of (selected.schedules ?? [])) { + if (candidate.dayOfWeek !== existing.dayOfWeek) continue + const overlapStart = Math.max(candidate.startPeriod, existing.startPeriod) + const overlapEnd = Math.min( + candidate.startPeriod + candidate.periodCount, + existing.startPeriod + existing.periodCount, + ) + if (overlapEnd > overlapStart) overlapPeriods += overlapEnd - overlapStart + } + } + } + return totalPeriods === 0 || (overlapPeriods / totalPeriods * 100) <= 50 } const filteredOfferings = computed(() => { @@ -545,6 +573,63 @@ async function proxyEnroll() { } } +const forceDialog = ref(false) +const forceSubmitting = ref(false) + +function openForceEnrollment() { + studentKeyword.value = '' + selectedStudentIds.value = [] + eligiblePage.value = 1 + forceDialog.value = true + loadForceEligibleStudents() +} + +async function loadForceEligibleStudents(page = eligiblePage.value) { + if (!roster.value) return + eligibleLoading.value = true + eligiblePage.value = page + try { + const { data } = await http.get( + `/course-selections/offerings/${roster.value.id}/eligible-students`, + { + params: { + keyword: studentKeyword.value.trim() || undefined, + page, + pageSize: 20, + }, + }, + ) + eligibleStudents.value = data.items + eligibleTotal.value = data.total + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } finally { + eligibleLoading.value = false + } +} + +async function forceEnrollSubmit() { + if (!roster.value || selectedStudentIds.value.length === 0) { + ElMessage.warning('请至少选择一名学生。') + return + } + forceSubmitting.value = true + try { + const { data } = await http.post( + `/course-selections/offerings/${roster.value.id}/force-enroll`, + { studentIds: selectedStudentIds.value }, + ) + ElMessage.success(`已强制选入 ${data.enrolledCount} 名学生(忽略所有限制)`) + forceDialog.value = false + await loadRoster(roster.value.id) + if (selectedRound.value) await selectRound(selectedRound.value) + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } finally { + forceSubmitting.value = false + } +} + async function removeFromRoster(student: any) { if (!roster.value) return try { @@ -894,7 +979,10 @@ onMounted(async () => {
{{ offering.courseCode }} · {{ offering.taskNumber }}

{{ offering.courseName }}

-

{{ offering.teacherNames.join('、') || '教师待定' }} · {{ offering.credits }} 学分

+

+ {{ offering.teacherNames.join('、') || '教师待定' }} · {{ offering.credits }} 学分 + 重修 +

已选 @@ -918,8 +1006,14 @@ onMounted(async () => {