重修选课
This commit is contained in:
1 parent
be49a2586f
commit
57a60ff8d5
10 files changed
+7273
-28
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 !=
|
||||
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 !=
|
||||
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
Reference in new issue
Block a user