重修选课

This commit is contained in:
biss committed 2026-08-15 14:57:22 +08:00
1 parent be49a2586f
commit 57a60ff8d5
10 files changed
+7275 -30

No files matched your search

@@ -6,6 +6,7 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.CourseSelection;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
@@ -185,6 +186,7 @@ public sealed class CourseSelectionsController(
x.WithdrawalEndsAt,
x.MaxCredits,
x.MaxCourseCount,
x.Scope,
EligibleGrades = x.EligibleGrades
.OrderBy(item => item.Grade)
.Select(item => item.Grade),
@@ -231,6 +233,7 @@ public sealed class CourseSelectionsController(
WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime(),
MaxCredits = request.MaxCredits,
MaxCourseCount = request.MaxCourseCount,
Scope = request.Scope,
EligibleGrades = NormalizeGrades(request.EligibleGrades)
.Select(grade => new CourseSelectionRoundGrade { Grade = grade })
.ToList(),
@@ -262,6 +265,7 @@ public sealed class CourseSelectionsController(
round.WithdrawalEndsAt = request.WithdrawalEndsAt.ToUniversalTime();
round.MaxCredits = request.MaxCredits;
round.MaxCourseCount = request.MaxCourseCount;
round.Scope = request.Scope;
var requestedGrades = NormalizeGrades(request.EligibleGrades);
var requestedGradeSet = requestedGrades.ToHashSet();
db.CourseSelectionRoundGrades.RemoveRange(
@@ -1186,6 +1190,10 @@ public sealed class CourseSelectionsController(
{
return Forbid();
}
var retakeCourseIds = await RetakeCourseIdsAsync(
student.Id,
round.AcademicTermId,
cancellationToken);
var offerings = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x =>
@@ -1220,14 +1228,7 @@ public sealed class CourseSelectionsController(
.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),
retakeCourseIds.Contains(x.TeachingTask.CourseId),
db.ScheduleEntries
.Where(entry =>
entry.TeachingTaskId == x.TeachingTaskId &&
@@ -1247,6 +1248,15 @@ public sealed class CourseSelectionsController(
null))
.ToListAsync(cancellationToken);
if (round.Scope == CourseSelectionScope.RetakeOnly)
{
offerings = offerings.Where(x =>
x.IsRetake ||
x.EnrollmentStatus == CourseEnrollmentStatus.Enrolled ||
x.EnrollmentStatus == CourseEnrollmentStatus.Waitlisted)
.ToList();
}
var waitlistedOfferingIds = offerings
.Where(x => x.EnrollmentStatus == CourseEnrollmentStatus.Waitlisted)
.Select(x => x.Id)
@@ -1296,6 +1306,7 @@ public sealed class CourseSelectionsController(
round.WithdrawalEndsAt,
round.MaxCredits,
round.MaxCourseCount,
round.Scope,
EligibleGrades = eligibleGrades,
round.Status,
IsAvailableNow = CourseSelectionRules.IsSelectionOpen(
@@ -1405,16 +1416,13 @@ 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.Status == CourseEnrollmentStatus.Enrolled ||
x.Status == CourseEnrollmentStatus.Withdrawn) &&
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId !=
round.AcademicTermId,
var isRetake = await IsEligibleForRetakeAsync(
student.Id,
task.CourseId,
round.AcademicTermId,
cancellationToken);
if (round.Scope == CourseSelectionScope.RetakeOnly && !isRetake)
return ConflictProblem("该批次仅限有未通过历史成绩的学生办理重修选课。");
var existing = await db.CourseEnrollments.FirstOrDefaultAsync(
x =>
@@ -2013,6 +2021,8 @@ public sealed class CourseSelectionsController(
return ValidationProblem("选课开始时间必须早于结束时间。");
if (withdrawalEndsAt < endsAt)
return ValidationProblem("退课截止时间不能早于选课结束时间。");
if (!Enum.IsDefined(request.Scope))
return ValidationProblem("选课批次类型无效。");
var eligibleGrades = NormalizeGrades(request.EligibleGrades);
if (eligibleGrades.Length > 20)
return ValidationProblem("单个选课批次最多配置 20 个适用年级。");
@@ -2048,6 +2058,57 @@ public sealed class CourseSelectionsController(
.ToListAsync(cancellationToken);
}
private async Task<HashSet<Guid>> RetakeCourseIdsAsync(
Guid studentId,
Guid targetAcademicTermId,
CancellationToken cancellationToken)
{
var attempts = await db.GradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == studentId &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradeSheet.TeachingTask!.AcademicTermId != targetAcademicTermId)
.Select(x => new RetakeGradeAttempt(
x.GradeSheet!.TeachingTask!.CourseId,
x.TotalScore,
x.ExamStatus))
.ToListAsync(cancellationToken);
return attempts
.GroupBy(x => x.CourseId)
.Where(group =>
{
var evaluation = StudentCourseProgressRules.Evaluate(
group.Select(x => new StudentCourseAttemptSnapshot(
x.TotalScore,
x.ExamStatus)),
false);
return evaluation.HasFailedAttempt && !evaluation.HasPassedAttempt;
})
.Select(group => group.Key)
.ToHashSet();
}
private async Task<bool> IsEligibleForRetakeAsync(
Guid studentId,
Guid courseId,
Guid targetAcademicTermId,
CancellationToken cancellationToken)
{
var attempts = await db.GradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == studentId &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
x.GradeSheet.TeachingTask!.CourseId == courseId &&
x.GradeSheet.TeachingTask.AcademicTermId != targetAcademicTermId)
.Select(x => new StudentCourseAttemptSnapshot(
x.TotalScore,
x.ExamStatus))
.ToListAsync(cancellationToken);
var evaluation = StudentCourseProgressRules.Evaluate(attempts, false);
return evaluation.HasFailedAttempt && !evaluation.HasPassedAttempt;
}
private async Task<EnrollmentEligibility> EvaluateEnrollmentEligibilityAsync(
Student student,
CourseSelectionOffering offering,
@@ -2073,15 +2134,13 @@ public sealed class CourseSelectionsController(
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,
var isRetake = await IsEligibleForRetakeAsync(
student.Id,
task.CourseId,
round.AcademicTermId,
cancellationToken);
if (round.Scope == CourseSelectionScope.RetakeOnly && !isRetake)
return new(false, "该批次仅限有未通过历史成绩的学生办理重修选课或候补。");
if (!isRetake)
{
var duplicateCourse = await db.CourseEnrollments.AnyAsync(
@@ -2439,6 +2498,11 @@ public sealed class CourseSelectionsController(
private sealed record EnrollmentEligibility(bool IsRetake, string? Error);
private sealed record RetakeGradeAttempt(
Guid CourseId,
decimal? TotalScore,
GradeExamStatus ExamStatus);
private sealed record TeacherTeachingTaskInfo(
Guid Id,
string TaskNumber,
@@ -2473,7 +2537,8 @@ public sealed record CourseSelectionRoundRequest(
[Range(typeof(decimal), "0.5", "99")] decimal MaxCredits,
[Range(1, 100)] int? MaxCourseCount,
IReadOnlyCollection<int>? EligibleGrades,
[MaxLength(500)] string? Notes);
[MaxLength(500)] string? Notes,
CourseSelectionScope Scope = CourseSelectionScope.All);
public sealed record CourseSelectionOfferingRequest(
Guid TeachingTaskId,
@@ -12,6 +12,7 @@ public sealed class CourseSelectionRound : EntityBase
public DateTime WithdrawalEndsAt { get; set; }
public decimal MaxCredits { get; set; } = 30;
public int? MaxCourseCount { get; set; }
public CourseSelectionScope Scope { get; set; } = CourseSelectionScope.All;
public CourseSelectionRoundStatus Status { get; set; } =
CourseSelectionRoundStatus.Draft;
public string? Notes { get; set; }
@@ -58,6 +59,12 @@ public enum CourseSelectionRoundStatus
Closed = 3
}
public enum CourseSelectionScope
{
All = 1,
RetakeOnly = 2
}
public enum CourseEnrollmentStatus
{
Enrolled = 1,
@@ -860,6 +860,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.MaxCredits).HasPrecision(6, 1);
entity.Property(x => x.Scope).HasDefaultValue(CourseSelectionScope.All);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
entity.HasOne(x => x.AcademicTerm)
Loaded 3 of 10 files, more files were not shown because too many files have changed in this diff. Show more